├── 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 |
--------------------------------------------------------------------------------