Skip to content

Commit

Permalink
Improved live stream support, added lots of new camera properties and…
Browse files Browse the repository at this point in the history
… methods

* Live streams now download at real-time speeds (assuming no connection bottleneck)
* Live streams will append to an existing file by default (instead of overwriting)
* Lots of new camera properties and methods, see README.md for more detail.
  • Loading branch information
evanjd committed Aug 21, 2018
1 parent 20a7b6b commit 8955c3a
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 63 deletions.
69 changes: 48 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,47 @@ PyPi package soon.

## Features available

- Download real-time live stream data to disk or serve to your application as a raw bytes object
- Query/filter the activity history by start time and/or activity properties (duration, relevance)
- Download any activity video to disk or serve to your application as a raw bytes object
- Download still images from camera to disk or serve to your application as a raw bytes object
- Set streaming mode, privacy mode, LED status, speaker volume, microphone gain and other properties of camera
- On-demand polling from server to update camera properties
- Camera properties exposed:
- ID
- Node ID
- Name
- Live image (as JPEG)
- Last activity
- Timezone
- Connected status (is it powered and in range)
- Streaming status (is currently streaming and capable of recording activities)
- Privacy mode (is it recording activities)
- Firmware version
- Battery %
- Charging status
- Model
- Connected Wifi SSID
- Signal strength %
- Last activity
- Live image (as JPEG)
- IP address
- MAC address
- Microphone status and gain
- Speaker status and volume
- LED enabled
- Plan name
- Temperature (if supported by your device)
- Relative humidity % (if supported by your device)
- Activity properties exposed:
- Start time (local or UTC)
- End time (local or UTC)
- Duration
- Relevance level (indicating whether people/objects were detected)
- Download real-time live stream data to disk or serve to your application as a raw bytes object
- Query/filter the activity history by start time and/or activity properties (duration, relevance)
- Download any activity video to disk or serve to your application as a raw bytes object
- Download still images from camera to disk or serve to your application as a raw bytes object
- Enable/disable streaming
- On-demand polling from server to update camera properties

## Features planned

- Motion alerts (eventually)
- Logi Circle CLI (eventually)
- Speaker support (maybe)

## Usage example

Expand Down Expand Up @@ -95,21 +105,17 @@ loop.run_until_complete(get_latest_activity())
loop.close()
```

#### Download live stream data to disk:

##### You can use FFMPEG to concatenate the segments into an individual file.
#### Stream live stream data to disk:

```python
async def get_livestream():
camera = (await logi_api.cameras)[1]
camera = (await logi_api.cameras)[0]
live_stream = camera.live_stream
count = 1

while True:
# Keep writing until interrupted
filename = '%s-segment-%s.mp4' % (camera.name, count)
await live_stream.get_segment(filename=filename)
count += 1
# Keep appending to live stream MP4 until interrupted
filename = '%s-livestream.mp4' % (camera.name)
await live_stream.get_segment(filename=filename,append=True)

loop = asyncio.get_event_loop()
loop.run_until_complete(get_livestream())
Expand Down Expand Up @@ -153,7 +159,7 @@ loop.run_until_complete(future)
async def disable_streaming_all():
for camera in await logi_api.cameras:
if camera.is_streaming:
await camera.set_power('off')
await camera.set_streaming_mode(False)
print('%s is now %s.' %
(camera.name, 'on' if camera.is_streaming else 'off'))
else:
Expand All @@ -171,12 +177,28 @@ loop.close()
async def play_with_props():
for camera in await logi_api.cameras:
last_activity = await camera.last_activity
print('%s: %s' % (camera.name, ('is charging' if camera.is_charging else 'is not charging')))
print('%s: %s%% battery remaining' % (camera.name, camera.battery_level))
print('%s: %s' % (camera.name,
('is charging' if camera.is_charging else 'is not charging')))
print('%s: %s%% battery remaining' %
(camera.name, camera.battery_level))
print('%s: Model number is %s' % (camera.name, camera.model))
print('%s: Signal strength is %s%% (%s)' % (camera.name, camera.signal_strength_percentage, camera.signal_strength_category))
print('%s: Signal strength is %s%% (%s)' % (
camera.name, camera.signal_strength_percentage, camera.signal_strength_category))
print('%s: last activity was at %s and lasted for %s seconds.' % (
camera.name, last_activity.start_time.isoformat(), last_activity.duration.total_seconds()))
print('%s: Firmware version %s' % (camera.name, camera.firmware))
print('%s: IP address is %s' % (camera.name, camera.ip_address))
print('%s: MAC address is %s' % (camera.name, camera.mac_address))
print('%s: Microphone is %s and gain is set to %s (out of 100)' % (
camera.name, 'on' if camera.microphone_on else 'off', camera.microphone_gain))
print('%s: Speaker is %s and volume is set to %s (out of 100)' % (
camera.name, 'on' if camera.speaker_on else 'off', camera.speaker_volume))
print('%s: LED is %s' % (
camera.name, 'on' if camera.led_on else 'off'))
print('%s: Privacy mode is %s' % (
camera.name, 'on' if camera.privacy_mode else 'off'))
print('%s: Subscribed to plan %s' % (
camera.name, camera.plan_name))
await logi_api.logout()

loop = asyncio.get_event_loop()
Expand All @@ -198,6 +220,11 @@ loop.close()
- Added update() method to Camera object to refresh data from server
- 0.1.0
- Added preliminary support for live streams (to be improved)
- 0.1.1
- Fixed timing bug causing live streams to download at half real-time speeds
- Live streams will now automatically append to an existing file (instead of overwriting)
- Added a bunch of new camera properties
- Added support for setting privacy mode, LED status, speaker status, speaker volume, microphone status and microphone gain

## Meta

Expand Down
4 changes: 2 additions & 2 deletions logi_circle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from .const import (
API_URI, AUTH_ENDPOINT, CACHE_ATTRS, CACHE_FILE, CAMERAS_ENDPOINT, COOKIE_NAME, VALIDATE_ENDPOINT, HEADERS)
from .camera import Camera
from .exception import BadSession, NoSession, BadCache
from .exception import BadSession, NoSession, BadCache, BadLogin

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -67,7 +67,7 @@ async def _authenticate(self):
async with session.post(url, json=login_payload, headers=HEADERS) as req:
# Handle failed authentication due to incorrect user/pass
if req.status == 401:
raise ValueError(
raise BadLogin(
'Username or password provided is incorrect. Could not authenticate.')

# Throw error if authentication failed for any reason (connection issues, outage, etc)
Expand Down
136 changes: 121 additions & 15 deletions logi_circle/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .live_stream import LiveStream
from .utils import _stream_to_file
from .exception import UnexpectedContentType
from aiohttp.client_exceptions import ClientResponseError

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -49,6 +50,17 @@ def _set_attributes(self, camera):
self._attrs['wifi_signal_strength'] = config.get(
'wifiSignalStrength', None)
self._attrs['firmware'] = config.get('firmwareVersion', None)
self._attrs['ip_address'] = config.get('ipAddress', None)
self._attrs['mac_address'] = config.get('macAddress', None)
self._attrs['wifi_ssid'] = config.get('wifiSsid', None)
self._attrs['microphone_on'] = config.get('microphoneOn', False)
self._attrs['microphone_gain'] = config.get('microphoneGain', None)
self._attrs['speaker_on'] = config.get('speakerOn', False)
self._attrs['speaker_volume'] = config.get('speakerVolume', None)
self._attrs['led'] = config.get('ledEnabled', False)
self._attrs['privacy_mode'] = config.get('privacyMode', False)
self._attrs['plan_name'] = camera.get('planName', False)

if config.get('humidityIsAvailable', False):
self._attrs['humidity'] = config.get('humidity', None)
else:
Expand Down Expand Up @@ -176,25 +188,56 @@ async def get_snapshot_image(self, filename=None):
JPEG_CONTENT_TYPE, image.content_type, self.name)
raise UnexpectedContentType()

async def set_power(self, status):
"""Disables streaming for this camera."""

if status != 'on' and status != 'off':
raise ValueError('"on" or "off" expected for status argument.')

_LOGGER.debug('Setting power for %s to %s', self.name, status)
async def set_streaming_mode(self, status):
"""Sets streaming mode for this camera."""
translated_status = ('on' if status else 'off')
await self._set_config(prop='streamingMode', internal_prop='is_streaming', value=translated_status, internal_value=status, value_type=str)

async def set_microphone(self, status=None, gain=None):
"""Sets microphone status and/or gain."""
if status:
await self._set_config(prop='microphoneOn', internal_prop='microphone_on', value=status, value_type=bool)
if gain:
await self._set_config(prop='microphoneGain', internal_prop='microphone_gain', value=gain, value_type=int)

async def set_speaker(self, status=None, volume=None):
"""Sets speaker status and/or volume."""
if status:
await self._set_config(prop='speakerOn', internal_prop='speaker_on', value=status, value_type=bool)
if volume:
await self._set_config(prop='speakerVolume', internal_prop='speaker_volume', value=volume, value_type=int)

async def set_led(self, status):
"""Sets LED on or off."""
await self._set_config(prop='ledEnabled', internal_prop='led', value=status, value_type=bool)

async def set_privacy_mode(self, status):
"""Sets privacy mode on or off."""
await self._set_config(prop='privacyMode', internal_prop='privacy_mode', value=status, value_type=bool)

async def _set_config(self, prop, internal_prop, value, value_type, internal_value=None):
"""Internal method for updating the camera's configuration"""
if not isinstance(value, value_type):
raise ValueError('%s expected for status argument' % (value_type))

url = '%s/%s' % (ACCESSORIES_ENDPOINT, self.id)
payload = {"streamingMode": status}
payload = {prop: value}

req = await self._logi._fetch(
url=url, method='PUT', request_body=payload, raw=True)
_LOGGER.debug('Setting %s to %s', prop, str(value))

if req.status < 300:
self._attrs['is_streaming'] = status == 'on'
return True
else:
return False
try:
req = await self._logi._fetch(
url=url, method='PUT', request_body=payload, raw=True)
req.close()

# Update camera props to reflect change
self._attrs[internal_prop] = internal_value if internal_value is not None else value
_LOGGER.debug('Successfully set %s to %s', prop,
str(value))
except ClientResponseError as error:
_LOGGER.error(
'Status code %s returned when updating %s to %s', error.code, prop, str(value))
raise

@property
def id(self):
Expand Down Expand Up @@ -241,6 +284,11 @@ def model(self):
"""Return model number."""
return self._attrs.get('model')

@property
def firmware(self):
"""Return firmware version."""
return self._attrs.get('firmware')

@property
def signal_strength_percentage(self):
"""Return signal strength between 0-100 (0 = bad, 100 = excellent)."""
Expand Down Expand Up @@ -269,3 +317,61 @@ def temperature(self):
def humidity(self):
"""Return relative humidity (returns None if not supported by device)."""
return self._attrs.get('humidity')

@property
def ip_address(self):
"""Return local IP address for camera."""
return self._attrs.get('ip_address')

@property
def mac_address(self):
"""Return MAC address for camera's WiFi interface."""
return self._attrs.get('mac_address')

@property
def wifi_ssid(self):
"""Return WiFi SSID name the camera last connected with."""
return self._attrs.get('wifi_ssid')

@property
def microphone_on(self):
"""Return bool indicating whether microphone is enabled."""
return self._attrs.get('microphone_on')

@property
def microphone_gain(self):
"""Return microphone gain using absolute scale (1-100)."""
gain = self._attrs.get('microphone_gain')
try:
return int(gain)
except ValueError:
return gain

@property
def speaker_on(self):
"""Return bool indicating whether speaker is currently enabled."""
return self._attrs.get('speaker_on')

@property
def speaker_volume(self):
"""Return speaker volume using absolute scale (1-100)."""
volume = self._attrs.get('speaker_volume')
try:
return int(volume)
except ValueError:
return volume

@property
def led_on(self):
"""Return bool indicating whether LED is enabled."""
return self._attrs.get('led')

@property
def privacy_mode(self):
"""Return bool indicating whether privacy mode is enabled (ie. no activities recorded)."""
return self._attrs.get('privacy_mode')

@property
def plan_name(self):
"""Return plan/subscription product assigned to camera (free tier, paid tier, etc)."""
return self._attrs.get('plan_name')
4 changes: 4 additions & 0 deletions logi_circle/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ class NoSession(Exception):

class BadCache(Exception):
"""Cached credentials incomplete, corrupt or do not apply to current user"""


class BadLogin(Exception):
"""Username or password rejected"""
Loading

0 comments on commit 8955c3a

Please sign in to comment.