├── tests ├── __init__.py ├── data │ ├── __init__.py │ ├── test_types.py │ ├── test_viewer.py │ ├── test_doorlock.py │ ├── test_user.py │ └── test_event_detected_thumbnail.py ├── sample_data │ ├── __init__.py │ ├── sample_camera_heatmap.png │ ├── sample_camera_video.mp4 │ ├── sample_camera_snapshot.png │ ├── sample_camera_thumbnail.png │ ├── sample_public_api_camera_snapshot.png │ ├── sample_liveview.json │ ├── sample_keyrings.json │ ├── sample_constants.json │ ├── constants.py │ ├── sample_bridge.json │ ├── sample_ulp_users.json │ ├── sample_viewport.json │ ├── sample_chime.json │ ├── sample_light.json │ ├── sample_doorlock.json │ └── sample_sensor.json ├── test_dunder_main.py ├── common.py ├── test_cli.py └── test_api_polling.py ├── docs ├── _static │ └── .gitkeep ├── changelog.md ├── contributing.md ├── index.md ├── overrides │ ├── main.html │ └── partials │ │ └── toc-item.html ├── usage.md ├── installation.md ├── conf.py ├── Makefile ├── make.bat ├── api.md └── dev.md ├── src └── uiprotect │ ├── py.typed │ ├── _compat.py │ ├── release_cache.json │ ├── __main__.py │ ├── __init__.py │ ├── exceptions.py │ ├── cli │ ├── aiports.py │ ├── liveviews.py │ ├── viewers.py │ ├── doorlocks.py │ ├── lights.py │ ├── nvr.py │ ├── chimes.py │ ├── events.py │ ├── base.py │ └── sensors.py │ ├── data │ ├── convert.py │ ├── __init__.py │ └── websocket.py │ ├── stream.py │ └── websocket.py ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 2-feature-request.yml │ └── 1-bug_report.yml ├── workflows │ ├── poetry-upgrade.yml │ ├── issue-manager.yml │ ├── docker.yml │ └── ci.yml ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── labels.toml └── CODE_OF_CONDUCT.md ├── .bin ├── run-mypy ├── update-release-cache ├── lib │ └── common.sh └── test-code ├── .codespellrc ├── renovate.json ├── .docker ├── entrypoint.sh ├── bashrc └── docker-fix.sh ├── .dockerignore ├── .gitpod.yml ├── setup.py ├── commitlint.config.mjs ├── .all-contributorsrc ├── .editorconfig ├── .copier-answers.yml ├── .readthedocs.yml ├── templates └── CHANGELOG.md.j2 ├── .coveragerc ├── LICENSE ├── .vscode ├── launch.json └── tasks.json ├── .pre-commit-config.yaml ├── TESTDATA.md ├── mkdocs.yml ├── .devcontainer └── devcontainer.json ├── .gitignore ├── Dockerfile ├── CONTRIBUTING.md ├── LIVE_DATA_CI.md └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/uiprotect/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/sample_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ["uilibs"] 2 | -------------------------------------------------------------------------------- /.bin/run-mypy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | poetry run mypy src/uiprotect 4 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | ignore-words-list = socio-economic 3 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | (changelog)= 2 | 3 | ```{include} ../CHANGELOG.md 4 | 5 | ``` 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>browniebroke/renovate-configs:python"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | (contributing)= 2 | 3 | ```{include} ../CONTRIBUTING.md 4 | 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 | {% include-markdown "../README.md" %} 7 | -------------------------------------------------------------------------------- /tests/sample_data/sample_camera_heatmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uilibs/uiprotect/HEAD/tests/sample_data/sample_camera_heatmap.png -------------------------------------------------------------------------------- /tests/sample_data/sample_camera_video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uilibs/uiprotect/HEAD/tests/sample_data/sample_camera_video.mp4 -------------------------------------------------------------------------------- /.docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | /usr/local/bin/uiprotect "$@" 8 | -------------------------------------------------------------------------------- /tests/sample_data/sample_camera_snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uilibs/uiprotect/HEAD/tests/sample_data/sample_camera_snapshot.png -------------------------------------------------------------------------------- /tests/sample_data/sample_camera_thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uilibs/uiprotect/HEAD/tests/sample_data/sample_camera_thumbnail.png -------------------------------------------------------------------------------- /tests/sample_data/sample_public_api_camera_snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uilibs/uiprotect/HEAD/tests/sample_data/sample_public_api_camera_snapshot.png -------------------------------------------------------------------------------- /.bin/update-release-cache: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | poetry install 8 | poetry run uiprotect release-versions 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | test-data 3 | ufp-data 4 | *.mp3 5 | *.mp4 6 | .* 7 | *.xml 8 | Dockerfile 9 | LICENSE 10 | *.md 11 | !README.md 12 | tests/** 13 | *.egg-info 14 | *.js 15 | !.docker 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions 4 | url: https://github.com/uilibs/uiprotect/discussions/categories/q-a 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block outdated %} You're not viewing the latest 2 | version. 3 | 4 | Click here to go to latest. 5 | 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - command: | 3 | pip install poetry 4 | PIP_USER=false poetry install 5 | - command: | 6 | pip install pre-commit 7 | pre-commit install 8 | PIP_USER=false pre-commit install-hooks 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This is a shim to allow GitHub to detect the package, build is done with poetry 4 | # Taken from https://github.com/Textualize/rich 5 | 6 | import setuptools 7 | 8 | if __name__ == "__main__": 9 | setuptools.setup(name="uiprotect") 10 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | rules: { 4 | "header-max-length": [0, "always", Infinity], 5 | "body-max-line-length": [0, "always", Infinity], 6 | "footer-max-line-length": [0, "always", Infinity], 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | (usage)= 2 | 3 | # Usage 4 | 5 | Assuming that you've followed the {ref}`installations steps `, you're now ready to use this package. 6 | 7 | Call the command line interface: 8 | 9 | ```bash 10 | uiprotect --help 11 | ``` 12 | 13 | TODO: Document usage 14 | -------------------------------------------------------------------------------- /.github/workflows/poetry-upgrade.yml: -------------------------------------------------------------------------------- 1 | name: Upgrader 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "36 13 6 * *" 7 | 8 | jobs: 9 | upgrade: 10 | uses: browniebroke/github-actions/.github/workflows/poetry-upgrade.yml@v1 11 | secrets: 12 | gh_pat: ${{ secrets.GH_PAT }} 13 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | (installation)= 2 | 3 | # Installation 4 | 5 | The package is published on [PyPI](https://pypi.org/project/uiprotect/) and can be installed with `pip` (or any equivalent): 6 | 7 | ```bash 8 | pip install uiprotect 9 | ``` 10 | 11 | Next, see the {ref}`section about usage ` to see how to use it. 12 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "uiprotect", 3 | "projectOwner": "uilibs", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 80, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [], 13 | "contributorsPerLine": 7, 14 | "skipCi": true 15 | } 16 | -------------------------------------------------------------------------------- /src/uiprotect/_compat.py: -------------------------------------------------------------------------------- 1 | """Compat for external lib versions.""" 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from functools import cached_property 7 | else: 8 | try: 9 | from propcache.api import cached_property 10 | except ImportError: 11 | from propcache import cached_property 12 | 13 | __all__ = ("cached_property",) 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /src/uiprotect/release_cache.json: -------------------------------------------------------------------------------- 1 | ["1.13.4","1.13.7","1.14.11","1.15.0","1.16.9","1.17.1","1.17.2","1.17.3","1.17.4","1.18.0","1.18.1","1.19.0","1.19.1","1.19.2","1.20.0","1.20.1","1.20.2","1.20.3","1.21.0","1.21.2","1.21.3","1.21.4","1.21.5","1.21.6","2.0.0","2.0.1","2.1.1","2.1.2","2.10.10","2.10.11","2.11.21","2.2.11","2.2.2","2.2.6","2.2.9","2.6.17","2.7.18","2.7.33","2.7.34","2.8.28","2.8.35","2.9.42","3.0.22"] 2 | -------------------------------------------------------------------------------- /tests/test_dunder_main.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | 5 | def test_can_run_as_python_module(): 6 | """Run the CLI as a Python module.""" 7 | result = subprocess.run( 8 | [sys.executable, "-m", "uiprotect", "--help"], # S603,S607 9 | check=True, 10 | capture_output=True, 11 | ) 12 | assert result.returncode == 0 13 | assert b"uiprotect [OPTIONS]" in result.stdout 14 | -------------------------------------------------------------------------------- /docs/overrides/partials/toc-item.html: -------------------------------------------------------------------------------- 1 | {% if not page.meta.toc_depth or toc_item.level <= page.meta.toc_depth %} 2 |
  • 3 | {{ toc_item.title }} 4 | {% if toc_item.children %} 5 | 11 | {% endif %} 12 |
  • 13 | {% endif %} 14 | -------------------------------------------------------------------------------- /src/uiprotect/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | try: 6 | from dotenv import load_dotenv 7 | except ImportError: 8 | load_dotenv = None # type: ignore[assignment] 9 | 10 | from .cli import app 11 | 12 | 13 | def start() -> None: 14 | if load_dotenv is not None: 15 | env_file = os.path.join(os.getcwd(), ".env") 16 | if os.path.exists(env_file): 17 | load_dotenv(dotenv_path=env_file) 18 | else: 19 | load_dotenv() 20 | app() 21 | 22 | 23 | if __name__ == "__main__": 24 | start() 25 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | _commit: 368c483 3 | _src_path: gh:browniebroke/pypackage-template 4 | add_me_as_contributor: false 5 | cli_name: uiprotect 6 | copyright_year: '2024' 7 | documentation: true 8 | email: ui@koston.org 9 | full_name: UI Protect Maintainers 10 | github_username: uilibs 11 | has_cli: true 12 | initial_commit: true 13 | open_source_license: MIT 14 | package_name: uiprotect 15 | project_name: uiprotect 16 | project_short_description: Python API for Unifi Protect (Unofficial) 17 | project_slug: uiprotect 18 | run_poetry_install: true 19 | setup_github: true 20 | setup_pre_commit: true 21 | 22 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | jobs: 13 | post_create_environment: 14 | # Install poetry 15 | - python -m pip install poetry 16 | post_install: 17 | # Install dependencies, reusing RTD virtualenv 18 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs 19 | 20 | # Build documentation in the docs directory with mkdocs 21 | mkdocs: 22 | configuration: mkdocs.yml 23 | -------------------------------------------------------------------------------- /.bin/lib/common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function setRoot() { 4 | ROOT_PATH=$PWD 5 | while [[ $ROOT_PATH != / ]]; do 6 | output=$(find "$ROOT_PATH" -maxdepth 1 -mindepth 1 -name "pyproject.toml") 7 | if [[ -n $output ]]; then 8 | break 9 | fi 10 | # Note: if you want to ignore symlinks, use "$(realpath -s "$path"/..)" 11 | ROOT_PATH="$(readlink -f "$ROOT_PATH"/..)" 12 | done 13 | 14 | if [[ $ROOT_PATH == / ]]; then 15 | ROOT_PATH=$( realpath $( dirname "${BASH_SOURCE[0]}" )/../../ ) 16 | echo "Could not find \`pyproject.toml\`, following back to $( basename $ROOT_PATH )" 17 | else 18 | echo "Using project $( basename $ROOT_PATH )" 19 | fi 20 | } 21 | -------------------------------------------------------------------------------- /tests/sample_data/sample_liveview.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Txvocs Vwn", 3 | "isDefault": false, 4 | "isGlobal": true, 5 | "layout": 1, 6 | "slots": [ 7 | { 8 | "cameras": [ 9 | "1c9a2db4df6efda47a3509be", 10 | "e2ff0ade6be0f2a2beb61869", 11 | "c462c07dbd63ad805a7318c7", 12 | "4a333d993fe8e2e8472bc901", 13 | "ab3e27f2d55fad817dac7bb9", 14 | "f0cd15b8bed9e38899286a8c" 15 | ], 16 | "cycleMode": "motion", 17 | "cycleInterval": 10 18 | } 19 | ], 20 | "owner": "4c5f03a8c8bd48ad8e066285", 21 | "id": "d65bb41c14d6aa92bfa4a6d1", 22 | "modelKey": "liveview" 23 | } 24 | -------------------------------------------------------------------------------- /.bin/test-code: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | BASE_DIR=$( realpath $( dirname "${BASH_SOURCE[0]}" )/../ ) 8 | source "$BASE_DIR/.bin/lib/common.sh" 9 | setRoot 10 | 11 | WS_TIMEOUT="${WS_TIMEOUT:-40}" 12 | PYTEST_EXTRA_ARGS="${PYTEST_EXTRA_ARGS:-}" 13 | 14 | pushd "$ROOT_PATH" 2>&1 >/dev/null 15 | 16 | rm -rf .coverage.* .coverage 17 | 18 | echo -e "\nRunning tests (no benchmarks)..." 19 | poetry run pytest --timeout=10 --color=yes --cov-report=xml --benchmark-skip --maxfail=10 $PYTEST_EXTRA_ARGS 20 | 21 | echo -e "\nRunning benchmark tests..." 22 | poetry run pytest --timeout=$WS_TIMEOUT --cov-report=term --color=yes --benchmark-only -n=0 -rP $PYTEST_EXTRA_ARGS 23 | 24 | popd 2>&1 >/dev/null 25 | -------------------------------------------------------------------------------- /templates/CHANGELOG.md.j2: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | {%- for version, release in context.history.released.items() %} 4 | 5 | ## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) 6 | 7 | {%- for category, commits in release["elements"].items() %} 8 | {# Category title: Breaking, Fix, Documentation #} 9 | ### {{ category | capitalize }} 10 | {# List actual changes in the category #} 11 | {%- for commit in commits %} 12 | {% if commit is not none and commit.descriptions is defined %} 13 | - {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) 14 | {% endif %} 15 | {%- endfor %}{# for commit #} 16 | 17 | {%- endfor %}{# for category, commits #} 18 | 19 | {%- endfor %}{# for version, release #} 20 | -------------------------------------------------------------------------------- /src/uiprotect/__init__.py: -------------------------------------------------------------------------------- 1 | """Unofficial UniFi Protect Python API and Command Line Interface.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .api import ProtectApiClient 6 | from .exceptions import Invalid, NotAuthorized, NvrError 7 | from .utils import ( 8 | get_nested_attr, 9 | get_nested_attr_as_bool, 10 | get_top_level_attr_as_bool, 11 | make_enabled_getter, 12 | make_required_getter, 13 | make_value_getter, 14 | ) 15 | 16 | __all__ = [ 17 | "Invalid", 18 | "NotAuthorized", 19 | "NvrError", 20 | "ProtectApiClient", 21 | "get_nested_attr", 22 | "get_nested_attr_as_bool", 23 | "get_top_level_attr_as_bool", 24 | "make_enabled_getter", 25 | "make_required_getter", 26 | "make_value_getter", 27 | ] 28 | -------------------------------------------------------------------------------- /tests/sample_data/sample_keyrings.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "deviceType": "camera", 4 | "deviceId": "1c9a2db4df6efda47a3509be", 5 | "registryType": "nfc", 6 | "registryId": "64B2A621", 7 | "lastActivity": 1732904108638, 8 | "ulpUser": "73791632-9805-419c-8351-f3afaab8f064", 9 | "id": "672b573764f79603e400031d", 10 | "modelKey": "keyring" 11 | }, 12 | { 13 | "deviceType": "camera", 14 | "deviceId": "1c9a2db4df6efda47a3509be", 15 | "registryType": "fingerprint", 16 | "registryId": "1", 17 | "lastActivity": 1732904119477, 18 | "ulpUser": "0ef32f28-f654-404d-ab34-30e373e66436", 19 | "id": "672b573764f79603e44871d", 20 | "modelKey": "keyring" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # Project information 7 | project = "uiprotect" 8 | copyright = "2024, UI Protect Maintainers" 9 | author = "UI Protect Maintainers" 10 | release = "7.33.2" 11 | 12 | # General configuration 13 | extensions = [ 14 | "myst_parser", 15 | ] 16 | 17 | # The suffix of source filenames. 18 | source_suffix = [ 19 | ".rst", 20 | ".md", 21 | ] 22 | templates_path = [ 23 | "_templates", 24 | ] 25 | exclude_patterns = [ 26 | "_build", 27 | "Thumbs.db", 28 | ".DS_Store", 29 | ] 30 | 31 | # Options for HTML output 32 | html_theme = "furo" 33 | html_static_path = ["_static"] 34 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | # 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | commit-message: 14 | prefix: "chore(ci): " 15 | groups: 16 | github-actions: 17 | patterns: 18 | - "*" 19 | - package-ecosystem: "pip" # See documentation for possible values 20 | directory: "/" # Location of package manifests 21 | schedule: 22 | interval: "weekly" 23 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Common test utils.""" 2 | 3 | from uiprotect.data.base import ProtectModel 4 | 5 | 6 | def assert_equal_dump( 7 | obj1: list[ProtectModel] | dict[str, ProtectModel] | ProtectModel, 8 | obj2: list[ProtectModel] | dict[str, ProtectModel] | ProtectModel, 9 | ) -> bool: 10 | if isinstance(obj1, dict): 11 | obj1_dumped = {k: v.model_dump() for k, v in obj1.items()} 12 | elif isinstance(obj1, list): 13 | obj1_dumped = [item.model_dump() for item in obj1] 14 | else: 15 | obj1_dumped = obj1.model_dump() 16 | if isinstance(obj2, dict): 17 | obj2_dumped = {k: v.model_dump() for k, v in obj2.items()} 18 | elif isinstance(obj2, list): 19 | obj2_dumped = [item.model_dump() for item in obj2] 20 | else: 21 | obj2_dumped = obj2.model_dump() 22 | assert obj1_dumped == obj2_dumped 23 | -------------------------------------------------------------------------------- /.docker/bashrc: -------------------------------------------------------------------------------- 1 | BOLD="\[$(tput bold)\]" 2 | BLACK="\[$(tput setaf 0)\]" 3 | RED="\[$(tput setaf 1)\]" 4 | GREEN="\[$(tput setaf 2)\]" 5 | YELLOW="\[$(tput setaf 3)\]" 6 | BLUE="\[$(tput setaf 4)\]" 7 | MAGENTA="\[$(tput setaf 5)\]" 8 | CYAN="\[$(tput setaf 6)\]" 9 | WHITE="\[$(tput setaf 7)\]" 10 | RESET="\[$(tput sgr0)\]" 11 | 12 | function prompt_command { 13 | RET=$? 14 | if [[ "$(id -u)" -eq 0 ]]; then 15 | PS1="$BOLD$RED" 16 | else 17 | PS1="$GREEN" 18 | fi 19 | 20 | branch="$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/ (\1)/')" 21 | PS1+="\u$RESET:$YELLOW\w$RESET$CYAN$branch$RESET " 22 | 23 | if [[ "$RET" -eq 0 ]]; then 24 | PS1+="$BOLD$GREEN" 25 | else 26 | PS1+="$RET $BOLD$RED" 27 | fi 28 | PS1+="\\$ $RESET" 29 | export PS1 30 | } 31 | export PROMPT_COMMAND=prompt_command 32 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | .PHONY: help livehtml Makefile 12 | 13 | # Put it first so that "make" without argument is like "make help". 14 | help: 15 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 16 | 17 | # Build, watch and serve docs with live reload 18 | livehtml: 19 | sphinx-autobuild -b html -c . $(SOURCEDIR) $(BUILDDIR)/html 20 | 21 | # Catch-all target: route all unknown targets to Sphinx using the new 22 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 23 | %: Makefile 24 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 25 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | issues: 10 | types: 11 | - labeled 12 | pull_request_target: 13 | types: 14 | - labeled 15 | workflow_dispatch: 16 | 17 | jobs: 18 | issue-manager: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: tiangolo/issue-manager@0.6.0 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | config: > 25 | { 26 | "answered": { 27 | "message": "Assuming the original issue was solved, it will be automatically closed now." 28 | }, 29 | "waiting": { 30 | "message": "Automatically closing. To re-open, please provide the additional information requested." 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = src/uiprotect 3 | 4 | omit = 5 | site/* 6 | src/uiprotect/cli/* 7 | src/uiprotect/test_util/* 8 | 9 | [report] 10 | omit = 11 | site/* 12 | src/uiprotect/cli/* 13 | src/uiprotect/test_util/* 14 | 15 | # Regexes for lines to exclude from consideration 16 | exclude_lines = 17 | # Have to re-enable the standard pragma 18 | pragma: no cover 19 | 20 | # Don't complain about missing debug-only code: 21 | def __repr__ 22 | 23 | # Don't complain if tests don't hit defensive assertion code: 24 | raise AssertionError 25 | raise NotImplementedError 26 | raise exceptions.NotSupportedError 27 | 28 | # TYPE_CHECKING and @overload blocks are never executed during pytest run 29 | # except ImportError: are never executed as well 30 | if TYPE_CHECKING: 31 | @overload 32 | except ImportError: 33 | if _LOGGER.isEnabledFor(logging.DEBUG): 34 | -------------------------------------------------------------------------------- /.docker/docker-fix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | DOCKER_SOCK="" 8 | if [[ -e /var/run/docker-host.sock ]]; then 9 | DOCKER_SOCK="/var/run/docker-host.sock" 10 | else 11 | if [[ -e /var/run/docker.sock ]]; then 12 | DOCKER_SOCK="/var/run/docker.sock" 13 | fi 14 | fi 15 | 16 | # fix the group ID of the docker group so it can write to /var/run/docker.sock 17 | if [[ -n "$DOCKER_SOCK" ]]; then 18 | DOCKER_GID=$(ls -la $DOCKER_SOCK | awk '{print $4}') 19 | if [[ $DOCKER_GID != 'docker' ]]; then 20 | sudo groupmod -g $DOCKER_GID docker 21 | if [[ -f '/.codespaces' ]]; then 22 | echo -e '\e[1;31mYou must stop and restart the Codespace to be able to access docker properly' 23 | else 24 | echo -e '\e[1;31mYou must run the `Reload Window` command for be able to access docker properly' 25 | fi 26 | fi 27 | fi 28 | -------------------------------------------------------------------------------- /tests/sample_data/sample_constants.json: -------------------------------------------------------------------------------- 1 | { 2 | "server_name": "Uiiji Ryoyo", 3 | "server_id": "4B8290F6D7A3", 4 | "server_version": "1.21.0-beta.3", 5 | "server_ip": "192.168.102.63", 6 | "server_model": "UNVR-PRO", 7 | "last_update_id": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", 8 | "user_id": "4c5f03a8c8bd48ad8e066285", 9 | "counts": { 10 | "camera": 11, 11 | "user": 7, 12 | "group": 2, 13 | "liveview": 5, 14 | "viewer": 1, 15 | "display": 0, 16 | "light": 1, 17 | "bridge": 2, 18 | "sensor": 4, 19 | "doorlock": 1, 20 | "chime": 0, 21 | "schedule": 0 22 | }, 23 | "time": "2022-01-24T20:23:32.433278+00:00", 24 | "event_count": 1379, 25 | "camera_thumbnail": "e-90f051ebf085214d331644a5", 26 | "camera_heatmap": "e-90f051ebf085214d331644a5", 27 | "camera_video_length": 23, 28 | "camera_online": true 29 | } 30 | -------------------------------------------------------------------------------- /tests/sample_data/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | UFP_SAMPLE_DIR = os.environ.get("UFP_SAMPLE_DIR") 9 | if UFP_SAMPLE_DIR: 10 | DATA_FILE = Path(UFP_SAMPLE_DIR) / "sample_constants.json" 11 | else: 12 | DATA_FILE = Path(__file__).parent / "sample_constants.json" 13 | 14 | 15 | class ConstantData: 16 | _data: dict[str, Any] | None = None 17 | 18 | def __getitem__(self, key): 19 | return self.data().__getitem__(key) 20 | 21 | def __contains__(self, key): 22 | return self.data().__contains__(key) 23 | 24 | def get(self, key, default=None): 25 | return self.data().get(key, default) 26 | 27 | def data(self): 28 | if self._data is None: 29 | with DATA_FILE.open(encoding="utf-8") as f: 30 | self._data = json.load(f) 31 | return self._data 32 | 33 | 34 | CONSTANTS = ConstantData() 35 | -------------------------------------------------------------------------------- /tests/sample_data/sample_bridge.json: -------------------------------------------------------------------------------- 1 | { 2 | "mac": "A28D0DB15AE1", 3 | "host": "192.168.231.68", 4 | "connectionHost": "192.168.102.63", 5 | "type": "UFP-UAP-B", 6 | "name": "Vdr Fzr", 7 | "upSince": 1639807977891, 8 | "uptime": 3247782, 9 | "lastSeen": 1643055759891, 10 | "connectedSince": 1642374159304, 11 | "state": "CONNECTED", 12 | "hardwareRevision": 19, 13 | "firmwareVersion": "0.3.1", 14 | "latestFirmwareVersion": null, 15 | "firmwareBuild": null, 16 | "isUpdating": false, 17 | "isAdopting": false, 18 | "isAdopted": true, 19 | "isAdoptedByOther": false, 20 | "isProvisioned": false, 21 | "isRebooting": false, 22 | "isSshEnabled": false, 23 | "canAdopt": false, 24 | "isAttemptingToConnect": false, 25 | "wiredConnectionState": { 26 | "phyRate": null 27 | }, 28 | "id": "1f5a055254fb9169d7536fb9", 29 | "isConnected": true, 30 | "platform": "mt7621", 31 | "modelKey": "bridge" 32 | } 33 | -------------------------------------------------------------------------------- /tests/sample_data/sample_ulp_users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ulpId": "73791632-9805-419c-8351-f3afaab8f064", 4 | "firstName": "John Doe", 5 | "lastName": "", 6 | "fullName": "John Doe", 7 | "avatar": "", 8 | "status": "ACTIVE", 9 | "id": "73791632-9805-419c-8351-f3afaab8f064", 10 | "modelKey": "ulpUser" 11 | }, 12 | { 13 | "ulpId": "ddec43ea-1845-4a50-bdab-83bcd4b3c81d", 14 | "firstName": "Jane Doe", 15 | "lastName": "", 16 | "fullName": "Jane Doe", 17 | "avatar": "", 18 | "status": "ACTIVE", 19 | "id": "ddec43ea-1845-4a50-bdab-83bcd4b3c81d", 20 | "modelKey": "ulpUser" 21 | }, 22 | { 23 | "ulpId": "0ef32f28-f654-404d-ab34-30e373e66436", 24 | "firstName": "You Know Who", 25 | "lastName": "", 26 | "fullName": "You Know Who", 27 | "avatar": "/proxy/users/public/avatar/1732954155_589f14b3-b137-4487-9823-db62bb793c19.jpg", 28 | "status": "DEACTIVATED", 29 | "id": "0ef32f28-f654-404d-ab34-30e373e66436", 30 | "modelKey": "ulpUser" 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /tests/sample_data/sample_viewport.json: -------------------------------------------------------------------------------- 1 | { 2 | "mac": "85EEA7DF1601", 3 | "host": "192.168.27.69", 4 | "connectionHost": "192.168.102.63", 5 | "type": "UP Viewport", 6 | "name": "Uknkgc Jpyuso", 7 | "upSince": 1642661973905, 8 | "uptime": 393786, 9 | "lastSeen": 1643055759905, 10 | "connectedSince": 1642672730441, 11 | "state": "CONNECTED", 12 | "hardwareRevision": null, 13 | "firmwareVersion": "1.2.54", 14 | "latestFirmwareVersion": "1.2.54", 15 | "firmwareBuild": "dcfb16f3.210907.625", 16 | "isUpdating": false, 17 | "isAdopting": false, 18 | "isAdopted": true, 19 | "isAdoptedByOther": false, 20 | "isProvisioned": false, 21 | "isRebooting": false, 22 | "isSshEnabled": false, 23 | "canAdopt": false, 24 | "isAttemptingToConnect": false, 25 | "streamLimit": 16, 26 | "softwareVersion": "1.2.54", 27 | "wiredConnectionState": { 28 | "phyRate": 1000 29 | }, 30 | "liveview": "bf41f6b5ba0ddd046eeb1c98", 31 | "id": "081c58d13ad7e198d3dddffa", 32 | "isConnected": true, 33 | "marketName": "UP ViewPort", 34 | "modelKey": "viewer" 35 | } 36 | -------------------------------------------------------------------------------- /src/uiprotect/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class UnifiProtectError(Exception): 5 | """Base class for all other UniFi Protect errors""" 6 | 7 | 8 | class StreamError(UnifiProtectError): 9 | """Expcetion raised when trying to stream content""" 10 | 11 | 12 | class DataDecodeError(UnifiProtectError): 13 | """Exception raised when trying to decode a UniFi Protect object""" 14 | 15 | 16 | class WSDecodeError(UnifiProtectError): 17 | """Exception raised when decoding Websocket packet""" 18 | 19 | 20 | class WSEncodeError(UnifiProtectError): 21 | """Exception raised when encoding Websocket packet""" 22 | 23 | 24 | class ClientError(UnifiProtectError): 25 | """Base Class for all other UniFi Protect client errors""" 26 | 27 | 28 | class BadRequest(ClientError): 29 | """Invalid request from API Client""" 30 | 31 | 32 | class Invalid(ClientError): 33 | """Invalid return from Authorization Request.""" 34 | 35 | 36 | class NotAuthorized(PermissionError, BadRequest): 37 | """Wrong username, password or permission error.""" 38 | 39 | 40 | class NvrError(ClientError): 41 | """Other error.""" 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2024 UI Protect Maintainers 5 | Copyright (c) 2020 Bjarne Riis 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | toc_depth: 3 5 | --- 6 | 7 | # API Reference 8 | 9 | ## API Client (`uiprotect.api`) 10 | 11 | ::: uiprotect.api 12 | options: 13 | show_root_toc_entry: false 14 | show_signature_annotations: true 15 | show_source: false 16 | heading_level: 3 17 | 18 | ## Data Models (`uiprotect.data`) 19 | 20 | ::: uiprotect.data 21 | options: 22 | show_root_toc_entry: false 23 | show_signature_annotations: true 24 | show_source: false 25 | heading_level: 3 26 | 27 | ## Exceptions (`uiprotect.exception`) 28 | 29 | ::: uiprotect.exceptions 30 | options: 31 | show_root_toc_entry: false 32 | show_signature_annotations: true 33 | show_source: false 34 | heading_level: 3 35 | 36 | ## Stream (`uiprotect.stream`) 37 | 38 | ::: uiprotect.stream 39 | options: 40 | show_root_toc_entry: false 41 | show_signature_annotations: true 42 | show_source: false 43 | heading_level: 3 44 | 45 | ## Utils (`uiprotect.utils`) 46 | 47 | ::: uiprotect.utils 48 | options: 49 | show_root_toc_entry: false 50 | show_signature_annotations: true 51 | show_source: false 52 | heading_level: 3 53 | 54 | ## Websocket (`uiprotect.websocket`) 55 | 56 | ::: uiprotect.websocket 57 | options: 58 | show_root_toc_entry: false 59 | show_signature_annotations: true 60 | show_source: false 61 | heading_level: 3 62 | -------------------------------------------------------------------------------- /tests/sample_data/sample_chime.json: -------------------------------------------------------------------------------- 1 | { 2 | "mac": "BEEEE2FBE413", 3 | "host": "192.168.144.146", 4 | "connectionHost": "192.168.234.27", 5 | "type": "UP Chime", 6 | "name": "Xaorvu Tvsv", 7 | "upSince": 1651882870009, 8 | "uptime": 567870, 9 | "lastSeen": 1652450740009, 10 | "connectedSince": 1652448904587, 11 | "state": "CONNECTED", 12 | "hardwareRevision": null, 13 | "firmwareVersion": "1.3.4", 14 | "latestFirmwareVersion": "1.3.4", 15 | "firmwareBuild": "58bd350.220401.1859", 16 | "isUpdating": false, 17 | "isAdopting": false, 18 | "isAdopted": true, 19 | "isAdoptedByOther": false, 20 | "isProvisioned": false, 21 | "isRebooting": false, 22 | "isSshEnabled": true, 23 | "canAdopt": false, 24 | "isAttemptingToConnect": false, 25 | "volume": 100, 26 | "isProbingForWifi": false, 27 | "apMac": null, 28 | "apRssi": null, 29 | "elementInfo": null, 30 | "lastRing": 1652116059940, 31 | "isWirelessUplinkEnabled": true, 32 | "wiredConnectionState": { 33 | "phyRate": null 34 | }, 35 | "wifiConnectionState": { 36 | "channel": null, 37 | "frequency": null, 38 | "phyRate": null, 39 | "signalQuality": 100, 40 | "signalStrength": -44, 41 | "ssid": null 42 | }, 43 | "cameraIds": [], 44 | "id": "cf1a330397c08f919d02bd7c", 45 | "isConnected": true, 46 | "marketName": "UP Chime", 47 | "modelKey": "chime" 48 | } 49 | -------------------------------------------------------------------------------- /tests/data/test_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | import pytest 6 | 7 | from uiprotect.data.types import ModelType, get_field_type 8 | 9 | 10 | @pytest.mark.asyncio() 11 | async def test_model_type_from_string(): 12 | assert ModelType.from_string("camera") is ModelType.CAMERA 13 | assert ModelType.from_string("invalid") is ModelType.UNKNOWN 14 | 15 | 16 | @pytest.mark.parametrize( 17 | ("annotation", "origin", "type_"), 18 | [ 19 | (bytearray, None, bytearray), 20 | (typing.get_origin(dict[str, int]), None, dict), 21 | (dict, None, dict), 22 | # Extract value type from list, set, dict 23 | (list[int], list, int), 24 | (set[int], set, int), 25 | (dict[str, int], dict, int), 26 | # Extract type from Annotated 27 | (typing.Annotated[int, "Hello World"], None, int), 28 | # Remove '| None' from Union and extract remaining value type 29 | (int | None, None, int), 30 | (list[int] | None, list, int), 31 | (typing.Annotated[int, "Hello World"] | None, None, int), 32 | # Leave 'normal' unions as is 33 | (int | str, None, int | str), 34 | (int | str | bytes, None, int | str | bytes), 35 | ], 36 | ) 37 | def test_get_field_type(annotation, origin, type_): 38 | res = get_field_type(annotation) 39 | assert origin == res[0] 40 | assert type_ == res[1] 41 | 42 | 43 | def test_get_field_type_error(): 44 | with pytest.raises(ValueError, match="Type annotation cannot be None"): 45 | get_field_type(None) 46 | -------------------------------------------------------------------------------- /tests/sample_data/sample_light.json: -------------------------------------------------------------------------------- 1 | { 2 | "mac": "534D8B001B14", 3 | "host": "192.168.234.163", 4 | "connectionHost": "192.168.102.63", 5 | "type": "UP FloodLight", 6 | "name": "Pjq Osgvztd", 7 | "upSince": 1638128967900, 8 | "uptime": 4926792, 9 | "lastSeen": 1643055759900, 10 | "connectedSince": 1642902624923, 11 | "state": "CONNECTED", 12 | "hardwareRevision": null, 13 | "firmwareVersion": "1.9.3", 14 | "latestFirmwareVersion": "1.9.3", 15 | "firmwareBuild": "g990c553.211105.251", 16 | "isUpdating": false, 17 | "isAdopting": false, 18 | "isAdopted": true, 19 | "isAdoptedByOther": false, 20 | "isProvisioned": false, 21 | "isRebooting": false, 22 | "isSshEnabled": true, 23 | "canAdopt": false, 24 | "isAttemptingToConnect": false, 25 | "isPirMotionDetected": true, 26 | "lastMotion": 1643055813957, 27 | "isDark": false, 28 | "isLightOn": false, 29 | "isLocating": false, 30 | "wiredConnectionState": { 31 | "phyRate": 100 32 | }, 33 | "lightDeviceSettings": { 34 | "isIndicatorEnabled": false, 35 | "ledLevel": 6, 36 | "luxSensitivity": "medium", 37 | "pirDuration": 120000, 38 | "pirSensitivity": 46 39 | }, 40 | "lightOnSettings": { 41 | "isLedForceOn": false 42 | }, 43 | "lightModeSettings": { 44 | "mode": "off", 45 | "enableAt": "fulltime" 46 | }, 47 | "camera": "1c9a2db4df6efda47a3509be", 48 | "id": "3ada785d6626c88d7d52446a", 49 | "isConnected": true, 50 | "isCameraPaired": true, 51 | "marketName": "UP FloodLight", 52 | "modelKey": "light" 53 | } 54 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Subcommand: shell", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "module": "uiprotect", 9 | "args": [ 10 | "-u", 11 | "shell", 12 | ] 13 | }, 14 | { 15 | "name": "Run Subcommand: generate-sample-data", 16 | "type": "debugpy", 17 | "request": "launch", 18 | "module": "uiprotect", 19 | "args": [ 20 | "generate-sample-data", 21 | "-w", 22 | "30", 23 | "--actual", 24 | ], 25 | "env": { 26 | "UFP_SAMPLE_DIR": "${workspaceFolder}/test-data" 27 | } 28 | }, 29 | { 30 | "name": "Debug Tests (pytest/poetry)", 31 | "type": "debugpy", 32 | "request": "launch", 33 | "module": "pytest", 34 | "justMyCode": false, 35 | "console": "integratedTerminal", 36 | "args": [ 37 | "-v" 38 | ], 39 | "python": "/usr/local/bin/python3", 40 | "cwd": "${workspaceFolder}" 41 | }, 42 | { 43 | "name": "Python: Debug Test File", 44 | "type": "debugpy", 45 | "request": "launch", 46 | "module": "pytest", 47 | "justMyCode": false, 48 | "console": "integratedTerminal", 49 | "args": [ 50 | "${file}" 51 | ], 52 | "cwd": "${workspaceFolder}" 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: "CHANGELOG.md|.copier-answers.yml|.all-contributorsrc" 4 | default_stages: [pre-commit] 5 | 6 | ci: 7 | autofix_commit_msg: "chore(pre-commit.ci): auto fixes" 8 | autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" 9 | skip: [mypy] 10 | 11 | repos: 12 | - repo: https://github.com/commitizen-tools/commitizen 13 | rev: v4.10.0 14 | hooks: 15 | - id: commitizen 16 | stages: [commit-msg] 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v6.0.0 19 | hooks: 20 | - id: debug-statements 21 | - id: check-builtin-literals 22 | - id: check-case-conflict 23 | - id: check-docstring-first 24 | - id: check-toml 25 | - id: check-xml 26 | - id: detect-private-key 27 | - id: end-of-file-fixer 28 | - id: trailing-whitespace 29 | - repo: https://github.com/python-poetry/poetry 30 | rev: 2.2.1 31 | hooks: 32 | - id: poetry-check 33 | - repo: https://github.com/pre-commit/mirrors-prettier 34 | rev: v4.0.0-alpha.8 35 | hooks: 36 | - id: prettier 37 | args: ["--tab-width", "2"] 38 | - repo: https://github.com/astral-sh/ruff-pre-commit 39 | rev: v0.14.7 40 | hooks: 41 | - id: ruff 42 | args: [--fix, --exit-non-zero-on-fix] 43 | - id: ruff-format 44 | - repo: local 45 | hooks: 46 | - id: mypy 47 | name: mypy 48 | language: script 49 | entry: ./.bin/run-mypy 50 | types_or: [python, pyi] 51 | require_serial: true 52 | files: ^(src/uiprotect)/.+\.(py|pyi)$ 53 | -------------------------------------------------------------------------------- /TESTDATA.md: -------------------------------------------------------------------------------- 1 | # Generating Sample Data to help with Testing/Features 2 | 3 | ## Setup Python 4 | 5 | ### With Home Assistant (via the `unifiprotect` integration) 6 | 7 | 1. Make sure you have the _Community_ [SSH & Web Terminal Add-On](https://github.com/hassio-addons/addon-ssh) install 8 | 2. Open an SSH or Web terminal to the add-on 9 | 3. Run 10 | 11 | ```bash 12 | docker exec -it homeassistant bash 13 | ``` 14 | 15 | Use `/config/ufp-data` for your `-o` argument below. 16 | 17 | ### Without Home Assistant 18 | 19 | 1. Ensure Python 3.10+ is installed 20 | 2. Install uiprotect by issuing this command: `pip install uiprotect` 21 | 22 | Use `./ufp-data` for your `-o` argument below. 23 | 24 | ## Generate Data 25 | 26 | Inside the Python environment from above, run the following command. If you are using Home Assistant, use `-o /config/ufp-data` so it will output data in your config folder to make it easy to get off of your HA instance. 27 | 28 | ```bash 29 | uiprotect generate-sample-data -o /path/to/ufp-data --actual -w 300 -v -U your-unifi-protect-username -P your-unifi-protect-password -a ip-address-to-unifi-protect 30 | ``` 31 | 32 | This will generate a ton of data from your UniFi Protect instance for 5 minutes. During this time, go do stuff with your sensor to trigger events. When it is all done, you will have a bunch of json files in `/path/to/ufp-data`. Download those and zip them up and send them to us. 33 | 34 | It is recommended that you _do not_ post these files publicly as they do have some sensitive data in them related to your UniFi Network. If you would like you manually clean out the sensitive data from these files, feel free. 35 | 36 | The most critical data for you to remove are the `authUserId`, `accessKey`, and `users` keys from the `sample_bootstrap.json` file. 37 | -------------------------------------------------------------------------------- /src/uiprotect/cli/aiports.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | import typer 6 | 7 | from ..api import ProtectApiClient 8 | from ..cli import base 9 | from ..data import AiPort 10 | 11 | app = typer.Typer(rich_markup_mode="rich") 12 | 13 | ARG_DEVICE_ID = typer.Argument( 14 | None, help="ID of AiPort device to select for subcommands" 15 | ) 16 | 17 | 18 | @dataclass 19 | class AiPortContext(base.CliContext): 20 | devices: dict[str, AiPort] 21 | device: AiPort | None = None 22 | 23 | 24 | ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) 25 | 26 | 27 | @app.callback(invoke_without_command=True) 28 | def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: 29 | """ 30 | AiPort device CLI. 31 | 32 | Returns full list of AiPorts without any arguments passed. 33 | """ 34 | protect: ProtectApiClient = ctx.obj.protect 35 | context = AiPortContext( 36 | protect=ctx.obj.protect, 37 | device=None, 38 | devices=protect.bootstrap.aiports, 39 | output_format=ctx.obj.output_format, 40 | ) 41 | ctx.obj = context 42 | 43 | if device_id is not None and device_id not in ALL_COMMANDS: 44 | if (device := protect.bootstrap.aiports.get(device_id)) is None: 45 | typer.secho("Invalid aiport ID", fg="red") 46 | raise typer.Exit(1) 47 | ctx.obj.device = device 48 | 49 | if not ctx.invoked_subcommand: 50 | if device_id in ALL_COMMANDS: 51 | ctx.invoke(ALL_COMMANDS[device_id], ctx) 52 | return 53 | 54 | if ctx.obj.device is not None: 55 | base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) 56 | return 57 | 58 | base.print_unifi_dict(ctx.obj.devices) 59 | -------------------------------------------------------------------------------- /tests/sample_data/sample_doorlock.json: -------------------------------------------------------------------------------- 1 | { 2 | "mac": "F10599AB6955", 3 | "host": null, 4 | "connectionHost": "192.168.102.63", 5 | "type": "UFP-LOCK-R", 6 | "name": "Wkltg Qcjxv", 7 | "upSince": 1643050461849, 8 | "uptime": null, 9 | "lastSeen": 1643052750858, 10 | "connectedSince": 1643052765849, 11 | "state": "CONNECTED", 12 | "hardwareRevision": 7, 13 | "firmwareVersion": "1.2.0", 14 | "latestFirmwareVersion": "1.2.0", 15 | "firmwareBuild": null, 16 | "isUpdating": false, 17 | "isAdopting": false, 18 | "isAdopted": true, 19 | "isAdoptedByOther": false, 20 | "isProvisioned": false, 21 | "isRebooting": false, 22 | "isSshEnabled": false, 23 | "canAdopt": false, 24 | "isAttemptingToConnect": false, 25 | "credentials": "955756200c7f43936df9d5f7865f058e1528945aac0f0cb27cef960eb58f17db", 26 | "lockStatus": "CLOSING", 27 | "enableHomekit": false, 28 | "autoCloseTimeMs": 15000, 29 | "wiredConnectionState": { 30 | "phyRate": null 31 | }, 32 | "ledSettings": { 33 | "isEnabled": true 34 | }, 35 | "bluetoothConnectionState": { 36 | "signalQuality": 62, 37 | "signalStrength": -65 38 | }, 39 | "batteryStatus": { 40 | "percentage": 100, 41 | "isLow": false 42 | }, 43 | "bridge": "61b3f5c90050a703e700042a", 44 | "camera": "e2ff0ade6be0f2a2beb61869", 45 | "bridgeCandidates": [], 46 | "id": "1c812e80fd693ab51535be38", 47 | "isConnected": true, 48 | "hasHomekit": false, 49 | "marketName": "UP DoorLock", 50 | "modelKey": "doorlock", 51 | "privateToken": "MsjIV0UUpMWuAQZvJnCOfC1K9UAfgqDKCIcWtANWIuW66OXLwSgMbNEG2MEkL2TViSkMbJvFxAQEyHU0EJeVCWzY6dGHGuKXFXZMqJWZivBGDC8JoXiRxNIBqHZtXQKXZIoXWKLmhBL7SDxLoFNYEYNNLUGKGFBBGX2oNLi8KRW3SDSUTTWJZNwAUs8GKeJJ" 52 | } 53 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | ### Description of change 11 | 12 | 25 | 26 | ### Pull-Request Checklist 27 | 28 | 35 | 36 | - [ ] Code is up-to-date with the `main` branch 37 | - [ ] This pull request follows the [contributing guidelines](https://github.com/uilibs/uiprotect/blob/main/CONTRIBUTING.md). 38 | - [ ] This pull request links relevant issues as `Fixes #0000` 39 | - [ ] There are new or updated unit tests validating the change 40 | - [ ] Documentation has been updated to reflect this change 41 | - [ ] The new commits follow conventions outlined in the [conventional commit spec](https://www.conventionalcommits.org/en/v1.0.0/), such as "fix(api): prevent racing of requests". 42 | 43 | > - If pre-commit.ci is failing, try `pre-commit run -a` for further information. 44 | > - If CI / test is failing, try `poetry run pytest` for further information. 45 | 46 | 49 | -------------------------------------------------------------------------------- /tests/data/test_viewer.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | 10 | from tests.conftest import TEST_VIEWPORT_EXISTS 11 | from uiprotect.data.websocket import WSAction, WSSubscriptionMessage 12 | from uiprotect.exceptions import BadRequest 13 | 14 | if TYPE_CHECKING: 15 | from uiprotect.data import Liveview, Viewer 16 | 17 | 18 | @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") 19 | @pytest.mark.asyncio() 20 | async def test_viewer_set_liveview_invalid(viewer_obj: Viewer, liveview_obj: Liveview): 21 | viewer_obj.api.api_request.reset_mock() 22 | 23 | liveview = liveview_obj.update_from_dict({"id": "bad_id"}) 24 | 25 | with pytest.raises(BadRequest): 26 | await viewer_obj.set_liveview(liveview) 27 | 28 | assert not viewer_obj.api.api_request.called 29 | 30 | 31 | @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") 32 | @pytest.mark.asyncio() 33 | async def test_viewer_set_liveview_valid(viewer_obj: Viewer, liveview_obj: Liveview): 34 | viewer_obj.api.api_request.reset_mock() 35 | viewer_obj.api.emit_message = Mock() 36 | 37 | viewer_obj.liveview_id = "bad_id" 38 | 39 | await viewer_obj.set_liveview(liveview_obj) 40 | viewer_obj.api.api_request.assert_called_with( 41 | f"viewers/{viewer_obj.id}", 42 | method="patch", 43 | json={"liveview": liveview_obj.id}, 44 | ) 45 | 46 | # old/new is actually the same here since the client 47 | # generating the message is the one that changed it 48 | viewer_obj.api.emit_message.assert_called_with( 49 | WSSubscriptionMessage( 50 | action=WSAction.UPDATE, 51 | new_update_id=viewer_obj.api.bootstrap.last_update_id, 52 | changed_data={"liveview_id": liveview_obj.id}, 53 | old_obj=viewer_obj, 54 | new_obj=viewer_obj, 55 | ), 56 | ) 57 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | from unittest.mock import MagicMock 3 | 4 | import aiohttp 5 | from typer.testing import CliRunner 6 | 7 | from uiprotect.cli import _is_ssl_error, app 8 | 9 | runner = CliRunner() 10 | 11 | 12 | def test_help(): 13 | """The help message includes the CLI name.""" 14 | result = runner.invoke(app, ["--help"]) 15 | assert result.exit_code == 0 16 | assert "UniFi Protect CLI" in result.stdout 17 | 18 | 19 | def test_is_ssl_error_with_ssl_exceptions(): 20 | """SSL-related exceptions should be detected.""" 21 | # Direct SSL errors 22 | assert ( 23 | _is_ssl_error(ssl.SSLCertVerificationError("certificate verify failed")) is True 24 | ) 25 | 26 | # Mock aiohttp SSL errors (they require complex OSError arguments) 27 | ssl_error = MagicMock(spec=aiohttp.ClientConnectorSSLError) 28 | ssl_error.__class__ = aiohttp.ClientConnectorSSLError 29 | assert _is_ssl_error(ssl_error) is True 30 | 31 | cert_error = MagicMock(spec=aiohttp.ClientConnectorCertificateError) 32 | cert_error.__class__ = aiohttp.ClientConnectorCertificateError 33 | assert _is_ssl_error(cert_error) is True 34 | 35 | 36 | def test_is_ssl_error_with_wrapped_ssl_exceptions(): 37 | """SSL exceptions wrapped in other exceptions should be detected.""" 38 | ssl_error = ssl.SSLCertVerificationError() 39 | wrapped = RuntimeError("Connection failed") 40 | wrapped.__cause__ = ssl_error 41 | assert _is_ssl_error(wrapped) is True 42 | 43 | # Deeply nested 44 | outer = ValueError("Outer error") 45 | outer.__cause__ = wrapped 46 | assert _is_ssl_error(outer) is True 47 | 48 | 49 | def test_is_ssl_error_with_non_ssl_exceptions(): 50 | """Non-SSL exceptions should not be detected as SSL errors.""" 51 | assert _is_ssl_error(ValueError("some error")) is False 52 | assert _is_ssl_error(RuntimeError("connection refused")) is False 53 | assert _is_ssl_error(aiohttp.ClientError("generic error")) is False 54 | assert _is_ssl_error(ConnectionError("network error")) is False 55 | -------------------------------------------------------------------------------- /src/uiprotect/cli/liveviews.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | import typer 6 | 7 | from ..api import ProtectApiClient 8 | from ..cli import base 9 | from ..data import Liveview 10 | 11 | app = typer.Typer(rich_markup_mode="rich") 12 | 13 | ARG_DEVICE_ID = typer.Argument(None, help="ID of liveview to select for subcommands") 14 | ALL_COMMANDS = {"list-ids": app.command(name="list-ids")(base.list_ids)} 15 | 16 | app.command(name="protect-url")(base.protect_url) 17 | 18 | 19 | @dataclass 20 | class LiveviewContext(base.CliContext): 21 | devices: dict[str, Liveview] 22 | device: Liveview | None = None 23 | 24 | 25 | @app.callback(invoke_without_command=True) 26 | def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: 27 | """ 28 | Liveviews CLI. 29 | 30 | Returns full list of Liveviews without any arguments passed. 31 | """ 32 | protect: ProtectApiClient = ctx.obj.protect 33 | context = LiveviewContext( 34 | protect=ctx.obj.protect, 35 | device=None, 36 | devices=protect.bootstrap.liveviews, 37 | output_format=ctx.obj.output_format, 38 | ) 39 | ctx.obj = context 40 | 41 | if device_id is not None and device_id not in ALL_COMMANDS: 42 | if (device := protect.bootstrap.liveviews.get(device_id)) is None: 43 | typer.secho("Invalid liveview ID", fg="red") 44 | raise typer.Exit(1) 45 | ctx.obj.device = device 46 | 47 | if not ctx.invoked_subcommand: 48 | if device_id in ALL_COMMANDS: 49 | ctx.invoke(ALL_COMMANDS[device_id], ctx) 50 | return 51 | 52 | if ctx.obj.device is not None: 53 | base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) 54 | return 55 | 56 | base.print_unifi_dict(ctx.obj.devices) 57 | 58 | 59 | @app.command() 60 | def owner(ctx: typer.Context) -> None: 61 | """Gets the owner for the liveview.""" 62 | base.require_device_id(ctx) 63 | obj: Liveview = ctx.obj.device 64 | base.print_unifi_obj(obj.owner, ctx.obj.output_format) 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: A clear and concise description of what the problem is. 10 | value: I'm always frustrated when 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: solution 15 | attributes: 16 | label: Describe alternatives you've considered 17 | description: A clear and concise description of any alternative solutions or features you've considered. 18 | placeholder: Describe alternatives you've considered 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: context 23 | attributes: 24 | label: Additional context 25 | description: Add any other context or screenshots about the feature request here. 26 | placeholder: Additional context 27 | - type: checkboxes 28 | id: terms 29 | attributes: 30 | label: Code of Conduct 31 | description: By submitting this issue, you agree to follow our 32 | [Code of Conduct](https://github.com/uilibs/uiprotect/blob/main/.github/CODE_OF_CONDUCT.md). 33 | options: 34 | - label: I agree to follow this project's Code of Conduct 35 | required: true 36 | - type: checkboxes 37 | id: willing 38 | attributes: 39 | label: Are you willing to resolve this issue by submitting a Pull Request? 40 | description: Remember that first-time contributors are welcome! 🙌 41 | options: 42 | - label: Yes, I have the time, and I know how to start. 43 | - label: Yes, I have the time, but I don't know how to start. I would need guidance. 44 | - label: No, I don't have the time, although I believe I could do it if I had the time... 45 | - label: No, I don't have the time and I wouldn't even know how to start. 46 | - type: markdown 47 | attributes: 48 | value: 👋 Have a great day and thank you for the feature request! 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Describe the bug 9 | description: A clear and concise description of what the bug is. 10 | placeholder: Describe the bug 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: reproduce 15 | attributes: 16 | label: To Reproduce 17 | description: Steps to reproduce the behavior. 18 | placeholder: To Reproduce 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: context 23 | attributes: 24 | label: Additional context 25 | description: Add any other context about the problem here. 26 | placeholder: Additional context 27 | - type: input 28 | id: version 29 | attributes: 30 | label: Version 31 | description: Version of the project. 32 | placeholder: Version 33 | validations: 34 | required: true 35 | - type: input 36 | id: platform 37 | attributes: 38 | label: Platform 39 | description: Platform where the bug was found. 40 | placeholder: "Example: Windows 11 / macOS 12.0.1 / Ubuntu 20.04" 41 | validations: 42 | required: true 43 | - type: checkboxes 44 | id: terms 45 | attributes: 46 | label: Code of Conduct 47 | description: By submitting this issue, you agree to follow our 48 | [Code of Conduct](https://github.com/uilibs/uiprotect/blob/main/.github/CODE_OF_CONDUCT.md). 49 | options: 50 | - label: I agree to follow this project's Code of Conduct. 51 | required: true 52 | - type: checkboxes 53 | id: no-duplicate 54 | attributes: 55 | label: No Duplicate 56 | description: Please check [existing issues](https://github.com/uilibs/uiprotect/issues) to avoid duplicates. 57 | options: 58 | - label: I have checked existing issues to avoid duplicates. 59 | required: true 60 | - type: markdown 61 | attributes: 62 | value: 👋 Have a great day and thank you for the bug report! 63 | -------------------------------------------------------------------------------- /.github/labels.toml: -------------------------------------------------------------------------------- 1 | [breaking] 2 | color = "ffcc00" 3 | name = "breaking" 4 | description = "Breaking change." 5 | 6 | [bug] 7 | color = "d73a4a" 8 | name = "bug" 9 | description = "Something isn't working" 10 | 11 | [dependencies] 12 | color = "0366d6" 13 | name = "dependencies" 14 | description = "Pull requests that update a dependency file" 15 | 16 | [github_actions] 17 | color = "000000" 18 | name = "github_actions" 19 | description = "Update of github actions" 20 | 21 | [documentation] 22 | color = "1bc4a5" 23 | name = "documentation" 24 | description = "Improvements or additions to documentation" 25 | 26 | [duplicate] 27 | color = "cfd3d7" 28 | name = "duplicate" 29 | description = "This issue or pull request already exists" 30 | 31 | [enhancement] 32 | color = "a2eeef" 33 | name = "enhancement" 34 | description = "New feature or request" 35 | 36 | ["good first issue"] 37 | color = "7057ff" 38 | name = "good first issue" 39 | description = "Good for newcomers" 40 | 41 | ["help wanted"] 42 | color = "008672" 43 | name = "help wanted" 44 | description = "Extra attention is needed" 45 | 46 | [invalid] 47 | color = "e4e669" 48 | name = "invalid" 49 | description = "This doesn't seem right" 50 | 51 | [nochangelog] 52 | color = "555555" 53 | name = "nochangelog" 54 | description = "Exclude pull requests from changelog" 55 | 56 | [question] 57 | color = "d876e3" 58 | name = "question" 59 | description = "Further information is requested" 60 | 61 | [removed] 62 | color = "e99695" 63 | name = "removed" 64 | description = "Removed piece of functionalities." 65 | 66 | [tests] 67 | color = "bfd4f2" 68 | name = "tests" 69 | description = "CI, CD and testing related changes" 70 | 71 | [wontfix] 72 | color = "ffffff" 73 | name = "wontfix" 74 | description = "This will not be worked on" 75 | 76 | [discussion] 77 | color = "c2e0c6" 78 | name = "discussion" 79 | description = "Some discussion around the project" 80 | 81 | [hacktoberfest] 82 | color = "ffa663" 83 | name = "hacktoberfest" 84 | description = "Good issues for Hacktoberfest" 85 | 86 | [answered] 87 | color = "0ee2b6" 88 | name = "answered" 89 | description = "Automatically closes as answered after a delay" 90 | 91 | [waiting] 92 | color = "5f7972" 93 | name = "waiting" 94 | description = "Automatically closes if no answer after a delay" 95 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Unofficial UniFi Protect Python API and CLI 2 | site_url: https://uilibs.github.io/uiprotect/latest/ 3 | site_description: Unofficial UniFi Protect Python API and CLI 4 | repo_name: uiprotect 5 | repo_url: https://github.com/uilibs/uiprotect 6 | copyright: uiprotect is an unofficial API for UniFi Protect. There is no affiliation with Ubiquiti. 7 | 8 | markdown_extensions: 9 | - abbr 10 | - admonition 11 | - toc: 12 | permalink: true 13 | toc_depth: "1-5" 14 | - pymdownx.highlight: 15 | anchor_linenums: true 16 | use_pygments: true 17 | auto_title: true 18 | linenums: true 19 | - pymdownx.superfences 20 | - pymdownx.tabbed: 21 | alternate_style: true 22 | - attr_list 23 | - pymdownx.emoji: 24 | emoji_index: !!python/name:material.extensions.emoji.twemoji 25 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 26 | 27 | nav: 28 | - Home: "index.md" 29 | - Development: "dev.md" 30 | - Command Line: "cli.md" 31 | - API Reference: "api.md" 32 | - Changelog: "https://github.com/uilibs/uiprotect/releases" 33 | 34 | plugins: 35 | - search 36 | - mike: 37 | canonical_version: null 38 | version_selector: true 39 | css_dir: css 40 | javascript_dir: js 41 | - git-revision-date-localized: 42 | enable_creation_date: true 43 | - include-markdown 44 | - mkdocstrings: 45 | default_handler: python 46 | handlers: 47 | python: 48 | paths: [src] 49 | 50 | theme: 51 | name: material 52 | custom_dir: docs/overrides 53 | features: 54 | - navigation.instant 55 | - navigation.tracking 56 | - navigation.tabs 57 | - navigation.top 58 | - search.suggest 59 | - search.highlight 60 | - search.share 61 | - header.autohide 62 | palette: 63 | - media: "(prefers-color-scheme: light)" 64 | scheme: default 65 | toggle: 66 | icon: material/brightness-7 67 | name: Switch to dark mode 68 | primary: blue 69 | accent: light blue 70 | - media: "(prefers-color-scheme: dark)" 71 | scheme: slate 72 | toggle: 73 | icon: material/brightness-4 74 | name: Switch to light mode 75 | primary: blue 76 | accent: light blue 77 | 78 | extra: 79 | version: 80 | provider: mike 81 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Test Code", 6 | "type": "shell", 7 | "command": "${workspaceFolder}/.bin/test-code", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Update requirements", 12 | "type": "shell", 13 | "command": "${workspaceFolder}/.bin/update-requirements", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "MkDocs: Serve", 18 | "type": "shell", 19 | "command": "mkdocs serve", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "MkDocs: Build", 24 | "type": "shell", 25 | "command": "mkdocs build", 26 | "problemMatcher": [] 27 | }, 28 | { 29 | "label": "Generate Sample Data", 30 | "type": "shell", 31 | "command": "uiprotect generate-sample-data -w ${input:sampleTime} ${input:sampleAnonymize}", 32 | "problemMatcher": [], 33 | "options": { 34 | "env": { 35 | "UFP_SAMPLE_DIR": "${workspaceFolder}/${input:sampleLocation}" 36 | } 37 | } 38 | }, 39 | { 40 | "label": "Regenerate Release Cache", 41 | "type": "shell", 42 | "command": "uiprotect release-versions", 43 | "problemMatcher": [], 44 | }, 45 | ], 46 | "inputs": [ 47 | { 48 | "id": "sampleLocation", 49 | "description": "Location to generate sample data in", 50 | "default": "test-data", 51 | "type": "pickString", 52 | "options": [ 53 | "test-data", 54 | "tests/sample_data", 55 | ], 56 | }, 57 | { 58 | "id": "sampleTime", 59 | "description": "Length of time to generate sample data", 60 | "default": "30", 61 | "type": "promptString", 62 | }, 63 | { 64 | "id": "sampleAnonymize", 65 | "description": "Anonymize parameter for generate sample data", 66 | "default": "", 67 | "type": "pickString", 68 | "options": [ 69 | "", 70 | "--actual", 71 | ], 72 | }, 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /src/uiprotect/cli/viewers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | import typer 6 | 7 | from ..api import ProtectApiClient 8 | from ..cli import base 9 | from ..data import Viewer 10 | 11 | app = typer.Typer(rich_markup_mode="rich") 12 | 13 | ARG_DEVICE_ID = typer.Argument(None, help="ID of viewer to select for subcommands") 14 | 15 | 16 | @dataclass 17 | class ViewerContext(base.CliContext): 18 | devices: dict[str, Viewer] 19 | device: Viewer | None = None 20 | 21 | 22 | ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) 23 | 24 | 25 | @app.callback(invoke_without_command=True) 26 | def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: 27 | """ 28 | Viewers device CLI. 29 | 30 | Returns full list of Viewers without any arguments passed. 31 | """ 32 | protect: ProtectApiClient = ctx.obj.protect 33 | context = ViewerContext( 34 | protect=ctx.obj.protect, 35 | device=None, 36 | devices=protect.bootstrap.viewers, 37 | output_format=ctx.obj.output_format, 38 | ) 39 | ctx.obj = context 40 | 41 | if device_id is not None and device_id not in ALL_COMMANDS: 42 | if (device := protect.bootstrap.viewers.get(device_id)) is None: 43 | typer.secho("Invalid viewer ID", fg="red") 44 | raise typer.Exit(1) 45 | ctx.obj.device = device 46 | 47 | if not ctx.invoked_subcommand: 48 | if device_id in ALL_COMMANDS: 49 | ctx.invoke(ALL_COMMANDS[device_id], ctx) 50 | return 51 | 52 | if ctx.obj.device is not None: 53 | base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) 54 | return 55 | 56 | base.print_unifi_dict(ctx.obj.devices) 57 | 58 | 59 | @app.command() 60 | def liveview( 61 | ctx: typer.Context, 62 | liveview_id: str | None = typer.Argument(None), 63 | ) -> None: 64 | """Returns or sets the current liveview.""" 65 | base.require_device_id(ctx) 66 | obj: Viewer = ctx.obj.device 67 | 68 | if liveview_id is None: 69 | base.print_unifi_obj(obj.liveview, ctx.obj.output_format) 70 | else: 71 | protect: ProtectApiClient = ctx.obj.protect 72 | if (liveview_obj := protect.bootstrap.liveviews.get(liveview_id)) is None: 73 | typer.secho("Invalid liveview ID") 74 | raise typer.Exit(1) 75 | base.run(ctx, obj.set_liveview(liveview_obj)) 76 | -------------------------------------------------------------------------------- /tests/sample_data/sample_sensor.json: -------------------------------------------------------------------------------- 1 | { 2 | "mac": "4191A8E35F39", 3 | "host": null, 4 | "connectionHost": "192.168.102.63", 5 | "type": "UFP-SENSE", 6 | "name": "Vyiu Ccxeqw", 7 | "upSince": 1642991171327, 8 | "uptime": null, 9 | "lastSeen": 1643054753862, 10 | "connectedSince": 1643054778327, 11 | "state": "CONNECTED", 12 | "hardwareRevision": 6, 13 | "firmwareVersion": "1.0.2", 14 | "latestFirmwareVersion": "1.0.2", 15 | "firmwareBuild": null, 16 | "isUpdating": false, 17 | "isAdopting": false, 18 | "isAdopted": true, 19 | "isAdoptedByOther": false, 20 | "isProvisioned": false, 21 | "isRebooting": false, 22 | "isSshEnabled": false, 23 | "canAdopt": false, 24 | "isAttemptingToConnect": false, 25 | "isMotionDetected": false, 26 | "mountType": "door", 27 | "leakDetectedAt": null, 28 | "tamperingDetectedAt": null, 29 | "isOpened": true, 30 | "openStatusChangedAt": 1643055798932, 31 | "alarmTriggeredAt": null, 32 | "motionDetectedAt": 1643055801008, 33 | "wiredConnectionState": { 34 | "phyRate": null 35 | }, 36 | "stats": { 37 | "light": { 38 | "value": 20, 39 | "status": "neutral" 40 | }, 41 | "humidity": { 42 | "value": 29, 43 | "status": "neutral" 44 | }, 45 | "temperature": { 46 | "value": 16.95, 47 | "status": "neutral" 48 | } 49 | }, 50 | "bluetoothConnectionState": { 51 | "signalQuality": 45, 52 | "signalStrength": -72 53 | }, 54 | "batteryStatus": { 55 | "percentage": 80, 56 | "isLow": false 57 | }, 58 | "alarmSettings": { 59 | "isEnabled": false 60 | }, 61 | "lightSettings": { 62 | "isEnabled": true, 63 | "lowThreshold": null, 64 | "highThreshold": null, 65 | "margin": 10 66 | }, 67 | "motionSettings": { 68 | "isEnabled": true, 69 | "sensitivity": 100 70 | }, 71 | "temperatureSettings": { 72 | "isEnabled": true, 73 | "lowThreshold": null, 74 | "highThreshold": null, 75 | "margin": 0.1 76 | }, 77 | "humiditySettings": { 78 | "isEnabled": true, 79 | "lowThreshold": null, 80 | "highThreshold": null, 81 | "margin": 1 82 | }, 83 | "ledSettings": { 84 | "isEnabled": true 85 | }, 86 | "bridge": "61b3f5c90054a703e700042b", 87 | "camera": null, 88 | "bridgeCandidates": [], 89 | "id": "02ee9b99f17d69346e0c8c00", 90 | "isConnected": true, 91 | "marketName": "UP Sense", 92 | "modelKey": "sensor" 93 | } 94 | -------------------------------------------------------------------------------- /src/uiprotect/data/convert.py: -------------------------------------------------------------------------------- 1 | """UniFi Protect Data Conversion.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any, cast 6 | 7 | from uiprotect.data.base import ProtectModelWithId 8 | 9 | from ..exceptions import DataDecodeError 10 | from .devices import ( 11 | AiPort, 12 | Bridge, 13 | Camera, 14 | Chime, 15 | Doorlock, 16 | Light, 17 | Sensor, 18 | Viewer, 19 | ) 20 | from .nvr import NVR, Event, Liveview 21 | from .types import ModelType 22 | from .user import CloudAccount, Group, Keyring, UlpUser, User, UserLocation 23 | 24 | if TYPE_CHECKING: 25 | from ..api import ProtectApiClient 26 | from ..data.base import ProtectModel 27 | 28 | 29 | MODEL_TO_CLASS: dict[str, type[ProtectModel]] = { 30 | ModelType.EVENT: Event, 31 | ModelType.GROUP: Group, 32 | ModelType.USER_LOCATION: UserLocation, 33 | ModelType.CLOUD_IDENTITY: CloudAccount, 34 | ModelType.USER: User, 35 | ModelType.NVR: NVR, 36 | ModelType.LIGHT: Light, 37 | ModelType.CAMERA: Camera, 38 | ModelType.LIVEVIEW: Liveview, 39 | ModelType.VIEWPORT: Viewer, 40 | ModelType.BRIDGE: Bridge, 41 | ModelType.SENSOR: Sensor, 42 | ModelType.DOORLOCK: Doorlock, 43 | ModelType.CHIME: Chime, 44 | ModelType.AIPORT: AiPort, 45 | ModelType.KEYRING: Keyring, 46 | ModelType.ULP_USER: UlpUser, 47 | } 48 | 49 | 50 | def get_klass_from_dict(data: dict[str, Any]) -> type[ProtectModel]: 51 | """ 52 | Helper method to read the `modelKey` from a UFP JSON dict and get the correct Python class for conversion. 53 | Will raise `DataDecodeError` if the `modelKey` is for an unknown object. 54 | """ 55 | if "modelKey" not in data: 56 | raise DataDecodeError("No modelKey") 57 | 58 | model = ModelType(data["modelKey"]) 59 | 60 | klass = MODEL_TO_CLASS.get(model) 61 | 62 | if klass is None: 63 | raise DataDecodeError("Unknown modelKey") 64 | 65 | return klass 66 | 67 | 68 | def create_from_unifi_dict( 69 | data: dict[str, Any], 70 | api: ProtectApiClient | None = None, 71 | klass: type[ProtectModel] | None = None, 72 | model_type: ModelType | None = None, 73 | ) -> ProtectModel: 74 | """ 75 | Helper method to read the `modelKey` from a UFP JSON dict and convert to currect Python class. 76 | Will raise `DataDecodeError` if the `modelKey` is for an unknown object. 77 | """ 78 | if "modelKey" not in data: 79 | raise DataDecodeError("No modelKey") 80 | 81 | if model_type is not None and klass is None: 82 | klass = MODEL_TO_CLASS.get(model_type) 83 | 84 | if klass is None: 85 | klass = get_klass_from_dict(data) 86 | 87 | return klass.from_unifi_dict(**data, api=api) 88 | 89 | 90 | def list_from_unifi_list( 91 | api: ProtectApiClient, unifi_list: list[dict[str, ProtectModelWithId]] 92 | ) -> list[ProtectModelWithId]: 93 | return [ 94 | cast(ProtectModelWithId, create_from_unifi_dict(obj_dict, api)) 95 | for obj_dict in unifi_list 96 | ] 97 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: CD - Build Docker Image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | rebuild: 9 | description: "Rebuild tag?" 10 | required: true 11 | default: "no" 12 | type: choice 13 | options: 14 | - "no" 15 | - "yes" 16 | 17 | concurrency: 18 | group: docker-${{ github.event.workflow_run.head_branch || github.ref }} 19 | cancel-in-progress: true 20 | 21 | permissions: 22 | packages: write 23 | 24 | env: 25 | DEFAULT_PYTHON: "3.12" 26 | 27 | jobs: 28 | docker: 29 | name: Build Docker Image 30 | runs-on: ubuntu-latest 31 | environment: 32 | name: release 33 | 34 | steps: 35 | - name: Check repo 36 | uses: actions/checkout@v6 37 | with: 38 | fetch-depth: 0 39 | 40 | - name: Install poetry 41 | run: pipx install poetry 42 | 43 | - name: Set up Python 44 | uses: actions/setup-python@v6 45 | with: 46 | python-version: "${{ env.DEFAULT_PYTHON }}" 47 | cache: "poetry" 48 | 49 | - name: Install dependencies 50 | run: | 51 | poetry install --no-root 52 | 53 | - name: Get current version (rebuild) 54 | if: ${{ inputs.rebuild == 'yes' }} 55 | run: | 56 | UIPROTECT_VERSION=$(git describe --tags --abbrev=0) 57 | 58 | echo "UIPROTECT_VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV 59 | echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:dev,ghcr.io/uilibs/uiprotect:$UIPROTECT_VERSION" >> $GITHUB_ENV 60 | 61 | - name: Get current version (no rebuild) 62 | if: ${{ inputs.rebuild != 'yes' }} 63 | run: | 64 | UIPROTECT_VERSION=v$(poetry version -s) 65 | 66 | echo "UIPROTECT_VERSION=$UIPROTECT_VERSION" >> $GITHUB_ENV 67 | echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:dev,ghcr.io/uilibs/uiprotect:$(echo $UIPROTECT_VERSION | tr "+" -)" >> $GITHUB_ENV 68 | 69 | - name: Add Latest Docker Tag 70 | run: | 71 | if [[ ! "$UIPROTECT_VERSION" == *"dev"* ]]; then 72 | echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:latest,$DOCKER_TAGS" >> $GITHUB_ENV 73 | fi 74 | 75 | - name: Set up QEMU 76 | uses: docker/setup-qemu-action@v3 77 | 78 | - name: Set up Docker Buildx 79 | uses: docker/setup-buildx-action@v3 80 | 81 | - name: Login to GitHub Container Registry 82 | uses: docker/login-action@v3 83 | with: 84 | registry: ghcr.io 85 | username: ${{ github.actor }} 86 | password: ${{ secrets.GITHUB_TOKEN }} 87 | 88 | - name: Build and Push 89 | uses: docker/build-push-action@v6 90 | with: 91 | context: . 92 | platforms: linux/amd64,linux/arm64 93 | target: prod 94 | push: true 95 | build-args: | 96 | UIPROTECT_VERSION=${{ env.UIPROTECT_VERSION }} 97 | cache-from: ghcr.io/uilibs/uiprotect:buildcache 98 | cache-to: type=registry,ref=ghcr.io/uilibs/uiprotect:buildcache,mode=max 99 | tags: ${{ env.DOCKER_TAGS }} 100 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uiprotect-dev", 3 | "build": { 4 | "dockerfile": "../Dockerfile", 5 | "context": "..", 6 | "target": "devcontainer" 7 | }, 8 | "updateRemoteUserUID": true, 9 | "features": {}, 10 | "postCreateCommand": "bash -c 'export LANG=C.UTF-8 LC_ALL=C.UTF-8 && poetry install --with dev --no-interaction --no-ansi && LANG=C.UTF-8 LC_ALL=C.UTF-8 poetry run pre-commit install --install-hooks && echo \"LANG=C.UTF-8\" >> /etc/environment && echo \"LC_ALL=C.UTF-8\" >> /etc/environment'", 11 | "remoteUser": "root", 12 | "containerEnv": { 13 | "GIT_AUTHOR_NAME": "${localEnv:GIT_AUTHOR_NAME:${localEnv:USER:devcontainer}}", 14 | "GIT_AUTHOR_EMAIL": "${localEnv:GIT_AUTHOR_EMAIL:${localEnv:USER:devcontainer}@localhost}", 15 | "GIT_COMMITTER_NAME": "${localEnv:GIT_COMMITTER_NAME:${localEnv:USER:devcontainer}}", 16 | "GIT_COMMITTER_EMAIL": "${localEnv:GIT_COMMITTER_EMAIL:${localEnv:USER:devcontainer}@localhost}" 17 | }, 18 | "remoteEnv": { 19 | "LANG": "C.UTF-8", 20 | "LC_ALL": "C.UTF-8" 21 | }, 22 | "customizations": { 23 | "vscode": { 24 | "settings": { 25 | "terminal.integrated.defaultProfile.linux": "bash", 26 | "python.defaultInterpreterPath": "/usr/local/bin/python3", 27 | "python.terminal.activateEnvironment": false, 28 | "python.testing.pytestEnabled": true, 29 | "python.testing.unittestEnabled": false, 30 | "python.testing.pytestArgs": [ 31 | "tests", 32 | "-v", 33 | "--cov=uiprotect", 34 | "--cov-report=term-missing:skip-covered", 35 | "-n=auto" 36 | ], 37 | "python.analysis.typeCheckingMode": "strict", 38 | "python.analysis.autoImportCompletions": true, 39 | "editor.formatOnSave": true, 40 | "editor.rulers": [ 41 | 88 42 | ], 43 | "files.trimTrailingWhitespace": true, 44 | "files.insertFinalNewline": true, 45 | "[python]": { 46 | "editor.defaultFormatter": "charliermarsh.ruff", 47 | "editor.codeActionsOnSave": { 48 | "source.organizeImports.ruff": "explicit", 49 | "source.fixAll.ruff": "explicit" 50 | } 51 | }, 52 | "[markdown]": { 53 | "editor.defaultFormatter": "esbenp.prettier-vscode" 54 | }, 55 | "[json]": { 56 | "editor.defaultFormatter": "esbenp.prettier-vscode" 57 | }, 58 | "[yaml]": { 59 | "editor.defaultFormatter": "esbenp.prettier-vscode" 60 | } 61 | }, 62 | "extensions": [ 63 | "ms-python.python", 64 | "ms-python.vscode-pylance", 65 | "ms-azuretools.vscode-docker", 66 | "charliermarsh.ruff", 67 | "esbenp.prettier-vscode", 68 | "ms-python.mypy-type-checker", 69 | "ms-vscode.test-adapter-converter", 70 | "littlefoxteam.vscode-python-test-adapter" 71 | ] 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/uiprotect/data/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import ( 4 | ProtectAdoptableDeviceModel, 5 | ProtectBaseObject, 6 | ProtectDeviceModel, 7 | ProtectModel, 8 | ProtectModelWithId, 9 | ) 10 | from .bootstrap import Bootstrap 11 | from .convert import create_from_unifi_dict 12 | from .devices import ( 13 | AiPort, 14 | Bridge, 15 | Camera, 16 | CameraChannel, 17 | Chime, 18 | Doorlock, 19 | LCDMessage, 20 | Light, 21 | RingSetting, 22 | Sensor, 23 | Viewer, 24 | ) 25 | from .nvr import ( 26 | NVR, 27 | DoorbellMessage, 28 | Event, 29 | Liveview, 30 | NVRLocation, 31 | SmartDetectItem, 32 | SmartDetectTrack, 33 | ) 34 | from .types import ( 35 | DEFAULT, 36 | DEFAULT_TYPE, 37 | AnalyticsOption, 38 | AudioStyle, 39 | ChimeType, 40 | Color, 41 | CoordType, 42 | DoorbellMessageType, 43 | DoorbellText, 44 | EventCategories, 45 | EventType, 46 | FixSizeOrderedDict, 47 | HDRMode, 48 | ICRCustomValue, 49 | ICRLuxValue, 50 | IRLEDMode, 51 | LensType, 52 | LightModeEnableType, 53 | LightModeType, 54 | LockStatusType, 55 | ModelType, 56 | MountType, 57 | Percent, 58 | PermissionNode, 59 | ProtectWSPayloadFormat, 60 | PTZPosition, 61 | PTZPreset, 62 | RecordingMode, 63 | SensorStatusType, 64 | SensorType, 65 | SmartDetectAudioType, 66 | SmartDetectObjectType, 67 | StateType, 68 | StorageType, 69 | Version, 70 | VideoMode, 71 | WDRLevel, 72 | ) 73 | from .user import CloudAccount, Group, Permission, User, UserLocation 74 | from .websocket import ( 75 | WS_HEADER_SIZE, 76 | WSAction, 77 | WSJSONPacketFrame, 78 | WSPacket, 79 | WSPacketFrameHeader, 80 | WSRawPacketFrame, 81 | WSSubscriptionMessage, 82 | ) 83 | 84 | __all__ = [ 85 | "DEFAULT", 86 | "DEFAULT_TYPE", 87 | "NVR", 88 | "WS_HEADER_SIZE", 89 | "AiPort", 90 | "AnalyticsOption", 91 | "AudioStyle", 92 | "Bootstrap", 93 | "Bridge", 94 | "Camera", 95 | "CameraChannel", 96 | "Chime", 97 | "ChimeType", 98 | "CloudAccount", 99 | "Color", 100 | "CoordType", 101 | "DoorbellMessage", 102 | "DoorbellMessageType", 103 | "DoorbellText", 104 | "Doorlock", 105 | "Event", 106 | "EventCategories", 107 | "EventType", 108 | "FixSizeOrderedDict", 109 | "Group", 110 | "HDRMode", 111 | "ICRCustomValue", 112 | "ICRLuxValue", 113 | "IRLEDMode", 114 | "LCDMessage", 115 | "LensType", 116 | "Light", 117 | "LightModeEnableType", 118 | "LightModeType", 119 | "Liveview", 120 | "LockStatusType", 121 | "ModelType", 122 | "MountType", 123 | "NVRLocation", 124 | "PTZPosition", 125 | "PTZPreset", 126 | "Percent", 127 | "Permission", 128 | "PermissionNode", 129 | "ProtectAdoptableDeviceModel", 130 | "ProtectBaseObject", 131 | "ProtectDeviceModel", 132 | "ProtectModel", 133 | "ProtectModelWithId", 134 | "ProtectWSPayloadFormat", 135 | "RecordingMode", 136 | "RingSetting", 137 | "Sensor", 138 | "SensorStatusType", 139 | "SensorType", 140 | "SmartDetectAudioType", 141 | "SmartDetectItem", 142 | "SmartDetectObjectType", 143 | "SmartDetectTrack", 144 | "StateType", 145 | "StorageType", 146 | "User", 147 | "UserLocation", 148 | "Version", 149 | "VideoMode", 150 | "Viewer", 151 | "WDRLevel", 152 | "WSAction", 153 | "WSJSONPacketFrame", 154 | "WSPacket", 155 | "WSPacketFrameHeader", 156 | "WSRawPacketFrame", 157 | "WSSubscriptionMessage", 158 | "create_from_unifi_dict", 159 | ] 160 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # settings.json is user-specific overrides for devcontainer.json 4 | .vscode/settings.json 5 | test-data 6 | ufp-data 7 | *.mp3 8 | *.mp4 9 | *.js 10 | *.json 11 | *.csv 12 | backup 13 | .benchmarks 14 | .ruff_cache 15 | 16 | # Byte-compiled / optimized / DLL files 17 | __pycache__/ 18 | *.py[cod] 19 | *$py.class 20 | 21 | # C extensions 22 | *.so 23 | 24 | # Distribution / packaging 25 | .Python 26 | build/ 27 | develop-eggs/ 28 | dist/ 29 | downloads/ 30 | eggs/ 31 | .eggs/ 32 | lib/ 33 | lib64/ 34 | parts/ 35 | sdist/ 36 | var/ 37 | wheels/ 38 | pip-wheel-metadata/ 39 | share/python-wheels/ 40 | *.egg-info/ 41 | .installed.cfg 42 | *.egg 43 | MANIFEST 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .nox/ 59 | .coverage 60 | .coverage.* 61 | .cache 62 | nosetests.xml 63 | coverage.xml 64 | *.cover 65 | *.py,cover 66 | .hypothesis/ 67 | .pytest_cache/ 68 | cover/ 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Django stuff: 75 | *.log 76 | local_settings.py 77 | db.sqlite3 78 | db.sqlite3-journal 79 | 80 | # Flask stuff: 81 | instance/ 82 | .webassets-cache 83 | 84 | # Scrapy stuff: 85 | .scrapy 86 | 87 | # Sphinx documentation 88 | docs/_build/ 89 | 90 | # PyBuilder 91 | .pybuilder/ 92 | target/ 93 | 94 | # Jupyter Notebook 95 | .ipynb_checkpoints 96 | 97 | # IPython 98 | profile_default/ 99 | ipython_config.py 100 | 101 | # pyenv 102 | # For a library or package, you might want to ignore these files since the code is 103 | # intended to run in multiple environments; otherwise, check them in: 104 | # .python-version 105 | .python-version 106 | 107 | # pipenv 108 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 109 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 110 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 111 | # install all needed dependencies. 112 | #Pipfile.lock 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder {{package_name}} settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope {{package_name}} settings 138 | # Spyder project settings 139 | .spyderproject 140 | .spyproject 141 | 142 | # Rope project settings 143 | .ropeproject 144 | 145 | # mkdocs documentation 146 | /site 147 | 148 | # mypy 149 | .mypy_cache/ 150 | .dmypy.json 151 | dmypy.json 152 | 153 | # Pyre type checker 154 | .pyre/ 155 | 156 | # pytype static type analyzer 157 | .pytype/ 158 | 159 | # Cython debug symbols 160 | cython_debug/ 161 | .DS_Store 162 | test.py 163 | camerasnapshot.py 164 | dumpeventdata.py 165 | eventdata.json 166 | dumpeventdata.py 167 | heatmap_snapshot.py 168 | smartdetect.json 169 | dumpcameradata.py 170 | cameras.json 171 | setstatuslight.py 172 | # websocket.py 173 | test_function.py 174 | test_event.py 175 | events.json 176 | event_obj.json 177 | src/uiprotect/unifi_protect_server_ws.py 178 | websocket_test.py 179 | test_ws.py 180 | camera_cloudkey.json 181 | camera_cloudkey_privacy.json 182 | cameras_upsense.json 183 | event.json 184 | test_websocket.py 185 | test_raw.py 186 | rawdata.json 187 | 188 | # IDE settings 189 | .vscode/ 190 | .idea/ 191 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - name: Install poetry 19 | run: pipx install poetry 20 | - uses: actions/setup-python@v6 21 | with: 22 | python-version: 3.x 23 | cache: "poetry" 24 | - name: Install Dependencies 25 | run: | 26 | poetry install 27 | - uses: pre-commit/action@v3.0.1 28 | 29 | # Make sure commit messages follow the conventional commits convention: 30 | # https://www.conventionalcommits.org 31 | commitlint: 32 | name: Lint Commit Messages 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v6 36 | with: 37 | fetch-depth: 0 38 | - uses: wagoid/commitlint-github-action@v6.2.1 39 | 40 | test: 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | python-version: 45 | - "3.10" 46 | - "3.11" 47 | - "3.12" 48 | - "3.13" 49 | - "3.14" 50 | os: 51 | - ubuntu-latest 52 | runs-on: ${{ matrix.os }} 53 | steps: 54 | - uses: actions/checkout@v6 55 | - name: Install poetry 56 | run: pipx install poetry 57 | - name: Set up Python 58 | uses: actions/setup-python@v6 59 | id: setup-python 60 | with: 61 | python-version: ${{ matrix.python-version }} 62 | cache: "poetry" 63 | allow-prereleases: true 64 | - run: echo "Cache hit:${{ steps.setup-python.outputs.cache-hit }}" # true if cache-hit occurred on the primary key 65 | - name: Install Dependencies 66 | run: | 67 | sudo apt update 68 | sudo apt install -y ffmpeg 69 | poetry install 70 | - name: Test with Pytest 71 | run: ./.bin/test-code 72 | shell: bash 73 | - name: Upload coverage to Codecov 74 | uses: codecov/codecov-action@v5 75 | with: 76 | token: ${{ secrets.CODECOV_TOKEN }} 77 | 78 | release: 79 | needs: 80 | - test 81 | - lint 82 | - commitlint 83 | 84 | runs-on: ubuntu-latest 85 | environment: release 86 | concurrency: release 87 | permissions: 88 | id-token: write 89 | contents: write 90 | 91 | steps: 92 | - uses: actions/checkout@v6 93 | with: 94 | fetch-depth: 0 95 | ref: ${{ github.head_ref || github.ref_name }} 96 | token: ${{ secrets.BOT_ACCESS_TOKEN }} 97 | 98 | - name: Setup Git 99 | run: | 100 | git config user.name "uiprotectbot" 101 | git config user.email "uiprotect@koston.org" 102 | 103 | # Do a dry run of PSR 104 | - name: Test release 105 | uses: python-semantic-release/python-semantic-release@v10.5.2 106 | if: github.ref_name != 'main' 107 | with: 108 | no_operation_mode: true 109 | 110 | # On main branch: actual PSR + upload to PyPI & GitHub 111 | - name: Release 112 | uses: python-semantic-release/python-semantic-release@v10.5.2 113 | id: release 114 | if: github.ref_name == 'main' 115 | with: 116 | github_token: ${{ secrets.BOT_ACCESS_TOKEN }} 117 | 118 | - name: Publish package distributions to PyPI 119 | uses: pypa/gh-action-pypi-publish@release/v1 120 | if: steps.release.outputs.released == 'true' 121 | 122 | - name: Publish package distributions to GitHub Releases 123 | uses: python-semantic-release/upload-to-gh-release@main 124 | if: steps.release.outputs.released == 'true' 125 | with: 126 | github_token: ${{ secrets.BOT_ACCESS_TOKEN }} 127 | -------------------------------------------------------------------------------- /src/uiprotect/cli/doorlocks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import timedelta 5 | 6 | import typer 7 | 8 | from ..api import ProtectApiClient 9 | from ..cli import base 10 | from ..data import Doorlock 11 | 12 | app = typer.Typer(rich_markup_mode="rich") 13 | 14 | ARG_DEVICE_ID = typer.Argument(None, help="ID of doorlock to select for subcommands") 15 | 16 | 17 | @dataclass 18 | class DoorlockContext(base.CliContext): 19 | devices: dict[str, Doorlock] 20 | device: Doorlock | None = None 21 | 22 | 23 | ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) 24 | 25 | 26 | @app.callback(invoke_without_command=True) 27 | def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: 28 | """ 29 | Doorlock device CLI. 30 | 31 | Returns full list of Doorlocks without any arguments passed. 32 | """ 33 | protect: ProtectApiClient = ctx.obj.protect 34 | context = DoorlockContext( 35 | protect=ctx.obj.protect, 36 | device=None, 37 | devices=protect.bootstrap.doorlocks, 38 | output_format=ctx.obj.output_format, 39 | ) 40 | ctx.obj = context 41 | 42 | if device_id is not None and device_id not in ALL_COMMANDS: 43 | if (device := protect.bootstrap.doorlocks.get(device_id)) is None: 44 | typer.secho("Invalid doorlock ID", fg="red") 45 | raise typer.Exit(1) 46 | ctx.obj.device = device 47 | 48 | if not ctx.invoked_subcommand: 49 | if device_id in ALL_COMMANDS: 50 | ctx.invoke(ALL_COMMANDS[device_id], ctx) 51 | return 52 | 53 | if ctx.obj.device is not None: 54 | base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) 55 | return 56 | 57 | base.print_unifi_dict(ctx.obj.devices) 58 | 59 | 60 | @app.command() 61 | def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None: 62 | """Returns or sets tha paired camera for a doorlock.""" 63 | base.require_device_id(ctx) 64 | obj: Doorlock = ctx.obj.device 65 | 66 | if camera_id is None: 67 | base.print_unifi_obj(obj.camera, ctx.obj.output_format) 68 | else: 69 | protect: ProtectApiClient = ctx.obj.protect 70 | if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None: 71 | typer.secho("Invalid camera ID") 72 | raise typer.Exit(1) 73 | base.run(ctx, obj.set_paired_camera(camera_obj)) 74 | 75 | 76 | @app.command() 77 | def set_status_light(ctx: typer.Context, enabled: bool) -> None: 78 | """Sets status light for the lock.""" 79 | base.require_device_id(ctx) 80 | obj: Doorlock = ctx.obj.device 81 | 82 | base.run(ctx, obj.set_status_light(enabled)) 83 | 84 | 85 | @app.command() 86 | def set_auto_close_time( 87 | ctx: typer.Context, 88 | duration: int = typer.Argument(..., min=0, max=3600), 89 | ) -> None: 90 | """Sets auto-close time for the lock (in seconds). 0 = disabled.""" 91 | base.require_device_id(ctx) 92 | obj: Doorlock = ctx.obj.device 93 | 94 | base.run(ctx, obj.set_auto_close_time(timedelta(seconds=duration))) 95 | 96 | 97 | @app.command() 98 | def unlock(ctx: typer.Context) -> None: 99 | """Unlocks the lock.""" 100 | base.require_device_id(ctx) 101 | obj: Doorlock = ctx.obj.device 102 | base.run(ctx, obj.open_lock()) 103 | 104 | 105 | @app.command() 106 | def lock(ctx: typer.Context) -> None: 107 | """Locks the lock.""" 108 | base.require_device_id(ctx) 109 | obj: Doorlock = ctx.obj.device 110 | base.run(ctx, obj.close_lock()) 111 | 112 | 113 | @app.command() 114 | def calibrate(ctx: typer.Context, force: bool = base.OPTION_FORCE) -> None: 115 | """ 116 | Calibrate the doorlock. 117 | 118 | Door must be open and lock unlocked. 119 | """ 120 | base.require_device_id(ctx) 121 | obj: Doorlock = ctx.obj.device 122 | 123 | if force or typer.confirm("Is the door open and unlocked?"): 124 | base.run(ctx, obj.calibrate()) 125 | -------------------------------------------------------------------------------- /src/uiprotect/cli/lights.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import timedelta 5 | 6 | import typer 7 | 8 | from ..api import ProtectApiClient 9 | from ..cli import base 10 | from ..data import Light 11 | 12 | app = typer.Typer(rich_markup_mode="rich") 13 | 14 | ARG_DEVICE_ID = typer.Argument(None, help="ID of light to select for subcommands") 15 | 16 | 17 | @dataclass 18 | class LightContext(base.CliContext): 19 | devices: dict[str, Light] 20 | device: Light | None = None 21 | 22 | 23 | ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) 24 | 25 | 26 | @app.callback(invoke_without_command=True) 27 | def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: 28 | """ 29 | Lights device CLI. 30 | 31 | Returns full list of Viewers without any arguments passed. 32 | """ 33 | protect: ProtectApiClient = ctx.obj.protect 34 | context = LightContext( 35 | protect=ctx.obj.protect, 36 | device=None, 37 | devices=protect.bootstrap.lights, 38 | output_format=ctx.obj.output_format, 39 | ) 40 | ctx.obj = context 41 | 42 | if device_id is not None and device_id not in ALL_COMMANDS: 43 | if (device := protect.bootstrap.lights.get(device_id)) is None: 44 | typer.secho("Invalid light ID", fg="red") 45 | raise typer.Exit(1) 46 | ctx.obj.device = device 47 | 48 | if not ctx.invoked_subcommand: 49 | if device_id in ALL_COMMANDS: 50 | ctx.invoke(ALL_COMMANDS[device_id], ctx) 51 | return 52 | 53 | if ctx.obj.device is not None: 54 | base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) 55 | return 56 | 57 | base.print_unifi_dict(ctx.obj.devices) 58 | 59 | 60 | @app.command() 61 | def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None: 62 | """Returns or sets tha paired camera for a light.""" 63 | base.require_device_id(ctx) 64 | obj: Light = ctx.obj.device 65 | 66 | if camera_id is None: 67 | base.print_unifi_obj(obj.camera, ctx.obj.output_format) 68 | else: 69 | protect: ProtectApiClient = ctx.obj.protect 70 | if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None: 71 | typer.secho("Invalid camera ID") 72 | raise typer.Exit(1) 73 | base.run(ctx, obj.set_paired_camera(camera_obj)) 74 | 75 | 76 | @app.command() 77 | def set_status_light(ctx: typer.Context, enabled: bool) -> None: 78 | """Sets status light for light device.""" 79 | base.require_device_id(ctx) 80 | obj: Light = ctx.obj.device 81 | 82 | base.run(ctx, obj.set_status_light(enabled)) 83 | 84 | 85 | @app.command() 86 | def set_led_level( 87 | ctx: typer.Context, 88 | led_level: int = typer.Argument(..., min=1, max=6), 89 | ) -> None: 90 | """Sets brightness of LED on light.""" 91 | base.require_device_id(ctx) 92 | obj: Light = ctx.obj.device 93 | 94 | base.run(ctx, obj.set_led_level(led_level)) 95 | 96 | 97 | @app.command() 98 | def set_sensitivity( 99 | ctx: typer.Context, 100 | sensitivity: int = typer.Argument(..., min=0, max=100), 101 | ) -> None: 102 | """Sets motion sensitivity for the light.""" 103 | base.require_device_id(ctx) 104 | obj: Light = ctx.obj.device 105 | 106 | base.run(ctx, obj.set_sensitivity(sensitivity)) 107 | 108 | 109 | @app.command() 110 | def set_duration( 111 | ctx: typer.Context, 112 | duration: int = typer.Argument(..., min=15, max=900), 113 | ) -> None: 114 | """Sets timeout duration (in seconds) for light.""" 115 | base.require_device_id(ctx) 116 | obj: Light = ctx.obj.device 117 | 118 | base.run(ctx, obj.set_duration(timedelta(seconds=duration))) 119 | 120 | 121 | @app.command() 122 | def set_flood_light(ctx: typer.Context, enabled: bool) -> None: 123 | """Sets flood light (force on) for light device.""" 124 | base.require_device_id(ctx) 125 | obj: Light = ctx.obj.device 126 | 127 | base.run(ctx, obj.set_flood_light(enabled)) 128 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim-bookworm AS base 2 | 3 | LABEL org.opencontainers.image.source=https://github.com/uilibs/uiprotect 4 | 5 | ENV PYTHONUNBUFFERED=1 6 | ENV UV_SYSTEM_PYTHON=true 7 | ARG TARGETPLATFORM 8 | 9 | RUN addgroup --system --gid 1000 app \ 10 | && adduser --system --shell /bin/bash --uid 1000 --home /home/app --ingroup app app 11 | 12 | RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \ 13 | apt-get update -qq \ 14 | && apt-get install -yqq ffmpeg 15 | 16 | 17 | FROM base AS builder 18 | 19 | RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \ 20 | apt-get update -qq \ 21 | && apt-get install -yqq build-essential git 22 | 23 | RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ 24 | pip install --root-user-action=ignore -U pip uv poetry 25 | 26 | FROM builder AS prod-builder 27 | 28 | ARG UIPROTECT_VERSION 29 | 30 | WORKDIR /tmp/build 31 | COPY pyproject.toml poetry.lock ./ 32 | RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ 33 | poetry config virtualenvs.create false \ 34 | && poetry install --only main --no-root --no-interaction --no-ansi 35 | 36 | COPY . . 37 | RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ 38 | SETUPTOOLS_SCM_PRETEND_VERSION=${UIPROTECT_VERSION} poetry build -f wheel 39 | 40 | FROM base AS prod 41 | 42 | COPY --from=builder /usr/local/bin/uv /usr/local/bin/ 43 | COPY --from=prod-builder /tmp/build/dist/*.whl /tmp/ 44 | RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ 45 | uv pip install -U /tmp/*.whl \ 46 | && rm /tmp/*.whl 47 | 48 | COPY .docker/entrypoint.sh /usr/local/bin/entrypoint 49 | RUN chmod +x /usr/local/bin/entrypoint \ 50 | && mkdir /data \ 51 | && chown app:app /data 52 | 53 | USER app 54 | VOLUME /data 55 | WORKDIR /data 56 | ENTRYPOINT ["/usr/local/bin/entrypoint"] 57 | 58 | 59 | FROM builder AS builder-dev 60 | 61 | WORKDIR /workspaces/uiprotect 62 | COPY pyproject.toml poetry.lock ./ 63 | RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ 64 | poetry config virtualenvs.create false \ 65 | && poetry install --with dev --no-root --no-interaction --no-ansi 66 | 67 | FROM base AS dev 68 | 69 | # Python will not automatically write .pyc files 70 | ENV PYTHONDONTWRITEBYTECODE=1 71 | # Enables Python development mode, see https://docs.python.org/3/library/devmode.html 72 | ENV PYTHONDEVMODE=1 73 | 74 | COPY --from=builder-dev /usr/local/bin/ /usr/local/bin/ 75 | COPY --from=builder-dev /usr/local/lib/python3.13/ /usr/local/lib/python3.13/ 76 | COPY ./.docker/docker-fix.sh /usr/local/bin/docker-fix 77 | COPY ./.docker/bashrc /root/.bashrc 78 | COPY ./.docker/bashrc /home/app/.bashrc 79 | RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \ 80 | apt-get update -qq \ 81 | && apt-get install -yqq git curl vim procps jq sudo \ 82 | && echo 'app ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers \ 83 | && chown app:app /home/app/.bashrc \ 84 | && chmod +x /usr/local/bin/docker-fix 85 | 86 | ENV PYTHONPATH=/workspaces/uiprotect/ 87 | ENV PATH=$PATH:/workspaces/uiprotect/.bin 88 | USER app 89 | WORKDIR /workspaces/uiprotect/ 90 | 91 | 92 | # ============================================================================= 93 | # Development Container (for VS Code devcontainer) 94 | # ============================================================================= 95 | 96 | FROM python:3.13-slim-bookworm AS devcontainer 97 | 98 | RUN apt-get update && apt-get install -y \ 99 | curl \ 100 | git \ 101 | ffmpeg \ 102 | build-essential \ 103 | bash \ 104 | locales \ 105 | && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ 106 | && locale-gen en_US.UTF-8 \ 107 | && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \ 108 | && rm -rf /var/lib/apt/lists/* 109 | 110 | ENV LANG=en_US.UTF-8 111 | ENV LC_ALL=en_US.UTF-8 112 | 113 | SHELL ["/bin/bash", "-c"] 114 | 115 | RUN curl -sSL https://install.python-poetry.org | POETRY_VERSION=2.2.1 python3 - 116 | 117 | ENV PATH="/root/.local/bin:${PATH}" 118 | 119 | WORKDIR /workspace 120 | 121 | ENV POETRY_VIRTUALENVS_CREATE=false 122 | 123 | COPY pyproject.toml poetry.lock* ./ 124 | RUN poetry install --no-root 125 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given. 4 | 5 | You can contribute in many ways: 6 | 7 | ## Types of Contributions 8 | 9 | ### Report Bugs 10 | 11 | Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include: 12 | 13 | - Your operating system name and version. 14 | - Any details about your local setup that might be helpful in troubleshooting. 15 | - Detailed steps to reproduce the bug. 16 | 17 | ### Fix Bugs 18 | 19 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. 20 | 21 | ### Implement Features 22 | 23 | Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. 24 | 25 | ### Write Documentation 26 | 27 | uiprotect could always use more documentation, whether as part of the official uiprotect docs, in docstrings, or even on the web in blog posts, articles, and such. 28 | 29 | ### Submit Feedback 30 | 31 | The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature: 32 | 33 | - Explain in detail how it would work. 34 | - Keep the scope as narrow as possible, to make it easier to implement. 35 | - Remember that this is a volunteer-driven project, and that contributions are welcome 😊 36 | 37 | ## Get Started! 38 | 39 | Ready to contribute? Here's how to set yourself up for local development. 40 | 41 | 1. Fork the repo on GitHub. 42 | 43 | 2. Clone your fork locally: 44 | 45 | ```shell 46 | $ git clone git@github.com:your_name_here/uiprotect.git 47 | ``` 48 | 49 | 3. Install the project dependencies with [Poetry](https://python-poetry.org): 50 | 51 | ```shell 52 | $ poetry install 53 | ``` 54 | 55 | 4. Create a branch for local development: 56 | 57 | ```shell 58 | $ git checkout -b name-of-your-bugfix-or-feature 59 | ``` 60 | 61 | Now you can make your changes locally. 62 | 63 | 5. When you're done making changes, check that your changes pass our tests: 64 | 65 | ```shell 66 | $ poetry run pytest 67 | ``` 68 | 69 | 6. Linting is done through [pre-commit](https://pre-commit.com). Provided you have the tool installed globally, you can run them all as one-off: 70 | 71 | ```shell 72 | $ pre-commit run -a 73 | ``` 74 | 75 | Or better, install the hooks once and have them run automatically each time you commit: 76 | 77 | ```shell 78 | $ pre-commit install 79 | ``` 80 | 81 | 7. Commit your changes and push your branch to GitHub: 82 | 83 | ```shell 84 | $ git add . 85 | $ git commit -m "feat(something): your detailed description of your changes" 86 | $ git push origin name-of-your-bugfix-or-feature 87 | ``` 88 | 89 | Note: the commit message should follow [the conventional commits](https://www.conventionalcommits.org). We run [`commitlint` on CI](https://github.com/marketplace/actions/commit-linter) to validate it, and if you've installed pre-commit hooks at the previous step, the message will be checked at commit time. 90 | 91 | 8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed): 92 | 93 | ```shell 94 | $ gh pr create --fill 95 | ``` 96 | 97 | ## Pull Request Guidelines 98 | 99 | We like to have the pull request open as soon as possible, that's a great place to discuss any piece of work, even unfinished. You can use draft pull request if it's still a work in progress. Here are a few guidelines to follow: 100 | 101 | 1. Include tests for feature or bug fixes. 102 | 2. Update the documentation for significant features. 103 | 3. Ensure tests are passing on CI. 104 | 105 | ## Tips 106 | 107 | To run a subset of tests: 108 | 109 | ```shell 110 | $ pytest tests 111 | ``` 112 | 113 | ## Making a new release 114 | 115 | The deployment should be automated and can be triggered from the Semantic Release workflow in GitHub. The next version will be based on [the commit logs](https://python-semantic-release.readthedocs.io/en/latest/commit-log-parsing.html#commit-log-parsing). This is done by [python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/index.html) via a GitHub action. 116 | 117 | [gh-issues]: https://github.com/uilibs/uiprotect/issues 118 | -------------------------------------------------------------------------------- /docs/dev.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 | # Development 7 | 8 | ## Setup 9 | 10 | ### With VS Code 11 | 12 | Development with this project is designed to be done via VS Code + Docker. It is a pretty standard Python package, so feel free to use anything else, but all documentation assumes you are using VS Code. 13 | 14 | - [VS Code](https://code.visualstudio.com/) + [Remote Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 15 | - [Docker](https://docs.docker.com/get-docker/) 16 | - If you are using Linux, you need Docker Engine 19.0 or newer and you need to enable [Docker Buildkit](https://docs.docker.com/develop/develop-images/build_enhancements/) 17 | - If you are using Docker Desktop on MacOS or Windows, you will need Docker Desktop 3.2.0 or newer 18 | 19 | Once you have all three setup, 20 | 21 | 1. Clone repo 22 | 2. Open the main folder 23 | 3. You should be prompted to "Reopen folder to develop in a container". If you are not, you can open the [Command Palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) run the "Remote-Containers: Reopen in Container" command. 24 | 25 | This should be all you need to do to get a working development environment. The docker container will automatically be build and VS Code will attach itself to it. The integrated terminal in VS Code will already be set up with the `uiprotect` command. 26 | 27 | ### Docker (without VS Code) 28 | 29 | You can still setup develop without VS Code, but it is still recommended to use the development container to ensure you have all of the required dependencies. As a result, the above requirement for Docker is still needed. 30 | 31 | Once you have Docker setup, 32 | 33 | 1. Clone repo 34 | 2. Build and open dev container 35 | 36 | ```bash 37 | docker buildx build -f Dockerfile --target=dev -t uiprotect-dev . 38 | docker run --rm -it -v /home/cbailey/dev/uiprotect:/workspaces/uiprotect uiprotect-dev bash 39 | ``` 40 | 41 | ## Authenticating with your Local Protect Instance 42 | 43 | The project allows you to create an environment file to put your local protect instance data into so you do not need to constantly enter in or accidentally commit it to the Git repo. 44 | 45 | Make a file in the root of the project named `.env` with the following and change accordingly: 46 | 47 | ``` 48 | UFP_USERNAME=YOUR_USERNAME_HERE 49 | UFP_PASSWORD=YOUR_PASSWORD_HERE 50 | UFP_ADDRESS=YOUR_IP_ADDRESS 51 | UFP_PORT=443 52 | # set to true if you have a valid HTTPS certificate for your instance 53 | UFP_SSL_VERIFY=false 54 | ``` 55 | 56 | ## Linting and Testing 57 | 58 | The following scripts exist to easily format, lint and test code in the same fashion as CI: 59 | 60 | ``` 61 | pre-commit run --all-files 62 | .bin/test-code 63 | ``` 64 | 65 | These commands are also all available as [VS Code tasks](https://code.visualstudio.com/Docs/editor/tasks) as well. Tests are also fully integration with the Testing panel in VS Code and can be easily debug from there. 66 | 67 | ## Updating Requirements 68 | 69 | To generate an updated pinned requirements file to be used for testing and CI using the `.bin/update-requirements` script. 70 | 71 | There is also a [VS Code task](https://code.visualstudio.com/Docs/editor/tasks) to run this as well. 72 | 73 | ## Generating Test Data 74 | 75 | All of the tests in the project are ran against that is generated from a real UniFi Protect instance and then anonymized so it is safe to commit to a Git repo. To generate new sample test data: 76 | 77 | ``` 78 | uiprotect generate-sample-data 79 | ``` 80 | 81 | This will gather test data for 30 seconds and write it all into the `tests/sample_data` directory. During this time, it is a good idea to generate some good events that can tested. An example would be to generate a motion event for a FloodLight, Camera and/or Doorbell and then also ring a Doorbell. 82 | 83 | - All of the data that is generated is automatically anonymized so nothing sensitive about your NVR is exposed. To skip anonymization, use the `--actual` option. 84 | - To change output directory for sample data use the `-o / --output` option. 85 | - To adjust the time adjust how long to wait for Websocket messages, use the `-w / --wait` option. 86 | - To automatically zip up the generated sample data, use the `--zip` option. 87 | 88 | ```bash 89 | export UFP_SAMPLE_DIR=/workspaces/uiprotect/test-data 90 | uiprotect generate-sample-data 91 | ``` 92 | 93 | ### Real Data in Tests 94 | 95 | `pytest` will automatically also use the `UFP_SAMPLE_DIR` environment variable to locate sample data for running tests. This allows you to run `pytest` against a real NVR instance. 96 | 97 | ```bash 98 | export UFP_SAMPLE_DIR=/workspaces/uiprotect/test-data 99 | pytest 100 | ``` 101 | -------------------------------------------------------------------------------- /src/uiprotect/cli/nvr.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import timedelta 5 | 6 | import orjson 7 | import typer 8 | 9 | from ..cli import base 10 | from ..data import NVR, AnalyticsOption 11 | 12 | app = typer.Typer(rich_markup_mode="rich") 13 | 14 | ARG_TIMEOUT = typer.Argument(..., help="Timeout (in seconds)") 15 | ARG_DOORBELL_MESSAGE = typer.Argument(..., help="ASCII only. Max length 30") 16 | OPTION_ENABLE_SMART = typer.Option( 17 | False, 18 | "--enable-smart", 19 | help="Automatically enable smart detections", 20 | ) 21 | 22 | 23 | @dataclass 24 | class NVRContext(base.CliContext): 25 | device: NVR 26 | 27 | 28 | @app.callback(invoke_without_command=True) 29 | def main(ctx: typer.Context) -> None: 30 | """ 31 | NVR device CLI. 32 | 33 | Return NVR object without any arguments passed. 34 | """ 35 | context = NVRContext( 36 | protect=ctx.obj.protect, 37 | device=ctx.obj.protect.bootstrap.nvr, 38 | output_format=ctx.obj.output_format, 39 | ) 40 | ctx.obj = context 41 | 42 | if not ctx.invoked_subcommand: 43 | base.print_unifi_obj(context.device, ctx.obj.output_format) 44 | 45 | 46 | app.command(name="protect-url")(base.protect_url) 47 | app.command(name="reboot")(base.reboot) 48 | app.command(name="set-name")(base.set_name) 49 | 50 | 51 | @app.command() 52 | def set_analytics(ctx: typer.Context, value: AnalyticsOption) -> None: 53 | """Sets analytics collection for NVR.""" 54 | nvr: NVR = ctx.obj.device 55 | base.run(ctx, nvr.set_analytics(value)) 56 | 57 | 58 | @app.command() 59 | def set_default_reset_timeout(ctx: typer.Context, timeout: int = ARG_TIMEOUT) -> None: 60 | """ 61 | Sets default message reset timeout. 62 | 63 | This is how long until a custom message is reset back to the default message if no 64 | timeout is passed in when the custom message is set. 65 | """ 66 | nvr: NVR = ctx.obj.device 67 | base.run(ctx, nvr.set_default_reset_timeout(timedelta(seconds=timeout))) 68 | base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) 69 | 70 | 71 | @app.command() 72 | def set_default_doorbell_message( 73 | ctx: typer.Context, 74 | msg: str = ARG_DOORBELL_MESSAGE, 75 | ) -> None: 76 | """ 77 | Sets default message for doorbell. 78 | 79 | This is the message that is set when a custom doorbell message times out or an empty 80 | one is set. 81 | """ 82 | nvr: NVR = ctx.obj.device 83 | base.run(ctx, nvr.set_default_doorbell_message(msg)) 84 | base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) 85 | 86 | 87 | @app.command() 88 | def add_custom_doorbell_message( 89 | ctx: typer.Context, 90 | msg: str = ARG_DOORBELL_MESSAGE, 91 | ) -> None: 92 | """Adds a custom doorbell message.""" 93 | nvr: NVR = ctx.obj.device 94 | base.run(ctx, nvr.add_custom_doorbell_message(msg)) 95 | base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) 96 | 97 | 98 | @app.command() 99 | def remove_custom_doorbell_message( 100 | ctx: typer.Context, 101 | msg: str = ARG_DOORBELL_MESSAGE, 102 | ) -> None: 103 | """Removes a custom doorbell message.""" 104 | nvr: NVR = ctx.obj.device 105 | base.run(ctx, nvr.remove_custom_doorbell_message(msg)) 106 | base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) 107 | 108 | 109 | @app.command() 110 | def update(ctx: typer.Context, data: str) -> None: 111 | """Updates the NVR.""" 112 | nvr: NVR = ctx.obj.device 113 | base.run(ctx, nvr.api.update_nvr(orjson.loads(data))) 114 | 115 | 116 | @app.command() 117 | def set_smart_detections(ctx: typer.Context, value: bool) -> None: 118 | """Set if smart detections are globally enabled or not.""" 119 | nvr: NVR = ctx.obj.device 120 | base.run(ctx, nvr.set_smart_detections(value)) 121 | 122 | 123 | @app.command() 124 | def set_face_recognition( 125 | ctx: typer.Context, 126 | value: bool, 127 | enable_smart: bool = OPTION_ENABLE_SMART, 128 | ) -> None: 129 | """Set if face detections is enabled. Requires smart detections to be enabled.""" 130 | nvr: NVR = ctx.obj.device 131 | 132 | async def callback() -> None: 133 | if enable_smart: 134 | await nvr.set_smart_detections(True) 135 | await nvr.set_face_recognition(value) 136 | 137 | base.run(ctx, callback()) 138 | 139 | 140 | @app.command() 141 | def set_license_plate_recognition( 142 | ctx: typer.Context, 143 | value: bool, 144 | enable_smart: bool = OPTION_ENABLE_SMART, 145 | ) -> None: 146 | """Set if license plate detections is enabled. Requires smart detections to be enabled.""" 147 | nvr: NVR = ctx.obj.device 148 | 149 | async def callback() -> None: 150 | if enable_smart: 151 | await nvr.set_smart_detections(True) 152 | await nvr.set_license_plate_recognition(value) 153 | 154 | base.run(ctx, callback()) 155 | -------------------------------------------------------------------------------- /tests/data/test_doorlock.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import timedelta 6 | from typing import TYPE_CHECKING 7 | 8 | import pytest 9 | 10 | from tests.conftest import TEST_CAMERA_EXISTS, TEST_DOORLOCK_EXISTS 11 | from uiprotect.data.types import LockStatusType 12 | from uiprotect.exceptions import BadRequest 13 | from uiprotect.utils import to_ms 14 | 15 | if TYPE_CHECKING: 16 | from uiprotect.data import Camera, Doorlock, Light 17 | 18 | 19 | @pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata") 20 | @pytest.mark.asyncio() 21 | async def test_doorlock_set_paired_camera_none(doorlock_obj: Doorlock): 22 | doorlock_obj.api.api_request.reset_mock() 23 | 24 | doorlock_obj.camera_id = "bad_id" 25 | 26 | await doorlock_obj.set_paired_camera(None) 27 | 28 | doorlock_obj.api.api_request.assert_called_with( 29 | f"doorlocks/{doorlock_obj.id}", 30 | method="patch", 31 | json={"camera": None}, 32 | ) 33 | 34 | 35 | @pytest.mark.skipif( 36 | not TEST_DOORLOCK_EXISTS or not TEST_CAMERA_EXISTS, 37 | reason="Missing testdata", 38 | ) 39 | @pytest.mark.asyncio() 40 | async def test_doorlock_set_paired_camera(doorlock_obj: Light, camera_obj: Camera): 41 | doorlock_obj.api.api_request.reset_mock() 42 | 43 | doorlock_obj.camera_id = None 44 | 45 | await doorlock_obj.set_paired_camera(camera_obj) 46 | 47 | doorlock_obj.api.api_request.assert_called_with( 48 | f"doorlocks/{doorlock_obj.id}", 49 | method="patch", 50 | json={"camera": camera_obj.id}, 51 | ) 52 | 53 | 54 | @pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata") 55 | @pytest.mark.parametrize("status", [True, False]) 56 | @pytest.mark.asyncio() 57 | async def test_doorlock_set_status_light(doorlock_obj: Doorlock, status: bool): 58 | doorlock_obj.api.api_request.reset_mock() 59 | 60 | doorlock_obj.led_settings.is_enabled = not status 61 | 62 | await doorlock_obj.set_status_light(status) 63 | 64 | doorlock_obj.api.api_request.assert_called_with( 65 | f"doorlocks/{doorlock_obj.id}", 66 | method="patch", 67 | json={"ledSettings": {"isEnabled": status}}, 68 | ) 69 | 70 | 71 | @pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata") 72 | @pytest.mark.parametrize( 73 | "duration", 74 | [ 75 | timedelta(seconds=0), 76 | timedelta(seconds=15), 77 | timedelta(seconds=3600), 78 | timedelta(seconds=3601), 79 | ], 80 | ) 81 | @pytest.mark.asyncio() 82 | async def test_doorlock_set_auto_close_time( 83 | doorlock_obj: Doorlock, 84 | duration: timedelta, 85 | ): 86 | doorlock_obj.api.api_request.reset_mock() 87 | 88 | doorlock_obj.auto_close_time = timedelta(seconds=30) 89 | 90 | duration_invalid = duration is not None and int(duration.total_seconds()) == 3601 91 | if duration_invalid: 92 | with pytest.raises(BadRequest): 93 | await doorlock_obj.set_auto_close_time(duration) 94 | assert not doorlock_obj.api.api_request.called 95 | else: 96 | await doorlock_obj.set_auto_close_time(duration) 97 | 98 | expected = {"autoCloseTimeMs": to_ms(duration)} 99 | 100 | doorlock_obj.api.api_request.assert_called_with( 101 | f"doorlocks/{doorlock_obj.id}", 102 | method="patch", 103 | json=expected, 104 | ) 105 | 106 | 107 | @pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata") 108 | @pytest.mark.asyncio() 109 | async def test_doorlock_close(doorlock_obj: Doorlock): 110 | doorlock_obj.api.api_request.reset_mock() 111 | 112 | doorlock_obj.lock_status = LockStatusType.OPEN 113 | 114 | await doorlock_obj.close_lock() 115 | 116 | doorlock_obj.api.api_request.assert_called_with( 117 | f"doorlocks/{doorlock_obj.id}/close", 118 | method="post", 119 | ) 120 | 121 | 122 | @pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata") 123 | @pytest.mark.asyncio() 124 | async def test_doorlock_close_invalid(doorlock_obj: Doorlock): 125 | doorlock_obj.api.api_request.reset_mock() 126 | 127 | doorlock_obj.lock_status = LockStatusType.CLOSED 128 | 129 | with pytest.raises(BadRequest): 130 | await doorlock_obj.close_lock() 131 | 132 | assert not doorlock_obj.api.api_request.called 133 | 134 | 135 | @pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata") 136 | @pytest.mark.asyncio() 137 | async def test_doorlock_open(doorlock_obj: Doorlock): 138 | doorlock_obj.api.api_request.reset_mock() 139 | 140 | doorlock_obj.lock_status = LockStatusType.CLOSED 141 | 142 | await doorlock_obj.open_lock() 143 | 144 | doorlock_obj.api.api_request.assert_called_with( 145 | f"doorlocks/{doorlock_obj.id}/open", 146 | method="post", 147 | ) 148 | 149 | 150 | @pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata") 151 | @pytest.mark.asyncio() 152 | async def test_doorlock_open_invalid(doorlock_obj: Doorlock): 153 | doorlock_obj.api.api_request.reset_mock() 154 | 155 | doorlock_obj.lock_status = LockStatusType.OPEN 156 | 157 | with pytest.raises(BadRequest): 158 | await doorlock_obj.open_lock() 159 | 160 | assert not doorlock_obj.api.api_request.called 161 | -------------------------------------------------------------------------------- /LIVE_DATA_CI.md: -------------------------------------------------------------------------------- 1 | # How to make a GHA Workflow to Test Against your UFP Instance 2 | 3 | ## Fork vs. PR to main Repo 4 | 5 | It is recommended you do this all on your personal fork. Test it and make it all works. Then if you would like to submit your workflow to the main repo so we can use data from it, make a PR and coorindate with @uilibs to get it merged. 6 | 7 | **NOTE** If you do choose to make the PR and submit it, we will **not** have access to the sample data that is generated by your NVR. In the event the tests fail, we may request the files from you so we can reproduce any issues. 8 | 9 | ## Create a self-hosted GHA Runner 10 | 11 | You can use any method for creating a GHA runner you want. It is just required you use the labels `self-hosted,linux,ufp,YOUR_USERNAME`. To help out, 3 possible install methods are listed below. 12 | 13 | The only other additional requirement for the GHA runner is that it _must_ be able to communicate directly to your UniFi Protect instance. 14 | 15 | ### Getting Required variables 16 | 17 | Before starting any of the 3 options, you need 4 pieces of data: 18 | 19 | - `REPO_URL`: the URL for the repo the GHA action runner will be for 20 | - Either `https://github.com/uilibs/uiprotect` or the URL of your fork 21 | - `RUNNER_NAME`: an identifiable name for your runner. Can be anything, but make sure it unique. 22 | - `LABELS`: should be `self-hosted,linux,ufp,YOUR_USERNAME` 23 | - `RUNNER_TOKEN`: See below 24 | 25 | #### Generating Runner Token 26 | 27 | --- 28 | 29 | ## **NOTE**: If you want your workflow running on the main `uiprotect` repo, you will need to get @uilibs to do this step and give you the token. 30 | 31 | To create a self-hosted GHA runner, you need owner level access to the repo and then you need to go and generate a time-based auth token to create the runner (token expires in ~1 hour). 32 | 33 | 1. On your Github repo, Go to Settings -> Actions -> Runners and click "New self-hosted runner" 34 | 2. Copy the value after the `--token` argument under the "Configure" section 35 | 36 | ### Using a Home Assistant Add-on 37 | 38 | To help make the process as easy as possible, I made a simple Home Assistant add-on that wraps the "Using Docker" method below. 39 | 40 | 1. Go to "Supervisor -> Add-on Store -> Triple dots in corner -> Repositories" 41 | 2. Add the URL `https://github.com/uilibs/ha-addons` 42 | 3. Install the new "Github Actions Runner" add-on 43 | 4. Click the "Configuration" tab and enter your values from above 44 | 5. Start up the add-on 45 | 46 | `workdir` can be left as the default. It should already be a unique location that will not cause any issues. 47 | 48 | ### Using Docker 49 | 50 | You can use the awesome [premade Docker GHA runner](https://github.com/myoung34/docker-github-actions-runner). Just start it in your preferred method. 51 | 52 | To be able to access the data from outside of the docker container to debug and such, you will also need to make a fodler on the host machine for the runner workflow. Replace `/path/to/host/folder/for/data` for the path to your folder. 53 | 54 | ```bash 55 | docker run --rm -it \ 56 | -e REPO_URL=REPO_URL \ 57 | -e RUNNER_NAME=RUNNER_NAME \ 58 | -e LABELS=LABELS \ 59 | -e RUNNER_TOKEN=RUNNER_TOKEN \ 60 | -e RUNNER_WORKDIR=/data \ 61 | -v /path/to/host/folder/for/data:/data \ 62 | myoung34/github-runner:ubuntu-bionic 63 | ``` 64 | 65 | ### Roll your Own Runner 66 | 67 | If you would prefer to make your own runner to ensure the complete security of your home network. Go for it. 68 | 69 | The docs for making your own runner can be found on [Github's docs site](https://docs.github.com/en/actions/hosting-your-own-runners/adding-self-hosted-runners) 70 | 71 | ## Create a Local User in UFP for GHA 72 | 73 | Go to your UFP instance and create a Local User with admin permissions for GHA to use to access your instance. If you are already using the HA Integration for UFP, you can just reuse those credentials if you want. 74 | 75 | ## Add Secrets to Github Repo 76 | 77 | --- 78 | 79 | ## **NOTE**: If you want your workflow running on the main `uiprotect` repo, you will need to give the secrets to @uilibs and add the environment for you. 80 | 81 | Next step is to create an "environment" for your workflow to run it with the secrets you will need to connect to your UFP instance. We will also want to lock it down to prevent other users from dumping your secrets. 82 | 83 | 1. Go to "Settings -> Environments -> New Environment" and name it your Github username. 84 | 2. Change "Deployment Branches" to "Selected Branches" and add `master` 85 | 3. Under "Environment Secrets" add the following secrets: 86 | - `UFP_ADDRESS`: IP or host name to your UFP instance 87 | - `UFP_PORT`: Port for your UFP instance 88 | - `UFP_SSL_VERIFY`: True or False. Whether or not to verify SSL certs for instance 89 | - `UFP_USERNAME`: Username for your local admin user 90 | - `UFP_PASSWORD`: Password for your local admin user 91 | 92 | ## Create a workflow to Test data 93 | 94 | 1. Copy one of the existing workflows under `.github/workflows/test-live-*.yml` 95 | 2. Rename it to `test-live-YOUR_USERNAME.yml` 96 | 3. Open the file and change the username in the `name` section at the top 97 | 4. Replace the username in the `runs-on` section 98 | 5. Replace the username in the `environment` section 99 | 6. Replace `/share/gha-runner` in `UFP_SAMPLE_DIR: /share/gha-runner/ufp-data` to match the root directory of your GHA runner you configured above. if you are using the HA Add-on, you may not need to change anything here. 100 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported by contacting @uilibs. All complaints will be reviewed and 63 | investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /src/uiprotect/stream.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from asyncio.streams import StreamReader 6 | from asyncio.subprocess import PIPE, Process, create_subprocess_exec 7 | from pathlib import Path 8 | from shlex import split 9 | from typing import TYPE_CHECKING 10 | from urllib.parse import urlparse 11 | 12 | from aioshutil import which 13 | 14 | from .exceptions import BadRequest, StreamError 15 | 16 | if TYPE_CHECKING: 17 | from .data import Camera 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | CODEC_TO_ENCODER = { 22 | "aac": {"encoder": "aac", "format": "adts"}, 23 | "opus": {"encoder": "libopus", "format": "rtp"}, 24 | "vorbis": {"encoder": "libvorbis", "format": "ogg"}, 25 | } 26 | 27 | 28 | class FfmpegCommand: 29 | ffmpeg_path: Path | None 30 | args: list[str] 31 | process: Process | None = None 32 | 33 | stdout: list[str] = [] 34 | stderr: list[str] = [] 35 | 36 | def __init__(self, cmd: str, ffmpeg_path: Path | None = None) -> None: 37 | self.args = split(cmd) 38 | 39 | if "ffmpeg" in self.args[0] and ffmpeg_path is None: 40 | self.ffmpeg_path = Path(self.args.pop(0)) 41 | else: 42 | self.ffmpeg_path = ffmpeg_path 43 | 44 | @property 45 | def is_started(self) -> bool: 46 | return self.process is not None 47 | 48 | @property 49 | def is_running(self) -> bool: 50 | if self.process is None: 51 | return False 52 | 53 | return self.process.returncode is None 54 | 55 | @property 56 | def is_error(self) -> bool: 57 | if self.process is None: 58 | raise StreamError("ffmpeg has not started") 59 | 60 | if self.is_running: 61 | return False 62 | 63 | return self.process.returncode != 0 64 | 65 | async def start(self) -> None: 66 | if self.is_started: 67 | raise StreamError("ffmpeg command already started") 68 | 69 | if self.ffmpeg_path is None: 70 | system_ffmpeg = await which("ffmpeg") 71 | 72 | if system_ffmpeg is None: 73 | raise StreamError("Could not find ffmpeg") 74 | self.ffmpeg_path = Path(system_ffmpeg) 75 | 76 | if not self.ffmpeg_path.exists(): 77 | raise StreamError("Could not find ffmpeg") 78 | 79 | _LOGGER.debug("ffmpeg: %s %s", self.ffmpeg_path, " ".join(self.args)) 80 | self.process = await create_subprocess_exec( 81 | self.ffmpeg_path, 82 | *self.args, 83 | stdout=PIPE, 84 | stderr=PIPE, 85 | ) 86 | 87 | async def stop(self) -> None: 88 | if self.process is None: 89 | raise StreamError("ffmpeg has not started") 90 | 91 | self.process.kill() 92 | await self.process.wait() 93 | 94 | async def _read_stream(self, stream: StreamReader | None, attr: str) -> None: 95 | if stream is None: 96 | return 97 | 98 | while True: 99 | line = await stream.readline() 100 | if line: 101 | getattr(self, attr).append(line.decode("utf8").rstrip()) 102 | else: 103 | break 104 | 105 | async def run_until_complete(self) -> None: 106 | if not self.is_started: 107 | await self.start() 108 | 109 | if self.process is None: 110 | raise StreamError("Could not start stream") 111 | 112 | await asyncio.wait( 113 | [ 114 | asyncio.create_task(self._read_stream(self.process.stdout, "stdout")), 115 | asyncio.create_task(self._read_stream(self.process.stderr, "stderr")), 116 | ], 117 | ) 118 | await self.process.wait() 119 | 120 | 121 | class TalkbackStream(FfmpegCommand): 122 | camera: Camera 123 | content_url: str 124 | 125 | def __init__( 126 | self, 127 | camera: Camera, 128 | content_url: str, 129 | ffmpeg_path: Path | None = None, 130 | ): 131 | if not camera.feature_flags.has_speaker: 132 | raise BadRequest("Camera does not have a speaker for talkback") 133 | 134 | content_url = self.clean_url(content_url) 135 | input_args = self.get_args_from_url(content_url) 136 | if len(input_args) > 0: 137 | input_args += " " 138 | 139 | codec = camera.talkback_settings.type_fmt.value 140 | encoder = CODEC_TO_ENCODER.get(codec) 141 | if encoder is None: 142 | raise ValueError(f"Unsupported codec: {codec}") 143 | 144 | # vn = no video 145 | # acodec = audio codec to encode output in (aac) 146 | # ac = number of output channels (1) 147 | # ar = output sampling rate (22050) 148 | # b:a = set bit rate of output audio 149 | cmd = ( 150 | "-loglevel info -hide_banner " 151 | f'{input_args}-i "{content_url}" -vn ' 152 | f"-acodec {encoder['encoder']} -ac {camera.talkback_settings.channels} " 153 | f"-ar {camera.talkback_settings.sampling_rate} -b:a {camera.talkback_settings.sampling_rate} -map 0:a " 154 | f'-f {encoder["format"]} "udp://{camera.host}:{camera.talkback_settings.bind_port}?bitrate={camera.talkback_settings.sampling_rate}"' 155 | ) 156 | 157 | super().__init__(cmd, ffmpeg_path) 158 | 159 | @classmethod 160 | def clean_url(cls, content_url: str) -> str: 161 | parsed = urlparse(content_url) 162 | if parsed.scheme in {"file", ""}: 163 | path = Path(parsed.netloc + parsed.path) 164 | if not path.exists(): 165 | raise BadRequest(f"File {path} does not exist") 166 | content_url = str(path.absolute()) 167 | 168 | return content_url 169 | 170 | @classmethod 171 | def get_args_from_url(cls, content_url: str) -> str: 172 | # TODO: 173 | return "" 174 | -------------------------------------------------------------------------------- /src/uiprotect/cli/chimes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | import typer 6 | 7 | from ..api import ProtectApiClient 8 | from ..cli import base 9 | from ..data import Chime 10 | 11 | app = typer.Typer(rich_markup_mode="rich") 12 | 13 | ARG_DEVICE_ID = typer.Argument(None, help="ID of chime to select for subcommands") 14 | ARG_REPEAT = typer.Argument(..., help="Repeat times count", min=1, max=6) 15 | ARG_VOLUME = typer.Argument(..., help="Volume", min=1, max=100) 16 | 17 | 18 | @dataclass 19 | class ChimeContext(base.CliContext): 20 | devices: dict[str, Chime] 21 | device: Chime | None = None 22 | 23 | 24 | ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) 25 | 26 | 27 | @app.callback(invoke_without_command=True) 28 | def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: 29 | """ 30 | Chime device CLI. 31 | 32 | Returns full list of Chimes without any arguments passed. 33 | """ 34 | protect: ProtectApiClient = ctx.obj.protect 35 | context = ChimeContext( 36 | protect=ctx.obj.protect, 37 | device=None, 38 | devices=protect.bootstrap.chimes, 39 | output_format=ctx.obj.output_format, 40 | ) 41 | ctx.obj = context 42 | 43 | if device_id is not None and device_id not in ALL_COMMANDS: 44 | if (device := protect.bootstrap.chimes.get(device_id)) is None: 45 | typer.secho("Invalid chime ID", fg="red") 46 | raise typer.Exit(1) 47 | ctx.obj.device = device 48 | 49 | if not ctx.invoked_subcommand: 50 | if device_id in ALL_COMMANDS: 51 | ctx.invoke(ALL_COMMANDS[device_id], ctx) 52 | return 53 | 54 | if ctx.obj.device is not None: 55 | base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) 56 | return 57 | 58 | base.print_unifi_dict(ctx.obj.devices) 59 | 60 | 61 | @app.command() 62 | def cameras( 63 | ctx: typer.Context, 64 | camera_ids: list[str] = typer.Argument( 65 | None, 66 | help="Set to [] to empty list of cameras", 67 | ), 68 | add: bool = typer.Option(False, "-a", "--add", help="Add cameras instead of set"), 69 | remove: bool = typer.Option( 70 | False, 71 | "-r", 72 | "--remove", 73 | help="Remove cameras instead of set", 74 | ), 75 | ) -> None: 76 | """Returns or sets paired doorbells for the chime.""" 77 | base.require_device_id(ctx) 78 | obj: Chime = ctx.obj.device 79 | 80 | if add and remove: 81 | typer.secho("Add and remove are mutally exclusive", fg="red") 82 | raise typer.Exit(1) 83 | 84 | if len(camera_ids) == 0: 85 | base.print_unifi_list(obj.cameras) 86 | return 87 | 88 | protect: ProtectApiClient = ctx.obj.protect 89 | 90 | if len(camera_ids) == 1 and camera_ids[0] == "[]": 91 | camera_ids = [] 92 | 93 | for camera_id in camera_ids: 94 | if (camera := protect.bootstrap.cameras.get(camera_id)) is None: 95 | typer.secho(f"Invalid camera ID: {camera_id}", fg="red") 96 | raise typer.Exit(1) 97 | 98 | if not camera.feature_flags.is_doorbell: 99 | typer.secho(f"Camera is not a doorbell: {camera_id}", fg="red") 100 | raise typer.Exit(1) 101 | 102 | if add: 103 | camera_ids = list(set(obj.camera_ids) | set(camera_ids)) 104 | elif remove: 105 | camera_ids = list(set(obj.camera_ids) - set(camera_ids)) 106 | 107 | data_before_changes = obj.dict_with_excludes() 108 | obj.camera_ids = camera_ids 109 | base.run(ctx, obj.save_device(data_before_changes)) 110 | 111 | 112 | @app.command() 113 | def set_volume( 114 | ctx: typer.Context, 115 | value: int = ARG_VOLUME, 116 | camera_id: str | None = typer.Option( 117 | None, 118 | "-c", 119 | "--camera", 120 | help="Camera ID to apply volume to", 121 | ), 122 | ) -> None: 123 | """Set volume level for chime rings.""" 124 | base.require_device_id(ctx) 125 | obj: Chime = ctx.obj.device 126 | if camera_id is None: 127 | base.run(ctx, obj.set_volume(value)) 128 | else: 129 | protect: ProtectApiClient = ctx.obj.protect 130 | camera = protect.bootstrap.cameras.get(camera_id) 131 | if camera is None: 132 | typer.secho(f"Invalid camera ID: {camera_id}", fg="red") 133 | raise typer.Exit(1) 134 | base.run(ctx, obj.set_volume_for_camera(camera, value)) 135 | 136 | 137 | @app.command() 138 | def play( 139 | ctx: typer.Context, 140 | volume: int | None = typer.Option(None, "-v", "--volume", min=1, max=100), 141 | repeat_times: int | None = typer.Option(None, "-r", "--repeat", min=1, max=6), 142 | ) -> None: 143 | """Plays chime tone.""" 144 | base.require_device_id(ctx) 145 | obj: Chime = ctx.obj.device 146 | base.run(ctx, obj.play(volume=volume, repeat_times=repeat_times)) 147 | 148 | 149 | @app.command() 150 | def play_buzzer(ctx: typer.Context) -> None: 151 | """Plays chime buzzer.""" 152 | base.require_device_id(ctx) 153 | obj: Chime = ctx.obj.device 154 | base.run(ctx, obj.play_buzzer()) 155 | 156 | 157 | @app.command() 158 | def set_repeat_times( 159 | ctx: typer.Context, 160 | value: int = ARG_REPEAT, 161 | camera_id: str | None = typer.Option( 162 | None, 163 | "-c", 164 | "--camera", 165 | help="Camera ID to apply repeat times to", 166 | ), 167 | ) -> None: 168 | """Set number of times for a chime to repeat when doorbell is rang.""" 169 | base.require_device_id(ctx) 170 | obj: Chime = ctx.obj.device 171 | if camera_id is None: 172 | base.run(ctx, obj.set_repeat_times(value)) 173 | else: 174 | protect: ProtectApiClient = ctx.obj.protect 175 | camera = protect.bootstrap.cameras.get(camera_id) 176 | if camera is None: 177 | typer.secho(f"Invalid camera ID: {camera_id}", fg="red") 178 | raise typer.Exit(1) 179 | base.run(ctx, obj.set_repeat_times_for_camera(camera, value)) 180 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "uiprotect" 3 | version = "7.33.2" 4 | license = "MIT" 5 | description = "Python API for Unifi Protect (Unofficial)" 6 | authors = [{ name = "UI Protect Maintainers", email = "ui@koston.org" }] 7 | readme = "README.md" 8 | requires-python = ">=3.10" 9 | dynamic = ["classifiers", "dependencies"] 10 | 11 | [project.urls] 12 | "Repository" = "https://github.com/uilibs/uiprotect" 13 | "Documentation" = "https://uiprotect.readthedocs.io" 14 | "Bug Tracker" = "https://github.com/uilibs/uiprotect/issues" 15 | "Changelog" = "https://github.com/uilibs/uiprotect/blob/main/CHANGELOG.md" 16 | 17 | [project.scripts] 18 | uiprotect = "uiprotect.cli:app" 19 | 20 | [tool.poetry] 21 | classifiers = [ 22 | "Intended Audience :: Developers", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Topic :: Software Development :: Libraries", 26 | "Topic :: Software Development :: Build Tools", 27 | "Development Status :: 5 - Production/Stable", 28 | ] 29 | packages = [ 30 | { include = "uiprotect", from = "src" }, 31 | ] 32 | 33 | [tool.poetry.dependencies] 34 | python = ">=3.10" 35 | rich = ">=10" 36 | async-timeout = ">=3.0.1" 37 | aiofiles = ">=24" 38 | aiohttp = ">=3.10.0" 39 | aioshutil = ">=1.3" 40 | dateparser = ">=1.1.0" 41 | orjson = ">=3.9.15" 42 | packaging = ">=23" 43 | pillow = ">=10" 44 | platformdirs = ">=4" 45 | pydantic = ">=2.10.0" 46 | pyjwt = ">=2.6" 47 | yarl = ">=1.9" 48 | typer = ">=0.12.3" 49 | convertertools = ">=0.5.0" 50 | propcache = ">=0.0.0" 51 | pydantic-extra-types = ">=2.10.1" 52 | 53 | [tool.poetry.group.dev.dependencies] 54 | pytest = ">=7,<10" 55 | pytest-cov = ">=3,<8" 56 | aiosqlite = ">=0.20.0" 57 | asttokens = ">=2.4.1,<4.0.0" 58 | pytest-asyncio = ">=0.23.7,<1.4.0" 59 | pytest-benchmark = ">=4,<6" 60 | pytest-sugar = "^1.1.1" 61 | pytest-timeout = "^2.4.0" 62 | pytest-xdist = "^3.7.0" 63 | types-aiofiles = ">=23.2.0.20240403,<26.0.0.0" 64 | types-dateparser = "^1.2.2.20250809" 65 | mypy = "^1.18.2" 66 | pre-commit = "^4.0.0" 67 | 68 | [tool.poetry.group.docs] 69 | optional = true 70 | 71 | [tool.poetry.group.docs.dependencies] 72 | myst-parser = { version = ">=0.16", python = ">=3.11"} 73 | sphinx = { version = ">=4.0", python = ">=3.11"} 74 | furo = { version = ">=2023.5.20", python = ">=3.11"} 75 | sphinx-autobuild = { version = ">=2024.0.0", python = ">=3.11"} 76 | mike = "^2.1.1" 77 | mkdocs-material = "^9.7.0" 78 | mkdocs-material-extensions = "^1.3.1" 79 | pymdown-extensions = "^10.17.2" 80 | mkdocs-git-revision-date-localized-plugin = "^1.5.0" 81 | mkdocs-include-markdown-plugin = ">=6.1.1,<8.0.0" 82 | mkdocstrings = ">=0.25.1,<1.1.0" 83 | mkdocstrings-python = "^1.19.0" 84 | 85 | [tool.semantic_release] 86 | version_toml = ["pyproject.toml:project.version"] 87 | version_variables = [ 88 | "src/uiprotect/__init__.py:__version__", 89 | "docs/conf.py:release", 90 | ] 91 | build_command = "pip install poetry && poetry build" 92 | 93 | [tool.semantic_release.changelog] 94 | exclude_commit_patterns = [ 95 | "chore*", 96 | "ci*", 97 | ] 98 | 99 | [tool.semantic_release.changelog.environment] 100 | keep_trailing_newline = true 101 | 102 | [tool.semantic_release.branches.main] 103 | match = "main" 104 | 105 | [tool.semantic_release.branches.noop] 106 | match = "(?!main$)" 107 | prerelease = true 108 | 109 | [tool.pytest.ini_options] 110 | addopts = "-v -Wdefault --cov=uiprotect --cov-report=term-missing:skip-covered -n=auto" 111 | pythonpath = ["src"] 112 | 113 | [tool.coverage.run] 114 | branch = true 115 | 116 | [tool.coverage.report] 117 | exclude_lines = [ 118 | "pragma: no cover", 119 | "@overload", 120 | "if TYPE_CHECKING", 121 | "raise NotImplementedError", 122 | 'if __name__ == "__main__":', 123 | ] 124 | 125 | [tool.ruff] 126 | target-version = "py310" 127 | line-length = 88 128 | 129 | [tool.ruff.lint] 130 | ignore = [ 131 | "S101", # use of assert 132 | "D203", # 1 blank line required before class docstring 133 | "D212", # Multi-line docstring summary should start at the first line 134 | "D100", # Missing docstring in public module 135 | "D101", # Missing docstring in public module 136 | "D102", # Missing docstring in public method 137 | "D103", # Missing docstring in public module 138 | "D104", # Missing docstring in public package 139 | "D105", # Missing docstring in magic method 140 | "D107", # Missing docstring in `__init__` 141 | "D400", # First line should end with a period 142 | "D401", # First line of docstring should be in imperative mood 143 | "D205", # 1 blank line required between summary line and description 144 | "D415", # First line should end with a period, question mark, or exclamation point 145 | "D417", # Missing argument descriptions in the docstring 146 | "E501", # Line too long 147 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` 148 | "B008", # Do not perform function call 149 | "S110", # `try`-`except`-`pass` detected, consider logging the exception 150 | "D106", # Missing docstring in public nested class 151 | "UP007", # typer needs Optional syntax 152 | "S603", # check for execution of untrusted input 153 | "PERF203", # too many to fix right now 154 | "PLR2004", # Magic value used in comparison 155 | "PLR0913", # Too many arguments to function call 156 | "PLW1641", # Object does not implement __hash__ method 157 | ] 158 | select = [ 159 | "B", # flake8-bugbear 160 | "D", # flake8-docstrings 161 | "C4", # flake8-comprehensions 162 | "S", # flake8-bandit 163 | "F", # pyflake 164 | "FURB", # refurb rules 165 | "E", # pycodestyle 166 | "W", # pycodestyle 167 | "UP", # pyupgrade 168 | "I", # isort 169 | "PERF", # performance 170 | "PL", # pylint 171 | "RET", # return rules 172 | "RUF", # ruff specific 173 | "SIM", # simplify 174 | ] 175 | 176 | [tool.ruff.lint.per-file-ignores] 177 | "tests/**/*" = [ 178 | "D100", 179 | "D101", 180 | "D102", 181 | "D103", 182 | "D104", 183 | "S101", 184 | "PLR0911", 185 | "PLR0912", 186 | "PLR0915", 187 | ] 188 | "setup.py" = ["D100"] 189 | "conftest.py" = ["D100"] 190 | "docs/conf.py" = ["D100"] 191 | 192 | [tool.ruff.lint.isort] 193 | known-first-party = ["uiprotect", "tests"] 194 | 195 | [tool.mypy] 196 | disable_error_code = "import-untyped,unused-ignore" 197 | check_untyped_defs = true 198 | ignore_missing_imports = true 199 | disallow_any_generics = true 200 | disallow_incomplete_defs = true 201 | disallow_untyped_defs = true 202 | mypy_path = "src/" 203 | no_implicit_optional = true 204 | show_error_codes = true 205 | warn_unreachable = true 206 | warn_unused_ignores = true 207 | exclude = [ 208 | 'docs/.*', 209 | 'setup.py', 210 | ] 211 | 212 | [[tool.mypy.overrides]] 213 | module = "tests.*" 214 | allow_untyped_defs = true 215 | 216 | [[tool.mypy.overrides]] 217 | module = "docs.*" 218 | ignore_errors = true 219 | 220 | [build-system] 221 | requires = ["poetry-core>=2.1.0"] 222 | build-backend = "poetry.core.masonry.api" 223 | -------------------------------------------------------------------------------- /tests/data/test_user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers 4 | 5 | 6 | def test_ulp_user_creation(): 7 | user = UlpUser( 8 | id="1", 9 | ulp_id="ulp1", 10 | first_name="John", 11 | last_name="Doe", 12 | full_name="John Doe", 13 | avatar="avatar_url", 14 | status="active", 15 | ) 16 | assert user.id == "1" 17 | assert user.ulp_id == "ulp1" 18 | assert user.first_name == "John" 19 | assert user.last_name == "Doe" 20 | assert user.full_name == "John Doe" 21 | assert user.avatar == "avatar_url" 22 | assert user.status == "active" 23 | 24 | 25 | def test_ulp_users_add_and_remove(): 26 | users = UlpUsers() 27 | user = UlpUser( 28 | id="1", 29 | ulp_id="ulp1", 30 | first_name="John", 31 | last_name="Doe", 32 | full_name="John Doe", 33 | avatar="avatar_url", 34 | status="active", 35 | ) 36 | users.add(user) 37 | assert users.by_id("1") == user 38 | assert users.by_ulp_id("ulp1") == user 39 | 40 | users.remove(user) 41 | assert users.by_id("1") is None 42 | assert users.by_ulp_id("ulp1") is None 43 | 44 | 45 | def test_keyring_creation(): 46 | keyring = Keyring( 47 | id="1", 48 | device_type="type1", 49 | device_id="device1", 50 | registry_type="reg_type1", 51 | registry_id="reg_id1", 52 | last_activity=datetime.now(), 53 | ulp_user="ulp1", 54 | ) 55 | assert keyring.id == "1" 56 | assert keyring.device_type == "type1" 57 | assert keyring.device_id == "device1" 58 | assert keyring.registry_type == "reg_type1" 59 | assert keyring.registry_id == "reg_id1" 60 | assert keyring.ulp_user == "ulp1" 61 | 62 | 63 | def test_keyrings_add_and_remove(): 64 | keyrings = Keyrings() 65 | keyring = Keyring( 66 | id="1", 67 | device_type="type1", 68 | device_id="device1", 69 | registry_type="reg_type1", 70 | registry_id="reg_id1", 71 | last_activity=datetime.now(), 72 | ulp_user="ulp1", 73 | ) 74 | keyrings.add(keyring) 75 | assert keyrings.by_id("1") == keyring 76 | assert keyrings.by_registry_id("reg_id1") == keyring 77 | assert keyrings.by_ulp_id("ulp1") == keyring 78 | 79 | keyrings.remove(keyring) 80 | assert keyrings.by_id("1") is None 81 | assert keyrings.by_registry_id("reg_id1") is None 82 | assert keyrings.by_ulp_id("ulp1") is None 83 | 84 | 85 | def test_keyrings_equality(): 86 | keyrings1 = Keyrings() 87 | keyring1 = Keyring( 88 | id="1", 89 | device_type="type1", 90 | device_id="device1", 91 | registry_type="reg_type1", 92 | registry_id="reg_id1", 93 | last_activity=datetime.now(), 94 | ulp_user="ulp1", 95 | ) 96 | keyring2 = Keyring( 97 | id="2", 98 | device_type="type2", 99 | device_id="device2", 100 | registry_type="reg_type2", 101 | registry_id="reg_id2", 102 | last_activity=datetime.now(), 103 | ulp_user="ulp2", 104 | ) 105 | keyrings1.add(keyring1) 106 | keyrings1.add(keyring2) 107 | 108 | keyrings2 = Keyrings() 109 | keyrings2.add(keyring1) 110 | keyrings2.add(keyring2) 111 | 112 | assert keyrings1 == keyrings2 113 | 114 | keyrings3 = Keyrings() 115 | keyring3 = Keyring( 116 | id="3", 117 | device_type="type3", 118 | device_id="device3", 119 | registry_type="reg_type3", 120 | registry_id="reg_id3", 121 | last_activity=datetime.now(), 122 | ulp_user="ulp3", 123 | ) 124 | keyrings3.add(keyring3) 125 | 126 | assert keyrings1 != keyrings3 127 | 128 | 129 | def test_keyrings_eq_not_implemented(): 130 | keyrings = Keyrings() 131 | assert keyrings.__eq__(object()) == NotImplemented 132 | 133 | 134 | def test_ulp_users_eq_not_implemented(): 135 | users = UlpUsers() 136 | assert users.__eq__(object()) == NotImplemented 137 | 138 | 139 | def test_ulp_users_equality(): 140 | users1 = UlpUsers() 141 | user1 = UlpUser( 142 | id="1", 143 | ulp_id="ulp1", 144 | first_name="John", 145 | last_name="Doe", 146 | full_name="John Doe", 147 | avatar="avatar_url", 148 | status="active", 149 | ) 150 | user2 = UlpUser( 151 | id="2", 152 | ulp_id="ulp2", 153 | first_name="Jane", 154 | last_name="Doe", 155 | full_name="Jane Doe", 156 | avatar="avatar_url", 157 | status="inactive", 158 | ) 159 | users1.add(user1) 160 | users1.add(user2) 161 | 162 | users2 = UlpUsers() 163 | users2.add(user1) 164 | users2.add(user2) 165 | 166 | assert users1 == users2 167 | 168 | users3 = UlpUsers() 169 | user3 = UlpUser( 170 | id="3", 171 | ulp_id="ulp3", 172 | first_name="Jim", 173 | last_name="Beam", 174 | full_name="Jim Beam", 175 | avatar="avatar_url", 176 | status="active", 177 | ) 178 | users3.add(user3) 179 | 180 | assert users1 != users3 181 | 182 | 183 | def test_ulp_users_as_list(): 184 | users = UlpUsers() 185 | user1 = UlpUser( 186 | id="1", 187 | ulp_id="ulp1", 188 | first_name="John", 189 | last_name="Doe", 190 | full_name="John Doe", 191 | avatar="avatar_url", 192 | status="active", 193 | ) 194 | user2 = UlpUser( 195 | id="2", 196 | ulp_id="ulp2", 197 | first_name="Jane", 198 | last_name="Doe", 199 | full_name="Jane Doe", 200 | avatar="avatar_url", 201 | status="inactive", 202 | ) 203 | users.add(user1) 204 | users.add(user2) 205 | user_list = users.as_list() 206 | assert len(user_list) == 2 207 | assert user1 in user_list 208 | assert user2 in user_list 209 | 210 | 211 | def test_keyrings_from_list(): 212 | keyring1 = Keyring( 213 | id="1", 214 | device_type="type1", 215 | device_id="device1", 216 | registry_type="reg_type1", 217 | registry_id="reg_id1", 218 | last_activity=datetime.now(), 219 | ulp_user="ulp1", 220 | ) 221 | keyring2 = Keyring( 222 | id="2", 223 | device_type="type2", 224 | device_id="device2", 225 | registry_type="reg_type2", 226 | registry_id="reg_id2", 227 | last_activity=datetime.now(), 228 | ulp_user="ulp2", 229 | ) 230 | keyrings_list = [keyring1, keyring2] 231 | keyrings = Keyrings.from_list(keyrings_list) 232 | 233 | assert keyrings.by_id("1") == keyring1 234 | assert keyrings.by_id("2") == keyring2 235 | assert keyrings.by_registry_id("reg_id1") == keyring1 236 | assert keyrings.by_registry_id("reg_id2") == keyring2 237 | assert keyrings.by_ulp_id("ulp1") == keyring1 238 | assert keyrings.by_ulp_id("ulp2") == keyring2 239 | 240 | 241 | def test_ulp_users_from_list(): 242 | user1 = UlpUser( 243 | id="1", 244 | ulp_id="ulp1", 245 | first_name="John", 246 | last_name="Doe", 247 | full_name="John Doe", 248 | avatar="avatar_url", 249 | status="active", 250 | ) 251 | user2 = UlpUser( 252 | id="2", 253 | ulp_id="ulp2", 254 | first_name="Jane", 255 | last_name="Doe", 256 | full_name="Jane Doe", 257 | avatar="avatar_url", 258 | status="inactive", 259 | ) 260 | users_list = [user1, user2] 261 | users = UlpUsers.from_list(users_list) 262 | 263 | assert users.by_id("1") == user1 264 | assert users.by_id("2") == user2 265 | assert users.by_ulp_id("ulp1") == user1 266 | assert users.by_ulp_id("ulp2") == user2 267 | -------------------------------------------------------------------------------- /src/uiprotect/data/websocket.py: -------------------------------------------------------------------------------- 1 | """Classes for decoding/encoding data from UniFi OS Websocket""" 2 | 3 | from __future__ import annotations 4 | 5 | import base64 6 | import enum 7 | import struct 8 | import zlib 9 | from dataclasses import dataclass 10 | from functools import cache 11 | from typing import TYPE_CHECKING, Any 12 | 13 | import orjson 14 | 15 | from .._compat import cached_property 16 | from ..exceptions import WSDecodeError, WSEncodeError 17 | from .types import ProtectWSPayloadFormat 18 | 19 | if TYPE_CHECKING: 20 | from .base import ProtectModelWithId 21 | 22 | WS_HEADER_SIZE = 8 23 | 24 | 25 | @dataclass(slots=True) 26 | class WSPacketFrameHeader: 27 | packet_type: int 28 | payload_format: int 29 | deflated: int 30 | unknown: int 31 | payload_size: int 32 | 33 | 34 | @enum.unique 35 | class WSAction(str, enum.Enum): 36 | ADD = "add" 37 | UPDATE = "update" 38 | REMOVE = "remove" 39 | 40 | 41 | @dataclass(slots=True) 42 | class WSSubscriptionMessage: 43 | action: WSAction 44 | new_update_id: str 45 | changed_data: dict[str, Any] 46 | new_obj: ProtectModelWithId | None = None 47 | old_obj: ProtectModelWithId | None = None 48 | 49 | 50 | _PACKET_STRUCT = struct.Struct("!bbbbi") 51 | 52 | 53 | class BaseWSPacketFrame: 54 | unpack = _PACKET_STRUCT.unpack 55 | pack = _PACKET_STRUCT.pack 56 | 57 | data: Any 58 | position: int = 0 59 | header: WSPacketFrameHeader | None = None 60 | payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer 61 | is_deflated: bool = False 62 | length: int = 0 63 | 64 | def __repr__(self) -> str: 65 | return f"<{self.__class__.__name__} header={self.header} data={self.data}>" 66 | 67 | def set_data_from_binary(self, data: bytes) -> None: 68 | self.data = data 69 | if self.header is not None and self.header.deflated: 70 | self.data = zlib.decompress(self.data) 71 | 72 | def get_binary_from_data(self) -> bytes: 73 | raise NotImplementedError 74 | 75 | @staticmethod 76 | @cache 77 | def klass_from_format(format_raw: int) -> type[BaseWSPacketFrame]: 78 | payload_format = ProtectWSPayloadFormat(format_raw) 79 | 80 | if payload_format == ProtectWSPayloadFormat.JSON: 81 | return WSJSONPacketFrame 82 | 83 | return WSRawPacketFrame 84 | 85 | @staticmethod 86 | def from_binary( 87 | data: bytes, 88 | position: int = 0, 89 | klass: type[WSRawPacketFrame] | None = None, 90 | ) -> BaseWSPacketFrame: 91 | """ 92 | Decode a unifi updates websocket frame. 93 | 94 | The format of the frame is 95 | b: packet_type 96 | b: payload_format 97 | b: deflated 98 | b: unknown 99 | i: payload_size 100 | """ 101 | header_end = position + WS_HEADER_SIZE 102 | payload_size: int 103 | try: 104 | ( 105 | packet_type, 106 | payload_format, 107 | deflated, 108 | unknown, 109 | payload_size, 110 | ) = BaseWSPacketFrame.unpack( 111 | data[position:header_end], 112 | ) 113 | except struct.error as e: 114 | raise WSDecodeError from e 115 | 116 | if klass is None: 117 | frame = WSRawPacketFrame.klass_from_format(payload_format)() 118 | else: 119 | frame = klass() 120 | frame.payload_format = ProtectWSPayloadFormat(payload_format) 121 | 122 | frame.header = WSPacketFrameHeader( 123 | packet_type=packet_type, 124 | payload_format=payload_format, 125 | deflated=deflated, 126 | unknown=unknown, 127 | payload_size=payload_size, 128 | ) 129 | frame.length = WS_HEADER_SIZE + payload_size 130 | frame.is_deflated = bool(deflated) 131 | frame_end = header_end + payload_size 132 | frame.set_data_from_binary(data[header_end:frame_end]) 133 | 134 | return frame 135 | 136 | @property 137 | def packed(self) -> bytes: 138 | if self.header is None: 139 | raise WSEncodeError("No header to encode") 140 | 141 | data = self.get_binary_from_data() 142 | header = self.pack( 143 | self.header.packet_type, 144 | self.header.payload_format, 145 | self.header.deflated, 146 | self.header.unknown, 147 | len(data), 148 | ) 149 | 150 | return header + data 151 | 152 | 153 | class WSRawPacketFrame(BaseWSPacketFrame): 154 | data: bytes = b"" 155 | 156 | def get_binary_from_data(self) -> bytes: 157 | data = self.data 158 | if self.is_deflated: 159 | data = zlib.compress(data) 160 | 161 | return data 162 | 163 | 164 | class WSJSONPacketFrame(BaseWSPacketFrame): 165 | data: dict[str, Any] = {} 166 | payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer 167 | 168 | def set_data_from_binary(self, data: bytes) -> None: 169 | if self.header is not None and self.header.deflated: 170 | data = zlib.decompress(data) 171 | 172 | self.data = orjson.loads(data) 173 | 174 | def get_binary_from_data(self) -> bytes: 175 | data = self.json 176 | if self.is_deflated: 177 | data = zlib.compress(data) 178 | 179 | return data 180 | 181 | @property 182 | def json(self) -> bytes: 183 | return orjson.dumps(self.data) 184 | 185 | 186 | class WSPacket: 187 | """Class to handle a unifi protect websocket packet.""" 188 | 189 | _raw: bytes 190 | _raw_encoded: str | None = None 191 | 192 | _action_frame: BaseWSPacketFrame | None = None 193 | _data_frame: BaseWSPacketFrame | None = None 194 | 195 | def __init__(self, data: bytes) -> None: 196 | self._raw = data 197 | 198 | def __repr__(self) -> str: 199 | return f"<{self.__class__.__name__} action_frame={self.action_frame} data_frame={self.data_frame}>" 200 | 201 | def decode(self) -> None: 202 | data = self._raw 203 | self._action_frame = WSRawPacketFrame.from_binary(data) 204 | length = self._action_frame.length 205 | self._data_frame = WSRawPacketFrame.from_binary(data, length) 206 | 207 | @cached_property 208 | def action_frame(self) -> BaseWSPacketFrame: 209 | if self._action_frame is None: 210 | self.decode() 211 | if TYPE_CHECKING: 212 | assert self._action_frame is not None 213 | assert self._data_frame is not None 214 | self.__dict__["data_frame"] = self._data_frame 215 | return self._action_frame 216 | 217 | @cached_property 218 | def data_frame(self) -> BaseWSPacketFrame: 219 | if self._data_frame is None: 220 | self.decode() 221 | if TYPE_CHECKING: 222 | assert self._action_frame is not None 223 | assert self._data_frame is not None 224 | self.__dict__["action_frame"] = self._action_frame 225 | return self._data_frame 226 | 227 | @property 228 | def raw(self) -> bytes: 229 | return self._raw 230 | 231 | @raw.setter 232 | def raw(self, data: bytes) -> None: 233 | self._raw = data 234 | self._action_frame = None 235 | self._data_frame = None 236 | self._raw_encoded = None 237 | self.__dict__.pop("data_frame", None) 238 | self.__dict__.pop("action_frame", None) 239 | 240 | @property 241 | def raw_base64(self) -> str: 242 | if self._raw_encoded is None: 243 | self._raw_encoded = base64.b64encode(self._raw).decode("utf-8") 244 | 245 | return self._raw_encoded 246 | 247 | def pack_frames(self) -> bytes: 248 | self._raw_encoded = None 249 | self._raw = self.action_frame.packed + self.data_frame.packed 250 | 251 | return self._raw 252 | -------------------------------------------------------------------------------- /tests/test_api_polling.py: -------------------------------------------------------------------------------- 1 | """Tests for uiprotect.unifi_protect_server.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import timedelta 6 | from typing import TYPE_CHECKING 7 | from unittest.mock import patch 8 | 9 | import pytest 10 | 11 | from tests.conftest import MockDatetime 12 | from uiprotect.data import Camera, EventType 13 | from uiprotect.utils import to_js_time 14 | 15 | from .common import assert_equal_dump 16 | 17 | if TYPE_CHECKING: 18 | from uiprotect import ProtectApiClient 19 | 20 | 21 | @pytest.mark.asyncio() 22 | async def test_process_events_none(protect_client: ProtectApiClient, camera): 23 | def get_camera(): 24 | return protect_client.bootstrap.cameras[camera["id"]] 25 | 26 | bootstrap_before = protect_client.bootstrap.unifi_dict() 27 | camera_before = get_camera().model_copy() 28 | 29 | async def get_events(*args, **kwargs): 30 | return [] 31 | 32 | protect_client.get_events_raw = get_events # type: ignore[method-assign] 33 | 34 | await protect_client.update() 35 | 36 | assert protect_client.bootstrap.unifi_dict() == bootstrap_before 37 | assert_equal_dump(get_camera(), camera_before) 38 | 39 | 40 | def _reset_events(camera: Camera) -> None: 41 | camera.last_ring_event_id = None 42 | camera.last_ring = None 43 | camera.last_motion_event_id = None 44 | camera.last_motion = None 45 | camera.last_smart_detect = None 46 | camera.last_smart_detect_event_id = None 47 | camera.last_fingerprint_identified_event_id = None 48 | camera.last_fingerprint_identified = None 49 | camera.last_nfc_card_scanned_event_id = None 50 | camera.last_nfc_card_scanned = None 51 | camera.last_smart_detects = {} 52 | camera.last_smart_detect_event_ids = {} 53 | 54 | 55 | @pytest.mark.asyncio() 56 | @patch("uiprotect.api.datetime", MockDatetime) 57 | async def test_process_events_ring(protect_client: ProtectApiClient, now, camera): 58 | def get_camera(): 59 | return protect_client.bootstrap.cameras[camera["id"]] 60 | 61 | camera_before = get_camera().model_copy() 62 | 63 | expected_event_id = "bf9a241afe74821ceffffd05" 64 | 65 | async def get_events(*args, **kwargs): 66 | return [ 67 | { 68 | "id": expected_event_id, 69 | "type": "ring", 70 | "start": to_js_time(now - timedelta(seconds=1)), 71 | "end": to_js_time(now), 72 | "score": 0, 73 | "smartDetectTypes": [], 74 | "smartDetectEvents": [], 75 | "camera": camera["id"], 76 | "partition": None, 77 | "user": None, 78 | "metadata": {}, 79 | "thumbnail": f"e-{expected_event_id}", 80 | "heatmap": f"e-{expected_event_id}", 81 | "modelKey": "event", 82 | }, 83 | ] 84 | 85 | protect_client.get_events_raw = get_events # type: ignore[method-assign] 86 | 87 | await protect_client.update() # fetch initial bootstrap 88 | await protect_client.poll_events() # process events since bootstrap 89 | 90 | camera = get_camera() 91 | 92 | event = camera.last_ring_event 93 | _reset_events(camera) 94 | _reset_events(camera_before) 95 | 96 | assert camera.model_dump() == camera_before.model_dump() 97 | assert event.id == expected_event_id 98 | assert event.type == EventType.RING 99 | assert event.thumbnail_id == f"e-{expected_event_id}" 100 | assert event.heatmap_id == f"e-{expected_event_id}" 101 | 102 | 103 | @pytest.mark.asyncio() 104 | @patch("uiprotect.api.datetime", MockDatetime) 105 | async def test_process_events_motion(protect_client: ProtectApiClient, now, camera): 106 | def get_camera(): 107 | return protect_client.bootstrap.cameras[camera["id"]] 108 | 109 | camera_before = get_camera().model_copy() 110 | 111 | expected_event_id = "bf9a241afe74821ceffffd05" 112 | 113 | async def get_events(*args, **kwargs): 114 | return [ 115 | { 116 | "id": expected_event_id, 117 | "type": "motion", 118 | "start": to_js_time(now - timedelta(seconds=30)), 119 | "end": to_js_time(now), 120 | "score": 0, 121 | "smartDetectTypes": [], 122 | "smartDetectEvents": [], 123 | "camera": camera["id"], 124 | "partition": None, 125 | "user": None, 126 | "metadata": {}, 127 | "thumbnail": f"e-{expected_event_id}", 128 | "heatmap": f"e-{expected_event_id}", 129 | "modelKey": "event", 130 | }, 131 | ] 132 | 133 | protect_client.get_events_raw = get_events # type: ignore[method-assign] 134 | 135 | await protect_client.update() # fetch initial bootstrap 136 | await protect_client.poll_events() # process events since bootstrap 137 | 138 | camera_before.is_motion_detected = False 139 | camera = get_camera() 140 | 141 | event = camera.last_motion_event 142 | _reset_events(camera) 143 | _reset_events(camera_before) 144 | 145 | assert camera.model_dump() == camera_before.model_dump() 146 | assert event.id == expected_event_id 147 | assert event.type == EventType.MOTION 148 | assert event.thumbnail_id == f"e-{expected_event_id}" 149 | assert event.heatmap_id == f"e-{expected_event_id}" 150 | assert event.start == (now - timedelta(seconds=30)) 151 | 152 | 153 | @pytest.mark.asyncio() 154 | @patch("uiprotect.api.datetime", MockDatetime) 155 | async def test_process_events_smart(protect_client: ProtectApiClient, now, camera): 156 | def get_camera(): 157 | return protect_client.bootstrap.cameras[camera["id"]] 158 | 159 | camera_before = get_camera().model_copy() 160 | 161 | expected_event_id = "bf9a241afe74821ceffffd05" 162 | 163 | async def get_events(*args, **kwargs): 164 | return [ 165 | { 166 | "id": expected_event_id, 167 | "type": "smartDetectZone", 168 | "start": to_js_time(now - timedelta(seconds=30)), 169 | "end": to_js_time(now), 170 | "score": 0, 171 | "smartDetectTypes": ["person"], 172 | "smartDetectEvents": [], 173 | "camera": camera["id"], 174 | "partition": None, 175 | "user": None, 176 | "metadata": {}, 177 | "thumbnail": f"e-{expected_event_id}", 178 | "heatmap": f"e-{expected_event_id}", 179 | "modelKey": "event", 180 | }, 181 | ] 182 | 183 | protect_client.get_events_raw = get_events # type: ignore[method-assign] 184 | 185 | await protect_client.update() # fetch initial bootstrap 186 | await protect_client.poll_events() # process events since bootstrap 187 | 188 | camera = get_camera() 189 | 190 | smart_event = camera.last_smart_detect_event 191 | assert camera.last_smart_detect == smart_event.start 192 | 193 | _reset_events(camera) 194 | _reset_events(camera_before) 195 | 196 | assert camera.model_dump() == camera_before.model_dump() 197 | assert smart_event.id == expected_event_id 198 | assert smart_event.type == EventType.SMART_DETECT 199 | assert smart_event.thumbnail_id == f"e-{expected_event_id}" 200 | assert smart_event.heatmap_id == f"e-{expected_event_id}" 201 | assert smart_event.start == (now - timedelta(seconds=30)) 202 | assert smart_event.end == now 203 | 204 | 205 | @pytest.mark.asyncio() 206 | @patch("uiprotect.api.datetime", MockDatetime) 207 | async def test_event_return_none(protect_client: ProtectApiClient, now, camera): 208 | def get_camera(): 209 | return protect_client.bootstrap.cameras[camera["id"]] 210 | 211 | camera = get_camera() 212 | 213 | _reset_events(camera) 214 | 215 | assert camera.last_smart_detect_event is None 216 | assert camera.last_nfc_card_scanned_event is None 217 | assert camera.last_motion_event is None 218 | assert camera.last_fingerprint_identified_event is None 219 | -------------------------------------------------------------------------------- /src/uiprotect/cli/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from datetime import datetime 6 | from pathlib import Path 7 | 8 | import typer 9 | from rich.progress import Progress 10 | 11 | from .. import data as d 12 | from ..api import ProtectApiClient 13 | from ..cli import base 14 | from ..exceptions import NvrError 15 | from ..utils import local_datetime 16 | 17 | app = typer.Typer(rich_markup_mode="rich") 18 | 19 | ARG_EVENT_ID = typer.Argument(None, help="ID of camera to select for subcommands") 20 | OPTION_START = typer.Option(None, "-s", "--start") 21 | OPTION_END = typer.Option(None, "-e", "--end") 22 | OPTION_LIMIT = typer.Option(None, "-l", "--limit") 23 | OPTION_OFFSET = typer.Option(None, "-o", "--offet") 24 | OPTION_TYPES = typer.Option(None, "-t", "--type") 25 | OPTION_SMART_TYPES = typer.Option( 26 | None, 27 | "-d", 28 | "--smart-detect", 29 | help="If provided, will only return smartDetectZone events", 30 | ) 31 | 32 | 33 | @dataclass 34 | class EventContext(base.CliContext): 35 | events: dict[str, d.Event] | None = None 36 | event: d.Event | None = None 37 | 38 | 39 | ALL_COMMANDS: dict[str, Callable[..., None]] = {} 40 | 41 | 42 | @app.callback(invoke_without_command=True) 43 | def main( 44 | ctx: typer.Context, 45 | event_id: str | None = ARG_EVENT_ID, 46 | start: datetime | None = OPTION_START, 47 | end: datetime | None = OPTION_END, 48 | limit: int | None = OPTION_LIMIT, 49 | offset: int | None = OPTION_OFFSET, 50 | types: list[d.EventType] | None = OPTION_TYPES, 51 | smart_types: list[d.SmartDetectObjectType] | None = OPTION_SMART_TYPES, 52 | ) -> None: 53 | """ 54 | Events CLI. 55 | 56 | Returns list of events from the last 24 hours without any arguments passed. 57 | """ 58 | protect: ProtectApiClient = ctx.obj.protect 59 | context = EventContext( 60 | protect=ctx.obj.protect, 61 | event=None, 62 | events=None, 63 | output_format=ctx.obj.output_format, 64 | ) 65 | ctx.obj = context 66 | 67 | if event_id is not None and event_id not in ALL_COMMANDS: 68 | try: 69 | ctx.obj.event = base.run(ctx, protect.get_event(event_id)) 70 | except NvrError as err: 71 | typer.secho("Invalid event ID", fg="red") 72 | raise typer.Exit(1) from err 73 | 74 | if not ctx.invoked_subcommand: 75 | if ctx.obj.event is not None: 76 | base.print_unifi_obj(ctx.obj.event, ctx.obj.output_format) 77 | return 78 | 79 | if types is not None and len(types) == 0: 80 | types = None 81 | if smart_types is not None and len(smart_types) == 0: 82 | smart_types = None 83 | events = base.run( 84 | ctx, 85 | protect.get_events( 86 | start=start, 87 | end=end, 88 | limit=limit, 89 | offset=offset, 90 | types=types, 91 | smart_detect_types=smart_types, 92 | ), 93 | ) 94 | ctx.obj.events = {} 95 | for event in events: 96 | ctx.obj.events[event.id] = event 97 | 98 | if event_id in ALL_COMMANDS: 99 | ctx.invoke(ALL_COMMANDS[event_id], ctx) 100 | return 101 | 102 | base.print_unifi_dict(ctx.obj.events) 103 | 104 | 105 | def require_event_id(ctx: typer.Context) -> None: 106 | """Requires event ID in context""" 107 | if ctx.obj.event is None: 108 | typer.secho("Requires a valid event ID to be selected") 109 | raise typer.Exit(1) 110 | 111 | 112 | def require_no_event_id(ctx: typer.Context) -> None: 113 | """Requires no device ID in context""" 114 | if ctx.obj.event is not None or ctx.obj.events is None: 115 | typer.secho("Requires no event ID to be selected") 116 | raise typer.Exit(1) 117 | 118 | 119 | @app.command() 120 | def list_ids(ctx: typer.Context) -> None: 121 | """ 122 | Prints list of "id type timestamp" for each event. 123 | 124 | Timestamps dispalyed in your locale timezone. If it is not configured 125 | correctly, it will default to UTC. You can override your timezone with 126 | the TZ environment variable. 127 | """ 128 | require_no_event_id(ctx) 129 | objs: dict[str, d.Event] = ctx.obj.events 130 | to_print: list[tuple[str, str, datetime]] = [] 131 | longest_event = 0 132 | for obj in objs.values(): 133 | event_type = obj.type.value 134 | if event_type in { 135 | d.EventType.SMART_DETECT.value, 136 | d.EventType.SMART_DETECT_LINE.value, 137 | }: 138 | event_type = f"{event_type}[{','.join(obj.smart_detect_types)}]" 139 | longest_event = max(len(event_type), longest_event) 140 | dt = obj.timestamp or obj.start 141 | dt = local_datetime(dt) 142 | 143 | to_print.append((obj.id, event_type, dt)) 144 | 145 | if ctx.obj.output_format == base.OutputFormatEnum.JSON: 146 | base.json_output(to_print) 147 | else: 148 | for item in to_print: 149 | typer.echo(f"{item[0]}\t{item[1]:{longest_event}}\t{item[2]}") 150 | 151 | 152 | ALL_COMMANDS["list-ids"] = list_ids 153 | 154 | 155 | @app.command() 156 | def save_thumbnail( 157 | ctx: typer.Context, 158 | output_path: Path = typer.Argument(..., help="JPEG format"), 159 | ) -> None: 160 | """ 161 | Saves thumbnail for event. 162 | 163 | Only for ring, motion and smartDetectZone events. 164 | """ 165 | require_event_id(ctx) 166 | event: d.Event = ctx.obj.event 167 | 168 | thumbnail = base.run(ctx, event.get_thumbnail()) 169 | if thumbnail is None: 170 | typer.secho("Could not get thumbnail", fg="red") 171 | raise typer.Exit(1) 172 | 173 | Path(output_path).write_bytes(thumbnail) 174 | 175 | 176 | @app.command() 177 | def save_animated_thumbnail( 178 | ctx: typer.Context, 179 | output_path: Path = typer.Argument(..., help="GIF format"), 180 | ) -> None: 181 | """ 182 | Saves animated thumbnail for event. 183 | 184 | Only for ring, motion and smartDetectZone events. 185 | """ 186 | require_event_id(ctx) 187 | event: d.Event = ctx.obj.event 188 | 189 | thumbnail = base.run(ctx, event.get_animated_thumbnail()) 190 | if thumbnail is None: 191 | typer.secho("Could not get thumbnail", fg="red") 192 | raise typer.Exit(1) 193 | 194 | Path(output_path).write_bytes(thumbnail) 195 | 196 | 197 | @app.command() 198 | def save_heatmap( 199 | ctx: typer.Context, 200 | output_path: Path = typer.Argument(..., help="PNG format"), 201 | ) -> None: 202 | """ 203 | Saves heatmap for event. 204 | 205 | Only motion events have heatmaps. 206 | """ 207 | require_event_id(ctx) 208 | event: d.Event = ctx.obj.event 209 | 210 | heatmap = base.run(ctx, event.get_heatmap()) 211 | if heatmap is None: 212 | typer.secho("Could not get heatmap", fg="red") 213 | raise typer.Exit(1) 214 | 215 | Path(output_path).write_bytes(heatmap) 216 | 217 | 218 | @app.command() 219 | def save_video( 220 | ctx: typer.Context, 221 | output_path: Path = typer.Argument(..., help="MP4 format"), 222 | channel: int = typer.Option( 223 | 0, 224 | "-c", 225 | "--channel", 226 | min=0, 227 | max=3, 228 | help="0 = High, 1 = Medium, 2 = Low, 3 = Package", 229 | ), 230 | ) -> None: 231 | """ 232 | Exports video for event. 233 | 234 | Only for ring, motion and smartDetectZone events. 235 | """ 236 | require_event_id(ctx) 237 | event: d.Event = ctx.obj.event 238 | 239 | with Progress() as pb: 240 | task_id = pb.add_task("(1/2) Exporting", total=100) 241 | 242 | async def callback(step: int, current: int, total: int) -> None: 243 | pb.update( 244 | task_id, 245 | total=total, 246 | completed=current, 247 | description="(2/2) Downloading", 248 | ) 249 | 250 | base.run( 251 | ctx, 252 | event.get_video( 253 | channel, 254 | output_file=output_path, 255 | progress_callback=callback, 256 | ), 257 | ) 258 | -------------------------------------------------------------------------------- /src/uiprotect/cli/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Coroutine, Mapping, Sequence 4 | from dataclasses import dataclass 5 | from enum import Enum 6 | from typing import Any, TypeVar 7 | 8 | import orjson 9 | import typer 10 | from pydantic import ValidationError 11 | 12 | from ..api import ProtectApiClient 13 | from ..data import NVR, ProtectAdoptableDeviceModel, ProtectBaseObject 14 | from ..exceptions import BadRequest, NvrError, StreamError 15 | from ..utils import run_async 16 | 17 | T = TypeVar("T") 18 | 19 | OPTION_FORCE = typer.Option(False, "-f", "--force", help="Skip confirmation prompt") 20 | 21 | 22 | class OutputFormatEnum(str, Enum): 23 | JSON = "json" 24 | PLAIN = "plain" 25 | 26 | 27 | @dataclass 28 | class CliContext: 29 | protect: ProtectApiClient 30 | output_format: OutputFormatEnum 31 | 32 | 33 | def run(ctx: typer.Context, func: Coroutine[Any, Any, T]) -> T: 34 | """Helper method to call async function and clean up API client""" 35 | 36 | async def callback() -> T: 37 | return_value = await func 38 | await ctx.obj.protect.close_session() 39 | await ctx.obj.protect.close_public_api_session() 40 | return return_value 41 | 42 | try: 43 | return run_async(callback()) 44 | except (BadRequest, ValidationError, StreamError, NvrError) as err: 45 | typer.secho(str(err), fg="red") 46 | raise typer.Exit(1) from err 47 | 48 | 49 | def json_output(obj: Any) -> None: 50 | typer.echo(orjson.dumps(obj, option=orjson.OPT_INDENT_2).decode("utf-8")) 51 | 52 | 53 | def print_unifi_obj( 54 | obj: ProtectBaseObject | None, 55 | output_format: OutputFormatEnum, 56 | ) -> None: 57 | """Helper method to print a single protect object""" 58 | if obj is not None: 59 | json_output(obj.unifi_dict()) 60 | elif output_format == OutputFormatEnum.JSON: 61 | json_output(None) 62 | 63 | 64 | def print_unifi_list(objs: Sequence[ProtectBaseObject]) -> None: 65 | """Helper method to print a list of protect objects""" 66 | data = [o.unifi_dict() for o in objs] 67 | json_output(data) 68 | 69 | 70 | def print_unifi_dict(objs: Mapping[str, ProtectBaseObject]) -> None: 71 | """Helper method to print a dictionary of protect objects""" 72 | data = {k: v.unifi_dict() for k, v in objs.items()} 73 | json_output(data) 74 | 75 | 76 | def require_device_id(ctx: typer.Context) -> None: 77 | """Requires device ID in context""" 78 | if ctx.obj.device is None: 79 | typer.secho("Requires a valid device ID to be selected") 80 | raise typer.Exit(1) 81 | 82 | 83 | def require_no_device_id(ctx: typer.Context) -> None: 84 | """Requires no device ID in context""" 85 | if ctx.obj.device is not None: 86 | typer.secho("Requires no device ID to be selected") 87 | raise typer.Exit(1) 88 | 89 | 90 | def list_ids(ctx: typer.Context) -> None: 91 | """Requires no device ID. Prints list of "id name" for each device.""" 92 | require_no_device_id(ctx) 93 | objs: dict[str, ProtectAdoptableDeviceModel] = ctx.obj.devices 94 | to_print: list[tuple[str, str | None]] = [] 95 | for obj in objs.values(): 96 | name = obj.display_name 97 | if obj.is_adopted_by_other: 98 | name = f"{name} [Managed by Another Console]" 99 | elif obj.is_adopting: 100 | name = f"{name} [Adopting]" 101 | elif obj.can_adopt: 102 | name = f"{name} [Unadopted]" 103 | elif obj.is_rebooting: 104 | name = f"{name} [Restarting]" 105 | elif obj.is_updating: 106 | name = f"{name} [Updating]" 107 | elif not obj.is_connected: 108 | name = f"{name} [Disconnected]" 109 | 110 | to_print.append((obj.id, name)) 111 | 112 | if ctx.obj.output_format == OutputFormatEnum.JSON: 113 | json_output(to_print) 114 | else: 115 | for item in to_print: 116 | typer.echo(f"{item[0]}\t{item[1]}") 117 | 118 | 119 | def protect_url(ctx: typer.Context) -> None: 120 | """Gets UniFi Protect management URL.""" 121 | require_device_id(ctx) 122 | obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device 123 | if ctx.obj.output_format == OutputFormatEnum.JSON: 124 | json_output(obj.protect_url) 125 | else: 126 | typer.echo(obj.protect_url) 127 | 128 | 129 | def is_wired(ctx: typer.Context) -> None: 130 | """Returns if the device is wired or not.""" 131 | require_device_id(ctx) 132 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 133 | json_output(obj.is_wired) 134 | 135 | 136 | def is_wifi(ctx: typer.Context) -> None: 137 | """Returns if the device has WiFi or not.""" 138 | require_device_id(ctx) 139 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 140 | json_output(obj.is_wifi) 141 | 142 | 143 | def is_bluetooth(ctx: typer.Context) -> None: 144 | """Returns if the device has Bluetooth or not.""" 145 | require_device_id(ctx) 146 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 147 | json_output(obj.is_bluetooth) 148 | 149 | 150 | def bridge(ctx: typer.Context) -> None: 151 | """Returns bridge device if connected via Bluetooth.""" 152 | require_device_id(ctx) 153 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 154 | print_unifi_obj(obj.bridge, ctx.obj.output_format) 155 | 156 | 157 | def set_ssh(ctx: typer.Context, enabled: bool) -> None: 158 | """ 159 | Sets the isSshEnabled value for device. 160 | 161 | May not have an effect on many device types. Only seems to work for 162 | Linux and BusyBox based devices (camera, light and viewport). 163 | """ 164 | require_device_id(ctx) 165 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 166 | run(ctx, obj.set_ssh(enabled)) 167 | 168 | 169 | def set_name(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None: 170 | """Sets name for the device""" 171 | require_device_id(ctx) 172 | obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device 173 | run(ctx, obj.set_name(name)) 174 | 175 | 176 | def update(ctx: typer.Context, data: str) -> None: 177 | """ 178 | Updates the device. 179 | 180 | Makes a raw PATCH request to update a device. Advanced usage and usually recommended not to use. 181 | """ 182 | require_device_id(ctx) 183 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 184 | 185 | if obj.model is not None: 186 | run(ctx, obj.api.update_device(obj.model, obj.id, orjson.loads(data))) 187 | 188 | 189 | def reboot(ctx: typer.Context, force: bool = OPTION_FORCE) -> None: 190 | """Reboots the device.""" 191 | require_device_id(ctx) 192 | obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device 193 | 194 | if force or typer.confirm(f'Confirm reboot of "{obj.name}"" (id: {obj.id})'): 195 | run(ctx, obj.reboot()) 196 | 197 | 198 | def unadopt(ctx: typer.Context, force: bool = OPTION_FORCE) -> None: 199 | """Unadopt/Unmanage adopted device.""" 200 | require_device_id(ctx) 201 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 202 | 203 | if force or typer.confirm(f'Confirm undopt of "{obj.name}"" (id: {obj.id})'): 204 | run(ctx, obj.unadopt()) 205 | 206 | 207 | def adopt(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None: 208 | """ 209 | Adopts a device. 210 | 211 | By default, unadopted devices do not show up in the bootstrap. Use 212 | `uiprotect -u` to show unadopted devices. 213 | """ 214 | require_device_id(ctx) 215 | obj: ProtectAdoptableDeviceModel = ctx.obj.device 216 | 217 | run(ctx, obj.adopt(name)) 218 | 219 | 220 | def init_common_commands( 221 | app: typer.Typer, 222 | ) -> tuple[dict[str, Callable[..., Any]], dict[str, Callable[..., Any]]]: 223 | deviceless_commands: dict[str, Callable[..., Any]] = {} 224 | device_commands: dict[str, Callable[..., Any]] = {} 225 | 226 | deviceless_commands["list-ids"] = app.command()(list_ids) 227 | device_commands["is-wired"] = app.command()(is_wired) 228 | device_commands["is-wifi"] = app.command()(is_wifi) 229 | device_commands["is-bluetooth"] = app.command()(is_bluetooth) 230 | device_commands["bridge"] = app.command()(bridge) 231 | device_commands["set-ssh"] = app.command()(set_ssh) 232 | device_commands["set-name"] = app.command()(set_name) 233 | device_commands["update"] = app.command()(update) 234 | device_commands["reboot"] = app.command()(reboot) 235 | device_commands["unadopt"] = app.command()(unadopt) 236 | device_commands["adopt"] = app.command()(adopt) 237 | 238 | return deviceless_commands, device_commands 239 | -------------------------------------------------------------------------------- /src/uiprotect/websocket.py: -------------------------------------------------------------------------------- 1 | """UniFi Protect Websockets.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import contextlib 7 | import logging 8 | from collections.abc import Awaitable, Callable, Coroutine 9 | from enum import Enum 10 | from http import HTTPStatus 11 | from typing import Any 12 | 13 | import aiohttp 14 | from aiohttp import ( 15 | ClientError, 16 | ClientSession, 17 | ClientWebSocketResponse, 18 | WSMessage, 19 | WSMsgType, 20 | WSServerHandshakeError, 21 | ) 22 | from yarl import URL 23 | 24 | from .exceptions import NotAuthorized, NvrError 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | AuthCallbackType = Callable[..., Coroutine[Any, Any, dict[str, str] | None]] 28 | GetSessionCallbackType = Callable[[], Awaitable[ClientSession]] 29 | UpdateBootstrapCallbackType = Callable[[], None] 30 | _CLOSE_MESSAGE_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED} 31 | 32 | 33 | class WebsocketState(Enum): 34 | CONNECTED = True 35 | DISCONNECTED = False 36 | 37 | 38 | class Websocket: 39 | """UniFi Protect Websocket manager.""" 40 | 41 | _running = False 42 | _headers: dict[str, str] | None = None 43 | _websocket_loop_task: asyncio.Task[None] | None = None 44 | _stop_task: asyncio.Task[None] | None = None 45 | _ws_connection: ClientWebSocketResponse | None = None 46 | 47 | def __init__( 48 | self, 49 | get_url: Callable[[], URL], 50 | auth_callback: AuthCallbackType, 51 | update_bootstrap: UpdateBootstrapCallbackType, 52 | get_session: GetSessionCallbackType, 53 | subscription: Callable[[WSMessage], None], 54 | state_callback: Callable[[WebsocketState], None], 55 | *, 56 | timeout: float = 30.0, 57 | backoff: int = 10, 58 | verify: bool = True, 59 | receive_timeout: float | None = None, 60 | ) -> None: 61 | """Init Websocket.""" 62 | self.get_url = get_url 63 | self.timeout = timeout 64 | self.receive_timeout = receive_timeout 65 | self.backoff = backoff 66 | self.verify = verify 67 | self._get_session = get_session 68 | self._auth = auth_callback 69 | self._update_bootstrap = update_bootstrap 70 | self._subscription = subscription 71 | self._seen_non_close_message = False 72 | self._websocket_state = state_callback 73 | self._current_state: WebsocketState = WebsocketState.DISCONNECTED 74 | 75 | @property 76 | def is_connected(self) -> bool: 77 | """Return if the websocket is connected and has received a valid message.""" 78 | return self._ws_connection is not None and not self._ws_connection.closed 79 | 80 | async def _websocket_loop(self) -> None: 81 | """Running loop for websocket.""" 82 | await self.wait_closed() 83 | backoff = self.backoff 84 | 85 | while True: 86 | url = self.get_url() 87 | try: 88 | await self._websocket_inner_loop(url) 89 | except ClientError as ex: 90 | level = logging.ERROR if self._seen_non_close_message else logging.DEBUG 91 | if isinstance(ex, WSServerHandshakeError): 92 | if ex.status == HTTPStatus.UNAUTHORIZED.value: 93 | _LOGGER.log( 94 | level, "Websocket authentication error: %s: %s", url, ex 95 | ) 96 | await self._attempt_auth(True) 97 | else: 98 | _LOGGER.log(level, "Websocket handshake error: %s: %s", url, ex) 99 | else: 100 | _LOGGER.log(level, "Websocket disconnect error: %s: %s", url, ex) 101 | except asyncio.TimeoutError: 102 | level = logging.ERROR if self._seen_non_close_message else logging.DEBUG 103 | _LOGGER.log(level, "Websocket timeout: %s", url) 104 | except Exception: 105 | _LOGGER.exception("Unexpected error in websocket loop") 106 | 107 | self._state_changed(WebsocketState.DISCONNECTED) 108 | if self._running is False: 109 | break 110 | _LOGGER.debug("Reconnecting websocket in %s seconds", backoff) 111 | await asyncio.sleep(self.backoff) 112 | 113 | def _state_changed(self, state: WebsocketState) -> None: 114 | """State changed.""" 115 | if self._current_state is state: 116 | return 117 | self._current_state = state 118 | self._websocket_state(state) 119 | 120 | async def _websocket_inner_loop(self, url: URL) -> None: 121 | _LOGGER.debug("Connecting WS to %s", url) 122 | await self._attempt_auth(False) 123 | msg: WSMessage | None = None 124 | self._seen_non_close_message = False 125 | session = await self._get_session() 126 | # catch any and all errors for Websocket so we can clean up correctly 127 | try: 128 | self._ws_connection = await session.ws_connect( 129 | url, 130 | ssl=self.verify, 131 | headers=self._headers, 132 | timeout=aiohttp.ClientWSTimeout(ws_close=self.timeout), 133 | ) 134 | while True: 135 | msg = await self._ws_connection.receive(self.receive_timeout) 136 | msg_type = msg.type 137 | if msg_type is WSMsgType.ERROR: 138 | _LOGGER.exception("Error from Websocket: %s", msg.data) 139 | break 140 | if msg_type in _CLOSE_MESSAGE_TYPES: 141 | _LOGGER.debug("Websocket closed: %s", msg) 142 | break 143 | 144 | if not self._seen_non_close_message: 145 | self._seen_non_close_message = True 146 | self._state_changed(WebsocketState.CONNECTED) 147 | try: 148 | self._subscription(msg) 149 | except Exception: 150 | _LOGGER.exception("Error processing websocket message") 151 | finally: 152 | if ( 153 | msg is not None 154 | and msg.type is WSMsgType.CLOSE 155 | # If it closes right away or lastUpdateId is in the extra 156 | # its an indication that we should update the bootstrap 157 | # since lastUpdateId is invalid 158 | and ( 159 | not self._seen_non_close_message 160 | or (msg.extra and "lastUpdateId" in msg.extra) 161 | ) 162 | ): 163 | self._update_bootstrap() 164 | _LOGGER.debug("Websocket disconnected: last message: %s", msg) 165 | if self._ws_connection is not None and not self._ws_connection.closed: 166 | await self._ws_connection.close() 167 | self._ws_connection = None 168 | 169 | async def _attempt_auth(self, force: bool) -> None: 170 | """Attempt to authenticate.""" 171 | try: 172 | self._headers = await self._auth(force) 173 | except (NotAuthorized, NvrError) as ex: 174 | _LOGGER.debug("Error authenticating websocket: %s", ex) 175 | except Exception: 176 | _LOGGER.exception("Unknown error authenticating websocket") 177 | 178 | def start(self) -> None: 179 | """Start the websocket.""" 180 | if self._running: 181 | return 182 | self._running = True 183 | self._websocket_loop_task = asyncio.create_task(self._websocket_loop()) 184 | 185 | def stop(self) -> None: 186 | """Disconnect the websocket.""" 187 | _LOGGER.debug("Disconnecting websocket...") 188 | if not self._running: 189 | return 190 | if self._websocket_loop_task: 191 | self._websocket_loop_task.cancel() 192 | self._running = False 193 | ws_connection = self._ws_connection 194 | websocket_loop_task = self._websocket_loop_task 195 | self._ws_connection = None 196 | self._websocket_loop_task = None 197 | self._stop_task = asyncio.create_task( 198 | self._stop(ws_connection, websocket_loop_task) 199 | ) 200 | self._state_changed(WebsocketState.DISCONNECTED) 201 | 202 | async def wait_closed(self) -> None: 203 | """Wait for the websocket to close.""" 204 | if self._stop_task and not self._stop_task.done(): 205 | with contextlib.suppress(asyncio.CancelledError): 206 | await self._stop_task 207 | self._stop_task = None 208 | 209 | async def _stop( 210 | self, 211 | ws_connection: ClientWebSocketResponse | None, 212 | websocket_loop_task: asyncio.Task[None] | None, 213 | ) -> None: 214 | """Stop the websocket.""" 215 | if ws_connection: 216 | await ws_connection.close() 217 | if websocket_loop_task: 218 | with contextlib.suppress(asyncio.CancelledError): 219 | await websocket_loop_task 220 | -------------------------------------------------------------------------------- /src/uiprotect/cli/sensors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | import typer 6 | 7 | from ..api import ProtectApiClient 8 | from ..cli import base 9 | from ..data import MountType, Sensor 10 | 11 | app = typer.Typer(rich_markup_mode="rich") 12 | 13 | ARG_DEVICE_ID = typer.Argument(None, help="ID of sensor to select for subcommands") 14 | 15 | 16 | @dataclass 17 | class SensorContext(base.CliContext): 18 | devices: dict[str, Sensor] 19 | device: Sensor | None = None 20 | 21 | 22 | ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) 23 | 24 | 25 | @app.callback(invoke_without_command=True) 26 | def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: 27 | """ 28 | Sensors device CLI. 29 | 30 | Returns full list of Sensors without any arguments passed. 31 | """ 32 | protect: ProtectApiClient = ctx.obj.protect 33 | context = SensorContext( 34 | protect=ctx.obj.protect, 35 | device=None, 36 | devices=protect.bootstrap.sensors, 37 | output_format=ctx.obj.output_format, 38 | ) 39 | ctx.obj = context 40 | 41 | if device_id is not None and device_id not in ALL_COMMANDS: 42 | if (device := protect.bootstrap.sensors.get(device_id)) is None: 43 | typer.secho("Invalid sensor ID", fg="red") 44 | raise typer.Exit(1) 45 | ctx.obj.device = device 46 | 47 | if not ctx.invoked_subcommand: 48 | if device_id in ALL_COMMANDS: 49 | ctx.invoke(ALL_COMMANDS[device_id], ctx) 50 | return 51 | 52 | if ctx.obj.device is not None: 53 | base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) 54 | return 55 | 56 | base.print_unifi_dict(ctx.obj.devices) 57 | 58 | 59 | @app.command() 60 | def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None: 61 | """Returns or sets tha paired camera for a sensor.""" 62 | base.require_device_id(ctx) 63 | obj: Sensor = ctx.obj.device 64 | 65 | if camera_id is None: 66 | base.print_unifi_obj(obj.camera, ctx.obj.output_format) 67 | else: 68 | protect: ProtectApiClient = ctx.obj.protect 69 | if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None: 70 | typer.secho("Invalid camera ID") 71 | raise typer.Exit(1) 72 | base.run(ctx, obj.set_paired_camera(camera_obj)) 73 | 74 | 75 | @app.command() 76 | def is_tampering_detected(ctx: typer.Context) -> None: 77 | """Returns if tampering is detected for sensor""" 78 | base.require_device_id(ctx) 79 | obj: Sensor = ctx.obj.device 80 | base.json_output(obj.is_tampering_detected) 81 | 82 | 83 | @app.command() 84 | def is_alarm_detected(ctx: typer.Context) -> None: 85 | """Returns if alarm is detected for sensor""" 86 | base.require_device_id(ctx) 87 | obj: Sensor = ctx.obj.device 88 | base.json_output(obj.is_alarm_detected) 89 | 90 | 91 | @app.command() 92 | def is_contact_enabled(ctx: typer.Context) -> None: 93 | """Returns if contact sensor is enabled for sensor""" 94 | base.require_device_id(ctx) 95 | obj: Sensor = ctx.obj.device 96 | base.json_output(obj.is_contact_sensor_enabled) 97 | 98 | 99 | @app.command() 100 | def is_motion_enabled(ctx: typer.Context) -> None: 101 | """Returns if motion sensor is enabled for sensor""" 102 | base.require_device_id(ctx) 103 | obj: Sensor = ctx.obj.device 104 | base.json_output(obj.is_contact_sensor_enabled) 105 | 106 | 107 | @app.command() 108 | def is_alarm_enabled(ctx: typer.Context) -> None: 109 | """Returns if alarm sensor is enabled for sensor""" 110 | base.require_device_id(ctx) 111 | obj: Sensor = ctx.obj.device 112 | base.json_output(obj.is_alarm_sensor_enabled) 113 | 114 | 115 | @app.command() 116 | def is_light_enabled(ctx: typer.Context) -> None: 117 | """Returns if light sensor is enabled for sensor""" 118 | base.require_device_id(ctx) 119 | obj: Sensor = ctx.obj.device 120 | base.json_output(obj.is_light_sensor_enabled) 121 | 122 | 123 | @app.command() 124 | def is_temperature_enabled(ctx: typer.Context) -> None: 125 | """Returns if temperature sensor is enabled for sensor""" 126 | base.require_device_id(ctx) 127 | obj: Sensor = ctx.obj.device 128 | base.json_output(obj.is_temperature_sensor_enabled) 129 | 130 | 131 | @app.command() 132 | def is_humidity_enabled(ctx: typer.Context) -> None: 133 | """Returns if humidity sensor is enabled for sensor""" 134 | base.require_device_id(ctx) 135 | obj: Sensor = ctx.obj.device 136 | base.json_output(obj.is_humidity_sensor_enabled) 137 | 138 | 139 | @app.command() 140 | def set_status_light(ctx: typer.Context, enabled: bool) -> None: 141 | """Sets status light for sensor device.""" 142 | base.require_device_id(ctx) 143 | obj: Sensor = ctx.obj.device 144 | 145 | base.run(ctx, obj.set_status_light(enabled)) 146 | 147 | 148 | @app.command() 149 | def set_mount_type(ctx: typer.Context, mount_type: MountType) -> None: 150 | """Sets mount type for sensor device.""" 151 | base.require_device_id(ctx) 152 | obj: Sensor = ctx.obj.device 153 | 154 | base.run(ctx, obj.set_mount_type(mount_type)) 155 | 156 | 157 | @app.command() 158 | def set_motion(ctx: typer.Context, enabled: bool) -> None: 159 | """Sets motion sensor status for sensor device.""" 160 | base.require_device_id(ctx) 161 | obj: Sensor = ctx.obj.device 162 | 163 | base.run(ctx, obj.set_motion_status(enabled)) 164 | 165 | 166 | @app.command() 167 | def set_temperature(ctx: typer.Context, enabled: bool) -> None: 168 | """Sets temperature sensor status for sensor device.""" 169 | base.require_device_id(ctx) 170 | obj: Sensor = ctx.obj.device 171 | 172 | base.run(ctx, obj.set_temperature_status(enabled)) 173 | 174 | 175 | @app.command() 176 | def set_humidity(ctx: typer.Context, enabled: bool) -> None: 177 | """Sets humidity sensor status for sensor device.""" 178 | base.require_device_id(ctx) 179 | obj: Sensor = ctx.obj.device 180 | 181 | base.run(ctx, obj.set_humidity_status(enabled)) 182 | 183 | 184 | @app.command() 185 | def set_light(ctx: typer.Context, enabled: bool) -> None: 186 | """Sets light sensor status for sensor device.""" 187 | base.require_device_id(ctx) 188 | obj: Sensor = ctx.obj.device 189 | 190 | base.run(ctx, obj.set_light_status(enabled)) 191 | 192 | 193 | @app.command() 194 | def set_alarm(ctx: typer.Context, enabled: bool) -> None: 195 | """Sets alarm sensor status for sensor device.""" 196 | base.require_device_id(ctx) 197 | obj: Sensor = ctx.obj.device 198 | 199 | base.run(ctx, obj.set_alarm_status(enabled)) 200 | 201 | 202 | @app.command() 203 | def set_motion_sensitivity( 204 | ctx: typer.Context, 205 | sensitivity: int = typer.Argument(..., min=0, max=100), 206 | ) -> None: 207 | """Sets motion sensitivity for the sensor.""" 208 | base.require_device_id(ctx) 209 | obj: Sensor = ctx.obj.device 210 | 211 | base.run(ctx, obj.set_motion_sensitivity(sensitivity)) 212 | 213 | 214 | @app.command() 215 | def set_temperature_range( 216 | ctx: typer.Context, 217 | low: float = typer.Argument(..., min=0, max=44), 218 | high: float = typer.Argument(..., min=1, max=45), 219 | ) -> None: 220 | """Sets temperature safe range (in °C). Anything out side of range will trigger event.""" 221 | base.require_device_id(ctx) 222 | obj: Sensor = ctx.obj.device 223 | 224 | base.run(ctx, obj.set_temperature_safe_range(low, high)) 225 | 226 | 227 | @app.command() 228 | def set_humidity_range( 229 | ctx: typer.Context, 230 | low: float = typer.Argument(..., min=1, max=98), 231 | high: float = typer.Argument(..., min=2, max=99), 232 | ) -> None: 233 | """Sets humidity safe range (in relative % humidity). Anything out side of range will trigger event.""" 234 | base.require_device_id(ctx) 235 | obj: Sensor = ctx.obj.device 236 | 237 | base.run(ctx, obj.set_humidity_safe_range(low, high)) 238 | 239 | 240 | @app.command() 241 | def set_light_range( 242 | ctx: typer.Context, 243 | low: float = typer.Argument(..., min=1, max=999), 244 | high: float = typer.Argument(..., min=2, max=1000), 245 | ) -> None: 246 | """Sets light safe range (in lux). Anything out side of range will trigger event.""" 247 | base.require_device_id(ctx) 248 | obj: Sensor = ctx.obj.device 249 | 250 | base.run(ctx, obj.set_light_safe_range(low, high)) 251 | 252 | 253 | @app.command() 254 | def remove_temperature_range(ctx: typer.Context) -> None: 255 | """Removes temperature safe ranges so events will no longer fire.""" 256 | base.require_device_id(ctx) 257 | obj: Sensor = ctx.obj.device 258 | 259 | base.run(ctx, obj.remove_temperature_safe_range()) 260 | 261 | 262 | @app.command() 263 | def remove_humidity_range(ctx: typer.Context) -> None: 264 | """Removes humidity safe ranges so events will no longer fire.""" 265 | base.require_device_id(ctx) 266 | obj: Sensor = ctx.obj.device 267 | 268 | base.run(ctx, obj.remove_humidity_safe_range()) 269 | 270 | 271 | @app.command() 272 | def remove_light_range(ctx: typer.Context) -> None: 273 | """Removes light safe ranges so events will no longer fire.""" 274 | base.require_device_id(ctx) 275 | obj: Sensor = ctx.obj.device 276 | 277 | base.run(ctx, obj.remove_light_safe_range()) 278 | -------------------------------------------------------------------------------- /tests/data/test_event_detected_thumbnail.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, arg-type" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime, timezone 6 | from typing import TYPE_CHECKING 7 | 8 | from uiprotect.data.nvr import ( 9 | Event, 10 | EventDetectedThumbnail, 11 | EventMetadata, 12 | EventThumbnailGroup, 13 | ) 14 | from uiprotect.data.types import SmartDetectObjectType 15 | 16 | if TYPE_CHECKING: 17 | from uiprotect import ProtectApiClient 18 | 19 | 20 | def test_get_detected_thumbnail_with_clock_best_wall(): 21 | """Test that get_detected_thumbnail returns the thumbnail with clockBestWall.""" 22 | event = Event.model_construct( 23 | id="test_event", 24 | type="smartDetect", 25 | smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], 26 | metadata=EventMetadata.model_construct( 27 | detected_thumbnails=[ 28 | EventDetectedThumbnail.model_construct( 29 | type="vehicle", 30 | cropped_id="thumb_1", 31 | clock_best_wall=None, 32 | group=EventThumbnailGroup.model_construct( 33 | id="group_1", 34 | matched_name="ABC123", 35 | confidence=75, 36 | ), 37 | ), 38 | EventDetectedThumbnail.model_construct( 39 | type="vehicle", 40 | cropped_id="thumb_2", 41 | clock_best_wall=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), 42 | group=EventThumbnailGroup.model_construct( 43 | id="group_2", 44 | matched_name="XYZ789", 45 | confidence=92, 46 | ), 47 | ), 48 | EventDetectedThumbnail.model_construct( 49 | type="vehicle", 50 | cropped_id="thumb_3", 51 | clock_best_wall=None, 52 | group=EventThumbnailGroup.model_construct( 53 | id="group_3", 54 | matched_name="DEF456", 55 | confidence=85, 56 | ), 57 | ), 58 | ] 59 | ), 60 | ) 61 | 62 | thumbnail = event.get_detected_thumbnail() 63 | assert thumbnail is not None 64 | assert thumbnail.cropped_id == "thumb_2" 65 | assert thumbnail.group is not None 66 | assert thumbnail.group.matched_name == "XYZ789" 67 | 68 | 69 | def test_get_detected_thumbnail_no_metadata(): 70 | """Test that get_detected_thumbnail returns None when no metadata.""" 71 | event = Event.model_construct( 72 | id="test_event", 73 | type="smartDetect", 74 | smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], 75 | metadata=None, 76 | ) 77 | 78 | assert event.get_detected_thumbnail() is None 79 | 80 | 81 | def test_get_detected_thumbnail_no_detected_thumbnails(): 82 | """Test that get_detected_thumbnail returns None when no detected thumbnails.""" 83 | event = Event.model_construct( 84 | id="test_event", 85 | type="smartDetect", 86 | smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], 87 | metadata=EventMetadata.model_construct( 88 | detected_thumbnails=None, 89 | ), 90 | ) 91 | 92 | assert event.get_detected_thumbnail() is None 93 | 94 | 95 | def test_get_detected_thumbnail_empty_list(): 96 | """Test that get_detected_thumbnail returns None when detected thumbnails is empty.""" 97 | event = Event.model_construct( 98 | id="test_event", 99 | type="smartDetect", 100 | smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], 101 | metadata=EventMetadata.model_construct( 102 | detected_thumbnails=[], 103 | ), 104 | ) 105 | 106 | assert event.get_detected_thumbnail() is None 107 | 108 | 109 | def test_get_detected_thumbnail_no_clock_best_wall(): 110 | """Test that get_detected_thumbnail returns None when no thumbnail has clockBestWall.""" 111 | event = Event.model_construct( 112 | id="test_event", 113 | type="smartDetect", 114 | smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], 115 | metadata=EventMetadata.model_construct( 116 | detected_thumbnails=[ 117 | EventDetectedThumbnail.model_construct( 118 | type="vehicle", 119 | cropped_id="thumb_1", 120 | clock_best_wall=None, 121 | ), 122 | EventDetectedThumbnail.model_construct( 123 | type="vehicle", 124 | cropped_id="thumb_2", 125 | clock_best_wall=None, 126 | ), 127 | ] 128 | ), 129 | ) 130 | 131 | assert event.get_detected_thumbnail() is None 132 | 133 | 134 | def test_get_detected_thumbnail_first_with_clock_best_wall(): 135 | """Test that get_detected_thumbnail returns first thumbnail with clockBestWall.""" 136 | event = Event.model_construct( 137 | id="test_event", 138 | type="smartDetect", 139 | smart_detect_types=[SmartDetectObjectType.FACE], 140 | metadata=EventMetadata.model_construct( 141 | detected_thumbnails=[ 142 | EventDetectedThumbnail.model_construct( 143 | type="face", 144 | cropped_id="thumb_1", 145 | clock_best_wall=None, 146 | ), 147 | EventDetectedThumbnail.model_construct( 148 | type="face", 149 | cropped_id="thumb_2", 150 | clock_best_wall=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), 151 | group=EventThumbnailGroup.model_construct( 152 | id="group_1", 153 | matched_name="Alice", 154 | confidence=90, 155 | ), 156 | ), 157 | EventDetectedThumbnail.model_construct( 158 | type="face", 159 | cropped_id="thumb_3", 160 | clock_best_wall=datetime(2024, 1, 1, 12, 1, 0, tzinfo=timezone.utc), 161 | group=EventThumbnailGroup.model_construct( 162 | id="group_2", 163 | matched_name="Bob", 164 | confidence=95, 165 | ), 166 | ), 167 | ] 168 | ), 169 | ) 170 | 171 | thumbnail = event.get_detected_thumbnail() 172 | assert thumbnail is not None 173 | assert thumbnail.cropped_id == "thumb_2" 174 | assert thumbnail.group is not None 175 | assert thumbnail.group.matched_name == "Alice" 176 | 177 | 178 | def test_get_detected_thumbnail_from_real_data( 179 | raw_events: list[dict], protect_client: ProtectApiClient 180 | ): 181 | """Test get_detected_thumbnail with real event data.""" 182 | events_with_thumbs = [ 183 | e for e in raw_events if e.get("metadata", {}).get("detectedThumbnails") 184 | ] 185 | 186 | if not events_with_thumbs: 187 | return # Skip if no events with thumbnails 188 | 189 | # Parse first event with thumbnails 190 | event = Event.from_unifi_dict(**events_with_thumbs[0], api=protect_client) 191 | 192 | # Should return the thumbnail with clockBestWall 193 | thumbnail = event.get_detected_thumbnail() 194 | assert thumbnail is not None 195 | assert thumbnail.clock_best_wall is not None 196 | 197 | 198 | def test_event_thumbnail_attributes_get_value( 199 | raw_events: list[dict], protect_client: ProtectApiClient 200 | ): 201 | """Test EventThumbnailAttributes.get_value() helper method.""" 202 | events_with_attrs = [ 203 | e 204 | for e in raw_events 205 | if any( 206 | t.get("attributes") 207 | for t in e.get("metadata", {}).get("detectedThumbnails", []) 208 | ) 209 | ] 210 | 211 | if not events_with_attrs: 212 | return # Skip if no events with attributes 213 | 214 | event = Event.from_unifi_dict(**events_with_attrs[0], api=protect_client) 215 | thumbnail = event.get_detected_thumbnail() 216 | 217 | assert thumbnail is not None 218 | assert thumbnail.attributes is not None 219 | 220 | # Test get_value with EventThumbnailAttribute objects 221 | # Check if color exists (common for LPR events) 222 | if hasattr(thumbnail.attributes, "color"): 223 | color = thumbnail.attributes.get_value("color") 224 | assert color is not None 225 | assert isinstance(color, str) 226 | 227 | # Check if vehicleType exists (common for LPR events) 228 | if hasattr(thumbnail.attributes, "vehicleType"): 229 | vehicle_type = thumbnail.attributes.get_value("vehicleType") 230 | assert vehicle_type is not None 231 | assert isinstance(vehicle_type, str) 232 | 233 | # Test get_value with non-EventThumbnailAttribute fields 234 | # zone is list[int], not EventThumbnailAttribute 235 | if hasattr(thumbnail.attributes, "zone"): 236 | zone_value = thumbnail.attributes.get_value("zone") 237 | assert ( 238 | zone_value is None 239 | ) # Should be None because it's not EventThumbnailAttribute 240 | 241 | # Test get_value with non-existent field 242 | nonexistent = thumbnail.attributes.get_value("nonexistent_field_12345") 243 | assert nonexistent is None 244 | --------------------------------------------------------------------------------