diff --git a/README.rst b/README.rst index 7d58c4f..01d548e 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ Installation $ pip install pyarlo # Install latest development - $ pip install git+https://github.com/tchellomello/python-arlo@dev + $ pip install git+https://github.com/tchellomello/python-arlo Usage ----- diff --git a/pyarlo/__init__.py b/pyarlo/__init__.py index 6dc4269..639fe6f 100644 --- a/pyarlo/__init__.py +++ b/pyarlo/__init__.py @@ -36,11 +36,12 @@ def __init__(self, username=None, password=None, self.__headers = None self.__params = None + self._all_devices = {} + # set username and password self.__password = password self.__username = username self.session = requests.Session() - self.__base_stations = [] # login user self.login() @@ -131,6 +132,8 @@ def query(self, loop += 1 # define connection method + req = None + if method == 'GET': req = self.session.get(url, headers=headers, stream=stream) elif method == 'PUT': @@ -138,7 +141,7 @@ def query(self, elif method == 'POST': req = self.session.post(url, json=params, headers=headers) - if req.status_code == 200: + if req and (req.status_code == 200): if raw: _LOGGER.debug("Required raw object.") response = req @@ -163,9 +166,12 @@ def base_stations(self): @property def devices(self): """Return all devices on Arlo account.""" - devices = {} - devices['cameras'] = [] - devices['base_station'] = [] + if self._all_devices: + return self._all_devices + + self._all_devices = {} + self._all_devices['cameras'] = [] + self._all_devices['base_station'] = [] url = DEVICES_ENDPOINT data = self.query(url) @@ -176,15 +182,15 @@ def devices(self): device.get('deviceType') == 'arloq' or device.get('deviceType') == 'arloqs') and device.get('state') == 'provisioned'): - devices['cameras'].append(ArloCamera(name, device, self)) + camera = ArloCamera(name, device, self) + self._all_devices['cameras'].append(camera) if device.get('deviceType') == 'basestation' and \ device.get('state') == 'provisioned': base = ArloBaseStation(name, device, self.__token, self) - devices['base_station'].append(base) - self.__base_stations.append(base) + self._all_devices['base_station'].append(base) - return devices + return self._all_devices def lookup_camera_by_id(self, device_id): """Return camera object by device_id.""" diff --git a/pyarlo/base_station.py b/pyarlo/base_station.py index a6cad76..fa5aea7 100644 --- a/pyarlo/base_station.py +++ b/pyarlo/base_station.py @@ -3,12 +3,15 @@ import json import threading import logging +import time import sseclient from pyarlo.const import ( ACTION_BODY, SUBSCRIBE_ENDPOINT, UNSUBSCRIBE_ENDPOINT, FIXED_MODES, NOTIFY_ENDPOINT, RESOURCES) _LOGGER = logging.getLogger(__name__) +REFRESH_RATE = 15 + class ArloBaseStation(object): """Arlo Base Station module implementation.""" @@ -25,6 +28,11 @@ def __init__(self, name, attrs, session_token, arlo_session): self._attrs = attrs self._session = arlo_session self._session_token = session_token + self._available_modes = None + self._available_mode_ids = None + self._camera_properties = None + self._last_refresh = None + self._refresh_rate = REFRESH_RATE self.__sseclient = None self.__subscribed = False self.__events = [] @@ -42,6 +50,7 @@ def thread_function(self): data = self._session.query(url, method='GET', raw=True, stream=True) self.__sseclient = sseclient.SSEClient(data) + for event in (self.__sseclient).events(): if not self.__subscribed: break @@ -99,6 +108,7 @@ def publish_and_get_event(self, resource): resource=resource, mode=None, publish_response=False) + if status == 'success': i = 0 while not this_event and i < 2: @@ -123,12 +133,14 @@ def __run_action( self, method='GET', resource=None, + camera_id=None, mode=None, publish_response=None): """Run action. :param method: Specify the method GET, POST or PUT. Default is GET. :param resource: Specify one of the resources to fetch from arlo. + :param camera_id: Specify the camera ID involved with this action :param mode: Specify the mode to set, else None for GET operations :param publish_response: Set to True for SETs. Default False """ @@ -155,7 +167,9 @@ def __run_action( elif resource == 'modes': available_modes = self.available_modes_with_ids body['properties'] = {'active': available_modes.get(mode)} - + elif resource == 'privacy': + body['properties'] = {'privacyActive': not mode} + body['resource'] = "cameras/{0}".format(camera_id) else: _LOGGER.info("Invalid method requested") return None @@ -167,7 +181,7 @@ def __run_action( body['from'] = "{0}_web".format(self.user_id) body['to'] = self.device_id - body['transId'] = "web!e6d1b969.8aa4b!1498165992111" + body['transId'] = "web!{0}".format(self.xcloud_id) _LOGGER.debug("Action body: %s", body) @@ -175,7 +189,7 @@ def __run_action( self._session.query(url, method='POST', extra_params=body, extra_headers={"xCloudId": self.xcloud_id}) - if ret.get('success'): + if ret and ret.get('success'): return 'success' return None @@ -234,18 +248,28 @@ def xcloud_id(self): @property def available_modes(self): """Return list of available mode names.""" - return list(self.available_modes_with_ids.keys()) + if not self._available_modes: + modes = self.available_modes_with_ids + if not modes: + return None + self._available_modes = list(modes.keys()) + return self._available_modes @property def available_modes_with_ids(self): """Return list of objects containing available mode name and id.""" - modes = self.get_available_modes() - simple_modes = dict( - [(m.get("type", m.get("name")), m.get("id")) for m in modes] - ) - all_modes = FIXED_MODES.copy() - all_modes.update(simple_modes) - return all_modes + if not self._available_mode_ids: + all_modes = FIXED_MODES.copy() + self._available_mode_ids = all_modes + modes = self.get_available_modes() + if modes: + simple_modes = dict( + [(m.get("type", m.get("name")), m.get("id")) + for m in modes] + ) + all_modes.update(simple_modes) + self._available_mode_ids = all_modes + return self._available_mode_ids @property def available_resources(self): @@ -281,45 +305,47 @@ def get_available_modes(self): return None @property - def get_camera_properties(self): + def camera_properties(self): + """Return _camera_properties""" + if self._camera_properties is None: + self.get_cameras_properties() + return self._camera_properties + + def get_cameras_properties(self): """Return camera properties.""" resource = "cameras" resource_event = self.publish_and_get_event(resource) if resource_event: - return resource_event.get('properties') + self._last_refresh = int(time.time()) + self._camera_properties = resource_event.get('properties') + return - return None - - @property - def get_camera_battery_level(self): + def get_cameras_battery_level(self): """Return a list of battery levels of all cameras.""" battery_levels = {} - camera_properties = self.get_camera_properties - if not camera_properties: + if not self.camera_properties: return None - for camera in camera_properties: + for camera in self.camera_properties: serialnum = camera.get('serialNumber') cam_battery = camera.get('batteryLevel') battery_levels[serialnum] = cam_battery return battery_levels - @property - def get_camera_signal_strength(self): + def get_cameras_signal_strength(self): """Return a list of signal strength of all cameras.""" signal_strength = {} - camera_properties = self.get_camera_properties - if not camera_properties: + if not self.camera_properties: return None - for camera in camera_properties: + for camera in self.camera_properties: serialnum = camera.get('serialNumber') cam_strength = camera.get('signalStrength') signal_strength[serialnum] = cam_strength return signal_strength @property - def get_basestation_properties(self): + def properties(self): """Return the base station info.""" resource = "basestation" basestn_event = self.publish_and_get_event(resource) @@ -328,8 +354,7 @@ def get_basestation_properties(self): return None - @property - def get_camera_rules(self): + def get_cameras_rules(self): """Return the camera rules.""" resource = "rules" rules_event = self.publish_and_get_event(resource) @@ -338,8 +363,7 @@ def get_camera_rules(self): return None - @property - def get_camera_schedule(self): + def get_cameras_schedule(self): """Return the schedule set for cameras.""" resource = "schedule" schedule_event = self.publish_and_get_event(resource) @@ -371,7 +395,8 @@ def mode(self, mode): :param mode: arm, disarm """ - if mode not in self.available_modes: + modes = self.available_modes + if (not modes) or (mode not in modes): return self.__run_action( method='SET', @@ -380,8 +405,30 @@ def mode(self, mode): publish_response=True) self.update() + def set_camera_enabled(self, camera_id, is_enabled): + """Turn Arlo camera On/Off. + + :param mode: True, False + """ + self.__run_action( + method='SET', + resource='privacy', + camera_id=camera_id, + mode=is_enabled, + publish_response=True) + self.update() + def update(self): """Update object properties.""" - self._attrs = self._session.refresh_attributes(self.name) + current_time = int(time.time()) + last_refresh = 0 if self._last_refresh is None else self._last_refresh + + if current_time >= (last_refresh + self._refresh_rate): + self.get_cameras_properties() + self._attrs = self._session.refresh_attributes(self.name) + _LOGGER.debug("Called base station update of camera properties: " + "Scan Interval: %s, New Properties: %s", + self._refresh_rate, self.camera_properties) + return # vim:sw=4:ts=4:et: diff --git a/pyarlo/camera.py b/pyarlo/camera.py index 6d9dd59..80f33b4 100644 --- a/pyarlo/camera.py +++ b/pyarlo/camera.py @@ -71,14 +71,12 @@ def user_id(self): @property def unseen_videos(self): """Return number of unseen videos.""" - self.update() return self._attrs.get('mediaObjectCount') def unseen_videos_reset(self): """Reset the unseen videos counter.""" url = RESET_CAM_ENDPOINT.format(self.unique_id) ret = self._session.query(url).get('success') - self.update() return ret @property @@ -89,7 +87,6 @@ def user_role(self): @property def last_image(self): """Return last image capture by camera.""" - self.update() return http_get(self._attrs.get('presignedLastImageUrl')) @property @@ -128,34 +125,29 @@ def xcloud_id(self): """Return X-Cloud-ID attribute.""" return self._attrs.get('xCloudId') - @property - def get_battery_level(self): - """Get the camera battery level.""" - base = self._session.base_stations[0] - return base.get_camera_battery_level[self.device_id] - - @property - def properties(self): - """Get this camera's extended properties.""" - base = self._session.base_stations[0] - props = base.get_camera_properties - if not props: - return None + def _get_camera_properties(self): + """Lookup camera properties from base station.""" + base_stations = self._session.base_stations - for cam in props: - if cam["serialNumber"] == self.device_id: - return cam + if base_stations: + base = base_stations[0] + if base.camera_properties: + for cam in base.camera_properties: + if cam["serialNumber"] == self.device_id: + return cam return None + @property + def properties(self): + """Get this camera's properties.""" + return self._get_camera_properties() + @property def capabilities(self): """Get a camera's capabilities.""" properties = self.properties - if not self.properties: - return None - - return properties.get("capabilities") + return properties.get("capabilities") if properties else None @property def triggers(self): @@ -175,64 +167,55 @@ def triggers(self): return None @property - def get_signal_strength(self): - """Get the camera Signal strength.""" + def battery_level(self): + """Get the camera battery level.""" properties = self.properties - if not self.properties: - return None - - return properties.get("signalStrength") + return properties.get("batteryLevel") if properties else None @property - def get_brightness(self): - """Get the brightness property of camera.""" + def signal_strength(self): + """Get the camera Signal strength.""" properties = self.properties - if not self.properties: - return None - - return properties.get("brightness") + return properties.get("signalStrength") if properties else None @property - def get_mirror_state(self): + def brightness(self): """Get the brightness property of camera.""" properties = self.properties - if not self.properties: - return None - - return properties.get("flip") + return properties.get("brightness") if properties else None @property - def get_flip_state(self): - """Get the brightness property of camera.""" + def mirror_state(self): + """Get the mirror state of camera image.""" properties = self.properties - if not self.properties: - return None - - return properties.get("mirror") + return properties.get("mirror") if properties else None @property - def get_powersave_mode(self): - """Get the brightness property of camera.""" + def flip_state(self): + """Get the flipped state of camera image.""" properties = self.properties - if not self.properties: - return None + return properties.get("flip") if properties else None - return properties.get("powerSaveMode") + @property + def powersave_mode(self): + """Get the power mode (stream quality) of camera.""" + properties = self.properties + return properties.get("powerSaveMode") if properties else None @property def is_camera_connected(self): """Connectivity status of Cam with Base Station.""" properties = self.properties - if not self.properties: - return None - - return bool(properties.get("connectionState") == "available") + return bool(properties.get("connectionState") == "available") \ + if properties else None @property - def get_motion_detection_sensitivity(self): + def motion_detection_sensitivity(self): """Sensitivity level of Camera motion detection.""" - triggers = self.triggers - for trigger in triggers: + if not self.triggers: + return None + + for trigger in self.triggers: if trigger.get("type") != "pirMotionActive": continue @@ -242,6 +225,22 @@ def get_motion_detection_sensitivity(self): return None + @property + def audio_detection_sensitivity(self): + """Sensitivity level of Camera audio detection.""" + if not self.triggers: + return None + + for trigger in self.triggers: + if trigger.get("type") != "audioAmplitude": + continue + + sensitivity = trigger.get("sensitivity") + if sensitivity: + return sensitivity.get("default") + + return None + def live_streaming(self): """Return live streaming generator.""" url = STREAM_ENDPOINT @@ -274,4 +273,10 @@ def update(self): """Update object properties.""" self._attrs = self._session.refresh_attributes(self.name) + # force base_state to update properties + base_stations = self._session.base_stations + if base_stations: + base = base_stations[0] + base.update() + # vim:sw=4:ts=4:et: diff --git a/requirements.txt b/requirements.txt index a944a1c..2e07428 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -requests -sseclient-py +requests==2.18.4 +sseclient-py==1.7 diff --git a/setup.py b/setup.py index 1fd0be5..5199dbc 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='pyarlo', packages=['pyarlo'], - version='0.0.7', + version='0.0.8', description='Python Arlo is a library written in Python 2.7/3x ' + 'that exposes the Netgear Arlo cameras as Python objects.', author='Marcelo Moreira de Mello', diff --git a/tests/fixtures/pyarlo_camera_extended_properties.json b/tests/fixtures/pyarlo_camera_extended_properties.json new file mode 100644 index 0000000..64ed914 --- /dev/null +++ b/tests/fixtures/pyarlo_camera_extended_properties.json @@ -0,0 +1,111 @@ +[{'activityState': 'idle', + 'audioDetected': false, + 'batteryLevel': 0, + 'brightness': 0, + 'capabilities': ['H.264Streaming', + 'JPEGSnapshot', + 'SignalStrength', + 'Privacy', + 'Standby', + {'Resolutions': [{'text': '720p', 'x': 1280, 'y': 720}, + {'text': '480p', 'x': 848, 'y': 480}, + {'text': '360p', 'x': 640, 'y': 352}, + {'text': '240p', 'x': 416, 'y': 240}]}, + {'TimedStreamDuration': {'default': 10, 'max': 120, 'min': 5}}, + {'TriggerEndStreamDuration': {'default': 300, 'max': 300, 'min': 5}}, + {'Actions': [{'recordVideo': [{'StopActions': ['timeout', + 'triggerEndDetected']}]}, + 'sendEmailAlert', + 'pushNotification']}, + {'Triggers': [{'sensitivity': {'default': 80, + 'max': 100, + 'min': 1, + 'step': 1, + 'type': 'integer'}, + 'type': 'pirMotionActive'}]}], + 'connectionState': 'batteryCritical', + 'continuousStreamState': 'inactive', + 'flip': false, + 'hasStreamed': true, + 'hwVersion': 'H7', + 'idleLedEnable': true, + 'interfaceVersion': 3, + 'mic': {'mute': false, 'volume': 100}, + 'mirror': false, + 'modelId': 'VMC3030', + 'motion': {'sensitivity': 5, 'zones': []}, + 'motionDetected': false, + 'motionSetupModeEnabled': false, + 'motionSetupModeSensitivity': 80, + 'name': '', + 'nightVisionMode': 1, + 'olsonTimeZone': 'America/New_York', + 'powerSaveMode': 1, + 'privacyActive': false, + 'resolution': {'height': 720, 'width': 1280}, + 'serialNumber': '48B14C1299999', + 'signalStrength': 4, + 'speaker': {'mute': false, 'volume': 100}, + 'standbyActive': false, + 'streamingMode': 'eventBased', + 'swVersion': '1.2.11810', + 'zoom': {'bottomrightx': 1280, + 'bottomrighty': 720, + 'topleftx': 0, + 'toplefty': 0}}, + {'activityState': 'idle', + 'audioDetected': false, + 'batteryLevel': 35, + 'brightness': 0, + 'capabilities': ['H.264Streaming', + 'JPEGSnapshot', + 'SignalStrength', + 'Privacy', + 'Standby', + {'Resolutions': [{'text': '1080p', 'x': 1920, 'y': 1088}, + {'text': '720p', 'x': 1280, 'y': 720}, + {'text': '480p', 'x': 848, 'y': 480}, + {'text': '360p', 'x': 640, 'y': 352}, + {'text': '240p', 'x': 416, 'y': 240}]}, + {'TimedStreamDuration': {'default': 10, 'max': 120, 'min': 5}}, + {'TriggerEndStreamDuration': {'default': 300, 'max': 300, 'min': 5}}, + {'Actions': [{'recordVideo': [{'StopActions': ['timeout', + 'triggerEndDetected']}]}, + 'sendEmailAlert', + 'pushNotification']}, + {'Triggers': [{'sensitivity': {'default': 80, + 'max': 100, + 'min': 1, + 'step': 1, + 'type': 'integer'}, + 'type': 'pirMotionActive'}]}], + 'connectionState': 'available', + 'continuousStreamState': 'inactive', + 'flip': false, + 'hasStreamed': true, + 'hwVersion': 'H7', + 'idleLedEnable': true, + 'interfaceVersion': 3, + 'mic': {'mute': false, 'volume': 100}, + 'mirror': false, + 'modelId': 'VMC3030', + 'motion': {'sensitivity': 5, 'zones': []}, + 'motionDetected': false, + 'motionSetupModeEnabled': false, + 'motionSetupModeSensitivity': 80, + 'name': '', + 'nightVisionMode': 1, + 'olsonTimeZone': 'America/New_York', + 'powerSaveMode': 1, + 'privacyActive': false, + 'resolution': {'height': 720, 'width': 1280}, + 'serialNumber': '48B14CBBBBBBB', + 'signalStrength': 4, + 'speaker': {'mute': false, 'volume': 100}, + 'standbyActive': false, + 'streamingMode': 'eventBased', + 'swVersion': '1.2.11810', + 'zoom': {'bottomrightx': 1280, + 'bottomrighty': 720, + 'topleftx': 0, + 'toplefty': 0}}] diff --git a/tests/test_base_station.py b/tests/test_base_station.py index 16abacf..5031c8f 100644 --- a/tests/test_base_station.py +++ b/tests/test_base_station.py @@ -68,45 +68,45 @@ def test_is_motion_detection_enabled(self, mock): def test_get_properties(self, mock): """Test ArloBaseStation.get_basestation_properties.""" base = self.load_base_station(mock) - base_properties = base.get_basestation_properties + base_properties = base.properties mocked_properties = load_base_props() self.assertEqual(base_properties, mocked_properties["properties"]) @requests_mock.Mocker() @patch.object(ArloBaseStation, "publish_and_get_event", load_camera_props) def test_camera_properties(self, mock): - """Test ArloBaseStation.get_camera_properties.""" + """Test ArloBaseStation.get_cameras_properties.""" base = self.load_base_station(mock) - camera_properties = base.get_camera_properties + camera_properties = base.camera_properties mocked_properties = load_camera_props() self.assertEqual(camera_properties, mocked_properties["properties"]) @requests_mock.Mocker() @patch.object(ArloBaseStation, "publish_and_get_event", load_camera_props) def test_battery_level(self, mock): - """Test ArloBaseStation.get_camera_battery_level.""" + """Test ArloBaseStation.get_cameras_battery_level.""" base = self.load_base_station(mock) self.assertEqual( - base.get_camera_battery_level, + base.get_cameras_battery_level(), {"48B14C1299999": 95, "48B14CAAAAAAA": 77} ) @requests_mock.Mocker() @patch.object(ArloBaseStation, "publish_and_get_event", load_camera_props) def test_signal_strength(self, mock): - """Test ArloBaseStation.get_camera_signal_strength.""" + """Test ArloBaseStation.get_cameras_signal_strength.""" base = self.load_base_station(mock) self.assertEqual( - base.get_camera_signal_strength, + base.get_cameras_signal_strength(), {"48B14C1299999": 4, "48B14CAAAAAAA": 3} ) @requests_mock.Mocker() @patch.object(ArloBaseStation, "publish_and_get_event", load_camera_rules) def test_camera_rules(self, mock): - """Test ArloBaseStation.get_camera_rules.""" + """Test ArloBaseStation.get_cameras_rules.""" base = self.load_base_station(mock) - camera_rules = base.get_camera_rules + camera_rules = base.get_cameras_rules() mocked_rules = load_camera_rules() self.assertEqual(camera_rules, mocked_rules["properties"]) @@ -114,8 +114,8 @@ def test_camera_rules(self, mock): @patch.object( ArloBaseStation, "publish_and_get_event", load_camera_schedule) def test_camera_schedule(self, mock): - """Test ArloBaseStation.get_camera_schedule.""" + """Test ArloBaseStation.get_cameras_schedule.""" base = self.load_base_station(mock) - camera_schedule = base.get_camera_schedule + camera_schedule = base.get_cameras_schedule() mocked_schedules = load_camera_schedule() self.assertEqual(camera_schedule, mocked_schedules["properties"]) diff --git a/tests/test_camera.py b/tests/test_camera.py index f42c489..fdbf0de 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -15,9 +15,11 @@ from pyarlo.camera import ArloCamera from pyarlo.const import ( DEVICES_ENDPOINT, LIBRARY_ENDPOINT, LOGIN_ENDPOINT, - RESET_CAM_ENDPOINT, STREAM_ENDPOINT + NOTIFY_ENDPOINT, RESET_CAM_ENDPOINT, STREAM_ENDPOINT, + UNSUBSCRIBE_ENDPOINT ) +BASE_STATION_ID = "48B14CBBBBBBB" USERNAME = "foo" PASSWORD = "bar" USERID = "999-123456" @@ -33,6 +35,9 @@ def load_arlo(self, mock): text=load_fixture("pyarlo_authentication.json")) mock.get(DEVICES_ENDPOINT, text=load_fixture("pyarlo_devices.json")) mock.post(LIBRARY_ENDPOINT, text=load_fixture("pyarlo_videos.json")) + mock.post(NOTIFY_ENDPOINT.format(BASE_STATION_ID), + text=load_fixture("pyarlo_camera_properties.json")) + mock.get(UNSUBSCRIBE_ENDPOINT) return PyArlo(USERNAME, PASSWORD, preload=False) @requests_mock.Mocker() @@ -41,8 +46,11 @@ def test_camera_properties(self, mock): """Test ArloCamera properties.""" arlo = self.load_arlo(mock) cameras = arlo.cameras + basestation = arlo.base_stations[0] + basestation.update() self.assertEqual(len(cameras), 2) for camera in cameras: + camera.update() self.assertTrue(camera.__repr__().startswith("