-
Notifications
You must be signed in to change notification settings - Fork 11
/
Copy pathcamera.py
556 lines (462 loc) · 17.5 KB
/
camera.py
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
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
from __future__ import print_function, unicode_literals
from functools import wraps
from copy import deepcopy
from datetime import datetime
import time
from .single import UnifiVideoSingle
from . import utils
endpoints = {
'save': lambda x: 'camera/{}'.format(x),
'data': lambda x: 'camera/{}'.format(x),
'snapshot': lambda c, w: 'snapshot/camera/{}?force=true{}'.format(
c, '&width={}'.format(w) if w else ''),
'recording_span': lambda x, s, e: 'video/camera?' \
'startTime={}&endTime={}&cameras[]={}'.format(s, e, x)
}
# Supported camera models, checked during UnifiVideoCamera initialization.
#
# Some model specifications include list of supported features. These are
# not currently checked against and are included only to account for
# some potential future use.
#
# The structure is constructed from bits gleaned from the frontend JS
# served by UniFi Video.
models = {
'UVC': {},
'UVC G3': {
'features': [
'external_accessory'
],
},
'UVC G3 Dome': {
'features': [
'can_play_sound',
'toggable_led',
],
},
'UVC Dome': {},
'UVC Pro': {
'pro': True,
'features': [
'optical_zoom',
],
},
'UVC G3 Pro': {
'pro': True,
'features': [
'optical_zoom',
],
},
'UVC G3 Flex': {
'features': [
'can_play_sound',
'toggable_led',
],
},
'UVC Micro': {
'features': [
'can_play_sound',
'toggable_led',
],
},
'UVC G3 Micro': {
'features': [
'can_play_sound',
'toggable_led',
],
},
'Vision Pro': {
'features': [
'can_play_sound',
'toggable_led',
],
},
'airCam': {},
'airCam Dome': {},
'airCam Mini': {},
'UVC G4 Bullet': {
'features': [
],
},
'UVC G4 Pro': {
'features': [
'toggable_led',
'optical_zoom',
'animate_led_on_motion',
],
},
}
common_isp_actionables = [
['brightness', 0, 100],
['contrast', 0, 100],
['hue', 0, 100],
['saturation', 0, 100],
['denoise', 0, 100],
['sharpness', 0, 100],
]
def determine_img_actionables(fw_platform, camera_model):
actionables = list(map(lambda x: x[0], common_isp_actionables))
actionables.append('orientation')
if fw_platform == 'GEN1':
actionables.extend(['gamma', 'aeModeGen1'])
else:
actionables.extend(['wdr', 'aeMode'])
if camera_model in ['UVC Pro', 'UVC G3 Pro']:
actionables.extend(['irLedModePro', 'zoom', 'focus'])
else:
actionables.append('irLedMode')
return actionables
def isp_actionable(floor=0, ceiling=100, name=None):
def decfn(fn):
@wraps(fn)
def wrapper(camera, val=None):
fn_name = name or fn.__name__
if fn_name not in camera._isp_actionables:
raise CameraModelError('This camera model ({}) has no ' \
'support for {} control'.format(camera.model, fn_name))
if val is None:
return fn(camera)
if val > ceiling:
val = ceiling
elif val < floor:
val = floor
return fn(camera, val)
return wrapper
return decfn
def add_actionable(actionable):
name, floor, ceiling = actionable
def fn(self, value=None):
isp = self._data['ispSettings']
if value is None:
return isp.get(name, -1)
isp[name] = value
self.update(True)
if isp[name] == value:
return True
else:
return False
fn.__name__ = str(name)
fn.__doc__ = """Control image {name}
Args:
value (int or NoneType): New {name} value
(min: ``{floor}``, max: ``{ceiling}``)
Returns:
bool or int: ``True`` or ``False``, depending on whether new value
value was successfully registered. Current {name} value when
called without input value.
""".format(name=name, floor=floor, ceiling=ceiling)
setattr(UnifiVideoCamera, name, isp_actionable(floor, ceiling)(fn))
class CameraModelError(ValueError):
"""Unsupported camera model"""
def __init__(self, message=None):
if not message:
message = 'Unsupported camera model'
super(CameraModelError, self).__init__(message)
class UnifiVideoCamera(UnifiVideoSingle):
"""Represents a single camera connected to a UniFi Video server
(:class:`~unifi_video.api.UnifiVideoAPI`).
Attributes:
name (str or NoneType):
Camera name
uuid (str):
Camera UUID
host (str):
Camera host address
model (str or NoneType):
Camera model
platform (str or NoneType):
Firmware platform
overlay_text (str):
Custom text overlayd over the image
mac_addr (str):
Camera MAC address
utc_h_offset (int or NoneType):
UTC offset in hours
state (str):
Camera state
managed (bool):
Whether camera is managed by the UniFi Video instance
provisioned (bool):
Whether camera is provisioned
managed_by_others (bool):
Whether camera is managed by some other UniFi Video instance
disconnect_reason (str):
Reason for most recent disconnect
connected (bool):
Whether camera is connected (ie, not disconnected or in process of
rebooting or being upgraded)
last_recording_id (str):
MongoDB ObjectID of latest recording
last_recording_start_time (int):
Unix timestamp (in ms): start time of latest recording
last_seen (int):
Unix timestamp (in ms). Meaning depends on the value of
:attr:`UnifiVideoCamera.state`:
- *CONNECTED*: timestamp for when the camera came online
- *DISCONNECTED*: timestamp for when the camera went offline
last_seen_ndt (datetime or NoneType):
:attr:`UnifiVideoCamera.last_seen` as naive :class:`datetime` object
_id (str):
Camera ID (MongoDB ObjectID as hex string)
_data (dict):
Complete camera JSON from UniFi Video server
_isp_actionables (list):
List of supported image settings
Warning:
Attributes having to do with camera state reflect the state
as it was during object instantiation.
Warning:
:attr:`UnifiVideoCamera.last_seen` changes were observed on
UniFi Video v3.10.13. No attempt has been made to verify
:attr:`UnifiVideoCamera.last_seen` acts the same way across
all supported UniFi Video versions.
"""
def _load_data(self, data):
self.model = data.get('model', None)
if not self.model or self.model not in models:
raise CameraModelError
self._data = data
self._id = data['_id']
self.name = data.get('name', None)
self.uuid = data.get('uuid', '')
self.host = data.get('host', '')
self.platform = data.get('platform', None)
self.overlay_text = data.get('osdSettings', {}).get('tag', None)
self.mac_addr = utils.format_mac_addr(data.get('mac', 'ffffffffffff'))
self._isp_actionables = determine_img_actionables(self.platform,
self.model)
self.state = data.get('state', '')
self.managed = data.get('managed', None)
self.provisioned = data.get('provisioned', None)
self.managed_by_others = data.get('managedByOthers', None)
self.disconnect_reason = data.get('disconnectReason') or ''
self.connected = self.state == 'CONNECTED'
self.last_recording_id = data.get('lastRecordingId', '') or ''
self.last_recording_start_time = \
data.get('lastRecordingStartTime', 0) or 0
self.last_seen = data.get('lastSeen', 0)
self.last_seen_ndt = datetime.fromtimestamp(self.last_seen / 1000) \
if self.last_seen else None
try:
self.utc_offset = utils.parse_gmt_offset(
(data.get('deviceSettings') or {}).get('timezone', ''))
self.utc_h_offset = self.utc_offset / 3600
except (TypeError, ValueError):
self.utc_offset = None
self.utc_h_offset = None
def _simple_isp_actionable(self, setting_name, value):
isp = self._data['ispSettings']
if value is None:
return isp.get(setting_name, -1)
isp[setting_name] = value
self.update(True)
if isp[setting_name] == value:
return True
else:
return False
def _toggable_osd_actionable(self, setting_name, enabled, ints=False):
osd = self._data['osdSettings']
if enabled is None:
return bool(osd[setting_name])
osd[setting_name] = int(enabled) if ints else enabled
self.update(True)
if osd[setting_name] == enabled:
return True
else:
return False
def update(self, save=False):
"""Update settings from remote UniFi Video server (``self._api``).
Call with ``True`` to write local settings to remote before updating.
:param bool save: Whether to push settings to the camera
"""
if save:
self._load_data(self._extract_data(
self._api.put(endpoints['save'](self._id), self._data)))
else:
self._load_data(self._extract_data(
self._api.get(endpoints['data'](self._id))))
def snapshot(self, filename=None, width=0):
"""Take and download snapshot.
:param filename: Filename to save the snapshot to
:type filename: str or None
:param width: Image width in pixels
:type width: int
"""
return self._api.get(
endpoints['snapshot'](self._id, int(width)),
filename if filename else 'snapshot-{}-{}.jpg'.format(
self._id, int(time.time())))
def recording_between(self, start_time, end_time, filename=None):
'''Download a recording of the camera's footage from an arbitrary
timespan, between ``start_time`` and ``end_time``.
Arguments:
start_time (datetime or str or int):
Recording start time. (See
:meth:`~unifi_video.utils.dt_resolvable_to_ms`.)
end_time (datetime or str or int):
Recording end time. (See
:meth:`~unifi_video.utils.dt_resolvable_to_ms`.)
filename (str, optional):
Filename to save the recording to (a ZIP file).
Will use whatever the server provides if left out.
Tip:
Widen the time span by a few seconds at each end. UniFi Video often
falls a little short of the exact start and end times.
'''
start_time = utils.dt_resolvable_to_ms(
start_time,
utc_offset=self._api.utc_offset,
resolution=1000)
end_time = utils.dt_resolvable_to_ms(
end_time,
utc_offset=self._api.utc_offset,
resolution=1000)
return self._api.get(endpoints['recording_span'](
self._id, start_time, end_time), filename if filename else '')
@isp_actionable(0, 3, name='wdr')
def dynamic_range(self, wdr=None):
"""Control image WDR (dynamic range). Input should be either `None`
or an `int` between ``0`` and ``3``.
:param wdr: New WDR value
:type wdr: int or None
:return: If value provided: `True` or `False`, depending on
whether new value was registered. If no value provided: current
WDR value.
:rtype: `bool` or `int`
"""
return self._simple_isp_actionable('wdr', wdr)
def ir_leds(self, led_state=None):
"""Control IR leds.
:param led_state: New led state (``auto``, ``on``, or ``off``).
:type led_state: str or None
:return: `True` or `False` depending on successful value change.
Current led state if called with no args.
:rtype: `bool` or `str`
"""
isp = self._data['ispSettings']
if not led_state:
_s = isp['irLedMode']
_v = isp['irLedLevel']
if _s == 'auto':
return _s
elif _s == 'manual' and _v == 0:
return 'off'
elif _s == 'manual' and _v == 215:
return 'on'
isp['irLedLevel'] = 215
if led_state == 'auto':
isp['irLedMode'] = 'auto'
elif led_state == 'on':
isp['irLedMode'] = 'manual'
elif led_state == 'off':
isp['irLedMode'] = 'manual'
isp['irLedLevel'] = 0
else:
raise ValueError('Unknown led_state: {}'.format(led_state))
verify = isp['irLedMode'] + str(isp['irLedLevel'])
self.update(True)
if isp['irLedMode'] + str(isp['irLedLevel']) == verify:
return True
else:
return False
def onscreen_text(self, text=None):
"""Set or get on-screen text.
:param text: New on-screen text
:type text: str or None
:return: `True` for successful value change, `Fail` for failed
attempt, current `str` value if called without ``text``.
:rtype: `bool` or `str`
"""
if not text:
return self.overlay_text
osd = self._data['osdSettings']
osd['overrideMessage'] = True
osd['tag'] = text.strip()
self.update(True)
if osd['tag'] == text:
return True
else:
return False
def onscreen_timestamp(self, enabled=None):
"""Set or get on-screen timestamp state.
:param enabled: New state
:type enabled: bool or None
:return: `True` for successful state change, `Fail` for failed
attempt. Either of the two for current state (when called
without the ``enabled`` arg)
:rtype: `bool`
"""
return self._toggable_osd_actionable('enableDate', enabled, True)
def onscreen_watermark(self, enabled=None):
"""Enable or disable on-screen watermark. Call without args to get
current setting.
:param enabled: Enable or disable
:type enabled: bool or None
:return: `True` for successful change, `Fail` for failed attempt.
One or the other for calls without args.
:rtype: `bool`
"""
return self._toggable_osd_actionable('enableLogo', enabled, True)
def set_recording_settings(self, recording_mode=None, pre_padding_secs=None,
post_padding_secs=None):
"""Set recording mode and pre/post padding.
Possible recording modes:
- ``disable``: don't record
- ``fulltime``: record at all times
- ``motion``: record when motion detected
:param str recording_mode: See above
:param int pre_padding_secs: Number of seconds to include
pre-motion footage of
:param int post_padding_secs: Number of seconds to include
post-motion footage of
"""
rec_settings = self._data['recordingSettings']
if recording_mode:
if recording_mode == 'disable':
rec_settings['fullTimeRecordEnabled'] = False
rec_settings['motionRecordEnabled'] = False
elif recording_mode == 'fulltime':
rec_settings['fullTimeRecordEnabled'] = True
rec_settings['motionRecordEnabled'] = True
elif recording_mode == 'motion':
rec_settings['fullTimeRecordEnabled'] = False
rec_settings['motionRecordEnabled'] = True
else:
raise ValueError('Unknow recording mode "{}"'.format(
recording_mode))
if pre_padding_secs is not None:
rec_settings['prePaddingSecs'] = pre_padding_secs
if post_padding_secs is not None:
rec_settings['postPaddingSecs'] = post_padding_secs
verify = deepcopy(rec_settings)
self.update(True)
return verify == self._data['recordingSettings']
def get_recording_settings(self, all=False):
"""Get camera's recording settings
Arguments:
all (bool): Whether to show all available settings. The default
is to only show the settings that are controllable by calling
:func:`~unifi_video.camera.UnifiVideoCamera.set_recording_settings`.
"""
controllable = [
'motionRecordEnabled',
'fullTimeRecordEnabled',
'prePaddingSecs',
'postPaddingSecs',
]
all_settings = self._data.get('recordingSettings', {})
return {k: all_settings.get(k, None) for k in controllable} \
if not all else all_settings
def __str__(self):
_filter = ['name', 'model', 'platform']
return '{}: {}'.format(
type(self).__name__,
{k: v for k, v in self.__dict__.items() if k in _filter})
# Define methods for controlling the isp actionables that are common to all
# camera models. Other actionables -- those not common to all models --
# are controlled with methods defined in UnifiVideoCamera body.
#
# Note: "isp actionables" is what the Ubiquiti provided frontend JS refers
# to contrast, brightness, saturation, etc. as.
for actionable in common_isp_actionables:
add_actionable(actionable)