pax_global_header00006660000000000000000000000064147115436370014525gustar00rootroot0000000000000052 comment=eb32d7b5d7832565172382f98458c2dd409f1886 doorbirdpy-3.0.8/000077500000000000000000000000001471154363700137125ustar00rootroot00000000000000doorbirdpy-3.0.8/.gitignore000066400000000000000000000052641471154363700157110ustar00rootroot00000000000000# Created by https://www.gitignore.io/api/python,pycharm,windows ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 .idea/* # User-specific stuff: .idea/**/workspace.xml .idea/**/tasks.xml .idea/dictionaries # Sensitive or high-churn files: .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.xml .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml # Gradle: .idea/**/gradle.xml .idea/**/libraries # CMake cmake-build-debug/ # Mongo Explorer plugin: .idea/**/mongoSettings.xml ## File-based project format: *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties ### PyCharm Patch ### # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 # *.iml # modules.xml # .idea/misc.xml # *.ipr # Sonarlint plugin .idea/sonarlint ### Python ### # 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 ### Windows ### # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk # End of https://www.gitignore.io/api/python,pycharm,windowsdoorbirdpy-3.0.8/.gitlab-ci.yml000066400000000000000000000021521471154363700163460ustar00rootroot00000000000000# Verify that dependencies resolve install: stage: test image: python:3 script: - pip3 install -r requirements.txt - pip3 install -r requirements_tests.txt - python3 -m pytest tests/test_init.py code-style: stage: test image: python:3 script: - pip3 install -r requirements_tests.txt - ruff check doorbirdpy tests # PyPI template .pypi: stage: deploy image: python:3 before_script: # Install build and distribution tools - python3 -m pip install --user --upgrade setuptools wheel # Build the package - python3 setup.py sdist bdist_wheel # Prepare to upload to PyPI - python3 -m pip install --user --upgrade twine artifacts: paths: - dist/ # Upload dev builds to TestPyPI testpypi: extends: .pypi only: - dev@klikini/doorbirdpy script: # Upload to TestPyPI - python3 -m twine upload --repository-url https://test.pypi.org/legacy/ -u __token__ dist/* # Upload master builds to PyPI realpypi: extends: .pypi only: - master@klikini/doorbirdpy script: # Upload to PyPI - python3 -m twine upload -u __token__ dist/* doorbirdpy-3.0.8/CONTRIBUTING.md000066400000000000000000000004331471154363700161430ustar00rootroot00000000000000Contributions are welcome, including bug fixes and new features within scope, and especially updates to ensure compatibility with new DoorBird API versions. # Code style ![code style: black](https://img.shields.io/badge/code%20style-black-000000.svg) Line length: 120 characters doorbirdpy-3.0.8/LICENSE.txt000066400000000000000000000020471471154363700155400ustar00rootroot00000000000000Copyright 2020 DoorBirdPy Contributors 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. doorbirdpy-3.0.8/README.md000066400000000000000000000010471471154363700151730ustar00rootroot00000000000000# doorbirdpy Python wrapper for [DoorBird LAN API v0.36](https://www.doorbird.com/downloads/api_lan.pdf) [View on PyPI](https://pypi.org/project/DoorBirdPy/) # Features ## Supported - Live video request - Live image request - Open door/other relays - Light on - History image requests - Schedule requests - Favorites requests - Check request - Info request - RTSP - Monitor request ## Not yet supported - Live audio transmit - Live audio receive - SIP # Contributors - @klikini - @oblogic7 - @bdraco - [alandtse](https://github.com/alandtse) doorbirdpy-3.0.8/doorbirdpy/000077500000000000000000000000001471154363700160675ustar00rootroot00000000000000doorbirdpy-3.0.8/doorbirdpy/__init__.py000066400000000000000000000366641471154363700202170ustar00rootroot00000000000000"""Main DoorBirdPy module.""" from __future__ import annotations import aiohttp import re import asyncio from typing import Any from contextlib import suppress from typing import Callable from urllib.parse import urlencode from functools import cached_property from aiohttp.client import ClientSession from tenacity import retry, wait_exponential, stop_before_delay from doorbirdpy.schedule_entry import ( DoorBirdScheduleEntry, DoorBirdScheduleEntryOutput, DoorBirdScheduleEntrySchedule, ) __all__ = [ "DoorBird", "DoorBirdScheduleEntry", "DoorBirdScheduleEntryOutput", "DoorBirdScheduleEntrySchedule", ] _retry_kwargs = dict(wait=wait_exponential(min=1, max=10), stop=stop_before_delay(60), reraise=True) class DoorBird: """Represent a doorbell unit.""" _monitor_timeout = 45 # seconds to wait for a monitor update _monitor_max_failures = 4 def __init__( self, ip: str, username: str, password: str, http_session: ClientSession | None = None, secure: bool = False, port: int | None = None, timeout: float = 10.0, ) -> None: """ Initializes the options for subsequent connections to the unit. :param ip: The IP address of the unit :param username: The username (with sufficient privileges) of the unit :param password: The password for the provided username :param secure: set to True to use https instead of http for URLs :param port: override the HTTP port (defaults to 443 if secure = True, otherwise 80) :param timeout: The timeout for the HTTP requests """ self._ip = ip self._credentials = username, password self._http = http_session or ClientSession() self._secure = secure self._timeout = timeout if port: self._port = port else: self._port = 443 if self._secure else 80 self._monitor_task: asyncio.Task[None] | None = None async def close(self) -> None: """ Close the connection to the device. """ if self._http: await self._http.close() async def get_image(self, url: str, timeout: float | None = None) -> bytes: """ Perform a GET request to the given URL on the device and return the raw image data. :param url: The full URL to the API call :param timeout: The timeout for the request :return: The response object """ response = await self._http.get(url, timeout=timeout or self._timeout) response.raise_for_status() return await response.read() async def _get( self, url: str, timeout: float | None = None ) -> aiohttp.ClientResponse: """ Perform a GET request to the given URL on the device. :param url: The full URL to the API call :param timeout: The timeout for the request :return: The response object """ return await self._http.get(url, timeout=timeout or self._timeout) async def ready(self) -> tuple[bool, int]: """ Test the connection to the device. :return: A tuple containing the ready status (True/False) and the HTTP status code returned by the device or 0 for no status """ url = self._url("/bha-api/info.cgi", auth=True) try: response = await self._get(url) data = await response.json() code = data["BHA"]["RETURNCODE"] return int(code) == 1, int(response.status) except ValueError: return False, int(response.status) @cached_property def live_video_url(self) -> str: """ A multipart JPEG live video stream with the default resolution and compression as defined in the system configuration. :return: The URL of the stream """ return self._url("/bha-api/video.cgi") @cached_property def live_image_url(self) -> str: """ A JPEG file with the default resolution and compression as defined in the system configuration. :return: The URL of the image """ return self._url("/bha-api/image.cgi") @retry(**_retry_kwargs) async def energize_relay(self, relay: int | str = 1) -> bool: """ Energize a door opener/alarm output/etc relay of the device. :return: True if OK, False if not """ data = await self._get_json( self._url("/bha-api/open-door.cgi", {"r": relay}, auth=True) ) return int(data["BHA"]["RETURNCODE"]) == 1 @retry(**_retry_kwargs) async def turn_light_on(self) -> bool: """ Turn on the IR lights. :return: JSON """ data = await self._get_json(self._url("/bha-api/light-on.cgi", auth=True)) code = data["BHA"]["RETURNCODE"] return int(code) == 1 def history_image_url(self, index: int, event: str) -> str: """ A past image stored in the cloud. :param index: Index of the history images, where 1 is the latest history image :return: The URL of the image. """ return self._url("/bha-api/history.cgi", {"index": index, "event": event}) async def schedule(self) -> list[DoorBirdScheduleEntry]: """ Get schedule settings. :return: A list of DoorBirdScheduleEntry objects """ data = await self._get_json(self._url("/bha-api/schedule.cgi", auth=True)) return DoorBirdScheduleEntry.parse_all(data) async def get_schedule_entry( self, sensor: str, param: str = "" ) -> DoorBirdScheduleEntry: """ Find the schedule entry that matches the provided sensor and parameter or create a new one that does if none exists. :return: A DoorBirdScheduleEntry """ entries = await self.schedule() for entry in entries: if entry.input == sensor and entry.param == param: return entry return DoorBirdScheduleEntry(sensor, param) @retry(**_retry_kwargs) async def change_schedule(self, entry: DoorBirdScheduleEntry) -> tuple[bool, int]: """ Add or replace a schedule entry. :param entry: A DoorBirdScheduleEntry object to replace on the device :return: A tuple containing the success status (True/False) and the HTTP response code """ url = self._url("/bha-api/schedule.cgi", auth=True) response = await self._http.post( url, json=entry.export, timeout=self._timeout, headers={"Content-Type": "application/json"}, ) return int(response.status) == 200, response.status @retry(**_retry_kwargs) async def delete_schedule(self, event: str, param: str = "") -> bool: """ Delete a schedule entry. :param event: Event type (doorbell, motion, rfid, input) :param param: param value of schedule entry to delete :return: True if OK, False if not """ url = self._url( "/bha-api/schedule.cgi", {"action": "remove", "input": event, "param": param}, auth=True, ) response = await self._get(url) return int(response.status) == 200 async def _monitor_doorbird( self, on_event: Callable[[str], None], on_error: Callable[[Exception], None] ) -> None: """ Method to use by the monitoring thread """ url = self._url( "/bha-api/monitor.cgi", {"ring": "doorbell,motionsensor"}, auth=True ) states = {"doorbell": "L", "motionsensor": "L"} failures = 0 while True: try: response = await self._http.get(url, timeout=self._monitor_timeout) reader = aiohttp.MultipartReader.from_response(response) while True: if (part := await reader.next()) is None: break if not isinstance(part, aiohttp.BodyPartReader): continue line = await part.text(encoding="utf-8") failures = 0 # reset the failure count on each successful response if match := re.match(r"(doorbell|motionsensor):(H|L)", line): event, value = match.group(1), match.group(2) if states[event] != value: states[event] = value if value == "H": on_event(event) except Exception as e: if failures >= self._monitor_max_failures: return on_error(e) failures += 1 await asyncio.sleep(2**failures) async def start_monitoring( self, on_event: Callable[[str], None], on_error: Callable[[Exception], None] ) -> None: """ Start monitoring for doorbird events :param on_event: A callback function, which takes the event name as its only parameter. The possible events are "doorbell" and "motionsensor" :param on_error: An error function, which will be called with an error if the thread fails. """ if self._monitor_task: await self.stop_monitoring() self._monitor_task = asyncio.create_task( self._monitor_doorbird(on_event, on_error) ) async def stop_monitoring(self) -> None: """ Stop monitoring for doorbird events """ if not self._monitor_task: return self._monitor_task.cancel() with suppress(asyncio.CancelledError): await self._monitor_task self._monitor_task = None async def doorbell_state(self) -> bool: """ The current state of the doorbell. :return: True for pressed, False for idle """ url = self._url("/bha-api/monitor.cgi", {"check": "doorbell"}, auth=True) response = await self._get(url) response.raise_for_status() try: return int((await response.text()).split("=")[1]) == 1 except IndexError: return False async def motion_sensor_state(self) -> bool: """ The current state of the motion sensor. :return: True for motion, False for idle """ url = self._url("/bha-api/monitor.cgi", {"check": "motionsensor"}, auth=True) response = await self._get(url) response.raise_for_status() try: return int((await response.text()).split("=")[1]) == 1 except IndexError: return False async def info(self) -> dict[str, Any]: """ Get information about the device. .. note: Unlike other API calls, this will not automatically be retried if it fails. :return: A dictionary of the device information: - FIRMWARE - BUILD_NUMBER - WIFI_MAC_ADDR (if the device is connected via WiFi) - RELAYS list (if firmware version >= 000108) - DEVICE-TYPE (if firmware version >= 000108) """ url = self._url("/bha-api/info.cgi", auth=True) response = await self._get(url) response.raise_for_status() data = await response.json() return data["BHA"]["VERSION"][0] async def favorites(self) -> dict[str, dict[str, Any]]: """ Get all saved favorites. :return: dict, as defined by the API. Top level items will be the favorite types (http, sip), which each reference another dict that maps ID to a dict with title and value keys. """ return await self._get_json(self._url("/bha-api/favorites.cgi", auth=True)) @retry(**_retry_kwargs) async def change_favorite( self, fav_type: str, title: str, value: str, fav_id: str | None = None ) -> bool: """ Add a new saved favorite or change an existing one. :param fav_type: sip or http :param title: Short description :param value: URL including protocol and credentials :param fav_id: The ID of the favorite, only used when editing existing favorites :return: successful, True or False """ args: dict[str, Any] = { "action": "save", "type": fav_type, "title": title, "value": value, } if fav_id: args["id"] = int(fav_id) url = self._url("/bha-api/favorites.cgi", args, auth=True) response = await self._get(url) return int(response.status) == 200 @retry(**_retry_kwargs) async def delete_favorite(self, fav_type: str, fav_id: str) -> bool: """ Delete a saved favorite. :param fav_type: sip or http :param fav_id: The ID of the favorite :return: successful, True or False """ url = self._url( "/bha-api/favorites.cgi", {"action": "remove", "type": fav_type, "id": fav_id}, auth=True, ) response = await self._get(url) return int(response.status) == 200 @retry(**_retry_kwargs) async def restart(self) -> bool: """ Restart the device. :return: successful, True or False """ url = self._url("/bha-api/restart.cgi") response = await self._get(url) return int(response.status) == 200 @cached_property def rtsp_live_video_url(self) -> str: """ Live video request over RTSP. :return: The URL for the MPEG H.264 live video stream """ return self._url("/mpeg/media.amp", port=554, protocol="rtsp") @cached_property def rtsp_over_http_live_video_url(self) -> str: """ Live video request using RTSP over HTTP. :return: The URL for the MPEG H.264 live video stream """ return self._url("/mpeg/media.amp", port=8557, protocol="rtsp") @cached_property def html5_viewer_url(self) -> str: """ The HTML5 viewer for interaction from other platforms. :return: The URL of the viewer """ return self._url("/bha-api/view.html") def _url( self, path: str, args: dict[str, Any] | None = None, port: int | None = None, auth: bool = True, protocol: str | None = None, ) -> str: """ Create a URL for accessing the device. :param path: The endpoint to call :param args: A dictionary of query parameters :param port: The port to use (defaults to 80) :param auth: Set to False to remove the URL authentication :param protocol: Allow protocol override (defaults to "http") :return: The full URL """ if not port: port = self._port if not protocol: protocol = "https" if self._secure else "http" query = urlencode(args) if args else "" if auth: user = ":".join(self._credentials) url = f"{protocol}://{user}@{self._ip}:{port}{path}" else: url = f"{protocol}://{self._ip}:{port}{path}" if query: url = f"{url}?{query}" return url async def _get_json(self, url: str) -> dict: """ Perform a GET request to the given URL on the device. :param url: The full URL to the API call :return: The JSON-decoded data sent by the device """ response = await self._get(url) response.raise_for_status() return await response.json() doorbirdpy-3.0.8/doorbirdpy/py.typed000066400000000000000000000000001471154363700175540ustar00rootroot00000000000000doorbirdpy-3.0.8/doorbirdpy/schedule_entry.py000066400000000000000000000131311471154363700214550ustar00rootroot00000000000000from __future__ import annotations import json from typing import Any class DoorBirdScheduleEntry: """ Parse a schedule entry from the device into an object. :param data: The entry as a dict :return: A DoorBirdScheduleEntry object """ @staticmethod def parse(data): entry = DoorBirdScheduleEntry(data["input"], data["param"]) for output in data["output"]: entry.output.append(DoorBirdScheduleEntryOutput.parse(output)) return entry """ Parse a list of schedule entries from the device into a list of objects. :param data: The list of entries :return: A list of DoorBirdScheduleEntry objects """ @staticmethod def parse_all(data) -> list[DoorBirdScheduleEntry]: entries: list[DoorBirdScheduleEntry] = [] for entry_data in data: entries.append(DoorBirdScheduleEntry.parse(entry_data)) return entries def __init__(self, input: str, param: str = "") -> None: self.input = input self.param = param self.output: list[DoorBirdScheduleEntryOutput] = [] @property def export(self) -> dict: return { "input": self.input, "param": self.param, "output": [output.export for output in self.output], } def __str__(self) -> str: return json.dumps(self.export) def __repr__(self) -> str: return f"" class DoorBirdScheduleEntryOutput: """ Parse a schedule action from the device into an object. :param data: The output action as a dict :return: A DoorBirdScheduleEntryOutput object """ @staticmethod def parse(data) -> DoorBirdScheduleEntryOutput: return DoorBirdScheduleEntryOutput( # If the "enabled" key is missing, the doorbird will assume it is enabled enabled=bool(data["enabled"]) if "enabled" in data else True, event=data["event"], param=data["param"], schedule=DoorBirdScheduleEntrySchedule.parse(data["schedule"]), ) def __init__( self, enabled: bool = True, event: str | None = None, param: str = "", schedule: DoorBirdScheduleEntrySchedule | None = None, ) -> None: self.enabled = enabled self.event = event self.param = param self.schedule = ( DoorBirdScheduleEntrySchedule() if schedule is None else schedule ) @property def export(self) -> dict: return { "enabled": "1" if self.enabled else "0", "event": self.event, "param": self.param, "schedule": self.schedule.export, } def __str__(self) -> str: return json.dumps(self.export) def __repr__(self) -> str: return f"" class DoorBirdScheduleEntrySchedule: """ Parse schedule times from the device into an object. :param data: The schedule as a dict :return: A DoorBirdScheduleEntrySchedule object """ @staticmethod def parse(data): schedule = DoorBirdScheduleEntrySchedule() if "once" in data: schedule.set_once(bool(data["once"])) if "from-to" in data: for from_to in data["from-to"]: schedule.add_range(from_to["from"], from_to["to"]) if "weekdays" in data: for weekday in data["weekdays"]: schedule.add_weekday(int(weekday["from"]), int(weekday["to"])) return schedule def __init__(self) -> None: self.once: dict[str, int] | None = None self.from_to: list[dict] | None = None self.weekdays: list[dict] | None = None """ Toggle the schedule on or off. The next time it runs, it will be toggled off. :param enabled: True to enable it for one run, False to disable it until enabled again """ def set_once(self, enabled: bool) -> None: self.once = {"valid": 1 if enabled else 0} """ Run the schedule only between the two specified times. :param sec_from: A unix timestamp representing the absolute start time of the schedule (such as April 25 2018) :param sec_to: A unix timestamp representing the absolute end time of the schedule (such as May 25 2018) """ def add_range(self, sec_from: str | int, sec_to: str | int) -> None: if not self.from_to: self.from_to = [] self.from_to.append({"from": str(int(sec_from)), "to": str(int(sec_to))}) """ Run the schedule between certain times on weekdays. :param sec_from: Seconds between Sunday at 00:00 and the desired start time :param sec_to: Seconds between Sunday at 00:00 and the desired end time """ def add_weekday(self, sec_from: str | int, sec_to: str | int) -> None: if not self.weekdays: self.weekdays = [] self.weekdays.append({"from": str(int(sec_from)), "to": str(int(sec_to))}) @property def export(self) -> dict[str, Any]: schedule: dict[str, Any] = {} if self.once: schedule["once"] = self.once if self.from_to: schedule["from-to"] = [] for from_to in self.from_to: schedule["from-to"].append(from_to) if self.weekdays: schedule["weekdays"] = [] for weekday in self.weekdays: schedule["weekdays"].append(weekday) return schedule def __str__(self) -> str: return json.dumps(self.export) def __repr__(self) -> str: return f"" doorbirdpy-3.0.8/requirements.txt000066400000000000000000000000271471154363700171750ustar00rootroot00000000000000aiohttp tenacity==9.0.0doorbirdpy-3.0.8/requirements_tests.txt000066400000000000000000000000471471154363700204210ustar00rootroot00000000000000ruff pytest pytest-asyncio aioresponsesdoorbirdpy-3.0.8/setup.py000066400000000000000000000011311471154363700154200ustar00rootroot00000000000000"""DoorBirdPy setup script.""" from setuptools import setup setup( name="DoorBirdPy", version="3.0.8", author="Andy Castille", author_email="andy@robiotic.net", maintainer="J. Nick Koston", maintainer_email="nick@koston.org", packages=["doorbirdpy"], install_requires=["aiohttp"], url="https://gitlab.com/klikini/doorbirdpy", download_url="https://gitlab.com/klikini/doorbirdpy/-/archive/master/doorbirdpy-master.zip", license="MIT", python_requires=">=3.9", description="Python wrapper for the DoorBird LAN API", platforms="Cross Platform", ) doorbirdpy-3.0.8/tests/000077500000000000000000000000001471154363700150545ustar00rootroot00000000000000doorbirdpy-3.0.8/tests/info.json000066400000000000000000000005231471154363700167020ustar00rootroot00000000000000{ "BHA": { "RETURNCODE": "1", "VERSION": [ { "FIRMWARE": "000125", "BUILD_NUMBER": "15870439", "WIFI_MAC_ADDR": "1234ABCD", "RELAYS": [ "1", "2", "ghchdi@1", "ghchdi@2", "ghchdi@3", "ghdwkh@1", "ghdwkh@2", "ghdwkh@3" ], "DEVICE-TYPE": "DoorBird D2101V" } ] } } doorbirdpy-3.0.8/tests/schedule.json000066400000000000000000000015061471154363700175450ustar00rootroot00000000000000[ { "input": "doorbell", "param": "1", "output": [ { "event": "notify", "param": "", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } }, { "event": "http", "param": "0", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } } ] }, { "input": "motion", "param": "", "output": [ { "event": "notify", "param": "", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } }, { "event": "http", "param": "5", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } } ] }, { "input": "relay", "param": "1", "output": [] } ] doorbirdpy-3.0.8/tests/schedule_get_entry.json000066400000000000000000000015061471154363700216250ustar00rootroot00000000000000[ { "input": "doorbell", "param": "1", "output": [ { "event": "notify", "param": "", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } }, { "event": "http", "param": "0", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } } ] }, { "input": "motion", "param": "", "output": [ { "event": "notify", "param": "", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } }, { "event": "http", "param": "5", "schedule": { "weekdays": [ { "to": "107999", "from": "108000" } ] } } ] }, { "input": "relay", "param": "1", "output": [] } ] doorbirdpy-3.0.8/tests/test_init.py000077500000000000000000000204651471154363700174420ustar00rootroot00000000000000from aiohttp import ClientResponseError from doorbirdpy import DoorBird from doorbirdpy.schedule_entry import DoorBirdScheduleEntry from aioresponses import aioresponses import pytest MOCK_HOST = "127.0.0.1" MOCK_USER = "user" MOCK_PASS = "pass" URL_TEMPLATE = "http://{}:{}@{}:80{}" @pytest.fixture def mock_aioresponse(): with aioresponses() as m: yield m @pytest.mark.asyncio async def test_ready(mock_aioresponse: aioresponses) -> None: with open("tests/info.json") as f: mock_aioresponse.get( URL_TEMPLATE.format(MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/info.cgi"), body=f.read(), ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) ready, code = await db.ready() assert ready is True assert code == 200 await db.close() @pytest.mark.asyncio async def test_get_image(mock_aioresponse: aioresponses) -> None: db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) url = db.live_image_url mock_aioresponse.get( url, body=b"jpeg", ) image_bytes = await db.get_image(url) assert image_bytes == b"jpeg" await db.close() @pytest.mark.asyncio async def test_http_url(): db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) url = db._url( path="/test", args=[ ("arg1", "value1"), ("arg2", "value2"), ], ) assert ( url == f"http://{MOCK_USER}:{MOCK_PASS}@{MOCK_HOST}:80/test?arg1=value1&arg2=value2" ) @pytest.mark.asyncio async def test_http_url_custom_port(): db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS, port=8080) url = db._url("/test") assert url == f"http://{MOCK_USER}:{MOCK_PASS}@{MOCK_HOST}:8080/test" @pytest.mark.asyncio async def test_https_url(): db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS, secure=True) url = db._url("/test") assert url == f"https://{MOCK_USER}:{MOCK_PASS}@{MOCK_HOST}:443/test" @pytest.mark.asyncio async def test_https_url_custom_port(): db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS, secure=True, port=8443) url = db._url("/test") assert url == f"https://{MOCK_USER}:{MOCK_PASS}@{MOCK_HOST}:8443/test" @pytest.mark.asyncio async def test_rtsp_url(): db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert db.rtsp_live_video_url.startswith( f"rtsp://{MOCK_USER}:{MOCK_PASS}@{MOCK_HOST}:554" ) @pytest.mark.asyncio async def test_rtsp_http_url(): db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert db.rtsp_over_http_live_video_url.startswith( f"rtsp://{MOCK_USER}:{MOCK_PASS}@{MOCK_HOST}:8557" ) @pytest.mark.asyncio async def test_energize_relay(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/open-door.cgi?r=1" ), body='{"BHA": {"RETURNCODE": "1"}}', ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert await db.energize_relay() is True await db.close() @pytest.mark.asyncio async def test_turn_light_on(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format(MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/light-on.cgi"), body='{"BHA": {"RETURNCODE": "1"}}', ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert await db.turn_light_on() is True await db.close() @pytest.mark.asyncio async def test_schedule(mock_aioresponse: aioresponses) -> None: with open("tests/schedule.json") as f: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/schedule.cgi" ), body=f.read(), ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert len(await db.schedule()) == 3 await db.close() @pytest.mark.asyncio async def test_get_schedule_entry(mock_aioresponse: aioresponses) -> None: with open("tests/schedule_get_entry.json") as f: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/schedule.cgi" ), body=f.read(), ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert isinstance( await db.get_schedule_entry("doorbell", "1"), DoorBirdScheduleEntry ) await db.close() @pytest.mark.asyncio async def test_change_schedule_entry(mock_aioresponse: aioresponses) -> None: with open("tests/schedule_get_entry.json") as f: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/schedule.cgi" ), body=f.read(), ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) entry = await db.get_schedule_entry("doorbell", "1") assert entry.output[0].enabled is True entry.param = "5" mock_aioresponse.post( URL_TEMPLATE.format(MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/schedule.cgi"), ) await db.change_schedule(entry) mock_aioresponse.assert_called_with( url=URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/schedule.cgi" ), method="POST", **{ "json": entry.export, "timeout": 10.0, "headers": {"Content-Type": "application/json"}, "allow_redirects": True, }, ) await db.close() @pytest.mark.asyncio async def test_doorbell_state_false(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/monitor.cgi?check=doorbell" ), body="doorbell=0\r\n", ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert await db.doorbell_state() is False await db.close() @pytest.mark.asyncio async def test_doorbell_state_true(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/monitor.cgi?check=doorbell" ), body="doorbell=1\r\n", ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert await db.doorbell_state() is True await db.close() @pytest.mark.asyncio async def test_motion_sensor_state_false(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/monitor.cgi?check=motionsensor" ), body="motionsensor=0\r\n", ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert await db.motion_sensor_state() is False await db.close() @pytest.mark.asyncio async def test_motion_sensor_state_true(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format( MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/monitor.cgi?check=motionsensor" ), body="motionsensor=1\r\n", ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert await db.motion_sensor_state() is True await db.close() @pytest.mark.asyncio async def test_info(mock_aioresponse: aioresponses) -> None: with open("tests/info.json") as f: mock_aioresponse.get( URL_TEMPLATE.format(MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/info.cgi"), body=f.read(), ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) data = await db.info() assert data == { "BUILD_NUMBER": "15870439", "DEVICE-TYPE": "DoorBird D2101V", "FIRMWARE": "000125", "RELAYS": [ "1", "2", "ghchdi@1", "ghchdi@2", "ghchdi@3", "ghdwkh@1", "ghdwkh@2", "ghdwkh@3", ], "WIFI_MAC_ADDR": "1234ABCD", } await db.close() @pytest.mark.asyncio async def test_info_auth_fails(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format(MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/info.cgi"), status=401, ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) with pytest.raises(ClientResponseError): await db.info() await db.close() @pytest.mark.asyncio async def test_reset(mock_aioresponse: aioresponses) -> None: mock_aioresponse.get( URL_TEMPLATE.format(MOCK_USER, MOCK_PASS, MOCK_HOST, "/bha-api/restart.cgi"), ) db = DoorBird(MOCK_HOST, MOCK_USER, MOCK_PASS) assert await db.restart() is True await db.close()