1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341
|
"""Camera class, representing a Logi Circle device"""
# coding: utf-8
# vim:sw=4:ts=4:et:
import logging
from datetime import datetime, timedelta
import pytz
from aiohttp.client_exceptions import ClientResponseError
from .const import (ACCESSORIES_ENDPOINT,
ACTIVITIES_ENDPOINT,
CONFIG_ENDPOINT,
PROP_MAP,
FEATURES_MAP,
ACTIVITY_API_LIMIT,
GEN_1_MODEL,
GEN_2_MODEL,
GEN_1_MODEL_NAME,
GEN_2_MODEL_NAME,
GEN_1_MOUNT,
GEN_2_MOUNT_WIRE,
GEN_2_MOUNT_WIREFREE,
MODEL_UNKNOWN,
MOUNT_UNKNOWN)
from .live_stream import LiveStream
from .activity import Activity
from .utils import _slugify_string
_LOGGER = logging.getLogger(__name__)
class Camera():
"""Generic implementation for Logi Circle camera."""
def __init__(self, logi, camera):
"""Initialise Logi Camera object."""
self.logi = logi
self._attrs = {}
self._live_stream = None
self._current_activity = None
self._last_activity = None
self._next_update_time = datetime.utcnow()
self._set_attributes(camera)
def _set_attributes(self, camera):
"""Sets attrs property based on mapping defined in PROP_MAP constant"""
config = camera['configuration']
for internal_prop, api_mapping in PROP_MAP.items():
base_obj = config if api_mapping.get('config') else camera
value = base_obj.get(api_mapping['key'], api_mapping.get('default_value'))
if value is None and api_mapping.get('required'):
raise KeyError("Mandatory property '%s' missing from camera JSON." %
(api_mapping['key']))
self._attrs[internal_prop] = value
self._local_tz = pytz.timezone(self.timezone)
self._live_stream = LiveStream(logi=self.logi, camera=self)
async def subscribe(self, event_types):
"""Shorthand method for subscribing to a single camera's events."""
return self.logi.subscribe(event_types, [self])
async def update(self, force=False):
"""Poll API for changes to camera properties."""
_LOGGER.debug('Updating properties for camera %s', self.name)
update_throttle = self.logi.update_throttle
if force is True or datetime.utcnow() >= self._next_update_time:
url = "%s/%s" % (ACCESSORIES_ENDPOINT, self.id)
camera = await self.logi._fetch(url=url)
self._set_attributes(camera)
self._next_update_time = datetime.utcnow(
) + timedelta(seconds=update_throttle)
else:
_LOGGER.debug('Request to update ignored, next update is permitted at %s.',
self._next_update_time)
async def set_config(self, prop, value):
"""Internal method for updating the camera's configuration."""
external_prop = PROP_MAP.get(prop)
if external_prop is None or not external_prop.get("settable", False):
raise NameError("Property '%s' is not settable." % (prop))
url = "%s/%s%s" % (ACCESSORIES_ENDPOINT, self.id, CONFIG_ENDPOINT)
payload = {external_prop['key']: value}
_LOGGER.debug("Setting %s (%s) to %s", prop, external_prop['key'], str(value))
try:
await self.logi._fetch(
url=url,
method="PUT",
request_body=payload)
self._attrs[prop] = 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.status, prop, str(value))
raise
async def query_activity_history(self,
property_filter=None,
date_filter=None,
date_operator='<=',
limit=ACTIVITY_API_LIMIT):
"""Filter the activity history, returning Activity objects for any matching result."""
if limit > ACTIVITY_API_LIMIT:
# Logi Circle API rejects requests where the limit exceeds 100, so we'll guard for that here.
raise ValueError(
'Limit may not exceed %s due to API restrictions.' % (ACTIVITY_API_LIMIT))
if date_filter is not None and not isinstance(date_filter, datetime):
raise TypeError('date_filter must be a datetime object.')
# Base payload object
payload = {
'limit': limit,
'scanDirectionNewer': True
}
if date_filter:
# Date filters are expressed using the same format for activity ID keys (YYYYMMDD"T"HHMMSSZ).
# Let's convert our date_filter to match.
# If timezone unaware, assume it's local to the camera's timezone.
date_filter_tz = date_filter.tzinfo or self._local_tz
# Activity ID keys are always expressed in UTC, so cast to UTC first.
utc_date_filter = date_filter.replace(
tzinfo=date_filter_tz).astimezone(pytz.utc)
payload['startActivityId'] = utc_date_filter.strftime(
'%Y%m%dT%H%M%SZ')
payload['operator'] = date_operator
if property_filter:
payload['filter'] = property_filter
url = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.id, ACTIVITIES_ENDPOINT)
raw_activitites = await self.logi._fetch(
url=url, method='POST', request_body=payload)
activities = []
for raw_activity in raw_activitites['activities']:
activity = Activity(activity=raw_activity,
url=url,
local_tz=self._local_tz,
logi=self.logi)
activities.append(activity)
return activities
@property
def supported_features(self):
"""Returns an array of supported sensors for this camera."""
return FEATURES_MAP[self.mount]
def supports_feature(self, feature):
"""Returns a bool indicating whether a given sensor is implemented for this camera."""
return feature in self.supported_features
@property
def current_activity(self):
"""Returns the current open activity - only available when subscribed to activity events."""
if (self._current_activity and
self._current_activity.start_time_utc >= (datetime.utcnow() - timedelta(minutes=3))):
# Only return activities that began in the last 3 minutes, as this is the maximum length of an activity
return self._current_activity
return None
async def get_last_activity(self, force_refresh=False):
"""Returns the most recent activity as an Activity object."""
if self._last_activity is None or force_refresh:
return await self._pull_last_activity()
return self._last_activity
async def _pull_last_activity(self):
"""Queries API for latest activity"""
activity = await self.query_activity_history(limit=1)
try:
self._last_activity = activity[0]
return self._last_activity
except IndexError:
# If there's no activity history for this camera at all.
return None
@property
def live_stream(self):
"""Return LiveStream class for this camera."""
return self._live_stream
@property
def id(self):
"""Return device ID."""
return self._attrs.get('id')
@property
def name(self):
"""Return device name."""
return self._attrs.get('name')
@property
def slugify_safe_name(self):
"""Returns device name (falling back to device ID if name cannot be slugified)"""
raw_name = self.name
if _slugify_string(raw_name):
# Return name if has > 0 chars after being slugified
return raw_name
# Fallback to camera ID
return self.id
@property
def timezone(self):
"""Return timezone offset."""
return self._attrs.get('timezone')
@property
def connected(self):
"""Return bool indicating whether device is online and can accept commands (hard "on")."""
return self._attrs.get('connected')
@property
def streaming(self):
"""Return streaming mode for camera (soft "on")."""
return self._attrs.get('streaming')
@property
def battery_level(self):
"""Return battery level (integer between -1 and 100)."""
# -1 means no battery, wired only.
return self._attrs.get('battery_level')
@property
def battery_saving(self):
"""Return whether battery saving mode is activated."""
return self._attrs.get('battery_saving')
@property
def charging(self):
"""Return bool indicating whether the device is currently charging."""
return self._attrs.get('charging')
@property
def model(self):
"""Return model number."""
return self._attrs.get('model')
@property
def model_name(self):
"""Return model name."""
if self.model == GEN_1_MODEL:
return GEN_1_MODEL_NAME
if self.model == GEN_2_MODEL:
return '%s (%s)' % (GEN_2_MODEL_NAME, self.mount)
return MODEL_UNKNOWN
@property
def mount(self):
"""Infer mount type from camera model and battery level."""
if self.model == GEN_1_MODEL:
return GEN_1_MOUNT
if self.model == GEN_2_MODEL:
if self.battery_level == -1:
return GEN_2_MOUNT_WIRE
return GEN_2_MOUNT_WIREFREE
return MOUNT_UNKNOWN
@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)."""
return self._attrs.get('signal_strength_percentage')
@property
def signal_strength_category(self):
"""Interpret signal strength value and return a friendly categorisation."""
signal_strength = self._attrs.get('signal_strength_percentage')
if signal_strength is not None:
if signal_strength > 80:
return 'Excellent'
if signal_strength > 60:
return 'Good'
if signal_strength > 40:
return 'Fair'
if signal_strength > 20:
return 'Poor'
return 'Bad'
return None
@property
def mac_address(self):
"""Return MAC address for camera's WiFi interface."""
return self._attrs.get('mac_address')
@property
def microphone(self):
"""Return bool indicating whether microphone is enabled."""
return self._attrs.get('microphone')
@property
def microphone_gain(self):
"""Return microphone gain using absolute scale (1-100)."""
return self._attrs.get('microphone_gain')
@property
def pir_wake_up(self):
"""Returns bool indicating whether camera can operate in low power PIR
wake up mode."""
return self._attrs.get('pir_wake_up')
@property
def speaker(self):
"""Return bool indicating whether speaker is currently enabled."""
return self._attrs.get('speaker')
@property
def speaker_volume(self):
"""Return speaker volume using absolute scale (1-100)."""
return self._attrs.get('speaker_volume')
@property
def led(self):
"""Return bool indicating whether LED is enabled."""
return self._attrs.get('led')
@property
def recording(self):
"""Return bool indicating whether recording mode is enabled."""
return not self._attrs.get('recording_disabled')
|