pax_global_header00006660000000000000000000000064142357344650014527gustar00rootroot0000000000000052 comment=804d3f4b5a69ae96186b7c7b6c291856099f0b52 evanjd-python-logi-circle-036454b/000077500000000000000000000000001423573446500167555ustar00rootroot00000000000000evanjd-python-logi-circle-036454b/.coveragerc000066400000000000000000000000331423573446500210720ustar00rootroot00000000000000[run] relative_files = Trueevanjd-python-logi-circle-036454b/.github/000077500000000000000000000000001423573446500203155ustar00rootroot00000000000000evanjd-python-logi-circle-036454b/.github/workflows/000077500000000000000000000000001423573446500223525ustar00rootroot00000000000000evanjd-python-logi-circle-036454b/.github/workflows/python-package.yml000066400000000000000000000026661423573446500260210ustar00rootroot00000000000000name: tests on: push: branches: [master] pull_request: branches: [master] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: [3.6, 3.7] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest pip install -r requirements.txt -r requirements_test.txt - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest --cov=logi_circle --cov-report term-missing - name: Coveralls uses: AndreMiras/coveralls-python-action@develop with: parallel: true flag-name: tests coveralls_finish: needs: test runs-on: ubuntu-latest steps: - name: Coveralls Finished uses: AndreMiras/coveralls-python-action@develop with: parallel-finished: true evanjd-python-logi-circle-036454b/.gitignore000066400000000000000000000023311423573446500207440ustar00rootroot00000000000000# MacOS .DS_Store # VS Code .vscode # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ evanjd-python-logi-circle-036454b/LICENSE000066400000000000000000000020531423573446500177620ustar00rootroot00000000000000MIT License Copyright (c) 2018 Evan Bruhn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. evanjd-python-logi-circle-036454b/README.md000066400000000000000000000214541423573446500202420ustar00rootroot00000000000000# Python Logi Circle API > Python 3.6+ API for interacting with Logi Circle cameras, written with asyncio and aiohttp. [![PyPI version](https://badge.fury.io/py/logi-circle.svg)](https://badge.fury.io/py/logi-circle) ![License](https://img.shields.io/packagist/l/doctrine/orm.svg) [![Build Status][github-actions-badge]][github-actions-url] [![Coverage Status][coverage-badge]][coverage-url] [![Open Issues][open-issues-badge]][open-issues-url] This library exposes the [Logi Circle](https://www.logitech.com/en-us/product/circle-2-home-security-camera) family of cameras as Python objects, wrapping Logi Circle's official API. [Now available as a Home Assistant integration!](https://www.home-assistant.io/components/logi_circle/) :tada: There are two versions of this API: - `2.x` - which targets the public API **(you are here)** - [`1.x`](https://github.com/evanjd/python-logi-circle/tree/private-api) - which targets the private API ([`private-api` branch](https://github.com/evanjd/python-logi-circle/tree/private-api)) To access the public API, you must request access from Logitech. Unfortunately, it's become increasingly difficult to get API keys issued by Logitech. Initially, API key requests were turned around in a few business days. Now, the wait time is several months. If you still want to request access, please refer to the instructions [here](https://www.home-assistant.io/components/logi_circle/#requesting-api-access) for more information. Otherwise, please refer to the [`private-api` branch](https://github.com/evanjd/python-logi-circle/tree/private-api). ## Features implemented - Download real-time live stream data to disk or serve to your application as a raw bytes object - 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 - Query/filter the activity history by start time and/or activity properties (duration, relevance) - Set name, timezone, streaming mode and privacy mode of a given camera - On-demand polling from server to update camera properties - Subscribe to WebSocket API to handle camera property updates and activities pushed from API - Read camera properties (see "play with props" example) ## Usage example #### Setup and authenticate: ```python import asyncio from logi_circle import LogiCircle logi = LogiCircle(client_id='your-client-id', client_secret='your-client-secret', redirect_uri='https://your-redirect-uri', api_key='your-api-key') if not logi.authorized: print('Navigate to %s and enter the authorization code passed back to your redirect URI' % (logi.authorize_url)) code = input('Code: ') async def authorize(): await logi.authorize(code) await logi.close() asyncio.get_event_loop().run_until_complete(authorize()) ``` #### Grab latest still image: ```python async def get_snapshot_images(): for camera in await logi.cameras: if camera.streaming: await camera.live_stream.download_jpeg(filename='%s.jpg' % (camera.name), quality=75, # JPEG compression % refresh=False) # Don't force cameras to wake await logi.close() asyncio.get_event_loop().run_until_complete(get_snapshot_images()) ``` #### Download 30s of live stream video from 1st camera (requires ffmpeg): ```python async def get_livestream(): camera = (await logi.cameras)[0] filename = '%s-livestream.mp4' % (camera.name) await camera.live_stream.download_rtsp(filename=filename, duration=30) await logi.close() asyncio.get_event_loop().run_until_complete(get_livestream()) ``` #### Download latest activity for all cameras: ```python async def get_latest_activity(): for camera in await logi.cameras: last_activity = await camera.last_activity if last_activity: # Get activity as image await last_activity.download_jpeg(filename='%s-last-activity.jpg' % (camera.name)) # Get activity as video await last_activity.download_mp4(filename='%s-last-activity.mp4' % (camera.name)) await logi.close() asyncio.get_event_loop().run_until_complete(get_latest_activity()) ``` #### Turn off streaming for all cameras: ```python async def disable_streaming_all(): for camera in await logi.cameras: if camera.streaming: await camera.set_config(prop='streaming', value=False) print('%s is now off.' % (camera.name)) else: print('%s is already off.' % (camera.name)) await logi.close() asyncio.get_event_loop().run_until_complete(disable_streaming_all()) ``` #### Subscribe to camera events with WS API: ```python async def subscribe_to_events(): subscription = await logi.subscribe(['accessory_settings_changed', "activity_created", "activity_updated", "activity_finished"]) while True: await subscription.get_next_event() asyncio.get_event_loop().run_until_complete(subscribe_to_events()) ``` #### Play with props: ```python async def play_with_props(): for camera in await logi.cameras: last_activity = await camera.get_last_activity() print('%s: %s' % (camera.name, ('is charging' if camera.charging else 'is not charging'))) if camera.battery_level >= 0: print('%s: %s%% battery remaining' % (camera.name, camera.battery_level)) print('%s: Battery saving mode is %s' % (camera.name, 'on' if camera.battery_saving else 'off')) print('%s: Model number is %s' % (camera.name, camera.model)) print('%s: Mount is %s' % (camera.name, camera.mount)) print('%s: Signal strength is %s%% (%s)' % ( camera.name, camera.signal_strength_percentage, camera.signal_strength_category)) if last_activity: 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: 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 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 else 'off', camera.speaker_volume)) print('%s: LED is %s' % ( camera.name, 'on' if camera.led else 'off')) print('%s: Recording mode is %s' % ( camera.name, 'on' if camera.recording else 'off')) await logi.close() asyncio.get_event_loop().run_until_complete(play_with_props()) ``` ## Thanks - This first version of API borrowed a lot of the design and some utility functions from [tchellomello's](https://github.com/tchellomello) [Python Ring Doorbell](https://github.com/tchellomello/python-ring-doorbell) project. It made a great template for how to implement a project like this, so thanks! - Thanks [sergeymaysak](https://github.com/sergeymaysak) for suggesting a switch to aiohttp, it made integrating with Home Assistant much easier. - Logitech for reaching out and providing support to reimplement this library using their official API. ## Contributing Pull requests are very welcome, every little bit helps! 1. Raise an issue with your feature request or bug before starting work. 2. Fork it (). 3. Create your feature branch (`git checkout -b feature/fooBar`). 4. Commit your changes (`git commit -am 'Add some fooBar'`). 5. Add/update tests if needed, then run `tox` to confirm no test failures. 6. Push to the branch (`git push origin feature/fooBar`). 7. Create a new pull request! ## Meta Evan Bruhn – [@evanjd](https://github.com/evanjd) – evan.bruhn@gmail.com Distributed under the MIT license. See `LICENSE` for more information. [open-issues-badge]: https://img.shields.io/github/issues/evanjd/python-logi-circle.svg [open-issues-url]: https://github.com/evanjd/python-logi-circle/issues [github-actions-badge]: https://github.com/evanjd/python-logi-circle/actions/workflows/python-package.yml/badge.svg [github-actions-url]: https://github.com/evanjd/python-logi-circle/actions/workflows/python-package.yml [coverage-badge]: https://img.shields.io/coveralls/github/evanjd/python-logi-circle/master.svg [coverage-url]: https://coveralls.io/github/evanjd/python-logi-circle?branch=master evanjd-python-logi-circle-036454b/logi_circle/000077500000000000000000000000001423573446500212305ustar00rootroot00000000000000evanjd-python-logi-circle-036454b/logi_circle/__init__.py000066400000000000000000000205121423573446500233410ustar00rootroot00000000000000"""Python wrapper for the official Logi Circle API""" # coding: utf-8 # vim:sw=4:ts=4:et: import logging import subprocess from .const import (DEFAULT_SCOPES, DEFAULT_CACHE_FILE, API_BASE, ACCOUNT_ENDPOINT, ACCESSORIES_ENDPOINT, NOTIFICATIONS_ENDPOINT, DEFAULT_FFMPEG_BIN) from .auth import AuthProvider from .camera import Camera from .subscription import Subscription from .exception import NotAuthorized, AuthorizationFailed, SessionInvalidated from .utils import _get_ids_for_cameras _LOGGER = logging.getLogger(__name__) class LogiCircle(): """A Python abstraction object to Logi Circle cameras.""" def __init__(self, client_id, client_secret, redirect_uri, api_key, scopes=DEFAULT_SCOPES, ffmpeg_path=None, cache_file=DEFAULT_CACHE_FILE, update_throttle=30): self.auth_provider = AuthProvider(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scopes=scopes, cache_file=cache_file, logi_base=self) self.authorize = self.auth_provider.authorize self.api_key = api_key self.ffmpeg_path = self._get_ffmpeg_path(ffmpeg_path) self.is_connected = False self.update_throttle = update_throttle self._subscriptions = [] self._cameras = [] @property def authorized(self): """Checks if the current client ID has a refresh token""" return self.auth_provider.authorized @property def authorize_url(self): """Returns the authorization URL for the Logi Circle API""" return self.auth_provider.authorize_url async def close(self): """Closes the aiohttp session""" await self.auth_provider.close() @property async def account(self): """Get account data from accounts endpoint.""" return await self._fetch(ACCOUNT_ENDPOINT) def flush_cameras(self): """Destroys cached camera list.""" self._cameras = None async def synchronize_cameras(self): """Caches camera list.""" await self.cameras @property async def cameras(self): """Return all cameras.""" if self._cameras: # Returned cached list return self._cameras # Get cameras from remote API cameras = [] raw_cameras = await self._fetch(ACCESSORIES_ENDPOINT) for camera in raw_cameras: cameras.append(Camera(self, camera)) self._cameras = cameras return cameras async def subscribe(self, event_types, cameras=None, ping_interval=60): """Subscribe camera(s) to one or more event types""" if not cameras: # If no cameras specified, subscribe all cameras = await self.cameras request = {"accessories": _get_ids_for_cameras(cameras), "eventTypes": event_types} # Request WS URL wss_url_request = await self._fetch(url=NOTIFICATIONS_ENDPOINT, headers={"X-Logi-NoRedirect": "true"}, request_body=request, method='POST', raw=True) # Retrieve WS URL from header and return Subscription object wss_url = wss_url_request.headers['X-Logi-Websocket-Url'] wss_url_request.close() subscription = Subscription(wss_url=wss_url, cameras=cameras, ping_interval=ping_interval) self._subscriptions.append(subscription) return subscription @property def subscriptions(self): """Returns all WS subscriptions.""" return self._subscriptions def _check_readiness(self): """Checks that this library is ready to submit requests to the Logi Circle API""" if not self.auth_provider.authorized: raise NotAuthorized('No access token available for this client ID') if self.auth_provider.invalid: raise SessionInvalidated('Logi API session invalidated due to 4xx exception refreshing token') async def _fetch(self, url, method='GET', params=None, request_body=None, headers=None, relative_to_api_root=True, raw=False, _reattempt=False): """Query data from the Logi Circle API.""" # pylint: disable=too-many-locals self._check_readiness() base_headers = { 'X-API-Key': self.api_key, 'Authorization': 'Bearer %s' % (self.auth_provider.access_token) } request_headers = {**base_headers, **(headers or {})} resolved_url = (API_BASE + url if relative_to_api_root else url) _LOGGER.debug("Fetching %s (%s)", resolved_url, method) resp = None session = await self.auth_provider.get_session() # Perform request if method == 'GET': resp = await session.get(resolved_url, headers=request_headers, params=params, allow_redirects=False) elif method in ['POST', 'PUT', 'DELETE']: func = getattr(session, method.lower()) resp = await func(resolved_url, headers=request_headers, params=params, json=request_body, allow_redirects=False) else: raise ValueError('Method %s not supported.' % (method)) content_type = resp.headers.get('content-type') _LOGGER.debug('Request %s (%s) returned %s with content type %s', resolved_url, method, resp.status, content_type) if resp.headers.get('X-Logi-Error'): _LOGGER.debug('Error header included with message: %s', resp.headers['X-Logi-Error']) if resp.status == 301 or resp.status == 302: # We need to implement our own redirect handling - Logi API # requires auth headers to passed to the redirected resource, but # aiohttp doesn't do this. redirect_uri = resp.headers['location'] return await self._fetch( url=redirect_uri, method=method, params=params, request_body=request_body, headers=headers, relative_to_api_root=False, raw=raw ) if resp.status == 401 and not _reattempt: # Token may have expired. Refresh and try again. await self.auth_provider.refresh() return await self._fetch( url=url, method=method, params=params, request_body=request_body, relative_to_api_root=relative_to_api_root, raw=raw, _reattempt=True ) if resp.status == 401 and _reattempt: raise AuthorizationFailed('Could not refresh access token') resp.raise_for_status() if raw: # Return unread ClientResponse object to client. return resp if 'json' in content_type: resp_data = await resp.json() else: resp_data = await resp.read() resp.close() return resp_data @staticmethod def _get_ffmpeg_path(ffmpeg_path=None): """Returns a bool indicating whether ffmpeg is installed.""" resolved_ffmpeg_path = ffmpeg_path or DEFAULT_FFMPEG_BIN try: subprocess.check_call([resolved_ffmpeg_path, "-version"], stdout=subprocess.DEVNULL) return resolved_ffmpeg_path except OSError: _LOGGER.warning( 'ffmpeg is not installed! Not all API methods will function.') return None evanjd-python-logi-circle-036454b/logi_circle/activity.py000066400000000000000000000122671423573446500234460ustar00rootroot00000000000000"""Activity class, represents activity observed by your camera (maximum 3 minutes)""" # coding: utf-8 # vim:sw=4:ts=4:et: from datetime import datetime, timedelta import logging import pytz from .const import (ISO8601_FORMAT_MASK, API_BASE, ACCEPT_IMAGE_HEADER, ACCEPT_VIDEO_HEADER, ACTIVITY_IMAGE_ENDPOINT, ACTIVITY_MP4_ENDPOINT, ACTIVITY_DASH_ENDPOINT, ACTIVITY_HLS_ENDPOINT) from .utils import _stream_to_file _LOGGER = logging.getLogger(__name__) class Activity(): """Generic implementation for a Logi Circle activity.""" def __init__(self, activity, url, local_tz, logi): """Initialize Activity object.""" self._logi = logi self._attrs = {} self._local_tz = local_tz self._set_attributes(activity) self._base_url = '%s%s/%s' % (API_BASE, url, self.activity_id) def _set_attributes(self, activity): self._attrs['activity_id'] = activity['activityId'] self._attrs['relevance_level'] = activity['relevanceLevel'] raw_start_time = activity['startTime'] raw_end_time = activity['endTime'] raw_duration = activity['playbackDuration'] self._attrs['start_time_utc'] = datetime.strptime( raw_start_time, ISO8601_FORMAT_MASK) self._attrs['end_time_utc'] = datetime.strptime( raw_end_time, ISO8601_FORMAT_MASK) self._attrs['start_time'] = self._attrs['start_time_utc'].replace( tzinfo=pytz.utc).astimezone(self._local_tz) self._attrs['end_time'] = self._attrs['end_time_utc'].replace( tzinfo=pytz.utc).astimezone(self._local_tz) self._attrs['duration'] = timedelta(milliseconds=raw_duration) @property def jpeg_url(self): """Returns the JPEG download URL for the current activity.""" return '%s%s' % (self._base_url, ACTIVITY_IMAGE_ENDPOINT) @property def mp4_url(self): """Returns the MP4 download URL for the current activity.""" return '%s%s' % (self._base_url, ACTIVITY_MP4_ENDPOINT) @property def hls_url(self): """Returns the HLS playlist download URL for the current activity.""" return '%s%s' % (self._base_url, ACTIVITY_HLS_ENDPOINT) @property def dash_url(self): """Returns the DASH manifest download URL for the current activity.""" return '%s%s' % (self._base_url, ACTIVITY_DASH_ENDPOINT) async def download_jpeg(self, filename=None): """Download the activity as a JPEG, optionally saving to disk.""" return await self._get_file(url=self.jpeg_url, filename=filename, accept_header=ACCEPT_IMAGE_HEADER) async def download_mp4(self, filename=None): """Download the activity as an MP4, optionally saving to disk.""" return await self._get_file(url=self.mp4_url, filename=filename, accept_header=ACCEPT_VIDEO_HEADER) async def download_hls(self, filename=None): """Download the activity's HLS playlist, optionally saving to disk.""" return await self._get_file(url=self.hls_url, filename=filename) async def download_dash(self, filename=None): """Download the activity's DASH manifest, optionally saving to disk.""" return await self._get_file(url=self.dash_url, filename=filename) async def _get_file(self, url, filename=None, accept_header=None): """Download the specified URL, optionally saving to disk.""" asset = await self._logi._fetch(url=url, headers=accept_header, raw=True, relative_to_api_root=False) if filename: # Stream to file await _stream_to_file(asset.content, filename) asset.close() else: # Return binary object content = await asset.read() asset.close() return content @property def activity_id(self): """Return activity ID.""" return self._attrs['activity_id'] @property def start_time(self): """Return start time as datetime object, local to the camera's timezone.""" return self._attrs['start_time'] @property def end_time(self): """Return end time as datetime object, local to the camera's timezone.""" return self._attrs['end_time'] @property def start_time_utc(self): """Return start time as datetime object in the UTC timezone.""" return self._attrs['start_time_utc'] @property def end_time_utc(self): """Return end time as datetime object in the UTC timezone.""" return self._attrs['end_time_utc'] @property def duration(self): """Return activity duration as a timedelta object.""" return self._attrs['duration'] @property def relevance_level(self): """Return relevance level.""" return self._attrs['relevance_level'] evanjd-python-logi-circle-036454b/logi_circle/auth.py000066400000000000000000000151141423573446500225450ustar00rootroot00000000000000"""Authorization provider for the Logi Circle API wrapper""" # coding: utf-8 # vim:sw=4:ts=4:et: import os import logging import pickle from urllib.parse import urlencode import aiohttp import asyncio from .const import AUTH_BASE, AUTH_ENDPOINT, TOKEN_ENDPOINT from .exception import AuthorizationFailed, NotAuthorized, SessionInvalidated _LOGGER = logging.getLogger(__name__) class AuthProvider(): """OAuth2 client for the Logi Circle API""" def __init__(self, client_id, client_secret, redirect_uri, scopes, cache_file, logi_base): self.client_id = client_id self.client_secret = client_secret self.redirect_uri = redirect_uri self.scopes = scopes self.cache_file = cache_file self.logi = logi_base self.tokens = self._read_token() self.invalid = False self.session = None self._lock = asyncio.Lock() @property def authorized(self): """Checks if the current client ID has a refresh token""" return self.client_id in self.tokens and 'refresh_token' in self.tokens[self.client_id] @property def authorize_url(self): """Returns the authorization URL for the Logi Circle API""" query_string = {"response_type": "code", "client_id": self.client_id, "client_secret": self.client_secret, "redirect_uri": self.redirect_uri, "scope": self.scopes} return '%s?%s' % (AUTH_BASE + AUTH_ENDPOINT, urlencode(query_string)) @property def refresh_token(self): """The refresh token granted by the Logi Circle API for the current client ID.""" if not self.authorized: return None return self.tokens[self.client_id].get('refresh_token') @property def access_token(self): """The access token granted by the Logi Circle API for the current client ID.""" if not self.authorized: return None return self.tokens[self.client_id].get('access_token') async def authorize(self, code): """Request a bearer token with the supplied authorization code""" authorize_payload = {"grant_type": "authorization_code", "code": code, "redirect_uri": self.redirect_uri, "client_id": self.client_id, "client_secret": self.client_secret} await self._authenticate(authorize_payload) async def clear_authorization(self): """Logs out and clears all persisted tokens for this client ID.""" await self.close() self.tokens[self.client_id] = {} self._save_token() async def refresh(self): """Use the persisted refresh token to request a new access token.""" if not self.authorized: raise NotAuthorized( 'No refresh token is available for client ID %s' % (self.client_id)) refresh_payload = {"grant_type": "refresh_token", "refresh_token": self.refresh_token, "client_id": self.client_id, "client_secret": self.client_secret} _LOGGER.debug("Refreshing access token for client %s", self.client_id) await self._authenticate(refresh_payload) async def close(self): """Closes the aiohttp session.""" for subscription in self.logi.subscriptions: if subscription.opened: # Signal subscription to close itself when the next frame is processed. subscription.invalidate() _LOGGER.warning('One or more WS connections have not been closed.') if isinstance(self.session, aiohttp.ClientSession): await self.session.close() self.session = None self.logi.is_connected = False async def _authenticate(self, payload): """Request or refresh the access token with Logi Circle""" if self.invalid: raise SessionInvalidated('Logi API session invalidated due to 4xx exception refreshing token') if self._lock.locked(): async with self._lock: _LOGGER.debug("Concurrent request to authenticate client ID %s ignored", self.client_id) return async with self._lock: _LOGGER.debug("Authenticating client ID %s", self.client_id) session = await self.get_session() async with session.post(AUTH_BASE + TOKEN_ENDPOINT, data=payload) as req: try: response = await req.json() if req.status >= 400: self.logi.is_connected = False if req.status >= 400 and req.status < 500: self.invalid = True error_message = response.get( "error_description", "Non-OK code %s returned" % (req.status)) raise AuthorizationFailed(error_message) # Authorization succeeded. Persist the refresh and access tokens. _LOGGER.debug("Successfully authenticated client ID %s", self.client_id) self.logi.is_connected = True self.invalid = False self.tokens[self.client_id] = response self._save_token() except aiohttp.ContentTypeError: response = await req.text() self.logi.is_connected = False if req.status >= 400 and req.status < 500: self.invalid = True if req.status >= 400: raise AuthorizationFailed("Non-OK code %s returned: %s" % (req.status, response)) else: raise AuthorizationFailed("Unexpected content type from Logi API: %s" % (response)) async def get_session(self): """Returns a aiohttp session, creating one if it doesn't already exist.""" if not isinstance(self.session, aiohttp.ClientSession): self.session = aiohttp.ClientSession() self.logi.is_connected = True return self.session def _save_token(self): """Dump data into a pickle file.""" with open(self.cache_file, 'wb') as pickle_db: pickle.dump(self.tokens, pickle_db) return True def _read_token(self): """Read data from a pickle file.""" filename = self.cache_file if os.path.isfile(filename): data = pickle.load(open(filename, 'rb')) return data return {} evanjd-python-logi-circle-036454b/logi_circle/camera.py000066400000000000000000000276201423573446500230410ustar00rootroot00000000000000"""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') evanjd-python-logi-circle-036454b/logi_circle/const.py000066400000000000000000000105451423573446500227350ustar00rootroot00000000000000# coding: utf-8 # vim:sw=4:ts=4:et: """Constants""" import os try: DEFAULT_CACHE_FILE = os.path.join( os.getenv("HOME"), '.logi_circle-session.cache') except (AttributeError, TypeError): DEFAULT_CACHE_FILE = os.path.join('.', '.logi_circle-session.cache') # OAuth2 constants AUTH_HOST = "accounts.logi.com" AUTH_BASE = "https://%s" % (AUTH_HOST) AUTH_ENDPOINT = "/identity/oauth2/authorize" TOKEN_ENDPOINT = "/identity/oauth2/token" DEFAULT_SCOPES = ("circle:activities_basic circle:activities circle:accessories circle:accessories_ro " "circle:live_image circle:live circle:notifications circle:summaries") # API endpoints API_HOST = "api.circle.logi.com" API_BASE = "https://%s" % (API_HOST) # Relative to API root NOTIFICATIONS_ENDPOINT = "/api/accounts/self/notifications" ACCOUNT_ENDPOINT = "/api/accounts/self" ACCESSORIES_ENDPOINT = "/api/accessories" ACTIVITIES_ENDPOINT = "/activities" # Relative to camera root CONFIG_ENDPOINT = "/config" LIVE_IMAGE_ENDPOINT = "/live/image" LIVE_RTSP_ENDPOINT = "/live/rtsp" # Relative to activity root ACTIVITY_IMAGE_ENDPOINT = "/image" ACTIVITY_MP4_ENDPOINT = "/mp4" ACTIVITY_DASH_ENDPOINT = "/mpd" ACTIVITY_HLS_ENDPOINT = "/hls/activity.m3u8" # Headers ACCEPT_IMAGE_HEADER = {"Accept": "image/jpeg"} ACCEPT_VIDEO_HEADER = {"Accept": "video/mp4"} # Misc DEFAULT_IMAGE_QUALITY = 75 DEFAULT_IMAGE_REFRESH = False DEFAULT_FFMPEG_BIN = "ffmpeg" ISO8601_FORMAT_MASK = '%Y-%m-%dT%H:%M:%SZ' ACTIVITY_API_LIMIT = 100 GEN_1_MODEL = "A1533" GEN_2_MODEL = "V-R0008" GEN_1_MODEL_NAME = "Logi Circle" GEN_2_MODEL_NAME = "Logi Circle 2" GEN_1_MOUNT = "Charging Ring" GEN_2_MOUNT_WIRE = "Wired" GEN_2_MOUNT_WIREFREE = "Wire-free" # Battery powered MODEL_UNKNOWN = "Unknown Logi Circle generation" MOUNT_UNKNOWN = "Unknown" ACTIVITY_EVENTS = ["activity_created", "activity_updated", "activity_finished"] # Prop to API mapping PROP_MAP = { "id": {"key": "accessoryId", "required": True}, "name": {"key": "name", "required": True, "settable": True}, "mac_address": {"key": "mac", "required": True}, "connected": {"key": "isConnected", "default_value": False}, "model": {"key": "modelNumber"}, "streaming": {"key": "streamingEnabled", "config": True, "default_value": False, "settable": True}, "charging": {"key": "batteryCharging", "config": True}, "battery_saving": {"key": "saveBattery", "config": True}, "timezone": {"key": "timeZone", "config": True, "default_value": "UTC", "settable": True}, "battery_level": {"key": "batteryLevel", "config": True, "default_value": -1}, "signal_strength_percentage": {"key": "wifiSignalStrength", "config": True}, "firmware": {"key": "firmwareVersion", "config": True}, "microphone": {"key": "microphoneOn", "config": True, "default_value": False}, "microphone_gain": {"key": "microphoneGain", "config": True}, "pir_wake_up": {"key": "pirWakeUp", "config": True, "default_value": False}, "speaker": {"key": "speakerOn", "config": True, "default_value": False}, "speaker_volume": {"key": "speakerVolume", "config": True}, "led": {"key": "ledEnabled", "config": True, "default_value": False, "settable": True}, "recording_disabled": {"key": "privacyMode", "config": True, "default_value": False, "settable": True} } # Feature mapping FEATURES_MAP = { GEN_1_MOUNT: ["activity", "charging", "battery_level", "last_activity_time", "recording", "signal_strength_percentage", "signal_strength_category", "speaker_volume", "streaming"], GEN_2_MOUNT_WIRE: ["activity", "last_activity_time", "recording", "signal_strength_percentage", "signal_strength_category", "speaker_volume", "streaming"], GEN_2_MOUNT_WIREFREE: ["activity", "charging", "battery_level", "last_activity_time", "recording", "signal_strength_percentage", "signal_strength_category", "speaker_volume", "streaming"], MOUNT_UNKNOWN: ["last_activity_time"] } evanjd-python-logi-circle-036454b/logi_circle/exception.py000066400000000000000000000006721423573446500236050ustar00rootroot00000000000000"""Custom exceptions""" class AuthorizationFailed(Exception): """When authorization fails for any reason.""" class SessionInvalidated(Exception): """When authorization is attempted on an invalidated session.""" class NotAuthorized(Exception): """When supplied client ID has not been authorized.""" class SubscriptionClosed(Exception): """When requesting the next WebSockets frame on an already closed subscription.""" evanjd-python-logi-circle-036454b/logi_circle/live_stream.py000066400000000000000000000054561423573446500241260ustar00rootroot00000000000000"""LiveStream class, representing a Logi Circle camera's live stream""" # coding: utf-8 # vim:sw=4:ts=4:et: import logging import subprocess from .const import (ACCESSORIES_ENDPOINT, LIVE_IMAGE_ENDPOINT, LIVE_RTSP_ENDPOINT, ACCEPT_IMAGE_HEADER, DEFAULT_IMAGE_QUALITY, DEFAULT_IMAGE_REFRESH) from .utils import _stream_to_file _LOGGER = logging.getLogger(__name__) class LiveStream(): """Generic implementation for Logi Circle live stream.""" def __init__(self, logi, camera): """Initialise Logi Camera object.""" self.logi = logi self.camera_id = camera.id def get_jpeg_url(self): """Get URL for camera JPEG snapshot""" url = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.camera_id, LIVE_IMAGE_ENDPOINT) return url async def download_jpeg(self, quality=DEFAULT_IMAGE_QUALITY, refresh=DEFAULT_IMAGE_REFRESH, filename=None): """Download the most recent snapshot image for this camera""" url = self.get_jpeg_url() params = {'quality': quality, 'refresh': str(refresh).lower()} image = await self.logi._fetch(url=url, raw=True, headers=ACCEPT_IMAGE_HEADER, params=params) if filename: await _stream_to_file(image.content, filename) image.close() return True content = await image.read() image.close() return content async def get_rtsp_url(self): """Get RTSP stream URL.""" # Request RTSP stream url = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.camera_id, LIVE_RTSP_ENDPOINT) stream_resp_payload = await self.logi._fetch(url=url) # Return time-limited RTSP URI rtsp_uri = stream_resp_payload["rtsp_uri"].replace('rtsp://', 'rtsps://') return rtsp_uri async def download_rtsp(self, duration, # in seconds filename, ffmpeg_bin=None, blocking=False): """Downloads the live stream into a specific file for a specific duration""" ffmpeg_bin = ffmpeg_bin or self.logi.ffmpeg_path # Bail now if ffmpeg is missing if ffmpeg_bin is None: raise RuntimeError( "This method requires ffmpeg to be installed and available from the current execution context.") rtsp_uri = await self.get_rtsp_url() subprocess_method = getattr(subprocess, 'check_call' if blocking else 'Popen') subprocess_method( [ffmpeg_bin, "-i", rtsp_uri, "-t", str(duration), "-vcodec", "copy", "-acodec", "copy", filename], stderr=subprocess.DEVNULL ) evanjd-python-logi-circle-036454b/logi_circle/subscription.py000066400000000000000000000112231423573446500243250ustar00rootroot00000000000000"""Subscription class""" # coding: utf-8 # vim:sw=4:ts=4:et: import logging import asyncio import json import aiohttp from .const import ACTIVITY_EVENTS, ACCESSORIES_ENDPOINT, ACTIVITIES_ENDPOINT from .utils import _get_camera_from_id from .activity import Activity from .exception import SubscriptionClosed _LOGGER = logging.getLogger(__name__) class Subscription(): """Generic implementation for a Logi Circle event subscription.""" def __init__(self, wss_url, cameras, ping_interval=60, raw=False): """Initialize Subscription object""" self.wss_url = wss_url self._cameras = cameras self._ping_interval = ping_interval self._ws = None self._session = None self._raw = raw self._closed = False self._invalidated = False async def open(self): """Establish a new WebSockets connection""" if not self.opened: return RuntimeError('This subscription has been closed') self._session = aiohttp.ClientSession() self._ws = await self._session.ws_connect( self.wss_url) _LOGGER.debug("Opened WS connection to url %s", self.wss_url) if self._ping_interval > 0: asyncio.ensure_future(self._auto_ping(self._ping_interval)) async def close(self): """Close WebSockets connection""" if not self.opened: return self._closed = True if isinstance(self._ws, aiohttp.ClientWebSocketResponse): await self._ws.close() self._ws = None if isinstance(self._session, aiohttp.ClientSession): await self._session.close() self._session = None async def ping(self): """Send a ping frame""" if not self.opened or self._ws is None: return _LOGGER.debug("WS: Sending ping frame") await self._ws.ping() async def get_next_event(self): """Wait for next WS frame""" if self._session is None: await self.open() if self._invalidated: _LOGGER.debug("WS: Invalidating subscription") await self.close() return {} if not self.opened: raise SubscriptionClosed("Subscription is closed") _LOGGER.debug("WS: Waiting for next frame") msg = await self._ws.receive() if self._raw: return msg if self._ws.closed: await self.close() return {} if msg.data: self._handle_event(msg.data) return msg def invalidate(self): """Signal event broker(s) to close subscription on next WS frame.""" self._invalidated = True @property def opened(self): """Returns a bool indicating whether the subscription is active.""" return not self._closed @property def invalidated(self): """Returns a bool indicating whether the subscription has been invalidated.""" return self._invalidated @staticmethod def _handle_activity(event_type, event, camera): """Controls the camera's current_activity prop based on incoming activity events.""" if event_type in ['activity_created', 'activity_updated']: # Set camera's current activity prop to this activity camera._current_activity = Activity(activity=event, url='%s/%s%s' % (ACCESSORIES_ENDPOINT, camera.id, ACTIVITIES_ENDPOINT), local_tz=camera._local_tz, logi=camera.logi) camera._last_activity = camera._current_activity if event_type == 'activity_finished' and camera._current_activity: camera._current_activity = None async def _auto_ping(self, interval): """Send ping frames at the specified interval""" while self.opened: await asyncio.sleep(interval) await self.ping() def _handle_event(self, data): """Perform action with event""" event = json.loads(data) event_type = event['eventType'] camera = _get_camera_from_id(event['eventData']['accessoryId'], self._cameras) _LOGGER.debug('WS: Got event %s for %s', event_type, camera.name) if event_type == "accessory_settings_changed": # Update camera props with changes camera._set_attributes(event['eventData']) elif event_type in ACTIVITY_EVENTS: # Set/unset camera's current activity Subscription._handle_activity(event_type, event['eventData'], camera) else: _LOGGER.warning('WS: Event type %s was unhandled', event_type) evanjd-python-logi-circle-036454b/logi_circle/utils.py000066400000000000000000000022511423573446500227420ustar00rootroot00000000000000"""Utilities library shared by the Logi, Camera and Activity classes.""" # coding: utf-8 # vim:sw=4:ts=4:et: import logging import slugify _LOGGER = logging.getLogger(__name__) def _write_to_file(data, filename, open_mode='wb'): # pragma: no cover """Write binary object directly to file.""" with open(filename, open_mode) as file_handle: file_handle.write(data) async def _stream_to_file(stream, filename, open_mode='wb'): """Stream aiohttp response to file.""" with open(filename, open_mode) as file_handle: while True: chunk = await stream.read(1024) if not chunk: break file_handle.write(chunk) def _get_ids_for_cameras(cameras): """Get list of camera IDs from cameras""" return list(map(lambda camera: camera.id, cameras)) def _get_camera_from_id(camera_id, cameras): """Get Camera object from ID""" camera = list(filter(lambda cam: camera_id == cam.id, cameras)) if camera: return camera[0] raise ValueError("No camera found with ID %s" % (camera_id)) def _slugify_string(text): """Slugify a given text.""" return slugify.slugify(text, separator='_') evanjd-python-logi-circle-036454b/pylintrc000066400000000000000000000003401423573446500205410ustar00rootroot00000000000000[MASTER] reports=no max-line-length=120 max-args=8 good-names=id disable= protected-access, too-many-instance-attributes, too-many-public-methods, too-many-arguments [EXCEPTIONS] overgeneral-exceptions=Exception evanjd-python-logi-circle-036454b/pytest.ini000066400000000000000000000000341423573446500210030ustar00rootroot00000000000000[pytest] asyncio_mode=strictevanjd-python-logi-circle-036454b/requirements.txt000066400000000000000000000000511423573446500222350ustar00rootroot00000000000000pytz aiohttp==3.7.4 python-slugify==1.2.6evanjd-python-logi-circle-036454b/requirements_test.txt000066400000000000000000000001021423573446500232710ustar00rootroot00000000000000coveralls pylint flake8 pytest pytest-cov aresponses tox freezegunevanjd-python-logi-circle-036454b/setup.cfg000066400000000000000000000001361423573446500205760ustar00rootroot00000000000000[metadata] description-file = README.md [tool:pytest] testpaths = tests norecursedirs = .git evanjd-python-logi-circle-036454b/setup.py000066400000000000000000000023011423573446500204630ustar00rootroot00000000000000# coding=utf-8 from setuptools import setup def readme(): with open('README.md', encoding='utf-8') as desc: return desc.read() setup( name='logi_circle', packages=['logi_circle'], version='0.2.3', description='A Python library to communicate with Logi Circle cameras', long_description=readme(), long_description_content_type='text/markdown', author='Evan Bruhn', author_email='evan.bruhn@gmail.com', url='https://github.com/evanjd/python-logi-circle', license='MIT', include_package_data=True, install_requires=['aiohttp', 'pytz'], test_suite='tests', keywords=[ 'logi', 'logi circle', 'logitech' 'home automation', ], classifiers=[ 'Environment :: Other Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Framework :: AsyncIO', 'Programming Language :: Python', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Topic :: Home Automation', 'Topic :: Software Development :: Libraries :: Python Modules' ], ) evanjd-python-logi-circle-036454b/tests/000077500000000000000000000000001423573446500201175ustar00rootroot00000000000000evanjd-python-logi-circle-036454b/tests/__init__.py000066400000000000000000000000541423573446500222270ustar00rootroot00000000000000"""Unit tests for Python Logi Circle API""" evanjd-python-logi-circle-036454b/tests/fixtures/000077500000000000000000000000001423573446500217705ustar00rootroot00000000000000evanjd-python-logi-circle-036454b/tests/fixtures/accessories.json000066400000000000000000000075231423573446500251750ustar00rootroot00000000000000[ { "accountId": "abcdefgh-1234-ijkl-5678-mnopqrstuvwx", "accessoryId": "yzabcdef-9012-ghij-3456-klmnopqrstuv", "name": "Mock Camera Gen1", "nodeConnected": true, "messengerConnected": true, "isConnected": true, "configuration": { "firmwareVersion": "4.7.706", "timeInSync": true, "timeZone": "Australia/Melbourne", "wifiSignalStrength": 74, "batteryLevel": 100, "batteryCharging": false, "horizontalFlip": false, "verticalFlip": false, "nightVisionMode": "auto", "microphoneOn": true, "microphoneGain": 100, "speakerOn": true, "speakerVolume": 90, "streamingEnabled": true, "videoStream": { "width": 1920, "height": 1080 }, "ledEnabled": false, "saveBattery": false, "powerLineFreq": 60, "privacyMode": false, "pirWakeUp": false, "persistentLog": false, "fastLiveView": false, "wifiLI": 0, "buttonIsAvailable": false, "buttonConfig": null, "temperatureIsAvailable": false, "temperature": 0, "humidityIsAvailable": false, "humidity": 0, "wifiSleepPM": 0 }, "modified": "2018-01-01T00:00:00.000000Z", "mac": "00-11-a2-44-bc-d5", "modelNumber": "A1533" }, { "accountId": "abcdefgh-1234-ijkl-5678-mnopqrstuvwx", "accessoryId": "aaaaaaaa-bbbb-cccc-dddd-eeefffggghhh", "name": "Mock Camera Gen2 Wired", "nodeConnected": false, "messengerConnected": false, "isConnected": false, "configuration": { "firmwareVersion": "5.2.191", "timeInSync": true, "timeZone": "Australia/Melbourne", "wifiSignalStrength": 94, "batteryLevel": -1, "batteryCharging": false, "horizontalFlip": false, "verticalFlip": false, "nightVisionMode": "auto", "microphoneOn": true, "microphoneGain": 50, "speakerOn": true, "speakerVolume": 26, "streamingEnabled": true, "videoStream": { "width": 1280, "height": 720 }, "ledEnabled": true, "saveBattery": false, "powerLineFreq": 0, "privacyMode": false, "pirWakeUp": false, "persistentLog": false, "fastLiveView": false, "wifiLI": 0, "buttonIsAvailable": false, "buttonConfig": null, "temperatureIsAvailable": false, "temperature": 0, "humidityIsAvailable": false, "humidity": 0, "wifiSleepPM": 0 }, "modified": "2018-01-01T00:00:00.000000Z", "mac": "AA-00-BB-11-CC-22", "modelNumber": "V-R0008" }, { "accountId": "abcdefgh-1234-ijkl-5678-mnopqrstuvwx", "accessoryId": "ZZZZxxxx-1212-0000-abcd-abc111def222", "name": "Mock Camera Gen2 Wire Free", "nodeConnected": true, "messengerConnected": false, "isConnected": true, "configuration": { "firmwareVersion": "5.2.191", "timeInSync": true, "timeZone": "Australia/Melbourne", "wifiSignalStrength": 85, "batteryLevel": 96, "batteryCharging": false, "horizontalFlip": false, "verticalFlip": false, "nightVisionMode": "auto", "microphoneOn": true, "microphoneGain": 50, "speakerOn": true, "speakerVolume": 26, "streamingEnabled": true, "videoStream": { "width": 1280, "height": 720 }, "ledEnabled": false, "saveBattery": false, "powerLineFreq": 0, "privacyMode": false, "pirWakeUp": true, "persistentLog": false, "fastLiveView": false, "wifiLI": 0, "buttonIsAvailable": false, "buttonConfig": null, "temperatureIsAvailable": false, "temperature": 0, "humidityIsAvailable": false, "humidity": 0, "wifiSleepPM": 0 }, "modified": "2018-01-01T00:00:00.000000Z", "mac": "DD-55-EE-66-FF-77", "modelNumber": "V-R0008" } ] evanjd-python-logi-circle-036454b/tests/fixtures/accessory.json000066400000000000000000000022701423573446500246570ustar00rootroot00000000000000{ "accountId": "abcdefgh-1234-ijkl-5678-mnopqrstuvwx", "accessoryId": "yzabcdef-9012-ghij-3456-klmnopqrstuv", "name": "Mock Camera Gen1", "nodeConnected": true, "messengerConnected": true, "isConnected": true, "configuration": { "firmwareVersion": "4.7.706", "timeInSync": true, "timeZone": "Australia/Melbourne", "wifiSignalStrength": 88, "batteryLevel": 99, "batteryCharging": false, "horizontalFlip": false, "verticalFlip": false, "nightVisionMode": "auto", "microphoneOn": true, "microphoneGain": 100, "speakerOn": true, "speakerVolume": 90, "streamingEnabled": true, "videoStream": { "width": 1920, "height": 1080 }, "ledEnabled": false, "saveBattery": false, "powerLineFreq": 60, "privacyMode": false, "pirWakeUp": false, "persistentLog": false, "fastLiveView": false, "wifiLI": 0, "buttonIsAvailable": false, "buttonConfig": null, "temperatureIsAvailable": false, "temperature": 0, "humidityIsAvailable": false, "humidity": 0, "wifiSleepPM": 0 }, "modified": "2018-01-01T00:00:00.000000Z", "mac": "00-11-a2-44-bc-d5", "modelNumber": "A1533" } evanjd-python-logi-circle-036454b/tests/fixtures/activities.json000066400000000000000000000006411423573446500250300ustar00rootroot00000000000000{ "activities": [ { "activityId": "20180101T071700Z", "playbackDuration": 60000, "startTime": "2018-01-01T07:17:00Z", "endTime": "2018-01-01T07:18:00Z", "relevanceLevel": 0 }, { "activityId": "20180102T071700Z", "playbackDuration": 30000, "startTime": "2018-01-02T07:17:00Z", "endTime": "2018-01-02T07:18:00Z", "relevanceLevel": 1 } ] } evanjd-python-logi-circle-036454b/tests/fixtures/activity.json000066400000000000000000000002471423573446500245220ustar00rootroot00000000000000{ "activityId": "20180101T071700Z", "playbackDuration": 60000, "startTime": "2018-01-01T07:17:00Z", "endTime": "2018-01-01T07:18:00Z", "relevanceLevel": 0 } evanjd-python-logi-circle-036454b/tests/fixtures/auth_code.json000066400000000000000000000002601423573446500246140ustar00rootroot00000000000000{ "access_token": "hello;0VER;9000", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "ABCdefHIJklmNOPqrsTUVwxyZ-n0w1Kn0wMYabcsN3XTt1m3W0NTy0uPLAYw1thM3" } evanjd-python-logi-circle-036454b/tests/fixtures/failed_authorization.json000066400000000000000000000000561423573446500270700ustar00rootroot00000000000000{ "error_description": "Everybody panic!" } evanjd-python-logi-circle-036454b/tests/fixtures/mpd.xml000066400000000000000000000033121423573446500232710ustar00rootroot00000000000000 https://node-mocked-2.video.logi.com:443/api/accessories/mock-camera/ evanjd-python-logi-circle-036454b/tests/fixtures/refresh_token.json000066400000000000000000000002661423573446500255250ustar00rootroot00000000000000{ "access_token": "4SC0RE;AND;7Y3ARS;ag0", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "ABCdefHIJklmNOPqrsTUVwxyZ-n0w1Kn0wMYabcsN3XTt1m3W0NTy0uPLAYw1thM3" } evanjd-python-logi-circle-036454b/tests/fixtures/rtsp_uri.json000066400000000000000000000000711423573446500245300ustar00rootroot00000000000000{ "rtsp_uri": "rtsp://my.cool.video/rtsp/stream.mp4" } evanjd-python-logi-circle-036454b/tests/helpers.py000066400000000000000000000020041423573446500221270ustar00rootroot00000000000000"""Helper functions for Logi Circle API unit tests.""" import os import asyncio def get_fixture_name(filename): """Strips extension from filename when building fixtures dict key.""" return os.path.splitext(filename)[0] def get_fixtures(): """Grabs all fixtures and returns them in a dict.""" fixtures = {} path = os.path.join(os.path.dirname(__file__), 'fixtures') for filename in os.listdir(path): with open(os.path.join(path, filename)) as fdp: fixture = fdp.read() fixtures[get_fixture_name(filename)] = fixture continue return fixtures def async_return(result): """Mock a return from an async function.""" future = asyncio.Future() future.set_result(result) return future class FakeStream(): """Mocks a stream returned by aiohttp""" async def read(self): """Mock read method""" return b'123' def close(self): """Mock close method""" # pylint: disable=no-self-use return True evanjd-python-logi-circle-036454b/tests/test_activity.py000066400000000000000000000134001423573446500233620ustar00rootroot00000000000000# -*- coding: utf-8 -*- """The tests for the Logi API platform.""" import json import os from unittest.mock import MagicMock from datetime import datetime import pytz import aresponses from tests.test_base import LogiUnitTestBase from logi_circle.activity import Activity from logi_circle.const import (API_BASE, ISO8601_FORMAT_MASK, ACCEPT_IMAGE_HEADER, ACCEPT_VIDEO_HEADER, ACTIVITY_IMAGE_ENDPOINT, ACTIVITY_MP4_ENDPOINT, ACTIVITY_DASH_ENDPOINT, ACTIVITY_HLS_ENDPOINT) from .helpers import async_return BASE_ACTIVITY_URL = '/abc123' TEST_TZ = 'Etc/GMT+10' TEMP_FILE = 'temp.mp4' class TestActivity(LogiUnitTestBase): """Unit test for the Activity class.""" def setUp(self): """Set up Activity class with fixtures""" super(TestActivity, self).setUp() self.activity_json = json.loads(self.fixtures['activity']) self.activity = Activity(activity=self.activity_json, logi=self.logi, url=BASE_ACTIVITY_URL, local_tz=pytz.timezone(TEST_TZ)) def tearDown(self): """Remove test Activity instance""" super(TestActivity, self).tearDown() del self.activity_json del self.activity def cleanup(self): """Cleanup any assets downloaded as part of the unit tests.""" super(TestActivity, self).cleanup() if os.path.isfile(TEMP_FILE): os.remove(TEMP_FILE) def test_activity_props(self): """Test props match fixture""" self.assertEqual(self.activity.activity_id, self.activity_json['activityId']) self.assertEqual(self.activity.duration.seconds, self.activity_json['playbackDuration'] / 1000) self.assertEqual(self.activity.start_time_utc, datetime.strptime(self.activity_json['startTime'], ISO8601_FORMAT_MASK)) self.assertEqual(self.activity.end_time_utc, datetime.strptime(self.activity_json['endTime'], ISO8601_FORMAT_MASK)) self.assertEqual(self.activity.start_time, self.activity.start_time_utc.replace( tzinfo=pytz.utc).astimezone(self.activity._local_tz)) self.assertEqual(self.activity.end_time, self.activity.end_time_utc.replace( tzinfo=pytz.utc).astimezone(self.activity._local_tz)) def test_activity_assets(self): """Test props match fixture""" url_base = '%s%s/%s' % (API_BASE, BASE_ACTIVITY_URL, self.activity_json['activityId']) self.assertEqual(self.activity.jpeg_url, url_base + ACTIVITY_IMAGE_ENDPOINT) self.assertEqual(self.activity.mp4_url, url_base + ACTIVITY_MP4_ENDPOINT) self.assertEqual(self.activity.hls_url, url_base + ACTIVITY_HLS_ENDPOINT) self.assertEqual(self.activity.dash_url, url_base + ACTIVITY_DASH_ENDPOINT) my_file = 'myfile.file' self.activity._get_file = MagicMock( return_value=async_return(None)) async def run_test(): # Image await self.activity.download_jpeg(my_file) self.activity._get_file.assert_called_with(url=self.activity.jpeg_url, filename=my_file, accept_header=ACCEPT_IMAGE_HEADER) # Video await self.activity.download_mp4(my_file) self.activity._get_file.assert_called_with(url=self.activity.mp4_url, filename=my_file, accept_header=ACCEPT_VIDEO_HEADER) # Dash await self.activity.download_dash(my_file) self.activity._get_file.assert_called_with(url=self.activity.dash_url, filename=my_file) # HLS await self.activity.download_hls(my_file) self.activity._get_file.assert_called_with(url=self.activity.hls_url, filename=my_file) self.loop.run_until_complete(run_test()) def test_get_file(self): """Test get file utility function.""" self.logi.auth_provider = self.get_authorized_auth_provider() test_file_endpoint = '/coolfile.mp4' async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add('myfile.com', test_file_endpoint, 'get', aresponses.Response(status=200, text='123456', headers={'content-type': 'application/octet-stream'})) arsps.add('myfile.com', test_file_endpoint, 'get', aresponses.Response(status=200, text='789012', headers={'content-type': 'application/octet-stream'})) # Should serve file as bytes object if filename parameter not specified myfile = await self.activity._get_file('https://myfile.com/coolfile.mp4') self.assertEqual(myfile, b'123456') # Should download file await self.activity._get_file('https://myfile.com/coolfile.mp4', TEMP_FILE) with open(TEMP_FILE, 'r') as test_file: data = test_file.read() self.assertEqual(data, "789012") self.loop.run_until_complete(run_test()) evanjd-python-logi-circle-036454b/tests/test_auth.py000066400000000000000000000145021423573446500224730ustar00rootroot00000000000000# -*- coding: utf-8 -*- """The tests for the Logi API platform.""" import json from urllib.parse import urlparse, parse_qs import aresponses from tests.test_base import LogiUnitTestBase from logi_circle.const import AUTH_HOST, TOKEN_ENDPOINT, DEFAULT_SCOPES from logi_circle.exception import NotAuthorized, AuthorizationFailed, SessionInvalidated class TestAuth(LogiUnitTestBase): """Unit test for core Logi class.""" def test_prelogin_state(self): """Validate pre-auth state.""" logi = self.logi async def run_test(): # Should start false, no login performed yet. self.assertFalse(logi.is_connected) # No refresh or access token self.assertIsNone(logi.auth_provider.refresh_token) self.assertIsNone(logi.auth_provider.access_token) # Impossible to refresh since there's no refresh token with self.assertRaises(NotAuthorized): await logi.auth_provider.refresh() self.loop.run_until_complete(run_test()) def test_authorize_url(self): """Test authorize URL generation.""" parsed_url = urlparse(self.logi.authorize_url) parsed_qs = parse_qs(parsed_url.query) self.assertEqual(parsed_qs['response_type'][0], 'code') self.assertEqual(parsed_qs['client_id'][0], self.client_id) self.assertEqual(parsed_qs['client_secret'][0], self.client_secret) self.assertEqual(parsed_qs['redirect_uri'][0], self.redirect_uri) self.assertEqual(parsed_qs['scope'][0], DEFAULT_SCOPES) def test_authorize(self): """Test successful authorization code and refresh token request handling.""" logi = self.logi auth_fixture = self.fixtures['auth_code'] dict_auth_fixture = json.loads(auth_fixture) refresh_fixture = self.fixtures['refresh_token'] dict_refresh_fixture = json.loads(refresh_fixture) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(AUTH_HOST, TOKEN_ENDPOINT, 'post', aresponses.Response(status=200, text=auth_fixture, headers={'content-type': 'application/json'})) arsps.add(AUTH_HOST, TOKEN_ENDPOINT, 'post', aresponses.Response(status=200, text=refresh_fixture, headers={'content-type': 'application/json'})) # Mock authorization, and verify AuthProvider state await logi.authorize('beepboop123') self.assertTrue( logi.is_connected, 'API reports not connected after successful login') self.assertTrue( logi.authorized, 'API reports not authorized after successful login') self.assertIsNotNone( logi.auth_provider.session, 'Session not created after successful login') self.assertEqual(logi.auth_provider.refresh_token, dict_auth_fixture['refresh_token']) self.assertEqual(logi.auth_provider.access_token, dict_auth_fixture['access_token']) # Mock refresh of access token, and verify AuthProvider state await logi.auth_provider.refresh() self.assertTrue( logi.is_connected, 'API reports not connected after token refresh') self.assertTrue( logi.authorized, 'API reports not authorized after token_refresh') self.assertIsNotNone( logi.auth_provider.session, 'Session not created after token_refresh') self.assertEqual(logi.auth_provider.refresh_token, dict_refresh_fixture['refresh_token']) self.assertEqual(logi.auth_provider.access_token, dict_refresh_fixture['access_token']) self.loop.run_until_complete(run_test()) def test_failed_authorization(self): """Test failed authorization.""" logi = self.logi async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(AUTH_HOST, TOKEN_ENDPOINT, 'post', aresponses.Response(status=401, text=self.fixtures['failed_authorization'], headers={'content-type': 'application/json'})) # Mock authorization, and verify AuthProvider state with self.assertRaises(AuthorizationFailed): await logi.authorize('letmein') self.loop.run_until_complete(run_test()) def test_session_invalidation(self): """Test session invalidation.""" logi = self.logi async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(AUTH_HOST, TOKEN_ENDPOINT, 'post', aresponses.Response(status=401, text=self.fixtures['failed_authorization'], headers={'content-type': 'application/json'})) # Mock authorization, and verify AuthProvider state with self.assertRaises(AuthorizationFailed): await logi.authorize('letmein') # Attempt authorisation again with self.assertRaises(SessionInvalidated): await logi.authorize('letmein') self.loop.run_until_complete(run_test()) def test_token_persistence(self): """Test that token is loaded from the cache file implicitly.""" # Write mock token to disk auth_fixture = json.loads(self.fixtures['auth_code']) auth_provider = self.get_authorized_auth_provider() self.assertTrue( auth_provider.authorized, 'API reports not authorized with token loaded from disk') self.assertEqual( auth_provider.refresh_token, auth_fixture['refresh_token'] ) self.assertEqual( auth_provider.access_token, auth_fixture['access_token'] ) evanjd-python-logi-circle-036454b/tests/test_base.py000066400000000000000000000044051423573446500224450ustar00rootroot00000000000000"""Register Logi API with mock aiohttp ClientSession and responses.""" import os import pickle import json import unittest import asyncio from tests.helpers import get_fixtures from logi_circle.const import DEFAULT_SCOPES from logi_circle.auth import AuthProvider CLIENT_ID = 'abcdefghijklmnopqrstuvwxyz' CLIENT_SECRET = 'correct_horse_battery_staple' REDIRECT_URI = 'https://my.groovy.app/' API_KEY = 'ZYXWVUTSRQPONMLKJIHGFEDCBA' CACHE_FILE = os.path.join(os.path.dirname(__file__), 'cache.db') FIXTURES = get_fixtures() class LogiUnitTestBase(unittest.TestCase): """Base Logi Circle test class.""" def setUp(self): """Setup unit test, create event loop.""" from logi_circle import LogiCircle self.logi = LogiCircle(client_id=CLIENT_ID, client_secret=CLIENT_SECRET, redirect_uri=REDIRECT_URI, cache_file=CACHE_FILE, api_key=API_KEY) self.fixtures = FIXTURES self.client_id = CLIENT_ID self.client_secret = CLIENT_SECRET self.redirect_uri = REDIRECT_URI self.cache_file = CACHE_FILE self.loop = asyncio.new_event_loop() def get_authorized_auth_provider(self): """Returns a pre-authorized AuthProvider instance""" auth_fixture = json.loads(self.fixtures['auth_code']) token = {} token[self.client_id] = auth_fixture with open(self.cache_file, 'wb') as pickle_db: pickle.dump(token, pickle_db) return AuthProvider(client_id=self.client_id, client_secret=self.client_secret, redirect_uri=self.redirect_uri, scopes=DEFAULT_SCOPES, cache_file=self.cache_file, logi_base=self.logi) def cleanup(self): """Cleanup any data created from the tests.""" async def close_session(): await self.logi.close() self.loop.run_until_complete(close_session()) self.loop.close() self.logi = None if os.path.isfile(CACHE_FILE): os.remove(CACHE_FILE) def tearDown(self): """Stop everything started.""" self.cleanup() evanjd-python-logi-circle-036454b/tests/test_camera.py000066400000000000000000000355601423573446500227710ustar00rootroot00000000000000# -*- coding: utf-8 -*- """The tests for the Logi API platform.""" from datetime import datetime import json import aresponses from aiohttp.client_exceptions import ClientResponseError from tests.test_base import LogiUnitTestBase from logi_circle.camera import Camera from logi_circle.activity import Activity from logi_circle.const import (API_HOST, ACCESSORIES_ENDPOINT, ACTIVITIES_ENDPOINT, ACTIVITY_API_LIMIT, CONFIG_ENDPOINT, GEN_1_MODEL, GEN_2_MODEL, GEN_1_MOUNT, GEN_2_MOUNT_WIRE, GEN_2_MOUNT_WIREFREE, MOUNT_UNKNOWN) class TestCamera(LogiUnitTestBase): """Unit test for the Camera class.""" def setUp(self): """Set up Camera class with fixtures""" super(TestCamera, self).setUp() self.gen1_fixture = json.loads(self.fixtures['accessories'])[0] self.test_camera = Camera(self.logi, self.gen1_fixture) self.logi.auth_provider = self.get_authorized_auth_provider() def tearDown(self): """Remove test Camera instance""" super(TestCamera, self).tearDown() del self.gen1_fixture del self.test_camera def test_camera_props(self): """Camera props should match fixtures""" gen1_fixture = self.gen1_fixture # Mandatory props self.assertEqual(self.test_camera.id, gen1_fixture['accessoryId']) self.assertEqual(self.test_camera.name, gen1_fixture['name']) self.assertEqual(self.test_camera.mac_address, gen1_fixture['mac']) gen1_fixture['cfg'] = gen1_fixture['configuration'] # Optional props self.assertEqual(self.test_camera.model, gen1_fixture['modelNumber']) self.assertEqual(self.test_camera.mount, GEN_1_MOUNT) self.assertEqual(self.test_camera.connected, gen1_fixture['isConnected']) self.assertEqual(self.test_camera.streaming, gen1_fixture['cfg']['streamingEnabled']) self.assertEqual(self.test_camera.timezone, gen1_fixture['cfg']['timeZone']) self.assertEqual(self.test_camera.battery_level, gen1_fixture['cfg']['batteryLevel']) self.assertEqual(self.test_camera.charging, gen1_fixture['cfg']['batteryCharging']) self.assertEqual(self.test_camera.battery_saving, gen1_fixture['cfg']['saveBattery']) self.assertEqual(self.test_camera.signal_strength_percentage, gen1_fixture['cfg']['wifiSignalStrength']) self.assertEqual(self.test_camera.firmware, gen1_fixture['cfg']['firmwareVersion']) self.assertEqual(self.test_camera.microphone, gen1_fixture['cfg']['microphoneOn']) self.assertEqual(self.test_camera.microphone_gain, gen1_fixture['cfg']['microphoneGain']) self.assertEqual(self.test_camera.speaker, gen1_fixture['cfg']['speakerOn']) self.assertEqual(self.test_camera.speaker_volume, gen1_fixture['cfg']['speakerVolume']) self.assertEqual(self.test_camera.led, gen1_fixture['cfg']['ledEnabled']) self.assertEqual(self.test_camera.recording, not gen1_fixture['cfg']['privacyMode']) def test_missing_mandatory_props(self): """Camera should raise if mandatory props missing""" incomplete_camera = { "name": "Incomplete cam", "accessoryId": "123", "configuration": { "stuff": "123" } } with self.assertRaises(KeyError): Camera(self.logi, incomplete_camera) def test_missing_optional_props(self): """Camera should not raise if optional props missing""" incomplete_camera = { "name": "Incomplete cam", "accessoryId": "123", "mac": "ABC", "configuration": { "modelNumber": "1234", "batteryLevel": 1 }, "isConnected": False } camera = Camera(self.logi, incomplete_camera) self.assertEqual(camera.name, "Incomplete cam") self.assertEqual(camera.id, "123") self.assertEqual(camera.mac_address, "ABC") # Optional int/string props not passed to Camera should be None self.assertIsNone(camera.charging) self.assertIsNone(camera.battery_saving) self.assertIsNone(camera.signal_strength_percentage) self.assertIsNone(camera.signal_strength_category) self.assertIsNone(camera.firmware) self.assertIsNone(camera.microphone_gain) self.assertIsNone(camera.speaker_volume) # Optional bools should be neutral self.assertFalse(camera.streaming) self.assertFalse(camera.microphone) self.assertFalse(camera.speaker) self.assertFalse(camera.led) self.assertTrue(camera.recording) # Timezone should fallback to UTC self.assertEqual(camera.timezone, "UTC") # Mount should be unknown self.assertEqual(camera.mount, MOUNT_UNKNOWN) def test_camera_mount_prop(self): """Test mount property correctly infers type from other props""" gen2_wired_fixture = json.loads(self.fixtures['accessories'])[1] gen2_wirefree_fixture = json.loads(self.fixtures['accessories'])[2] gen1_camera = Camera(self.logi, self.gen1_fixture) gen2_wired_camera = Camera(self.logi, gen2_wired_fixture) gen2_wirefree_camera = Camera(self.logi, gen2_wirefree_fixture) # Test 1st gen self.assertEqual(gen1_camera.mount, GEN_1_MOUNT) self.assertEqual(gen1_camera.model, GEN_1_MODEL) # Test 2nd gen wired camera (should have no battery) self.assertEqual(gen2_wired_camera.mount, GEN_2_MOUNT_WIRE) self.assertEqual(gen2_wired_camera.battery_level, -1) self.assertEqual(gen2_wired_camera.model, GEN_2_MODEL) # Test 2nd gen wire-free camera (should have battery) self.assertEqual(gen2_wirefree_camera.mount, GEN_2_MOUNT_WIREFREE) self.assertNotEqual(gen2_wirefree_camera.battery_level, -1) self.assertEqual(gen2_wirefree_camera.model, GEN_2_MODEL) def test_signal_strength_categories(self): """Test friendly signal strength categorisation""" self.test_camera._attrs['signal_strength_percentage'] = 99 self.assertEqual(self.test_camera.signal_strength_category, 'Excellent') self.test_camera._attrs['signal_strength_percentage'] = 79 self.assertEqual(self.test_camera.signal_strength_category, 'Good') self.test_camera._attrs['signal_strength_percentage'] = 59 self.assertEqual(self.test_camera.signal_strength_category, 'Fair') self.test_camera._attrs['signal_strength_percentage'] = 39 self.assertEqual(self.test_camera.signal_strength_category, 'Poor') self.test_camera._attrs['signal_strength_percentage'] = 19 self.assertEqual(self.test_camera.signal_strength_category, 'Bad') self.test_camera._attrs['signal_strength_percentage'] = None self.assertIsNone(self.test_camera.signal_strength_category) def test_update(self): """Test polling for changes in camera properties""" endpoint = '%s/%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'get', aresponses.Response(status=200, text=self.fixtures['accessory'], headers={'content-type': 'application/json'})) # Props should match fixture self.assertEqual(self.test_camera.battery_level, 100) self.assertEqual(self.test_camera.signal_strength_percentage, 74) await self.test_camera.update() # Props should have changed. self.assertEqual(self.test_camera.battery_level, 99) self.assertEqual(self.test_camera.signal_strength_percentage, 88) self.loop.run_until_complete(run_test()) def test_set_config_valid(self): """Test updating configuration for camera""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, CONFIG_ENDPOINT) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'put', aresponses.Response(status=200)) arsps.add(API_HOST, endpoint, 'put', aresponses.Response(status=200)) arsps.add(API_HOST, endpoint, 'put', aresponses.Response(status=200)) # Set streaming enabled property self.assertEqual(self.test_camera.streaming, True) # Prop should change when config successfully updated await self.test_camera.set_config('streaming', False) self.assertEqual(self.test_camera.streaming, False) # Disable recording self.assertEqual(self.test_camera.recording, True) # Prop should change when config successfully updated await self.test_camera.set_config('recording_disabled', True) self.assertEqual(self.test_camera.recording, False) # Enable recording # Prop should change when config successfully updated await self.test_camera.set_config('recording_disabled', False) self.assertEqual(self.test_camera.recording, True) self.loop.run_until_complete(run_test()) def test_set_config_error(self): """Test updating configuration for camera""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, CONFIG_ENDPOINT) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'put', aresponses.Response(status=500)) self.assertEqual(self.test_camera.streaming, True) # Prop should not update if PUT request fails with self.assertRaises(ClientResponseError): await self.test_camera.set_config('streaming', False) self.assertEqual(self.test_camera.streaming, True) self.loop.run_until_complete(run_test()) def test_set_config_invalid(self): """Test updating invalid configuration prop for camera""" async def run_test(): # Read-only prop with self.assertRaises(NameError): await self.test_camera.set_config('firmware', 'Windows 95') # Non-existent prop with self.assertRaises(NameError): await self.test_camera.set_config('nonsense', 123) self.loop.run_until_complete(run_test()) def test_get_last_activity(self): """Test get last activity property""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, ACTIVITIES_ENDPOINT) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'post', aresponses.Response(status=200, text=self.fixtures['activities'], headers={'content-type': 'application/json'})) # Props should match fixture self.assertIsInstance(await self.test_camera.get_last_activity(), Activity) self.loop.run_until_complete(run_test()) def test_no_last_activity(self): """Test last_activity property when no activities reported from server""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, ACTIVITIES_ENDPOINT) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'post', aresponses.Response(status=200, text='{ "activities" : [] }', headers={'content-type': 'application/json'})) # Props should match fixture self.assertIsNone(await self.test_camera.get_last_activity()) self.loop.run_until_complete(run_test()) def test_query_activity_history(self): """Test get last activity property""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, ACTIVITIES_ENDPOINT) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'post', aresponses.Response(status=200, text=self.fixtures['activities'], headers={'content-type': 'application/json'})) activities = await self.test_camera.query_activity_history( property_filter='prop_filter', date_filter=datetime.now(), date_operator='>', limit=100 ) self.assertIsInstance(activities, list) for activity in activities: self.assertIsInstance(activity, Activity) self.loop.run_until_complete(run_test()) def test_activity_api_limits(self): """Test requesting more activities then API permits""" async def run_test(): with self.assertRaises(ValueError): await self.test_camera.query_activity_history(limit=ACTIVITY_API_LIMIT + 1) self.loop.run_until_complete(run_test()) def test_activity_reject_bad_type(self): """Test rejection of date filter if it's not a datetime object""" async def run_test(): with self.assertRaises(TypeError): await self.test_camera.query_activity_history(date_filter='2018-01-01') self.loop.run_until_complete(run_test()) def test_slugify_safe_name(self): """Returns camera ID if camera name string empty after slugification.""" valid_name = 'My camera' invalid_name = '!@#$%^&*()' # Test valid name self.test_camera._attrs['name'] = valid_name self.assertEqual(self.test_camera.slugify_safe_name, valid_name) # Test invalid name self.test_camera._attrs['name'] = invalid_name self.assertEqual(self.test_camera.slugify_safe_name, self.test_camera.id) # Test whitespace self.test_camera._attrs['name'] = ' ' self.assertEqual(self.test_camera.slugify_safe_name, self.test_camera.id) # Test empty name self.test_camera._attrs['name'] = '' self.assertEqual(self.test_camera.slugify_safe_name, self.test_camera.id) evanjd-python-logi-circle-036454b/tests/test_init.py000066400000000000000000000267261423573446500225100ustar00rootroot00000000000000# -*- coding: utf-8 -*- """The tests for the Logi API platform.""" from unittest.mock import patch import aresponses import aiohttp from tests.test_base import LogiUnitTestBase from logi_circle import LogiCircle from logi_circle.const import AUTH_HOST, TOKEN_ENDPOINT, API_HOST, ACCESSORIES_ENDPOINT, DEFAULT_FFMPEG_BIN from logi_circle.exception import NotAuthorized, AuthorizationFailed, SessionInvalidated class TestAuth(LogiUnitTestBase): """Unit test for core Logi class.""" def test_fetch_no_auth(self): """Fetch should return NotAuthorized if no access token is present""" logi = self.logi async def run_test(): with self.assertRaises(NotAuthorized): await logi._fetch(url='/api') self.loop.run_until_complete(run_test()) def test_fetch_with_auth(self): """Fetch should process request if user is authorized""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=200, text='{ "abc" : 123 }', headers={'content-type': 'application/json'})) arsps.add(API_HOST, '/api', 'post', aresponses.Response(status=200, text='{ "foo" : "bar" }', headers={'content-type': 'application/json'})) arsps.add(API_HOST, '/api', 'put', aresponses.Response(status=200, text='{ "success" : true }', headers={'content-type': 'application/json'})) arsps.add(API_HOST, '/api', 'delete', aresponses.Response(status=200, text='{ "success" : true }', headers={'content-type': 'application/json'})) get_result = await logi._fetch(url='/api') post_result = await logi._fetch(url='/api', method='POST') put_result = await logi._fetch(url='/api', method='PUT') delete_result = await logi._fetch(url='/api', method='DELETE') self.assertEqual(get_result['abc'], 123) self.assertEqual(post_result['foo'], 'bar') self.assertTrue(put_result['success']) self.assertTrue(delete_result['success']) self.loop.run_until_complete(run_test()) def test_fetch_token_refresh(self): """Fetch should refresh token if it expires""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() auth_fixture = self.fixtures['auth_code'] async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=401)) arsps.add(AUTH_HOST, TOKEN_ENDPOINT, 'post', aresponses.Response(status=200, text=auth_fixture, headers={'content-type': 'application/json'})) arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=200, text='{ "foo" : "bar" }', headers={'content-type': 'application/json'})) get_result = await logi._fetch(url='/api') self.assertEqual(get_result['foo'], 'bar') self.loop.run_until_complete(run_test()) def test_fetch_guard_infinite_loop(self): """Fetch should bail out if request 401s immediately after token refresh""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() auth_fixture = self.fixtures['auth_code'] async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=401)) arsps.add(AUTH_HOST, TOKEN_ENDPOINT, 'post', aresponses.Response(status=200, text=auth_fixture, headers={'content-type': 'application/json'})) arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=401)) with self.assertRaises(AuthorizationFailed): await logi._fetch(url='/api') self.loop.run_until_complete(run_test()) def test_fetch_guard_invalid_session(self): """Fetch should bail out if session was invalidated""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=401)) arsps.add(AUTH_HOST, TOKEN_ENDPOINT, 'post', aresponses.Response(status=429, text='too many requests')) with self.assertRaises(AuthorizationFailed): await logi._fetch(url='/api') with self.assertRaises(SessionInvalidated): await logi._fetch(url='/api') self.loop.run_until_complete(run_test()) def test_fetch_raw(self): """Fetch should return ClientResponse object if raw parameter set""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=200)) raw = await logi._fetch(url='/api', raw=True) self.assertIsInstance(raw, aiohttp.ClientResponse) raw.close() self.loop.run_until_complete(run_test()) def test_fetch_invalid_method(self): """Fetch should raise ValueError for unsupported methods""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() async def run_test(): with self.assertRaises(ValueError): await logi._fetch(url='/api', method='TEAPOT') self.loop.run_until_complete(run_test()) def test_fetch_follow_redirect(self): """Fetch should follow redirects""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() expected_response = 'you made it!' fetch_method = 'GET' fetch_headers = {'beep': 'boop'} fetch_params = {'janeway': 'pie'} async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, '/api', 'get', aresponses.Response(status=301, headers={'location': 'https://overhere.com/resource'})) arsps.add('overhere.com', '/resource', 'get', aresponses.Response(status=200, text=expected_response, headers={'content-type': 'text/plain'})) with patch.object(logi, '_fetch', wraps=logi._fetch) as mock_fetch: test_req = await logi._fetch(url='/api', method=fetch_method, headers=fetch_headers, params=fetch_params) # Should return response from redirect self.assertEqual(test_req, expected_response.encode()) # Should call fetch twice (initial redirect, resouce URL) self.assertEqual(mock_fetch.call_count, 2) # Params should be the same for redirected call (except URL) for call in mock_fetch.call_args_list: self.assertEqual(call[1].get('method'), fetch_method) self.assertEqual(call[1].get('headers'), fetch_headers) self.assertEqual(call[1].get('params'), fetch_params) self.loop.run_until_complete(run_test()) def test_get_cameras(self): """Camera property should return 3 cameras""" logi = self.logi logi.auth_provider = self.get_authorized_auth_provider() async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, ACCESSORIES_ENDPOINT, 'get', aresponses.Response(status=200, text=self.fixtures['accessories'], headers={'content-type': 'application/json'})) cameras = await logi.cameras self.assertEqual(len(cameras), 3) self.loop.run_until_complete(run_test()) def test_ffmpeg_valid(self): """Resolved ffmpeg path should be set if ffmpeg binary detected""" logi = self.logi # Patch subprocess so that it always returns nothing (implicit success) with patch('subprocess.check_call'): # Default value should be used if ffmpeg unset. self.assertEqual(logi._get_ffmpeg_path(), DEFAULT_FFMPEG_BIN) # Input ffmpeg_bin should be respected if valid self.assertEqual(logi._get_ffmpeg_path('groovy_ffmpeg'), 'groovy_ffmpeg') # Test property on class. logi_default_ffmpeg = LogiCircle(client_id="bud", client_secret="wei", api_key="serrrrr", redirect_uri="https://www.youtube.com/watch?v=dQw4w9WgXcQ") self.assertEqual(logi_default_ffmpeg.ffmpeg_path, DEFAULT_FFMPEG_BIN) logi_custom_ffmpeg = LogiCircle(client_id="bud", client_secret="wei", api_key="serrrrr", redirect_uri="https://www.youtube.com/watch?v=dQw4w9WgXcQ", ffmpeg_path="super_cool_ffmpeg") self.assertEqual(logi_custom_ffmpeg.ffmpeg_path, "super_cool_ffmpeg") def test_ffmpeg_invalid(self): """Resolved ffmpeg path should None if ffmpeg missing""" logi = self.logi # Test function ffmpeg_path = logi._get_ffmpeg_path('this-is-not-ffmpeg') self.assertIsNone(ffmpeg_path) # Test property on class. logi_bad_ffmpeg = LogiCircle(client_id="bud", client_secret="wei", api_key="serrrrr", redirect_uri="https://www.youtube.com/watch?v=dQw4w9WgXcQ", ffmpeg_path="this-still-is-not-ffmpeg") self.assertIsNone(logi_bad_ffmpeg.ffmpeg_path) evanjd-python-logi-circle-036454b/tests/test_live_stream.py000066400000000000000000000151171423573446500240470ustar00rootroot00000000000000"""The tests for the Logi API platform.""" import json import os from unittest.mock import MagicMock, patch import aresponses from tests.test_camera import TestCamera from logi_circle.const import (API_HOST, ACCESSORIES_ENDPOINT, LIVE_RTSP_ENDPOINT, LIVE_IMAGE_ENDPOINT, ACCEPT_IMAGE_HEADER, DEFAULT_IMAGE_QUALITY, DEFAULT_IMAGE_REFRESH) from .helpers import async_return, FakeStream TEMP_IMAGE = 'temp.jpg' class TestLiveStream(TestCamera): """Unit test for the LiveStream class.""" def cleanup(self): """Cleanup any assets downloaded as part of the unit tests.""" super(TestLiveStream, self).cleanup() if os.path.isfile(TEMP_IMAGE): os.remove(TEMP_IMAGE) def test_get_image(self): """Test response handling for get_image method""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, LIVE_IMAGE_ENDPOINT) async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'get', aresponses.Response(status=200, text="Look ma I'm an image", headers={'content-type': 'image/jpeg'})) arsps.add(API_HOST, endpoint, 'get', aresponses.Response(status=200, text="What a purdy picture", headers={'content-type': 'image/jpeg'})) # I am of course cheating here by returning text instead of an image # for image requests. get_image trusts that the Logi API will always # return a valid image for these requests so I don't want to overcomplicate # the test. # Test return of image img = await self.test_camera.live_stream.download_jpeg() self.assertEqual(img, b"Look ma I'm an image") # Test download of image to disk await self.test_camera.live_stream.download_jpeg(filename=TEMP_IMAGE) with open(TEMP_IMAGE, 'r') as test_file: data = test_file.read() self.assertEqual(data, "What a purdy picture") self.loop.run_until_complete(run_test()) def test_get_image_params(self): """Test handling of quality and refresh parameters""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, LIVE_IMAGE_ENDPOINT) # Spy on fetch requests self.logi._fetch = MagicMock( return_value=async_return(FakeStream())) async def run_test(): # Test defaults await self.test_camera.live_stream.download_jpeg() self.logi._fetch.assert_called_with( headers=ACCEPT_IMAGE_HEADER, params={'quality': DEFAULT_IMAGE_QUALITY, 'refresh': str(DEFAULT_IMAGE_REFRESH).lower()}, raw=True, url=endpoint) # Test quality await self.test_camera.live_stream.download_jpeg(quality=55) self.logi._fetch.assert_called_with( headers=ACCEPT_IMAGE_HEADER, params={'quality': 55, 'refresh': str(DEFAULT_IMAGE_REFRESH).lower()}, raw=True, url=endpoint) await self.test_camera.live_stream.download_jpeg(refresh=True) self.logi._fetch.assert_called_with( headers=ACCEPT_IMAGE_HEADER, params={'quality': DEFAULT_IMAGE_QUALITY, 'refresh': 'true'}, raw=True, url=endpoint) self.loop.run_until_complete(run_test()) def test_get_rtsp_url(self): """Test retrieval of RTSP URL""" endpoint = '%s/%s%s' % (ACCESSORIES_ENDPOINT, self.test_camera.id, LIVE_RTSP_ENDPOINT) expected_rtsp_uri = json.loads(self.fixtures['rtsp_uri'])['rtsp_uri'].replace('rtsp:', 'rtsps:') async def run_test(): async with aresponses.ResponsesMockServer(loop=self.loop) as arsps: arsps.add(API_HOST, endpoint, 'get', aresponses.Response(status=200, text=self.fixtures['rtsp_uri'], headers={'content-type': 'application/json'})) rtsp_uri = await self.test_camera.live_stream.get_rtsp_url() self.assertEqual(expected_rtsp_uri, rtsp_uri) self.loop.run_until_complete(run_test()) def test_get_download_rtsp(self): """Test download of RTSP stream""" # pylint: disable=invalid-name TEST_RTSP_URL = 'rtsps://woop.woop.com/abc123' TEST_DURATION = 915 TEST_FILENAME = 'test.mp4' TEST_FFMPEG_BIN = '/mock/ffmpeg' # pylint: enable=invalid-name self.test_camera.live_stream.get_rtsp_url = MagicMock( return_value=async_return(TEST_RTSP_URL)) async def run_test(): with patch('subprocess.check_call') as mock_subprocess: self.logi.ffmpeg_path = TEST_FFMPEG_BIN await self.test_camera.live_stream.download_rtsp(duration=TEST_DURATION, filename=TEST_FILENAME, blocking=True) # Check ffmpeg bin is first argument self.assertEqual(mock_subprocess.call_args[0][0][0], TEST_FFMPEG_BIN) # Test RTSP URI is somewhere in the call self.assertIn(TEST_RTSP_URL, mock_subprocess.call_args[0][0]) # Test duration is somewhere in the call self.assertIn(str(TEST_DURATION), mock_subprocess.call_args[0][0]) # Test filename is somewhere in the call self.assertIn(TEST_FILENAME, mock_subprocess.call_args[0][0]) # Download should raise if ffmpeg not detected self.logi.ffmpeg_path = None with self.assertRaises(RuntimeError): await self.test_camera.live_stream.download_rtsp(duration=TEST_DURATION, filename=TEST_FILENAME, blocking=True) self.loop.run_until_complete(run_test()) evanjd-python-logi-circle-036454b/tox.ini000066400000000000000000000011041423573446500202640ustar00rootroot00000000000000[tox] envlist = py36, py37, lint skip_missing_interpreters = True [testenv] setenv = PYTHONPATH = {toxinidir}:{toxinidir}/logi_circle whitelist_externals = /usr/bin/env install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = py.test --basetemp={envtmpdir} --cov=logi_circle --cov-report term-missing deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements_tests.txt [testenv:lint] ignore_errors = True commands = pylint logi_circle flake8 logi_circle pylint tests flake8 tests [flake8] max-line-length = 120