pax_global_header00006660000000000000000000000064146336374070014527gustar00rootroot0000000000000052 comment=a486a4c8f801d9c5a034ba14e6a555ccc8bf3761 snjoetw-py-canary-a486a4c/000077500000000000000000000000001463363740700155355ustar00rootroot00000000000000snjoetw-py-canary-a486a4c/.flake8000066400000000000000000000001451463363740700167100ustar00rootroot00000000000000[flake8] max-line-length = 88 select = B,C,E,F,W,T4,B9 # ignore = E203, E266, E501, W503, F403, F401 snjoetw-py-canary-a486a4c/.gitignore000066400000000000000000000022131463363740700175230ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # 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/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # 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 # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .idea snjoetw-py-canary-a486a4c/.pre-commit-config.yaml000066400000000000000000000020771463363740700220240ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.2.0 hooks: - id: check-yaml - id: check-ast - id: check-merge-conflict - id: debug-statements - id: detect-private-key - id: end-of-file-fixer - id: requirements-txt-fixer - id: trailing-whitespace exclude: ^tests/fixtures/ - repo: https://github.com/asottile/pyupgrade rev: v2.32.1 hooks: - id: pyupgrade args: [--py36-plus] - repo: https://github.com/psf/black rev: 22.3.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 rev: 4.0.1 hooks: - id: flake8 - repo: local hooks: # Run mypy through our wrapper script in order to get the possible # pyenv and/or virtualenv activated; it may not have been e.g. if # committing from a GUI tool that was not launched from an activated # shell. - id: pylint name: pylint entry: script/run-in-env.sh pylint -j 0 language: script types: [python] files: ^canary/.+\.py$ snjoetw-py-canary-a486a4c/.travis.yml000066400000000000000000000004321463363740700176450ustar00rootroot00000000000000sudo: false language: python matrix: fast_finish: true include: - python: "3.5" env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 - python: "3.6" env: TOXENV=lint install: pip install -U tox coveralls script: tox cache: pip after_success: coverallssnjoetw-py-canary-a486a4c/LICENSE000066400000000000000000000020471463363740700165450ustar00rootroot00000000000000MIT License Copyright (c) 2017 Joe Lu 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. snjoetw-py-canary-a486a4c/MANIFEST000066400000000000000000000001571463363740700166710ustar00rootroot00000000000000# file GENERATED by distutils, do NOT edit setup.py canary/__init__.py canary/api.py canary/live_stream_api.py snjoetw-py-canary-a486a4c/README.md000066400000000000000000000004371463363740700170200ustar00rootroot00000000000000# py-canary [![Build Status](https://travis-ci.org/snjoetw/py-canary.svg?branch=master)](https://travis-ci.org/snjoetw/py-canary) Python API for Canary Security Camera. This is used in [Home Assistant](https://home-assistant.io) but should be generic enough that can be used elsewhere. snjoetw-py-canary-a486a4c/canary/000077500000000000000000000000001463363740700170125ustar00rootroot00000000000000snjoetw-py-canary-a486a4c/canary/__init__.py000066400000000000000000000000371463363740700211230ustar00rootroot00000000000000"""Init file for py-canary.""" snjoetw-py-canary-a486a4c/canary/api.py000066400000000000000000000143141463363740700201400ustar00rootroot00000000000000import logging from datetime import datetime, timedelta, date import requests from canary.const import ( URL_LOGIN_API, ATTR_USERNAME, ATTR_PASSWORD, ATTR_CLIENT_ID, ATTR_VALUE_CLIENT_ID, ATTR_VALUE_CLIENT_SECRET, ATTR_CLIENT_SECRET, ATTR_GRANT_TYPE, ATTR_VALUE_SCOPE, ATTR_VALUE_GRANT_TYPE, ATTR_SCOPE, HEADER_USER_AGENT, HEADER_VALUE_USER_AGENT, ATTR_TOKEN, URL_MODES_API, ATTR_OBJECTS, URL_LOCATIONS_API, URL_LOCATION_API, DATETIME_FORMAT, URL_READINGS_API, HEADER_AUTHORIZATION, HEADER_VALUE_AUTHORIZATION, DATETIME_MS_FORMAT, DATETIME_MS_FORMAT_NOTZ, ) from canary.live_stream_api import LiveStreamApi, LiveStreamSession from canary.model import Mode, Location, Reading _LOGGER = logging.getLogger(__name__) class Api: def __init__(self, username, password, timeout=10, token=None): self._username = username self._password = password self._timeout = timeout self._token = token self._modes_by_name = {} self._live_stream_api = None if self._token is None: self.login() else: self._modes_by_name = {mode.name: mode for mode in self.get_modes()} def login(self): response = requests.post( URL_LOGIN_API, { ATTR_USERNAME: self._username, ATTR_PASSWORD: self._password, ATTR_CLIENT_ID: ATTR_VALUE_CLIENT_ID, ATTR_CLIENT_SECRET: ATTR_VALUE_CLIENT_SECRET, ATTR_GRANT_TYPE: ATTR_VALUE_GRANT_TYPE, ATTR_SCOPE: ATTR_VALUE_SCOPE, }, timeout=self._timeout, headers={HEADER_USER_AGENT: HEADER_VALUE_USER_AGENT}, ) _LOGGER.debug( "Received login response: %d, %s", response.status_code, response.content ) response.raise_for_status() self._token = response.json()[ATTR_TOKEN] self._modes_by_name = {mode.name: mode for mode in self.get_modes()} def get_modes(self): json = self._call_api("get", URL_MODES_API).json()[ATTR_OBJECTS] return [Mode(data) for data in json] def get_locations(self): json = self._call_api("get", URL_LOCATIONS_API).json()[ATTR_OBJECTS] return [Location(data, self._modes_by_name) for data in json] def get_location(self, location_id): url = f"{URL_LOCATION_API}{location_id}/" json = self._call_api("get", url).json() return Location(json, self._modes_by_name) def set_location_mode(self, location_id, mode_name, is_private=False): url = f"{URL_LOCATION_API}{location_id}/" self._call_api( "patch", url, json={ "mode": self._modes_by_name[mode_name].resource_uri, "is_private": is_private, }, ) def get_readings(self, device_id): end = datetime.utcnow() start = end - timedelta(minutes=5) created_range = ( f"{start.strftime(DATETIME_FORMAT)},{end.strftime(DATETIME_FORMAT)}" ) json = self._call_api( "get", URL_READINGS_API, { "created__range": created_range, "device": device_id, # "resolution": "10m", "limit": 0, }, ).json()[ATTR_OBJECTS] return [Reading(data) for data in json] def get_latest_readings(self, device_id): readings = self.get_readings(device_id) readings_by_type = {} for reading in readings: if reading.sensor_type not in readings_by_type: readings_by_type[reading.sensor_type] = reading return readings_by_type.values() def get_entries(self, location_id): if self._live_stream_api is None: self._live_stream_api = LiveStreamApi( self._username, self._password, self._timeout, self._token ) utc_beginning, utc_ending = self._get_todays_date_range_utc() return self._live_stream_api.get_entries( location_id, { "end": f"{utc_ending.strftime(DATETIME_MS_FORMAT)[:-3]}Z", "start": f"{utc_beginning.strftime(DATETIME_MS_FORMAT)[:-3]}Z", }, ) def get_latest_entries(self, location_id): entries = self.get_entries(location_id) entries_by_device_uuid = {} for entry in entries: for device_uuid in entry.device_uuids: if device_uuid not in entries_by_device_uuid: entries_by_device_uuid[device_uuid] = entry return entries_by_device_uuid.values() def get_live_stream_session(self, device): if self._live_stream_api is None: self._live_stream_api = LiveStreamApi( self._username, self._password, self._timeout, self._token ) return LiveStreamSession(self._live_stream_api, device) def _get_todays_date_range_utc(self): utc_offset = datetime.utcnow() - datetime.now() today = date.today() beginning = today.strftime("%Y-%m-%d 00:00:00.0001") utc_beginning = ( datetime.strptime(beginning, DATETIME_MS_FORMAT_NOTZ) + utc_offset ) ending = today.strftime("%Y-%m-%d 23:59:59.99999") utc_ending = datetime.strptime(ending, DATETIME_MS_FORMAT_NOTZ) + utc_offset return utc_beginning, utc_ending def _call_api(self, method, url, params=None, **kwargs): _LOGGER.debug("About to call %s with %s", url, params) response = requests.request( method, url, params=params, timeout=self._timeout, headers=self._api_headers(), **kwargs, ) _LOGGER.debug( "Received API response: %d, %s", response.status_code, response.content ) response.raise_for_status() return response def _api_headers(self): return { HEADER_USER_AGENT: HEADER_VALUE_USER_AGENT, HEADER_AUTHORIZATION: f"{HEADER_VALUE_AUTHORIZATION} {self._token}", } @property def auth_token(self): # -> str | None: return self._token snjoetw-py-canary-a486a4c/canary/const.py000066400000000000000000000033331463363740700205140ustar00rootroot00000000000000HEADER_AUTHORIZATION = "Authorization" HEADER_USER_AGENT = "User-Agent" HEADER_VALUE_AUTHORIZATION = "Bearer" HEADER_VALUE_USER_AGENT = "Canary/5.9.0 (iPhone; iOS 15.4.1; Scale/3.00)" URL_LOGIN_PAGE = "https://my.canary.is/manifest.json" URL_WATCHLIVE_BASE = "https://my.canary.is/api/watchlive/" # URL_LOGIN_API = "https://api-prod.canaryis.com/o/access_token/" URL_ENTRIES_API = "https://my.canary.is/api/entries/tl2/" URL_LOGIN_API = "https://api.canaryis.com/o/access_token/" URL_LOCATIONS_API = "https://api.canaryis.com/v1/locations/" URL_LOCATION_API = "https://api.canaryis.com/v1/locations/" URL_MODES_API = "https://api.canaryis.com/v1/modes/" URL_READINGS_API = "https://api.canaryis.com/v1/readings/" ATTR_USERNAME = "username" ATTR_PASSWORD = "password" ATTR_CLIENT_ID = "client_id" ATTR_CLIENT_SECRET = "client_secret" ATTR_GRANT_TYPE = "grant_type" ATTR_SCOPE = "scope" ATTR_TOKEN = "access_token" ATTR_SESSION_ID = "sessionId" ATTR_DEVICE_UUID = "deviceUUID" ATTR_OBJECTS = "objects" ATTR_VALUE_CLIENT_ID = "a183323eab0544d83808" ATTR_VALUE_CLIENT_SECRET = "ba883a083b2d45fa7c6a6567ca7a01e473c3a269" ATTR_VALUE_GRANT_TYPE = "password" ATTR_VALUE_SCOPE = "write" DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" DATETIME_FORMAT_NOTZ = "%Y-%m-%dT%H:%M:%S" DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" DATETIME_MS_FORMAT_NOTZ = "%Y-%m-%d %H:%M:%S.%f" LOCATION_MODE_HOME = "home" LOCATION_MODE_AWAY = "away" LOCATION_MODE_NIGHT = "night" LOCATION_STATE_ARMED = "armed" LOCATION_STATE_DISARMED = "disarmed" LOCATION_STATE_PRIVACY = "privacy" LOCATION_STATE_STANDBY = "standby" RECORDING_STATES = [LOCATION_STATE_ARMED, LOCATION_STATE_DISARMED] COOKIE_XSRF_TOKEN = "XSRF-TOKEN" COOKIE_SSESYRANAC = "ssesyranac" HEADER_XSRF_TOKEN = "X-XSRF-TOKEN" snjoetw-py-canary-a486a4c/canary/live_stream_api.py000066400000000000000000000132001463363740700225230ustar00rootroot00000000000000import logging import requests from requests import HTTPError from canary.const import ( URL_LOGIN_PAGE, COOKIE_XSRF_TOKEN, COOKIE_SSESYRANAC, URL_LOGIN_API, ATTR_USERNAME, ATTR_PASSWORD, ATTR_CLIENT_ID, ATTR_VALUE_CLIENT_ID, ATTR_VALUE_GRANT_TYPE, ATTR_GRANT_TYPE, ATTR_SCOPE, ATTR_VALUE_SCOPE, ATTR_TOKEN, URL_WATCHLIVE_BASE, ATTR_SESSION_ID, URL_ENTRIES_API, HEADER_XSRF_TOKEN, HEADER_AUTHORIZATION, HEADER_VALUE_AUTHORIZATION, ATTR_DEVICE_UUID, ) from canary.model import Entry _LOGGER = logging.getLogger(__name__) class LiveStreamApi: def __init__(self, username, password, timeout=10, token=None): self._username = username self._password = password self._timeout = timeout self._token = token self._ssesyranac = None self._xsrf_token = None self.pre_login() if token is None: self.login() def pre_login(self): response = requests.get(URL_LOGIN_PAGE) xsrf_token = response.cookies[COOKIE_XSRF_TOKEN] ssesyranac = response.cookies[COOKIE_SSESYRANAC] self._ssesyranac = ssesyranac self._xsrf_token = xsrf_token def login(self): response = self._call_api( "post", URL_LOGIN_API, params={ ATTR_USERNAME: self._username, ATTR_PASSWORD: self._password, ATTR_CLIENT_ID: ATTR_VALUE_CLIENT_ID, ATTR_GRANT_TYPE: ATTR_VALUE_GRANT_TYPE, ATTR_SCOPE: ATTR_VALUE_SCOPE, }, ) _LOGGER.debug( "Received API response: %d, %s", response.status_code, response.content ) response.raise_for_status() self._token = response.json()[ATTR_TOKEN] def start_session(self, device_uuid): response = self._call_api( "post", f"{URL_WATCHLIVE_BASE}{device_uuid}/session", json={}, # "deviceUUID": device_uuid}, ) response.raise_for_status() session_id = response.json().get(ATTR_SESSION_ID) if self.renew_session(device_uuid, session_id): return session_id return None def renew_session(self, device_uuid, session_id): response = self._call_api( "post", f"{URL_WATCHLIVE_BASE}{device_uuid}/send", json={ATTR_SESSION_ID: session_id}, ) response.raise_for_status() json = response.json() return "message" in json and json["message"] == "success" def stop_session(self, device_uuid, session_id): """Ends the session""" response = self._call_api( "post", f"{URL_WATCHLIVE_BASE}{device_uuid}/stop", json={ ATTR_DEVICE_UUID: device_uuid, ATTR_SESSION_ID: session_id, "action": "delete", }, ) response.raise_for_status() json = response.json() return "message" in json and json["message"] == "success" def get_entries(self, location_id, params): json = self._call_api( "get", f"{URL_ENTRIES_API}{location_id}", params=params ).json() return [Entry(data) for data in json] def get_live_stream_url(self, device_id, session_id): return f"{URL_WATCHLIVE_BASE}{device_id}/{session_id}/stream.m3u8" def _call_api(self, method, url, params=None, **kwargs): _LOGGER.debug("About to call %s with %s", url, params) response = requests.request( method, url, params=params, timeout=self._timeout, headers=self._api_headers(), cookies=self._api_cookies(), **kwargs, ) _LOGGER.debug( "Received API response: %d, %s", response.status_code, response.content ) response.raise_for_status() return response def _api_cookies(self): return { COOKIE_XSRF_TOKEN: self._xsrf_token, COOKIE_SSESYRANAC: self._ssesyranac, } def _api_headers(self): return { HEADER_XSRF_TOKEN: self._xsrf_token, HEADER_AUTHORIZATION: f"{HEADER_VALUE_AUTHORIZATION} {self._token}", } @property def auth_token(self): # -> str | None: return self._token class LiveStreamSession: def __init__(self, api, device): self._api = api self._device_uuid = device.uuid self._device_id = device.device_id self._session_id = None self.start_renew_session() def start_renew_session(self): if self._session_id is None: self._session_id = self._api.start_session(self._device_uuid) else: try: self._api.renew_session(self._device_uuid, self._session_id) except HTTPError as ex: if ex.response.status_code == 403: self._session_id = self._api.start_session(self._device_uuid) else: self._session_id = None raise ex def stop_session(self) -> None: self._api.stop_session(self._device_uuid, self._session_id) self.clear_session() def clear_session(self) -> None: self._session_id = None @property def live_stream_url(self): # -> str | None: if self._session_id is None: return None return self._api.get_live_stream_url(self._device_id, self._session_id) @property def auth_token(self): # -> str | None: if self._api is None: return None return self._api.auth_token snjoetw-py-canary-a486a4c/canary/model.py000066400000000000000000000122031463363740700204620ustar00rootroot00000000000000from enum import Enum from datetime import datetime, timezone from canary.const import RECORDING_STATES, DATETIME_FORMAT class Customer: def __init__(self, data): self._id = data["id"] self._first_name = data["first_name"] self._last_name = data["last_name"] self._is_celsius = data["celsius"] @property def customer_id(self): return self._id @property def first_name(self): return self._first_name @property def last_name(self): return self._last_name @property def is_celsius(self): return self._is_celsius class Location: def __init__(self, data, modes_by_name): self._id = data["id"] self._name = data["name"] self._is_private = data["is_private"] self._devices = [] self._customers = [] mode_name = data.get("mode", {}).get("name", None) self._mode = modes_by_name.get(mode_name, None) current_mode_name = data.get("current_mode", {}).get("name", None) self._current_mode = modes_by_name.get(current_mode_name, None) for device_data in data["devices"]: self._devices.append(Device(device_data)) for customer_data in data["customers"]: self._customers.append(Customer(customer_data)) @property def location_id(self): return self._id @property def name(self): return self._name @property def mode(self): return self._mode @property def current_mode(self): return self._current_mode @property def devices(self): return self._devices @property def customers(self): return self._customers @property def is_private(self): return self._is_private @property def is_recording(self): if self.current_mode is None: return False return self.current_mode.name in RECORDING_STATES @property def is_celsius(self): for customer in self._customers: if customer is not None and customer.is_celsius: return True return False class Device: def __init__(self, data): self._id = data["id"] self._uuid = data["uuid"] self._name = data["name"] self._device_mode = None self._is_online = data["online"] self._device_type = data["device_type"] @property def device_id(self): return self._id @property def uuid(self): return self._uuid @property def name(self): return self._name @property def device_mode(self): return self._device_mode @property def is_online(self): return self._is_online @property def device_type(self): return self._device_type class Reading: def __init__(self, data): self._sensor_type = SensorType(data["sensor_type"]["name"]) self._value = data["value"] @property def sensor_type(self): return self._sensor_type @property def value(self): return self._value class SensorType(Enum): AIR_QUALITY = "air_quality" HUMIDITY = "humidity" TEMPERATURE = "temperature" BATTERY = "battery" WIFI = "wifi" DATE_LAST_ENTRY = "last_entry_date" ENTRIES_CAPTURED_TODAY = "entries_captured_today" class Entry: def __init__(self, data): self._entry_id = data["id"] self._start_time = data.get("start_time", "") self._device_uuids = [] self._starred = data.get("starred", False) self._selected = data.get("selected", False) self._thumbnails = [] for device_data in data.get("device_uuids", []): # for whatever reason, this call has hyphens in the uuid's # while all others do not self._device_uuids.append(device_data.replace("-", "")) for thumbnail_data in data.get("thumbnails", []): self._thumbnails.append(Thumbnail(thumbnail_data)) @property def entry_id(self): return self._entry_id @property def start_time(self): # -> datetime | None: try: return datetime.strptime(self._start_time + "Z", DATETIME_FORMAT).replace( tzinfo=timezone.utc ) except ValueError: return None @property def device_uuids(self): return self._device_uuids @property def starred(self): return self._starred @property def selected(self): return self._selected @property def thumbnails(self): return self._thumbnails class Thumbnail: def __init__(self, data): self._image_url = data["signed_url"] @property def image_url(self): return self._image_url class Mode: def __init__(self, data): self._id = data["id"] self._name = data["name"] self._resource_uri = data["resource_uri"] def __repr__(self): return f"Mode(id={self.mode_id}, name={self.name})" @property def mode_id(self): return self._id @property def name(self): return self._name @property def resource_uri(self): return self._resource_uri snjoetw-py-canary-a486a4c/pylintrc000066400000000000000000000022431463363740700173250ustar00rootroot00000000000000[MASTER] reports=no # Reasons disabled: # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # abstract-class-not-used - is flaky, should not show up but does # unused-argument - generic callbacks and setup methods create a lot of warnings # global-statement - used for the on-demand requirement installation # redefined-variable-type - this is Python, we're duck typing! # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing disable= locally-disabled, duplicate-code, cyclic-import, abstract-class-little-used, abstract-class-not-used, unused-argument, global-statement, redefined-variable-type, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, too-many-lines, too-few-public-methods, abstract-method, no-self-use, missing-docstring [EXCEPTIONS] overgeneral-exceptions=Exception snjoetw-py-canary-a486a4c/pyproject.toml000066400000000000000000000010111463363740700204420ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.black] line-length = 88 [tool.pytest.ini_options] log_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" log_date_format = "%Y-%m-%d %H:%M:%S" testpaths = [ "tests", ] markers = [ "slow: mark test as slow (deselect with '-m \"not slow\"')", "current_test: marks the current coding test to run", "unfinished: test is currently being coded (deselect with '-m \"not unfinished\"')" ] snjoetw-py-canary-a486a4c/requirements.txt000066400000000000000000000000101463363740700210100ustar00rootroot00000000000000requestssnjoetw-py-canary-a486a4c/requirements_tests.txt000066400000000000000000000001351463363740700222420ustar00rootroot00000000000000coveralls flake8 mock pre-commit pylint pytest pytest-cov pytest-freezegun requests_mock tox snjoetw-py-canary-a486a4c/run_api.py000066400000000000000000000105141463363740700175450ustar00rootroot00000000000000import json import logging import re from canary.api import Api # This will open a watch live session to get the URL and allow time # to open the m3u8 file in VLC LIVE_STREAM = False REDACT = False def write_config(canary: Api): # Data to be written dictionary = { "username": canary._username, "password": canary._password, "token": canary._token, } # Serializing json json_object = json.dumps(dictionary, indent=4) # Writing to sample.json with open("./env/variables.json", "w") as outfile: outfile.write(json_object) def read_settings(): with open("./env/variables.json") as openfile: # Reading from json file json_object = json.load(openfile) try: if json_object["token"] == "": json_object["token"] = None except KeyError: json_object["token"] = None return json_object if __name__ == "__main__": logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) # set logging level settings = read_settings() canary = Api( username=settings["username"], password=settings["password"], token=settings["token"], ) write_config(canary) locations_by_id = {} readings_by_device_id = {} for location in canary.get_locations(): location_id = location.location_id locations_by_id[location_id] = location for device in location.devices: logger.info( "device %s is a %s and is %s", device.name, device.device_type["name"], "online" if device.is_online else "offline", ) if device.is_online: readings_by_device_id[device.device_id] = canary.get_latest_readings( device.device_id ) # below requires a new login as well, since there are new # cookies that need to be set. if LIVE_STREAM: lss = canary.get_live_stream_session(device=device) logger.info( "device %s live stream session url = %s", device.name, re.sub( r"watchlive/\d+/[a-z\d]+/", "watchlive/--loc_id--/--hash--/", lss.live_stream_url, ) if REDACT else lss.live_stream_url, ) input( "Press Enter to close the live stream session and continue..." ) lss.stop_session() logger.info("live stream session closed") logger.info("Getting the day's entries...") entries = canary.get_entries(location_id=location_id) for entry in entries: logger.info( "id: %s - device_uuid: %s - date: %s", entry.entry_id, entry.device_uuids[0], entry.start_time, ) for thumbnail in entry.thumbnails: logger.info("-- %s", thumbnail.image_url) logger.info("Getting a single entry by device...") entries = canary.get_latest_entries(location_id) for entry in entries: logger.info( "id: %s - device_uuid: %s - date: %s", entry.entry_id, entry.device_uuids[0], entry.start_time, ) for thumbnail in entry.thumbnails: logger.info("-- %s", thumbnail.image_url) logger.info("Latest Readings by device...") for key in readings_by_device_id: for reading in readings_by_device_id[key]: # yes this loop is not really needed, # but to anonymize the device id's we need it for device in location.devices: if device.device_id == key: logger.info( "device %s - sensor: %s value: %s", device.name, reading.sensor_type.name, reading.value, ) snjoetw-py-canary-a486a4c/script/000077500000000000000000000000001463363740700170415ustar00rootroot00000000000000snjoetw-py-canary-a486a4c/script/run-in-env.sh000077500000000000000000000007121463363740700213760ustar00rootroot00000000000000#!/usr/bin/env sh set -eu # Activate pyenv and virtualenv if present, then run the specified command # pyenv, pyenv-virtualenv if [ -s .python-version ]; then PYENV_VERSION=$(head -n 1 .python-version) export PYENV_VERSION fi # other common virtualenvs my_path=$(git rev-parse --show-toplevel) for venv in venv .venv .; do if [ -f "${my_path}/${venv}/bin/activate" ]; then . "${my_path}/${venv}/bin/activate" break fi done exec "$@" snjoetw-py-canary-a486a4c/setup.cfg000066400000000000000000000010701463363740700173540ustar00rootroot00000000000000[metadata] name = py-canary version = 0.5.4 author = snjoetw author_email = snjoetw@gmail.com description = Python API for Canary Security Camera long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/snjoetw/py-canary project_urls = Bug Tracker = https://github.com/snjoetw/py-canary/issues classifiers = Programming Language :: Python :: 3 License :: OSI Approved :: MIT License Operating System :: OS Independent [options] packages = canary python_requires = >=3.6 install_requires = requests snjoetw-py-canary-a486a4c/tests/000077500000000000000000000000001463363740700166775ustar00rootroot00000000000000snjoetw-py-canary-a486a4c/tests/__init__.py000066400000000000000000000000001463363740700207760ustar00rootroot00000000000000snjoetw-py-canary-a486a4c/tests/fixtures/000077500000000000000000000000001463363740700205505ustar00rootroot00000000000000snjoetw-py-canary-a486a4c/tests/fixtures/api_entries_70001.json000066400000000000000000000013311463363740700244720ustar00rootroot00000000000000[ { "id": "00000000-0000-0000-0001-000000000000", "start_time": "2022-05-06T00:08:14", "thumbnails": [ { "device_id": "ffffffff-feed-ffff-ffff-ffffffffffff", "signed_url": "https://image_url.com", "start_time": 1651795694 } ], "device_uuids": [ "ffffffff-feed-ffff-ffff-ffffffffffff" ], "starred": false, "selected": false }, { "id": "00000000-0000-0000-0003-000000000000", "start_time": "2022-05-05T22:14:40", "thumbnails": [ { "device_id": "ffffffff-feed-ffff-ffff-ffffffffffff", "signed_url": "https://image_url.com", "start_time": 1651788880 } ], "device_uuids": [ "ffffffff-feed-ffff-ffff-ffffffffffff" ], "starred": false, "selected": false } ] snjoetw-py-canary-a486a4c/tests/fixtures/api_locations.json000066400000000000000000000106321463363740700242710ustar00rootroot00000000000000{ "meta": { "limit": 20, "next": null, "offset": 0, "previous": null, "total_count": 2 }, "objects": [ { "auto_mode": { "id": 1, "name": "disarmed", "resource_uri": "/v1/modes/1/" }, "auto_mode_enabled": true, "current_mode": { "id": 2, "name": "armed", "resource_uri": "/v1/modes/2/" }, "customers": [ { "celsius": true, "current_location": "/v1/locations/70002/", "first_name": "Joe", "id": 90001, "is_active": true, "last_name": "Lu" }, { "celsius": true, "current_location": null, "first_name": "Jenny", "has_seen_data_share_prompt": false, "id": 90002, "is_active": true, "last_name": "Chen" } ], "devices": [ { "activation_attempts": 1, "activation_status": "activated", "activation_token": null, "device_activated": true, "device_type": { "id": 1, "name": "Canary" }, "id": 80001, "uuid": "uuid-80001", "location": "/v1/locations/70001/", "mode": "/v1/modes/2/", "name": "Dining Room", "online": true, "siren_active": false, "video_uploading": false }, { "activation_attempts": 1, "activation_status": "deactivated", "activation_token": null, "device_activated": false, "device_type": { "id": 1, "name": "Canary" }, "id": 80002, "uuid": "uuid-80002", "location": "/v1/locations/70001/", "mode": "/v1/modes/1/", "name": "Front Door", "online": false, "siren_active": false, "video_uploading": true }, { "activation_attempts": 2, "activation_status": "deactivated", "activation_token": null, "device_activated": false, "device_type": { "id": 1, "name": "Canary" }, "id": 80003, "uuid": "uuid-80003", "location": "/v1/locations/70001/", "mode": "/v1/modes/2/", "name": "Bedroom", "online": false, "siren_active": false, "video_uploading": true }, { "activation_attempts": 1, "activation_status": "activated", "activation_token": null, "device_activated": true, "device_type": { "id": 1, "name": "Canary" }, "id": 80004, "uuid": "uuid-80004", "location": "/v1/locations/70001/", "mode": "/v1/modes/2/", "name": "Front Door", "online": true, "siren_active": false, "video_uploading": false } ], "id": 70001, "is_private": false, "mode": { "id": 5, "name": "away", "resource_uri": "/v1/modes/5/" }, "name": "Vacation Home", "night_mode_enabled": true }, { "auto_mode": { "id": 4, "name": "home", "resource_uri": "/v1/modes/4/" }, "auto_mode_enabled": false, "current_mode": { "id": 7, "name": "standby", "resource_uri": "/v1/modes/7/" }, "customers": [ { "celsius": false, "current_location": "/v1/locations/70002/", "first_name": "Joe", "id": 90001, "is_active": true, "last_name": "Lu" } ], "devices": [ { "activation_attempts": 1, "activation_status": "activated", "activation_token": null, "device_activated": true, "device_type": { "id": 1, "name": "Canary" }, "id": 80005, "uuid": "uuid-80005", "location": "/v1/locations/70002/", "mode": "/v1/modes/7/", "name": "Office", "online": true, "siren_active": false, "video_uploading": true } ], "id": 70002, "is_private": false, "mode": { "id": 4, "name": "home", "resource_uri": "/v1/modes/4/" }, "name": "Home", "night_mode_enabled": false } ] }snjoetw-py-canary-a486a4c/tests/fixtures/api_login.json000066400000000000000000000002301463363740700233770ustar00rootroot00000000000000{ "access_token": "aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj", "token_type": "Bearer", "expires_in": 31530999, "scope": "read write read+write" }snjoetw-py-canary-a486a4c/tests/fixtures/api_modes.json000066400000000000000000000013621463363740700234050ustar00rootroot00000000000000{ "meta": { "limit": 20, "next": null, "offset": 0, "previous": null, "total_count": 7 }, "objects": [ { "id": 2, "name": "armed", "resource_uri": "/v1/modes/2/" }, { "id": 5, "name": "away", "resource_uri": "/v1/modes/5/" }, { "id": 1, "name": "disarmed", "resource_uri": "/v1/modes/1/" }, { "id": 4, "name": "home", "resource_uri": "/v1/modes/4/" }, { "id": 6, "name": "night", "resource_uri": "/v1/modes/6/" }, { "id": 3, "name": "privacy", "resource_uri": "/v1/modes/3/" }, { "id": 7, "name": "standby", "resource_uri": "/v1/modes/7/" } ] }snjoetw-py-canary-a486a4c/tests/fixtures/api_readings_80005.json000066400000000000000000000023761463363740700246340ustar00rootroot00000000000000{ "meta": { "limit": 500, "next": null, "offset": 0, "previous": null, "total_count": 429 }, "objects": [ { "sensor_type": { "id": 3, "name": "air_quality", "resource_uri": "/v1/sensors/3/" }, "status": null, "value": "0.8129177689552307" }, { "sensor_type": { "id": 3, "name": "air_quality", "resource_uri": "/v1/sensors/3/" }, "status": null, "value": "0.8159225031416467" }, { "sensor_type": { "id": 1, "name": "humidity", "resource_uri": "/v1/sensors/1/" }, "status": null, "value": "41.68813060192352" }, { "sensor_type": { "id": 1, "name": "humidity", "resource_uri": "/v1/sensors/1/" }, "status": null, "value": "41.42166693667148" }, { "sensor_type": { "id": 2, "name": "temperature", "resource_uri": "/v1/sensors/2/" }, "status": null, "value": "19.0007521446715" }, { "sensor_type": { "id": 2, "name": "temperature", "resource_uri": "/v1/sensors/2/" }, "status": null, "value": "19.120397973567883" } ] }snjoetw-py-canary-a486a4c/tests/fixtures/live_stream_api_login.json000066400000000000000000000001751463363740700260010ustar00rootroot00000000000000{ "access_token": "ffffffffffffffffffffffffffffffffffffffff", "user": { "email": "someuser@someemailserver.com" } }snjoetw-py-canary-a486a4c/tests/test_api.py000066400000000000000000000106121463363740700210610ustar00rootroot00000000000000"""The tests for the Canary sensor platform.""" import os import unittest import pytest import requests_mock from canary.api import Api from canary.const import ( URL_LOGIN_API, URL_MODES_API, URL_LOCATIONS_API, URL_READINGS_API, URL_LOGIN_PAGE, COOKIE_XSRF_TOKEN, COOKIE_SSESYRANAC, ) from canary.model import SensorType COOKIE_XSRF_VAL = "xsrf" COOKIE_COOKIE_SSESYRANAC_VAL = "ssesyranac" FIXED_DATE_RANGE = ( "?end=2022-05-05T23%3A59%3A59.999Z&start=2022-05-05T00%3A00%3A00.000Z" ) URL_ENTRY_API = f"https://my.canary.is/api/entries/tl2/70001{FIXED_DATE_RANGE}" def load_fixture(filename): """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), "fixtures", filename) with open(path) as fptr: return fptr.read() def _setup_responses(mock): mock.register_uri("POST", URL_LOGIN_API, text=load_fixture("api_login.json")) mock.register_uri("GET", URL_MODES_API, text=load_fixture("api_modes.json")) mock.register_uri("GET", URL_LOCATIONS_API, text=load_fixture("api_locations.json")) mock.register_uri( "GET", URL_READINGS_API, text=load_fixture("api_readings_80005.json") ) mock.register_uri( "GET", URL_LOGIN_PAGE, cookies={ COOKIE_XSRF_TOKEN: COOKIE_XSRF_VAL, COOKIE_SSESYRANAC: COOKIE_COOKIE_SSESYRANAC_VAL, }, ) mock.register_uri( "GET", URL_ENTRY_API, text=load_fixture("api_entries_70001.json"), ) class TestApi(unittest.TestCase): @requests_mock.Mocker() def test_locations(self, mock): """Test the Canary locations API.""" _setup_responses(mock) api = Api("user", "pass") locations = api.get_locations() self.assertEqual(2, len(locations)) for location in locations: if location.name == "Vacation Home": self.assertTrue(location.is_recording) self.assertFalse(location.is_private) self.assertTrue(location.is_celsius) self.assertEqual(2, len(location.customers)) self.assertEqual("away", location.mode.name) self.assertEqual("armed", location.current_mode.name) self.assertEqual(70001, location.location_id) elif location.name == "Home": self.assertFalse(location.is_recording) self.assertFalse(location.is_private) self.assertFalse(location.is_celsius) self.assertEqual(1, len(location.customers)) self.assertEqual("home", location.mode.name) self.assertEqual("standby", location.current_mode.name) self.assertEqual(70002, location.location_id) @pytest.mark.freeze_time("2022-05-05") @requests_mock.Mocker() def test_location_with_motion_entry(self, mock): """Test the Canary entries API.""" _setup_responses(mock) api = Api("user", "pass") entries = api.get_entries(70001) self.assertEqual(2, len(entries)) entry = entries[0] self.assertEqual("00000000-0000-0000-0001-000000000000", entry.entry_id) self.assertEqual("2022-05-06 00:08:14+00:00", str(entry.start_time)) self.assertEqual(False, entry.starred) self.assertEqual(False, entry.selected) self.assertEqual(1, len(entry.thumbnails)) self.assertEqual(1, len(entry.device_uuids)) thumbnail = entry.thumbnails[0] self.assertEqual("https://image_url.com", thumbnail.image_url) device_uuid = entry.device_uuids[0] self.assertEqual("fffffffffeedffffffffffffffffffff", device_uuid) @requests_mock.Mocker() def test_device_with_readings(self, mock): """Test the Canary entries API.""" _setup_responses(mock) api = Api("user", "pass") readings = api.get_readings(80001) self.assertEqual(6, len(readings)) readings = api.get_latest_readings(80001) self.assertEqual(3, len(readings)) for reading in readings: if reading.sensor_type == SensorType.AIR_QUALITY: self.assertEqual("0.8129177689552307", reading.value) elif reading.sensor_type == SensorType.HUMIDITY: self.assertEqual("41.68813060192352", reading.value) elif reading.sensor_type == SensorType.TEMPERATURE: self.assertEqual("19.0007521446715", reading.value) snjoetw-py-canary-a486a4c/tests/test_live_stream_api.py000066400000000000000000000025661463363740700234640ustar00rootroot00000000000000"""The tests for the Canary sensor platform.""" import os import unittest import requests_mock from canary.live_stream_api import LiveStreamApi from canary.const import ( URL_LOGIN_API, URL_LOGIN_PAGE, COOKIE_XSRF_TOKEN, COOKIE_SSESYRANAC, ) COOKIE_XSRF_VAL = "xsrf" COOKIE_COOKIE_SSESYRANAC_VAL = "ssesyranac" def load_fixture(filename): """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), "fixtures", filename) with open(path) as fptr: return fptr.read() def _setup_responses(mock): mock.register_uri( "POST", URL_LOGIN_API, text=load_fixture("live_stream_api_login.json") ) mock.register_uri( "GET", URL_LOGIN_PAGE, cookies={ COOKIE_XSRF_TOKEN: COOKIE_XSRF_VAL, COOKIE_SSESYRANAC: COOKIE_COOKIE_SSESYRANAC_VAL, }, ) class TestLiveStreamApi(unittest.TestCase): @requests_mock.Mocker() def test_login(self, mock): """Test login for canary live stream api""" _setup_responses(mock) api = LiveStreamApi("user", "pass") api.login() with self.subTest("stores the token on the api object"): self.assertEqual(api._token, "ffffffffffffffffffffffffffffffffffffffff") with self.subTest("stores ssesyranac cookie on the api object"): self.assertEqual(api._ssesyranac, "ssesyranac") snjoetw-py-canary-a486a4c/tox.ini000066400000000000000000000007421463363740700170530ustar00rootroot00000000000000[tox] envlist = py35, py36, lint skip_missing_interpreters = True [testenv] setenv = PYTHONPATH = {toxinidir}:{toxinidir}/canary whitelist_externals = /usr/bin/env install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = py.test --basetemp={envtmpdir} --cov --cov-report term-missing deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements_tests.txt [testenv:lint] ignore_errors = True commands = flake8 pylint canary