File: camera.py

package info (click to toggle)
python-logi-circle 0.2.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 300 kB
  • sloc: python: 1,685; xml: 16; sh: 5; makefile: 4
file content (341 lines) | stat: -rw-r--r-- 12,176 bytes parent folder | download
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')