pax_global_header 0000666 0000000 0000000 00000000064 15246377057 0014532 g ustar 00root root 0000000 0000000 52 comment=280274607a6632ec5823fa412d5fa555777537a8
uilibs-uiprotect-317a52c/ 0000775 0000000 0000000 00000000000 15246377057 0015364 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.bin/ 0000775 0000000 0000000 00000000000 15246377057 0016212 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.bin/lib/ 0000775 0000000 0000000 00000000000 15246377057 0016760 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.bin/lib/common.sh 0000664 0000000 0000000 00000001210 15246377057 0020576 0 ustar 00root root 0000000 0000000 #!/bin/bash
function setRoot() {
ROOT_PATH=$PWD
while [[ $ROOT_PATH != / ]]; do
output=$(find "$ROOT_PATH" -maxdepth 1 -mindepth 1 -name "pyproject.toml")
if [[ -n $output ]]; then
break
fi
# Note: if you want to ignore symlinks, use "$(realpath -s "$path"/..)"
ROOT_PATH="$(readlink -f "$ROOT_PATH"/..)"
done
if [[ $ROOT_PATH == / ]]; then
ROOT_PATH=$( realpath $( dirname "${BASH_SOURCE[0]}" )/../../ )
echo "Could not find \`pyproject.toml\`, following back to $( basename $ROOT_PATH )"
else
echo "Using project $( basename $ROOT_PATH )"
fi
}
uilibs-uiprotect-317a52c/.bin/run-mypy 0000775 0000000 0000000 00000000051 15246377057 0017734 0 ustar 00root root 0000000 0000000 #!/bin/sh
poetry run mypy src/uiprotect
uilibs-uiprotect-317a52c/.bin/test-code 0000775 0000000 0000000 00000001177 15246377057 0020035 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
BASE_DIR=$( realpath $( dirname "${BASH_SOURCE[0]}" )/../ )
source "$BASE_DIR/.bin/lib/common.sh"
setRoot
WS_TIMEOUT="${WS_TIMEOUT:-40}"
PYTEST_EXTRA_ARGS="${PYTEST_EXTRA_ARGS:-}"
pushd "$ROOT_PATH" 2>&1 >/dev/null
rm -rf .coverage.* .coverage
echo -e "\nRunning tests (no benchmarks)..."
poetry run pytest --timeout=10 --color=yes --cov-report=xml --benchmark-skip --maxfail=10 $PYTEST_EXTRA_ARGS
echo -e "\nRunning benchmark tests..."
poetry run pytest --timeout=$WS_TIMEOUT --cov-report=term --color=yes --benchmark-only -n=0 -rP $PYTEST_EXTRA_ARGS
popd 2>&1 >/dev/null
uilibs-uiprotect-317a52c/.bin/update-release-cache 0000775 0000000 0000000 00000000161 15246377057 0022077 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
poetry install
poetry run uiprotect release-versions
uilibs-uiprotect-317a52c/.copier-answers.yml 0000664 0000000 0000000 00000001044 15246377057 0021125 0 ustar 00root root 0000000 0000000 # Changes here will be overwritten by Copier
_commit: 368c483
_src_path: gh:browniebroke/pypackage-template
add_me_as_contributor: false
cli_name: uiprotect
copyright_year: '2024'
documentation: true
email: ui@koston.org
full_name: UI Protect Maintainers
github_username: uilibs
has_cli: true
initial_commit: true
open_source_license: MIT
package_name: uiprotect
project_name: uiprotect
project_short_description: Python API for Unifi Protect (Unofficial)
project_slug: uiprotect
run_poetry_install: true
setup_github: true
setup_pre_commit: true
uilibs-uiprotect-317a52c/.coveragerc 0000664 0000000 0000000 00000001417 15246377057 0017510 0 ustar 00root root 0000000 0000000 [run]
source = src/uiprotect
omit =
site/*
src/uiprotect/cli/*
src/uiprotect/test_util/*
[report]
omit =
site/*
src/uiprotect/cli/*
src/uiprotect/test_util/*
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain about missing debug-only code:
def __repr__
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
raise exceptions.NotSupportedError
# TYPE_CHECKING and @overload blocks are never executed during pytest run
# except ImportError: are never executed as well
if TYPE_CHECKING:
@overload
except ImportError:
if _LOGGER.isEnabledFor(logging.DEBUG):
uilibs-uiprotect-317a52c/.devcontainer/ 0000775 0000000 0000000 00000000000 15246377057 0020123 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.devcontainer/devcontainer.json 0000664 0000000 0000000 00000006526 15246377057 0023510 0 ustar 00root root 0000000 0000000 {
"name": "uiprotect-dev",
"build": {
"dockerfile": "../Dockerfile",
"context": "..",
"target": "devcontainer"
},
"updateRemoteUserUID": true,
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"initializeCommand": "touch ~/.gitconfig",
"postCreateCommand": "poetry install --no-interaction --no-ansi && poetry run pre-commit install --install-hooks",
"mounts": [
"source=${localEnv:HOME}/.claude,target=/root/.claude,type=bind,consistency=cached",
"source=${localEnv:HOME}/.gitconfig,target=/root/.gitconfig,type=bind,consistency=cached"
],
"remoteUser": "root",
"containerEnv": {
"GIT_AUTHOR_NAME": "${localEnv:GIT_AUTHOR_NAME:${localEnv:USER:devcontainer}}",
"GIT_AUTHOR_EMAIL": "${localEnv:GIT_AUTHOR_EMAIL:${localEnv:USER:devcontainer}@localhost}",
"GIT_COMMITTER_NAME": "${localEnv:GIT_COMMITTER_NAME:${localEnv:USER:devcontainer}}",
"GIT_COMMITTER_EMAIL": "${localEnv:GIT_COMMITTER_EMAIL:${localEnv:USER:devcontainer}@localhost}"
},
"remoteEnv": {
"LANG": "en_US.UTF-8",
"LC_ALL": "en_US.UTF-8"
},
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"python.defaultInterpreterPath": "/usr/local/bin/python3",
"python.terminal.activateEnvironment": false,
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
"tests",
"-v",
"--cov=uiprotect",
"--cov-report=term-missing:skip-covered",
"-n=auto"
],
"python.analysis.typeCheckingMode": "strict",
"python.analysis.autoImportCompletions": true,
"editor.formatOnSave": true,
"editor.rulers": [
88
],
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.organizeImports.ruff": "explicit",
"source.fixAll.ruff": "explicit"
}
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"ms-azuretools.vscode-docker",
"charliermarsh.ruff",
"esbenp.prettier-vscode",
"ms-python.mypy-type-checker",
"ms-vscode.test-adapter-converter",
"littlefoxteam.vscode-python-test-adapter",
"GitHub.vscode-pull-request-github",
"usernamehw.errorlens",
"anthropic.claude-code"
]
}
}
}
uilibs-uiprotect-317a52c/.docker/ 0000775 0000000 0000000 00000000000 15246377057 0016711 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.docker/bashrc 0000664 0000000 0000000 00000001330 15246377057 0020073 0 ustar 00root root 0000000 0000000 BOLD="\[$(tput bold)\]"
BLACK="\[$(tput setaf 0)\]"
RED="\[$(tput setaf 1)\]"
GREEN="\[$(tput setaf 2)\]"
YELLOW="\[$(tput setaf 3)\]"
BLUE="\[$(tput setaf 4)\]"
MAGENTA="\[$(tput setaf 5)\]"
CYAN="\[$(tput setaf 6)\]"
WHITE="\[$(tput setaf 7)\]"
RESET="\[$(tput sgr0)\]"
function prompt_command {
RET=$?
if [[ "$(id -u)" -eq 0 ]]; then
PS1="$BOLD$RED"
else
PS1="$GREEN"
fi
branch="$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/ (\1)/')"
PS1+="\u$RESET:$YELLOW\w$RESET$CYAN$branch$RESET "
if [[ "$RET" -eq 0 ]]; then
PS1+="$BOLD$GREEN"
else
PS1+="$RET $BOLD$RED"
fi
PS1+="\\$ $RESET"
export PS1
}
export PROMPT_COMMAND=prompt_command
uilibs-uiprotect-317a52c/.docker/docker-fix.sh 0000664 0000000 0000000 00000001454 15246377057 0021304 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
DOCKER_SOCK=""
if [[ -e /var/run/docker-host.sock ]]; then
DOCKER_SOCK="/var/run/docker-host.sock"
else
if [[ -e /var/run/docker.sock ]]; then
DOCKER_SOCK="/var/run/docker.sock"
fi
fi
# fix the group ID of the docker group so it can write to /var/run/docker.sock
if [[ -n "$DOCKER_SOCK" ]]; then
DOCKER_GID=$(ls -la $DOCKER_SOCK | awk '{print $4}')
if [[ $DOCKER_GID != 'docker' ]]; then
sudo groupmod -g $DOCKER_GID docker
if [[ -f '/.codespaces' ]]; then
echo -e '\e[1;31mYou must stop and restart the Codespace to be able to access docker properly'
else
echo -e '\e[1;31mYou must run the `Reload Window` command for be able to access docker properly'
fi
fi
fi
uilibs-uiprotect-317a52c/.docker/entrypoint.sh 0000664 0000000 0000000 00000000132 15246377057 0021454 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
/usr/local/bin/uiprotect "$@"
uilibs-uiprotect-317a52c/.dockerignore 0000664 0000000 0000000 00000000165 15246377057 0020042 0 ustar 00root root 0000000 0000000 .vscode
test-data
ufp-data
*.mp3
*.mp4
.*
*.xml
Dockerfile
LICENSE
*.md
!README.md
tests/**
*.egg-info
*.js
!.docker
uilibs-uiprotect-317a52c/.editorconfig 0000664 0000000 0000000 00000000444 15246377057 0020043 0 ustar 00root root 0000000 0000000 # http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
end_of_line = lf
[*.bat]
indent_style = tab
end_of_line = crlf
[LICENSE]
insert_final_newline = false
[Makefile]
indent_style = tab
uilibs-uiprotect-317a52c/.github/ 0000775 0000000 0000000 00000000000 15246377057 0016724 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.github/CODE_OF_CONDUCT.md 0000664 0000000 0000000 00000012056 15246377057 0021527 0 ustar 00root root 0000000 0000000 # Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting @uilibs. All complaints will be reviewed and
investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
uilibs-uiprotect-317a52c/.github/FUNDING.yml 0000664 0000000 0000000 00000000023 15246377057 0020534 0 ustar 00root root 0000000 0000000 github: ["uilibs"]
uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 15246377057 0021107 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/1-bug_report.yml 0000664 0000000 0000000 00000003531 15246377057 0024142 0 ustar 00root root 0000000 0000000 name: Bug report
description: Create a report to help us improve
labels: [bug]
body:
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
placeholder: Describe the bug
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: To Reproduce
description: Steps to reproduce the behavior.
placeholder: To Reproduce
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.
placeholder: Additional context
- type: input
id: version
attributes:
label: Version
description: Version of the project.
placeholder: Version
validations:
required: true
- type: input
id: platform
attributes:
label: Platform
description: Platform where the bug was found.
placeholder: "Example: Windows 11 / macOS 12.0.1 / Ubuntu 20.04"
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our
[Code of Conduct](https://github.com/uilibs/uiprotect/blob/main/.github/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow this project's Code of Conduct.
required: true
- type: checkboxes
id: no-duplicate
attributes:
label: No Duplicate
description: Please check [existing issues](https://github.com/uilibs/uiprotect/issues) to avoid duplicates.
options:
- label: I have checked existing issues to avoid duplicates.
required: true
- type: markdown
attributes:
value: 👋 Have a great day and thank you for the bug report!
uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/2-feature-request.yml 0000664 0000000 0000000 00000003610 15246377057 0025112 0 ustar 00root root 0000000 0000000 name: Feature request
description: Suggest an idea for this project
labels: [enhancement]
body:
- type: textarea
id: description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is.
value: I'm always frustrated when
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
placeholder: Describe alternatives you've considered
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
placeholder: Additional context
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our
[Code of Conduct](https://github.com/uilibs/uiprotect/blob/main/.github/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow this project's Code of Conduct
required: true
- type: checkboxes
id: willing
attributes:
label: Are you willing to resolve this issue by submitting a Pull Request?
description: Remember that first-time contributors are welcome! 🙌
options:
- label: Yes, I have the time, and I know how to start.
- label: Yes, I have the time, but I don't know how to start. I would need guidance.
- label: No, I don't have the time, although I believe I could do it if I had the time...
- label: No, I don't have the time and I wouldn't even know how to start.
- type: markdown
attributes:
value: 👋 Have a great day and thank you for the feature request!
uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/config.yml 0000664 0000000 0000000 00000000270 15246377057 0023076 0 ustar 00root root 0000000 0000000 blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/uilibs/uiprotect/discussions/categories/q-a
about: Please ask and answer questions here.
uilibs-uiprotect-317a52c/.github/PULL_REQUEST_TEMPLATE.md 0000664 0000000 0000000 00000003543 15246377057 0022532 0 ustar 00root root 0000000 0000000
### Description of change
### Pull-Request Checklist
- [ ] Code is up-to-date with the `main` branch
- [ ] This pull request follows the [contributing guidelines](https://github.com/uilibs/uiprotect/blob/main/CONTRIBUTING.md).
- [ ] This pull request links relevant issues as `Fixes #0000`
- [ ] There are new or updated unit tests validating the change
- [ ] Documentation has been updated to reflect this change
- [ ] The pull request title follows the [conventional commit spec](https://www.conventionalcommits.org/en/v1.0.0/), such as "fix(api): prevent racing of requests" (PRs are squash-merged, so the title becomes the commit on `main`).
> - If pre-commit.ci is failing, try `pre-commit run -a` for further information.
> - If CI / test is failing, try `poetry run pytest` for further information.
uilibs-uiprotect-317a52c/.github/dependabot.yml 0000664 0000000 0000000 00000001345 15246377057 0021557 0 ustar 00root root 0000000 0000000 # To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
#
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(ci): "
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
uilibs-uiprotect-317a52c/.github/labels.toml 0000664 0000000 0000000 00000003515 15246377057 0021067 0 ustar 00root root 0000000 0000000 [breaking]
color = "ffcc00"
name = "breaking"
description = "Breaking change."
[bug]
color = "d73a4a"
name = "bug"
description = "Something isn't working"
[dependencies]
color = "0366d6"
name = "dependencies"
description = "Pull requests that update a dependency file"
[github_actions]
color = "000000"
name = "github_actions"
description = "Update of github actions"
[documentation]
color = "1bc4a5"
name = "documentation"
description = "Improvements or additions to documentation"
[duplicate]
color = "cfd3d7"
name = "duplicate"
description = "This issue or pull request already exists"
[enhancement]
color = "a2eeef"
name = "enhancement"
description = "New feature or request"
["good first issue"]
color = "7057ff"
name = "good first issue"
description = "Good for newcomers"
["help wanted"]
color = "008672"
name = "help wanted"
description = "Extra attention is needed"
[invalid]
color = "e4e669"
name = "invalid"
description = "This doesn't seem right"
[nochangelog]
color = "555555"
name = "nochangelog"
description = "Exclude pull requests from changelog"
[question]
color = "d876e3"
name = "question"
description = "Further information is requested"
[removed]
color = "e99695"
name = "removed"
description = "Removed piece of functionalities."
[tests]
color = "bfd4f2"
name = "tests"
description = "CI, CD and testing related changes"
[wontfix]
color = "ffffff"
name = "wontfix"
description = "This will not be worked on"
[discussion]
color = "c2e0c6"
name = "discussion"
description = "Some discussion around the project"
[hacktoberfest]
color = "ffa663"
name = "hacktoberfest"
description = "Good issues for Hacktoberfest"
[answered]
color = "0ee2b6"
name = "answered"
description = "Automatically closes as answered after a delay"
[waiting]
color = "5f7972"
name = "waiting"
description = "Automatically closes if no answer after a delay"
uilibs-uiprotect-317a52c/.github/workflows/ 0000775 0000000 0000000 00000000000 15246377057 0020761 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.github/workflows/ci.yml 0000664 0000000 0000000 00000014073 15246377057 0022104 0 ustar 00root root 0000000 0000000 name: CI
on:
push:
branches:
- main
pull_request:
types: [opened, edited, synchronize, reopened]
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
UV_PYTHON_PREFERENCE: only-managed
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
- name: Install poetry
run: uv tool install poetry
- uses: actions/setup-python@v7
with:
python-version: 3.x
cache: "poetry"
- name: Install Dependencies
run: |
poetry install --all-extras
- name: Run pre-commit (prek)
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
# Make sure the PR title follows the conventional commits convention:
# https://www.conventionalcommits.org
# PRs are squash-merged, so the PR title becomes the commit on main and
# drives python-semantic-release's version bump.
pr-title:
name: Lint PR Title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
starts with a lowercase character.
test:
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
- name: Install poetry
run: uv tool install poetry
- name: Set up Python
uses: actions/setup-python@v7
id: setup-python
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
allow-prereleases: true
- run: echo "Cache hit:${{ steps.setup-python.outputs.cache-hit }}" # true if cache-hit occurred on the primary key
- name: Install Dependencies
run: poetry install --all-extras
- name: Test with Pytest
run: ./.bin/test-code
shell: bash
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
benchmarks:
name: Run CodSpeed benchmarks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
- name: Install poetry
run: uv tool install poetry
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: "poetry"
- name: Install Dependencies
run: poetry install --only=main,dev
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@v4
with:
run: poetry run pytest tests/benchmarks --no-cov -o addopts="" --codspeed
mode: simulation
# Dry run on PRs and non-main pushes. No environment, no publish
# permissions, no OIDC, so PR runs carry no release blast radius.
release-dry-run:
needs:
- test
- lint
if: github.ref_name != 'main'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Create local branch name
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
run: git switch -C "$BRANCH"
- name: Test release
uses: python-semantic-release/python-semantic-release@v10.6.1
with:
no_operation_mode: true
# Real release, only on main. The release environment and write/OIDC
# permissions are scoped to this job so they never apply to PR runs.
release:
needs:
- test
- lint
if: github.ref_name == 'main'
runs-on: ubuntu-latest
environment: release
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# Use the merge commit ref so fork-PR branches (which only exist in
# the contributor's fork) don't cause a 404. PSR needs a branch
# name, so we recreate one locally in the next step.
ref: ${{ github.ref }}
# Fall back to GITHUB_TOKEN on dependabot PRs, where
# secrets.BOT_ACCESS_TOKEN is not exposed and would otherwise
# leave actions/checkout's `token` input empty.
token: ${{ secrets.BOT_ACCESS_TOKEN || github.token }}
- name: Create local branch
env:
BRANCH: ${{ github.ref_name }}
run: git switch -C "$BRANCH"
- name: Setup Git
run: |
git config user.name "uiprotectbot"
git config user.email "uiprotect@koston.org"
# On main branch: actual PSR + upload to PyPI & GitHub
- name: Release
uses: python-semantic-release/python-semantic-release@v10.6.1
id: release
with:
github_token: ${{ secrets.BOT_ACCESS_TOKEN }}
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
if: steps.release.outputs.released == 'true'
- name: Publish package distributions to GitHub Releases
uses: python-semantic-release/publish-action@v10.6.1
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.BOT_ACCESS_TOKEN }}
uilibs-uiprotect-317a52c/.github/workflows/docker.yml 0000664 0000000 0000000 00000005275 15246377057 0022764 0 ustar 00root root 0000000 0000000 name: CD - Build Docker Image
on:
release:
types: [published]
workflow_dispatch:
inputs:
rebuild:
description: "Rebuild tag?"
required: true
default: "no"
type: choice
options:
- "no"
- "yes"
concurrency:
group: docker-${{ github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: true
permissions:
packages: write
env:
DEFAULT_PYTHON: "3.12"
jobs:
docker:
name: Build Docker Image
runs-on: ubuntu-latest
environment:
name: release
steps:
- name: Check repo
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install poetry
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
cache: "poetry"
- name: Install dependencies
run: |
poetry install --no-root
- name: Get current version (rebuild)
if: ${{ inputs.rebuild == 'yes' }}
run: |
UIPROTECT_VERSION=$(git describe --tags --abbrev=0)
echo "UIPROTECT_VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:dev,ghcr.io/uilibs/uiprotect:$UIPROTECT_VERSION" >> $GITHUB_ENV
- name: Get current version (no rebuild)
if: ${{ inputs.rebuild != 'yes' }}
run: |
UIPROTECT_VERSION=v$(poetry version -s)
echo "UIPROTECT_VERSION=$UIPROTECT_VERSION" >> $GITHUB_ENV
echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:dev,ghcr.io/uilibs/uiprotect:$(echo $UIPROTECT_VERSION | tr "+" -)" >> $GITHUB_ENV
- name: Add Latest Docker Tag
run: |
if [[ ! "$UIPROTECT_VERSION" == *"dev"* ]]; then
echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:latest,$DOCKER_TAGS" >> $GITHUB_ENV
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
target: prod
push: true
build-args: |
UIPROTECT_VERSION=${{ env.UIPROTECT_VERSION }}
cache-from: ghcr.io/uilibs/uiprotect:buildcache
cache-to: type=registry,ref=ghcr.io/uilibs/uiprotect:buildcache,mode=max
tags: ${{ env.DOCKER_TAGS }}
uilibs-uiprotect-317a52c/.github/workflows/issue-manager.yml 0000664 0000000 0000000 00000001340 15246377057 0024242 0 ustar 00root root 0000000 0000000 name: Issue Manager
on:
schedule:
- cron: "0 0 * * *"
issue_comment:
types:
- created
issues:
types:
- labeled
pull_request_target:
types:
- labeled
workflow_dispatch:
jobs:
issue-manager:
runs-on: ubuntu-latest
steps:
- uses: tiangolo/issue-manager@0.8.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
config: >
{
"answered": {
"message": "Assuming the original issue was solved, it will be automatically closed now."
},
"waiting": {
"message": "Automatically closing. To re-open, please provide the additional information requested."
}
}
uilibs-uiprotect-317a52c/.github/workflows/poetry-upgrade.yml 0000664 0000000 0000000 00000000340 15246377057 0024450 0 ustar 00root root 0000000 0000000 name: Upgrader
on:
workflow_dispatch:
schedule:
- cron: "36 13 6 * *"
jobs:
upgrade:
uses: browniebroke/github-actions/.github/workflows/poetry-upgrade.yml@v1
secrets:
gh_pat: ${{ secrets.GH_PAT }}
uilibs-uiprotect-317a52c/.github/workflows/spec-validation.yml 0000664 0000000 0000000 00000017721 15246377057 0024576 0 ustar 00root root 0000000 0000000 name: Spec conformance
# Validate the public-API client against Ubiquiti's Integration OpenAPI spec.
#
# The expensive validation runs at most ONCE per Protect release: the daily cron
# only queries the firmware-latest API (seconds, no download) and short-circuits
# while the validated version is current or a drift issue is already open. The
# spec (~400 KB, from the developer portal) is fetched ephemerally and never
# committed or uploaded.
on:
schedule:
# Daily at 06:17 UTC. Cheap until a release actually drifts.
- cron: "17 6 * * *"
workflow_dispatch:
inputs:
version:
description: "Protect version to validate (MAJOR.MINOR.PATCH); blank = latest release"
required: false
type: string
dry_run:
description: "Run validation but do not open/close issues or PRs"
required: false
type: boolean
default: false
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: spec-validation
cancel-in-progress: false
jobs:
validate:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ inputs.dry_run }}
# Passed via env (never interpolated into a run script) to avoid shell
# template injection from the workflow_dispatch input.
VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
- name: Install poetry
run: uv tool install poetry
- uses: actions/setup-python@v7
with:
python-version: 3.x
cache: "poetry"
# Step 1: resolve the latest (or requested) release — API call only, no download.
- name: Resolve target version
id: resolve
run: |
set -euo pipefail
if [ -n "$VERSION" ]; then
latest=$(python scripts/fetch_openapi.py --print-version --version "$VERSION")
else
latest=$(python scripts/fetch_openapi.py --print-version)
fi
validated=$(cat openapi/.validated-version)
echo "latest=$latest" >> "$GITHUB_OUTPUT"
echo "validated=$validated" >> "$GITHUB_OUTPUT"
echo "Latest release: $latest | validated marker: $validated"
# Step 2: steady state — nothing to do.
- name: Short-circuit if already validated
id: steady
run: |
set -euo pipefail
if [ "${{ steps.resolve.outputs.latest }}" = "${{ steps.resolve.outputs.validated }}" ]; then
echo "Marker already at latest release; nothing to validate."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
# Step 3: known drift — an issue for this version already exists. Exit BEFORE
# downloading the spec so steady-state and known-drift days never fetch.
- name: Short-circuit if drift issue already open
id: known
if: steps.steady.outputs.skip != 'true'
run: |
set -euo pipefail
title="spec drift: protect ${{ steps.resolve.outputs.latest }}"
count=$(gh issue list --state open --limit 200 --search "in:title \"$title\"" --json title \
--jq "[.[] | select(.title == \"$title\")] | length")
if [ "$count" -gt 0 ]; then
echo "Drift issue already open for ${{ steps.resolve.outputs.latest }}; skipping fetch."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
# Step 4a: fetch the spec (~400 KB) and validate — only when drift is new.
- name: Install dependencies
if: steps.steady.outputs.skip != 'true' && steps.known.outputs.skip != 'true'
run: poetry install --all-extras
- name: Fetch spec and validate
id: validate
if: steps.steady.outputs.skip != 'true' && steps.known.outputs.skip != 'true'
run: |
set -euo pipefail
if [ -n "$VERSION" ]; then
poetry run python scripts/fetch_openapi.py --version "$VERSION"
else
poetry run python scripts/fetch_openapi.py
fi
set +e
poetry run python scripts/validate_spec.py > report.md
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
set -e
cat report.md
# Step 4b (green): bump the marker via PR.
- name: Open marker-bump PR (green)
if: >-
steps.steady.outputs.skip != 'true' &&
steps.known.outputs.skip != 'true' &&
steps.validate.outputs.exit_code == '0'
env:
LATEST: ${{ steps.resolve.outputs.latest }}
# A PAT, not github.token: the repository's "Allow GitHub Actions to
# create and approve pull requests" setting overrides the job's
# pull-requests permission, so GITHUB_TOKEN's createPullRequest call
# is always rejected. A user token is exempt, and its PR triggers CI
# (a GITHUB_TOKEN one does not). Scoped to this step so the issue
# steps keep using github.token; the fallback keeps
# workflow_dispatch usable where the secret is absent.
GH_TOKEN: ${{ secrets.BOT_ACCESS_TOKEN || github.token }}
run: |
set -euo pipefail
branch="spec-validation/bump-$LATEST"
if [ "$DRY_RUN" = "true" ]; then
echo "[dry-run] would open PR bumping .validated-version to $LATEST"
exit 0
fi
open=$(gh pr list --state open --limit 200 --head "$branch" --json number --jq "length")
if [ "$open" -gt 0 ]; then
echo "Marker-bump PR already open for $branch; nothing to do."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# -B resets the local branch if a prior run left it around; the
# force-push overwrites any orphan remote branch (no open PR) so a
# stale spec-validation/bump-* left by a failed run can't cause a
# non-fast-forward rejection. The content is deterministic — a single
# line in openapi/.validated-version — so overwriting is safe.
git checkout -B "$branch"
printf '%s\n' "$LATEST" > openapi/.validated-version
git add openapi/.validated-version
git commit -m "chore(spec): bump validated version to $LATEST"
git push --force --set-upstream origin "$branch"
gh pr create \
--title "chore(spec): bump validated version to $LATEST" \
--body "Automated: public-API client conforms to the Protect $LATEST Integration spec. The spec itself is not committed (Ubiquiti IP); reproduce locally with \`python scripts/fetch_openapi.py --version $LATEST && python scripts/validate_spec.py\`." \
--base main --head "$branch"
# Step 4c (red): open one drift issue, close superseded ones.
- name: Open drift issue (red)
if: >-
steps.steady.outputs.skip != 'true' &&
steps.known.outputs.skip != 'true' &&
steps.validate.outputs.exit_code != '0'
env:
LATEST: ${{ steps.resolve.outputs.latest }}
run: |
set -euo pipefail
title="spec drift: protect $LATEST"
if [ "$DRY_RUN" = "true" ]; then
echo "[dry-run] would open issue: $title"
cat report.md
exit 0
fi
gh issue create --title "$title" --body-file report.md
# Close older drift issues as superseded.
gh issue list --state open --limit 200 --search 'in:title "spec drift: protect"' \
--json number,title \
--jq ".[] | select(.title != \"$title\") | .number" | while read -r num; do
[ -z "$num" ] && continue
gh issue close "$num" \
--comment "Superseded by drift detected for Protect $LATEST."
done
uilibs-uiprotect-317a52c/.gitignore 0000664 0000000 0000000 00000006102 15246377057 0017353 0 ustar 00root root 0000000 0000000 # Created by .ignore support plugin (hsz.mobi)
### Python template
# settings.json is user-specific overrides for devcontainer.json
.vscode/settings.json
test-data
ufp-data
*.mp3
*.mp4
*.js
*.json
*.csv
backup
.benchmarks
.ruff_cache
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder {{package_name}} settings
.spyderproject
.spyproject
# Rope {{package_name}} settings
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
.DS_Store
test.py
camerasnapshot.py
dumpeventdata.py
eventdata.json
dumpeventdata.py
heatmap_snapshot.py
smartdetect.json
dumpcameradata.py
cameras.json
setstatuslight.py
# websocket.py
test_function.py
test_event.py
events.json
event_obj.json
src/uiprotect/unifi_protect_server_ws.py
websocket_test.py
test_ws.py
camera_cloudkey.json
camera_cloudkey_privacy.json
cameras_upsense.json
event.json
test_websocket.py
test_raw.py
rawdata.json
# IDE settings
.vscode/
.idea/
# Public Integration API spec: fetched on demand, never committed (Ubiquiti IP).
openapi/integration.json
# …except the validated-version marker — a bare version string carries no IP and
# its git history is the audit trail of when spec conformance last moved forward.
!openapi/.validated-version
uilibs-uiprotect-317a52c/.gitpod.yml 0000664 0000000 0000000 00000000323 15246377057 0017451 0 ustar 00root root 0000000 0000000 tasks:
- command: |
pip install poetry
PIP_USER=false poetry install --all-extras
- command: |
pip install pre-commit
pre-commit install
PIP_USER=false pre-commit install-hooks
uilibs-uiprotect-317a52c/.pre-commit-config.yaml 0000664 0000000 0000000 00000002706 15246377057 0021652 0 ustar 00root root 0000000 0000000 # See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: "CHANGELOG.md|.copier-answers.yml"
default_stages: [pre-commit]
ci:
autofix_commit_msg: "chore(pre-commit.ci): auto fixes"
autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate"
skip: [mypy]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: debug-statements
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-toml
- id: check-xml
- id: detect-private-key
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/python-poetry/poetry
rev: 2.4.1
hooks:
- id: poetry-check
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
args: ["--tab-width", "2"]
- repo: https://github.com/codespell-project/codespell
rev: v2.4.3
hooks:
- id: codespell
additional_dependencies: [tomli]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.4
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: local
hooks:
- id: mypy
name: mypy
language: script
entry: ./.bin/run-mypy
types_or: [python, pyi]
require_serial: true
files: ^(src/uiprotect)/.+\.(py|pyi)$
uilibs-uiprotect-317a52c/.readthedocs.yml 0000664 0000000 0000000 00000001116 15246377057 0020451 0 ustar 00root root 0000000 0000000 # Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.12"
jobs:
post_create_environment:
# Install poetry
- python -m pip install poetry
post_install:
# Install dependencies, reusing RTD virtualenv
- VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --only main,docs
# Build documentation in the docs directory with mkdocs
mkdocs:
configuration: mkdocs.yml
uilibs-uiprotect-317a52c/.vscode/ 0000775 0000000 0000000 00000000000 15246377057 0016725 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/.vscode/launch.json 0000664 0000000 0000000 00000002677 15246377057 0021106 0 ustar 00root root 0000000 0000000 {
"version": "0.2.0",
"configurations": [
{
"name": "Run Subcommand: shell",
"type": "debugpy",
"request": "launch",
"module": "uiprotect",
"args": [
"-u",
"shell",
]
},
{
"name": "Run Subcommand: generate-sample-data",
"type": "debugpy",
"request": "launch",
"module": "uiprotect",
"args": [
"generate-sample-data",
"-w",
"30",
"--actual",
],
"env": {
"UFP_SAMPLE_DIR": "${workspaceFolder}/test-data"
}
},
{
"name": "Debug Tests (pytest/poetry)",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"justMyCode": false,
"console": "integratedTerminal",
"args": [
"-v"
],
"python": "/usr/local/bin/python3",
"cwd": "${workspaceFolder}"
},
{
"name": "Python: Debug Test File",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"justMyCode": false,
"console": "integratedTerminal",
"args": [
"${file}"
],
"cwd": "${workspaceFolder}"
}
]
}
uilibs-uiprotect-317a52c/.vscode/tasks.json 0000664 0000000 0000000 00000004107 15246377057 0020747 0 ustar 00root root 0000000 0000000 {
"version": "2.0.0",
"tasks": [
{
"label": "Test Code",
"type": "shell",
"command": "${workspaceFolder}/.bin/test-code",
"problemMatcher": []
},
{
"label": "Update requirements",
"type": "shell",
"command": "${workspaceFolder}/.bin/update-requirements",
"problemMatcher": []
},
{
"label": "MkDocs: Serve",
"type": "shell",
"command": "mkdocs serve",
"problemMatcher": []
},
{
"label": "MkDocs: Build",
"type": "shell",
"command": "mkdocs build",
"problemMatcher": []
},
{
"label": "Generate Sample Data",
"type": "shell",
"command": "uiprotect generate-sample-data -w ${input:sampleTime} ${input:sampleAnonymize}",
"problemMatcher": [],
"options": {
"env": {
"UFP_SAMPLE_DIR": "${workspaceFolder}/${input:sampleLocation}"
}
}
},
{
"label": "Regenerate Release Cache",
"type": "shell",
"command": "uiprotect release-versions",
"problemMatcher": [],
},
],
"inputs": [
{
"id": "sampleLocation",
"description": "Location to generate sample data in",
"default": "test-data",
"type": "pickString",
"options": [
"test-data",
"tests/sample_data",
],
},
{
"id": "sampleTime",
"description": "Length of time to generate sample data",
"default": "30",
"type": "promptString",
},
{
"id": "sampleAnonymize",
"description": "Anonymize parameter for generate sample data",
"default": "",
"type": "pickString",
"options": [
"",
"--actual",
],
},
]
}
uilibs-uiprotect-317a52c/AGENTS.md 0000664 0000000 0000000 00000062612 15246377057 0016676 0 ustar 00root root 0000000 0000000 # Notes for LLM contributors
A short orientation file for an LLM working in this repo. Skim
before making changes; keep edits consistent with what's described
here. Read [README.md](README.md) for the user-facing intro and
[CONTRIBUTING.md](CONTRIBUTING.md) for the human-facing
contributor guide.
## What this project is
`uiprotect` is an unofficial async Python client + CLI for the
UniFi Protect surveillance NVR. It is the library that the Home
Assistant `unifiprotect` integration is built on top of, and it
is the de-facto Python binding for talking to a UniFi Protect
console — Ubiquiti does not publish an official one. Public API
is exported from the top-level `uiprotect` package; the main
entry point is `uiprotect.ProtectApiClient` in `src/uiprotect/api.py`.
There is no upstream protocol owner. The wire format is whatever
the UniFi Protect server in front of you serves: a partially
documented REST API plus a binary WebSocket update stream. Both
shift across firmware releases, and the test corpus under
`tests/sample_data/` is the closest thing to a reference. Behaviour
changes that depend on a specific Protect firmware version should
say so in the commit message.
The project was forked from `pyunifiprotect` after that project
relicensed away from MIT; staying MIT-licensed is a hard
requirement so the Home Assistant core integration can keep
depending on it.
## Code style
- **Docstrings: terse, default to single-line.** A docstring is
the function's _contract_, not its narrative. Almost every
docstring should be one line — `"""Summary."""` — describing
what the function does and what the caller can pass. Multi-line
is the exception, only justified when there is non-obvious
caller-visible behaviour the type signature and parameter names
don't already convey. Ruff is configured with most of the `D`
family disabled (`D100`–`D107`, `D2xx`, `D4xx`) precisely so
that missing docstrings on internal helpers don't flag — only
add one when it earns its keep.
**What does NOT belong in docstrings or comments:**
- Rationale / motivation / "why we used to do X" — that's the
PR description and the commit message. Git already remembers.
- Cross-references to issue numbers ("closes #N", "follow-up
to #M") — the PR body carries those.
- Restatement of the function body in prose. If the next line
of the docstring is just describing what the next line of
code does, delete the docstring line.
- Test docstrings retelling the production-side story. A test
docstring should name what the test pins, in one sentence —
not re-explain the bug, the fix, or the surrounding flow.
- **Comments**: same bar. Default to writing no comments. Add
one only when the _why_ is non-obvious: a hidden constraint, a
subtle invariant, a workaround for a specific Protect firmware
quirk, behaviour that would surprise a reader. Firmware-version
citations are useful when the reason for a branch is "this
server returns the field, that one doesn't" — leave those in.
If removing the comment wouldn't confuse a future reader, don't
write it.
Three anti-patterns that keep showing up in generated code —
the downstream Home Assistant integration enforces the same
bar, so match it:
- **No section / divider banners.** Comments like
`# ---- Convenience setters ----` or `# === websocket ===`,
inside or outside a function, carry no information the code
doesn't, drift out of sync, and go stale. Group related
members with a blank line and let the names carry the
structure — don't draw a banner.
- **Never restate the next line(s).** A comment that
paraphrases what the following statement plainly does
(`# increment the counter` above `count += 1`) is noise.
Comment the non-obvious _why_, or write nothing.
- **Keep `try` clauses minimal.** Wrap only the statement(s)
that can raise the caught exception, not a large block, and
don't catch from calls that aren't expected to raise. Not a
comment rule, but the same "say only what matters" hygiene
and enforced downstream.
**Don't remove existing comments** unless the code they
describe is gone — the original author left them for a reason,
often a specific Protect firmware regression. The one
exception is the two anti-patterns above (divider banners and
lines that merely restate the following code): those carry no
author intent worth preserving and should be deleted on sight.
- **Don't pad commits, docstrings, or comments with cross-
references** to old codepaths or issue numbers unless there's
a clear reason a future reader needs that link.
- **Method order**: public API at the top, private helpers
(`_underscore_prefixed`) at the bottom. The supported surface
is what `src/uiprotect/__init__.py` re-exports plus the public
attributes on `ProtectApiClient`, `Bootstrap`, and the device /
NVR model classes. Anything else is internal and can change
between releases.
- **Line length**: 88 (ruff `line-length = 88`, not the more
common 100/110). `requires-python = ">=3.11"`,
`target-version = "py311"` for ruff; pyupgrade runs `--py311-plus`.
- **Imports**: ruff/isort sorted, `known_first_party = ["uiprotect", "tests"]`.
Prefer `from __future__ import annotations` in new modules so
modern type syntax works without runtime forward-reference
juggling, especially given the pydantic model graph.
- **Pydantic v2 only.** Models live under `src/uiprotect/data/`
and are built on pydantic v2 (`pydantic >= 2.13.4`). Do not
reintroduce v1 idioms (`.dict()`, `Config` inner class,
`validator` decorator) when touching this code — match the v2
style already present (`model_dump()`, `model_config`,
`field_validator`). The pinned-floor on pydantic is deliberate;
do not loosen it without checking what changed across versions.
- **`orjson` for serialisation.** The codebase uses `orjson`
everywhere it serialises or parses Protect payloads. Don't
reach for stdlib `json` in hot paths; match the existing
`orjson.loads` / `orjson.dumps` usage.
- **Async-only public surface.** UniFi Protect itself is async,
and so is this library. New public functions on
`ProtectApiClient` and friends should be `async def`. Don't
introduce sync wrappers around async I/O; callers that need
sync should drive the event loop themselves.
- **Typed strictly.** mypy runs with `disallow_untyped_defs`,
`disallow_incomplete_defs`, `check_untyped_defs`,
`no_implicit_optional`, `warn_unreachable`, and
`warn_unused_ignores`, and is wired into pre-commit through
`.bin/run-mypy` against `src/uiprotect/`. New code under
`src/uiprotect/` must type-check cleanly; tests are exempted
via the `tests.*` override. Don't paper over a real type error
with `# type: ignore` — `warn_unused_ignores` will eventually
catch you, and the right answer is almost always a tighter
annotation upstream.
## Commit / PR conventions
- **Conventional Commits PR title, lowercase subject.** PRs are
squash-merged, so the **PR title** becomes the commit on `main`
and is the only string that needs to parse as a Conventional
Commit. The repo enforces this via the `pr-title` CI job in
`ci.yml` using `amannn/action-semantic-pull-request`. The
_type_ prefix is required: `feat:`, `fix:`, `chore:`, `ci:`,
`docs:`, `refactor:`, `test:`, `perf:`, `build:`, etc., and
the subject (text after `type(scope):`) must start lowercase
(enforced by `subjectPattern: ^(?![A-Z]).+$`). Per-commit
messages on the PR branch are **not** linted — they get
collapsed at squash-merge. `python-semantic-release` excludes
`chore*` and `ci*` from the changelog (see
`[tool.semantic_release.changelog]` in `pyproject.toml`), so
use those prefixes for housekeeping and reserve
`feat`/`fix`/`perf` for user-visible changes that should land
in `CHANGELOG.md`.
- **No `Co-Authored-By` trailers from automated agents.** Project
preference; releases are cut by `python-semantic-release` from
the commit log, and a trailer from an LLM ends up in the
generated changelog.
- Imperative-mood subject after the type prefix ("fix(api):
handle empty bootstrap", not "fix(api): handled empty bootstrap").
Scopes in parentheses are encouraged where useful
(`api`, `cli`, `data`, `websocket`, `stream`).
- The repo **does** ship a PR template at
[.github/PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md).
Fill out the "Description of change" section in prose and tick
the checklist items that apply (write `N/A` rather than
silently leaving rows blank). Reference closing issues with
`Fixes #NNNN` so the issue auto-closes on merge.
- Pre-commit runs ruff (lint + format), mypy, prettier,
poetry-check, and the standard hygiene hooks (trailing
whitespace, end-of-file fixer, debug-statements,
detect-private-key, check-toml/xml). Run pre-commit locally
before pushing; the CI lint job is just `pre-commit run -a`,
so a green local run = a green CI lint job. The `pre-commit.ci`
bot fixes formatting drift automatically with a
`chore(pre-commit.ci): auto fixes` commit, so don't bother
hand-fixing whitespace-only failures.
- **Releases are automated.** `python-semantic-release` cuts
releases from `main` based on commit type prefixes — `feat`
bumps minor, `fix`/`perf` bump patch, a `BREAKING CHANGE:`
footer (or `!` after the type) bumps major. Mis-typed commits
ship the wrong version. If a change is user-visible, type it
honestly.
## Running tests
Requires Python >=3.11. **Install all extras to run the full suite** — the
`cli` extra provides Pillow, typer and rich. Without it the CLI/Pillow test
modules (`tests/test_api.py`, `tests/test_cli.py`, `tests/test_dunder_main.py`)
are skipped via `pytest.importorskip` rather than run, so a bare install
silently exercises a reduced suite:
```bash
poetry install --all-extras
poetry run pytest
```
The default `addopts` in `pyproject.toml` already pass
`-v -Wdefault --cov=uiprotect --cov-report=term-missing:skip-covered -n=auto`
and `pythonpath = ["src"]`, so a bare `pytest` produces a
coverage-aware, xdist-parallel run. `pytest-asyncio` is used in
the default per-test mode (no auto mode); async tests are marked
explicitly with `@pytest.mark.asyncio`.
**Every PR must reach 100% patch coverage.** Each line you add or
change under `src/uiprotect/` has to be exercised by a test —
check the `--cov-report=term-missing:skip-covered` output and confirm
none of the diff's lines appear as missing before pushing. Don't ship a
change with uncovered new code; if a line is genuinely
unreachable, mark it `# pragma: no cover` with a one-line reason
rather than leaving it untested. This applies to automated
contributions too: a PR that adds production code without the
tests to cover it is incomplete.
The test suite is fixture-driven: `tests/sample_data/` holds
captured bootstrap JSON / WebSocket frames from real Protect
consoles and `tests/conftest.py` loads them into the mock client.
When fixing a bug that depends on a specific server response,
add a fixture or extend an existing one rather than mocking ad-hoc
in the test — the goal is for the corpus to grow alongside
firmware drift. See [TESTDATA.md](TESTDATA.md) for how the corpus
is generated.
CodSpeed benchmarks live under `tests/benchmarks/` and run in CI
through the CodSpeed integration. Don't regress benchmark numbers
on hot paths (`api.py` request building, `data/convert.py`,
`websocket.py` decode) without flagging the trade-off in the PR
body.
`tests/test_cli.py` covers the typer-based CLI surface. If you
add a new subcommand under `src/uiprotect/cli/`, extend the CLI
tests as well — the CLI tests are how we catch import-time
regressions that would otherwise only fire for users on
`uiprotect --help`.
The CI matrix runs CPython 3.11 – 3.14 across Linux/macOS, and a
separate live-data leg can run against a real Protect NVR on a
self-hosted runner — see [LIVE_DATA_CI.md](LIVE_DATA_CI.md). The
live leg is optional and is not gated for typical PRs.
## Useful entry points
| Path | What |
| ---------------------------------------- | -------------------------------------------------------------------------------- |
| `src/uiprotect/__init__.py` | Public package — re-exports `ProtectApiClient` and key exceptions |
| `src/uiprotect/api.py` | `ProtectApiClient` — REST surface, auth, bootstrap, subscribe, request plumbing |
| `src/uiprotect/websocket.py` | `Websocket` — the long-lived update connection that drives live state |
| `src/uiprotect/stream.py` | PyAV-backed audio streaming to camera speakers (talkback) |
| `src/uiprotect/utils.py` | Shared helpers: URL building, time parsing, type coercion, dict diffing |
| `src/uiprotect/exceptions.py` | Public exception hierarchy raised from `ProtectApiClient` |
| `src/uiprotect/data/__init__.py` | Pydantic model re-exports — the typed surface most callers consume |
| `src/uiprotect/data/base.py` | `ProtectBaseObject` / `ProtectModel` base classes shared by every Protect model |
| `src/uiprotect/data/bootstrap.py` | `Bootstrap` — the cached snapshot returned by `protect.bootstrap` |
| `src/uiprotect/data/devices.py` | Camera / Light / Sensor / Viewer / Chime / AiPort device models |
| `src/uiprotect/data/nvr.py` | `NVR`, `Event`, `Liveview`, related top-level models |
| `src/uiprotect/data/user.py` | User / permission / cloud-account models |
| `src/uiprotect/data/websocket.py` | WebSocket frame decoder + `WSSubscriptionMessage` definition |
| `src/uiprotect/data/convert.py` | Raw-JSON → pydantic-model conversion entry point used by API + WS paths |
| `src/uiprotect/data/types.py` | Enums, typed dicts, and protocol-level constants |
| `src/uiprotect/data/public_bootstrap.py` | Public-API bootstrap shape (parallels `bootstrap.py` for the newer API) |
| `src/uiprotect/data/public_devices.py` | Public-API device models |
| `src/uiprotect/cli/` | typer-based CLI — one module per device family plus `base.py` for shared options |
| `src/uiprotect/test_util/` | Helpers used by `generate-sample-data`; not part of the public API |
| `tests/` | Pytest suite |
| `tests/sample_data/` | Captured Protect bootstrap / WS / event JSON used as fixtures |
| `tests/benchmarks/` | CodSpeed benchmarks |
| `tests/conftest.py` | Fixture wiring: builds a mock `ProtectApiClient` from `sample_data/` |
| `templates/` | Rich templates used by the CLI for human-readable output |
## API strategy
The project is migrating from the **private API** (reverse-engineered,
undocumented endpoints under `/api/…` and the binary WebSocket stream;
models in `src/uiprotect/data/devices.py`) to the **Public Integration
API** (Ubiquiti's officially documented REST API under
`/integration/v1/…`; models in `src/uiprotect/data/public_devices.py`
and `src/uiprotect/data/public_bootstrap.py`; tests under
`tests/test_api_*_public.py`).
**Do not implement new features on the private API.** If a capability
is missing from the public API, the right answer is to wait for or
request the public endpoint — not to add it via the private path.
Derive shapes from the existing public models and tests in this repo.
**Public-only client mode.** `ProtectApiClient` can be constructed with
only an API key and no private credentials —
`ProtectApiClient.public_only(host, port, api_key=...)`, or by passing
`api_key=` with `username`/`password` omitted. In that mode
`is_public_only` is `True` and the private-session entry points
(`authenticate`, `ensure_authenticated`, `update`, `get_bootstrap`)
raise `PublicOnlyModeError`; only the Public Integration API surface is
available (`update_public`, `subscribe_events`, `subscribe_devices`, the
`*_public` getters/setters, `get_meta_info`). A revoked/invalid/missing
key surfaces as `NotAuthorized` across REST and the public websockets.
`MetaInfo.version` parses `applicationVersion` into a `Version`
comparable to the private `NVR.version` min-version gate. API-key
_provisioning_ (`create_api_key`) is private-API and out of scope for
public-only clients — the key is supplied pre-provisioned.
**Deprecate private-API counterparts when the public API is feature-
complete for a given capability.** Once a device method or endpoint is
fully covered by the public API, mark the corresponding private-API
method with a `DeprecationWarning` pointing to the public replacement.
**Remove private-API code that the Home Assistant integration no longer
uses.** Before removing a private-API method or model, check whether
the latest released version of the HA integration still references it:
search `homeassistant/components/unifiprotect/` in the
`home-assistant/core` GitHub repository. If the symbol does not
appear there, it is safe to remove.
## Public Integration API spec
The OpenAPI spec is not committed (Ubiquiti's IP). Fetch it on demand — no
auth or console access needed:
```bash
python scripts/fetch_openapi.py # latest release
python scripts/fetch_openapi.py --version 7.0.104 # pin to a version
```
Output: `openapi/integration.json` (gitignored) — request/response shapes,
required fields, enums, and allowed HTTP methods for every `/integration/v1/…`
endpoint. If the file is absent, run the script first.
### Spec conformance validation
`scripts/validate_spec.py` checks the public-API client against a fetched
spec and reports drift: spec endpoints with no covering `*_public` method
(warning), model fields the spec dropped/retyped (error) or added (warning),
new values on a named, tracked enum (warning), and any spec enum — named **or**
inline — that is not faithfully typed in the library (**error**, so the check
gates: `main()` exits non-zero and the cron opens a drift issue instead of a
green marker-bump PR). The enum-coverage check walks the whole spec keyed by
value-set (the `unknown` forward-compat sentinel ignored on both sides) and
counts a value-set covered only when it _equals_ a single library enum, or is
explicitly pinned in `_MODELLED_AS_SUBSET` to one named superset enum the public
models already type the field with — never a coincidental subset of _any_ enum,
which is the value-set collision an earlier any-subset check let slip. Coverage
is decided on the value-set alone: an exact match proves some library enum
carries those values, not that the owning model field is annotated with it.
Enums are classified inbound (reachable from a response the library
deserializes) vs. outbound-only (request param/body) by `$ref` reachability,
per enum _occurrence_ — `(owning component schema, value-set)` — so an
outbound-only enum whose values coincide with an inbound one is not dragged
inbound with it. Reusable responses (`components.responses` or a `$ref`-ed
response entry) are not resolved and fail the check loudly rather than silently
misclassifying. Outbound-only enums are waived by direction, while an inbound
enum left untyped needs an explicit, documented entry in
`_ENUM_COVERAGE_WAIVERS`, keyed by `(owning component schema, value-set)` so a
waiver for a generic set like `{closed, open}` cannot silently cover an
unrelated enum on another schema. The default is to model; waivers are the rare
exception. Single-value `const` discriminators (`modelKey`, an event `type`) are
walked too, under a membership rule rather than the exact match an `enum` array
must make: no library enum can ever _equal_ a one-value set, so a const counts as
covered when some library enum defines the value, and non-string consts —
`sirenDuration`, `activationDelay` and friends, which enumerate allowed _numbers_
— are skipped. `check_event_types` pins the axis that membership alone leaves
loose: `PUBLIC_EVENT_TYPES` must equal the `type` consts of the spec's `event`
`oneOf`, both directions an error, because `EventType` has no `unknown` member
and an unmodelled public event type is dropped on parse. `oneOf`/`allOf` nodes
resolve to the _union_ of their object variants (a property the variants disagree
on becomes a nested union, so per-event `metadata` blocks merge too), which is
what lets the single all-optional `PublicEvent` model be field-checked against
the 38-variant `event` union. Spec `required` vs. model `optional` is not checked — the library models every public-API field optional by design, so it
would be guaranteed noise. It exits non-zero on any error and prints a markdown
summary. Reproduce locally with:
```bash
python scripts/fetch_openapi.py --version "$(cat openapi/.validated-version)"
python scripts/validate_spec.py
```
The only committed artifact is `openapi/.validated-version` — a bare version
string (no IP) whose git history records when conformance last moved forward.
`tests/test_public_schema_conformance.py` runs the same checks when a spec is
present and skips cleanly when it is absent (the CI default); the logic itself
is unit-tested network-free against in-memory mock specs in
`tests/test_validate_spec.py`. The `.github/workflows/spec-validation.yml`
cron runs the full validation at most once per Protect release — opening a
marker-bump PR when green or a single drift issue when red — and short-circuits
on a firmware-API check before downloading anything while the marker is current
or a drift issue is already open. Endpoint coverage is **derived, not hand-
maintained**: the declarative `@public_*` decorator registry
(`uiprotect._public_api.registry`) covers every uniform endpoint, and the
hand-written exception methods are covered by one recorded example call each
(`_EXAMPLE_CALLS` — a request spy captures `(verb, path)` then short-circuits).
`check_completeness` asserts every public-API coroutine is accounted for, so a
new method that nobody wired up fails the suite instead of silently leaving its
endpoint uncovered.
## Reporting security issues
Suspected security vulnerabilities go through GitHub's [private
vulnerability reporting][gh-report], not public issues or pull
requests. The policy is spelled out in [SECURITY.md](SECURITY.md).
The library holds credentials for users' UniFi Protect consoles,
so a bug class disclosed in a public PR title is a credible
information leak even before a patch ships. If a user describes
what sounds like a vulnerability in chat, point them at that
route instead of opening a public issue, PR, or commit that
names the bug class and the affected code path.
[gh-report]: https://github.com/uilibs/uiprotect/security/advisories/new
## Things not to do
- **Don't relicense or pull in non-MIT dependencies.** Staying
MIT is the reason this fork exists; an incompatible licence
would break the Home Assistant integration that depends on
this library.
- **Don't break pydantic v2 compatibility.** No v1 idioms
(`.dict()`, `Config` inner class, `validator` decorator) when
editing model code — match the v2 style already in the
`src/uiprotect/data/` tree.
- **Don't introduce sync wrappers around async I/O.** The public
surface is async; callers needing sync drive the loop
themselves.
- **Don't paper over typing errors with `# type: ignore`.** mypy
runs with `warn_unused_ignores`, so a bare ignore will rot.
Fix the upstream annotation instead.
- **Don't add `Co-Authored-By` trailers from automated agents
to commits** in this repo. `python-semantic-release` reads the
commit log when generating the changelog and the trailer
leaks into release notes.
- **Don't introduce a PR title that violates Conventional
Commits.** The `pr-title` CI job will reject it, and because
the PR title is what lands on `main` at squash-merge time, a
mis-typed title bumps the wrong version on the next
semantic-release run.
- **Don't reach for stdlib `json` in hot paths.** The codebase
uses `orjson` everywhere; match the existing style.
- **Don't hand-edit `CHANGELOG.md`.** It is generated by
`python-semantic-release` from commit messages; manual edits
are overwritten on the next release.
- **Don't commit captured Protect data without scrubbing.**
Bootstrap and event JSON from a real NVR contains
`authUserId`, `accessKey`, and user records; see
[TESTDATA.md](TESTDATA.md) for the keys that must be removed
before a sample lands in `tests/sample_data/`.
- **Don't drop Python 3.11 support without coordination.**
`requires-python = ">=3.11"` is set deliberately; the Home
Assistant integration's minimum tracks this floor.
- **Don't store mutable state (caches, dedupe sets, etc.)
module-globally or as a class-level mutable default.** One
process can run multiple `ProtectApiClient` instances against
different consoles (e.g. several Home Assistant config
entries), so per-instance state must live on the instance —
`self.…`, the `Bootstrap`/`PublicBootstrap` snapshot, or a
per-client dataclass field (use `field(default_factory=…)`),
never a module global or shared class attribute. A global
cache leaks and collides across NVRs and is invisible in
single-console tests.
uilibs-uiprotect-317a52c/CHANGELOG.md 0000664 0000000 0000000 00000251232 15246377057 0017202 0 ustar 00root root 0000000 0000000 # Changelog
## v16.10.0 (2026-09-03)
### Features
- Route remaining write callsites through the public api ([`dc913ad`](https://github.com/uilibs/uiprotect/commit/dc913ada462f712fb273d34d75be962d6dde6e02))
## v16.9.0 (2026-09-03)
### Features
- Add publiccamerafeatureflags.has_highfps ([`0546eee`](https://github.com/uilibs/uiprotect/commit/0546eee67b855e8d85d43b9197fd9bc277a67b68))
## v16.8.0 (2026-09-03)
### Features
- Add camera.can_detect(smart_type) ([`16c10e3`](https://github.com/uilibs/uiprotect/commit/16c10e36ecd59b9e31d139f756390954d5aa52d4))
## v16.7.0 (2026-09-02)
### Features
- Add publiccamera.can_detect(smart_type) ([`5daf56c`](https://github.com/uilibs/uiprotect/commit/5daf56ca28b1e32c79af632c2c4cfa85bdccd5dc))
## v16.6.1 (2026-08-29)
### Bug fixes
- Honour override_connection_host for public rtsps urls ([`bdd7495`](https://github.com/uilibs/uiprotect/commit/bdd7495b12466750907ae69c4852bd21853065c5))
## v16.6.0 (2026-08-29)
### Features
- Allow clearing the doorbell lcd message over the public api ([`4009fe1`](https://github.com/uilibs/uiprotect/commit/4009fe10b167d68f1dc3a1e63a3779e91b1bb706))
## v16.5.0 (2026-08-28)
### Features
- Announce siren timed-run expiry over the devices websocket ([`55cec39`](https://github.com/uilibs/uiprotect/commit/55cec394bfe3a925e9b9ee1093b0600f2200a547))
## v16.4.0 (2026-08-28)
### Features
- Cover pos transactions and widen the spec conformance checks ([`f4b99e4`](https://github.com/uilibs/uiprotect/commit/f4b99e438b797f042c74cd17603d43dc100f9ebb))
## v16.3.0 (2026-08-28)
### Features
- Add datetime accessors for public epoch-ms timestamps ([`d8d85b3`](https://github.com/uilibs/uiprotect/commit/d8d85b39d0481d630c07b3fda49a42989bc3a369))
## v16.2.0 (2026-08-28)
### Features
- Complete the public event enums, types and metadata ([`820f314`](https://github.com/uilibs/uiprotect/commit/820f314d1b52fb8e4f2e6e4eefd6b51fc73d766b))
- Expose publicbootstrap.store_for for a model type ([`42e1162`](https://github.com/uilibs/uiprotect/commit/42e1162798d5af8b35c4f43dd8bb7a4c5e0fe399))
### Bug fixes
- Open the marker-bump pr with a user token ([`f2e65d4`](https://github.com/uilibs/uiprotect/commit/f2e65d40f304f95fd3aa7aecfd10c38e1d02c91a))
- Normalise resolved protect version to a bare major.minor.patch ([`f2e65d4`](https://github.com/uilibs/uiprotect/commit/f2e65d40f304f95fd3aa7aecfd10c38e1d02c91a))
## v16.1.0 (2026-08-24)
### Bug fixes
- Include customfilteronly in is_ir_led_slider_enabled ([`ec29041`](https://github.com/uilibs/uiprotect/commit/ec29041341b3ee2c7075c2db8231bbdf7f00634f))
### Features
- Emit add/remove devices-ws frames for the public resync ([`38719f9`](https://github.com/uilibs/uiprotect/commit/38719f9bdd9d4cdaf76839ae738ca26b8c93f244))
## v16.0.0 (2026-08-24)
### Features
- Remove the private setters superseded by their public twins ([`8e73f8a`](https://github.com/uilibs/uiprotect/commit/8e73f8aee5bebf6d393bd488b1874e59d60dbae0))
### Refactoring
- Swap the write callsites that already have a public twin ([`5104dee`](https://github.com/uilibs/uiprotect/commit/5104dee8094ba5e5622b6f8b104182f6323724e6))
## v15.14.3 (2026-08-17)
### Bug fixes
- Give the private websocket a heartbeat ([`c3c7e5b`](https://github.com/uilibs/uiprotect/commit/c3c7e5b69536f26272da5087842cff394239d450))
### Documentation
- Note public-api doorbell settings are read-only (spec gap) ([`8a8ead2`](https://github.com/uilibs/uiprotect/commit/8a8ead2bbe6a862d3e6b747c431ab2388d58a47d))
- Record public-api doorbell settings are read-only ([`8a8ead2`](https://github.com/uilibs/uiprotect/commit/8a8ead2bbe6a862d3e6b747c431ab2388d58a47d))
## v15.14.2 (2026-07-15)
### Bug fixes
- Redact rtspsstreams urls in anonymize_data ([`fcd2a88`](https://github.com/uilibs/uiprotect/commit/fcd2a8874884af94d1785ebec7fc266baa5e9321))
## v15.14.1 (2026-07-15)
### Bug fixes
- Wrap body-read failures in nvrerror ([`9cbf8ef`](https://github.com/uilibs/uiprotect/commit/9cbf8ef41150e75b6ccd8362d66c3d77f3f9db9c))
## v15.14.0 (2026-07-15)
### Features
- Add publicbootstrap.all_devices enumeration helper ([`59a1875`](https://github.com/uilibs/uiprotect/commit/59a1875bfd2f6efc973b5fc3138cb95b172ff505))
## v15.13.1 (2026-07-15)
### Bug fixes
- Fetch /v1/nvrs in resolve_nvr_mac on an unprimed client ([`387b870`](https://github.com/uilibs/uiprotect/commit/387b870cb0193f22a4d19b9b3925085a00731906))
## v15.13.0 (2026-07-15)
### Features
- Add combined set_light setter to public light tree ([`eff3045`](https://github.com/uilibs/uiprotect/commit/eff304580d88d74664541238e278b032f822f3ec))
### Documentation
- Codify comment anti-patterns and sweep src/uiprotect ([`5c3e239`](https://github.com/uilibs/uiprotect/commit/5c3e2399e21cc470030a253f1a27f7b286328f98))
## v15.12.2 (2026-07-13)
### Bug fixes
- Emit disconnected before auth_failed ([`cb2b57e`](https://github.com/uilibs/uiprotect/commit/cb2b57e26fa79021f5fdbaa5b6acc1ad6207fab3))
## v15.12.1 (2026-07-13)
### Bug fixes
- Notify subscribers when background rtsps refresh writes streams ([`f71fb78`](https://github.com/uilibs/uiprotect/commit/f71fb78811748d45e34568b6683ad839597ced59))
## v15.12.0 (2026-07-13)
### Features
- Make update_public() independent of websocket subscription order ([`b3de73e`](https://github.com/uilibs/uiprotect/commit/b3de73ec665dc5f66791fa5c06f9da393422d12f))
## v15.11.0 (2026-07-12)
### Features
- Shared identity interface across model trees ([`20c48cd`](https://github.com/uilibs/uiprotect/commit/20c48cd43e8913d012f36cdfbb01ac455c2d1832))
## v15.10.1 (2026-07-12)
### Bug fixes
- Apply update_public bootstrap batch atomically ([`fa934fd`](https://github.com/uilibs/uiprotect/commit/fa934fddd89a2411aedd28d27c9c3ede17d809bd))
## v15.10.0 (2026-07-12)
### Features
- Backfill public nvr mac from console fallback on older firmware ([`d9d5e3b`](https://github.com/uilibs/uiprotect/commit/d9d5e3b4bf4c254187430de129ea17f2bee63f5b))
## v15.9.0 (2026-07-11)
### Features
- Unify device convenience setters on the public model tree ([`ceedc47`](https://github.com/uilibs/uiprotect/commit/ceedc47bcc2f58765ad1d63614fac4328f588960))
### Testing
- Cover public device convenience setters and write-through ([`ceedc47`](https://github.com/uilibs/uiprotect/commit/ceedc47bcc2f58765ad1d63614fac4328f588960))
### Documentation
- Document public-tree device convenience setters ([`ceedc47`](https://github.com/uilibs/uiprotect/commit/ceedc47bcc2f58765ad1d63614fac4328f588960))
## v15.8.0 (2026-07-11)
### Features
- Surface public-ws auth failure via auth_failed state ([`11a1d90`](https://github.com/uilibs/uiprotect/commit/11a1d90a5d809cb0cded72fe6d1f2230f3383330))
## v15.7.1 (2026-07-10)
### Bug fixes
- Flush stale active events on events-ws reconnect ([`6b1388d`](https://github.com/uilibs/uiprotect/commit/6b1388dd183098bbe0250adfef1cab34ad94f165))
## v15.7.0 (2026-07-10)
### Features
- Resolve nvr mac via public→private→console fallback ([`8800440`](https://github.com/uilibs/uiprotect/commit/88004402a4bb86d5acccaa707f75aed9156c038b))
## v15.6.0 (2026-07-10)
### Features
- Expose rtsps quality ↔ channel-id mapping publicly ([`0a175b8`](https://github.com/uilibs/uiprotect/commit/0a175b860909dac2907848848adcdc21dfac4daa))
## v15.5.0 (2026-07-09)
### Features
- Expose derived detection/config properties on publiccamera ([`245407b`](https://github.com/uilibs/uiprotect/commit/245407ba755a6b07b8bc486a716cf2a364189128))
- Expose derived detection/config properties on publiccamera ([`245407b`](https://github.com/uilibs/uiprotect/commit/245407ba755a6b07b8bc486a716cf2a364189128))
## v15.4.4 (2026-07-09)
### Documentation
- Use absolute agents.md link so strict mkdocs build passes ([`be0cfec`](https://github.com/uilibs/uiprotect/commit/be0cfecd6fadf4e52e61441f1e89d93be7239295))
### Bug fixes
- Validate public numeric setters before the request ([`c3c3858`](https://github.com/uilibs/uiprotect/commit/c3c3858197a3f602beaac1a4d69417dd07fefebe))
- Validate public numeric setters before the request ([`c3c3858`](https://github.com/uilibs/uiprotect/commit/c3c3858197a3f602beaac1a4d69417dd07fefebe))
## v15.4.3 (2026-07-07)
### Bug fixes
- Tolerate unknown smart detect types on public models ([`eb3a1ac`](https://github.com/uilibs/uiprotect/commit/eb3a1ac3974b957bb5dfdd452ded3e7b2772c2e4))
## v15.4.2 (2026-07-05)
### Bug fixes
- Emit connected on connect and keepalive public ws ([`f4f96da`](https://github.com/uilibs/uiprotect/commit/f4f96da661097666ca4ff38f71583bdada946372))
## v15.4.1 (2026-07-03)
### Bug fixes
- Bound and surface rtsps stream priming failures in update_public ([`b577028`](https://github.com/uilibs/uiprotect/commit/b577028fc90123833cf673cf7c0d79b49f75163e))
- Bound and surface rtsps stream priming failures in update_public ([`b577028`](https://github.com/uilibs/uiprotect/commit/b577028fc90123833cf673cf7c0d79b49f75163e))
## v15.4.0 (2026-07-02)
### Features
- Unify public device models under publicdevicemodel ([`1e54765`](https://github.com/uilibs/uiprotect/commit/1e547656a575e43a0ecb43ad4e860642f1171e8a))
## v15.3.0 (2026-06-26)
### Features
- Derive remaining smart-audio detection booleans on publiccamera ([`6d5a048`](https://github.com/uilibs/uiprotect/commit/6d5a04816e121b8d1700d8ed8cf2cfc2425cec2b))
## v15.2.0 (2026-06-26)
### Features
- Emit publiccamera devices-ws update on detection transition ([`f342e3d`](https://github.com/uilibs/uiprotect/commit/f342e3d10ae786b8aab80def4b89e3819ef0d594))
## v15.1.0 (2026-06-25)
### Features
- Derive public-events-ws detection state on publiccamera ([`f6c1f0c`](https://github.com/uilibs/uiprotect/commit/f6c1f0c9ea46b11bca1b8a7b58d537227dadb604))
## v15.0.0 (2026-06-25)
### Features
- Expose linkstation.last_event as datetime ([`e941549`](https://github.com/uilibs/uiprotect/commit/e94154935ef0e5e02cb36f6e68029282ca3a4df6))
## v14.3.0 (2026-06-25)
### Features
- Add viewer.set_liveview_public and set_name_public ([`c908bb8`](https://github.com/uilibs/uiprotect/commit/c908bb826efd9dc7c0533477011b527627da9c06))
## v14.2.0 (2026-06-25)
### Features
- Add public-api sensor setters (patch /v1/sensors/{id}) ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046))
- Extend update_sensor_public with armed/glass-break fields ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046))
- Add public-api sensor setters on sensor ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046))
- Expose public-api sensor setters in the cli ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046))
- Add granular single-positional public sensor setters ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046))
## v14.1.1 (2026-06-24)
### Bug fixes
- Add missing irledmode value customfilteronly ([`8b0ee1a`](https://github.com/uilibs/uiprotect/commit/8b0ee1ac437d96e41b0f0eb1d5d374ed8b0a402c))
### Refactoring
- Route private viewer liveview set through public api ([`125034f`](https://github.com/uilibs/uiprotect/commit/125034f9c664c8a74c15b002240a064d852d16ab))
- Migrate chime cli to public-api setters ([`c2a5517`](https://github.com/uilibs/uiprotect/commit/c2a5517e77c34e125b038c047fd951870222b380))
- Migrate chime cli to public-api setters ([`c2a5517`](https://github.com/uilibs/uiprotect/commit/c2a5517e77c34e125b038c047fd951870222b380))
- Delegate per-camera chime repeat-times to model setter ([`c2a5517`](https://github.com/uilibs/uiprotect/commit/c2a5517e77c34e125b038c047fd951870222b380))
## v14.1.0 (2026-06-21)
### Features
- Add chime.set_repeat_times_for_camera_public ([`dfcd552`](https://github.com/uilibs/uiprotect/commit/dfcd552cf4c9bbead6393423faa2f57b3d19078a))
## v14.0.0 (2026-06-21)
### Features
- Remove standalone doorlock device support ([`339ac55`](https://github.com/uilibs/uiprotect/commit/339ac5572c07476491cdabcc4b54dfac8fcfba22))
## v13.5.1 (2026-06-20)
### Bug fixes
- Re-auth and retry once on private 401 ([`93201f4`](https://github.com/uilibs/uiprotect/commit/93201f4a45ac5cebc2c597b6b09035f91e892481))
- Re-auth and retry once on private 401 ([`93201f4`](https://github.com/uilibs/uiprotect/commit/93201f4a45ac5cebc2c597b6b09035f91e892481))
## v13.5.0 (2026-06-20)
### Features
- Decouple public events path onto a typed public event model ([`018c05e`](https://github.com/uilibs/uiprotect/commit/018c05e79cf8445282773938e4ba95d79e21bea7))
- Add strongly-typed public event model and metadata enums ([`018c05e`](https://github.com/uilibs/uiprotect/commit/018c05e79cf8445282773938e4ba95d79e21bea7))
### Documentation
- Refresh readme for the public-api direction and clean up ([`a9bc45d`](https://github.com/uilibs/uiprotect/commit/a9bc45d17e9a45407535f9a6794ca28d4415ea84))
- Reorganize and de-duplicate the readme ([`a9bc45d`](https://github.com/uilibs/uiprotect/commit/a9bc45d17e9a45407535f9a6794ca28d4415ea84))
## v13.4.0 (2026-06-19)
### Features
- Add device type/guid, sensor feature flags, and event alarm_type ([`7a60cf7`](https://github.com/uilibs/uiprotect/commit/7a60cf7e06fd952883f9be43d1211470ab4cd866))
- Add device type/guid, sensor feature flags, and event alarm_type ([`7a60cf7`](https://github.com/uilibs/uiprotect/commit/7a60cf7e06fd952883f9be43d1211470ab4cd866))
### Documentation
- Add contributing section (no new private-api features, ai policy) ([`593af0b`](https://github.com/uilibs/uiprotect/commit/593af0b75163c57297f206ad0d7863bb43ca20df))
### Refactoring
- Decorate uniform alarm-hub public methods ([`3cbc521`](https://github.com/uilibs/uiprotect/commit/3cbc52197e7963e9fee731050b47199572233693))
## v13.3.0 (2026-06-18)
### Features
- Add camerachannel.rtsps_quality mapping ([`958ebdd`](https://github.com/uilibs/uiprotect/commit/958ebdd75a84c43871c05180d211765d8454084a))
- Add camerachannel.rtsps_quality mapping ([`958ebdd`](https://github.com/uilibs/uiprotect/commit/958ebdd75a84c43871c05180d211765d8454084a))
## v13.2.0 (2026-06-18)
### Features
- Add publicdevicemodel base for public mac/state ([`2e2e5ff`](https://github.com/uilibs/uiprotect/commit/2e2e5ffc8ab68a3f0a194a7000096e25e48a7cba))
### Refactoring
- Add publicdevicemodel base for public mac/state fields ([`2e2e5ff`](https://github.com/uilibs/uiprotect/commit/2e2e5ffc8ab68a3f0a194a7000096e25e48a7cba))
## v13.1.2 (2026-06-14)
### Bug fixes
- Deliver born-closed public events (lone update, no add) ([`a3a503a`](https://github.com/uilibs/uiprotect/commit/a3a503a5003b84a37e5a20b6850e36aa8a94c146))
## v13.1.1 (2026-06-12)
### Bug fixes
- Relax av floor to >=17.0.1 for home assistant compatibility ([`c4ed9e1`](https://github.com/uilibs/uiprotect/commit/c4ed9e1fb44841e5c9c1468becb4133ccd91b630))
## v13.1.0 (2026-06-12)
### Features
- Pace public path with in-house rate limiter (alt to #984) ([`45c721d`](https://github.com/uilibs/uiprotect/commit/45c721d5c636966f30bac48faf8bff858fc144c1))
- Pace public path with in-house rate limiter seeded from ratelimit-policy ([`45c721d`](https://github.com/uilibs/uiprotect/commit/45c721d5c636966f30bac48faf8bff858fc144c1))
### Refactoring
- Declarative decorators for uniform public-api endpoints ([`755cd5c`](https://github.com/uilibs/uiprotect/commit/755cd5c67cb2489ed29a2d70b57cd73e7283d84d))
- Declarative decorators for uniform public-api endpoints ([`755cd5c`](https://github.com/uilibs/uiprotect/commit/755cd5c67cb2489ed29a2d70b57cd73e7283d84d))
## v13.0.0 (2026-06-10)
### Features
- Rename metainfo.applicationversion to application_version ([`e682d4d`](https://github.com/uilibs/uiprotect/commit/e682d4d82f0b9468a3711cce8a73a9e459f09f64))
## v12.1.0 (2026-06-09)
### Features
- Resolve console mac for public-only clients via /api/system ([`bb62fff`](https://github.com/uilibs/uiprotect/commit/bb62fffce4be9327d8fbb1dc0d580833d7e9872d))
- Resolve console mac for public-only clients via /api/system ([`bb62fff`](https://github.com/uilibs/uiprotect/commit/bb62fffce4be9327d8fbb1dc0d580833d7e9872d))
## v12.0.0 (2026-06-08)
### Features
- Own rtsps stream lifecycle on publiccamera ([`e05fff5`](https://github.com/uilibs/uiprotect/commit/e05fff58b9a0e0959438708f03377f0c82fd37d9))
- Own rtsps stream lifecycle on publiccamera ([`e05fff5`](https://github.com/uilibs/uiprotect/commit/e05fff58b9a0e0959438708f03377f0c82fd37d9))
## v11.10.0 (2026-06-08)
### Bug fixes
- Keep the public rtsps cache never-empty via in-place refresh ([`c40b2d4`](https://github.com/uilibs/uiprotect/commit/c40b2d410d7f01417322cfa8b4c935af110ce87d))
### Features
- Refresh the public rtsps cache in place instead of emptying it ([`c40b2d4`](https://github.com/uilibs/uiprotect/commit/c40b2d410d7f01417322cfa8b4c935af110ce87d))
## v11.9.0 (2026-06-08)
### Features
- Cache rtsps streams on the public bootstrap ([`bee1b85`](https://github.com/uilibs/uiprotect/commit/bee1b858526e1289d29fd7d2e64fcce326500d80))
- Cache rtsps streams on the public bootstrap ([`bee1b85`](https://github.com/uilibs/uiprotect/commit/bee1b858526e1289d29fd7d2e64fcce326500d80))
## v11.8.0 (2026-06-07)
### Features
- Public-stream helpers for a thin ha integration ([`795df19`](https://github.com/uilibs/uiprotect/commit/795df194b44289f781ce77781af2832ecda56ed1))
- Add public-stream helpers for thin ha integration ([`795df19`](https://github.com/uilibs/uiprotect/commit/795df194b44289f781ce77781af2832ecda56ed1))
### Refactoring
- Strip exact ?enablesrtp suffix in get_stream_url ([`795df19`](https://github.com/uilibs/uiprotect/commit/795df194b44289f781ce77781af2832ecda56ed1))
## v11.7.1 (2026-06-07)
### Bug fixes
- Default lcdmessage.text so missing text no longer raises ([`2aa7bbd`](https://github.com/uilibs/uiprotect/commit/2aa7bbd5cd6916bb65a96bacf2277ba7e4f7b892))
## v11.7.0 (2026-06-07)
### Features
- Raise typed armedmodeerror when alarm is armed ([`5f0336c`](https://github.com/uilibs/uiprotect/commit/5f0336ccc7f54a28836f858479af34a70b9e3a3e))
- Raise typed armedmodeerror when an operation is rejected because the alarm is armed ([`5f0336c`](https://github.com/uilibs/uiprotect/commit/5f0336ccc7f54a28836f858479af34a70b9e3a3e))
### Testing
- Skip cli-extra modules when pillow/typer are absent ([`ccc5360`](https://github.com/uilibs/uiprotect/commit/ccc5360428912b49bd2ec1b44d235a9e15811488))
## v11.6.0 (2026-06-06)
### Features
- Support public-only (api-key-only) client mode ([`72c4a7a`](https://github.com/uilibs/uiprotect/commit/72c4a7ae84dfadb746112b1f1dcbddd232273c23))
- Support public-only (api-key-only) client mode ([`72c4a7a`](https://github.com/uilibs/uiprotect/commit/72c4a7ae84dfadb746112b1f1dcbddd232273c23))
### Testing
- Clean up pytest hygiene warnings on python 3.14 ([`87fedca`](https://github.com/uilibs/uiprotect/commit/87fedcaff2172530e732e3b492f61c6f0009134b))
- Silence pytest hygiene warnings on 3.14 ([`87fedca`](https://github.com/uilibs/uiprotect/commit/87fedcaff2172530e732e3b492f61c6f0009134b))
- Drop benchmark scaffolding from ws subscription tests ([`87fedca`](https://github.com/uilibs/uiprotect/commit/87fedcaff2172530e732e3b492f61c6f0009134b))
### Build system
- Re-raise av floor to >=17.0.1 ([`1cd3af1`](https://github.com/uilibs/uiprotect/commit/1cd3af13d6cbe7caa4b3a20bb064c0e0db83ad82))
### Documentation
- Require 100% patch coverage for every pr ([`a14fb24`](https://github.com/uilibs/uiprotect/commit/a14fb24f5310901c331a1908858d68473424f458))
- Require 100% patch coverage for every pr ([`a14fb24`](https://github.com/uilibs/uiprotect/commit/a14fb24f5310901c331a1908858d68473424f458))
- Audit and fix doc set against current codebase ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500))
- Audit and fix doc set against current codebase ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500))
- Clarify public-api cli group wording in usage.md ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500))
- Fix create-api-key api class, websocket auth note, and tls defaults ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500))
## v11.5.0 (2026-06-05)
### Features
- Add typed subscribe_devices() device-state lifecycle ([`4bd6ef7`](https://github.com/uilibs/uiprotect/commit/4bd6ef786486cde9a70e54fa82cb814d51914a2a))
- Add typed subscribe_devices() device-state lifecycle ([`4bd6ef7`](https://github.com/uilibs/uiprotect/commit/4bd6ef786486cde9a70e54fa82cb814d51914a2a))
### Build system
- Remove dead sphinx docs stack ([`b73d07b`](https://github.com/uilibs/uiprotect/commit/b73d07bb85c39293b0f0f94c1cd32f4a05957b90))
- Remove dead sphinx stack, drop starlette/uvicorn transitives ([`b73d07b`](https://github.com/uilibs/uiprotect/commit/b73d07bb85c39293b0f0f94c1cd32f4a05957b90))
## v11.4.0 (2026-06-05)
### Features
- Back public camera/light/sensor/chime with dedicated models ([`f3076fe`](https://github.com/uilibs/uiprotect/commit/f3076fec22fa1b221456bfca618e45270834a16f))
## v11.3.1 (2026-06-04)
### Bug fixes
- Skip unknown event types on the live stream ([`9bf519b`](https://github.com/uilibs/uiprotect/commit/9bf519be2a6cfd19bd0e94e6f95ba129ec393c9a))
## v11.3.0 (2026-06-04)
### Features
- Move cli-only dependencies (rich, dateparser, pillow) into the cli extra ([`297a63e`](https://github.com/uilibs/uiprotect/commit/297a63e4b1163242a5d7622f4bb00a0e48869b65))
## v11.2.0 (2026-06-04)
### Features
- Make typer an optional cli extra ([`1bb4ddd`](https://github.com/uilibs/uiprotect/commit/1bb4dddf28a21214fee7b70303576a9dcc414003))
## v11.1.0 (2026-06-04)
### Bug fixes
- Tolerate unknown videomode values from newer firmware ([`46a030a`](https://github.com/uilibs/uiprotect/commit/46a030a1fdd303bb63f66b980eeaccb18c734dcd))
- Make alarm hub battery/cover fields optional ([`94b4b21`](https://github.com/uilibs/uiprotect/commit/94b4b210355578574aa757a2dacac630fe2ba0c9))
### Features
- Single-source protectevent + subscribe_events ([`47efd08`](https://github.com/uilibs/uiprotect/commit/47efd08d6e725dfeb5b1b6a479cdce467a8eb7fc))
## v11.0.2 (2026-06-04)
### Bug fixes
- Relax pyjwt and av floors to unblock the ha lib bump ([`ff5b676`](https://github.com/uilibs/uiprotect/commit/ff5b67674cc1548733281a92c5152a28545e9763))
## v11.0.1 (2026-06-03)
### Bug fixes
- Tolerate unknown osdoverlaylocation values from newer firmware ([`66bf72b`](https://github.com/uilibs/uiprotect/commit/66bf72b2c40bc9c804727347d31b3bfd4acd1871))
## v11.0.0 (2026-06-03)
### Features
- Type siren.state as devicestate and add sirenconnectiontype enum ([`04e8976`](https://github.com/uilibs/uiprotect/commit/04e89765d1b56dc4364cb7d158751ad681ae3125))
## v10.18.1 (2026-06-03)
### Bug fixes
- Sanitize camera names in backup paths to block traversal ([`741adcd`](https://github.com/uilibs/uiprotect/commit/741adcde0be341688827b96bd2c911688685358a))
## v10.18.0 (2026-06-03)
### Features
- Add public api convenience setters on light ([`3484cbb`](https://github.com/uilibs/uiprotect/commit/3484cbb6d8d918ad422dbea89c8a7c0369c93e9a))
## v10.17.0 (2026-06-02)
### Features
- Type alarm hub status fields as enums ([`cc75a00`](https://github.com/uilibs/uiprotect/commit/cc75a0004ab937c099b1e07a8061fab319f0ac5f))
### Documentation
- Fix typo in get_camera_video ([`8f4cfd5`](https://github.com/uilibs/uiprotect/commit/8f4cfd5f10b22a0b6e532f2709e082de40717a84))
## v10.16.0 (2026-06-02)
### Features
- Add typed alarm hub accessors to linkstation ([`5754734`](https://github.com/uilibs/uiprotect/commit/57547349d445804266eb4cad15c1fb14f2d5ca29))
## v10.15.0 (2026-06-02)
### Features
- Add channelquality enum for rtsps helpers ([`2187b12`](https://github.com/uilibs/uiprotect/commit/2187b121caa7c86419ae181abbed9272ca50b4ee))
## v10.14.0 (2026-06-02)
### Features
- Add assetfiletype enum for publicfile.type and files api ([`7560c5f`](https://github.com/uilibs/uiprotect/commit/7560c5faec92897bca20f06680ad4148e3d4e1c0))
## v10.13.0 (2026-06-02)
### Features
- Tighten relay public-api field types to spec enums ([`bf589f8`](https://github.com/uilibs/uiprotect/commit/bf589f8f287916fae3882d3481d9898deaab40f9))
## v10.12.0 (2026-06-02)
### Features
- Add ulpuserstatus enum for publiculpuser.status ([`377d728`](https://github.com/uilibs/uiprotect/commit/377d72838e9919fdfccde9099a35ae2517733821))
## v10.11.0 (2026-06-02)
### Features
- Add public users, ulp-users, files, disable-mic endpoints ([`9e6107f`](https://github.com/uilibs/uiprotect/commit/9e6107f06e19d047078ccc08e235ef464fb04b1e))
## v10.10.2 (2026-06-02)
### Bug fixes
- Refuse to retry past tls failure with credentials ([`4c7550b`](https://github.com/uilibs/uiprotect/commit/4c7550b0df880b352c0cac854f4e3a6d3c4f8a5b))
### Refactoring
- Correct list_from_unifi_list dict value annotation ([`ce1f140`](https://github.com/uilibs/uiprotect/commit/ce1f140f49e8bc54b3fc5a5ef5cfb17be6d801c3))
## v10.10.1 (2026-06-02)
### Bug fixes
- Stripping of all instances of -e in thumbnail and heatmap generations ([`c4b3fd2`](https://github.com/uilibs/uiprotect/commit/c4b3fd28aa51ba9297f8889d6c6c63f10b8c8d33))
## v10.10.0 (2026-06-02)
### Features
- Add public api bridges and viewers endpoint groups ([`1a0ed11`](https://github.com/uilibs/uiprotect/commit/1a0ed11cc296058a0b2f3314d5df0e26cdea97bf))
## v10.9.0 (2026-06-02)
### Features
- Add link-stations and alarm-hubs public api endpoints ([`a55887f`](https://github.com/uilibs/uiprotect/commit/a55887ff100e780405d9685ca240675b04d31c4f))
## v10.8.0 (2026-05-29)
### Features
- Add public api liveviews endpoint group (list/get/update/create) ([`8153836`](https://github.com/uilibs/uiprotect/commit/8153836efa6dc1c4b7a8360ef923a3ef6cbe0a9e))
## v10.7.0 (2026-05-28)
### Features
- Add public api speakers endpoint group (list/get/update/test-sound) ([`a5f6068`](https://github.com/uilibs/uiprotect/commit/a5f606811dd37b2fe21538d86f158b39f3ca24b0))
## v10.6.0 (2026-05-26)
### Features
- Add public api fobs endpoint group (list/get/update) ([`514d87e`](https://github.com/uilibs/uiprotect/commit/514d87ec0114f9ac148c6108d13a8cdfd0fc56f8))
### Documentation
- Add script and agent instructions to fetch integration openapi spec ([`fe16ee0`](https://github.com/uilibs/uiprotect/commit/fe16ee02e8a48d001ee0374d1ac42ae1c15da2cb))
- Document api migration strategy in agents.md ([`fe16ee0`](https://github.com/uilibs/uiprotect/commit/fe16ee02e8a48d001ee0374d1ac42ae1c15da2cb))
- Add script and agent instructions to fetch integration openapi spec ([`fe16ee0`](https://github.com/uilibs/uiprotect/commit/fe16ee02e8a48d001ee0374d1ac42ae1c15da2cb))
### Testing
- Port session and video tests off sync io ([`a7deac1`](https://github.com/uilibs/uiprotect/commit/a7deac15360f949f5a51cedf179810d464bc86b3))
## v10.5.1 (2026-05-22)
### Bug fixes
- Catch blocking io in async tests with blockbuster ([`afbdbb7`](https://github.com/uilibs/uiprotect/commit/afbdbb7900092cc93eb4370b51833d3166c99182))
### Documentation
- Document api migration strategy in agents.md ([`d5df437`](https://github.com/uilibs/uiprotect/commit/d5df4378b81b122262ef032d4b80278012f8bc7d))
- Add security.md with private vulnerability reporting policy ([`55a61c5`](https://github.com/uilibs/uiprotect/commit/55a61c5f406f090789885cc0f52f7a6f7f2e7ec1))
## v10.5.0 (2026-05-18)
### Features
- Add public api methods for camera configuration ([`d7e9fb6`](https://github.com/uilibs/uiprotect/commit/d7e9fb65aa074c9917eeff12b93ed70a4fdf930d))
### Documentation
- Add agents.md orientation file for llm contributors ([`c0172a5`](https://github.com/uilibs/uiprotect/commit/c0172a56a38de23c4702cbfef3dc502048c1a0b3))
## v10.4.1 (2026-04-26)
### Bug fixes
- Always send json body; add sirenduration enum and turn_off_at ([`d89d8ac`](https://github.com/uilibs/uiprotect/commit/d89d8ac11cd1f968de532dd310aa6753e78140cf))
## v10.4.0 (2026-04-25)
### Features
- Add publicnvr, nvrarmmodestatus and relayoutputstate enums ([`2c92650`](https://github.com/uilibs/uiprotect/commit/2c926509342374092f1bb436e9c5973b56a0efef))
## v10.3.1 (2026-04-23)
### Bug fixes
- Add missing public event websocket types ([`8460c84`](https://github.com/uilibs/uiprotect/commit/8460c8453489e375a6dfa7230f8dafb0a03dae6a))
## v10.3.0 (2026-04-23)
### Features
- Add some public integration api endpoints ([`687d4bc`](https://github.com/uilibs/uiprotect/commit/687d4bcebf9bae22ba807ba1c7b10a9b506dab81))
## v10.2.6 (2026-04-11)
### Bug fixes
- Add missing max_event_history_in_state_machine import in bootstrap.py ([`63dce4c`](https://github.com/uilibs/uiprotect/commit/63dce4c8ae6d1e2e0d319993cd1097fb2a961603))
## v10.2.5 (2026-04-10)
### Bug fixes
- Keep sensor on when concurrent smart detect events overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Keep sensor on when concurrent smart detect events overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Prefer active event over ended when no tracking exists ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Keep sensor on when concurrent smart detect events overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Overlap edge case ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Bound bootstrap.events to prevent unbounded memory growth ([`386f806`](https://github.com/uilibs/uiprotect/commit/386f806944d23dfc508e6f703937168cd704265c))
### Testing
- Add coverage for current_id=none with active event branch ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Cover stale event cleanup in active index ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Decouple processing-order assertion from timestamp order ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
- Assert sensor state during concurrent smart-detect overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
### Refactoring
- Extract reset_smart_detect fixture to reduce duplication ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02))
## v10.2.4 (2026-04-10)
### Performance improvements
- Add codspeed benchmarks for websocket packet processing ([`65a97df`](https://github.com/uilibs/uiprotect/commit/65a97df05d5b558d19019f8e79cfa794a6a46f32))
## v10.2.3 (2026-03-24)
### Bug fixes
- Handle unknown enum values and keyerror in ws processing ([`c9dadf7`](https://github.com/uilibs/uiprotect/commit/c9dadf75f081281e4d547873af7d957ce3ae5fdc))
- Handle unknown enum values and keyerror in ws processing ([`c9dadf7`](https://github.com/uilibs/uiprotect/commit/c9dadf75f081281e4d547873af7d957ce3ae5fdc))
- Improve resilience in ws error handling by checking for pending refresh tasks ([`c9dadf7`](https://github.com/uilibs/uiprotect/commit/c9dadf75f081281e4d547873af7d957ce3ae5fdc))
- Resolve deadlock in talkbackstream.stop() during concurrent playback ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973))
- Resolve deadlock in talkbackstream.stop() during concurrent playback ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973))
- Prevent start() from clearing a pending stop signal in talkbackstream ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973))
- Coverage, add restart test (not that we do, but latent bug if we start doing that) ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973))
## v10.2.2 (2026-02-26)
### Bug fixes
- Handle duplicate camelcase/snake_case keys from protect 7.x ([`c6d5532`](https://github.com/uilibs/uiprotect/commit/c6d553292e0cdbd7cf03f8950ace108fb88252ed))
## v10.2.1 (2026-02-22)
### Bug fixes
- Protect7 missing modelkey ([`0aeb5d3`](https://github.com/uilibs/uiprotect/commit/0aeb5d39185894077f573a9ad27aebbf9d7d83bc))
## v10.2.0 (2026-02-22)
### Features
- Record public api websockets during sample data generation ([`35c30d2`](https://github.com/uilibs/uiprotect/commit/35c30d2f7760ac880bf48a36c44f13b188ae2f62))
## v10.1.0 (2026-01-31)
### Features
- Add retry with exponential backoff ([`14e3617`](https://github.com/uilibs/uiprotect/commit/14e36178d322557858695b9f0e5918c77b27f76d))
## v10.0.1 (2026-01-21)
### Bug fixes
- Update is_package detection logic for g6 entry ([`5ba9026`](https://github.com/uilibs/uiprotect/commit/5ba90263eb64354696014a3095973a0a514058a8))
## v10.0.0 (2026-01-20)
### Features
- Migrate ptz control to public api ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e))
- Migrate ptz control to public api ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e))
- Add ptz preset and patrol retrieval tests ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e))
### Testing
- Add activepatrolslot field to sample data files ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e))
### Bug fixes
- Address pr review comments ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e))
- Restore activepatrolslot in sample_aiport.json for consistency ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e))
## v9.0.0 (2026-01-20)
### Chores
- Drop python 3.10 support ([`0836912`](https://github.com/uilibs/uiprotect/commit/0836912b20ac3677484bbd4d99ae5123803131cb))
## v8.1.1 (2026-01-12)
### Bug fixes
- Re-release ([`dbabddb`](https://github.com/uilibs/uiprotect/commit/dbabddbf61ab68e1fa7ed5e0fd6e7d5a406090a5))
## v8.1.0 (2026-01-12)
### Features
- Add public api support for chime ring volume per camera ([`87782fb`](https://github.com/uilibs/uiprotect/commit/87782fbe688e970cb0e1e1e448683417fb5f57c4))
## v8.0.0 (2026-01-06)
### Features
- Replace ffmpeg with pyav for talkback streaming ([`9998db0`](https://github.com/uilibs/uiprotect/commit/9998db00b0a0f541befe009aee84ad5e92dfb6b9))
## v7.33.4 (2026-01-05)
### Bug fixes
- Update schemas for latest protect version ([`9fa5afa`](https://github.com/uilibs/uiprotect/commit/9fa5afae647ec73206255342dabf2cd08d1285e5))
## v7.33.3 (2025-12-21)
### Bug fixes
- Handle ipv6 addresses in ipv4-typed fields ([`98a39b9`](https://github.com/uilibs/uiprotect/commit/98a39b9aeb94d4d976e9e8b568a214599815365f))
## v7.33.2 (2025-12-05)
### Bug fixes
- Allow dns hostnames with override_connection_host (#679) ([`4f84e69`](https://github.com/uilibs/uiprotect/commit/4f84e69618bfe38e2312dfe558c0649f170928b1))
### Testing
- Add test coverage for _update_bootstrap_soon method ([`cf11161`](https://github.com/uilibs/uiprotect/commit/cf111612fb18409654cda86f8dd4feea80f414b8))
## v7.33.1 (2025-12-01)
### Bug fixes
- Remove deprecated python 3.16 and pydantic v3 patterns ([`b09dab7`](https://github.com/uilibs/uiprotect/commit/b09dab7139e404eaeb8a555da56ae3e0129f1d73))
### Testing
- Add coverage for json deserialization in api_request ([`00eab75`](https://github.com/uilibs/uiprotect/commit/00eab75378fb70f1d9dacc152afb6ec2b06fcaec))
- Improve utils.py test coverage from 45% to 94% ([`2aff97f`](https://github.com/uilibs/uiprotect/commit/2aff97f7fee7cc6816f024b26fab33ea8709b358))
- Add unit tests for _auth_websocket method ([`d1f3841`](https://github.com/uilibs/uiprotect/commit/d1f3841d8838e770d22700bfac273b34fc7f09c4))
## v7.33.0 (2025-11-30)
### Bug fixes
- Make rx_bytes and tx_bytes optional in camerastats ([`8d20059`](https://github.com/uilibs/uiprotect/commit/8d20059c1adc83685c9ccd18e524b1480be7e381))
### Features
- Add ptz public api methods ([`a4e2013`](https://github.com/uilibs/uiprotect/commit/a4e2013124dde43c4aea1975362e01c28013f8f2))
## v7.32.0 (2025-11-29)
### Features
- Add ssl verification prompt with default to disabled ([`f9a07f7`](https://github.com/uilibs/uiprotect/commit/f9a07f707aea417fc7a000fcec6a5fe560964034))
## v7.31.0 (2025-11-29)
### Bug fixes
- Pydantic serialization warnings for camerazone color ([`5869b88`](https://github.com/uilibs/uiprotect/commit/5869b88c549e5f98d31eaf799ae006f30f04bd5d))
### Features
- Add update_light_public() method and fix lux_sensitivity handling ([`ef4e378`](https://github.com/uilibs/uiprotect/commit/ef4e3789fcfdb797ebe0e9b2c437cb4cfb977576))
## v7.30.1 (2025-11-29)
### Bug fixes
- Camera speaker volume ([`ea66817`](https://github.com/uilibs/uiprotect/commit/ea66817c5e05f3acc2da74544f8c431e8e3b28f6))
### Documentation
- Update readme with current features and requirements ([`7301124`](https://github.com/uilibs/uiprotect/commit/73011248abfa7b7febd1a66f4c07a9bd2818d77d))
## v7.30.0 (2025-11-28)
### Features
- Improve devcontainer configuration ([`f32aba2`](https://github.com/uilibs/uiprotect/commit/f32aba29c790a85b8d76686cf86b90dc92b20a94))
## v7.29.0 (2025-11-26)
### Features
- Implement session management with clear_session and clear_all_sessions methods ([`20dbf23`](https://github.com/uilibs/uiprotect/commit/20dbf2336052adf442cf508f5c96bd5a9eff2fb4))
- Enhance rtsp url handling for stacked nvr scenarios ([`80e5370`](https://github.com/uilibs/uiprotect/commit/80e5370e79ea6cd68d0ca0a3b1af9279b0789f63))
## v7.28.0 (2025-11-24)
### Features
- Add support for ufp 6.x detected license plate and face name properties ([`811e2e2`](https://github.com/uilibs/uiprotect/commit/811e2e2b2019a950c8276e21db10f8c209d42d43))
## v7.27.0 (2025-11-24)
### Features
- Update ledsettings for protect 6.x compatibility and add new fields ([`45ab777`](https://github.com/uilibs/uiprotect/commit/45ab77734ac674f4f7ca32441fad118e677ba52d))
## v7.26.0 (2025-11-21)
### Features
- Add ipv6 support for host formatting and connection urls ([`958f139`](https://github.com/uilibs/uiprotect/commit/958f139b0a3becfc8f30cf4509af0aa28cb46696))
## v7.25.0 (2025-11-21)
### Features
- Handle optional doorlocks field in bootstrap class ([`bb589b2`](https://github.com/uilibs/uiprotect/commit/bb589b20132bfbb54c2a053053ff2bf665a7823e))
## v7.24.0 (2025-11-21)
### Features
- Extract and set x-csrf-token during authentication ([`57d878a`](https://github.com/uilibs/uiprotect/commit/57d878a908728ae2d5b5a461d2e749939e8e671d))
## v7.23.0 (2025-10-16)
### Features
- Add events and devices websocket support with subscription handling ([`bf71cb4`](https://github.com/uilibs/uiprotect/commit/bf71cb411e0c90ed008d17e2003f66312ad652d6))
## v7.22.0 (2025-10-14)
### Features
- Remove last uses of pydantic.v1 ([`1d2004b`](https://github.com/uilibs/uiprotect/commit/1d2004bcb5b2c4c78c175c28b1226e797d50ebb8))
## v7.21.1 (2025-08-14)
### Bug fixes
- Remove typer dependency to resolve home assistant version conflicts ([`3619dd2`](https://github.com/uilibs/uiprotect/commit/3619dd2c203eed36ee5c2e77b1b6f20fb35b6380))
## v7.21.0 (2025-08-06)
### Features
- Implement get public api methods for nvr, lights, cameras, and chimes ([`94cc56a`](https://github.com/uilibs/uiprotect/commit/94cc56a9f5bd1feba3a3cf690008966e53d32083))
## v7.20.0 (2025-07-27)
### Features
- Add publicapi rtsps streams management for cameras with create, get, and delete functionalities ([`a53f4b6`](https://github.com/uilibs/uiprotect/commit/a53f4b6e787551aed522e3969914f0a70aa70c96))
## v7.19.0 (2025-07-22)
### Features
- Add methods to set and check api key for nvr ([`166751f`](https://github.com/uilibs/uiprotect/commit/166751fbf403def10763df2294006a96fe49e7db))
## v7.18.1 (2025-07-21)
### Bug fixes
- Ensure highquality parameter is a string in camera pub api snapshot requests ([`57db9e0`](https://github.com/uilibs/uiprotect/commit/57db9e0bdf65b161fcb9d4d7aaf39f8bd8fd4c77))
## v7.18.0 (2025-07-21)
### Features
- Update public api camera snapshot method to accept highquality parameter ([`3df7b70`](https://github.com/uilibs/uiprotect/commit/3df7b7067e8555a405202e49bfe75982c2d9bc71))
## v7.17.0 (2025-07-21)
### Features
- Add support for full hd snapshot feature flag ([`3059e84`](https://github.com/uilibs/uiprotect/commit/3059e84a6d5d6c034b1fb32a897d954aebf661cf))
## v7.16.0 (2025-07-20)
### Features
- Add public api camera snapshot ([`baba0e3`](https://github.com/uilibs/uiprotect/commit/baba0e3ed72dde13fe7113960907b54b87eafcc9))
- Add public api camera snapshot retrieval and related tests ([`baba0e3`](https://github.com/uilibs/uiprotect/commit/baba0e3ed72dde13fe7113960907b54b87eafcc9))
## v7.15.1 (2025-07-20)
### Bug fixes
- Update create_api_key to use 'self' instead of userid ([`755a1b7`](https://github.com/uilibs/uiprotect/commit/755a1b7273d4a97434564f88dbb2502aef61c173))
- Update create_api_key to use 'self' instead of userid and remove related tests ([`755a1b7`](https://github.com/uilibs/uiprotect/commit/755a1b7273d4a97434564f88dbb2502aef61c173))
## v7.15.0 (2025-07-19)
### Features
- Add public api session without cookie and update tests ([`89416ef`](https://github.com/uilibs/uiprotect/commit/89416ef8fe06048677254b95eb0913e6f0106161))
## v7.14.2 (2025-07-08)
### Bug fixes
- Add note about switching to the new public api ([`eb478d2`](https://github.com/uilibs/uiprotect/commit/eb478d2c981fe4c8faa3ad6fa18c47cbc9a7760e))
### Refactoring
- Removes release version retrieval return false for compatibility ([`e646655`](https://github.com/uilibs/uiprotect/commit/e6466558819c414395112fb1858bc5dd28a39c90))
## v7.14.1 (2025-06-21)
### Bug fixes
- Improve uuid handling in convert_unifi_data function ([`6f45e1d`](https://github.com/uilibs/uiprotect/commit/6f45e1d26641a51e43d541c28f3cf9abab355e98))
## v7.14.0 (2025-06-18)
### Features
- Add lpr_reflex to videomode enum ([`bc86bf1`](https://github.com/uilibs/uiprotect/commit/bc86bf15e4437c8ba2d29ed3e4bb7b49c8f09bba))
## v7.13.0 (2025-06-09)
### Features
- Add get_meta_info using public api ([`f30b50a`](https://github.com/uilibs/uiprotect/commit/f30b50a45bb551816bc2284d45d30611ee0d95e4))
## v7.12.0 (2025-06-08)
### Features
- Add optional api key to protectapiclient initialization ([`86e8b54`](https://github.com/uilibs/uiprotect/commit/86e8b54bb662aaf07587452c2a92bf3718756910))
## v7.11.0 (2025-05-31)
### Features
- Add adaptive mode to recordingmode enum ([`4b4155a`](https://github.com/uilibs/uiprotect/commit/4b4155aec2f74076669648194231136774f27198))
- Add adaptive mode to recordingmode enum and update sample_bootstrap.json ([`4b4155a`](https://github.com/uilibs/uiprotect/commit/4b4155aec2f74076669648194231136774f27198))
## v7.10.1 (2025-05-27)
### Bug fixes
- Update codec_to_encoder for correct codec format in talkbackstream ([`dfba227`](https://github.com/uilibs/uiprotect/commit/dfba227a2845c855c275234f5363f42d72eab4f5))
## v7.10.0 (2025-05-25)
### Features
- Add lpr_none_reflex to videomode enum ([`9ab42a0`](https://github.com/uilibs/uiprotect/commit/9ab42a0970c7ba7534996e142dc80a0f20210dc8))
### Unknown
### Bug fixes
- Initialize config dictionary in _read_auth_config method ([`f63e1b3`](https://github.com/uilibs/uiprotect/commit/f63e1b3d8357e82e3f88c07c7c17164140a5aa36))
## v7.9.2 (2025-05-24)
### Bug fixes
- Docker builds ([`f458c4a`](https://github.com/uilibs/uiprotect/commit/f458c4a35aeea78a601ff92719114449412f2a1d))
## v7.9.1 (2025-05-24)
### Bug fixes
- Docker builds ([`8b18b8f`](https://github.com/uilibs/uiprotect/commit/8b18b8fa479c1df833a4a36ad605abdd8cd69bc4))
## v7.9.0 (2025-05-24)
### Features
- Add create api key functionality and corresponding tests ([`3b74740`](https://github.com/uilibs/uiprotect/commit/3b74740cfe256f29970710426165cdb415a15a86))
## v7.8.0 (2025-05-22)
### Features
- Add option to force on flood light for light devices ([`c7c5331`](https://github.com/uilibs/uiprotect/commit/c7c533178722aa49e51fe1d2f8e35f63918f3007))
## v7.7.0 (2025-05-22)
### Features
- Support face detection ([`7f77238`](https://github.com/uilibs/uiprotect/commit/7f772388b7e2a280c2195ec71e6ab936d873abec))
## v7.6.1 (2025-05-22)
### Bug fixes
- Add error handling and logging to get_camera_video() ([`0080f5b`](https://github.com/uilibs/uiprotect/commit/0080f5b5bc6df4661dd4fea0ae2d70ff78099294))
## v7.6.0 (2025-05-07)
### Features
- Add codec mapping for audio encoding in talkbackstream ([`eae52d4`](https://github.com/uilibs/uiprotect/commit/eae52d464eff2a4a4011b760b547ce5ea666e917))
## v7.5.6 (2025-05-02)
### Bug fixes
- Update poetry to v2 ([`ccd89ed`](https://github.com/uilibs/uiprotect/commit/ccd89ed61d19298ba317b2f5a38c449dd3436935))
## v7.5.5 (2025-04-24)
### Bug fixes
- Change phy_rate to float ([`54f60fe`](https://github.com/uilibs/uiprotect/commit/54f60fec70ba5bb75b4efea43a40be70f56e2aeb))
## v7.5.4 (2025-04-12)
### Bug fixes
- Add spdx license identifier ([`85722f6`](https://github.com/uilibs/uiprotect/commit/85722f680d8b727646a165ab13ffa9f8888f5697))
## v7.5.3 (2025-04-11)
### Bug fixes
- Pydantic deprecationwarning ([`0aeb912`](https://github.com/uilibs/uiprotect/commit/0aeb912134fdc203e4fca358b408acada781043c))
## v7.5.2 (2025-03-30)
### Bug fixes
- Support non-integer zoom levels ([`a4976cc`](https://github.com/uilibs/uiprotect/commit/a4976cc50784e246526faa2fe494b51e1f77d8d9))
- Support non-integer zoom levels ([`a4976cc`](https://github.com/uilibs/uiprotect/commit/a4976cc50784e246526faa2fe494b51e1f77d8d9))
## v7.5.1 (2025-02-04)
### Bug fixes
- Handle fps being none ([`c988946`](https://github.com/uilibs/uiprotect/commit/c98894640ca7d70830890a4200cd92df4bf4a029))
- Handle fps being none ([`c988946`](https://github.com/uilibs/uiprotect/commit/c98894640ca7d70830890a4200cd92df4bf4a029))
- Handle fps being none ([`c988946`](https://github.com/uilibs/uiprotect/commit/c98894640ca7d70830890a4200cd92df4bf4a029))
## v7.5.0 (2025-01-24)
### Features
- Update data models to allow none for optional fields to support access devices ([`c6102e4`](https://github.com/uilibs/uiprotect/commit/c6102e4c94899ceebe75d4daddc15981d2368cb3))
- Update data models to allow none for optional fields ([`c6102e4`](https://github.com/uilibs/uiprotect/commit/c6102e4c94899ceebe75d4daddc15981d2368cb3))
- Add new optional fields to recording and camera settings for intercom ([`c6102e4`](https://github.com/uilibs/uiprotect/commit/c6102e4c94899ceebe75d4daddc15981d2368cb3))
## v7.4.1 (2025-01-05)
### Bug fixes
- Handle missing keys in bootstrap data and log an error ([`e06cd7b`](https://github.com/uilibs/uiprotect/commit/e06cd7b2811e2fa292917aec924dfeadc6c43644))
## v7.4.0 (2025-01-04)
### Features
- Add missing ispsettings enum values ([`e593606`](https://github.com/uilibs/uiprotect/commit/e593606debb26c5c7f596037f1739a1881e8d8c8))
## v7.3.0 (2025-01-04)
### Features
- Add none option to autoexposuremode enum ([`04ad788`](https://github.com/uilibs/uiprotect/commit/04ad78889d12a49d2ec415186bc9c8de46903ae9))
## v7.2.0 (2025-01-03)
### Features
- Add set_light_is_led_force_on method ([`5488b1d`](https://github.com/uilibs/uiprotect/commit/5488b1d7accb3d0f3a3df05101b8bc87ef67f25b))
- Add ringtone model and update related functionality ([`d0c93b5`](https://github.com/uilibs/uiprotect/commit/d0c93b5cf562d24acd90dea6e6a77c1ea56b00c1))
- Add ringtone_id and track_no parameters to play chime tones ([`d0c93b5`](https://github.com/uilibs/uiprotect/commit/d0c93b5cf562d24acd90dea6e6a77c1ea56b00c1))
### Unknown
## v7.1.0 (2024-12-18)
### Features
- Add aiport support ([`ba459ff`](https://github.com/uilibs/uiprotect/commit/ba459ff1619957123f71fcf48da7042e3e086ddd))
- Add aiport support ([`ba459ff`](https://github.com/uilibs/uiprotect/commit/ba459ff1619957123f71fcf48da7042e3e086ddd))
## v7.0.2 (2024-12-11)
### Bug fixes
- Migrate more deprecated pydantic calls ([`50ef161`](https://github.com/uilibs/uiprotect/commit/50ef1616429ea013fcb82155beb7510fa0ea156c))
## v7.0.1 (2024-12-11)
### Bug fixes
- Treat no access to keyrings/users as empty ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e))
- Treat no access to keyrings/users as empty ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e))
- Rushed logic ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e))
- Should be a 403 ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e))
- Use internal exception ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e))
## v7.0.0 (2024-12-11)
### Features
- Remove pydantic v1 shims ([`44063a0`](https://github.com/uilibs/uiprotect/commit/44063a050d831893e7e8eded35ff292401511414))
## v6.8.0 (2024-12-09)
### Bug fixes
- Import of self for python 3.10 ([`fe7fc3a`](https://github.com/uilibs/uiprotect/commit/fe7fc3ad42d1166625b2d0cb3d1f8447b273a1a6))
### Features
- Refactor keyrings and ulpusers to add internal indices ([`705df32`](https://github.com/uilibs/uiprotect/commit/705df32514254b754ebea1ebbc659f669b7ffa10))
- Refactor keyrings and ulpusers ([`705df32`](https://github.com/uilibs/uiprotect/commit/705df32514254b754ebea1ebbc659f669b7ffa10))
## v6.7.0 (2024-12-07)
### Features
- Add keyring and ulp-user ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Add sample data for testing purposes ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Add ulp-user and keyring integration ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Work in progress on adding keyrings functionality ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Add dict_from_unifi_list function and refactor keyrings and ulpusers retrieval ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Define nfc fingerprint support version as constant ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
### Refactoring
- Streamline keyrings and ulpusers handling in protectapiclient ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Replace get_keyrings and get_ulpusers methods with direct dict_from_unifi_list calls ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Update dict_from_unifi_list to use any type for return dictionary ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Rename keyring and ulp_user update methods for clarity and improve message processing ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Improve bootstrap update pop after keyring ulpusr requests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Remove to_snake_case from update_from_dict ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Typed dict_from_unifi_list ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Update dict_from_unifi_list to use protectmodelwithid type ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Consolidate keyring and ulp user message processing into a single method ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Update device key retrieval and add ulp user management tests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Improve object removal and update handling in bootstrap class ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Streamline action handling in bootstrap class ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Remove unused user message processing method in bootstrap class ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Move dict_from_unifi_list function to convert module ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
### Bug fixes
- Conditionally assign keyrings and ulp_users based on nvr version ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Convert keys to snake_case in protectbaseobject data processing ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Enhance type checking for model class in bootstrap and update return type in create_from_unifi_dict ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Improve type handling in bootstrap and convert functions for better type safety ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Enhance type safety by casting keyrings and ulp_users in protectapiclient ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Remove type check for protectmodelwithid and enhance mock data in tests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Add debug logging for unexpected websocket actions and enhance tests for user removal and updates ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Initialize keyrings and ulp_users as empty dictionaries; update return type in dict_from_unifi_list ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
### Testing
- Add websocket tests for keyring add, update, and remove actions ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Add websocket tests for keyring add actions with nfc and fingerprint ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Improve formatting in nfc keyring add test ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Add tests for force update with version checks ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Remove outdated nfc fingerprint support version tests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
- Remove additional keys from obj_dict in bootstrap test ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c))
## v6.6.5 (2024-12-02)
### Bug fixes
- Add isthirdpartycamera field to camera model ([`828b510`](https://github.com/uilibs/uiprotect/commit/828b5109f225613f04066eafa1063e6ce715fe3a))
## v6.6.4 (2024-11-29)
### Bug fixes
- Update permission logic for get_snapshot method ([`207959b`](https://github.com/uilibs/uiprotect/commit/207959bf1598acd4ad9e1da1146058b8a18de99c))
- Update permission logic for get_snapshot method ([`207959b`](https://github.com/uilibs/uiprotect/commit/207959bf1598acd4ad9e1da1146058b8a18de99c))
## v6.6.3 (2024-11-27)
### Bug fixes
- Improve partitioned cookie back-compat patching for python 3.13+ ([`a352283`](https://github.com/uilibs/uiprotect/commit/a3522837e283595905907a76671f022b8e680c1d))
## v6.6.2 (2024-11-24)
### Bug fixes
- Bot release token ([`1868448`](https://github.com/uilibs/uiprotect/commit/18684484c76d87d14820f484814097714e71f1a8))
- Update release process to allow the bot to do releases ([`3f7c839`](https://github.com/uilibs/uiprotect/commit/3f7c8393227c9fa78212fa176186027bdb4e9c4d))
- Allow get snapshot with liveonly permissions ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d))
### Features
- Allow snapshots with `read_live` permissions ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d))
- Enable `read_live` permission for `get_package_snapshot` ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d))
### Testing
- Add test data for `read_live` permissions ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d))
- Add unit tests for get_snapshot method ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d))
## v6.6.1 (2024-11-20)
### Bug fixes
- Handle indexerror selecting value "below 1 lux" for icr_custom_value ([`41f5a3b`](https://github.com/uilibs/uiprotect/commit/41f5a3b4a5fe8999b742a0b245fef6056f5422a7))
## v6.6.0 (2024-11-18)
### Features
- Add feature flags for nfc and fingerprint ([`e208d9e`](https://github.com/uilibs/uiprotect/commit/e208d9e2a73c9a665ef08e8e9341c183f23969aa))
## v6.5.0 (2024-11-17)
### Features
- Add processing for nfc scan and fingerprint identified events ([`0a58b29`](https://github.com/uilibs/uiprotect/commit/0a58b29cef1b4ab67eb25a765c21fc9cb001d1a4))
## v6.4.0 (2024-11-04)
### Features
- Add support for fetching the rtsp url without srtp ([`7d0cfd3`](https://github.com/uilibs/uiprotect/commit/7d0cfd3fe4ae49e485284886b516fbef3ac76a22))
## v6.3.2 (2024-10-29)
### Bug fixes
- Talkback stream bitrate settings ([`f10dedf`](https://github.com/uilibs/uiprotect/commit/f10dedfc5f1108c42310ac15a929b054f57160b8))
## v6.3.1 (2024-10-06)
### Bug fixes
- Typing with version of propcache older than 1.0.0 ([`94f9eaa`](https://github.com/uilibs/uiprotect/commit/94f9eaaa4c17f33f096099d696074ab59377b1ed))
## v6.3.0 (2024-10-06)
### Features
- Add support for propcache v1.0.0+ ([`b37f833`](https://github.com/uilibs/uiprotect/commit/b37f833ed64906a076e13251eb74f8fce605042f))
## v6.2.0 (2024-10-03)
### Features
- Switch to using fast cached_property from propcache ([`e5ce415`](https://github.com/uilibs/uiprotect/commit/e5ce4153cbb42d3f820d40444c2ede8a56133f5a))
## v6.1.0 (2024-09-19)
### Bug fixes
- Add additional types to device_events ([`072bc7c`](https://github.com/uilibs/uiprotect/commit/072bc7cbc6a8af634f4638ac79658715cb31379a))
- Bump psr to 9.8.8 to fix release process ([`b109433`](https://github.com/uilibs/uiprotect/commit/b1094333c8767dd7588fe0d0f97f4c711b7e2595))
### Features
- Speed up url joins ([`a10fc5a`](https://github.com/uilibs/uiprotect/commit/a10fc5adc88a1cf78199f5ca2e4a995032f58743))
## v6.0.2 (2024-08-13)
### Bug fixes
- Bump aiofiles requirement to >=24 ([`1eb9ea7`](https://github.com/uilibs/uiprotect/commit/1eb9ea7c5fb2036ad0af42eb607604652d1b0210))
## v6.0.1 (2024-08-09)
### Bug fixes
- Simplify ssl verify flag in websocket class ([`c36e19a`](https://github.com/uilibs/uiprotect/commit/c36e19a549c78f4fd123b89f562669fdaa5f78a5))
## v6.0.0 (2024-08-08)
### Bug fixes
- Remove default websocket receive timeout ([`8b0b303`](https://github.com/uilibs/uiprotect/commit/8b0b3033880532ddbf00cb59df881100db273dcb))
## v5.4.0 (2024-07-20)
### Features
- Improve performance of convert_unifi_data ([`45f66b4`](https://github.com/uilibs/uiprotect/commit/45f66b4d6f35cbd02abae21f0905089b0e329d59))
## v5.3.0 (2024-07-16)
### Features
- Speed up camera snapshots ([`d333865`](https://github.com/uilibs/uiprotect/commit/d3338658c2fa714e993c3d668945b44a1e7ebd27))
## v5.2.2 (2024-07-04)
### Bug fixes
- Reflection of chime duration seconds ([`0266b8e`](https://github.com/uilibs/uiprotect/commit/0266b8e2470084df63422d4971c04354710b1ae8))
## v5.2.1 (2024-07-04)
### Bug fixes
- Avoid reflecting back smoke_cmonx when changing smart audio ([`7270a5c`](https://github.com/uilibs/uiprotect/commit/7270a5cb40ed9c83db353677abc0496dc7b59f9e))
## v5.2.0 (2024-07-03)
### Features
- Remove deepcopy before calling update_from_dict ([`23bc68f`](https://github.com/uilibs/uiprotect/commit/23bc68f2ca31c06e224cb5f5600ce87e1c842ec6))
## v5.1.0 (2024-07-03)
### Features
- Small cleanups to smart detect lookups ([`ef21763`](https://github.com/uilibs/uiprotect/commit/ef217638129bc48fb67d9e60fe828f78daf2a017))
## v5.0.0 (2024-07-02)
### Features
- Do not auto convert enums to values for fetching attrs ([`f6d7ead`](https://github.com/uilibs/uiprotect/commit/f6d7eade0e2b1dc4073b5e45f7f2a75909180a30))
## v4.2.0 (2024-06-27)
### Features
- Replace manual dict deletes with convertertools ([`22f7df8`](https://github.com/uilibs/uiprotect/commit/22f7df8852d5dcb252337a3f4620932619b6c5be))
## v4.1.0 (2024-06-27)
### Features
- Avoid the need to deepcopy in the ws stats ([`5318b02`](https://github.com/uilibs/uiprotect/commit/5318b0219c89a1183218c94525fe08319208bc30))
## v4.0.0 (2024-06-26)
### Features
- Remove is_ringing property and ring ping back from camera ([`b400435`](https://github.com/uilibs/uiprotect/commit/b400435366c859d0350a9095ae6e9136afb2b08a))
## v3.8.0 (2024-06-26)
### Bug fixes
- Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3))
- Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3))
- Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3))
- Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3))
- Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3))
- Do not swallow asyncio.cancellederror ([`09bc38b`](https://github.com/uilibs/uiprotect/commit/09bc38b419b26c00363b47c5ae8ce0e6a7280133))
- Remove unreachable code ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe))
- Remove unreachable code ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe))
### Features
- Improve websocket error handling ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe))
- Pass existing data to _handle_ws_error instead of creating it again ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe))
- Cleanup duplicate code in _handle_ws_error ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe))
## v3.7.0 (2024-06-25)
### Features
- Small cleanups to packet packing/unpacking ([`00cb125`](https://github.com/uilibs/uiprotect/commit/00cb125e89f5f43f7c759719d5fc581fb631af3c))
- Small cleanups to devices ([`1b64a8e`](https://github.com/uilibs/uiprotect/commit/1b64a8e89259e9d791a9c9703ced088e4fc7622c))
- Cleanup some additional dupe attr lookups ([`24849d8`](https://github.com/uilibs/uiprotect/commit/24849d819cfbba582a0f21c975de895d3754ef3b))
## v3.6.0 (2024-06-25)
### Features
- Reduce some duplicate attr lookups in devices ([`8ea72ea`](https://github.com/uilibs/uiprotect/commit/8ea72eae1c8c0e37206a1268937287b0b1f29b28))
## v3.5.0 (2024-06-25)
### Features
- Use more list/dict comps where possible ([`9c1ef3f`](https://github.com/uilibs/uiprotect/commit/9c1ef3f30b8e1c01edb5a6d44b0126edd9e3610d))
## v3.4.0 (2024-06-25)
### Features
- Reduce duplicate code to do unifi_dict_to_dict conversions ([`f616c52`](https://github.com/uilibs/uiprotect/commit/f616c528cc94a313dd2ac0ba7e302bfcfca4afde))
## v3.3.1 (2024-06-24)
### Bug fixes
- License classifier ([`ac048d7`](https://github.com/uilibs/uiprotect/commit/ac048d7325529823ab7d2840dc63aaa822008b32))
## v3.3.0 (2024-06-24)
### Features
- Skip empty models in unifi_dict ([`d42023f`](https://github.com/uilibs/uiprotect/commit/d42023f9f07d3bdf097669637e1ad754a70ea0b7))
## v3.2.0 (2024-06-24)
### Features
- Refactor internal object tracking ([`ad1b2b4`](https://github.com/uilibs/uiprotect/commit/ad1b2b45f3d72243ca8cb24c326b4f0fcd0bd71f))
## v3.1.9 (2024-06-24)
### Bug fixes
- Remove event is in range check ([`2847f40`](https://github.com/uilibs/uiprotect/commit/2847f402a19655e9dee1d596b331e70b25bf3da3))
## v3.1.8 (2024-06-23)
### Bug fixes
- Small tweaks to compact code ([`aa136ba`](https://github.com/uilibs/uiprotect/commit/aa136badd8ff7dbad6b74fcd1418de5f8ca04d73))
## v3.1.7 (2024-06-23)
### Bug fixes
- Remove unreachable code in the websocket decoder ([`235cdef`](https://github.com/uilibs/uiprotect/commit/235cdef8bf930fc7b86084fc44cccea96fb316ef))
## v3.1.6 (2024-06-23)
### Bug fixes
- Remove unreachable api in data checks ([`c7772a9`](https://github.com/uilibs/uiprotect/commit/c7772a9ecdf8d29290d0ba84e31a6f104fcb1dd1))
- Make creation of update sync primitives lazy ([`b05af57`](https://github.com/uilibs/uiprotect/commit/b05af578a1ed9b30a1c986a13d006fbaf89b760f))
## v3.1.5 (2024-06-23)
### Bug fixes
- Exclude_fields would mutate the classvar ([`1c461e1`](https://github.com/uilibs/uiprotect/commit/1c461e1a481eb1c022c1dc5aa09529fc1abfec0e))
## v3.1.4 (2024-06-23)
### Bug fixes
- Ensure test harness does not delete coveragerc ([`02bd064`](https://github.com/uilibs/uiprotect/commit/02bd0640fc6ce917db180a410ab0d102b6c8c73a))
## v3.1.3 (2024-06-23)
### Bug fixes
- Add test coverage for updating to none ([`b2adeac`](https://github.com/uilibs/uiprotect/commit/b2adeac94fcef09bac8fe06c9795c8a41694ff95))
## v3.1.2 (2024-06-23)
### Bug fixes
- Coveragerc fails to omit cli and tests ([`d1a4052`](https://github.com/uilibs/uiprotect/commit/d1a4052984e8545b5ac876337909ae235813db7f))
## v3.1.1 (2024-06-22)
### Bug fixes
- _raise_for_status when raise_exception is not set ([`0a6ff9e`](https://github.com/uilibs/uiprotect/commit/0a6ff9e358e66058f2f7ca3bff12925f3b1d4e90))
## v3.1.0 (2024-06-22)
### Features
- Add websocket state subscription ([`d7083ab`](https://github.com/uilibs/uiprotect/commit/d7083ab8ced2dc3cc65dcaf6ea2dd8c869e70a96))
## v3.0.0 (2024-06-22)
### Features
- Remove the force flag from update ([`0bee3e6`](https://github.com/uilibs/uiprotect/commit/0bee3e64d8f1a540e6bfde7b3ab282bc26e6f150))
## v2.3.0 (2024-06-22)
### Features
- Handle websocket auth errors on restart ([`7026491`](https://github.com/uilibs/uiprotect/commit/7026491ac909cb2ed2bf3d9457cf86a1a44de025))
## v2.2.0 (2024-06-22)
### Features
- Decrease websocket logging for known errors ([`05df499`](https://github.com/uilibs/uiprotect/commit/05df499863006b8d66d2ca0e3c76c639730e30de))
## v2.1.0 (2024-06-22)
### Features
- Improve websocket error handling ([`813ac9c`](https://github.com/uilibs/uiprotect/commit/813ac9ca2eaefa2623b15f43d9cdf4f3fab31bcb))
## v2.0.0 (2024-06-22)
### Features
- Rework websocket ([`574a846`](https://github.com/uilibs/uiprotect/commit/574a846ff4e34737169b49ec418b4a112fa12f3e))
## v1.20.0 (2024-06-21)
### Features
- Include getter builder utils for fetching ufp object values ([`9056edf`](https://github.com/uilibs/uiprotect/commit/9056edf85ecf8cd59d053411ae18f1d05093d9e5))
## v1.19.3 (2024-06-21)
### Bug fixes
- Pin and drop pydantic compat imports now that pydantic is fixed ([`00adc2c`](https://github.com/uilibs/uiprotect/commit/00adc2cc39cf004e93952a8ef489ef1051c1fb83))
## v1.19.2 (2024-06-20)
### Bug fixes
- Ensure update_from_dict creates the object is it was previously none ([`f268c01`](https://github.com/uilibs/uiprotect/commit/f268c01bac2b9969f10de70dae2295ce87a6f70b))
## v1.19.1 (2024-06-19)
### Bug fixes
- Update broken documentation readme link ([`1580c04`](https://github.com/uilibs/uiprotect/commit/1580c042d04d989e1ebe4b919df3d232ae4e8ae9))
## v1.19.0 (2024-06-17)
### Features
- Simplify websocket stats logic ([`5b01f34`](https://github.com/uilibs/uiprotect/commit/5b01f34b9c5cc8bcb3cae9f274acd687870a4091))
### Bug fixes
- Refactoring error in 83 ([`ed477c2`](https://github.com/uilibs/uiprotect/commit/ed477c288047fd1fba39f51d6e695adb6a72ba08))
## v1.18.1 (2024-06-17)
### Bug fixes
- Ensure camera and chime keys are not included in the base ignored set ([`02ab5f6`](https://github.com/uilibs/uiprotect/commit/02ab5f696db9497610ec6b34739452abdfe6ca68))
- Ignore cameraids for chime updates ([`3a7e48d`](https://github.com/uilibs/uiprotect/commit/3a7e48dea4111eb6b0a6012ffe08cafcd66cf4d6))
## v1.18.0 (2024-06-17)
### Features
- Add repr for websocket packets ([`60dd356`](https://github.com/uilibs/uiprotect/commit/60dd356a233ab183c31375417ded3f6e53427e5d))
### Refactoring
- Avoid writing out some more key converts ([`851c798`](https://github.com/uilibs/uiprotect/commit/851c7987b772a185fd4c448dddd9e180fd4f16da))
## v1.17.0 (2024-06-17)
### Features
- Improve performance of websocket packet processing ([`58df1c3`](https://github.com/uilibs/uiprotect/commit/58df1c3ac1c050c418d6ea6255ce18ad64422168))
### Refactoring
- Remove and consolidate unused code in base ([`523d931`](https://github.com/uilibs/uiprotect/commit/523d931f6a06b7c66fc7af7cdfac2abf8ebaa737))
- Use tuples for all the delete iterators ([`9ec88ce`](https://github.com/uilibs/uiprotect/commit/9ec88ce68ab5c0d9f6cb30175eb4ffd9b4a47d43))
- Cleanup debug ([`7883c24`](https://github.com/uilibs/uiprotect/commit/7883c24c9b9a08e41ec044e943e6fab3b66a56f1))
- Reduce code to remove keys ([`7b496cb`](https://github.com/uilibs/uiprotect/commit/7b496cb72b3b5efffad18bb86f58355e910122e7))
## v1.16.0 (2024-06-17)
### Features
- Refactor protect obj methods to use comprehensions ([`ae4cdb9`](https://github.com/uilibs/uiprotect/commit/ae4cdb914b162c756f8384c0c25f256fbaa634d7))
## v1.15.0 (2024-06-17)
### Features
- Small cleanup to get device functions ([`86f18d8`](https://github.com/uilibs/uiprotect/commit/86f18d8901d8fd9b6e2ebfa9c3926ed1d1d0e45c))
## v1.14.0 (2024-06-17)
### Features
- Optimize update_from_dict ([`1b8ed6d`](https://github.com/uilibs/uiprotect/commit/1b8ed6dc146c0351927eeb15c47373481b3ad40e))
## v1.13.0 (2024-06-16)
### Features
- Improve performance of processing websocket messages ([`84277cb`](https://github.com/uilibs/uiprotect/commit/84277cb3ac8b47e8d6b483ace8e31c0d9b07baad))
## v1.12.1 (2024-06-16)
### Bug fixes
- Ensure ping back messages are called back and empty updates excluded ([`b319dba`](https://github.com/uilibs/uiprotect/commit/b319dba4b88e0a7d7b237ec57f2e89ca46c1cc6c))
## v1.12.0 (2024-06-16)
### Bug fixes
- Add missing eventstats key to stats_keys ([`6c8be31`](https://github.com/uilibs/uiprotect/commit/6c8be3129c763d6ade16c57df01cc79d57190fef))
### Features
- Small cleanups to bootstrap code ([`78e6dbb`](https://github.com/uilibs/uiprotect/commit/78e6dbb8165b97522b7f42d8f9e885f0e23cd1eb))
## v1.11.1 (2024-06-16)
### Bug fixes
- Revert to using protected attrs for property cache ([`f0b259c`](https://github.com/uilibs/uiprotect/commit/f0b259caaf7c990de68f1a51a0bd166f94eb3bf7))
## v1.11.0 (2024-06-16)
### Features
- Speed up bootstrap by adding cached_property ([`c6b746d`](https://github.com/uilibs/uiprotect/commit/c6b746d8e4d961c0fc1f98d693357e9becd26baa))
## v1.10.0 (2024-06-16)
### Features
- Make websocket dataclasses sloted ([`58e42f6`](https://github.com/uilibs/uiprotect/commit/58e42f69b7603ab77ffe170d091051febe22e48f))
### Performance improvements
- Make websocket dataclass sloted ([`58e42f6`](https://github.com/uilibs/uiprotect/commit/58e42f69b7603ab77ffe170d091051febe22e48f))
## v1.9.0 (2024-06-15)
### Features
- Improve performance of websocket message processing ([`d6a6472`](https://github.com/uilibs/uiprotect/commit/d6a6472d3516e27dcfdd2ed3b5d8ca68428e273f))
## v1.8.0 (2024-06-15)
### Features
- Replace some attrs with cached methods ([`fc0fc57`](https://github.com/uilibs/uiprotect/commit/fc0fc5717a171eb705dce4f88dca79509bd889b4))
- Replace some never used attrs with cached methods ([`fc0fc57`](https://github.com/uilibs/uiprotect/commit/fc0fc5717a171eb705dce4f88dca79509bd889b4))
### Refactoring
- Delete unused bootstrap constants ([`0283c45`](https://github.com/uilibs/uiprotect/commit/0283c4564c905bee1b1f82cc4c0280a02e07ec5d))
- Small cleanups to _process_add_packet ([`8fd8280`](https://github.com/uilibs/uiprotect/commit/8fd82800b63c7cb8c70da164dcc3e1853fc170a6))
## v1.7.2 (2024-06-14)
### Bug fixes
- Pingback did not hold a strong reference to the task ([`7b11ce9`](https://github.com/uilibs/uiprotect/commit/7b11ce952a9e2f66fc5ac9ceccd1a21e74c218b9))
## v1.7.1 (2024-06-14)
### Bug fixes
- Refactoring error in _process_add_packet ([`e21516b`](https://github.com/uilibs/uiprotect/commit/e21516b212762955a49d6da66f2f823a1b252ca2))
## v1.7.0 (2024-06-14)
### Features
- Add debug logging when saving device changes ([`1c57d00`](https://github.com/uilibs/uiprotect/commit/1c57d005f8f97c148b70401256929c262ba5a8a1))
- Add debug logging when saving device changes ([`1c57d00`](https://github.com/uilibs/uiprotect/commit/1c57d005f8f97c148b70401256929c262ba5a8a1))
- Add debug logging when saving device changes ([`1c57d00`](https://github.com/uilibs/uiprotect/commit/1c57d005f8f97c148b70401256929c262ba5a8a1))
### Refactoring
- Cleanup duplicate doorbell text code ([`5e3fac8`](https://github.com/uilibs/uiprotect/commit/5e3fac8b862dfe7df83fe7b5b565578f494b8bf1))
- Cleanup duplicate doorbell text code ([`5e3fac8`](https://github.com/uilibs/uiprotect/commit/5e3fac8b862dfe7df83fe7b5b565578f494b8bf1))
## v1.6.0 (2024-06-14)
### Features
- Simplify object conversions ([`feb8236`](https://github.com/uilibs/uiprotect/commit/feb8236d7e1817a604186a493d57511fff455e47))
## v1.5.0 (2024-06-14)
### Features
- Make audio_type a cached_property ([`50d22de`](https://github.com/uilibs/uiprotect/commit/50d22de5bbf03328c307c7710015e6ec62ab6826))
## v1.4.1 (2024-06-14)
### Bug fixes
- Use none instead of ... for privateattr ([`fc06f42`](https://github.com/uilibs/uiprotect/commit/fc06f420b6c4531dd59bfa3db8b53a965409cac0))
## v1.4.0 (2024-06-14)
### Features
- Only process incoming websocket packet model type once ([`57d7c10`](https://github.com/uilibs/uiprotect/commit/57d7c10d3915fbf45dd81a855298530a36b9e3c7))
## v1.3.0 (2024-06-13)
### Features
- Cleanup duplicate object lookups in event processing ([`ec00121`](https://github.com/uilibs/uiprotect/commit/ec001218a39f7ec10bcc18005e59a1130f16f8aa))
## v1.2.2 (2024-06-13)
### Bug fixes
- Restore some unreachable code in _process_device_update ([`c638cd3`](https://github.com/uilibs/uiprotect/commit/c638cd3b087d63279bd8f798bd8831fc2e11a916))
## v1.2.1 (2024-06-13)
### Bug fixes
- Blocking i/o in the event loop ([`36a4355`](https://github.com/uilibs/uiprotect/commit/36a4355170566b9d7cfb1632d9c35c28b693d9ce))
## v1.2.0 (2024-06-13)
### Features
- Avoid fetching and iterating convert keys when empty ([`7c9ae89`](https://github.com/uilibs/uiprotect/commit/7c9ae89ed667bbe3e9ca2f5561489d4b8335180e))
### Code style
- Remove ide workspace files and add the directories for them to the gitignore ([`486e3f9`](https://github.com/uilibs/uiprotect/commit/486e3f92f4d12ab195f0433e599c9eac0f008aef))
## v1.1.0 (2024-06-12)
### Features
- Remove _get_frame_data helper ([`21d6768`](https://github.com/uilibs/uiprotect/commit/21d6768132d553cc9f59e73cc7adbfde02a42915))
### Refactoring
- Consolidate logic to remove keys ([`9da56d2`](https://github.com/uilibs/uiprotect/commit/9da56d2c0f094d31b0cf8cba07c4c07fd96c64ea))
- Use new _event_is_in_range helper in _process_camera_event ([`49e0a67`](https://github.com/uilibs/uiprotect/commit/49e0a67c5f2473ae1a6bfbe3db513a77786a68df))
- Reduce duplicate code to process sensor events ([`78c291b`](https://github.com/uilibs/uiprotect/commit/78c291b76a0cbce1f891f91c9c01236d71edbf81))
## v1.0.1 (2024-06-11)
### Bug fixes
- New cookie flag preventing auth cookie from being stored ([`b6eb7fc`](https://github.com/uilibs/uiprotect/commit/b6eb7fcef23885d734ba0f9031bf15bdbba91bc5))
## v1.0.0 (2024-06-11)
### Bug fixes
- Remove unused is_ready property from the api client ([`c36ee42`](https://github.com/uilibs/uiprotect/commit/c36ee422ddd04f811019d2e99cbb1d6b398eae01))
### Refactoring
- Use internal self._api inside the object ([`c20e7a9`](https://github.com/uilibs/uiprotect/commit/c20e7a9690a15f42ff0f17105141f21b2e6e4020))
## v0.15.1 (2024-06-11)
### Bug fixes
- Missing url param in websocket disconnected error log message ([`60e6511`](https://github.com/uilibs/uiprotect/commit/60e651110ed935bb0c35b09aedbc2253a73c35a4))
## v0.15.0 (2024-06-11)
### Features
- Cache bootstrap on the protectapiclient once it has been initialized ([`185e47f`](https://github.com/uilibs/uiprotect/commit/185e47fed693c5a6f8383cece10c5267dbb7e046))
## v0.14.0 (2024-06-11)
### Features
- Cache parsing of datetimes ([`8b6747a`](https://github.com/uilibs/uiprotect/commit/8b6747ae41d483da7395f49e402e29f68112fe83))
### Refactoring
- Use f-strings in more places ([`22706c8`](https://github.com/uilibs/uiprotect/commit/22706c896121eac3b6847a951ef516f350119072))
## v0.13.0 (2024-06-11)
### Features
- Cleanup processing camera events ([`2c1a266`](https://github.com/uilibs/uiprotect/commit/2c1a266a3f7c290e4ae9724642eb427ca41cabf1))
## v0.12.0 (2024-06-11)
### Features
- Cleanup websocket add/remove packet processing ([`fdf0f6e`](https://github.com/uilibs/uiprotect/commit/fdf0f6eef96c17c0d2afe008444c24ce8fad72ee))
- Use a single function to normalize mac addresses ([`7ce8654`](https://github.com/uilibs/uiprotect/commit/7ce86543d4ec1efa9143839b1b7be1c6dd977ca1))
## v0.11.0 (2024-06-11)
### Features
- Cleanup processing of websocket packets ([`b59e19c`](https://github.com/uilibs/uiprotect/commit/b59e19c13ea48e5ab235090c1b02d8d73c3aac24))
## v0.10.1 (2024-06-11)
### Bug fixes
- Remove useless time check ([`749cfef`](https://github.com/uilibs/uiprotect/commit/749cfef9b44f87397153977c673c577659450a48))
## v0.10.0 (2024-06-11)
### Features
- Improve performance of process websocket packets ([`7b59c98`](https://github.com/uilibs/uiprotect/commit/7b59c98d02d2f874375b168979a1db253da58914))
## v0.9.0 (2024-06-10)
### Features
- Avoid linear searches to process websocket packets ([`86d5f19`](https://github.com/uilibs/uiprotect/commit/86d5f198071b0478b480804d055ed80c88341ee1))
## v0.8.0 (2024-06-10)
### Features
- Guard debug logging that reformats data in the arguments ([`0cfdea8`](https://github.com/uilibs/uiprotect/commit/0cfdea8d27c0a35d71cd98d65120288218f4ca4c))
### Refactoring
- Remove useless .keys() calls ([`ec1fd12`](https://github.com/uilibs/uiprotect/commit/ec1fd129deb06b5d2334d49ccd0b238033c5b904))
## v0.7.0 (2024-06-10)
### Features
- Refactor protect object subtype bucketing ([`e4123ac`](https://github.com/uilibs/uiprotect/commit/e4123ac13015c186f141c1bfec3a7c064bb2d732))
## v0.6.0 (2024-06-10)
### Features
- Small code cleanups ([`f1668ae`](https://github.com/uilibs/uiprotect/commit/f1668ae2c9c9f49f6e703a387159d305c2cba847))
## v0.5.0 (2024-06-10)
### Features
- Memoize enum type check to speed up data conversion ([`73b0c4a`](https://github.com/uilibs/uiprotect/commit/73b0c4a813e99d3f353a8fbf3d8a997158cedf3a))
## v0.4.1 (2024-06-10)
### Bug fixes
- Handle unifi os 4 token change ([`a6aab8f`](https://github.com/uilibs/uiprotect/commit/a6aab8f1eefd631119288f6d29d643f3984c5b0d))
## v0.4.0 (2024-06-10)
### Features
- Avoid parsing last_update_id ([`ac86b13`](https://github.com/uilibs/uiprotect/commit/ac86b13b3efc8fc619471536ea993f3741882264))
## v0.3.10 (2024-06-10)
### Bug fixes
- Add missing doorbellmessagetype image ([`eaed04b`](https://github.com/uilibs/uiprotect/commit/eaed04bbc1697553895a64edc573d1acc9112a1a))
## v0.3.9 (2024-06-09)
### Bug fixes
- Revert global flags check ([`8dc437f`](https://github.com/uilibs/uiprotect/commit/8dc437f38dc4f6f6081d9a8a80f9f295b31bf579))
## v0.3.8 (2024-06-09)
### Bug fixes
- Improve readme and testdata docs ([`90ae6a8`](https://github.com/uilibs/uiprotect/commit/90ae6a8cec7a10c1631b301a5d64c94bffdee16d))
## v0.3.7 (2024-06-09)
### Bug fixes
- Revert pydantic changes for ha compat ([`c7770c1`](https://github.com/uilibs/uiprotect/commit/c7770c135deaa52da078794c67d5e3f5dbe3455d))
## v0.3.6 (2024-06-09)
### Bug fixes
- Switch readthedocs to mkdocs ([`6009f9d`](https://github.com/uilibs/uiprotect/commit/6009f9dbb5beed141a8af866eb6e1dfd081af067))
- More docs fixes ([`52261ef`](https://github.com/uilibs/uiprotect/commit/52261eff11919768d75e73f9f3a85243c7eff90a))
## v0.3.5 (2024-06-09)
### Bug fixes
- Add missing docs deps ([`399de45`](https://github.com/uilibs/uiprotect/commit/399de45721cb72c1cd6c945ad9aa0d73d82dea8f))
## v0.3.4 (2024-06-09)
### Bug fixes
- Small fixes for readme.md ([`7a0acf4`](https://github.com/uilibs/uiprotect/commit/7a0acf4da9cfcc1cbf6111cc9d2083be68aa9d93))
## v0.3.3 (2024-06-09)
### Bug fixes
- Ensure uv is installed for docker image ([`d286198`](https://github.com/uilibs/uiprotect/commit/d286198ce4d26ff5151c9b937058b4c223aa95f2))
## v0.3.2 (2024-06-09)
### Bug fixes
- Docker file ([`8474862`](https://github.com/uilibs/uiprotect/commit/84748626bbe29492997801759164a6242ebf7b72))
- Update typer ([`54f26b1`](https://github.com/uilibs/uiprotect/commit/54f26b16223d0ed83c2e249df458ec5ccc407fb6))
- Make package installable ([`169e790`](https://github.com/uilibs/uiprotect/commit/169e7903bc72ad513f475c5477c0b6f4cd5c7653))
## v0.3.1 (2024-06-09)
### Bug fixes
- Dockerfile ([`b25d8a1`](https://github.com/uilibs/uiprotect/commit/b25d8a1218158368ec50d1a2b20280b94696ccee))
- Docker ci ([`3d8e9fe`](https://github.com/uilibs/uiprotect/commit/3d8e9fe294c7c75a7efc2d2653a51fdb052fbf29))
## v0.3.0 (2024-06-09)
### Features
- Migrate docs ([`1e62ec2`](https://github.com/uilibs/uiprotect/commit/1e62ec204c6d1b26f95486a8c27a61bb40a8219b))
## v0.2.2 (2024-06-09)
### Bug fixes
- Readme updates ([`8cf5d24`](https://github.com/uilibs/uiprotect/commit/8cf5d24915e9aed2ffbdce4390dd061c9c40d4a1))
## v0.2.1 (2024-06-09)
### Bug fixes
- Adjust jinja check for changelog template ([`e5f55c1`](https://github.com/uilibs/uiprotect/commit/e5f55c1f1af84d3f9053bf9b36c3662dab706882))
- Changelog generation ([`2b770e9`](https://github.com/uilibs/uiprotect/commit/2b770e9a4a6ccfa352fd0fc2b30099ef07b59db8))
## v0.2.0 (2024-06-09)
### Features
- Update classifiers ([`0d4eaf6`](https://github.com/uilibs/uiprotect/commit/0d4eaf6e5fe30c83c52d30d388d65ebe33ee7c3f))
### Unknown
### Bug fixes
- Re-enable changelog ([`68620b0`](https://github.com/uilibs/uiprotect/commit/68620b09b65ee553982c2c54bfc1e0a3c6ba4380))
## v0.1.0 (2024-06-09)
### Bug fixes
- Pre-commit auto update ([`27c1514`](https://github.com/uilibs/uiprotect/commit/27c1514064b5b44d13abd57fc5df3f81dc741c78))
- Cli test ([`b2e4e8e`](https://github.com/uilibs/uiprotect/commit/b2e4e8ef3536bbedc8d3765afb4fd3cb45b478ba))
- Only pyupgrade non-typer code ([`8a5f9b6`](https://github.com/uilibs/uiprotect/commit/8a5f9b644b80a2f739bc5d9720e316150e938ab6))
- Ensure workers ([`d7578de`](https://github.com/uilibs/uiprotect/commit/d7578dedd0443f5ce4333475dde06c28882cbfd0))
- Tests in ci ([`f008537`](https://github.com/uilibs/uiprotect/commit/f0085378ac15125e7e75d80daae7876b37fa8b6d))
- Add mypy to dev deps ([`bde29f2`](https://github.com/uilibs/uiprotect/commit/bde29f236622ec8c3756add4ddb8103644b04c8f))
- More mypy fixes ([`f889c50`](https://github.com/uilibs/uiprotect/commit/f889c5061dd3428bf47bcf1294c0117880e3f20b))
- Add more missing types ([`6d959f9`](https://github.com/uilibs/uiprotect/commit/6d959f9f48b0fd14a43468d52abd8593311bfe10))
- Disable some more rules inline ([`03c726f`](https://github.com/uilibs/uiprotect/commit/03c726f0c0594fcde0a4bb93020c1c99dce6a149))
- Add missing types ([`ef87e72`](https://github.com/uilibs/uiprotect/commit/ef87e72b73e1ec5372bc19260916f093f2b2fe45))
- Disable some rules ([`6cfd103`](https://github.com/uilibs/uiprotect/commit/6cfd103beba3d8689f2c9730831efd89bc0fd679))
### Unknown
## v0.0.0 (2024-06-09)
### Unknown
### Testing
- Add tests for set_person_track ([`a00de52`](https://github.com/uilibs/uiprotect/commit/a00de52bda63a822f2ffce53bd9188aa7a91def8))
### Documentation
- Add documentation for set_person_track command ([`a00de52`](https://github.com/uilibs/uiprotect/commit/a00de52bda63a822f2ffce53bd9188aa7a91def8))
### Bug fixes
- Actually set chime_duration ([`e7edd26`](https://github.com/uilibs/uiprotect/commit/e7edd26823505f73e97b1a46e70f397a95126a3f))
### Features
- Make chime duration adjustable ([`b4d13c1`](https://github.com/uilibs/uiprotect/commit/b4d13c146f292eae216109f747d3bee6608b0f28))
uilibs-uiprotect-317a52c/CLAUDE.md 0000664 0000000 0000000 00000000013 15246377057 0016635 0 ustar 00root root 0000000 0000000 @AGENTS.md
uilibs-uiprotect-317a52c/CONTRIBUTING.md 0000664 0000000 0000000 00000007712 15246377057 0017624 0 ustar 00root root 0000000 0000000 # Contributing
Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given.
You can contribute in many ways:
## Types of Contributions
### Report Bugs
Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include:
- Your operating system name and version.
- Any details about your local setup that might be helpful in troubleshooting.
- Detailed steps to reproduce the bug.
### Fix Bugs
Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it.
### Implement Features
Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it.
### Write Documentation
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.
### Submit Feedback
The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature:
- Explain in detail how it would work.
- Keep the scope as narrow as possible, to make it easier to implement.
- Remember that this is a volunteer-driven project, and that contributions are welcome 😊
## Get Started!
Ready to contribute? Here's how to set yourself up for local development.
1. Fork the repo on GitHub.
2. Clone your fork locally:
```shell
$ git clone git@github.com:your_name_here/uiprotect.git
```
3. Install the project dependencies with [Poetry](https://python-poetry.org):
```shell
$ poetry install --all-extras
```
`--all-extras` pulls in the optional `cli` extra (`typer`) so the
CLI and its tests (`tests/test_cli.py`) work in your environment.
4. Create a branch for local development:
```shell
$ git checkout -b name-of-your-bugfix-or-feature
```
Now you can make your changes locally.
5. When you're done making changes, check that your changes pass our tests:
```shell
$ poetry run pytest
```
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:
```shell
$ pre-commit run -a
```
Or better, install the hooks once and have them run automatically each time you commit:
```shell
$ pre-commit install
```
7. Commit your changes and push your branch to GitHub:
```shell
$ git add .
$ git commit -m "feat(something): your detailed description of your changes"
$ git push origin name-of-your-bugfix-or-feature
```
Note: PRs are squash-merged, so the **pull request title** is what lands on `main` and must follow [the conventional commits](https://www.conventionalcommits.org). We run [`amannn/action-semantic-pull-request`](https://github.com/amannn/action-semantic-pull-request) on CI to validate the PR title. Per-commit messages on the PR branch are not linted.
8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed):
```shell
$ gh pr create --fill
```
## Pull Request Guidelines
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:
1. Include tests for feature or bug fixes.
2. Update the documentation for significant features.
3. Ensure tests are passing on CI.
## Tips
To run a subset of tests:
```shell
$ pytest tests
```
## Making a new release
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.
[gh-issues]: https://github.com/uilibs/uiprotect/issues
uilibs-uiprotect-317a52c/Dockerfile 0000664 0000000 0000000 00000007513 15246377057 0017364 0 ustar 00root root 0000000 0000000 FROM python:3.13-slim-bookworm AS base
LABEL org.opencontainers.image.source=https://github.com/uilibs/uiprotect
ENV PYTHONUNBUFFERED=1
ENV UV_SYSTEM_PYTHON=true
ARG TARGETPLATFORM
RUN addgroup --system --gid 1000 app \
&& adduser --system --shell /bin/bash --uid 1000 --home /home/app --ingroup app app
FROM base AS builder
RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \
apt-get update -qq \
&& apt-get install -yqq build-essential git
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
pip install --root-user-action=ignore -U pip uv poetry
FROM builder AS prod-builder
ARG UIPROTECT_VERSION
WORKDIR /tmp/build
COPY pyproject.toml poetry.lock ./
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
poetry config virtualenvs.create false \
&& poetry install --only main --no-root --no-interaction --no-ansi
COPY . .
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
SETUPTOOLS_SCM_PRETEND_VERSION=${UIPROTECT_VERSION} poetry build -f wheel
FROM base AS prod
COPY --from=builder /usr/local/bin/uv /usr/local/bin/
COPY --from=prod-builder /tmp/build/dist/*.whl /tmp/
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
uv pip install -U "uiprotect[cli] @ file://$(echo /tmp/*.whl)" \
&& rm /tmp/*.whl
COPY .docker/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint \
&& mkdir /data \
&& chown app:app /data
USER app
VOLUME /data
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint"]
FROM builder AS builder-dev
WORKDIR /workspaces/uiprotect
COPY pyproject.toml poetry.lock ./
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
poetry config virtualenvs.create false \
&& poetry install --with dev --all-extras --no-root --no-interaction --no-ansi
FROM base AS dev
# Python will not automatically write .pyc files
ENV PYTHONDONTWRITEBYTECODE=1
# Enables Python development mode, see https://docs.python.org/3/library/devmode.html
ENV PYTHONDEVMODE=1
COPY --from=builder-dev /usr/local/bin/ /usr/local/bin/
COPY --from=builder-dev /usr/local/lib/python3.13/ /usr/local/lib/python3.13/
COPY ./.docker/docker-fix.sh /usr/local/bin/docker-fix
COPY ./.docker/bashrc /root/.bashrc
COPY ./.docker/bashrc /home/app/.bashrc
RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \
apt-get update -qq \
&& apt-get install -yqq git curl vim procps jq sudo \
&& echo 'app ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers \
&& chown app:app /home/app/.bashrc \
&& chmod +x /usr/local/bin/docker-fix
ENV PYTHONPATH=/workspaces/uiprotect/
ENV PATH=$PATH:/workspaces/uiprotect/.bin
USER app
WORKDIR /workspaces/uiprotect/
# =============================================================================
# Development Container (for VS Code devcontainer)
# =============================================================================
FROM python:3.13-slim-bookworm AS devcontainer
ARG TARGETPLATFORM
RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \
apt-get update -qq \
&& apt-get install -yqq curl git build-essential bash locales \
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen en_US.UTF-8 \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
ENV LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8
SHELL ["/bin/bash", "-c"]
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
curl -sSL https://install.python-poetry.org | POETRY_VERSION=2.2.1 python3 -
ENV PATH="/root/.local/bin:${PATH}"
WORKDIR /workspace
ENV POETRY_VIRTUALENVS_CREATE=false
COPY pyproject.toml poetry.lock* ./
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
poetry install --with dev --all-extras --no-root
uilibs-uiprotect-317a52c/LICENSE 0000664 0000000 0000000 00000002127 15246377057 0016373 0 ustar 00root root 0000000 0000000
MIT License
Copyright (c) 2024 UI Protect Maintainers
Copyright (c) 2020 Bjarne Riis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
uilibs-uiprotect-317a52c/LIVE_DATA_CI.md 0000664 0000000 0000000 00000012052 15246377057 0017611 0 ustar 00root root 0000000 0000000 # How to make a GHA Workflow to Test Against your UFP Instance
## Fork vs. PR to main Repo
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.
**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.
## Create a self-hosted GHA Runner
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.
The only other additional requirement for the GHA runner is that it _must_ be able to communicate directly to your UniFi Protect instance.
### Getting Required variables
Before starting any of the 3 options, you need 4 pieces of data:
- `REPO_URL`: the URL for the repo the GHA action runner will be for
- Either `https://github.com/uilibs/uiprotect` or the URL of your fork
- `RUNNER_NAME`: an identifiable name for your runner. Can be anything, but make sure it unique.
- `LABELS`: should be `self-hosted,linux,ufp,YOUR_USERNAME`
- `RUNNER_TOKEN`: See below
#### Generating Runner Token
---
## **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.
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).
1. On your Github repo, Go to Settings -> Actions -> Runners and click "New self-hosted runner"
2. Copy the value after the `--token` argument under the "Configure" section
### Using a Home Assistant Add-on
To help make the process as easy as possible, I made a simple Home Assistant add-on that wraps the "Using Docker" method below.
1. Go to "Supervisor -> Add-on Store -> Triple dots in corner -> Repositories"
2. Add the URL `https://github.com/uilibs/ha-addons`
3. Install the new "Github Actions Runner" add-on
4. Click the "Configuration" tab and enter your values from above
5. Start up the add-on
`workdir` can be left as the default. It should already be a unique location that will not cause any issues.
### Using Docker
You can use the awesome [premade Docker GHA runner](https://github.com/myoung34/docker-github-actions-runner). Just start it in your preferred method.
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.
```bash
docker run --rm -it \
-e REPO_URL=REPO_URL \
-e RUNNER_NAME=RUNNER_NAME \
-e LABELS=LABELS \
-e RUNNER_TOKEN=RUNNER_TOKEN \
-e RUNNER_WORKDIR=/data \
-v /path/to/host/folder/for/data:/data \
myoung34/github-runner:ubuntu-bionic
```
### Roll your Own Runner
If you would prefer to make your own runner to ensure the complete security of your home network. Go for it.
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)
## Create a Local User in UFP for GHA
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.
## Add Secrets to Github Repo
---
## **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.
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.
1. Go to "Settings -> Environments -> New Environment" and name it your Github username.
2. Change "Deployment Branches" to "Selected Branches" and add `master`
3. Under "Environment Secrets" add the following secrets:
- `UFP_ADDRESS`: IP or host name to your UFP instance
- `UFP_PORT`: Port for your UFP instance
- `UFP_SSL_VERIFY`: True or False. Whether or not to verify SSL certs for instance
- `UFP_USERNAME`: Username for your local admin user
- `UFP_PASSWORD`: Password for your local admin user
## Create a workflow to Test data
1. Copy one of the existing workflows under `.github/workflows/test-live-*.yml`
2. Rename it to `test-live-YOUR_USERNAME.yml`
3. Open the file and change the username in the `name` section at the top
4. Replace the username in the `runs-on` section
5. Replace the username in the `environment` section
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.
uilibs-uiprotect-317a52c/README.md 0000664 0000000 0000000 00000045371 15246377057 0016655 0 ustar 00root root 0000000 0000000 # Unofficial UniFi Protect Python API and CLI
---
**Documentation**: https://uiprotect.readthedocs.io
**Source Code**: https://github.com/uilibs/uiprotect
---
## About
Python API and CLI for UniFi Protect (Unofficial).
This module communicates with UniFi Protect surveillance software installed on a UniFi OS Console such as a Ubiquiti CloudKey+ (Cloud Key Gen2 Plus), a UniFi Network Video Recorder (UNVR or UNVR Pro), or a UniFi Dream Machine Pro, SE, or Pro Max.
`uiprotect` is increasingly built on Ubiquiti's official, documented Public Integration API. Where a capability is not yet available there, it falls back to the older private API, which is undocumented and can change as Ubiquiti evolves the software — so those parts may have gaps or shift between firmware releases.
The module is primarily written for the purpose of being used in Home Assistant core [integration for UniFi Protect](https://www.home-assistant.io/integrations/unifiprotect) but might be used for other purposes also.
Full documentation for the project is available at [uiprotect.readthedocs.io](https://uiprotect.readthedocs.io/).
## Requirements
If you want to install `uiprotect` natively, the below are the requirements:
- [UniFi Protect](https://ui.com/camera-security) version 7.1+
- The library is generally tested against the latest stable version.
- [Python](https://www.python.org/) 3.11+
- POSIX compatible system
- [PyAV](https://pyav.org/) (av) - included as a dependency
- PyAV is used for audio streaming to camera speakers (talkback feature)
Alternatively you can use the [provided Docker container](#using-docker-container), in which case the only requirement is [Docker](https://docs.docker.com/desktop/) or another OCI compatible orchestrator (such as Kubernetes or podman).
Windows is **not supported**. If you need to use `uiprotect` on Windows, use Docker Desktop and the provided docker container or [WSL](https://docs.microsoft.com/en-us/windows/wsl/install).
## Installation
### From PyPI
`uiprotect` is available on PyPI:
```bash
pip install uiprotect
```
To use the command-line interface, install the `cli` extra (it pulls in `typer`):
```bash
pip install "uiprotect[cli]"
```
### From GitHub
```bash
pip install git+https://github.com/uilibs/uiprotect.git#egg=uiprotect
# with the CLI:
pip install "uiprotect[cli] @ git+https://github.com/uilibs/uiprotect.git"
```
### Using Docker Container
A Docker container is also provided, so you do not need to install/manage Python as well. You can add the following to your `.bashrc` or similar.
```bash
function uiprotect() {
docker run --rm -it \
-e UFP_USERNAME=YOUR_USERNAME_HERE \
-e UFP_PASSWORD=YOUR_PASSWORD_HERE \
-e UFP_ADDRESS=YOUR_IP_ADDRESS \
-e UFP_PORT=443 \
-e UFP_SSL_VERIFY=false \
-e TZ=America/New_York \
-v $PWD:/data ghcr.io/uilibs/uiprotect:latest "$@"
}
```
Some notes about the Docker version since it is running inside a container:
- You can update at any time using the command `docker pull ghcr.io/uilibs/uiprotect:latest`
- Your local current working directory (`$PWD`) will automatically be mounted to `/data` inside of the container. For commands that output files, this is the _only_ path you can write to and have the file persist.
- The container supports `linux/amd64` and `linux/arm64` natively. This means it will also work well on macOS or Windows using Docker Desktop.
- `TZ` should be the [Olson timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the timezone your UniFi Protect instance is in.
- For more details on `TZ` and other environment variables, check the [command line docs](https://uilibs.github.io/uiprotect/latest/cli/)
## Quickstart
### CLI
> [!WARNING]
> Ubiquiti SSO accounts are not supported and actively discouraged from being used. There is no option to use MFA. You are expected to use local access user. `uiprotect` is not designed to allow you to use your owner account to access the console or to be used over the public internet as both pose a security risk.
> [!NOTE]
> `uiprotect` is increasingly built on Ubiquiti's official Public Integration API, which authenticates with a console-scoped **API key** instead of a username/password — no SSO, MFA, or owner account involved. New functionality targets this path first, and it is expected to become the primary — and eventually the only — supported authentication method. See [Public-only mode](#public-only-mode) below.
```bash
export UFP_USERNAME=YOUR_USERNAME_HERE
export UFP_PASSWORD=YOUR_PASSWORD_HERE
export UFP_ADDRESS=YOUR_IP_ADDRESS
export UFP_PORT=443
# set to true if you have a valid HTTPS certificate for your instance
export UFP_SSL_VERIFY=false
# Alternatively, use an API key for authentication (required for public API operations)
export UFP_API_KEY=YOUR_API_KEY_HERE
uiprotect --help
uiprotect nvr
```
#### Available CLI Commands
**Top-level commands:**
- `uiprotect shell` - Start an interactive Python shell with the API client
- `uiprotect create-api-key ` - Create a new API key for authentication
- `uiprotect get-meta-info` - Get metadata information
- `uiprotect generate-sample-data` - Generate sample data for testing
- `uiprotect profile-ws` - Profile WebSocket performance
- `uiprotect decode-ws-msg` - Decode WebSocket messages
**Device management commands:**
- `uiprotect nvr` - NVR information and settings
- `uiprotect events` - Event management and export
- `uiprotect cameras` - Camera management
- `uiprotect lights` - Light device management
- `uiprotect sensors` - Sensor management
- `uiprotect viewers` - Viewer management
- `uiprotect liveviews` - Live view configuration
- `uiprotect chimes` - Chime management
- `uiprotect aiports` - AI port management
For more details on any command, use `uiprotect --help`.
### Python
UniFi Protect itself is 100% async, so as such this library is primarily designed to be used in an async context.
The main interface for the library is the `uiprotect.ProtectApiClient`:
```python
from uiprotect import ProtectApiClient
# Initialize with username/password
protect = ProtectApiClient(host, port, username, password, verify_ssl=True)
# Or with API key (required for public API operations)
protect = ProtectApiClient(host, port, username, password, api_key=api_key, verify_ssl=True)
await protect.update() # this will initialize the protect .bootstrap and open a Websocket connection for updates
# get names of your cameras
for camera in protect.bootstrap.cameras.values():
print(camera.name)
# subscribe to Websocket for updates to UFP
def callback(msg: WSSubscriptionMessage):
# do stuff
unsub = protect.subscribe_websocket(callback)
# remove subscription
unsub()
```
#### Public-only mode
You can also build a client that does no private login at all — just an API
key. Private-session entry points (`update()`, `authenticate()`,
`get_bootstrap()`) raise `PublicOnlyModeError`; drive everything through
`update_public()`, `subscribe_events()`, `subscribe_devices()`, the
`get_*_public()` / `update_*_public()` methods, and `get_meta_info()`. A
revoked key surfaces as `NotAuthorized`.
```python
from uiprotect import ProtectApiClient
protect = ProtectApiClient.public_only(host, port, api_key=api_key, verify_ssl=True)
await protect.update_public()
# work with the public-API device snapshots
for siren in await protect.get_sirens_public():
print(siren.name)
# the public API exposes no NVR mac; get_console_mac() resolves it out-of-band
# via the UniFi-OS /api/system endpoint. For new code, prefer the public-API
# primary key (nvr.id) as the device identity rather than the mac.
console_mac = await protect.get_console_mac()
```
## Usage
### Subscribing to events
`ProtectApiClient` exposes two parallel websocket contracts. The raw
`subscribe_events_websocket` continues to deliver `WSSubscriptionMessage`
frames for advanced callers, and the typed `subscribe_events` API
delivers `(ProtectEvent, EventChange)` pairs intended for application
code. The typed path goes through the Public Integration API, so the
`ProtectApiClient` must be configured with an API key and
`update_public()` must have been called at least once before calling
`subscribe_events`. Use `subscribe_events_and_prime()` to subscribe and prime
in one order-independent call.
```python
import logging
from uiprotect import EventChange, ProtectApiClient, ProtectEvent
_LOGGER = logging.getLogger(__name__)
protect = ProtectApiClient(..., api_key="...")
def on_event(event: ProtectEvent, change: EventChange) -> None:
if change is EventChange.STARTED:
_LOGGER.info("%s on %s: %s", event.type, event.device_id, event.identity)
elif change is EventChange.ENDED:
_LOGGER.info("%s ended after %s", event.type, event.end - event.start)
# Order-independent: subscribes and primes in the correct order.
unsubscribe = await protect.subscribe_events_and_prime(on_event)
# ...
unsubscribe()
```
Notes:
- `subscribe_events` delivers only events whose `EventType` maps to a
non-`OTHER` `ProtectEventChannel` (detection / sensor / alarm-hub /
access). Administrative events such as `provision`, `factoryReset` and
`fwUpdate` are dropped. Callers that need the unfiltered stream
should use `subscribe_events_websocket`.
- `event.raw` is a permanent escape hatch onto the underlying private-API
`Event` model when the public contract does not expose the field you
need. In particular, smart-detect _detected attributes_ (license-plate
text, face-match name) are **not** available over the public API today,
so consumers that need them must fall back to the private path via
`event.raw`.
- `EventChange.UPDATED` may carry no public-visible delta — diff
`event.raw` if you need to know exactly what changed.
- `protect.active_events(device_id=...)` returns the in-flight set,
derived directly from the public bootstrap cache. Useful for restoring
binary-sensor state after a reload — it works before any
`subscribe_events` call as long as `update_public()` has primed the
cache.
- All runtime state is sourced from `public_bootstrap`: lifecycle/active
state from `public_bootstrap.events`, credential-event identity from
`public_bootstrap.ulp_users` (UniFi Identity), and `event.device_mac`
from the bootstrap device stores. All are refreshed by `update_public()`
— including automatically on websocket reconnect — and resolve with
eventual consistency: an `identity` that resolves to
`UnknownIdentity(reason="ulp_user_not_cached")` for a freshly-enrolled
ULP user, or a `device_mac` of `None` for a device not yet in the
bootstrap, both fill in on the next `update_public()` / reconnect resync.
### Subscribing to device state
`subscribe_devices` is the device-side analog of `subscribe_events`: it
delivers a typed `ProtectDeviceChange` for each `ADDED` / `UPDATED` /
`REMOVED` device over the Public Integration API. Together with
`public_bootstrap` (the device snapshot) and `subscribe_events`
(detection / sensor events), it gives a thin consumer the three
concern-separated primitives it needs without any model-type routing or
merge logic of its own.
Like `subscribe_events`, it delivers merged public models from the primed
cache, so `subscribe_devices` requires `update_public()` to have run first and
raises `RuntimeError` otherwise. To avoid encoding that ordering yourself, use
`subscribe_devices_and_prime()`, which connects the websocket and primes in the
correct order in one call so the prime/subscribe ordering is moot. With the
combined helper, updates that arrive while `update_public()` is priming are
buffered and replayed onto the fresh snapshot, so the already-connected
subscriber does not miss an update from the prime window; with the explicit
two-step form the subscriber is registered after priming and starts from the
refreshed cache.
```python
import logging
from uiprotect import DeviceChange, ProtectApiClient, ProtectDeviceChange
_LOGGER = logging.getLogger(__name__)
protect = ProtectApiClient(..., api_key="...")
def on_device(change: ProtectDeviceChange) -> None:
if change.change is DeviceChange.UPDATED and "state" in change.changed_fields:
_LOGGER.info("%s -> %s", change.device_id, change.model.state)
# Order-independent: subscribes and primes in the correct order.
unsubscribe = await protect.subscribe_devices_and_prime(on_device)
# ...or the explicit two-step form, which must prime before subscribing:
# await protect.update_public()
# unsubscribe = protect.subscribe_devices(on_device)
unsubscribe()
```
Notes:
- Each change carries the merged `Public*` model in `change.model`
(`None` for `REMOVED`, where only an id / `modelKey` reference is
delivered). `change.changed_fields` is populated only for `UPDATED`.
- Single and bulk WS envelopes are expanded transparently to one change
per device, so consumers never see batched `id` arrays.
- Connection / `state` transitions surface as ordinary `UPDATED`s with
`state` in `changed_fields` — there is no separate side channel.
- The callback must not raise: an exception is caught and logged but
otherwise swallowed.
- `change.device_mac` resolves with eventual consistency — a device not
yet in the bootstrap yields `None` until the next `update_public()` /
reconnect resync.
- This is device _state_ only. It does not synthesize detection / motion
(use `subscribe_events`) and there is no adoption concept folded in.
## Roadmap & limitations
The library is moving from the legacy private API to Ubiquiti's official Public Integration API. The private API is considered legacy and is being phased out — new work targets the public API, implemented spec-conformantly: covering the features the spec exposes and staying as close to it as possible.
An explicit architectural goal is to keep the library shaped so the [Home Assistant integration](https://www.home-assistant.io/integrations/unifiprotect) can stay thin — capabilities, device models, and events are surfaced here so the integration carries as little logic of its own as possible.
## Contributing
Please **open an issue and agree on the approach before implementing** anything — it
avoids wasted effort on changes that don't fit the project's direction.
> [!IMPORTANT]
> **This library does not accept new features built on the private API.**
> uiprotect is migrating from the reverse-engineered private API to UniFi's official
> Public Integration API. If a capability is missing from the public API, the right
> path is to request it from Ubiquiti / wait for it to be exposed there — **not** to
> add it on the private path. Issues or PRs that introduce new private-API
> functionality will be closed.
**Using AI? Fine — but you have to drive it.** We use AI tooling ourselves, so an
unreviewed AI-generated PR or issue doesn't save us anything; it just shifts the
review and cleanup cost onto us. AI-assisted contributions are welcome **only when
you genuinely understand the architecture and the project's strategic direction, and
the approach has been agreed in an issue first.**
Where a contribution actually helps is the part AI can't supply — often because it
involves a device none of the maintainers happen to own. We have plenty of UniFi
hardware, just not every model, so testing and validation on a device we don't have,
sanitized payload captures from it, or first-hand knowledge of how it behaves in the
field are genuinely valuable — shaped to fit the architecture (see
[`AGENTS.md`](https://github.com/uilibs/uiprotect/blob/main/AGENTS.md)). Raw AI output that skips the prior discussion or ignores
these guidelines just creates review burden and will be closed.
### Developer Setup
The recommended way to develop is using the provided **devcontainer** with VS Code:
1. Install [VS Code](https://code.visualstudio.com/) and the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
2. Open the project in VS Code
3. When prompted, click "Reopen in Container" (or use Command Palette: "Dev Containers: Reopen in Container")
4. The devcontainer will automatically set up Python, Poetry, pre-commit hooks, and all dependencies
Alternatively, if you want to develop natively without devcontainer:
```bash
# Install dependencies (--all-extras installs the cli extra for CLI tests)
poetry install --with dev --all-extras
# Install pre-commit hooks
poetry run pre-commit install --install-hooks
# Run tests
poetry run pytest
# Run pre-commit checks manually
poetry run pre-commit run --all-files
```
## History
This project was split off from `pyunifiprotect` because that project changed its license to one that would not be accepted in Home Assistant. This project is committed to keeping the MIT license.
## Credits
- Bjarne Riis ([@briis](https://github.com/briis/)) for the original pyunifiprotect package
- Christopher Bailey ([@AngellusMortis](https://github.com/AngellusMortis/)) for the maintaining the pyunifiprotect package
uilibs-uiprotect-317a52c/SECURITY.md 0000664 0000000 0000000 00000005432 15246377057 0017161 0 ustar 00root root 0000000 0000000 # Security Policy
## Reporting a vulnerability
Please report security vulnerabilities privately through GitHub's
[private vulnerability reporting][gh-report] for this repository.
That route sends the report directly to the maintainers and lets
us coordinate a fix, a CVE, and a release before public
disclosure.
**Do not** open a regular GitHub issue, a pull request, or post
to a public channel (mailing list, chat room, Stack Overflow,
etc.) for a suspected vulnerability. The library holds
credentials for users' UniFi Protect consoles, so a bug class
disclosed in a public PR title is a credible information leak
even before a patch ships. If you are unsure whether something
is a vulnerability, use the private report — we would rather see
a false alarm than a public one.
We aim to acknowledge new reports within a few business days.
[gh-report]: https://github.com/uilibs/uiprotect/security/advisories/new
## Supported versions
Security fixes are released against the latest version on PyPI.
Older releases are not maintained — please upgrade to the
current release before reporting, and confirm the issue still
reproduces there.
## Scope
`uiprotect` is an async Python client for the UniFi Protect
surveillance NVR. It authenticates against a Protect console
with the user's credentials and parses REST responses plus a
binary WebSocket update stream whose wire formats are not
publicly documented and shift across firmware releases. In-scope
issues include:
- Memory-safety, parsing, or denial-of-service issues triggered
by crafted server responses reaching `ProtectApiClient`, the
WebSocket decoder in `data/websocket.py`, the JSON →
pydantic-model conversion in `data/convert.py`, or any of the
pydantic models under `src/uiprotect/data/`.
- Credential or session-token handling bugs: logging or
serialising credentials, leaking the API token into logs or
exceptions, TLS validation that can be silently disabled, or
authentication state surviving where it should be cleared.
- Logic bugs that cause the client to act on behalf of a
different user, expose data across NVR accounts, or persist
credentials to disk in unexpected locations.
- Issues in the build / packaging pipeline (wheel contents,
release flow) that could lead to a compromised wheel on PyPI.
Out of scope:
- Risks inherent to handing the library credentials for a UniFi
Protect console — it is a client that drives the Protect API
on the user's behalf and inherits whatever permissions those
credentials carry.
- Vulnerabilities in the upstream UniFi Protect server itself,
or in Ubiquiti firmware. Report those to Ubiquiti through
their published security channels, not here.
- Misconfiguration of a downstream application that uses the
library (for example, the Home Assistant `unifiprotect`
integration).
uilibs-uiprotect-317a52c/TESTDATA.md 0000664 0000000 0000000 00000003171 15246377057 0017121 0 ustar 00root root 0000000 0000000 # Generating Sample Data to help with Testing/Features
## Setup Python
### With Home Assistant (via the `unifiprotect` integration)
1. Make sure you have the _Community_ [SSH & Web Terminal Add-On](https://github.com/hassio-addons/addon-ssh) install
2. Open an SSH or Web terminal to the add-on
3. Run
```bash
docker exec -it homeassistant bash
```
Use `/config/ufp-data` for your `-o` argument below.
### Without Home Assistant
1. Ensure Python 3.11+ is installed
2. Install uiprotect by issuing this command: `pip install uiprotect`
Use `./ufp-data` for your `-o` argument below.
## Generate Data
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.
```bash
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
```
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.
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.
The most critical data for you to remove are the `authUserId`, `accessKey`, and `users` keys from the `sample_bootstrap.json` file.
uilibs-uiprotect-317a52c/docs/ 0000775 0000000 0000000 00000000000 15246377057 0016314 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/docs/api.md 0000664 0000000 0000000 00000001534 15246377057 0017412 0 ustar 00root root 0000000 0000000 ---
hide:
- navigation
toc_depth: 3
---
# API Reference
Rendering options (hidden sources, signature annotations, heading level) are
configured globally for the `mkdocstrings` Python handler in `mkdocs.yml`.
## API Client (`uiprotect.api`)
::: uiprotect.api
## Data Models (`uiprotect.data`)
::: uiprotect.data
## Enums and Types (`uiprotect.data.types`)
::: uiprotect.data.types
## Public Bootstrap (`uiprotect.data.public_bootstrap`)
::: uiprotect.data.public_bootstrap
## Public Devices (`uiprotect.data.public_devices`)
::: uiprotect.data.public_devices
## Events (`uiprotect.events`)
::: uiprotect.events
## Exceptions (`uiprotect.exceptions`)
::: uiprotect.exceptions
## Stream (`uiprotect.stream`)
::: uiprotect.stream
## Utils (`uiprotect.utils`)
::: uiprotect.utils
## Websocket (`uiprotect.websocket`)
::: uiprotect.websocket
uilibs-uiprotect-317a52c/docs/cli.md 0000664 0000000 0000000 00000072524 15246377057 0017417 0 ustar 00root root 0000000 0000000 ---
hide:
- navigation
---
# Command Line
The `uiprotect` command is provided to give a CLI interface to interact with your UniFi Protect instance as well. All
commands support JSON output so it works great with `jq` for complex scripting.
## Authentication
Following traditional [twelve factor app design](https://12factor.net/), the preferred way to provided authentication
credentials to provided environment variables, but CLI args are also supported.
!!! warning "About Ubiquiti SSO accounts"
Ubiquiti SSO accounts are not supported and actively discouraged from being used. There is no option to use MFA. You are expected to use local access user. `uiprotect` is not designed to allow you to use your owner account to access the your console or to be used over the public Internet as both pose a security risk.
### Environment Variables
```bash
export UFP_USERNAME=YOUR_USERNAME_HERE
export UFP_PASSWORD=YOUR_PASSWORD_HERE
export UFP_ADDRESS=YOUR_IP_ADDRESS
export UFP_PORT=443
# optional: set to false if you have a self-signed certificate
# export UFP_SSL_VERIFY=false
uiprotect nvr
```
### CLI Args
```bash
uiprotect -U YOUR_USERNAME_HERE -P YOUR_PASSWORD_HERE -a YOUR_IP_ADDRESS -p 443 --no-verify-ssl nvr
```
## Timezones
A number of commands allow you to enter a datetime as an argument or output files with the datetime in the filename. As a result, it is very important for `uiprotect` to know your consoles local timezone. If you on a physical machine (not docker/VM), chances are this is already set up correctly for you (`/etc/localtime`), but otherwise you may need to set the `TZ` environment variable. `TZ` can also be used to override your system timezone as well if for whatever reason you need to. It should be the [Olson timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the timezone that your UniFi Protect Instance is in.
```bash
TZ=America/New_York uiprotect --help
```
## Reference
```bash
$ uiprotect --help
Usage: uiprotect [OPTIONS] COMMAND [ARGS]...
UniFi Protect CLI
```
### Options
| | Option | Required? | Env | Type | Default | Description |
| ---- | ------------------------------ | ------------------ | -------------- | -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `-U` | `--username` | :white_check_mark: | `UFP_USERNAME` | text | | UniFi Protect username |
| `-P` | `--password` | :white_check_mark: | `UFP_PASSWORD` | text | | UniFi Protect password |
| `-a` | `--address` | :white_check_mark: | `UFP_ADDRESS` | text | | UniFi Protect IP address or hostname |
| `-p` | `--port` | | `UFP_PORT` | integer | `443` | UniFi Protect port |
| | `--verify-ssl/--no-verify-ssl` | | | boolean | `True` | Verify SSL certificate. Use `--no-verify-ssl` for self-signed certificates. Will prompt to disable if verification fails. |
| | `--output-format` | | | `json`,`plain` | `plain` | Preferred output format. Not all commands support both JSON and plain and may still output in one or the other. |
| `-u` | `--include-unadopted` | | | | | Include devices not adopted by this NVR. |
| | `--show-completion` | | | | | Show completion for the current shell, to copy it or customize the installation. |
| | `--help` | | | | | Show help message and exit. |
### Subcommands
For any subcommand you can use `uiprotect COMMAND --help`
Commands are split between the reverse-engineered **private** API
(username/password auth) and Ubiquiti's documented **Public Integration API**
(API-key auth — see [Public vs. private API](usage.md#public-vs-private-api)).
The API column marks which is which; note that `viewers` (private) and
`viewers-public` (public) are distinct command groups.
`Hybrid` groups still select the device from the private bootstrap, but most of
their write commands issue their PATCH through the Public Integration API. They
therefore need username/password auth for the lookup _and_ an API key for those
writes.
| Command | API | Description |
| ---------------------- | ------- | ---------------------------------------------------------------- |
| `aiports` | Private | AiPort device CLI. |
| `arm` | Public | Arm profile and alarm commands. |
| `backup` | Private | [Backup CLI](#backup-cli). |
| `bridges` | Public | Bridge commands. |
| `cameras` | Hybrid | Camera device CLI. |
| `chimes` | Private | Chime device CLI. |
| `create-api-key` | Private | Create a new API key for the current user. |
| `decode-ws-msg` | — | Decodes a base64 encoded UniFi Protect Websocket binary message. |
| `events` | Private | Events CLI. |
| `files-public` | Public | Device asset file commands. |
| `fobs` | Public | Key fob commands. |
| `generate-sample-data` | Private | Generates sample data for UniFi Protect instance. |
| `get-meta-info` | Public | Get metadata about the current UniFi Protect instance. |
| `lights` | Hybrid | Lights device CLI. |
| `link-stations` | Public | Link station and alarm hub commands. |
| `liveviews` | Public | Liveview commands. |
| `nvr` | Private | NVR device CLI. |
| `profile-ws` | Private | Profiles Websocket messages for UniFi Protect instance. |
| `relays` | Public | Relay commands. |
| `sensors` | Hybrid | Sensors device CLI. |
| `shell` | Private | Opens iPython shell with Protect client initialized. |
| `sirens` | Public | Siren commands. |
| `speakers` | Public | Speaker commands. |
| `ulp-users-public` | Public | UniFi Identity (ULP) user commands. |
| `users-public` | Public | Protect user commands. |
| `viewers` | Private | Viewers device CLI. |
| `viewers-public` | Public | Viewer commands. |
#### Multiple Item CLI Commands
All adoptable device CLIs and the event CLI work on the idea you have multiple cameras, multiple lights or multiple events. As such, they have four variations:
```bash
# list all devices (or events)
uiprotect cameras
# list short list of all devices (or events)
uiprotect cameras list-ids
# list a specific device (or event)
uiprotect cameras DEVICE_ID
# run a command against a specific device (or event)
uiprotect cameras DEVICE_ID COMMAND
```
!!! note
The "list all devices" and "list a specific device" commands always return raw JSON. These commands can be paired with [jq](https://stedolan.github.io/jq/) to parse and quick extra device data from them.
| Command | Description |
| ------------------ | ------------------------------------------------------------------------ |
| `list-ids` | Requires no device ID. Prints list of "id name" for each device. |
| `set-person-track` | Requires device ID. Sets person auto tracking on or off for PTZ cameras. |
##### Examples
###### List All Cameras
=== "Plain"
```bash
$ uiprotect cameras list-ids
61b3f5c7033ea703e7000424: G4 Bullet
61f9824e004adc03e700132c: G4 PTZ
61be1d2f004bda03e700ab12: G4 Dome
```
=== "JSON"
```bash
$ uiprotect --output-format json cameras list-ids
[
[
"61b3f5c7033ea703e7000424",
"G4 Bullet"
],
[
"61f9824e004adc03e700132c",
"G4 PTZ"
],
[
"61be1d2f004bda03e700ab12",
"G4 Dome"
],
...
]
```
###### Check if a Camera is Online
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 | jq .isConnected
true
```
###### Take Snapshot of Camera
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 save-snapshot output.jpg
```
#### Adoptable Devices CLI Commands
Adoptable devices (Cameras, Chimes, Lights, Sensors, Viewers) all have some commands in common.
| Command | Description |
| -------------- | ------------------------------------------------- |
| `adopt` | Adopts a device. |
| `bridge` | Returns bridge device if connected via Bluetooth. |
| `is-bluetooth` | Returns if the device has Bluetooth or not. |
| `is-wifi` | Returns if the device has WiFi or not. |
| `is-wired` | Returns if the device is wired or not. |
| `protect-url` | Gets UniFi Protect management URL. |
| `reboot` | Reboots the device. |
| `unadopt` | Unadopt/Unmanage adopted device. |
| `update` | Updates the device. |
#### Liveviews CLI
The `liveviews` command group is driven by the Public Integration API and
authenticates with an API key only (no username/password required). It exposes
four subcommands:
| Command | Description |
| -------- | -------------------------------------------- |
| `list` | List all liveviews. |
| `show` | Show a single liveview by ID. |
| `create` | Create a new liveview. |
| `update` | Patch an existing liveview (partial update). |
```bash
# list all liveviews
uiprotect liveviews list
# show a single liveview
uiprotect liveviews show LIVEVIEW_ID
# create a new liveview
uiprotect liveviews create \
--name "Garage" \
--owner USER_ID \
--layout 4 \
--slots '[{"cameras":["CAMERA_ID"],"cycleMode":"motion","cycleInterval":10}]'
# patch an existing liveview
uiprotect liveviews update LIVEVIEW_ID --name "New name"
```
`--slots` takes a JSON array of slot objects with keys `cameras`, `cycleMode`
(`motion` or `time`), and `cycleInterval`. `--layout` must be between 1 and 26.
See `uiprotect liveviews --help` (and `--help` on each subcommand) for the
full flag list.
#### Public Integration API Device Groups
The following command groups are driven by Ubiquiti's documented
[Public Integration API](usage.md#public-vs-private-api) and authenticate with
an API key (set `UFP_API_KEY`, or create one with `uiprotect create-api-key`),
not username/password. Each exposes `list` and (where applicable) `show`
subcommands plus group-specific actions; run `uiprotect --help` for the
full flag list.
| Group | Common subcommands |
| ------------------ | ----------------------------------------------------------------------------- |
| `arm` | `list`, `status`, `set-profile`, `enable-alarm`, `disable-alarm`, `trigger` |
| `bridges` | `list`, `show`, `set-name` |
| `fobs` | `list`, `show`, `set-name` |
| `files-public` | `list`, `upload` |
| `link-stations` | `list`, `show`, `set-name`, `trigger-output` |
| `relays` | `list`, `show`, `activate`, `set-name`, `set-status-light` |
| `sirens` | `list`, `show`, `play`, `stop`, `test-sound`, `set-volume` |
| `speakers` | `list`, `show`, `set-name`, `set-volume`, `set-mic-volume`, `set-mic-enabled` |
| `users-public` | `list`, `show` |
| `ulp-users-public` | `list`, `show` |
| `viewers-public` | `list`, `show`, `set-name`, `set-liveview` |
```bash
# list public-API sirens (API key auth)
uiprotect sirens list
# rename a viewer over the public API
uiprotect viewers-public set-name VIEWER_ID "Living Room"
```
The top-level `uiprotect get-meta-info` command is also Public-API driven.
`uiprotect create-api-key NAME` runs against the private, session-authenticated
(username/password) surface — you must already have a private session to mint
an API key.
#### Backup CLI
```bash
$ uiprotect backup --help
Usage: uiprotect backup [OPTIONS] COMMAND [ARGS]...
Backup CLI.
The backup CLI is still very WIP in progress and consider experimental and potentially unstable (interface may change in the future).
```
##### Backup Options
| | Option | Env | Type | Default | Description |
| ---- | ----------------- | ------------------- | -------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `-s` | `--start` | `UFP_BACKUP_START` | datetime | | Cutoff for start of backup. Defaults to start of recording for NVR. |
| `-e` | `--end` | `UFP_BACKUP_END` | datetime | | Cutoff for end of backup. Defaults to now. |
| | `--output-folder` | `UFP_BACKUP_OUTPUT` | path | `$PWD` | Base dir for creating files. Defaults to $PWD. |
| | `--thumb-format` | | text | `{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}thumb.jpg` | Filename format to save event thumbnails to. Set to empty string ("") to skip saving event thumbnails. |
| | `--gif-format` | | text | `{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}animated.gif]` | Filename format to save event gifs to. Set to empty string ("") to skip saving event gif. |
| | `--event-format` | | text | `{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}.mp4` | Filename format to save event gifs to. Set to empty string ("") to skip saving event videos. |
| | `--title-format` | | text | `{time_sort_pretty_local} {sep} {camera_name} {sep} {event_type_pretty} {sep} {length_pretty}` | Format to use to tag title for video metadata. |
| `-v` | `--verbose` | | boolean | `False` | Debug logging. |
| `-d` | `--max-download` | | integer | `5` | Max number of concurrent downloads. Adds additional loads to NVR. |
| | `--page-size` | | integer | `1000` | Number of events fetched at once from local database. Increases memory usage. |
| | `--length-cutoff` | | integer | `3600` | Event size cutoff for detecting abnormal events (in seconds). |
| | `--sep` | | boolean | `-` | Separator used for formatting. |
| | `--help` | | | | Show help message and exit. |
##### File Name and Title Formatting
There are [5 options](#backup-options) controlling output format for file names and metadata. This allows you to customize backups to your liking. All 5 options are a template string. Here are all of the available templating variables:
| Variable | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `year` | UTC year of start of export. |
| `month` | UTC month of start of export. |
| `day` | UTC day of start of export. |
| `hour` | UTC hour of start of export. |
| `minute` | UTC minute of start of export. |
| `datetime` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted UTC datetime of start of export. Uses `sep` between parts. |
| `date` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted UTC date of start of export. Uses `sep` between parts. |
| `time` | UTC time of start of export. Uses `sep` between parts. 24 hour time. |
| `time_sort_pretty` | UTC time of start of export. Uses `:` between parts. 24 hour time. |
| `time_pretty` | UTC time of start of export. Uses `:` between parts. 12 hour time with AM/PM. |
| `year_local` | [Local](#timezones) year of start of export. |
| `month_local` | [Local](#timezones) month of start of export. |
| `day_local` | [Local](#timezones) day of start of export. |
| `hour_local` | [Local](#timezones) hour of start of export. |
| `minute_local` | [Local](#timezones) minute of start of export. |
| `datetime_local` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted [Local](#timezones) datetime of start of export. Uses `sep` between parts. |
| `date_local` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted [Local](#timezones) date of start of export. Uses `sep` between parts. |
| `time_local` | [Local](#timezones) time of start of export. Uses `sep` between parts. 24 hour time. |
| `time_sort_pretty_local` | [Local](#timezones) time of start of export. Uses `:` between parts. 24 hour time. |
| `time_pretty_local` | [Local](#timezones) time of start of export. Uses `:` between parts. 12 hour time with AM/PM. |
| `mac` | MAC address of camera. |
| `camera_name` | Name of camera. |
| `camera_slug` | Lowercased name of camera with spaces replaced with `sep`. |
| `event_type` | Lowercased name of the event exported. |
| `event_type_pretty` | More human readable name of event exported. |
| `length_pretty` | Human readable version of the length of the clip exported. |
| `sep` | Separator to use in many cases. |
###### Datetimes
All datetimes for the Backup CLi can either be in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format or can be a human readable format that the Python library [dateparse](https://github.com/scrapinghub/dateparser) can understand. This will allow relative datetimes to be passed, such as `"1 hour ago"` which will make backing up incremental for cron jobs.
###### Formatting for Plex
You are able to export your Camera events and then access them in [Plex](https://www.plex.tv/) relatively well. For setup in Plex, the following is recommended:
- Enable the "Local Media Assets" Agent Source for the Movies Library Type (Settings -> Agents -> Movies). [Plex docs](https://support.plex.tv/articles/200265246-personal-media-movies/).
- Create a "Other Videos" library pointing to the same folder as your [--output-folder](#backup-options) folder.
- Scanner: "Plex Video Files Scanner"
- Agent: "Personal Media"
Recommended formats for the backup command:
| Option | Format |
| ---------------- | ----------------------------------------------------------------- |
| `--thumb-format` | `{year_local}/{month_local}/{day_local}/{hour_local}/{title}.jpg` |
| `--gif-format` | `{year_local}/{month_local}/{day_local}/{hour_local}/{title}.gif` |
| `--event-format` | `{year_local}/{month_local}/{day_local}/{hour_local}/{title}.mp4` |
| `--title-format` | `default` or whatever you want the title to be in Plex. |
##### Backing Up Camera Events
```bash
$ uiprotect backup events --help
Usage: uiprotect backup events [OPTIONS]
Backup thumbnails and video clips for camera events.
```
| | Option | Type | Default | Description |
| ---- | -------------- | ----------------------------------- | ----------------------------------- | ----------------------------------------------------------- |
| `-t` | `--event-type` | `motion`, `ring`, `smartDetectZone` | `motion`, `ring`, `smartDetectZone` | Events to export. Can be used multiple time. |
| `-m` | `--smart-type` | `person`, `vehicle`, `package` | `person`, `vehicle`, `package` | Smart Detection types to export. Can be used multiple time. |
| `-p` | `--prune` | boolean | `False` | Prune events older then start. |
| `-f` | `--force` | boolean | `False` | Force update all events and redownload all clips. |
| `-v` | `--verify` | boolean | `False` | Verifies files on disk. |
| | `--no-input` | boolean | `False` | Disables confirmation prompt if `-p` and `-f` both passed. |
| | `--help` | | | Show help message and exit. |
The `backup events` command essentially mirrors all of the selected events from your UniFi Protect instance into a local sqlite database (`events.db` inside of the `--output-folder`). As a result, the initial run make take a _really long time_ to run if your UniFi Protect instance has a lot of events inside of it.
As an example using a UniFi Protect instance with ~200k events and ~8 months of camera footage:
- Building the database is in the range of hours
- Doing the initial download of event thumbnails, gifs and video clips is in the range of tens of hours (potentially 1-2 days)
- Incremental or targeted backups are much faster (<1 per event)
!!! note "Cron Usage"
For incremental backups in crons, it is recommended you run the command with an absolute start first to build your events database and do an initial download of files. This will significantly speed up the incremental backup commands.
##### Examples
###### Backup All Events
```bash
uiprotect backup events
```
###### Backup All Smart Detections for the Past Hour
```bash
uiprotect backup --start "1 hour ago" events -t smartDetectZone
```
###### Backup All Person Smart Detections from December 31st at 10PM to January 1st at 5AM
```bash
uiprotect backup --start "2021-12-31T22:00:00" --end "2022-1-1T05:00:00" events -t smartDetectZone -m person
```
#### Camera CLI
Inherits [Multiple Item CLI Commands](#multiple-item-cli-commands) and [Adoptable Devices CLI Commands](#adoptable-devices-cli-commands).
##### Examples
###### Take Snapshot of Camera
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 save-snapshot output.jpg
```
###### Export Video From Camera
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 save-video export.mp4 2022-6-1T00:00:00 2022-6-1T00:00:30
```
!!! note "Timezones"
See the section on [Timezones](#timezones) for determining what timezone your datetimes are in.
###### Play Audio File to Cameras Speaker
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 play-audio test.mp3
```
###### Include Unadopted Cameras in list
```bash
$ uiprotect -u cameras list-ids
```
###### Adopt an Unadopted Camera
```bash
$ uiprotect -u cameras 61ddb66b018e2703e7008c19 adopt
```
###### Enable SSH on Camera
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 set-ssh true
# get current value to verify
$ uiprotect cameras 61ddb66b018e2703e7008c19 | jq .isSshEnabled
true
```
###### Reboot Camera
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 reboot
```
###### Reboot All Cameras
```bash
for id in $(uiprotect cameras list-ids | awk '{ print $1 }'); do
uiprotect cameras $id reboot
done
```
#### Chime CLI
Inherits [Multiple Item CLI Commands](#multiple-item-cli-commands) and [Adoptable Devices CLI Commands](#adoptable-devices-cli-commands).
##### Examples
###### Set Paired Cameras
```bash
$ uiprotect chimes 6275b22e00e3c403e702a019 cameras 61ddb66b018e2703e7008c19 61f9824e004adc03e700132c
```
uilibs-uiprotect-317a52c/docs/contributing.md 0000664 0000000 0000000 00000000054 15246377057 0021344 0 ustar 00root root 0000000 0000000 {% include-markdown "../CONTRIBUTING.md" %}
uilibs-uiprotect-317a52c/docs/dev.md 0000664 0000000 0000000 00000017105 15246377057 0017420 0 ustar 00root root 0000000 0000000 ---
hide:
- navigation
---
# Development
## Setup
### With VS Code
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.
- [VS Code](https://code.visualstudio.com/) + [Remote Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
- [Docker](https://docs.docker.com/get-docker/)
- 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/)
- If you are using Docker Desktop on MacOS or Windows, you will need Docker Desktop 3.2.0 or newer
Once you have all three setup,
1. Clone repo
2. Open the main folder
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.
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.
### Docker (without VS Code)
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.
Once you have Docker setup,
1. Clone repo
2. Build and open dev container
```bash
docker buildx build -f Dockerfile --target=dev -t uiprotect-dev .
docker run --rm -it -v "$(pwd)":/workspaces/uiprotect uiprotect-dev bash
```
## Authenticating with your Local Protect Instance
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.
Make a file in the root of the project named `.env` with the following and change accordingly:
```
UFP_USERNAME=YOUR_USERNAME_HERE
UFP_PASSWORD=YOUR_PASSWORD_HERE
UFP_ADDRESS=YOUR_IP_ADDRESS
UFP_PORT=443
# set to true if you have a valid HTTPS certificate for your instance
UFP_SSL_VERIFY=false
```
## Linting and Testing
The following scripts exist to easily format, lint and test code in the same fashion as CI:
```
pre-commit run --all-files
.bin/test-code
```
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.
If you are not using the dev container, the same checks run under Poetry:
```bash
poetry install --all-extras
poetry run pre-commit run --all-files
poetry run pytest
```
## Updating Requirements
To regenerate the pinned release cache used for testing and CI, run the
`.bin/update-release-cache` script.
There is also a [VS Code task](https://code.visualstudio.com/Docs/editor/tasks) to run this as well.
## Generating Test Data
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:
```
uiprotect generate-sample-data
```
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.
- 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.
- To change output directory for sample data use the `-o / --output` option.
- To adjust the time adjust how long to wait for Websocket messages, use the `-w / --wait` option.
- To automatically zip up the generated sample data, use the `--zip` option.
```bash
export UFP_SAMPLE_DIR=/workspaces/uiprotect/test-data
uiprotect generate-sample-data
```
### Real Data in Tests
`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.
```bash
export UFP_SAMPLE_DIR=/workspaces/uiprotect/test-data
pytest
```
## Adding a public API endpoint
The uniform slice of the Public Integration API surface on `ProtectApiClient` is declared with decorators from `uiprotect._public_api`. You keep the hand-written `async def` signature and one-line docstring (mypy-strict and the rendered API reference depend on them); the decorator supplies the body — path binding, payload assembly, dispatch through the existing `api_request_*` helpers, and model construction. The body is a stub (`raise NotImplementedError`, which is never reached because the decorator replaces it).
There are three forms:
```python
from ._public_api import public_get, public_patch, public_post
# List GET — one model per array entry (items=, plural)
@public_get("/v1/cameras", items=PublicCamera)
async def get_cameras_public(self) -> list[PublicCamera]:
"""Get all cameras using public API."""
raise NotImplementedError
# Object GET — a single model (item=, singular); placeholders bind from same-named params
@public_get("/v1/cameras/{camera_id}", item=PublicCamera)
async def get_camera_public(self, camera_id: str) -> PublicCamera:
"""Get a specific camera using public API."""
raise NotImplementedError
# Flat PATCH — body is the non-None keyword params, snake_case → camelCase
@public_patch("/v1/fobs/{fob_id}", item=Fob)
async def update_fob_public(self, fob_id: str, *, name: str | None = None) -> Fob:
"""Patch key-fob settings using public API."""
raise NotImplementedError
# Fire-and-forget POST — path-only, no body, no return value
@public_post("/v1/sirens/{siren_id}/stop")
async def stop_siren_public(self, siren_id: str) -> None:
"""Stop an active siren."""
raise NotImplementedError
```
`item=` always means a single model, `items=` a list; `public_get` takes exactly one of them (XOR), while `public_patch` returns one `item=` and `public_post` takes neither. Each decorator registers its `(verb, path)` against the class at import time; declaring the same `(verb, path)` twice raises immediately. The signature is validated at decoration too — every `{placeholder}` must name a real parameter, and a body-less `public_get`/`public_post` may not carry any non-placeholder parameter — so a declaration that lies fails the import, not a later call. The model class is passed as an argument (`item=`/`items=`), so `_public_api.py` imports nothing from `uiprotect.data` and stays circular-import-safe.
**The PATCH form is flat-body only.** A `@public_patch` body is built mechanically: every non-`None` keyword parameter becomes one camelCase wire key, and an empty body raises `BadRequest("At least one parameter must be provided")`. Path placeholders never leak into the body. This rule is correct only for genuinely uniform methods — anything that groups keys into nested objects (`ledSettings`/`osdSettings`), renames non-mechanically, validates ranges, carries a nullable `_UNSET` sentinel, or writes through to the public bootstrap cache (e.g. `update_camera_public`, `update_light_public`, `update_viewer_public`) stays hand-written. When in doubt, keep the body hand-written — behavior parity beats terseness. The hand-written exceptions are covered by their recorded example calls.
uilibs-uiprotect-317a52c/docs/index.md 0000664 0000000 0000000 00000000104 15246377057 0017740 0 ustar 00root root 0000000 0000000 ---
hide:
- navigation
---
{% include-markdown "../README.md" %}
uilibs-uiprotect-317a52c/docs/installation.md 0000664 0000000 0000000 00000001212 15246377057 0021333 0 ustar 00root root 0000000 0000000 # Installation
The package is published on [PyPI](https://pypi.org/project/uiprotect/) and can be installed with `pip` (or any equivalent):
```bash
pip install uiprotect
```
To use the command-line interface (the `uiprotect` console script), install
the `cli` extra, which pulls in [`typer`](https://typer.tiangolo.com/):
```bash
pip install "uiprotect[cli]"
```
`uiprotect` supports **Python 3.11+**.
Next, see the [usage](usage.md) page to see how to use it. If you plan to talk
to the documented [Public Integration API](usage.md#public-vs-private-api)
(API-key authentication), the usage page covers when to prefer it over the
private API.
uilibs-uiprotect-317a52c/docs/overrides/ 0000775 0000000 0000000 00000000000 15246377057 0020316 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/docs/overrides/main.html 0000664 0000000 0000000 00000000273 15246377057 0022132 0 ustar 00root root 0000000 0000000 {% extends "base.html" %} {% block outdated %} You're not viewing the latest
version.
Click here to go to latest.
{% endblock %}
uilibs-uiprotect-317a52c/docs/overrides/partials/ 0000775 0000000 0000000 00000000000 15246377057 0022135 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/docs/overrides/partials/toc-item.html 0000664 0000000 0000000 00000000702 15246377057 0024543 0 ustar 00root root 0000000 0000000 {% if not page.meta.toc_depth or toc_item.level <= page.meta.toc_depth %}
{{ toc_item.title }}
{% if toc_item.children %}
{% for toc_item in toc_item.children %} {% include
"partials/toc-item.html" %} {% endfor %}
{% endif %}
{% endif %}
uilibs-uiprotect-317a52c/docs/usage.md 0000664 0000000 0000000 00000035737 15246377057 0017761 0 ustar 00root root 0000000 0000000 # Usage
Assuming that you've followed the [installation steps](installation.md),
you're now ready to use this package. `uiprotect` is an async library, so the
examples below drive an event loop with `asyncio.run`.
## Command line
Most day-to-day tasks are available through the bundled CLI:
```bash
uiprotect --help
```
See the [Command Line](cli.md) reference for the full list of subcommands.
## Connecting from Python
Instantiate `ProtectApiClient` with your console's address and a local-access
user, call `update()` once to fetch the bootstrap snapshot, then read devices
off the cached `bootstrap`:
```python
import asyncio
from uiprotect import ProtectApiClient
async def main() -> None:
protect = ProtectApiClient(
host="10.0.0.1",
port=443,
username="YOUR_USERNAME",
password="YOUR_PASSWORD",
verify_ssl=True, # set False only for a self-signed certificate
)
# Fetch the bootstrap snapshot (cameras, lights, sensors, NVR, ...).
await protect.update()
for camera in protect.bootstrap.cameras.values():
print(camera.id, camera.name, camera.is_connected)
await protect.close_session()
asyncio.run(main())
```
!!! warning "About Ubiquiti SSO accounts"
Ubiquiti SSO (cloud) accounts are not supported. Use a local-access user.
Driving the cloud owner account over the public Internet is a security
risk and there is no MFA support.
## Subscribing to events
The typed event stream delivers `(ProtectEvent, EventChange)` pairs. It is
backed by the Public Integration API, so the client must be configured with
an `api_key` and `update_public()` must have run at least once before calling
`subscribe_events`. The example below primes then subscribes; to avoid
encoding that ordering yourself, call `subscribe_events_and_prime()` (or
`subscribe_devices_and_prime()` for device state), which connects the
WebSocket and primes in the correct order in a single call. With the combined
helper, frames that arrive while `update_public()` is priming are buffered
and replayed onto the fresh snapshot, so the already-connected subscriber
does not miss an update from the prime window; with the explicit two-step
form the subscriber is registered after priming and simply starts from the
refreshed cache:
```python
import asyncio
import logging
from uiprotect import EventChange, ProtectApiClient, ProtectEvent
_LOGGER = logging.getLogger(__name__)
async def main() -> None:
protect = ProtectApiClient(
host="10.0.0.1",
port=443,
username="YOUR_USERNAME",
password="YOUR_PASSWORD",
api_key="YOUR_API_KEY",
verify_ssl=True, # set False only for a self-signed certificate
)
await protect.update_public()
def on_event(event: ProtectEvent, change: EventChange) -> None:
if change is EventChange.STARTED:
_LOGGER.info("%s on %s", event.type, event.device_id)
unsubscribe = protect.subscribe_events(on_event)
try:
await asyncio.sleep(60)
finally:
unsubscribe()
await protect.close_session()
asyncio.run(main())
```
The lower-level `subscribe_websocket` continues to deliver raw
`WSSubscriptionMessage` frames for advanced callers over the private API and
does not require an API key. The parallel `subscribe_events_websocket` drives
the Public Integration API WebSocket and **does** require an API key. See the
project README for the full notes on the typed event contract.
### Detecting a revoked API key
A revoked or invalid API key can't be re-authenticated from inside the client
— the key is static — so the public WebSocket would otherwise redial forever on
repeated `401` handshakes without the consumer ever learning the key died.
After two consecutive `401`s the client emits `WebsocketState.AUTH_FAILED` over
the existing state channels (`subscribe_events_websocket_state` /
`subscribe_devices_websocket_state`) and switches to a longer backoff to stop
hammering the NVR. `AUTH_FAILED` **implies disconnected** — the socket is closed
and cannot recover without a new key — so the client always emits a
`DISCONNECTED` transition first when it trips. A consumer that tracks
connectivity as `state is WebsocketState.CONNECTED` and one that only reacts to
`DISCONNECTED` edges both observe the loss; `AUTH_FAILED` is the additional
signal that the cause is a dead key rather than a transient drop. Subscribe to
that channel to be notified, then install a fresh key with `set_api_key()` — it
re-arms both public WebSockets immediately so recovery doesn't wait out the
backoff:
```python
def on_state(state: WebsocketState) -> None:
if state is WebsocketState.AUTH_FAILED:
protect.set_api_key(fetch_new_api_key())
unsub = protect.subscribe_events_websocket_state(on_state)
```
Events arrive **only** on the events WebSocket, so if it drops and reconnects
while the devices WebSocket stays up, an `end` frame missed during the gap
would otherwise leave the event active — a camera's derived
`is_*_currently_detected` stuck ON until the periodic TTL sweep (~45 min worst
case) closes it. On events-WS reconnect the client therefore **force-ends
active detection events regardless of age** (a still-active detection
re-asserts on its next frame) and flushes other channels past the 1 h
staleness window. Both the typed `subscribe_events` stream (an `ENDED` change)
and `subscribe_devices` (a camera update naming the flipped `is_*_detected`
fields) see the drop immediately, and the derived camera flags read correct on
the next synchronous access.
A device added to (or removed from) the console while the devices WebSocket is
down produces no wire frame either. The reconnect resync re-fetches the public
bootstrap and announces that difference on the same channel: one `add` frame
per device the refresh newly reports and one `remove` frame per device it no
longer reports, delivered after the whole snapshot has merged so a subscriber
reacting to a frame already sees a consistent bootstrap. `subscribe_devices`
surfaces them as `DeviceChange.ADDED` / `DeviceChange.REMOVED`, exactly like
live traffic, so consumers need no add/remove bookkeeping of their own. The
initial `update_public()` prime stays silent — it is the baseline, not a diff.
## Camera RTSPS streams
RTSPS stream URLs live on the camera as `PublicCamera.rtsps_streams`. The
library owns their entire lifecycle: `update_public()` primes them, so a
consumer reads the field synchronously and carries no fetch/cache code:
```python
await protect.update_public()
# Read synchronously off the camera — primed by update_public().
camera = protect.public_bootstrap.cameras[camera_id]
streams = camera.rtsps_streams
```
Priming is **always** run by `update_public()`: connected cameras without
streams are fetched under a bounded-concurrency semaphore, best-effort (one
slow/offline camera is skipped, never aborting the rest), and disconnected
cameras are skipped. `get_camera_rtsps_streams(camera_id)` is a flag-free fetch
primitive used internally — it issues a plain GET with no cache side-effects.
The field stays correct from the sources the client controls — stream
create/delete is **not** signalled over the WebSocket, so passive observation
is impossible:
- **Write-through.** `create_camera_rtsps_streams` writes its result onto the
camera's `rtsps_streams`; `delete_camera_rtsps_streams` drops the deleted
qualities (and clears the field to `None` once no streams remain).
- **Prime / refresh on connect.** When a public devices-WS frame moves a camera
to `CONNECTED` (a reconnect or firmware change can rotate the `rtsp_alias`), a
background fetch is scheduled. A camera that was **offline at `update_public()`
time** — so skipped by the connected-only prime — is **primed** when it comes
online mid-session, not left streamless until the next reload. An
already-populated camera is **refreshed in place** instead. Either way a
WebSocket reconnect resync also refreshes every populated camera. The field is
**never emptied** — the old URLs stay readable until the fresh ones land, so
synchronous consumers reading `camera.rtsps_streams` never observe a spurious
`None`. It is only ever cleared by a camera `remove` frame (which drops the
whole camera) or the client's own `delete_camera_rtsps_streams`.
- **Observable.** A background prime/refresh that actually changes a camera's
streams is announced: the client emits a synthetic devices-WS `update` for
that camera (`new_obj` is the refreshed camera) through the existing devices
subscription, so both `subscribe_devices_websocket` and typed
`subscribe_devices` consumers observe stream availability without polling.
A refresh that yields no change — an identity-guard backoff, a fetch failure,
or a re-fetch equal to the cached value — emits nothing.
## Siren timed runs
`siren.play(duration)` starts a timed run and the console reports it on
`sirenStatus` (`isActive`, `activatedAt`, and `duration` in **milliseconds** —
the play request takes seconds). It reports nothing when the run ends, so
`Siren.is_active` compares the clock against `siren_status.turn_off_at`.
The client also **announces** the end of the run: it arms a timer at
`turn_off_at`, and when the run expires it clears the cached
`sirenStatus.isActive` and emits a synthetic devices-WS `update` for the siren
(`new_obj` is the siren) through the existing devices subscription. Both
`subscribe_devices_websocket` and typed `subscribe_devices` consumers observe
the siren stopping without scheduling a timer of their own.
The timer follows the device: every siren frame re-derives the deadline from
the merged status (so a restarted or manually stopped run re-arms or disarms
it), a `remove` frame drops it, a devices-websocket disconnect drops it and the
reconnect re-arms it from the cached status (the resync refreshes that status
when it runs), and `close_session()` drains any that are still pending.
The expiry is **inferred, not observed**: `activatedAt` is server time and the
deadline is compared against the local clock, so clock skew between the console
and the client shifts the announcement by the same amount.
## Public vs. private API
`uiprotect` can talk to UniFi Protect two ways, and is actively migrating
from the second to the first:
- **Public Integration API** — Ubiquiti's officially documented REST API
under `/integration/v1/…`. It authenticates with an **API key** (create one
with `uiprotect create-api-key NAME`), is stable across firmware releases,
and is the forward-looking path. The typed `subscribe_events` stream and the
public-API CLI groups (`viewers-public`, `users-public`, `liveviews`, …) are
driven by this API. Requests on this path are **auto-paced** to stay under
the server's per-API-key rate budget — the client seeds its rate from the
`RateLimit-Policy` header on the first response (with safety margin for the
shared-budget public WebSocket) and falls back to a conservative default
until that header is seen, so a bootstrap fan-out no longer trips a `429`
storm. Rotating the key via `set_api_key()` resets the pacing.
- **Private API** — the reverse-engineered, undocumented endpoints under
`/api/…` plus the binary WebSocket update stream. It authenticates with
username/password and powers most of the historical `bootstrap`-based
surface. It is **not** documented by Ubiquiti, can shift between firmware
versions, and is being deprecated capability-by-capability as the public
API gains coverage.
Prefer the public API for new code. Reach for the private API only for
capabilities the public API does not yet expose; treat that as a temporary
escape hatch rather than the default path.
## Device convenience setters (public API)
Camera, light, sensor and chime devices from `public_bootstrap` expose `set_*`
convenience methods that patch a single setting and write the server's response
straight back into the cached device — so a **public-only client** (API key, no
username/password) can mutate a device without hand-building nested
`update_*_public` bodies:
```python
await protect.update_public()
camera = next(iter(protect.public_bootstrap.cameras.values()))
# Write-through: the cached camera reflects the change on return.
await camera.set_status_light(True)
await camera.set_mic_volume(50)
assert protect.public_bootstrap.cameras[camera.id].mic_volume == 50
```
Each setter validates the device's capability flags (e.g. a camera without a
microphone rejects `set_mic_volume`), applies the same numeric bounds the server
enforces, and serialises concurrent read-modify-write setters (chime ring
volume, camera smart-detect toggles, light mode/device settings) on one device
under a per-object lock.
`PublicCamera.rtsps_streams` is owned out-of-band by the library and is never
touched by a setter.
The private-API `Device.set_*_public` methods remain and are unchanged; when a
public bootstrap is loaded they keep the cached public twin fresh through the
same `update_*_public` endpoints, so the Home Assistant integration and the CLI
continue to work as before.
## POS transaction overlay (public API)
`create_pos_transaction_public()` records a point-of-sale transaction as a
camera event so the receipt can be overlaid on recorded footage:
```python
from uiprotect.data import PosTransactionType
result = await protect.create_pos_transaction_public(
camera_id,
type=PosTransactionType.SALE,
external_id="order-4711",
amount=12.50,
currency="USD",
line_items=[{"title": "Coffee", "quantity": 2}],
)
```
`external_id` is unique per camera and drives best-effort idempotency: a repeat
comes back with `created=False` and `event_id` echoing the event already
ingested, while a concurrent repeat raises `BadRequest` on the server's 409 and
should be retried shortly. That de-duplication is in-memory and per-process, so
it does not survive a Protect restart. Overlay capture is best-effort too — a
200 confirms the event was recorded, not that footage exists for the window.
## Shared identity interface
The public device models expose the same derived identity attributes as the
private tree: `display_name` (falling back `name → type`, mirroring the private
`name → market_name → type`) and a `type` alias for the raw `device_type`
field. So `PublicNVR().display_name` and `PublicCamera().type` work the same as
their private-tree counterparts.
Code that handles "the NVR" or "a camera" generically — for example the Home
Assistant integration's device-info paths, which must now accept either tree —
can type against the `ProtectDeviceIdentity` protocol instead of `cast()`-ing
between the unrelated private (`NVR`, `Camera`, …) and public (`PublicNVR`,
`PublicCamera`, …) types. Both trees satisfy it structurally:
```python
from uiprotect.data import ProtectDeviceIdentity
def label(device: ProtectDeviceIdentity) -> str:
# Accepts an NVR or a PublicNVR (a Camera or a PublicCamera, …) unchanged.
return f"{device.display_name} ({device.type}) [{device.mac}] {device.id}"
```
The protocol covers `id`, `mac`, `display_name`, `type`, and `model`; `mac` and
`type` are optional because the public tree omits them on older firmware.
uilibs-uiprotect-317a52c/mkdocs.yml 0000664 0000000 0000000 00000004276 15246377057 0017400 0 ustar 00root root 0000000 0000000 site_name: Unofficial UniFi Protect Python API and CLI
site_url: https://uilibs.github.io/uiprotect/latest/
site_description: Unofficial UniFi Protect Python API and CLI
repo_name: uiprotect
repo_url: https://github.com/uilibs/uiprotect
copyright: uiprotect is an unofficial API for UniFi Protect. There is no affiliation with Ubiquiti.
markdown_extensions:
- abbr
- admonition
- toc:
permalink: true
toc_depth: "1-5"
- pymdownx.highlight:
anchor_linenums: true
use_pygments: true
auto_title: true
linenums: true
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- attr_list
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
nav:
- Home: "index.md"
- Installation: "installation.md"
- Usage: "usage.md"
- Command Line: "cli.md"
- API Reference: "api.md"
- Development: "dev.md"
- Contributing: "contributing.md"
- Changelog: "https://github.com/uilibs/uiprotect/releases"
plugins:
- search
- mike:
canonical_version: null
version_selector: true
css_dir: css
javascript_dir: js
- git-revision-date-localized:
enable_creation_date: true
- include-markdown
- mkdocstrings:
default_handler: python
handlers:
python:
paths: [src]
options:
show_root_toc_entry: false
show_signature_annotations: true
show_source: false
heading_level: 3
theme:
name: material
custom_dir: docs/overrides
features:
- navigation.instant
- navigation.tracking
- navigation.tabs
- navigation.top
- search.suggest
- search.highlight
- search.share
- header.autohide
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
primary: blue
accent: light blue
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to light mode
primary: blue
accent: light blue
extra:
version:
provider: mike
uilibs-uiprotect-317a52c/openapi/ 0000775 0000000 0000000 00000000000 15246377057 0017017 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/openapi/.validated-version 0000664 0000000 0000000 00000000010 15246377057 0022427 0 ustar 00root root 0000000 0000000 7.2.105
uilibs-uiprotect-317a52c/poetry.lock 0000664 0000000 0000000 00001204040 15246377057 0017561 0 ustar 00root root 0000000 0000000 # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "aiofiles"
version = "25.1.0"
description = "File support for asyncio."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"},
{file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"},
]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
description = "Happy Eyeballs for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"},
{file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"},
]
[[package]]
name = "aiohttp"
version = "3.14.3"
description = "Async http client/server framework (asyncio)"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b"},
{file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a"},
{file = "aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5"},
{file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f"},
{file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43"},
{file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9"},
{file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8"},
{file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479"},
{file = "aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b"},
{file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d"},
{file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d"},
{file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2"},
{file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48"},
{file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f"},
{file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32"},
{file = "aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e"},
{file = "aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c"},
{file = "aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb"},
{file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3"},
{file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a"},
{file = "aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8"},
{file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239"},
{file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f"},
{file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06"},
{file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929"},
{file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db"},
{file = "aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce"},
{file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c"},
{file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15"},
{file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c"},
{file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae"},
{file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910"},
{file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7"},
{file = "aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa"},
{file = "aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d"},
{file = "aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39"},
{file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5"},
{file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228"},
{file = "aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee"},
{file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a"},
{file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b"},
{file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529"},
{file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787"},
{file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42"},
{file = "aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b"},
{file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043"},
{file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427"},
{file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d"},
{file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0"},
{file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d"},
{file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19"},
{file = "aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559"},
{file = "aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a"},
{file = "aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c"},
{file = "aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86"},
{file = "aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627"},
{file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82"},
{file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c"},
{file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f"},
{file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80"},
{file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0"},
{file = "aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf"},
{file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd"},
{file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807"},
{file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8"},
{file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24"},
{file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5"},
{file = "aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4"},
{file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9"},
{file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1"},
{file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371"},
{file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde"},
{file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e"},
{file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71"},
{file = "aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0"},
{file = "aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883"},
{file = "aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2"},
{file = "aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062"},
{file = "aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6"},
{file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919"},
{file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7"},
{file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0"},
{file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924"},
{file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646"},
{file = "aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b"},
{file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30"},
{file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9"},
{file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f"},
{file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d"},
{file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147"},
{file = "aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c"},
{file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a"},
{file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0"},
{file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661"},
{file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22"},
{file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41"},
{file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf"},
{file = "aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da"},
{file = "aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100"},
{file = "aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc"},
{file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b"},
{file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0"},
{file = "aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e"},
{file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716"},
{file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f"},
{file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553"},
{file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100"},
{file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85"},
{file = "aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33"},
{file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f"},
{file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0"},
{file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098"},
{file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25"},
{file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9"},
{file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb"},
{file = "aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963"},
{file = "aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b"},
{file = "aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7"},
{file = "aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc"},
]
[package.dependencies]
aiohappyeyeballs = ">=2.5.0"
aiosignal = ">=1.4.0"
attrs = ">=17.3.0"
frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0"
propcache = ">=0.2.0"
typing_extensions = {version = ">=4.4", markers = "python_version < \"3.13\""}
yarl = ">=1.17.0,<2.0"
[package.extras]
speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\" and sys_platform != \"android\" and sys_platform != \"ios\"", "aiodns (>=3.3.0) ; sys_platform != \"android\" and sys_platform != \"ios\"", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\" and sys_platform != \"android\" and sys_platform != \"ios\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""]
[[package]]
name = "aiosignal"
version = "1.4.0"
description = "aiosignal: a list of registered asynchronous callbacks"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"},
{file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"},
]
[package.dependencies]
frozenlist = ">=1.1.0"
typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""}
[[package]]
name = "aiosqlite"
version = "0.22.1"
description = "asyncio bridge to the standard sqlite3 module"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"},
{file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"},
]
[package.extras]
dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"]
docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"]
[[package]]
name = "aiozoneinfo"
version = "0.2.3"
description = "Tools to fetch zoneinfo with asyncio"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiozoneinfo-0.2.3-py3-none-any.whl", hash = "sha256:5423f0354c9eed982e3f1c35edeeef1458d4cc6a10f106616891a089a8455661"},
{file = "aiozoneinfo-0.2.3.tar.gz", hash = "sha256:987ce2a7d5141f3f4c2e9d50606310d0bf60d688ad9f087aa7267433ba85fff3"},
]
[package.dependencies]
tzdata = ">=2024.1"
[[package]]
name = "annotated-doc"
version = "0.0.4"
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
]
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
name = "ast-serialize"
version = "0.4.0"
description = "Python bindings for mypy AST serialization"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ast_serialize-0.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a6f26937ce0293aafbece0e39019e020369a5a70486ff4088227f0cc888844a9"},
{file = "ast_serialize-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:074032142777e3e6091977dc3c5146a8ca58ae6825b7f64e9a0b604153ddabd8"},
{file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:404f3462b4532e13a70b8849bba241dbd82e30043ff58d98c7e762fd925b116a"},
{file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97c55336e16f5c4ca2bde7be94cca4b8f7d665d64f7008925a82e02707ba14ac"},
{file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:732b4ef76adcb0f298a7d18c4558336d83b1384f9ae0c7eaa1dc8d031b0a4390"},
{file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b3db87c4772097c0782250bcd550d66b1189a8c889793c7bcf153f4fee70005c"},
{file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43729a5e369ebbe7750635c0c206bc616fcd36e703cb9c4497d6b4df0291ee64"},
{file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:91d3786f3929786cdc4eeedfd110abb4603e7f6c1390c5af398f333a947b742d"},
{file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7fba7315fd4bd87cb5560792709f6e66e0606402d362c0a38dd32dfb66ba6066"},
{file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4db9769d57deb5545ce56ebbbbe3436dcc0ae2688ce14c295cd14e106624ece7"},
{file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dcd04f85a29deb80400e8987cfaceb9907140f763453cbffdbd6ff36f1b32c12"},
{file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:905fc11940831454d93589bd7ce2acb6a5eb01c2936156f751d2a21087c98cd3"},
{file = "ast_serialize-0.4.0-cp314-cp314t-win32.whl", hash = "sha256:3bdde2c4570143791f636aed4e3ef868f5b46eb90a18f8d5c41dd045aab08bef"},
{file = "ast_serialize-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6551d55b8607b97a7755683d743200b398c61a0b71a11b7f00c89c335a11d0f4"},
{file = "ast_serialize-0.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7234ff086cb152ea2a3b7ef895b5ebeb6d80779df049d5c6431c8e3536d5b03c"},
{file = "ast_serialize-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dcded5056d9f3d201df7833082c07ebcbc566ffc3d4105c9fc9fe278fa086ecb"},
{file = "ast_serialize-0.4.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bd50d201098aae0d202805fe9606c0545492f69a3ec4403337e32c54ad29fc41"},
{file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6615b39cd747967c3aabe68bf3f5f26748e823cc6b474ddc1510ed188a824149"},
{file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91362c0a9fdf1c344b7f50a5b0508b11a0732102998fbd754a191f7187e77031"},
{file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70d9c5d527bbfa69bd3c7d17dac11fb6781e36186a434a06d7d5892e0b2f88f9"},
{file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4738790cf54d8b416de992b87ee567056980bc82134d52458bd4985f389d1658"},
{file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:faa008dccfcb793ae9101325e4d6d026caaa5d845c2182f03749c759834b0a3a"},
{file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c5245228e65d38cb48e1251f0ca71b0fa417e527141491e8c92f740e8e2d121"},
{file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8f5153e9c44a02e61f4042c5f9249d2e8a759773d621a0b2f445a899e536e181"},
{file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1e1fb90def261f6a0db885876f7e1a49ad2dbac38ad9f2f62dba2f9543af16e7"},
{file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf2ff7b654c8e95143e20f5d75878cbb78b65b928b26c4d58ef71cdba9d6d981"},
{file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90fc5c0d35a22f1a92dd33635508626d50f8fc64deb897c23e78e666a60804c9"},
{file = "ast_serialize-0.4.0-cp39-abi3-win32.whl", hash = "sha256:9ecd6a1fc1b86f1f4e8ae206759b6319c10019706b3496b01b54d02b9b2cd918"},
{file = "ast_serialize-0.4.0-cp39-abi3-win_amd64.whl", hash = "sha256:79c8d015c771c8bfdb1208003b227b27c40034790a2c29c09f2317a041825ce2"},
{file = "ast_serialize-0.4.0-cp39-abi3-win_arm64.whl", hash = "sha256:1026f565a7ab846337c630909089b3346a2fe417bf1552b1581ab01852137407"},
{file = "ast_serialize-0.4.0.tar.gz", hash = "sha256:74e4e634ab82d1466acf0be27043178570b98ebeaa3165f9240a6fad4c286471"},
]
[[package]]
name = "attrs"
version = "25.4.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"},
{file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
]
[[package]]
name = "av"
version = "17.1.0"
description = "Pythonic bindings for FFmpeg's libraries."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "av-17.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:19c84fd72af5ef81a20f18fbc6f9aedff9e1455e53a7062c1d4c95926d73da4e"},
{file = "av-17.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:19264c9bb4bee404accc7ce9ec461f2044b7f577a70234d29aafde31ed17de46"},
{file = "av-17.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22dff0ae582d10ef08c75c2150a4fd27cfc26653b54930c7c27b9f7b3aa20723"},
{file = "av-17.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:90c49bc9608377d01e82e747377505419a229464873341db18202d5dddecce5a"},
{file = "av-17.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cc5a5247622cb77e24c342364eb68f88c1442ddfaab60c1f1f483359d3cc7879"},
{file = "av-17.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff457ed419348e5b8e8c811d341389b052c5e4d5839da3794d019b125b9fe830"},
{file = "av-17.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1370b11a697eb3f2555906f8ab3519b0cfe48425d7830a3996ad42e6bffafda5"},
{file = "av-17.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd41e53f53f9a3260751d9c3c11d34e93d70d61e506c81f13dbc1e3606e07b"},
{file = "av-17.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3453b06075c7bb973fdb6de52563f7692ff05cbc64c0bb45f4fd6e8709131f2f"},
{file = "av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41"},
{file = "av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9"},
{file = "av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847"},
{file = "av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f"},
{file = "av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702"},
{file = "av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f"},
{file = "av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0"},
{file = "av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e"},
{file = "av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c"},
{file = "av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6"},
{file = "av-17.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240"},
{file = "av-17.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa"},
{file = "av-17.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c"},
{file = "av-17.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63"},
{file = "av-17.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a"},
{file = "av-17.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19"},
{file = "av-17.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b"},
{file = "av-17.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02"},
{file = "av-17.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f"},
{file = "av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4"},
{file = "av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3"},
]
[[package]]
name = "babel"
version = "2.17.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"},
{file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"},
]
[package.extras]
dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
[[package]]
name = "backrefs"
version = "5.9"
description = "A wrapper around re and regex that adds additional back references."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f"},
{file = "backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf"},
{file = "backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa"},
{file = "backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b"},
{file = "backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9"},
{file = "backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60"},
{file = "backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59"},
]
[package.extras]
extras = ["regex"]
[[package]]
name = "blockbuster"
version = "1.5.26"
description = "Utility to detect blocking calls in the async event loop"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb"},
{file = "blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629"},
]
[package.dependencies]
forbiddenfruit = {version = ">=0.1.4", markers = "implementation_name == \"cpython\""}
[[package]]
name = "bracex"
version = "2.6"
description = "Bash style brace expander."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952"},
{file = "bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7"},
]
[[package]]
name = "certifi"
version = "2025.10.5"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"},
{file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"},
]
[[package]]
name = "cfgv"
version = "3.5.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"},
{file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
]
[[package]]
name = "click"
version = "8.3.0"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev", "docs"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {main = "extra == \"cli\" and platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
[[package]]
name = "convertertools"
version = "1.1.0"
description = "Tools for converting python data types"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "convertertools-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cb6881bb503f9b42b7846ce3b90e55596e5452f70faa8fd35aeb3dd9fe49bc61"},
{file = "convertertools-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c42e1701fa72039fedaa49649a295be4c622053df86ce2c0e9671a99ed10930"},
{file = "convertertools-1.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef5d1920b2fc52408edf25e8aede88cecd636860794838b4042ebd8aa595f206"},
{file = "convertertools-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c500dd22f69eeea9c691b63f25505875c4fae74f89d775c87a67af0dd80d2fc"},
{file = "convertertools-1.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:abbbbe1310ad9cc89c3c06f89a9d35ac54f8c6559a2c40376548a22932401c71"},
{file = "convertertools-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a144e00a7f50e75f04a10a3ba57d7ff2d9fe0e22a9d4f61c15380a4f1f843004"},
{file = "convertertools-1.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0fda8421f5e08f717646fe4e27f3dae09638d85257324643342c42780c85149d"},
{file = "convertertools-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238f09f7e856d55a13a2973a006984f9f1885e03f9ba2d504f7543444106b599"},
{file = "convertertools-1.1.0-cp311-cp311-win32.whl", hash = "sha256:0d83077ad836453b9f8fa2e2159557098699ed2c5c41f1be84748b2ccada0d91"},
{file = "convertertools-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc09d3f8cd7f80eef3090c2e2e972e9f4eb5d89b82c0c85a032f365558a6f58b"},
{file = "convertertools-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dccdf095f3c99a7c55f23c644ec85501514abb55f9835b57ef810d65e4bce243"},
{file = "convertertools-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56322511183c841f9009cdd42f09e3d9bf0c2d0dc9a5cc839e396225af63fe18"},
{file = "convertertools-1.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6a690029a9d4ab4440e8737de89ae9f974f1f4f41082363f9052b6bcc04e9a83"},
{file = "convertertools-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a9c08655c2c99b837159056b68fc0f23a960d5bc4dd6174d048d1db38acfb9f"},
{file = "convertertools-1.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0bc6e9de7a5b664a5f1d4198e74fa3b031a1bb0109dccb618deeb8894b522533"},
{file = "convertertools-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc30a186d25451c925ac7833a4ba817f2ee6ccc831c3baaf4c7e11d56bba4d36"},
{file = "convertertools-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:68da6eaaa651ee95fc716e33dafcff3b621d7fd5dc6d92c5a0d719d7ca9d6d44"},
{file = "convertertools-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:10c50914ac3d8189b4d2c28b6ba14939dc00a88addf1ff7c4b3509938b228a99"},
{file = "convertertools-1.1.0-cp312-cp312-win32.whl", hash = "sha256:c36480cc8a4e1d193ec8b56113d858f146ad1273aacca3bc2b854f2b0f684eb0"},
{file = "convertertools-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:00cc09e85a2f8445488601c5721815151d3d8a5e3c8ff1795e0cf9e49433c03c"},
{file = "convertertools-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78002227ca997ad4a3a0f84ab4342067aa550292026a3baabd69ab710536a2ba"},
{file = "convertertools-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dddae2c8c73895f694dba6b7b24e2994674b31d2b618b9455d88f8fc1a960959"},
{file = "convertertools-1.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:25db34e9711a26be4d11fc0a11fdd815d95ddc69402ecf3e2decc3cf2a86c3ca"},
{file = "convertertools-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fc5528a853853abe0f2d44594c565a49ae14eb9326257e30458af3f9e9729f5"},
{file = "convertertools-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96404443cbc3e90e52761910eba7a493d85381efe956299341b4971a34dea001"},
{file = "convertertools-1.1.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:e52e53ba05d1dadf0f5078032bb8a6a320899bb432324da877092c3cfefcb154"},
{file = "convertertools-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1287671ca84400fc6265350eaa169446b7c28af911ababa134dd84bf2c032770"},
{file = "convertertools-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:708ac55e43068cb92581dd324de197d455e80e27e93ddc6ea0460dcfe5232d54"},
{file = "convertertools-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:46c939000304e6f68ed9256757628ede27a8e84747b6179c94f4a8fa18f16cdc"},
{file = "convertertools-1.1.0-cp313-cp313-win32.whl", hash = "sha256:e91c3fa55c4275195a6bc005a9c5581f6b572c16df090603adc3114b5936c332"},
{file = "convertertools-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f574c48adaa9409d59dcf379be0b152195ce23c3aa8beccfaae36c435799d912"},
{file = "convertertools-1.1.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e1edfb9685a945597335ea3cab6a94d4f39897ff3b6e08f9644c0c33abd95f28"},
{file = "convertertools-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dc9d99f91aba49a9a5f0a7f427583e46472bde21e1882af438302cb9f405936"},
{file = "convertertools-1.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98af0203b102ce1e058faba9da0b56e1e76fdd3946ab8386d756cafe303649c7"},
{file = "convertertools-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:834b14b508fdf0c91bfb77db0085ed437882151de2aef709e52abcb78b0441fa"},
{file = "convertertools-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a2d334ae552abb9ff5a6ee4aa9320387d0906daeb5116bfaf8fd4c3d17971653"},
{file = "convertertools-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0e38f134b8ae1ec237f717e5d4e358a21a6f3159a75d8f88ec753821efd7416e"},
{file = "convertertools-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:223b1ca690e126f8bd721adc6cd72345027b868094f51bcf82bc02bdcdb3b2c4"},
{file = "convertertools-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:615f915e3d3fff40a5075cecf8d88b6c5a6a2bc0667944c10d81f8797fbfd323"},
{file = "convertertools-1.1.0-cp314-cp314-win32.whl", hash = "sha256:e4cc195222e359084496f0a36e436742bc9c7b9ebb37534164c51ef642d926e5"},
{file = "convertertools-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:2c7fceb2359898e37c28535315f2e11cfb122ec35b7a33ac0e5a349e478b9380"},
{file = "convertertools-1.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:abde0b0e9d2c09de6dbef7585c5d03afd541dad3b322f54c14b8e0cf450e0be3"},
{file = "convertertools-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1492a9a88603e05c0c9fa51a2e72cb4f2263699b5f9fcf5973d1d07c947574b1"},
{file = "convertertools-1.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:55bbf257409a5bf9ce9457619edec8dc4ac2bc024914805128b6017dff0e2a64"},
{file = "convertertools-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6e10696da03491852ccf99ef1c17628554b4ff16e79a90040b149c00d196b61"},
{file = "convertertools-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d80ecbc13d07ec2a932a792551c8ba07ddc0cd2d272703d1bd77d953878474a"},
{file = "convertertools-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f3d3401b652e8dafa95f8fdd6cacc0de7c04b9d3117a2a30d3590f501335a83d"},
{file = "convertertools-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:73378b4275579862b97c8f316363bfc00e5b8b8498fe39223ba114001e4c9ee0"},
{file = "convertertools-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:991137feb7a878303bd6774ea19f84e87841da745af9a7188f898699906bb9ec"},
{file = "convertertools-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:5c42f55e3eb5e427447f7920e66d6d83fb51fc81728b0d427821687d7348e4c0"},
{file = "convertertools-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfdcb297219ee7ef99d9c20679ba9efdb1170e0d4fd90c8644b1fa5ea2f04db2"},
{file = "convertertools-1.1.0.tar.gz", hash = "sha256:c23fc6848d3b1e74fbcc5dfa3a9729834e86114bf6d0fd9c9e35b9062ace93ca"},
]
[[package]]
name = "coverage"
version = "7.10.7"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"},
{file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"},
{file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"},
{file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"},
{file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"},
{file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"},
{file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"},
{file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"},
{file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"},
{file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"},
{file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"},
{file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"},
{file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"},
{file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"},
{file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"},
{file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"},
{file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"},
{file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"},
{file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"},
{file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"},
{file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"},
{file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"},
{file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"},
{file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"},
{file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"},
{file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"},
{file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"},
{file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"},
{file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"},
{file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"},
{file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"},
{file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"},
{file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"},
{file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"},
{file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"},
{file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"},
{file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"},
{file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"},
{file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"},
{file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"},
{file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"},
{file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"},
{file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"},
{file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"},
{file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"},
{file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"},
{file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"},
{file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"},
{file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"},
{file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"},
{file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "dateparser"
version = "1.4.1"
description = "Date parsing library designed to parse dates from HTML pages"
optional = true
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "dateparser-1.4.1-py3-none-any.whl", hash = "sha256:f25d4e051a84be27a35bd297e3e1dc59ff78373701b89be352ba80372d22d0d0"},
{file = "dateparser-1.4.1.tar.gz", hash = "sha256:f265df13c0380e2e07543ba74b67c0681aaa1096981ffcd35227e1aa0cb81c7c"},
]
[package.dependencies]
python-dateutil = ">=2.7.0"
pytz = ">=2024.2"
regex = ">=2024.9.11"
tzlocal = ">=0.2"
[package.extras]
calendars = ["convertdate (>=2.2.1)", "hijridate"]
langdetect = ["langdetect (>=1.0.0)"]
[[package]]
name = "distlib"
version = "0.4.0"
description = "Distribution utilities"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
[[package]]
name = "execnet"
version = "2.1.1"
description = "execnet: rapid multi-Python deployment"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"},
{file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"},
]
[package.extras]
testing = ["hatch", "pre-commit", "pytest", "tox"]
[[package]]
name = "filelock"
version = "3.20.3"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
[[package]]
name = "forbiddenfruit"
version = "0.1.4"
description = "Patch python built-in objects"
optional = false
python-versions = "*"
groups = ["dev"]
markers = "implementation_name == \"cpython\""
files = [
{file = "forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"},
]
[[package]]
name = "frozenlist"
version = "1.8.0"
description = "A list-like structure which implements collections.abc.MutableSequence"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"},
{file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"},
{file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"},
{file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"},
{file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"},
{file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"},
{file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"},
{file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"},
{file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"},
{file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"},
{file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"},
{file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"},
{file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"},
{file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"},
{file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"},
{file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"},
{file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"},
{file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"},
{file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"},
{file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"},
{file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"},
{file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"},
{file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"},
{file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"},
{file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"},
{file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"},
{file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"},
{file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"},
{file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"},
{file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"},
{file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"},
{file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"},
{file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"},
{file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"},
{file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"},
{file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"},
{file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"},
{file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"},
{file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"},
{file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"},
{file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"},
{file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"},
{file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"},
{file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"},
{file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"},
{file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"},
{file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"},
{file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"},
{file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"},
{file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"},
{file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"},
{file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"},
{file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"},
{file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"},
{file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"},
{file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"},
{file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"},
{file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"},
{file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"},
{file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"},
{file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"},
{file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"},
{file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"},
{file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"},
{file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"},
{file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"},
{file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"},
{file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"},
{file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"},
{file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"},
{file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"},
{file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"},
{file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"},
{file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"},
{file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"},
{file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"},
{file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"},
{file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"},
{file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"},
{file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"},
{file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"},
{file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"},
{file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"},
{file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"},
{file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"},
{file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"},
{file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"},
{file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"},
{file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"},
{file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"},
{file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"},
{file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"},
{file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"},
{file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"},
{file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"},
{file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"},
{file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"},
{file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"},
{file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"},
{file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"},
{file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"},
{file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"},
{file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"},
{file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"},
{file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"},
{file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"},
{file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"},
{file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"},
{file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"},
{file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"},
{file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"},
{file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"},
{file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"},
{file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"},
{file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"},
{file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"},
{file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"},
{file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"},
{file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"},
{file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"},
{file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"},
{file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"},
{file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"},
{file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"},
{file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"},
{file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"},
{file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"},
{file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"},
{file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"},
{file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"},
]
[[package]]
name = "ghp-import"
version = "2.1.0"
description = "Copy your docs directly to the gh-pages branch."
optional = false
python-versions = "*"
groups = ["docs"]
files = [
{file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
{file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
]
[package.dependencies]
python-dateutil = ">=2.8.1"
[package.extras]
dev = ["flake8", "markdown", "twine", "wheel"]
[[package]]
name = "gitdb"
version = "4.0.12"
description = "Git Object Database"
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"},
{file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"},
]
[package.dependencies]
smmap = ">=3.0.1,<6"
[[package]]
name = "gitpython"
version = "3.1.58"
description = "GitPython is a Python library used to interact with Git repositories"
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f"},
{file = "gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22"},
]
[package.dependencies]
gitdb = ">=4.0.1,<5"
[package.extras]
doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
test = ["basedpyright (==1.39.9) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
[[package]]
name = "griffelib"
version = "2.0.0"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f"},
{file = "griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934"},
]
[package.extras]
pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"]
[[package]]
name = "identify"
version = "2.6.15"
description = "File identification library for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
]
[package.extras]
license = ["ukkonen"]
[[package]]
name = "idna"
version = "3.15"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
groups = ["main", "docs"]
files = [
{file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
{file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
]
[package.extras]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "librt"
version = "0.11.0"
description = "Mypyc runtime library"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"},
{file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"},
{file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"},
{file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"},
{file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"},
{file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"},
{file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"},
{file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"},
{file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"},
{file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"},
{file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"},
{file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"},
{file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"},
{file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"},
{file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"},
{file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"},
{file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"},
{file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"},
{file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"},
{file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"},
{file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"},
{file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"},
{file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"},
{file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"},
{file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"},
{file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"},
{file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"},
{file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"},
{file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"},
{file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"},
{file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"},
{file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"},
{file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"},
{file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"},
{file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"},
{file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"},
{file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"},
{file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"},
{file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"},
{file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"},
{file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"},
]
[[package]]
name = "markdown"
version = "3.9"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"},
{file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
]
markers = {main = "extra == \"cli\""}
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins (>=0.5.0)"]
profiling = ["gprof2dot"]
rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
[[package]]
name = "markupsafe"
version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["main", "dev"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
markers = {main = "extra == \"cli\""}
[[package]]
name = "mergedeep"
version = "1.3.4"
description = "A deep merge function for �."
optional = false
python-versions = ">=3.6"
groups = ["docs"]
files = [
{file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"},
{file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
]
[[package]]
name = "mike"
version = "2.2.0"
description = "Manage multiple versions of your MkDocs-powered documentation"
optional = false
python-versions = "*"
groups = ["docs"]
files = [
{file = "mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040"},
{file = "mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e"},
]
[package.dependencies]
jinja2 = ">=2.7"
mkdocs = ">=1.0,<2.0"
pyparsing = ">=3.0"
pyyaml = ">=5.1"
pyyaml-env-tag = "*"
verspec = "*"
[package.extras]
dev = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"]
test = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"]
[[package]]
name = "mkdocs"
version = "1.6.1"
description = "Project documentation with Markdown."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"},
{file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"},
]
[package.dependencies]
click = ">=7.0"
colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""}
ghp-import = ">=1.0"
jinja2 = ">=2.11.1"
markdown = ">=3.3.6"
markupsafe = ">=2.0.1"
mergedeep = ">=1.3.4"
mkdocs-get-deps = ">=0.2.0"
packaging = ">=20.5"
pathspec = ">=0.11.1"
pyyaml = ">=5.1"
pyyaml-env-tag = ">=0.1"
watchdog = ">=2.0"
[package.extras]
i18n = ["babel (>=2.9.0)"]
min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"]
[[package]]
name = "mkdocs-autorefs"
version = "1.4.3"
description = "Automatically link across pages in MkDocs."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9"},
{file = "mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75"},
]
[package.dependencies]
Markdown = ">=3.3"
markupsafe = ">=2.0.1"
mkdocs = ">=1.1"
[[package]]
name = "mkdocs-get-deps"
version = "0.2.0"
description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"},
{file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"},
]
[package.dependencies]
mergedeep = ">=1.3.4"
platformdirs = ">=2.2.0"
pyyaml = ">=5.1"
[[package]]
name = "mkdocs-git-revision-date-localized-plugin"
version = "1.5.3"
description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "mkdocs_git_revision_date_localized_plugin-1.5.3-py3-none-any.whl", hash = "sha256:cd96e432de6a7e59b31c7041574b22f84179c8636835419ff458877ecfaaaf05"},
{file = "mkdocs_git_revision_date_localized_plugin-1.5.3.tar.gz", hash = "sha256:873444b54cab4d47c69bd6e85da05ef5fbe81fee27e64508114c46a0e4f81e37"},
]
[package.dependencies]
babel = ">=2.7.0"
gitpython = ">=3.1.44"
mkdocs = ">=1.0,<2"
tzdata = {version = ">=2023.3", markers = "sys_platform == \"win32\""}
[[package]]
name = "mkdocs-include-markdown-plugin"
version = "7.3.0"
description = "Mkdocs Markdown includer plugin."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "mkdocs_include_markdown_plugin-7.3.0-py3-none-any.whl", hash = "sha256:5b5c99b5d3c9b9ce0114a9e60353bbafb6be53a26c2d3b74ec6b767a7a8e55ca"},
{file = "mkdocs_include_markdown_plugin-7.3.0.tar.gz", hash = "sha256:2800126746452e31c2e321bbd43c8190b356e0de353e20cbc16a34a3c3d6796c"},
]
[package.dependencies]
mkdocs = ">=1.4"
wcmatch = "*"
[package.extras]
cache = ["platformdirs"]
[[package]]
name = "mkdocs-material"
version = "9.7.7"
description = "Documentation that simply works"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f"},
{file = "mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855"},
]
[package.dependencies]
babel = ">=2.10"
backrefs = ">=5.7.post1"
colorama = ">=0.4"
jinja2 = ">=3.1"
markdown = ">=3.2"
mkdocs = ">=1.6,<2"
mkdocs-material-extensions = ">=1.3"
paginate = ">=0.5"
pygments = ">=2.16"
pymdown-extensions = ">=10.2"
requests = ">=2.30"
[package.extras]
git = ["mkdocs-git-committers-plugin-2 (>=1.1)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4)"]
imaging = ["cairosvg (>=2.6)", "pillow (>=10.2)"]
recommended = ["mkdocs-minify-plugin (>=0.7)", "mkdocs-redirects (>=1.2)", "mkdocs-rss-plugin (>=1.6)"]
[[package]]
name = "mkdocs-material-extensions"
version = "1.3.1"
description = "Extension pack for Python Markdown and MkDocs Material."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"},
{file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"},
]
[[package]]
name = "mkdocstrings"
version = "1.0.6"
description = "Automatic documentation from sources, for MkDocs."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7"},
{file = "mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd"},
]
[package.dependencies]
Jinja2 = ">=3.1"
Markdown = ">=3.6"
MarkupSafe = ">=1.1"
mkdocs = ">=1.6"
mkdocs-autorefs = ">=1.4"
pymdown-extensions = ">=6.3"
[package.extras]
crystal = ["mkdocstrings-crystal (>=0.3.4)"]
python = ["mkdocstrings-python (>=1.16.2)"]
python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]]
name = "mkdocstrings-python"
version = "2.0.5"
description = "A Python handler for mkdocstrings."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d"},
{file = "mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594"},
]
[package.dependencies]
griffelib = ">=2.0"
mkdocs-autorefs = ">=1.4"
mkdocstrings = ">=0.30"
[[package]]
name = "multidict"
version = "6.7.0"
description = "multidict implementation"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"},
{file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"},
{file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"},
{file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"},
{file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"},
{file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"},
{file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"},
{file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"},
{file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"},
{file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"},
{file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"},
{file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"},
{file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"},
{file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"},
{file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"},
{file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"},
{file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"},
{file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"},
{file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"},
{file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"},
{file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"},
{file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"},
{file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"},
{file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"},
{file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"},
{file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"},
{file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"},
{file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"},
{file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"},
{file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"},
{file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"},
{file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"},
{file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"},
{file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"},
{file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"},
{file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"},
{file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"},
{file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"},
{file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"},
{file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"},
{file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"},
{file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"},
{file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"},
{file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"},
{file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"},
{file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"},
{file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"},
{file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"},
{file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"},
{file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"},
{file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"},
{file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"},
{file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"},
{file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"},
{file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"},
{file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"},
{file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"},
{file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"},
{file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"},
{file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"},
{file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"},
{file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"},
{file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"},
{file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"},
{file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"},
{file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"},
{file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"},
{file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"},
{file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"},
{file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"},
{file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"},
{file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"},
{file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"},
{file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"},
{file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"},
{file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"},
{file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"},
{file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"},
{file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"},
{file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"},
{file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"},
{file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"},
{file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"},
{file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"},
{file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"},
{file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"},
{file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"},
{file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"},
{file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"},
{file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"},
{file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"},
{file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"},
{file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"},
{file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"},
{file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"},
{file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"},
{file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"},
{file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"},
{file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"},
{file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"},
{file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"},
{file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"},
{file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"},
{file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"},
{file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"},
{file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"},
{file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"},
{file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"},
{file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"},
{file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"},
{file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"},
{file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"},
{file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"},
{file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"},
{file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"},
{file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"},
{file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"},
{file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"},
{file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"},
{file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"},
{file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"},
{file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"},
{file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"},
{file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"},
{file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"},
{file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"},
{file = "multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c"},
{file = "multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40"},
{file = "multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851"},
{file = "multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687"},
{file = "multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5"},
{file = "multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb"},
{file = "multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6"},
{file = "multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e"},
{file = "multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e"},
{file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32"},
{file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c"},
{file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84"},
{file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329"},
{file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e"},
{file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4"},
{file = "multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91"},
{file = "multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f"},
{file = "multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546"},
{file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"},
{file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"},
]
[[package]]
name = "mypy"
version = "2.1.0"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc"},
{file = "mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849"},
{file = "mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd"},
{file = "mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166"},
{file = "mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8"},
{file = "mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8"},
{file = "mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e"},
{file = "mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41"},
{file = "mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca"},
{file = "mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538"},
{file = "mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398"},
{file = "mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563"},
{file = "mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389"},
{file = "mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666"},
{file = "mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af"},
{file = "mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6"},
{file = "mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211"},
{file = "mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b"},
{file = "mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22"},
{file = "mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b"},
{file = "mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8"},
{file = "mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5"},
{file = "mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e"},
{file = "mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e"},
{file = "mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285"},
{file = "mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5"},
{file = "mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65"},
{file = "mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d"},
{file = "mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2"},
{file = "mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f"},
{file = "mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4"},
{file = "mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef"},
{file = "mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135"},
{file = "mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21"},
{file = "mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57"},
{file = "mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e"},
{file = "mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780"},
{file = "mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd"},
{file = "mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08"},
{file = "mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081"},
{file = "mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7"},
{file = "mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6"},
{file = "mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289"},
{file = "mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633"},
]
[package.dependencies]
ast-serialize = ">=0.3.0,<1.0.0"
librt = {version = ">=0.11.0", markers = "platform_python_implementation != \"PyPy\""}
mypy_extensions = ">=1.0.0"
pathspec = ">=1.0.0"
typing_extensions = [
{version = ">=4.6.0", markers = "python_version < \"3.15\""},
{version = ">=4.14.0", markers = "python_version >= \"3.15\""},
]
[package.extras]
dmypy = ["psutil (>=4.0)"]
faster-cache = ["orjson"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "nodeenv"
version = "1.9.1"
description = "Node.js virtual environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
files = [
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
[[package]]
name = "orjson"
version = "3.11.9"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce"},
{file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd"},
{file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4"},
{file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4"},
{file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e"},
{file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb"},
{file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47"},
{file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d"},
{file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13"},
{file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92"},
{file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48"},
{file = "orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94"},
{file = "orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244"},
{file = "orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f"},
{file = "orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c"},
{file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5"},
{file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c"},
{file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd"},
{file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62"},
{file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877"},
{file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1"},
{file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd"},
{file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff"},
{file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980"},
{file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2"},
{file = "orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180"},
{file = "orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02"},
{file = "orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697"},
{file = "orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49"},
{file = "orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291"},
{file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09"},
{file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4"},
{file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882"},
{file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff"},
{file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe"},
{file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61"},
{file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2"},
{file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206"},
{file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f"},
{file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa"},
{file = "orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470"},
{file = "orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be"},
{file = "orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624"},
{file = "orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021"},
{file = "orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c"},
{file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81"},
{file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a"},
{file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10"},
{file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362"},
{file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97"},
{file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218"},
{file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9"},
{file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677"},
{file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4"},
{file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0"},
{file = "orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32"},
{file = "orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979"},
{file = "orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254"},
{file = "orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e"},
{file = "orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e"},
{file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0"},
{file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124"},
{file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c"},
{file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7"},
{file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1"},
{file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db"},
{file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b"},
{file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972"},
{file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0"},
{file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586"},
{file = "orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673"},
{file = "orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b"},
{file = "orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9"},
{file = "orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f"},
]
[[package]]
name = "packaging"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev", "docs"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
name = "paginate"
version = "0.5.7"
description = "Divides large result sets into pages for easier browsing"
optional = false
python-versions = "*"
groups = ["docs"]
files = [
{file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"},
{file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"},
]
[package.extras]
dev = ["pytest", "tox"]
lint = ["black"]
[[package]]
name = "pathspec"
version = "1.0.4"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.9"
groups = ["dev", "docs"]
files = [
{file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"},
{file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"},
]
[package.extras]
hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"]
tests = ["pytest (>=9)", "typing-extensions (>=4.15)"]
[[package]]
name = "pillow"
version = "12.3.0"
description = "Python Imaging Library (fork)"
optional = true
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a"},
{file = "pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7"},
{file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f"},
{file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec"},
{file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468"},
{file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed"},
{file = "pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1"},
{file = "pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb"},
{file = "pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f"},
{file = "pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756"},
{file = "pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6"},
{file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd"},
{file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd"},
{file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c"},
{file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5"},
{file = "pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b"},
{file = "pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a"},
{file = "pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26"},
{file = "pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"},
{file = "pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"},
{file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"},
{file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"},
{file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c"},
{file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df"},
{file = "pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f"},
{file = "pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"},
{file = "pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510"},
{file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89"},
{file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace"},
{file = "pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec"},
{file = "pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66"},
{file = "pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35"},
{file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65"},
{file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3"},
{file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a"},
{file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e"},
{file = "pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f"},
{file = "pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8"},
{file = "pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b"},
{file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330"},
{file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217"},
{file = "pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930"},
{file = "pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8"},
{file = "pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0"},
{file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321"},
{file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b"},
{file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198"},
{file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130"},
{file = "pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a"},
{file = "pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d"},
{file = "pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838"},
{file = "pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e"},
{file = "pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17"},
{file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385"},
{file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c"},
{file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d"},
{file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931"},
{file = "pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7"},
{file = "pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c"},
{file = "pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45"},
{file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139"},
{file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402"},
{file = "pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c"},
{file = "pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f"},
{file = "pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701"},
{file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace"},
{file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4"},
{file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39"},
{file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71"},
{file = "pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827"},
{file = "pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5"},
{file = "pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658"},
{file = "pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf"},
{file = "pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64"},
{file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e"},
{file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777"},
{file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1"},
{file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9"},
{file = "pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8"},
{file = "pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418"},
{file = "pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59"},
{file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468"},
{file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94"},
{file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e"},
{file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3"},
{file = "pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a"},
{file = "pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]]
name = "platformdirs"
version = "4.11.4"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
groups = ["main", "dev", "docs"]
files = [
{file = "platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586"},
{file = "platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
version = "4.6.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"},
{file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"},
]
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "propcache"
version = "0.5.2"
description = "Accelerated property cache"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"},
{file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"},
{file = "propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d"},
{file = "propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274"},
{file = "propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe"},
{file = "propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d"},
{file = "propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5"},
{file = "propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78"},
{file = "propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959"},
{file = "propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b"},
{file = "propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f"},
{file = "propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0"},
{file = "propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82"},
{file = "propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab"},
{file = "propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba"},
{file = "propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a"},
{file = "propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476"},
{file = "propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a"},
{file = "propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031"},
{file = "propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42"},
{file = "propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84"},
{file = "propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a"},
{file = "propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117"},
{file = "propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d"},
{file = "propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4"},
{file = "propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d"},
{file = "propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757"},
{file = "propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f"},
{file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d"},
{file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa"},
{file = "propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568"},
{file = "propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191"},
{file = "propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7"},
{file = "propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96"},
{file = "propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999"},
{file = "propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e"},
{file = "propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825"},
{file = "propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4"},
{file = "propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0"},
{file = "propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c"},
{file = "propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0"},
{file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb"},
{file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078"},
{file = "propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2"},
{file = "propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821"},
{file = "propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370"},
{file = "propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6"},
{file = "propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe"},
{file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"},
]
[[package]]
name = "py-cpuinfo"
version = "9.0.0"
description = "Get CPU info with pure Python"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"},
{file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"},
]
[[package]]
name = "pydantic"
version = "2.13.4"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"},
{file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
pydantic-core = "2.46.4"
typing-extensions = ">=4.14.1"
typing-inspection = ">=0.4.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]]
name = "pydantic-core"
version = "2.46.4"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"},
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"},
{file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"},
{file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"},
{file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"},
{file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"},
{file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"},
{file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"},
{file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"},
{file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"},
{file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"},
{file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"},
{file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"},
{file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"},
{file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"},
{file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"},
{file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"},
{file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"},
{file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"},
{file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"},
{file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"},
{file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"},
{file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"},
{file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"},
{file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"},
{file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"},
{file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"},
{file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"},
{file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"},
{file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"},
{file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"},
{file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"},
{file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"},
{file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"},
{file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"},
{file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"},
{file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"},
{file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"},
{file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"},
{file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"},
{file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"},
{file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"},
{file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"},
{file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"},
{file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"},
{file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"},
{file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"},
{file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"},
{file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"},
{file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"},
{file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"},
{file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"},
{file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"},
{file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"},
{file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"},
{file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"},
{file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"},
{file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"},
{file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"},
{file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"},
{file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"},
{file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"},
{file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"},
{file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"},
{file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"},
{file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"},
{file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"},
{file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"},
{file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"},
{file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"},
{file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"},
{file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"},
{file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"},
{file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"},
{file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"},
{file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"},
{file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"},
{file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"},
{file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"},
{file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"},
{file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"},
{file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"},
{file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"},
{file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"},
{file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"},
{file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"},
{file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"},
{file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"},
{file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"},
{file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"},
{file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"},
{file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"},
{file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"},
{file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"},
{file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"},
{file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"},
{file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"},
{file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"},
{file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"},
{file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"},
{file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"},
{file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"},
{file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"},
{file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"},
{file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"},
{file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"},
]
[package.dependencies]
typing-extensions = ">=4.14.1"
[[package]]
name = "pydantic-extra-types"
version = "2.11.2"
description = "Extra Pydantic types."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pydantic_extra_types-2.11.2-py3-none-any.whl", hash = "sha256:683b8943252543e49760f89733b1519bc62f31d1a287ebbdc5a7b7959fb4acfd"},
{file = "pydantic_extra_types-2.11.2.tar.gz", hash = "sha256:3a2b83b61fe920925688e7838b59caa90a45637d1dbba2b1364b8d1f7ff72a0a"},
]
[package.dependencies]
pydantic = ">=2.5.2"
typing-extensions = "*"
[package.extras]
all = ["cron-converter (>=1.2.2)", "jsonschema (>=4.0.0)", "pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<10)", "pycountry (>=23)", "pymongo (>=4.0.0,<5.0.0)", "python-ulid (>=1,<2) ; python_version < \"3.9\"", "python-ulid (>=1,<4) ; python_version >= \"3.9\"", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)", "uuid-utils (>=0.6.0) ; python_version < \"3.14\""]
cron = ["cron-converter (>=1.2.2)"]
jsonschema = ["jsonschema (>=4.0.0)"]
pendulum = ["pendulum (>=3.0.0,<4.0.0)"]
phonenumbers = ["phonenumbers (>=8,<10)"]
pycountry = ["pycountry (>=23)"]
python-ulid = ["python-ulid (>=1,<2) ; python_version < \"3.9\"", "python-ulid (>=1,<4) ; python_version >= \"3.9\""]
semver = ["semver (>=3.0.2)"]
uuid-utils = ["uuid-utils (>=0.6.0) ; python_version < \"3.14\""]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev", "docs"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
markers = {main = "extra == \"cli\""}
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyjwt"
version = "2.13.0"
description = "JSON Web Token implementation in Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"},
{file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"},
]
[package.extras]
crypto = ["cryptography (>=3.4.0)"]
[[package]]
name = "pymdown-extensions"
version = "11.0.1"
description = "Extension pack for Python Markdown."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2"},
{file = "pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0"},
]
[package.dependencies]
markdown = ">=3.6"
pyyaml = "*"
[package.extras]
extra = ["pygments (>=2.19.1)"]
[[package]]
name = "pyparsing"
version = "3.2.5"
description = "pyparsing - Classes and methods to define and execute parsing grammars"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"},
{file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pytest"
version = "9.1.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"},
{file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"},
]
[package.dependencies]
pytest = ">=8.4,<10"
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-benchmark"
version = "5.2.3"
description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803"},
{file = "pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779"},
]
[package.dependencies]
py-cpuinfo = "*"
pytest = ">=8.1"
[package.extras]
aspect = ["aspectlib"]
elasticsearch = ["elasticsearch"]
histogram = ["pygal", "pygaljs", "setuptools"]
[[package]]
name = "pytest-codspeed"
version = "5.0.3"
description = "Pytest plugin to create CodSpeed benchmarks"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_codspeed-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:005348ea52ace3ede2e2f595913912ad2564cca7b124211a88dc78a9cb1fca63"},
{file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbe6a4a00b449b6ba2771f644cbc38bdf55acf5c812e60e5659110e19dd9f510"},
{file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac4344f34bbcdd17f6f8c30dbac3da2f80d223dd112e568fd7f7c2cd4cbc693"},
{file = "pytest_codspeed-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f56d0339cd98d26f6e561987be25bdd2761a5d53d8f73493b1ebe02d0d451093"},
{file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c682f6645d4eb472f3bd95dbda1805e3af4243610572cb7d6bf94a88e8a0b6c"},
{file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f852bee785a7a124cb1720b1915670c6742af87747dc4d838f3ffdbd365ce9d9"},
{file = "pytest_codspeed-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2eeb25fb1ac3f73c4de50e739e78fea396b89782bdb740bf2a7cd2df21f8d4ee"},
{file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73c5c9d98a3372a42611989ccfa437cce3842431ac6d6b9ab42c4f0e59c070f7"},
{file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2e0ab65df73e837666d12357280ca50ff6d6ac03ea5266703be518b68170edf"},
{file = "pytest_codspeed-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6524c57fec279a22ffef6112af404036afc71b4704758ae9f0abda429b8478d4"},
{file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c383c9121deb58a69f174188e9e4488ffc0daced0ed276abf87747182511901"},
{file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4bcdb4b6522738152885ef067e0c8524d5699828d780fb6f464cdb3db44369c"},
{file = "pytest_codspeed-5.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25464363c7f9b9bd5022e969c0addba616fa40ac9b8f0fc9e030c4538863b32d"},
{file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efd43f82ea03ced8488a767ded9473f050791ab7783ea8654107e1e0ac66af40"},
{file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:782f9985b6f6b45b8bc20152d206d3a52b56dd088ba81cb70a71f0b39841be9e"},
{file = "pytest_codspeed-5.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9aa0815b90196f3c20d736ea8691381e97f12bbe8c7d87af10a351e434b452cb"},
{file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85505c96a3477c346ec2d2b7dced8478f4c651e2b1666ee102d53a832b511853"},
{file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20eba63765be9d1b6cacbbfad84b87d49eb04b357a7045a0899880da181f81e3"},
{file = "pytest_codspeed-5.0.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ec9fa6f0af0a9feb0e0bd517fb59ef28f806fbd50c0c6900ac26cbb4d080eba5"},
{file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8df77b3409f54f4a268f77f3ff74992fe1d995cdbaf2cecf8ad74d32db217ce7"},
{file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d8695a227ea1c3a41d25db5b3fe720bf1b4808bd38862be811a4efd902c792"},
{file = "pytest_codspeed-5.0.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bf4cc4178cbace8f4d2bd240408276bc4da3850ac5fcb5fb5f8a74ab417615bb"},
{file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abe793da40f87295d33988673d34f06ea569848b44490b847552cd416816258a"},
{file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3a9ed38dfa776443b86f4b49a982e8443d0953db4974bd2673d63cc904ae1ad"},
{file = "pytest_codspeed-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bce0a6ea93a5b19658f713312bb67554c19283ab15b454a1e3e55a13e78130f8"},
{file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a2097247985f5d915a94b80c5552d10979ca858c859fc3edef1bf2baa5c9b7a"},
{file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e192905a2230f9956e6160732f76577836953a4a1fb2b1e7be74e51ac7b2a0"},
{file = "pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378"},
{file = "pytest_codspeed-5.0.3.tar.gz", hash = "sha256:91afef90e6a96b013495e4702ef5d6358614a449e71008cdc194ef668778b92f"},
]
[package.dependencies]
pytest = ">=3.8"
rich = ">=13.8.1"
[package.extras]
compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"]
[[package]]
name = "pytest-cov"
version = "7.1.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"},
{file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-sugar"
version = "1.1.1"
description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)."
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d"},
{file = "pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8"},
]
[package.dependencies]
pytest = ">=6.2.0"
termcolor = ">=2.1.0"
[package.extras]
dev = ["black", "flake8", "pre-commit"]
[[package]]
name = "pytest-timeout"
version = "2.4.0"
description = "pytest plugin to abort hanging tests"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
]
[package.dependencies]
pytest = ">=7.0.0"
[[package]]
name = "pytest-xdist"
version = "3.8.0"
description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"},
{file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"},
]
[package.dependencies]
execnet = ">=2.1"
pytest = ">=7.0.0"
[package.extras]
psutil = ["psutil (>=3.0)"]
setproctitle = ["setproctitle"]
testing = ["filelock"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main", "docs"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
markers = {main = "extra == \"cli\""}
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pytz"
version = "2025.2"
description = "World timezone definitions, modern and historical"
optional = true
python-versions = "*"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"},
{file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
]
[[package]]
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "pyyaml-env-tag"
version = "1.1"
description = "A custom YAML tag for referencing environment variables in YAML files."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"},
{file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"},
]
[package.dependencies]
pyyaml = "*"
[[package]]
name = "regex"
version = "2025.9.18"
description = "Alternative regular expression module, to replace re."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "regex-2025.9.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:12296202480c201c98a84aecc4d210592b2f55e200a1d193235c4db92b9f6788"},
{file = "regex-2025.9.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:220381f1464a581f2ea988f2220cf2a67927adcef107d47d6897ba5a2f6d51a4"},
{file = "regex-2025.9.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87f681bfca84ebd265278b5daa1dcb57f4db315da3b5d044add7c30c10442e61"},
{file = "regex-2025.9.18-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34d674cbba70c9398074c8a1fcc1a79739d65d1105de2a3c695e2b05ea728251"},
{file = "regex-2025.9.18-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:385c9b769655cb65ea40b6eea6ff763cbb6d69b3ffef0b0db8208e1833d4e746"},
{file = "regex-2025.9.18-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8900b3208e022570ae34328712bef6696de0804c122933414014bae791437ab2"},
{file = "regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c204e93bf32cd7a77151d44b05eb36f469d0898e3fba141c026a26b79d9914a0"},
{file = "regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3acc471d1dd7e5ff82e6cacb3b286750decd949ecd4ae258696d04f019817ef8"},
{file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6479d5555122433728760e5f29edb4c2b79655a8deb681a141beb5c8a025baea"},
{file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:431bd2a8726b000eb6f12429c9b438a24062a535d06783a93d2bcbad3698f8a8"},
{file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0cc3521060162d02bd36927e20690129200e5ac9d2c6d32b70368870b122db25"},
{file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a021217b01be2d51632ce056d7a837d3fa37c543ede36e39d14063176a26ae29"},
{file = "regex-2025.9.18-cp310-cp310-win32.whl", hash = "sha256:4a12a06c268a629cb67cc1d009b7bb0be43e289d00d5111f86a2efd3b1949444"},
{file = "regex-2025.9.18-cp310-cp310-win_amd64.whl", hash = "sha256:47acd811589301298c49db2c56bde4f9308d6396da92daf99cba781fa74aa450"},
{file = "regex-2025.9.18-cp310-cp310-win_arm64.whl", hash = "sha256:16bd2944e77522275e5ee36f867e19995bcaa533dcb516753a26726ac7285442"},
{file = "regex-2025.9.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:51076980cd08cd13c88eb7365427ae27f0d94e7cebe9ceb2bb9ffdae8fc4d82a"},
{file = "regex-2025.9.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:828446870bd7dee4e0cbeed767f07961aa07f0ea3129f38b3ccecebc9742e0b8"},
{file = "regex-2025.9.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28821d5637866479ec4cc23b8c990f5bc6dd24e5e4384ba4a11d38a526e1414"},
{file = "regex-2025.9.18-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726177ade8e481db669e76bf99de0b278783be8acd11cef71165327abd1f170a"},
{file = "regex-2025.9.18-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5cca697da89b9f8ea44115ce3130f6c54c22f541943ac8e9900461edc2b8bd4"},
{file = "regex-2025.9.18-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dfbde38f38004703c35666a1e1c088b778e35d55348da2b7b278914491698d6a"},
{file = "regex-2025.9.18-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2f422214a03fab16bfa495cfec72bee4aaa5731843b771860a471282f1bf74f"},
{file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a295916890f4df0902e4286bc7223ee7f9e925daa6dcdec4192364255b70561a"},
{file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5db95ff632dbabc8c38c4e82bf545ab78d902e81160e6e455598014f0abe66b9"},
{file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb967eb441b0f15ae610b7069bdb760b929f267efbf522e814bbbfffdf125ce2"},
{file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f04d2f20da4053d96c08f7fde6e1419b7ec9dbcee89c96e3d731fca77f411b95"},
{file = "regex-2025.9.18-cp311-cp311-win32.whl", hash = "sha256:895197241fccf18c0cea7550c80e75f185b8bd55b6924fcae269a1a92c614a07"},
{file = "regex-2025.9.18-cp311-cp311-win_amd64.whl", hash = "sha256:7e2b414deae99166e22c005e154a5513ac31493db178d8aec92b3269c9cce8c9"},
{file = "regex-2025.9.18-cp311-cp311-win_arm64.whl", hash = "sha256:fb137ec7c5c54f34a25ff9b31f6b7b0c2757be80176435bf367111e3f71d72df"},
{file = "regex-2025.9.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:436e1b31d7efd4dcd52091d076482031c611dde58bf9c46ca6d0a26e33053a7e"},
{file = "regex-2025.9.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c190af81e5576b9c5fdc708f781a52ff20f8b96386c6e2e0557a78402b029f4a"},
{file = "regex-2025.9.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4121f1ce2b2b5eec4b397cc1b277686e577e658d8f5870b7eb2d726bd2300ab"},
{file = "regex-2025.9.18-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:300e25dbbf8299d87205e821a201057f2ef9aa3deb29caa01cd2cac669e508d5"},
{file = "regex-2025.9.18-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b47fcf9f5316c0bdaf449e879407e1b9937a23c3b369135ca94ebc8d74b1742"},
{file = "regex-2025.9.18-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:57a161bd3acaa4b513220b49949b07e252165e6b6dc910ee7617a37ff4f5b425"},
{file = "regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f130c3a7845ba42de42f380fff3c8aebe89a810747d91bcf56d40a069f15352"},
{file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f96fa342b6f54dcba928dd452e8d8cb9f0d63e711d1721cd765bb9f73bb048d"},
{file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f0d676522d68c207828dcd01fb6f214f63f238c283d9f01d85fc664c7c85b56"},
{file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40532bff8a1a0621e7903ae57fce88feb2e8a9a9116d341701302c9302aef06e"},
{file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:039f11b618ce8d71a1c364fdee37da1012f5a3e79b1b2819a9f389cd82fd6282"},
{file = "regex-2025.9.18-cp312-cp312-win32.whl", hash = "sha256:e1dd06f981eb226edf87c55d523131ade7285137fbde837c34dc9d1bf309f459"},
{file = "regex-2025.9.18-cp312-cp312-win_amd64.whl", hash = "sha256:3d86b5247bf25fa3715e385aa9ff272c307e0636ce0c9595f64568b41f0a9c77"},
{file = "regex-2025.9.18-cp312-cp312-win_arm64.whl", hash = "sha256:032720248cbeeae6444c269b78cb15664458b7bb9ed02401d3da59fe4d68c3a5"},
{file = "regex-2025.9.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a40f929cd907c7e8ac7566ac76225a77701a6221bca937bdb70d56cb61f57b2"},
{file = "regex-2025.9.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c90471671c2cdf914e58b6af62420ea9ecd06d1554d7474d50133ff26ae88feb"},
{file = "regex-2025.9.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a351aff9e07a2dabb5022ead6380cff17a4f10e4feb15f9100ee56c4d6d06af"},
{file = "regex-2025.9.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc4b8e9d16e20ddfe16430c23468a8707ccad3365b06d4536142e71823f3ca29"},
{file = "regex-2025.9.18-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b8cdbddf2db1c5e80338ba2daa3cfa3dec73a46fff2a7dda087c8efbf12d62f"},
{file = "regex-2025.9.18-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a276937d9d75085b2c91fb48244349c6954f05ee97bba0963ce24a9d915b8b68"},
{file = "regex-2025.9.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92a8e375ccdc1256401c90e9dc02b8642894443d549ff5e25e36d7cf8a80c783"},
{file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dc6893b1f502d73037cf807a321cdc9be29ef3d6219f7970f842475873712ac"},
{file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a61e85bfc63d232ac14b015af1261f826260c8deb19401c0597dbb87a864361e"},
{file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ef86a9ebc53f379d921fb9a7e42b92059ad3ee800fcd9e0fe6181090e9f6c23"},
{file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d3bc882119764ba3a119fbf2bd4f1b47bc56c1da5d42df4ed54ae1e8e66fdf8f"},
{file = "regex-2025.9.18-cp313-cp313-win32.whl", hash = "sha256:3810a65675845c3bdfa58c3c7d88624356dd6ee2fc186628295e0969005f928d"},
{file = "regex-2025.9.18-cp313-cp313-win_amd64.whl", hash = "sha256:16eaf74b3c4180ede88f620f299e474913ab6924d5c4b89b3833bc2345d83b3d"},
{file = "regex-2025.9.18-cp313-cp313-win_arm64.whl", hash = "sha256:4dc98ba7dd66bd1261927a9f49bd5ee2bcb3660f7962f1ec02617280fc00f5eb"},
{file = "regex-2025.9.18-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fe5d50572bc885a0a799410a717c42b1a6b50e2f45872e2b40f4f288f9bce8a2"},
{file = "regex-2025.9.18-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b9d9a2d6cda6621551ca8cf7a06f103adf72831153f3c0d982386110870c4d3"},
{file = "regex-2025.9.18-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:13202e4c4ac0ef9a317fff817674b293c8f7e8c68d3190377d8d8b749f566e12"},
{file = "regex-2025.9.18-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874ff523b0fecffb090f80ae53dc93538f8db954c8bb5505f05b7787ab3402a0"},
{file = "regex-2025.9.18-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d13ab0490128f2bb45d596f754148cd750411afc97e813e4b3a61cf278a23bb6"},
{file = "regex-2025.9.18-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:05440bc172bc4b4b37fb9667e796597419404dbba62e171e1f826d7d2a9ebcef"},
{file = "regex-2025.9.18-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5514b8e4031fdfaa3d27e92c75719cbe7f379e28cacd939807289bce76d0e35a"},
{file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:65d3c38c39efce73e0d9dc019697b39903ba25b1ad45ebbd730d2cf32741f40d"},
{file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae77e447ebc144d5a26d50055c6ddba1d6ad4a865a560ec7200b8b06bc529368"},
{file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3ef8cf53dc8df49d7e28a356cf824e3623764e9833348b655cfed4524ab8a90"},
{file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9feb29817df349c976da9a0debf775c5c33fc1c8ad7b9f025825da99374770b7"},
{file = "regex-2025.9.18-cp313-cp313t-win32.whl", hash = "sha256:168be0d2f9b9d13076940b1ed774f98595b4e3c7fc54584bba81b3cc4181742e"},
{file = "regex-2025.9.18-cp313-cp313t-win_amd64.whl", hash = "sha256:d59ecf3bb549e491c8104fea7313f3563c7b048e01287db0a90485734a70a730"},
{file = "regex-2025.9.18-cp313-cp313t-win_arm64.whl", hash = "sha256:dbef80defe9fb21310948a2595420b36c6d641d9bea4c991175829b2cc4bc06a"},
{file = "regex-2025.9.18-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c6db75b51acf277997f3adcd0ad89045d856190d13359f15ab5dda21581d9129"},
{file = "regex-2025.9.18-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8f9698b6f6895d6db810e0bda5364f9ceb9e5b11328700a90cae573574f61eea"},
{file = "regex-2025.9.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29cd86aa7cb13a37d0f0d7c21d8d949fe402ffa0ea697e635afedd97ab4b69f1"},
{file = "regex-2025.9.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c9f285a071ee55cd9583ba24dde006e53e17780bb309baa8e4289cd472bcc47"},
{file = "regex-2025.9.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5adf266f730431e3be9021d3e5b8d5ee65e563fec2883ea8093944d21863b379"},
{file = "regex-2025.9.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1137cabc0f38807de79e28d3f6e3e3f2cc8cfb26bead754d02e6d1de5f679203"},
{file = "regex-2025.9.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cc9e5525cada99699ca9223cce2d52e88c52a3d2a0e842bd53de5497c604164"},
{file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bbb9246568f72dce29bcd433517c2be22c7791784b223a810225af3b50d1aafb"},
{file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6a52219a93dd3d92c675383efff6ae18c982e2d7651c792b1e6d121055808743"},
{file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ae9b3840c5bd456780e3ddf2f737ab55a79b790f6409182012718a35c6d43282"},
{file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d488c236ac497c46a5ac2005a952c1a0e22a07be9f10c3e735bc7d1209a34773"},
{file = "regex-2025.9.18-cp314-cp314-win32.whl", hash = "sha256:0c3506682ea19beefe627a38872d8da65cc01ffa25ed3f2e422dffa1474f0788"},
{file = "regex-2025.9.18-cp314-cp314-win_amd64.whl", hash = "sha256:57929d0f92bebb2d1a83af372cd0ffba2263f13f376e19b1e4fa32aec4efddc3"},
{file = "regex-2025.9.18-cp314-cp314-win_arm64.whl", hash = "sha256:6a4b44df31d34fa51aa5c995d3aa3c999cec4d69b9bd414a8be51984d859f06d"},
{file = "regex-2025.9.18-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b176326bcd544b5e9b17d6943f807697c0cb7351f6cfb45bf5637c95ff7e6306"},
{file = "regex-2025.9.18-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0ffd9e230b826b15b369391bec167baed57c7ce39efc35835448618860995946"},
{file = "regex-2025.9.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec46332c41add73f2b57e2f5b642f991f6b15e50e9f86285e08ffe3a512ac39f"},
{file = "regex-2025.9.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b80fa342ed1ea095168a3f116637bd1030d39c9ff38dc04e54ef7c521e01fc95"},
{file = "regex-2025.9.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4d97071c0ba40f0cf2a93ed76e660654c399a0a04ab7d85472239460f3da84b"},
{file = "regex-2025.9.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0ac936537ad87cef9e0e66c5144484206c1354224ee811ab1519a32373e411f3"},
{file = "regex-2025.9.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec57f96d4def58c422d212d414efe28218d58537b5445cf0c33afb1b4768571"},
{file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48317233294648bf7cd068857f248e3a57222259a5304d32c7552e2284a1b2ad"},
{file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:274687e62ea3cf54846a9b25fc48a04459de50af30a7bd0b61a9e38015983494"},
{file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a78722c86a3e7e6aadf9579e3b0ad78d955f2d1f1a8ca4f67d7ca258e8719d4b"},
{file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06104cd203cdef3ade989a1c45b6215bf42f8b9dd705ecc220c173233f7cba41"},
{file = "regex-2025.9.18-cp314-cp314t-win32.whl", hash = "sha256:2e1eddc06eeaffd249c0adb6fafc19e2118e6308c60df9db27919e96b5656096"},
{file = "regex-2025.9.18-cp314-cp314t-win_amd64.whl", hash = "sha256:8620d247fb8c0683ade51217b459cb4a1081c0405a3072235ba43a40d355c09a"},
{file = "regex-2025.9.18-cp314-cp314t-win_arm64.whl", hash = "sha256:b7531a8ef61de2c647cdf68b3229b071e46ec326b3138b2180acb4275f470b01"},
{file = "regex-2025.9.18-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3dbcfcaa18e9480669030d07371713c10b4f1a41f791ffa5cb1a99f24e777f40"},
{file = "regex-2025.9.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1e85f73ef7095f0380208269055ae20524bfde3f27c5384126ddccf20382a638"},
{file = "regex-2025.9.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9098e29b3ea4ffffeade423f6779665e2a4f8db64e699c0ed737ef0db6ba7b12"},
{file = "regex-2025.9.18-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90b6b7a2d0f45b7ecaaee1aec6b362184d6596ba2092dd583ffba1b78dd0231c"},
{file = "regex-2025.9.18-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c81b892af4a38286101502eae7aec69f7cd749a893d9987a92776954f3943408"},
{file = "regex-2025.9.18-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3b524d010973f2e1929aeb635418d468d869a5f77b52084d9f74c272189c251d"},
{file = "regex-2025.9.18-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b498437c026a3d5d0be0020023ff76d70ae4d77118e92f6f26c9d0423452446"},
{file = "regex-2025.9.18-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0716e4d6e58853d83f6563f3cf25c281ff46cf7107e5f11879e32cb0b59797d9"},
{file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:065b6956749379d41db2625f880b637d4acc14c0a4de0d25d609a62850e96d36"},
{file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d4a691494439287c08ddb9b5793da605ee80299dd31e95fa3f323fac3c33d9d4"},
{file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ef8d10cc0989565bcbe45fb4439f044594d5c2b8919d3d229ea2c4238f1d55b0"},
{file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4baeb1b16735ac969a7eeecc216f1f8b7caf60431f38a2671ae601f716a32d25"},
{file = "regex-2025.9.18-cp39-cp39-win32.whl", hash = "sha256:8e5f41ad24a1e0b5dfcf4c4e5d9f5bd54c895feb5708dd0c1d0d35693b24d478"},
{file = "regex-2025.9.18-cp39-cp39-win_amd64.whl", hash = "sha256:50e8290707f2fb8e314ab3831e594da71e062f1d623b05266f8cfe4db4949afd"},
{file = "regex-2025.9.18-cp39-cp39-win_arm64.whl", hash = "sha256:039a9d7195fd88c943d7c777d4941e8ef736731947becce773c31a1009cb3c35"},
{file = "regex-2025.9.18.tar.gz", hash = "sha256:c5ba23274c61c6fef447ba6a39333297d0c247f53059dba0bca415cac511edc4"},
]
[[package]]
name = "requests"
version = "2.33.0"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"},
{file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "rich"
version = "15.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.9.0"
groups = ["main", "dev"]
files = [
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
]
markers = {main = "extra == \"cli\""}
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "shellingham"
version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = true
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
]
[[package]]
name = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main", "docs"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
markers = {main = "extra == \"cli\""}
[[package]]
name = "smmap"
version = "5.0.2"
description = "A pure Python implementation of a sliding window memory map manager"
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"},
{file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"},
]
[[package]]
name = "termcolor"
version = "3.1.0"
description = "ANSI color formatting for output in terminal"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa"},
{file = "termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970"},
]
[package.extras]
tests = ["pytest", "pytest-cov"]
[[package]]
name = "typer"
version = "0.27.1"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = true
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56"},
{file = "typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df"},
]
[package.dependencies]
annotated-doc = ">=0.0.2"
colorama = {version = "*", markers = "platform_system == \"Windows\""}
rich = ">=13.8.0"
shellingham = ">=1.3.0"
[[package]]
name = "types-aiofiles"
version = "25.1.0.20260518"
description = "Typing stubs for aiofiles"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "types_aiofiles-25.1.0.20260518-py3-none-any.whl", hash = "sha256:f776bdfb4bec17f743d9ef042e61edf03bdcc7821fc08556fba9b63d873fdea9"},
{file = "types_aiofiles-25.1.0.20260518.tar.gz", hash = "sha256:c0c95eb78755d4fa7b397d4f0332c632714dd7cd0d17f49b96e31d4d7a8d8c76"},
]
[[package]]
name = "types-dateparser"
version = "1.4.1.20260617"
description = "Typing stubs for dateparser"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "types_dateparser-1.4.1.20260617-py3-none-any.whl", hash = "sha256:af5619f005b56880898c54a9cb25bbc1c145751710d456be4f94c50751316ddb"},
{file = "types_dateparser-1.4.1.20260617.tar.gz", hash = "sha256:aaf1958a88e2bca849ff36b4696476c829c7b0ad06ff8d473635a8a985468346"},
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
]
[package.dependencies]
typing-extensions = ">=4.12.0"
[[package]]
name = "tzdata"
version = "2025.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main", "docs"]
files = [
{file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
]
markers = {docs = "sys_platform == \"win32\""}
[[package]]
name = "tzlocal"
version = "5.3.1"
description = "tzinfo object for the local timezone"
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"cli\""
files = [
{file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"},
{file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"},
]
[package.dependencies]
tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
[[package]]
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "verspec"
version = "0.1.0"
description = "Flexible version handling"
optional = false
python-versions = "*"
groups = ["docs"]
files = [
{file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"},
{file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"},
]
[package.extras]
test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"]
[[package]]
name = "virtualenv"
version = "20.36.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5"
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
[[package]]
name = "watchdog"
version = "6.0.0"
description = "Filesystem events monitoring"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"},
{file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"},
{file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"},
{file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"},
{file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"},
{file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"},
{file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"},
{file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"},
{file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"},
{file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"},
{file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"},
{file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"},
{file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"},
{file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"},
{file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"},
{file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"},
{file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"},
{file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"},
{file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"},
{file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"},
{file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"},
{file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"},
{file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"},
{file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"},
{file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"},
{file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"},
{file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"},
{file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"},
{file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"},
{file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"},
]
[package.extras]
watchmedo = ["PyYAML (>=3.10)"]
[[package]]
name = "wcmatch"
version = "10.1"
description = "Wildcard/glob file name matcher."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a"},
{file = "wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af"},
]
[package.dependencies]
bracex = ">=2.1.1"
[[package]]
name = "yarl"
version = "1.24.5"
description = "Yet another URL library"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"},
{file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"},
{file = "yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba"},
{file = "yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d"},
{file = "yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224"},
{file = "yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13"},
{file = "yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3"},
{file = "yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a"},
{file = "yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e"},
{file = "yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd"},
{file = "yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25"},
{file = "yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a"},
{file = "yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d"},
{file = "yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec"},
{file = "yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9"},
{file = "yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba"},
{file = "yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b"},
{file = "yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c"},
{file = "yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2"},
{file = "yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb"},
{file = "yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16"},
{file = "yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4"},
{file = "yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740"},
{file = "yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1"},
{file = "yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76"},
{file = "yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d"},
{file = "yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9"},
{file = "yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4"},
{file = "yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad"},
{file = "yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f"},
{file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88"},
{file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba"},
{file = "yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047"},
{file = "yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104"},
{file = "yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688"},
{file = "yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7"},
{file = "yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f"},
]
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
propcache = ">=0.2.1"
[extras]
cli = ["dateparser", "pillow", "rich", "typer"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
content-hash = "d4ef814cf1a63a9d01f3d416928f0493d432320ae41d3ae84ade9756c4092638"
uilibs-uiprotect-317a52c/pyproject.toml 0000664 0000000 0000000 00000021602 15246377057 0020301 0 ustar 00root root 0000000 0000000 [project]
name = "uiprotect"
version = "16.10.0"
license = "MIT"
description = "Python API for Unifi Protect (Unofficial)"
authors = [{ name = "UI Protect Maintainers", email = "ui@koston.org" }]
readme = "README.md"
requires-python = ">=3.11"
dynamic = ["classifiers", "dependencies"]
[project.optional-dependencies]
cli = [
"typer>=0.27.0",
"rich>=15.0.0",
"dateparser>=1.4.1",
"pillow>=12.3.0",
]
[project.urls]
"Repository" = "https://github.com/uilibs/uiprotect"
"Documentation" = "https://uiprotect.readthedocs.io"
"Bug Tracker" = "https://github.com/uilibs/uiprotect/issues"
"Changelog" = "https://github.com/uilibs/uiprotect/blob/main/CHANGELOG.md"
[project.scripts]
uiprotect = "uiprotect.cli:app"
[tool.poetry]
classifiers = [
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: OS Independent",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Build Tools",
"Development Status :: 5 - Production/Stable",
]
packages = [
{ include = "uiprotect", from = "src" },
]
[tool.poetry.dependencies]
python = ">=3.11"
rich = { version = ">=15.0.0", optional = true }
aiofiles = ">=24"
aiohttp = ">=3.14.3"
av = ">=17.0.1"
dateparser = { version = ">=1.4.1", optional = true }
orjson = ">=3.11.9"
packaging = ">=26.2"
pillow = { version = ">=12.3.0", optional = true }
platformdirs = ">=4.10.1"
pydantic = ">=2.13.4"
# Temporarily lowered from >=2.13.0 so the Home Assistant lib bump is not
# blocked on home-assistant/core#172893 (PyJWT 2.12->2.13). uiprotect only
# decodes with verify_signature=False, so the 2.13.0 change is irrelevant
# here; re-raise once that PR lands.
pyjwt = ">=2.12.1"
yarl = ">=1.24.5"
typer = { version = ">=0.27.0", optional = true }
convertertools = ">=0.5.0"
propcache = ">=0.5.2"
pydantic-extra-types = ">=2.10.1"
aiozoneinfo = ">=0.2.3"
[tool.poetry.group.dev.dependencies]
pytest = ">=9.1.1,<10"
pytest-cov = ">=3,<8"
aiosqlite = ">=0.20.0"
pytest-asyncio = ">=1.4.0,<1.5.0"
pytest-benchmark = ">=4,<6"
pytest-codspeed = "^5.0.3"
pytest-sugar = "^1.1.1"
pytest-timeout = "^2.4.0"
pytest-xdist = "^3.7.0"
types-aiofiles = ">=25.1.0.20260518,<26.0.0.0"
types-dateparser = "^1.4.1.20260617"
mypy = "^2.1.0"
pre-commit = "^4.6.0"
blockbuster = ">=1.5.5,<2.0.0"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
mike = "^2.2.0"
mkdocs-material = "^9.7.7"
mkdocs-material-extensions = "^1.3.1"
pymdown-extensions = "^11.0.1"
mkdocs-git-revision-date-localized-plugin = "^1.5.3"
mkdocs-include-markdown-plugin = ">=7.3.0,<8.0.0"
mkdocstrings = ">=1.0.6,<1.1.0"
mkdocstrings-python = "^2.0.5"
[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
version_variables = [
"src/uiprotect/__init__.py:__version__",
]
build_command = "pip install poetry && poetry build"
[tool.semantic_release.changelog]
exclude_commit_patterns = [
"chore*",
"ci*",
]
[tool.semantic_release.changelog.environment]
keep_trailing_newline = true
[tool.semantic_release.branches.main]
match = "main"
[tool.semantic_release.branches.noop]
match = "(?!main$)"
prerelease = true
[tool.pytest.ini_options]
addopts = "-v -Wdefault --cov=uiprotect --cov-report=term-missing:skip-covered -n=auto"
pythonpath = ["src"]
[tool.codespell]
skip = "poetry.lock,*.lock,*/sample_data/*,*/openapi/*,CHANGELOG.md,*/site/*,site/*"
# domain terms / protocol field names that are not English typos:
# - aiport: UniFi AiPort device codename (AiPort/aiport/AIPORT)
# - tempature: misspelled wire-format disk-health key served by Protect firmware
ignore-words-list = "aiport,tempature"
[tool.coverage.run]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"@overload",
"if TYPE_CHECKING",
"raise NotImplementedError",
'if __name__ == "__main__":',
]
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
ignore = [
"S101", # use of assert
"D203", # 1 blank line required before class docstring
"D212", # Multi-line docstring summary should start at the first line
"D100", # Missing docstring in public module
"D101", # Missing docstring in public module
"D102", # Missing docstring in public method
"D103", # Missing docstring in public module
"D104", # Missing docstring in public package
"D105", # Missing docstring in magic method
"D107", # Missing docstring in `__init__`
"D400", # First line should end with a period
"D401", # First line of docstring should be in imperative mood
"D205", # 1 blank line required between summary line and description
"D415", # First line should end with a period, question mark, or exclamation point
"D417", # Missing argument descriptions in the docstring
"E501", # Line too long
"RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
"B008", # Do not perform function call
"S110", # `try`-`except`-`pass` detected, consider logging the exception
"D106", # Missing docstring in public nested class
"UP007", # typer needs Optional syntax
"S603", # check for execution of untrusted input
"PERF203", # too many to fix right now
"PLR2004", # Magic value used in comparison
"PLR0913", # Too many arguments to function call
"PLR0917", # Too many positional arguments (public signatures and typer CLI args)
"PLW1641", # Object does not implement __hash__ method
# Mirrored from esphome/aioesphomeapi (see issue #872) — pre-ignored ahead
# of enabling the parent rule families in future tiers.
"TRY003", # avoid specifying long messages outside the exception class
"ANN401", # disallow Any
"ASYNC109", # async function with timeout parameter
"TID252", # relative imports — this repo uses `from ..exceptions import ...` throughout
]
select = [
"A", # flake8-builtins
"B", # flake8-bugbear
"D", # flake8-docstrings
"C4", # flake8-comprehensions
"DTZ", # flake8-datetimez
"ERA", # eradicate (commented-out code)
"FA", # flake8-future-annotations
"FLY", # flynt (f-string conversion)
"G", # flake8-logging-format
"ICN", # flake8-import-conventions
"INP", # flake8-no-pep420 (implicit namespace packages)
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"N", # pep8-naming
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PTH", # flake8-use-pathlib
"PYI", # flake8-pyi
"Q", # flake8-quotes
"RSE", # flake8-raise
"SLOT", # flake8-slots
"T10", # flake8-debugger
"T20", # flake8-print
"TC", # flake8-type-checking
"TD", # flake8-todos
"FIX", # flake8-fixme
"YTT", # flake8-2020
"S", # flake8-bandit
"F", # pyflake
"FURB", # refurb rules
"E", # pycodestyle
"W", # pycodestyle
"UP", # pyupgrade
"I", # isort
"PERF", # performance
"PL", # pylint
"RET", # return rules
"RUF", # ruff specific
"SIM", # simplify
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*" = [
"D100",
"D101",
"D102",
"D103",
"D104",
"S101",
"PLR0911",
"PLR0912",
"PLR0915",
"DTZ001", # tests intentionally construct naive datetimes
"DTZ005", # tests intentionally call datetime.now() without tz
"ERA001", # test fixtures keep commented-out reference snippets
"E402", # optional-dependency guards (pytest.importorskip) precede imports
]
"setup.py" = ["D100"]
"conftest.py" = ["D100"]
"scripts/**/*" = [
"T201", # standalone CLI scripts print to stderr by design
"INP001", # scripts/ is a runnable folder, not an importable package
]
"src/uiprotect/data/**/*" = [
# pydantic v2 model_rebuild() needs field-type classes resolvable at runtime
"TC001",
"TC002",
"TC003",
]
"src/uiprotect/events/**/*" = [
# pydantic v2 model field types must resolve at runtime for validation
"TC001",
"TC002",
"TC003",
]
"src/uiprotect/devices/**/*" = [
# pydantic v2 model field types must resolve at runtime for validation
"TC001",
"TC002",
"TC003",
]
"src/uiprotect/cli/**/*" = [
# typer inspects runtime annotations on command callbacks
"TC001",
"TC002",
"TC003",
]
"src/uiprotect/exceptions.py" = [
# public exception names — renaming would break the API consumers depend on
"N818",
]
[tool.ruff.lint.isort]
known-first-party = ["uiprotect", "tests"]
[tool.mypy]
disable_error_code = "import-untyped,unused-ignore"
check_untyped_defs = true
ignore_missing_imports = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
mypy_path = "src/"
no_implicit_optional = true
show_error_codes = true
warn_unreachable = true
warn_unused_ignores = true
exclude = [
'docs/.*',
'setup.py',
]
[[tool.mypy.overrides]]
module = "tests.*"
allow_untyped_defs = true
[[tool.mypy.overrides]]
module = "docs.*"
ignore_errors = true
[build-system]
requires = ["poetry-core>=2.1.0"]
build-backend = "poetry.core.masonry.api"
uilibs-uiprotect-317a52c/renovate.json 0000664 0000000 0000000 00000000101 15246377057 0020072 0 ustar 00root root 0000000 0000000 {
"extends": ["github>browniebroke/renovate-configs:python"]
}
uilibs-uiprotect-317a52c/scripts/ 0000775 0000000 0000000 00000000000 15246377057 0017053 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/scripts/fetch_openapi.py 0000664 0000000 0000000 00000016057 15246377057 0022242 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
"""
Fetch the UniFi Protect integration OpenAPI spec.
Primary source is Ubiquiti's developer portal
(https://developer.ui.com/protect/), which serves the spec as a small JSON
document per version. The official deb package remains available as a
fallback via ``--from-deb``.
Writes openapi/integration.json (gitignored). Requires only stdlib.
"""
from __future__ import annotations
import argparse
import io
import json
import re
import sys
import tarfile
import urllib.error
import urllib.request
from pathlib import Path
FIRMWARE_API = "https://fw-update.ubnt.com/api/v2/firmware-latest"
PORTAL_INDEX = "https://developer.ui.com/protect/"
PORTAL_SPEC = "https://developer.ui.com/protect/v{version}/openapi.json"
SPEC_MEMBER = "./usr/share/unifi-protect/app/fixtures/integration/openapi.json"
DEFAULT_OUTPUT = Path(__file__).resolve().parent.parent / "openapi" / "integration.json"
def fetch_spec(
version: str | None = None,
output: Path = DEFAULT_OUTPUT,
*,
from_deb: bool = False,
) -> None:
"""Fetch the integration OpenAPI spec and write it to ``output``."""
deb_url, ver = _query_firmware(version)
print(f"unifi-protect {ver}", file=sys.stderr)
spec_bytes = _fetch_from_deb(deb_url) if from_deb else _fetch_from_portal(ver)
spec = json.loads(spec_bytes)
# The portal spec carries a placeholder ``info.version`` ("0.0.0"); the
# real version only lives in the URL. Stamp it so consumers can trust it.
if spec.get("info", {}).get("version") in (None, "0.0.0"):
spec.setdefault("info", {})["version"] = ver
spec_bytes = json.dumps(spec, indent=2).encode() + b"\n"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(spec_bytes)
print(
f"Written {output} "
f"(version {spec['info']['version']}, "
f"{len(spec['paths'])} paths)",
file=sys.stderr,
)
def _fetch_from_portal(version: str) -> bytes:
"""Download the spec from the developer portal (~400 KB)."""
url = PORTAL_SPEC.format(version=version.removeprefix("v"))
print(f"Downloading: {url}", file=sys.stderr)
request = urllib.request.Request(url) # noqa: S310
try:
with urllib.request.urlopen(request, timeout=60) as resp: # noqa: S310
if "json" not in resp.headers.get("Content-Type", ""):
raise RuntimeError(f"Unexpected content type from {url}")
return resp.read()
except urllib.error.HTTPError as err:
if err.code == 404:
raise RuntimeError(
f"No spec for version {version} on the developer portal; "
"see --list for published versions or retry with --from-deb"
) from err
raise
def _fetch_from_deb(url: str) -> bytes:
"""Download the unifi-protect deb (~74 MB) and extract the spec."""
print(f"Downloading (~74 MB): {url}", file=sys.stderr)
with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310
deb_bytes = resp.read()
print("Extracting spec …", file=sys.stderr)
return _extract_from_deb(deb_bytes)
def list_versions() -> list[str]:
"""Return the spec versions published on the developer portal, newest first."""
request = urllib.request.Request(PORTAL_INDEX)
with urllib.request.urlopen(request, timeout=60) as resp: # noqa: S310
html = resp.read().decode(errors="replace")
# The portal is a Next.js app; the version picker's data is embedded in
# the page payload as an escaped JSON array of {"version": "vX.Y.Z"}.
match = re.search(r'versions\\":\[(.*?)\]', html)
if match is None:
raise RuntimeError(f"Could not find the version list on {PORTAL_INDEX}")
return re.findall(r"v(\d+\.\d+\.\d+)", match.group(1))
def _query_firmware(version: str | None) -> tuple[str, str]:
"""
Return (deb_download_url, version) for the requested protect version.
The version is normalised to a bare ``MAJOR.MINOR.PATCH``; the firmware API
reports it ``v``-prefixed.
"""
params = [
"filter=eq~~product~~unifi-protect",
"filter=eq~~platform~~uos-deb11-arm64",
]
if version:
parts = version.split(".")
if len(parts) != 3:
raise ValueError(f"version must be MAJOR.MINOR.PATCH, got {version!r}")
major, minor, patch = parts
params += [
f"filter=eq~~version_major~~{major}",
f"filter=eq~~version_minor~~{minor}",
f"filter=eq~~version_patch~~{patch}",
]
else:
params.append("filter=eq~~channel~~release")
url = f"{FIRMWARE_API}?{'&'.join(params)}"
with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310
data = json.loads(resp.read())
entries = data["_embedded"]["firmware"]
if not entries:
raise RuntimeError(f"No firmware found for version={version!r}")
fw = entries[0]
return fw["_links"]["data"]["href"], fw["version"].removeprefix("v")
def _extract_from_deb(deb_bytes: bytes) -> bytes:
"""Extract the integration openapi.json from a .deb (ar archive)."""
if deb_bytes[:8] != b"!\n":
raise ValueError("Not an ar archive")
offset = 8
while offset < len(deb_bytes):
name = deb_bytes[offset : offset + 16].decode("ascii").rstrip()
size = int(deb_bytes[offset + 48 : offset + 58].decode("ascii").strip())
offset += 60 # ar header is exactly 60 bytes
member_data = deb_bytes[offset : offset + size]
offset += size + (size % 2) # ar pads members to even byte boundary
if name.startswith("data.tar"):
with tarfile.open(fileobj=io.BytesIO(member_data)) as tf:
f = tf.extractfile(tf.getmember(SPEC_MEMBER))
if f is None:
raise RuntimeError(f"{SPEC_MEMBER!r} is not a regular file in deb")
return f.read()
raise FileNotFoundError(f"{SPEC_MEMBER!r} not found in deb")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--version",
help="Protect version to fetch, e.g. 7.0.104 (default: latest release)",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
metavar="PATH",
help=f"Output path (default: {DEFAULT_OUTPUT})",
)
parser.add_argument(
"--print-version",
action="store_true",
help="Print the resolved version to stdout and exit (no download)",
)
parser.add_argument(
"--list",
action="store_true",
help="List spec versions published on the developer portal and exit",
)
parser.add_argument(
"--from-deb",
action="store_true",
help="Extract the spec from the official deb (~74 MB) instead of the portal",
)
args = parser.parse_args()
if args.list:
print("\n".join(list_versions()))
elif args.print_version:
_, ver = _query_firmware(args.version)
print(ver)
else:
fetch_spec(args.version, args.output, from_deb=args.from_deb)
uilibs-uiprotect-317a52c/scripts/validate_spec.py 0000664 0000000 0000000 00000113350 15246377057 0022233 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
"""
Validate the public-API client against the Integration OpenAPI spec.
Reads ``openapi/integration.json`` (fetched on demand by
``scripts/fetch_openapi.py``; gitignored, never committed) and reports drift
between Ubiquiti's spec and the hand-maintained client surface:
* spec endpoints with no covering client method — derived from the declarative
``@public_*`` decorator registry plus one recorded example call per
hand-written exception method, never a hand-maintained table (warning),
* model fields the spec dropped or retyped (error) and fields the spec added
that the model lacks (warning),
* spec enum values absent from the matching library enum (warning),
* inbound spec enums and ``const`` discriminators with no library counterpart
(error),
* spec ``event`` union types absent from ``PUBLIC_EVENT_TYPES``, or modelled
types the spec no longer declares (error).
The library deliberately models every public-API field optional (older firmware
and partial/reference responses omit them), so spec ``required`` vs. model
``optional`` is not checked — it would be guaranteed noise on every run.
Exits non-zero on any error and prints a markdown summary the spec-validation
workflow embeds in a drift issue. The check functions are also imported by
``tests/test_public_schema_conformance.py`` so a contributor who fetches the
spec locally runs the full validation for free.
"""
from __future__ import annotations
import asyncio
import contextlib
import enum
import functools
import importlib
import inspect
import pkgutil
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, get_args
import orjson
# ``scripts/`` is not an importable package, so make ``src/`` importable when
# this file is run directly or imported by the test via a sys.path insert.
_SRC = Path(__file__).resolve().parents[1] / "src"
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
from uiprotect import data as uiprotect_data # noqa: E402
from uiprotect._public_api import registry # noqa: E402
from uiprotect.api import ProtectApiClient # noqa: E402
from uiprotect.data import ( # noqa: E402
PUBLIC_EVENT_TYPES,
ArmProfile,
Fob,
LinkStation,
PublicBridge,
PublicCamera,
PublicChime,
PublicEvent,
PublicLight,
PublicLiveview,
PublicNVR,
PublicPosTransactionResponse,
PublicSensor,
PublicUlpUser,
PublicUser,
PublicViewer,
Relay,
Siren,
Speaker,
)
from uiprotect.data.base import ProtectBaseObject # noqa: E402
from uiprotect.data.types import DeviceState # noqa: E402
from uiprotect.utils import to_snake_case # noqa: E402
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
SPEC_PATH = Path(__file__).resolve().parents[1] / "openapi" / "integration.json"
# ---------------------------------------------------------------------------
# Endpoint coverage source.
#
# The set of covered endpoints is DERIVED, never hand-maintained:
#
# * Declarative endpoints come straight from the import-time ``@public_get`` /
# ``@public_patch`` / ``@public_post`` registry — the decorator *is* the
# implementation, so it cannot drift out of sync with the client.
# * The hand-written exception methods (grouped/validated PATCHes, the
# alarm-hub URL aliases, file upload, ``update_public``, etc.) are covered by
# one executable example call each, recorded by spying on
# ``BaseApiClient.request`` (see ``_record_example_calls``).
# * The two websocket subscriptions are not REST calls; their paths are read
# straight off the client's ``*_ws_path`` attributes.
#
# A spec endpoint absent from this derived union reads as a new/uncovered
# endpoint and raises a warning. ``check_completeness`` separately asserts every
# public-API coroutine is itself accounted for, so a newly added method that
# nobody wired up cannot silently leave a gap.
# ---------------------------------------------------------------------------
# Registry path templates use Python parameter names (``/v1/cameras/{camera_id}``)
# while the spec uses its own (``/v1/cameras/{id}``); recorded example calls
# inject this sentinel where a path parameter goes. Normalizing every ``{...}``
# placeholder and the sentinel to a bare ``{}`` lets the three sources compare
# equal regardless of the parameter name.
_PATH_PARAM_RE = re.compile(r"\{[^/{}]+\}")
_RECORD_SENTINEL = "__spec_param__"
def _normalize_path(path: str) -> str:
"""Collapse named path placeholders and the record sentinel to a bare ``{}``."""
return _PATH_PARAM_RE.sub("{}", path).replace(_RECORD_SENTINEL, "{}")
# Public-API coroutines that issue requests but are neither decorated nor named
# ``*_public`` (so the completeness scan would otherwise miss them).
_EXTRA_PUBLIC_COROUTINES = frozenset(
{
"get_public_api_camera_snapshot",
"create_camera_rtsps_streams",
"get_camera_rtsps_streams",
"delete_camera_rtsps_streams",
}
)
# One executable example call per hand-written (non-declarative) public-API
# coroutine. Each is invoked against a throw-away client whose ``request`` is
# spied on, so the call records its ``(verb, path)`` and then short-circuits
# before any network I/O. Path parameters get ``_RECORD_SENTINEL``; PATCH bodies
# get a single field so the "at least one parameter" guards pass.
_S = _RECORD_SENTINEL
_EXAMPLE_CALLS: dict[str, Callable[[ProtectApiClient], Awaitable[Any]]] = {
"get_public_api_camera_snapshot": lambda c: c.get_public_api_camera_snapshot(_S),
"create_camera_rtsps_streams": lambda c: c.create_camera_rtsps_streams(_S, "high"),
"get_camera_rtsps_streams": lambda c: c.get_camera_rtsps_streams(_S),
"delete_camera_rtsps_streams": lambda c: c.delete_camera_rtsps_streams(_S, "high"),
"create_talkback_session_public": lambda c: c.create_talkback_session_public(_S),
"disable_camera_mic_permanently_public": (
lambda c: c.disable_camera_mic_permanently_public(_S)
),
"update_camera_public": lambda c: c.update_camera_public(_S, name=_S),
"update_light_public": lambda c: c.update_light_public(_S, name=_S),
"update_sensor_public": lambda c: c.update_sensor_public(_S, name=_S),
"update_siren_public": lambda c: c.update_siren_public(_S, name=_S),
"play_siren_public": lambda c: c.play_siren_public(_S),
"test_siren_sound_public": lambda c: c.test_siren_sound_public(_S),
"update_relay_public": lambda c: c.update_relay_public(_S, name=_S),
"activate_relay_output_public": (
lambda c: c.activate_relay_output_public(_S, _S, state="on")
),
"test_speaker_sound_public": lambda c: c.test_speaker_sound_public(_S),
"trigger_alarm_hub_output_public": (
lambda c: c.trigger_alarm_hub_output_public(_S, _S, enable=True)
),
"get_bridge_public": lambda c: c.get_bridge_public(_S),
"update_bridge_public": lambda c: c.update_bridge_public(_S, name=_S),
"get_viewer_public": lambda c: c.get_viewer_public(_S),
"update_viewer_public": lambda c: c.update_viewer_public(_S, name=_S),
"get_liveview_public": lambda c: c.get_liveview_public(_S),
"create_liveview_public": lambda c: c.create_liveview_public(
name=_S, is_default=False, is_global=False, owner=_S, layout=1, slots=[]
),
"update_liveview_public": lambda c: c.update_liveview_public(_S, name=_S),
"send_alarm_webhook_public": lambda c: c.send_alarm_webhook_public(_S),
"get_arm_profiles_public": lambda c: c.get_arm_profiles_public(),
"create_arm_profile_public": lambda c: c.create_arm_profile_public(
name=_S,
automations=[],
schedules=[],
record_everything=False,
activation_delay=0,
),
"update_arm_profile_public": lambda c: c.update_arm_profile_public(_S, name=_S),
"delete_arm_profile_public": lambda c: c.delete_arm_profile_public(_S),
"get_arm_manager_settings_public": lambda c: c.get_arm_manager_settings_public(),
"set_current_arm_profile_public": lambda c: c.set_current_arm_profile_public(_S),
"enable_arm_alarm_public": lambda c: c.enable_arm_alarm_public(),
"disable_arm_alarm_public": lambda c: c.disable_arm_alarm_public(),
"get_files_public": lambda c: c.get_files_public(_S),
"upload_file_public": lambda c: c.upload_file_public(_S, b"x", "asset.png"),
"update_public": lambda c: c.update_public(),
}
class _ShortCircuitError(Exception):
"""Raised by the request spy to abort an example call before network I/O."""
async def _record_example_calls() -> set[tuple[str, str]]:
"""Run every example call against a spied client; return recorded ``(verb, path)``."""
client = ProtectApiClient.public_only("127.0.0.1", 443, api_key="x")
recorded: set[tuple[str, str]] = set()
prefix = client.public_api_path
async def _spy(method: str, url: str, *_args: Any, **_kwargs: Any) -> Any:
recorded.add((method.upper(), _normalize_path(url.removeprefix(prefix))))
raise _ShortCircuitError
client.request = _spy # type: ignore[method-assign]
for call in _EXAMPLE_CALLS.values():
# ``update_public`` gathers its fetches with ``return_exceptions=True``
# and swallows the sentinel; the others propagate it. Either way the
# spy has already recorded every path the call reached.
with contextlib.suppress(_ShortCircuitError):
await call(client)
return recorded
def _registry_endpoints() -> set[tuple[str, str]]:
"""Covered ``(VERB, path)`` for every declaratively-decorated endpoint."""
return {
(verb.upper(), _normalize_path(path))
for verb, path in registry.for_class(ProtectApiClient.__name__)
}
def _subscribe_endpoints() -> set[tuple[str, str]]:
"""The two websocket subscription paths, read off the client's path attributes."""
prefix = ProtectApiClient.public_api_path
endpoints: set[tuple[str, str]] = set()
for attr in ("events_ws_path", "devices_ws_path"):
path: str = getattr(ProtectApiClient, attr)
endpoints.add(("GET", _normalize_path(path.removeprefix(prefix))))
return endpoints
@functools.cache
def covered_endpoints() -> frozenset[tuple[str, str]]:
"""Derived union of declarative, recorded, and websocket-subscription endpoints."""
recorded = asyncio.run(_record_example_calls())
return frozenset(_registry_endpoints() | recorded | _subscribe_endpoints())
# (model class, spec schema name) pairs validated field-for-field.
_MODEL_SCHEMAS: list[tuple[type[ProtectBaseObject], str]] = [
(PublicCamera, "camera"),
(PublicLight, "light"),
(PublicSensor, "sensor"),
(PublicChime, "chime"),
(PublicNVR, "nvr"),
(PublicViewer, "viewer"),
(PublicBridge, "bridge"),
(PublicLiveview, "liveview"),
(Siren, "siren"),
(Relay, "relay"),
(Speaker, "speaker"),
(Fob, "fob"),
(LinkStation, "linkStation"),
(PublicUser, "user"),
(PublicUlpUser, "ulpUser"),
(ArmProfile, "armProfile"),
# ``event`` is a ``oneOf`` union; its variants are merged into one property
# map, matching the single all-optional ``PublicEvent`` model.
(PublicEvent, "event"),
(PublicPosTransactionResponse, "posTransactionResponse"),
]
# Spec enum schemas with a known library counterpart. Spec values absent from
# the lib enum are warnings; the lib's ``UNKNOWN`` sentinel is ignored.
_ENUM_SCHEMAS: list[tuple[type[Any], str]] = [
(DeviceState, "deviceState"),
]
# The library's forward-compat sentinel value, ignored on both sides of the
# enum-coverage comparison so a lib enum that models ``UNKNOWN`` still counts as
# covering a spec enum regardless of whether either side lists a literal
# ``unknown``.
_ENUM_SENTINEL = "unknown"
# Spec enums whose value-set is a strict *subset* of a single, explicitly named
# library enum that the public models already type the field with. The library
# enum is a forward-compatible superset — it also carries values from the
# private API or newer firmware (e.g. ``VideoMode``'s ``homekit``) — so an exact
# value-set match is impossible by design, yet the field IS faithfully typed.
# Each entry is pinned to ONE named enum and verified ``spec ⊆ lib`` at runtime,
# so a coincidental value-set collision can never satisfy it (that was the v1
# any-subset false-negative). A spec value that grows beyond the named enum
# changes the value-set, breaks the key match, and re-surfaces the enum as an
# unmodelled warning.
_MODELLED_AS_SUBSET: dict[frozenset[str], str] = {
frozenset(
{"default", "highFps", "sport", "slowShutter", "lprReflex", "lprNoneReflex"}
): "VideoMode",
frozenset({"neutral", "low", "safe", "high"}): "SensorStatusType",
frozenset(
{"animal", "face", "licensePlate", "package", "person", "vehicle"}
): "SmartDetectObjectType",
frozenset(
{
"alrmBabyCry",
"alrmBark",
"alrmBurglar",
"alrmCarHorn",
"alrmCmonx",
"alrmGlassBreak",
"alrmSiren",
"alrmSmoke",
"alrmSpeak",
}
): "SmartDetectAudioType",
}
# Inbound spec enums (reachable from a response the library deserializes)
# intentionally left untyped, kept opaque pending a dedicated modelling effort.
# Outbound-only enums (request param / body) need no entry — they are waived by
# direction. Each waiver is keyed by ``(owning component schema, value-set)`` —
# the value-set alone would let a waiver for a generic set like ``{closed,
# open}`` silently cover an unrelated future enum on another schema. Waivers are
# the rare, documented exception: the default is to model.
_ENUM_COVERAGE_WAIVERS: dict[tuple[str, frozenset[str]], str] = {
# ``alarmHubStatus`` electrical internals (input power, current-meter,
# terminal and e-fuse status). Kept as opaque ``additionalProperties`` — a
# private value space deferred to a separate modelling effort.
(
"alarmHubStatus",
frozenset({"high", "low"}),
): "alarm-hub inputPower status (deferred)",
(
"alarmHubStatus",
frozenset({"alert", "normal"}),
): "alarm-hub criticalAlarm status (deferred)",
(
"alarmHubStatus",
frozenset({"fault", "normal", "warning"}),
): "alarm-hub efuse status (deferred)",
(
"alarmHubStatus",
frozenset({"closed", "open"}),
): "alarm-hub input idleSubState (deferred)",
(
"alarmHubStatus",
frozenset(
{"cut", "disabled", "idle", "not-connected", "short", "tamper", "triggered"}
),
): "alarm-hub plusPinStatus (deferred)",
(
"alarmHubStatus",
frozenset(
{
"cut",
"disabled",
"idle",
"not-connected",
"partially-connected",
"short",
"tamper",
"triggered",
}
),
): "alarm-hub terminalStatus (deferred)",
(
"alarmHubStatus",
frozenset({"high-current", "none", "over-current"}),
): "alarm-hub output efuseAlert (deferred)",
(
"alarmHubStatus",
frozenset({"dry-contact", "powered-12v"}),
): "alarm-hub output mode (deferred)",
(
"alarmHubStatus",
frozenset({"active", "disabled", "off"}),
): "alarm-hub output statusLabel (deferred)",
(
"alarmHubStatus",
frozenset({"+", "-", "com", "nc", "no"}),
): "alarm-hub output wiredPins (deferred)",
(
"alarmHubStatus",
frozenset({"connected", "not-connected", "partially-connected"}),
): "alarm-hub auxiliaryPower status (deferred)",
}
# Library-owned fields populated out-of-band, absent from the spec schema.
# Fields the library owns that no spec revision lists (computed convenience).
_LIBRARY_OWNED_FIELDS: dict[str, set[str]] = {
"camera": {"rtsps_streams"},
}
# Model fields not present in the spec schema; excluded from the drift error.
_EXTRA_MODEL_FIELDS: dict[str, set[str]] = {
"camera": {"device_type", "device_guid"},
"light": {"device_type", "device_guid"},
"sensor": {"device_type", "device_guid", "feature_flags"},
"chime": {"device_type", "device_guid"},
# ``mac`` is exposed on ``GET /v1/nvrs`` only from Protect newer than 7.1;
# older validated specs omit it, so exempt it from the drift error.
"nvr": {"device_type", "device_guid", "mac"},
"viewer": {"device_type", "device_guid"},
"bridge": {"device_type", "device_guid"},
}
_HTTP_METHODS = ("get", "post", "put", "patch", "delete")
def _merge_variants(
variants: list[dict[str, Any]], schemas: dict[str, Any]
) -> dict[str, dict[str, Any]] | None:
"""Union the object variants of an ``allOf`` / ``oneOf`` into one property map."""
merged: dict[str, dict[str, Any]] = {}
is_object = False
for sub in variants:
resolved = _resolve_object_props(sub, schemas)
if resolved is None:
continue
is_object = True
for key, schema in resolved.items():
previous = merged.get(key)
if previous is not None and previous != schema:
# A property several variants disagree on (the per-event-type
# ``metadata`` blocks) is itself a union; re-wrap it so a
# recursive resolve merges those the same way.
merged[key] = {"oneOf": [previous, schema]}
else:
merged[key] = schema
return merged if is_object else None
def _resolve_object_props(
node: dict[str, Any], schemas: dict[str, Any]
) -> dict[str, dict[str, Any]] | None:
"""Return ``{property: schema}`` for an object node, ``None`` for scalar leaves."""
if "$ref" in node:
return _resolve_object_props(schemas[node["$ref"].split("/")[-1]], schemas)
if "allOf" in node:
return _merge_variants(node["allOf"], schemas)
if "oneOf" in node:
return _merge_variants(node["oneOf"], schemas)
if node.get("type") == "array":
return _resolve_object_props(node.get("items", {}), schemas)
if "properties" in node:
return dict(node["properties"])
return None
def _resolve_const(node: dict[str, Any], schemas: dict[str, Any]) -> str | None:
"""
Follow ``$ref`` / ``allOf`` / ``oneOf`` to the string ``const`` a node declares.
A ``oneOf`` resolves only when every variant agrees on the same value — which
is what ``_merge_variants`` produces when several union members declare the
same discriminator with differing siblings. Genuine disagreement (or a
variant carrying no const at all) returns ``None``.
"""
if "$ref" in node:
return _resolve_const(schemas.get(node["$ref"].split("/")[-1], {}), schemas)
if "allOf" in node:
for sub in node["allOf"]:
const = _resolve_const(sub, schemas)
if const is not None:
return const
return None
if "oneOf" in node:
consts = {_resolve_const(sub, schemas) for sub in node["oneOf"]}
return consts.pop() if len(consts) == 1 else None
value = node.get("const")
return value if isinstance(value, str) else None
def _union_variants(
node: dict[str, Any], schemas: dict[str, Any]
) -> list[dict[str, Any]] | None:
"""Flatten a ``$ref``/``oneOf`` chain into its leaf variants, ``None`` if not a union."""
if "$ref" in node:
return _union_variants(schemas.get(node["$ref"].split("/")[-1], {}), schemas)
variants = node.get("oneOf")
if not variants:
return None
leaves: list[dict[str, Any]] = []
for sub in variants:
nested = _union_variants(sub, schemas)
leaves.extend(nested if nested is not None else [sub])
return leaves
def _leaf_model(annotation: Any) -> type[ProtectBaseObject] | None:
"""Unwrap ``Optional`` / ``list`` to the ``ProtectBaseObject`` leaf, if any."""
if isinstance(annotation, type) and issubclass(annotation, ProtectBaseObject):
return annotation
for arg in get_args(annotation):
leaf = _leaf_model(arg)
if leaf is not None:
return leaf
return None
def _spec_field_name(key: str, remaps: dict[str, str]) -> str:
"""Return the model field name for a spec key after remap + snake-case normalization."""
return to_snake_case(remaps.get(key) or key)
def check_endpoints(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Spec endpoints absent from the derived covered set are new-endpoint warnings."""
errors: list[str] = []
warnings: list[str] = []
covered = covered_endpoints()
for path, operations in spec.get("paths", {}).items():
for method in operations:
if method not in _HTTP_METHODS:
continue
if (method.upper(), _normalize_path(path)) not in covered:
warnings.append(
f"new endpoint `{method.upper()} {path}` has no covering client "
f"method (declarative decorator or recorded example call)"
)
return errors, warnings
def _public_api_coroutines() -> set[str]:
"""Names of every public-API coroutine on ``ProtectApiClient``."""
return {
name
for name, member in inspect.getmembers(
ProtectApiClient, inspect.iscoroutinefunction
)
if name.endswith("_public")
or hasattr(member, "__public_endpoint__")
or name in _EXTRA_PUBLIC_COROUTINES
}
def check_completeness() -> list[str]:
"""
Every public-API coroutine must be covered, declaratively or by example call.
The endpoint coverage set is only as complete as the example-call table, so
this guards the table itself: a newly added public method that nobody wired
up surfaces here instead of silently leaving its endpoint uncovered. Spec-
independent — it checks the client against itself, not against the spec.
"""
declarative = set(registry.for_class(ProtectApiClient.__name__).values())
accounted = declarative | set(_EXAMPLE_CALLS)
missing = _public_api_coroutines() - accounted
return [
f"public-API coroutine `{name}` has no declarative decorator and no "
f"recorded example call (add one to `_EXAMPLE_CALLS`)"
for name in sorted(missing)
]
def check_model_fields(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Compare spec schema properties against public model fields, both directions."""
errors: list[str] = []
warnings: list[str] = []
schemas = spec.get("components", {}).get("schemas", {})
for cls, schema_name in _MODEL_SCHEMAS:
if schema_name not in schemas:
errors.append(
f"{schema_name}: tracked schema absent from spec "
f"(server removed/renamed it)"
)
continue
props = _resolve_object_props({"$ref": f"#/.../{schema_name}"}, schemas)
if props is None:
errors.append(f"{schema_name}: spec schema is not object-shaped")
continue
remaps = cls._get_unifi_remaps()
spec_fields = {_spec_field_name(key, remaps) for key in props}
model_fields = set(cls.model_fields)
owned = _LIBRARY_OWNED_FIELDS.get(schema_name, set())
extra = _EXTRA_MODEL_FIELDS.get(schema_name, set())
removed = model_fields - spec_fields - owned - extra
errors.extend(
f"{schema_name}: model field `{name}` absent from spec "
f"(server removed/retyped it)"
for name in sorted(removed)
)
added = spec_fields - model_fields
warnings.extend(
f"{schema_name}: spec field `{name}` has no model counterpart"
for name in sorted(added)
)
return errors, warnings
def check_enums(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Spec enum values absent from the matching library enum are warnings."""
errors: list[str] = []
warnings: list[str] = []
schemas = spec.get("components", {}).get("schemas", {})
for enum_cls, schema_name in _ENUM_SCHEMAS:
schema = schemas.get(schema_name)
if schema is None:
errors.append(
f"{schema_name}: tracked schema absent from spec "
f"(server removed/renamed it)"
)
continue
if "enum" not in schema:
errors.append(
f"{schema_name}: tracked enum schema no longer declares `enum`"
)
continue
lib_values = {member.value for member in enum_cls}
warnings.extend(
f"{schema_name}: spec enum value `{value}` absent from "
f"`{enum_cls.__name__}`"
for value in schema["enum"]
if value not in lib_values
)
return errors, warnings
def _iter_data_modules(data_pkg: Any) -> list[Any]:
"""Import and return every submodule of the ``uiprotect.data`` package."""
return [
importlib.import_module(info.name)
for info in pkgutil.walk_packages(data_pkg.__path__, f"{data_pkg.__name__}.")
]
@functools.cache
def _library_enum_classes() -> list[type[enum.Enum]]:
"""Every ``enum.Enum`` declared under the ``uiprotect.data`` package."""
return [
obj
for module in (uiprotect_data, *_iter_data_modules(uiprotect_data))
for obj in vars(module).values()
if isinstance(obj, type) and issubclass(obj, enum.Enum)
]
def _library_enums_by_name() -> dict[str, frozenset[str]]:
"""Value-set (minus the sentinel) of every ``enum.Enum`` under ``uiprotect.data``."""
out: dict[str, frozenset[str]] = {}
for obj in _library_enum_classes():
value_set = frozenset(str(m.value) for m in obj) - {_ENUM_SENTINEL}
if value_set:
out[obj.__name__] = value_set
return out
def _library_enum_values() -> frozenset[str]:
"""
Every value any library enum defines, sentinel included.
The const rule compares against this rather than the sentinel-stripped
per-enum sets: a spec ``const`` of ``unknown`` is a real wire value, and an
enum declaring an ``unknown`` member does define it.
"""
return frozenset(str(m.value) for obj in _library_enum_classes() for m in obj)
def _enum_owner(path: str) -> str:
"""Component schema an enum occurrence belongs to (the raw path elsewhere)."""
parts = path.split(".")
if len(parts) > 2 and parts[0] == "components":
return parts[2]
return path
def _enum_value_set(node: dict[str, Any]) -> frozenset[str]:
"""Value-set of a node's ``enum`` array, sentinel stripped."""
values = node.get("enum")
if not isinstance(values, list):
return frozenset()
return frozenset(str(value) for value in values) - {_ENUM_SENTINEL}
def _const_value_set(node: dict[str, Any]) -> frozenset[str]:
"""
One-value set for a node's string ``const``.
Unlike an ``enum`` array, ``unknown`` is kept: in a spec ``const`` it is a
real wire value the server pins, not the library's forward-compat sentinel.
Non-string consts are skipped: they enumerate allowed *numbers*
(``sirenDuration``, ``activationDelay``), which the library models as plain
ints rather than as an enum.
"""
value = node.get("const")
return frozenset({value}) if isinstance(value, str) else frozenset()
def _node_value_sets(node: dict[str, Any]) -> tuple[frozenset[str], ...]:
"""Every non-empty value-set a single schema node declares."""
return tuple(
value_set
for value_set in (_enum_value_set(node), _const_value_set(node))
if value_set
)
def _iter_spec_value_sets(
spec: dict[str, Any],
extract: Callable[[dict[str, Any]], frozenset[str]],
) -> list[tuple[frozenset[str], str]]:
"""Every value-set ``extract`` finds in the spec, once per owning schema."""
found: dict[tuple[str, frozenset[str]], str] = {}
def _walk(node: Any, path: str) -> None:
if isinstance(node, dict):
key = extract(node)
if key:
found.setdefault((_enum_owner(path), key), path)
for key_name, child in node.items():
_walk(child, f"{path}.{key_name}")
elif isinstance(node, list):
for index, child in enumerate(node):
_walk(child, f"{path}[{index}]")
_walk(spec.get("components", {}).get("schemas", {}), "components.schemas")
_walk(spec.get("paths", {}), "paths")
return sorted(
((key[1], path) for key, path in found.items()), key=lambda item: item[1]
)
def _iter_spec_enums(spec: dict[str, Any]) -> list[tuple[frozenset[str], str]]:
"""Every enum value-set in the spec (named or inline), once per owning schema."""
return _iter_spec_value_sets(spec, _enum_value_set)
def _iter_spec_consts(spec: dict[str, Any]) -> list[tuple[frozenset[str], str]]:
"""Every string ``const`` in the spec, once per owning schema."""
return _iter_spec_value_sets(spec, _const_value_set)
def _reachable_enum_ids(
roots: list[tuple[Any, str]], schemas: dict[str, Any]
) -> set[tuple[str, frozenset[str]]]:
"""Enum occurrence identities reachable from ``roots``, following ``$ref``."""
out: set[tuple[str, frozenset[str]]] = set()
seen_refs: set[str] = set()
def _walk(node: Any, path: str, owner: str | None) -> None:
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str):
name = ref.split("/")[-1]
if name not in seen_refs:
seen_refs.add(name)
_walk(schemas.get(name, {}), f"components.schemas.{name}", name)
return
for key in _node_value_sets(node):
out.add((owner if owner is not None else path, key))
for key_name, child in node.items():
_walk(child, f"{path}.{key_name}", owner)
elif isinstance(node, list):
for index, child in enumerate(node):
_walk(child, f"{path}[{index}]", owner)
for root, root_path in roots:
_walk(root, root_path, None)
return out
def _reusable_response_error(spec: dict[str, Any]) -> str | None:
"""Reject reusable responses — ``_inbound_enum_ids`` does not resolve them."""
unsupported = "the inbound enum classification does not cover reusable responses"
if spec.get("components", {}).get("responses"):
return f"spec declares `components.responses`; {unsupported}"
for path, operations in spec.get("paths", {}).items():
if not isinstance(operations, dict):
continue
for method, operation in operations.items():
if not isinstance(operation, dict):
continue
for code, response in operation.get("responses", {}).items():
if isinstance(response, dict) and "$ref" in response:
return (
f"response `paths.{path}.{method}.responses.{code}` is a "
f"`$ref`; {unsupported}"
)
return None
def _inbound_enum_ids(spec: dict[str, Any]) -> set[tuple[str, frozenset[str]]]:
"""Enum occurrence identities reachable from any response body schema."""
schemas = spec.get("components", {}).get("schemas", {})
roots: list[tuple[Any, str]] = [
(
media["schema"],
f"paths.{path}.{method}.responses.{code}.content.{media_type}.schema",
)
for path, operations in spec.get("paths", {}).items()
if isinstance(operations, dict)
for method, operation in operations.items()
if isinstance(operation, dict)
for code, response in operation.get("responses", {}).items()
if isinstance(response, dict)
for media_type, media in response.get("content", {}).items()
if isinstance(media, dict) and "schema" in media
]
return _reachable_enum_ids(roots, schemas)
def check_enum_coverage(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
"""
Every inbound spec enum and ``const`` must be modelled or explicitly waived.
Coverage is decided on the value-set alone — an exact match proves some
library enum carries those values, not that the owning model field is
annotated with it.
The ``const`` half is weaker still, and is a **lint for new vocabulary, not
a conformance check**. A const declares a single value, so no library enum
can ever *equal* its value-set; it is treated as covered when any library
enum anywhere defines the value. A const that changes from one value the
library already knows to another is therefore invisible here — only a value
no enum defines at all is reported. ``check_event_types`` closes that gap
for the ``event`` union's ``type`` const and nothing else: the remaining
const occurrences (``*ModelKey``, ``WSAction`` ``type``, the ``lightMode``
and ``enableAt`` discriminators) have no equivalent per-axis pin.
"""
errors: list[str] = []
reusable = _reusable_response_error(spec)
if reusable is not None:
return [reusable], []
lib_by_name = _library_enums_by_name()
exact_sets = set(lib_by_name.values())
const_values = _library_enum_values()
inbound = _inbound_enum_ids(spec)
def _reportable(value_set: frozenset[str], path: str) -> bool:
"""Inbound occurrence carrying no waiver; outbound-only is waived by direction."""
identity = (_enum_owner(path), value_set)
return identity not in _ENUM_COVERAGE_WAIVERS and identity in inbound
for value_set, path in _iter_spec_enums(spec):
if value_set in exact_sets:
continue
mapped = _MODELLED_AS_SUBSET.get(value_set)
if mapped is not None:
missing = value_set - lib_by_name.get(mapped, frozenset())
if not missing:
continue
errors.append(
f"spec enum at `{path}` is mapped to `{mapped}` in "
f"`_MODELLED_AS_SUBSET` but `{mapped}` no longer defines value(s) "
f"{sorted(missing)} (enum renamed or members removed)"
)
continue
if _reportable(value_set, path):
errors.append(
f"inbound spec enum at `{path}` (values {sorted(value_set)}) is not "
f"modelled by any library enum, not mapped in `_MODELLED_AS_SUBSET`, "
f"and not waived in `_ENUM_COVERAGE_WAIVERS`"
)
errors.extend(
f"inbound spec const at `{path}` (value {min(value_set)!r}) is not "
f"defined by any library enum and not waived in `_ENUM_COVERAGE_WAIVERS`"
for value_set, path in _iter_spec_consts(spec)
if not value_set <= const_values and _reportable(value_set, path)
)
return errors, []
def check_event_types(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
"""
``PUBLIC_EVENT_TYPES`` must equal the ``type`` consts of the ``event`` union.
``EventType`` has no ``unknown`` member, so an event type the spec declares
and the library does not raises on parse and the event is dropped; both
directions are therefore errors.
Nested unions are flattened first: the server is free to group a family of
event schemas behind its own ``oneOf`` without that being drift.
"""
errors: list[str] = []
schemas = spec.get("components", {}).get("schemas", {})
schema = schemas.get("event")
if schema is None:
return ["event: tracked schema absent from spec (server removed it)"], []
variants = _union_variants(schema, schemas)
if not variants:
return ["event: spec schema no longer declares a `oneOf` union"], []
spec_types: set[str] = set()
for index, variant in enumerate(variants):
props = _resolve_object_props(variant, schemas) or {}
const = _resolve_const(props.get("type", {}), schemas)
if const is not None:
spec_types.add(const)
else:
errors.append(
f"event: `oneOf[{index}]` "
f"({variant.get('$ref', '')}) declares no `type` const"
)
modelled = {event_type.value for event_type in PUBLIC_EVENT_TYPES}
errors.extend(
f"event: spec event type `{value}` is absent from `PUBLIC_EVENT_TYPES`"
for value in sorted(spec_types - modelled)
)
errors.extend(
f"event: `PUBLIC_EVENT_TYPES` models `{value}`, absent from the spec "
f"`event` union (server removed it)"
for value in sorted(modelled - spec_types)
)
return errors, []
_CHECKS = (
check_endpoints,
check_model_fields,
check_enums,
check_enum_coverage,
check_event_types,
)
def run_checks(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Run every check and return the merged ``(errors, warnings)``."""
errors: list[str] = []
warnings: list[str] = []
for check in _CHECKS:
e, w = check(spec)
errors.extend(e)
warnings.extend(w)
return errors, warnings
def format_summary(
errors: list[str], warnings: list[str], version: str | None = None
) -> str:
"""Render a markdown summary of the validation result."""
header = "## Public-API spec conformance"
if version:
header += f" — Protect {version}"
lines = [header, ""]
if not errors and not warnings:
lines.append("No drift: client surface matches the spec.")
return "\n".join(lines)
if errors:
lines.append(f"### Errors ({len(errors)})")
lines.extend(f"- {item}" for item in errors)
lines.append("")
if warnings:
lines.append(f"### Warnings ({len(warnings)})")
lines.extend(f"- {item}" for item in warnings)
return "\n".join(lines).rstrip()
def main() -> int:
"""Load the on-disk spec, run all checks, print a summary, exit non-zero on error."""
if not SPEC_PATH.exists():
print(
f"spec not found at {SPEC_PATH}; run `python scripts/fetch_openapi.py` first",
file=sys.stderr,
)
return 2
spec = orjson.loads(SPEC_PATH.read_bytes())
version = spec.get("info", {}).get("version")
errors, warnings = run_checks(spec)
errors.extend(check_completeness())
print(format_summary(errors, warnings, version))
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main())
uilibs-uiprotect-317a52c/setup.py 0000664 0000000 0000000 00000000357 15246377057 0017103 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# This is a shim to allow GitHub to detect the package, build is done with poetry
# Taken from https://github.com/Textualize/rich
import setuptools
if __name__ == "__main__":
setuptools.setup(name="uiprotect")
uilibs-uiprotect-317a52c/src/ 0000775 0000000 0000000 00000000000 15246377057 0016153 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/ 0000775 0000000 0000000 00000000000 15246377057 0020171 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/__init__.py 0000664 0000000 0000000 00000002445 15246377057 0022307 0 ustar 00root root 0000000 0000000 """Unofficial UniFi Protect Python API and Command Line Interface."""
from __future__ import annotations
from .api import ProtectApiClient, calculate_retry_delay, parse_retry_after
from .devices import DeviceChange, ProtectDeviceChange
from .events import (
EventChange,
EventIdentity,
ProtectEvent,
ProtectEventChannel,
UlpUserIdentity,
UnknownIdentity,
)
from .exceptions import (
ArmedModeError,
BadRequest,
GlobalAlarmManagerError,
Invalid,
NotAuthorized,
NvrError,
PublicOnlyModeError,
)
from .utils import (
get_nested_attr,
get_nested_attr_as_bool,
get_top_level_attr_as_bool,
make_enabled_getter,
make_required_getter,
make_value_getter,
)
__all__ = [
"ArmedModeError",
"BadRequest",
"DeviceChange",
"EventChange",
"EventIdentity",
"GlobalAlarmManagerError",
"Invalid",
"NotAuthorized",
"NvrError",
"ProtectApiClient",
"ProtectDeviceChange",
"ProtectEvent",
"ProtectEventChannel",
"PublicOnlyModeError",
"UlpUserIdentity",
"UnknownIdentity",
"calculate_retry_delay",
"get_nested_attr",
"get_nested_attr_as_bool",
"get_top_level_attr_as_bool",
"make_enabled_getter",
"make_required_getter",
"make_value_getter",
"parse_retry_after",
]
uilibs-uiprotect-317a52c/src/uiprotect/__main__.py 0000664 0000000 0000000 00000000710 15246377057 0022261 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from pathlib import Path
try:
from dotenv import load_dotenv
except ImportError:
load_dotenv = None # type: ignore[assignment]
from .cli import app
def start() -> None:
if load_dotenv is not None:
env_file = Path.cwd() / ".env"
if env_file.exists():
load_dotenv(dotenv_path=env_file)
else:
load_dotenv()
app()
if __name__ == "__main__":
start()
uilibs-uiprotect-317a52c/src/uiprotect/_compat.py 0000664 0000000 0000000 00000000456 15246377057 0022172 0 ustar 00root root 0000000 0000000 """Compat for external lib versions."""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from functools import cached_property
else:
try:
from propcache.api import cached_property
except ImportError:
from propcache import cached_property
__all__ = ("cached_property",)
uilibs-uiprotect-317a52c/src/uiprotect/_public_api.py 0000664 0000000 0000000 00000014560 15246377057 0023017 0 ustar 00root root 0000000 0000000 """
Declarative endpoint decorators for the uniform public-API surface.
These decorators turn the *genuinely uniform* slice of
:class:`uiprotect.api.ProtectApiClient`'s Public Integration API methods into
one-line declarations. The hand-written ``async def`` signature and docstring
are kept (mypy-strict and mkdocstrings rely on them); the decorator supplies the
body — path binding, payload assembly, dispatch through the existing
``api_request_*`` helpers, and model construction.
The module imports nothing from :mod:`uiprotect.data`: model classes arrive as
decorator arguments (``item=``/``items=``), so it stays import-clean and
circular-import-safe. It depends only on the stdlib plus the public exception
type.
"""
from __future__ import annotations
import functools
import inspect
import re
from collections.abc import Awaitable, Callable
from typing import Any, Concatenate, ParamSpec, TypeVar, cast
from .exceptions import BadRequest
_P = ParamSpec("_P")
_R = TypeVar("_R")
_Method = Callable[Concatenate[Any, _P], Awaitable[_R]]
_PLACEHOLDER_RE = re.compile(r"{(\w+)}")
_EMPTY_BODY_MESSAGE = "At least one parameter must be provided"
def _to_camel(name: str) -> str:
"""Convert a snake_case parameter name to camelCase wire key."""
head, *rest = name.split("_")
return head + "".join(word.capitalize() for word in rest)
class _PublicEndpointRegistry:
"""Write-once per-class table mapping ``(verb, path)`` to a method name."""
def __init__(self) -> None:
self._by_class: dict[str, dict[tuple[str, str], str]] = {}
def register(self, cls_name: str, verb: str, path: str, method_name: str) -> None:
table = self._by_class.setdefault(cls_name, {})
key = (verb, path)
if key in table:
raise RuntimeError(
f"Duplicate public endpoint {verb.upper()} {path} on "
f"{cls_name}: already registered as {table[key]!r}, "
f"cannot also register {method_name!r}"
)
table[key] = method_name
def for_class(self, cls_name: str) -> dict[tuple[str, str], str]:
return dict(self._by_class.get(cls_name, {}))
def all_endpoints(self) -> dict[str, dict[tuple[str, str], str]]:
return {cls: dict(table) for cls, table in self._by_class.items()}
registry = _PublicEndpointRegistry()
def _build_body(
sig: inspect.Signature,
arguments: dict[str, Any],
placeholders: frozenset[str],
) -> dict[str, Any]:
"""Assemble a flat camelCase JSON body from non-``None`` keyword params."""
body: dict[str, Any] = {}
for name, value in arguments.items():
if name == "self" or name in placeholders:
continue
if value is None:
continue
body[_to_camel(name)] = value
return body
def _validate_signature(
func: _Method[_P, _R],
sig: inspect.Signature,
placeholders: frozenset[str],
has_body: bool,
) -> None:
"""Fail loudly at decoration if the declaration and signature disagree."""
params = sig.parameters
missing = sorted(name for name in placeholders if name not in params)
if missing:
raise TypeError(
f"{func.__qualname__}: path placeholder(s) {missing} have no "
f"matching parameter"
)
if not has_body:
extras = sorted(
name for name in params if name != "self" and name not in placeholders
)
if extras:
raise TypeError(
f"{func.__qualname__}: body-less endpoint has non-placeholder "
f"parameter(s) {extras}"
)
def _endpoint(
verb: str,
path: str,
*,
item: type[Any] | None,
items: type[Any] | None,
has_body: bool,
) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]:
placeholders = frozenset(_PLACEHOLDER_RE.findall(path))
def decorator(func: _Method[_P, _R]) -> _Method[_P, _R]:
sig = inspect.signature(func)
_validate_signature(func, sig, placeholders, has_body)
cls_name = func.__qualname__.rsplit(".", 1)[0]
registry.register(cls_name, verb, path, func.__name__)
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
arguments = bound.arguments
self = arguments["self"]
url = path.format(**{name: arguments[name] for name in placeholders})
if items is not None:
data = await self.api_request_list(url=url, public_api=True)
return [items.from_unifi_dict(**entry, api=self) for entry in data]
if has_body:
body = _build_body(sig, arguments, placeholders)
if not body:
raise BadRequest(_EMPTY_BODY_MESSAGE)
result = await self.api_request_obj(
url=url, method=verb, json=body, public_api=True
)
return item.from_unifi_dict(**result, api=self) # type: ignore[union-attr]
if item is not None:
data = await self.api_request_obj(url=url, public_api=True)
return item.from_unifi_dict(**data, api=self)
await self.api_request_raw(url=url, method=verb, public_api=True)
return None
wrapper.__public_endpoint__ = (verb, path) # type: ignore[attr-defined]
return cast("_Method[_P, _R]", wrapper)
return decorator
def public_get(
path: str,
*,
item: type[Any] | None = None,
items: type[Any] | None = None,
) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]:
"""Declare a GET endpoint: ``item=`` for one object, ``items=`` for a list."""
if (item is None) == (items is None):
raise TypeError(f"public_get('{path}') requires exactly one of item= or items=")
return _endpoint("get", path, item=item, items=items, has_body=False)
def public_patch(
path: str, *, item: type[Any]
) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]:
"""Declare a flat-body PATCH endpoint; empty body raises ``BadRequest``."""
return _endpoint("patch", path, item=item, items=None, has_body=True)
def public_post(
path: str, *, item: type[Any] | None = None
) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]:
"""Declare a POST endpoint: path-only and fire-and-forget, or ``item=`` with a body."""
return _endpoint("post", path, item=item, items=None, has_body=item is not None)
uilibs-uiprotect-317a52c/src/uiprotect/_rate_limit.py 0000664 0000000 0000000 00000005240 15246377057 0023034 0 ustar 00root root 0000000 0000000 """Lightweight event-loop-safe pacer for the Public Integration API."""
from __future__ import annotations
import math
from asyncio import Lock, get_running_loop, sleep
# Requests/second used until a ``RateLimit-Policy`` header seeds the real rate.
# The observed budget is 10/1s; default conservatively to leave WS headroom.
DEFAULT_RATE: float = 8.0
# Fraction of the server-advertised budget we actually consume, leaving room
# for the shared-budget public WebSocket.
SAFETY_MARGIN: float = 0.8
class PublicApiRateLimiter:
"""Fixed-interval async gate for one client's public path."""
def __init__(self, rate: float = DEFAULT_RATE) -> None:
self._lock = Lock()
self._interval = 1.0 / rate
self._next_allowed = 0.0
self._seeded = False
async def acquire(self) -> None:
"""Block until the next request is permitted under the current rate."""
async with self._lock:
now = get_running_loop().time()
if now < self._next_allowed:
await sleep(self._next_allowed - now)
self._next_allowed += self._interval
else:
self._next_allowed = now + self._interval
def seed_from_policy(self, policy: str | None) -> None:
"""Seed the rate once from a draft-8 ``RateLimit-Policy`` header value."""
if self._seeded:
return
rate = _parse_policy_rate(policy)
if rate is None:
return
new_interval = 1.0 / (rate * SAFETY_MARGIN)
# Seeding a slower rate must not let one request slip out at the old,
# faster spacing — push the next slot out by the widening delta.
if self._next_allowed and new_interval > self._interval:
self._next_allowed += new_interval - self._interval
self._interval = new_interval
self._seeded = True
def _parse_policy_rate(policy: str | None) -> float | None:
"""Return requests/second from ``q`` / ``w`` of a RateLimit-Policy header."""
if not policy:
return None
quota: float | None = None
window: float | None = None
# Only the first policy member matters; its params are ``;``-separated.
for part in policy.split(",", 1)[0].split(";"):
key, _, value = part.strip().partition("=")
if key == "q":
quota = _to_float(value)
elif key == "w":
window = _to_float(value)
if quota is None or window is None or quota <= 0 or window <= 0:
return None
return quota / window
def _to_float(value: str) -> float | None:
try:
result = float(value)
except ValueError:
return None
return result if math.isfinite(result) else None
uilibs-uiprotect-317a52c/src/uiprotect/api.py 0000664 0000000 0000000 00000634227 15246377057 0021332 0 ustar 00root root 0000000 0000000 """UniFi Protect Server Wrapper."""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import logging
import random
import re
import sys
import time
import warnings
from datetime import UTC, datetime, timedelta
from functools import partial
from http import HTTPStatus, cookies
from http.cookies import Morsel, SimpleCookie
from ipaddress import IPv4Address, IPv6Address, ip_address
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NotRequired, Self, TypedDict, cast
from urllib.parse import SplitResult, quote
import aiofiles
import aiohttp
import orjson
from aiofiles import os as aos
from aiohttp import ClientResponse, CookieJar, client_exceptions
from aiozoneinfo import async_get_time_zone
from platformdirs import user_cache_dir, user_config_dir
from yarl import URL
from uiprotect.data.convert import list_from_unifi_list
from uiprotect.data.nvr import MetaInfo
from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers
from ._compat import cached_property
from ._public_api import public_get, public_patch, public_post
from ._rate_limit import PublicApiRateLimiter
from .data import (
NVR,
ArmProfile,
Bootstrap,
Bridge,
Camera,
ChannelQuality,
DeviceState,
Event,
EventCategories,
EventType,
Fob,
Light,
LinkStation,
Liveview,
ModelType,
NvrArmMode,
NvrArmModeStatus,
OsdOverlayLocation,
ProtectAdoptableDeviceModel,
ProtectModel,
PublicArmScheduleDict,
PublicBootstrap,
PublicBridge,
PublicCamera,
PublicChime,
PublicDeviceModel,
PublicEvent,
PublicFile,
PublicHdrMode,
PublicLight,
PublicLiveview,
PublicLiveviewSlotDict,
PublicNVR,
PublicPosLineItemDict,
PublicPosLocationDict,
PublicPosTransactionResponse,
PublicSensor,
PublicSensorAlarmSettings,
PublicSensorGlassBreakSettingsWrite,
PublicSensorHumiditySettings,
PublicSensorLightSettings,
PublicSensorMotionSettings,
PublicSensorTemperatureSettings,
PublicUlpUser,
PublicUser,
PublicViewer,
Relay,
RTSPSStreams,
Sensor,
Siren,
SmartDetectAudioType,
SmartDetectObjectType,
SmartDetectTrack,
Speaker,
Version,
VideoMode,
Viewer,
WSAction,
WSPacket,
WSSubscriptionMessage,
create_from_unifi_dict,
)
from .data.devices import AiPort, Chime
from .data.types import (
AssetFileType,
IteratorCallback,
PosTransactionType,
ProgressCallback,
PTZPatrol,
PTZPreset,
SensorScheduleMode,
SirenDuration,
)
from .exceptions import (
ArmedModeError,
BadRequest,
ClientError,
GlobalAlarmManagerError,
NotAuthorized,
NvrError,
PublicOnlyModeError,
)
from .stream import TalkbackSession
if TYPE_CHECKING:
from .devices.dispatcher import DeviceDispatcher
from .events.dispatcher import EventDispatcher
from .utils import (
decode_token_cookie,
format_host_for_url,
get_response_reason,
ip_from_host,
normalize_mac,
pybool_to_json_bool,
set_debug,
to_js_time,
utc_now,
)
from .websocket import Websocket, WebsocketState
if TYPE_CHECKING:
from collections.abc import Callable
from uiprotect.data.devices import LightDeviceSettings, LightModeSettings
from uiprotect.data.public_devices import (
PublicLightDeviceSettings,
PublicLightModeSettings,
)
from .data.base import ProtectModelWithId
from .data.public_bootstrap import DeviceWSResult, FetchDiff
from .devices import ProtectDeviceChange
from .events import EventChange, ProtectEvent
if "partitioned" not in cookies.Morsel._reserved: # type: ignore[attr-defined]
# See: https://github.com/python/cpython/issues/112713
cookies.Morsel._reserved["partitioned"] = "partitioned" # type: ignore[attr-defined]
cookies.Morsel._flags.add("partitioned") # type: ignore[attr-defined]
async def _async_warm_nvr_timezone(nvr_data: dict[str, Any]) -> None:
"""
Warm zoneinfo's cache for ``nvr_data["timezone"]`` off the event loop.
``NVR.unifi_dict_conversions`` constructs ``ZoneInfo(name)`` synchronously
during parse; first construction does an ``os.stat`` lookup that blocks
the loop. Awaiting :func:`async_get_time_zone` here primes ZoneInfo's
internal cache so the later sync construction is a free hit.
"""
tz_name = nvr_data.get("timezone")
if isinstance(tz_name, str):
await async_get_time_zone(tz_name)
class LightPatchRequest(TypedDict, total=False):
"""Type for PATCH /v1/lights/{id} request body."""
name: str
isLightForceEnabled: bool
lightModeSettings: dict[str, Any]
lightDeviceSettings: dict[str, Any]
class PublicApiChimeRingSettingRequest(TypedDict):
"""Type for ringSettings items in PATCH /v1/chimes/{id} request body (Public API)."""
cameraId: str
repeatTimes: int
ringtoneId: NotRequired[str | None]
volume: int
class PublicApiChimePatchRequest(TypedDict, total=False):
"""Type for PATCH /v1/chimes/{id} request body (Public API)."""
name: str
cameraIds: list[str]
ringSettings: list[PublicApiChimeRingSettingRequest]
class CameraPublicApiLcdMessageRequest(TypedDict, total=False):
"""
Type for lcdMessage in PATCH /v1/cameras/{id} request body (Public API).
Per the integration spec, ``type`` is required for every message variant;
``text`` is required for CUSTOM_MESSAGE and IMAGE; ``resetAt`` is optional
for all variants (UNIX timestamp in ms; omit to use the NVR default, pass
``None`` for "forever"). A ``resetAt`` already in the past clears the
message.
"""
type: str
text: str
resetAt: int | None
TOKEN_COOKIE_MAX_EXP_SECONDS = 60
# how many seconds before the bootstrap is refreshed from Protect
DEVICE_UPDATE_INTERVAL = 900
# retry timeout for thumbnails/heatmaps
RETRY_TIMEOUT = 10
# Retry configuration constants
RETRY_DEFAULT_ATTEMPTS = 3
RETRY_BASE_DELAY = 1.0
RETRY_MAX_DELAY = 30.0
RETRY_EXPONENTIAL_BASE = 2.0
RETRY_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
TYPES_BUG_MESSAGE = """There is currently a bug in UniFi Protect that makes `start` / `end` not work if `types` is not provided. This means uiprotect has to iterate over all of the events matching the filters provided to return values.
If your Protect instance has a lot of events, this request will take much longer then expected. It is recommended adding additional filters to speed the request up."""
_LOGGER = logging.getLogger(__name__)
_COOKIE_RE = re.compile(r"^set-cookie: ", re.IGNORECASE)
# Sentinel used by ``update_viewer_public`` to distinguish "do not change" (the
# default) from "explicitly set to null". The viewer's ``liveview`` wire field
# is legitimately nullable, so a plain ``None`` cannot serve both meanings.
# A dedicated singleton type keeps the sentinel out of ``Any`` so callers
# can't accidentally smuggle it through the typed surface.
class _UnsetType:
# ``Self`` rather than ``_UnsetType`` so the singleton stays correctly
# typed under any (currently hypothetical) subclass — also satisfies PYI034.
_instance: Self | None = None
def __new__(cls) -> Self:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __repr__(self) -> str:
return ""
_UNSET: _UnsetType = _UnsetType()
# Substring present in the 400 error reason returned by the NVR when the
# alarm-manager endpoint is *not local* (i.e. set to Global instead of Local).
# Matched case-insensitively so minor server-side capitalisation changes are
# tolerated. Extracted as a constant to make the match visible and easy to update.
_GLOBAL_ALARM_MANAGER_REASON = "global alarm manager"
_ARM_ALARM_ARMED_REASON = "arm alarm is armed"
def _log_or_raise(
label: str, exc: BaseException, *, tolerate_not_authorized: bool = False
) -> None:
"""
Log ``BadRequest``/``NvrError`` (endpoint unavailable); re-raise anything
else. ``NotAuthorized`` (a ``BadRequest`` subclass) is re-raised unless
``tolerate_not_authorized`` is set, in which case it is logged.
"""
if isinstance(exc, NotAuthorized):
if tolerate_not_authorized:
_LOGGER.debug(
"%s endpoint not authorized (feature disabled?): %s", label, exc
)
return
raise exc
if isinstance(exc, (BadRequest, NvrError)):
_LOGGER.debug("%s endpoint unavailable: %s", label, exc)
else:
raise exc
NFC_FINGERPRINT_SUPPORT_VERSION = Version("5.1.57")
# Minimum interval (seconds) between two automatic public-bootstrap resyncs
# triggered by devices-websocket reconnects. Guards against reconnect storms
# on flaky networks or controller reboots.
PUBLIC_RESYNC_MIN_INTERVAL = 10.0
# WebSocket heartbeat (seconds) for the public integration WS connections. The
# UniFi OS nginx reverse proxy closes an idle tunnel after ``proxy_read_timeout
# 10m``; the quiet ``subscribe/devices`` channel only pushes on a ~10-minute
# cadence, so without keepalive the idle timer drops it every cycle. A ping
# elicits a server pong — an upstream read that resets the proxy timer — and
# also gives aiohttp liveness detection. 180s keeps ~3 touches per 10-min
# window so a single lost frame does not trip the timeout.
PUBLIC_WS_HEARTBEAT = 180.0
# Same keepalive for the private WS. Traffic volume is not liveness: when the
# path drops without a close frame — a switch/controller reboot mid-stream —
# ``receive()`` blocks forever on the half-open socket, so the state stays
# CONNECTED and the reconnect loop never runs. A ping is the only thing that
# fails on a dead peer.
PRIVATE_WS_HEARTBEAT = 180.0
# Max concurrent RTSPS-stream fetches issued while priming cameras in
# ``update_public``. Bounds the GET fan-out so a large install does not fire
# dozens of requests at once.
RTSPS_PRIME_CONCURRENCY = 8
# Per-camera timeout for a single RTSPS-stream prime fetch. The public API
# session carries no ``ClientTimeout``, so without this a camera whose GET hangs
# (console under load right after an upgrade reboot) would hold its concurrency
# slot for aiohttp's 300s default and stall ``update_public`` for minutes.
RTSPS_PRIME_TIMEOUT = 10.0
# Number of extra attempts per camera after the first prime fetch fails. One
# short retry heals a single transient failure during a boot storm without
# leaving the camera streamless for the whole session.
RTSPS_PRIME_RETRIES = 1
def calculate_retry_delay(attempt: int, retry_after: float | None = None) -> float:
"""
Calculate delay before next retry attempt with exponential backoff and jitter.
Args:
attempt: Current retry attempt number (0-based).
retry_after: Optional Retry-After header value in seconds.
Returns:
Delay in seconds before next retry.
"""
if retry_after is not None and retry_after > 0:
delay = min(retry_after, RETRY_MAX_DELAY)
# Only add positive jitter when server specified a delay
jitter_range = delay * 0.25
delay += random.uniform(0, jitter_range) # noqa: S311
else:
delay = RETRY_BASE_DELAY * (RETRY_EXPONENTIAL_BASE**attempt)
delay = min(delay, RETRY_MAX_DELAY)
# Full jitter (±25% of delay) for calculated delays
jitter_range = delay * 0.25
delay += random.uniform(-jitter_range, jitter_range) # noqa: S311
# Ensure delay stays within bounds [0.1, RETRY_MAX_DELAY]
return max(0.1, min(delay, RETRY_MAX_DELAY))
def parse_retry_after(response: ClientResponse) -> float | None:
"""
Parse Retry-After header from response.
Args:
response: HTTP response object.
Returns:
Retry delay in seconds, or None if header not present/parseable.
"""
retry_after = response.headers.get("Retry-After")
if retry_after is None:
return None
try:
# Retry-After can be seconds or HTTP-date, we only handle seconds
return float(retry_after)
except ValueError:
_LOGGER.debug("Could not parse Retry-After header: %s", retry_after)
return None
def get_user_hash(host: str, username: str) -> str:
session = hashlib.sha256()
session.update(host.encode("utf8"))
session.update(username.encode("utf8"))
return session.hexdigest()
class BaseApiClient:
_host: str
_port: int
_username: str | None
_password: str | None
_api_key: str | None = None
# True when constructed with only an API key (no private credentials).
# Private-session entry points fail fast in this mode.
_public_only: bool = False
_verify_ssl: bool
_ws_timeout: int
_is_authenticated: bool = False
_last_token_cookie: Morsel[str] | None = None
_last_token_cookie_decode: dict[str, Any] | None = None
_session: aiohttp.ClientSession | None = None
_public_api_session: aiohttp.ClientSession | None = None
_loaded_session: bool = False
_cookiename = "TOKEN"
_max_retries: int = RETRY_DEFAULT_ATTEMPTS
headers: dict[str, str] | None = None
_private_websocket: Websocket | None = None
_events_websocket: Websocket | None = None
_devices_websocket: Websocket | None = None
_public_resync_task: asyncio.Task[None] | None = None
private_api_path: str = "/proxy/protect/api/"
public_api_path: str = "/proxy/protect/integration"
private_ws_path: str = "/proxy/protect/ws/updates"
events_ws_path: str = "/proxy/protect/integration/v1/subscribe/events"
devices_ws_path: str = "/proxy/protect/integration/v1/subscribe/devices"
cache_dir: Path
config_dir: Path
store_sessions: bool
def __init__(
self,
host: str,
port: int,
username: str | None = None,
password: str | None = None,
api_key: str | None = None,
verify_ssl: bool = True,
session: aiohttp.ClientSession | None = None,
public_api_session: aiohttp.ClientSession | None = None,
ws_timeout: int = 30,
cache_dir: Path | None = None,
config_dir: Path | None = None,
store_sessions: bool = True,
ws_receive_timeout: int | None = None,
max_retries: int = RETRY_DEFAULT_ATTEMPTS,
) -> None:
# Public-only when no private credentials are supplied but an API key
# is. The private session is never opened in this mode.
public_only = not username and not password and bool(api_key)
# Reject incomplete private credentials (one of username/password
# missing) so the failure is at construction, not a malformed
# /api/auth/login with a null field at first request.
if not (username and password) and not public_only:
raise BadRequest(
"Provide both username and password, or an api_key, "
"to construct a client"
)
self._auth_lock = asyncio.Lock()
# Serializes ``update_public()``: an overlapping prime could apply an
# older snapshot over a newer one (and over live WS merges in between).
self._public_update_lock = asyncio.Lock()
self._host = host
self._port = port
self._username = username
self._password = password
self._api_key = api_key
self._public_only = public_only
self._verify_ssl = verify_ssl
self._ws_timeout = ws_timeout
self._ws_receive_timeout = ws_receive_timeout
self._loaded_session = False
self._update_task: asyncio.Task[Bootstrap | None] | None = None
# Per-camera background RTSPS-stream refresh tasks, keyed by camera id.
# Per-instance (never a class-level mutable default — one process can
# drive several consoles). Cancelled in :meth:`close_session`.
self._rtsps_refresh_tasks: dict[str, asyncio.Task[None]] = {}
# Per-siren timers announcing the end of a timed run, keyed by siren id.
# Per-instance for the same reason as above; cancelled in
# :meth:`close_session` and on a devices-websocket disconnect.
self._siren_off_tasks: dict[str, asyncio.Task[None]] = {}
# Proactive per-API-key pacer for the public path. Per-instance so
# several consoles in one process never share a budget.
self._public_rate_limiter = PublicApiRateLimiter()
self._max_retries = max_retries
self.config_dir = config_dir or (Path(user_config_dir()) / "ufp")
self.cache_dir = cache_dir or (Path(user_cache_dir()) / "ufp_cache")
self.store_sessions = store_sessions
if session is not None:
self._session = session
if public_api_session is not None:
self._public_api_session = public_api_session
self._update_url()
def _update_cookiename(self, cookie: SimpleCookie) -> None:
if "UOS_TOKEN" in cookie:
self._cookiename = "UOS_TOKEN"
def _update_url(self) -> None:
"""Updates the url after changing _host or _port."""
formatted_host = format_host_for_url(self._host)
if self._port != 443:
self._url = URL(f"https://{formatted_host}:{self._port}")
self._ws_url = URL(
f"wss://{formatted_host}:{self._port}{self.private_ws_path}"
)
self._events_ws_url = URL(
f"https://{formatted_host}:{self._port}{self.events_ws_path}"
)
self._devices_ws_url = URL(
f"https://{formatted_host}:{self._port}{self.devices_ws_path}"
)
else:
self._url = URL(f"https://{formatted_host}")
self._ws_url = URL(f"wss://{formatted_host}{self.private_ws_path}")
self._events_ws_url = URL(f"https://{formatted_host}{self.events_ws_path}")
self._devices_ws_url = URL(
f"https://{formatted_host}{self.devices_ws_path}"
)
self.base_url = str(self._url)
@property
def _ws_url_object(self) -> URL:
"""Get Websocket URL."""
if last_update_id := self._get_last_update_id():
return self._ws_url.with_query(lastUpdateId=last_update_id)
return self._ws_url
@property
def ws_url(self) -> str:
"""Get Websocket URL."""
return str(self._ws_url_object)
@property
def events_ws_url(self) -> str:
"""Get Events Websocket URL."""
return str(self._events_ws_url)
@property
def devices_ws_url(self) -> str:
"""Get Devices Websocket URL."""
return str(self._devices_ws_url)
@property
def is_public_only(self) -> bool:
"""Whether this client was built with only an API key (no private login)."""
return self._public_only
@property
def config_file(self) -> Path:
return self.config_dir / "unifi_protect.json"
async def get_session(self) -> aiohttp.ClientSession:
"""Gets or creates current client session"""
if self._session is None or self._session.closed:
if self._session is not None and self._session.closed:
_LOGGER.debug("Session was closed, creating a new one")
# need unsafe to access httponly cookies
self._session = aiohttp.ClientSession(cookie_jar=CookieJar(unsafe=True))
return self._session
async def get_public_api_session(self) -> aiohttp.ClientSession:
"""Gets or creates current public API client session"""
if self._public_api_session is None or self._public_api_session.closed:
if self._public_api_session is not None and self._public_api_session.closed:
_LOGGER.debug("Public API session was closed, creating a new one")
self._public_api_session = aiohttp.ClientSession()
return self._public_api_session
async def _auth_websocket(self, force: bool) -> dict[str, str] | None:
"""Authenticate for Websocket."""
if force:
await self._reauthenticate()
else:
await self.ensure_authenticated()
return self.headers
async def _auth_public_api_websocket(
self, force: bool = False
) -> dict[str, str] | None:
"""Authenticate for Public API Websocket."""
if self._api_key is None:
raise NotAuthorized("API key is required for public API WebSocket")
return {"X-API-KEY": self._api_key}
def _get_websocket(self) -> Websocket:
"""Gets or creates current Websocket."""
if self._private_websocket is None:
self._private_websocket = Websocket(
self._get_websocket_url,
self._auth_websocket,
self._update_bootstrap_soon,
self.get_session,
self._process_ws_message,
self._on_websocket_state_change,
verify=self._verify_ssl,
timeout=self._ws_timeout,
receive_timeout=self._ws_receive_timeout,
heartbeat=PRIVATE_WS_HEARTBEAT,
)
return self._private_websocket
def _get_events_websocket(self) -> Websocket:
"""Gets or creates current Events Websocket."""
if self._events_websocket is None:
self._events_websocket = Websocket(
lambda: self._events_ws_url,
self._auth_public_api_websocket,
lambda: None,
self.get_public_api_session,
self._process_events_ws_message,
self._on_events_websocket_state_change,
verify=self._verify_ssl,
timeout=self._ws_timeout,
receive_timeout=self._ws_receive_timeout,
heartbeat=PUBLIC_WS_HEARTBEAT,
)
return self._events_websocket
def _get_devices_websocket(self) -> Websocket:
"""Gets or creates current Devices Websocket."""
if self._devices_websocket is None:
self._devices_websocket = Websocket(
lambda: self._devices_ws_url,
self._auth_public_api_websocket,
lambda: None,
self.get_public_api_session,
self._process_devices_ws_message,
self._on_devices_websocket_state_change,
verify=self._verify_ssl,
timeout=self._ws_timeout,
receive_timeout=self._ws_receive_timeout,
heartbeat=PUBLIC_WS_HEARTBEAT,
)
return self._devices_websocket
def _update_bootstrap_soon(self) -> None:
"""Update bootstrap soon."""
_LOGGER.debug("Updating bootstrap soon")
# Force the next bootstrap update
# since the lastUpdateId is not valid anymore
if self._update_task and not self._update_task.done():
return
self._update_task = asyncio.create_task(self.update())
async def close_session(self) -> None:
"""Closing and deletes all client sessions."""
await self._cancel_update_task()
await self._cancel_public_resync_task()
await self._cancel_rtsps_refresh_tasks()
await self._cancel_siren_off_tasks()
if self._session is not None:
await self._session.close()
self._session = None
self._loaded_session = False
if self._public_api_session is not None:
await self._public_api_session.close()
self._public_api_session = None
async def close_public_api_session(self) -> None:
"""Closing and deletes public API client session."""
if self._public_api_session is not None:
await self._public_api_session.close()
self._public_api_session = None
async def _cancel_update_task(self) -> None:
if self._update_task:
self._update_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._update_task
self._update_task = None
async def _cancel_public_resync_task(self) -> None:
# If a subclass tracks queued follow-up resync work, clear it before
# cancellation so shutdown cannot re-schedule a new task in a
# ``finally`` block.
self._public_resync_pending = False
if self._public_resync_task is not None:
self._public_resync_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._public_resync_task
self._public_resync_task = None
async def _cancel_rtsps_refresh_tasks(self) -> None:
"""Cancel and await every pending background RTSPS refresh task."""
if not self._rtsps_refresh_tasks:
return
tasks = list(self._rtsps_refresh_tasks.values())
self._rtsps_refresh_tasks.clear()
for task in tasks:
task.cancel()
for task in tasks:
with contextlib.suppress(asyncio.CancelledError):
await task
def _cancel_all_siren_offs(self) -> list[asyncio.Task[None]]:
"""Cancel every pending siren expiry announcement, returning the tasks."""
tasks = list(self._siren_off_tasks.values())
self._siren_off_tasks.clear()
for task in tasks:
task.cancel()
return tasks
async def _cancel_siren_off_tasks(self) -> None:
"""Cancel and await every pending siren expiry announcement."""
for task in self._cancel_all_siren_offs():
with contextlib.suppress(asyncio.CancelledError):
await task
def set_header(self, key: str, value: str | None) -> None:
"""Set header."""
self.headers = self.headers or {}
if value is None:
self.headers.pop(key, None)
else:
self.headers[key] = value
async def _paced_request(
self,
session: aiohttp.ClientSession,
method: str,
request_url: URL,
headers: dict[str, str],
public_api: bool,
**kwargs: Any,
) -> aiohttp.ClientResponse:
"""Gate the public path through the rate limiter, then send the request."""
if public_api:
await self._public_rate_limiter.acquire()
response = await self._do_request(
session, method, request_url, headers, **kwargs
)
if public_api:
self._public_rate_limiter.seed_from_policy(
response.headers.get("RateLimit-Policy")
)
return response
async def _do_request(
self,
session: aiohttp.ClientSession,
method: str,
request_url: URL,
headers: dict[str, str],
**kwargs: Any,
) -> aiohttp.ClientResponse:
"""Execute a single HTTP request with disconnect retry."""
last_err: aiohttp.ServerDisconnectedError | None = None
for _attempt in range(2):
try:
req_context = session.request(
method,
request_url,
headers=headers,
**kwargs,
)
response = await req_context.__aenter__()
try:
await self._update_last_token_cookie(response)
except Exception:
response.release()
raise
return response
except aiohttp.ServerDisconnectedError as err:
# If the server disconnected, try again
# since HTTP/1.1 allows the server to disconnect at any time
last_err = err
except client_exceptions.ClientError as err:
raise NvrError(
f"Error requesting data from {self._host}: {err}",
) from err
raise NvrError(
f"Error requesting data from {self._host}: {last_err}",
) from last_err
async def request(
self,
method: str,
url: str,
require_auth: bool = False,
auto_close: bool = True,
public_api: bool = False,
**kwargs: Any,
) -> aiohttp.ClientResponse:
"""
Make a request to UniFi Protect with automatic retry on transient errors.
Automatically retries requests that receive 408, 429, 500, 502, 503,
or 504 status codes using exponential backoff.
"""
if require_auth and not public_api:
await self.ensure_authenticated()
request_url = self._url.join(
URL(SplitResult("", "", url, "", ""), encoded=True)
)
headers = kwargs.get("headers") or self.headers or {}
if require_auth and public_api:
if self._api_key is None:
raise NotAuthorized("API key is required for public API requests")
headers = {"X-API-KEY": self._api_key}
_LOGGER.debug("Request url: %s", request_url)
if not self._verify_ssl:
kwargs["ssl"] = False
if public_api:
session = await self.get_public_api_session()
else:
session = await self.get_session()
# First attempt (always happens, even with max_retries=0)
response = await self._paced_request(
session, method, request_url, headers, public_api, **kwargs
)
# Retry loop for transient errors
for retry_attempt in range(self._max_retries):
if response.status not in RETRY_STATUS_CODES:
break
retry_after = parse_retry_after(response)
response.release()
delay = calculate_retry_delay(retry_attempt, retry_after)
# Escalate log level: DEBUG (1st) → INFO (2nd) → WARNING (3rd+)
log_level = (logging.DEBUG, logging.INFO, logging.WARNING)[
min(retry_attempt, 2)
]
_LOGGER.log(
log_level,
"Request to %s returned %s, retrying in %.2f seconds (attempt %d/%d)",
request_url,
response.status,
delay,
retry_attempt + 1,
self._max_retries,
)
await asyncio.sleep(delay)
response = await self._paced_request(
session, method, request_url, headers, public_api, **kwargs
)
# Re-auth-and-retry guard for early server-side session invalidation.
# UniFi OS can drop a private session before its JWT cookie expires, so
# is_authenticated() still trusts the dead cookie and the request 401s.
# Mirror the websocket path: clear the session, log in fresh, retry once.
# The public-API path is excluded — a 401 there is a bad API key, not a
# recoverable session.
if (
require_auth
and not public_api
and response.status == HTTPStatus.UNAUTHORIZED.value
):
response.release()
await self._reauthenticate()
# headers references self.headers, which _reauthenticate refreshes
# in place; rebuild only when the caller supplied its own headers.
if kwargs.get("headers") is None:
headers = self.headers or {}
response = await self._paced_request(
session, method, request_url, headers, public_api, **kwargs
)
if auto_close:
try:
_LOGGER.debug(
"%s %s %s",
response.status,
response.content_type,
response,
)
response.release()
except Exception:
# make sure response is released
response.release()
# re-raise exception
raise
return response
async def api_request_raw(
self,
url: str,
method: str = "get",
require_auth: bool = True,
raise_exception: bool = True,
api_path: str | None = None,
public_api: bool = False,
**kwargs: Any,
) -> bytes | None:
"""Make an API request; transport failures surface as ``NvrError``."""
path = self.private_api_path
if api_path is not None:
path = api_path
elif public_api:
path = self.public_api_path
response = await self.request(
method,
f"{path}{url}",
require_auth=require_auth,
auto_close=False,
public_api=public_api,
**kwargs,
)
try:
# Check for successful status codes (2xx range)
if not (200 <= response.status < 300):
await self._raise_for_status(response, raise_exception)
return None
try:
data: bytes | None = await response.read()
except (client_exceptions.ClientError, TimeoutError) as err:
raise NvrError(
f"Error reading response from {self._host}: {err}",
) from err
response.release()
return data
except Exception:
# make sure response is released
response.release()
# re-raise exception
raise
async def _raise_for_status(
self, response: aiohttp.ClientResponse, raise_exception: bool = True
) -> None:
"""Raise an exception based on the response status."""
url = response.url
reason = await get_response_reason(response)
msg = "Request failed: %s - Status: %s - Reason: %s"
status = response.status
# Success status codes (2xx) should not raise exceptions
if 200 <= status < 300:
return
if raise_exception:
if status in {
HTTPStatus.UNAUTHORIZED.value,
HTTPStatus.FORBIDDEN.value,
}:
raise NotAuthorized(msg % (url, status, reason))
if status == HTTPStatus.TOO_MANY_REQUESTS.value:
_LOGGER.debug("Too many requests - Login is rate limited: %s", response)
raise NvrError(msg % (url, status, reason))
# Handle 400 Bad Request specifically; check for two typed
# sub-cases before the generic fallback: the global alarm manager
# error (returned when alarm-manager is not local, e.g. set to
# global instead) and the arm-alarm-armed error (returned when an
# operation is rejected because the alarm is currently armed).
# Any other 400 reason is treated as a generic bad request.
if status == HTTPStatus.BAD_REQUEST.value:
if _GLOBAL_ALARM_MANAGER_REASON in reason.lower():
raise GlobalAlarmManagerError(msg % (url, status, reason))
if _ARM_ALARM_ARMED_REASON in reason.lower():
raise ArmedModeError(msg % (url, status, reason))
raise BadRequest(msg % (url, status, reason))
# Other 4xx client errors also raise BadRequest
if (
status > HTTPStatus.BAD_REQUEST.value
and status < HTTPStatus.INTERNAL_SERVER_ERROR.value
):
raise BadRequest(msg % (url, status, reason))
raise NvrError(msg % (url, status, reason))
_LOGGER.debug(msg, url, status, reason)
async def api_request(
self,
url: str,
method: str = "get",
require_auth: bool = True,
raise_exception: bool = True,
api_path: str | None = None,
public_api: bool = False,
**kwargs: Any,
) -> list[Any] | dict[str, Any] | None:
data = await self.api_request_raw(
url=url,
method=method,
require_auth=require_auth,
raise_exception=raise_exception,
api_path=api_path,
public_api=public_api,
**kwargs,
)
if data is not None:
json_data: list[Any] | dict[str, Any]
try:
json_data = orjson.loads(data)
return json_data
except orjson.JSONDecodeError as ex:
_LOGGER.error("Could not decode JSON from %s", url)
raise NvrError(f"Could not decode JSON from {url}") from ex
return None
async def api_request_obj(
self,
url: str,
method: str = "get",
require_auth: bool = True,
raise_exception: bool = True,
public_api: bool = False,
**kwargs: Any,
) -> dict[str, Any]:
data = await self.api_request(
url=url,
method=method,
require_auth=require_auth,
raise_exception=raise_exception,
public_api=public_api,
**kwargs,
)
if not isinstance(data, dict):
raise NvrError(f"Could not decode object from {url}")
return data
async def api_request_list(
self,
url: str,
method: str = "get",
require_auth: bool = True,
raise_exception: bool = True,
public_api: bool = False,
**kwargs: Any,
) -> list[Any]:
data = await self.api_request(
url=url,
method=method,
require_auth=require_auth,
raise_exception=raise_exception,
public_api=public_api,
**kwargs,
)
if not isinstance(data, list):
raise NvrError(f"Could not decode list from {url}")
return data
async def ensure_authenticated(self) -> None:
"""Ensure we are authenticated."""
if self._public_only:
raise PublicOnlyModeError(
"Private authentication is unavailable on a public-only client; "
"use the public API surface (update_public, subscribe_*, public setters)"
)
await self._load_session()
if self.is_authenticated() is False:
await self.authenticate()
async def authenticate(self) -> None:
"""Authenticate and get a token."""
if self._public_only:
raise PublicOnlyModeError(
"Private authentication is unavailable on a public-only client; "
"use the public API surface (update_public, subscribe_*, public setters)"
)
if self._auth_lock.locked():
# If an auth is already in progress
# do not start another one
async with self._auth_lock:
return
async with self._auth_lock:
url = "/api/auth/login"
if self._session is not None:
self._session.cookie_jar.clear()
self.set_header("cookie", None)
auth = {
"username": self._username,
"password": self._password,
"rememberMe": self.store_sessions,
}
response = await self.request("post", url=url, json=auth)
if response.status != 200:
await self._raise_for_status(response, True)
csrf_token = response.headers.get("x-csrf-token")
if csrf_token:
self.set_header("x-csrf-token", csrf_token)
cookie_header = response.headers.get("set-cookie", "")
self.set_header("cookie", cookie_header)
self._is_authenticated = True
# parse and store the cookie for session persistence
if self.store_sessions and cookie_header:
# extract cookie from header to save in session file
cookie = SimpleCookie(cookie_header)
if cookie:
for cookie_obj in cookie.values():
self._last_token_cookie = cookie_obj
await self._update_auth_config(cookie_obj)
break # auth response only contains single cookie (TOKEN or UOS_TOKEN)
_LOGGER.debug("Authenticated successfully!")
async def _reauthenticate(self) -> None:
"""
Force a fresh private-API login after a dead session.
Clears the invalidated session (cookie jar + headers +
``_is_authenticated``) so ``ensure_authenticated`` performs a real
login instead of trusting the stale JWT cookie. The clear is
synchronous and the re-login serializes on ``authenticate``'s
``_auth_lock``, so concurrent callers (e.g. simultaneous 401s) collapse
onto a single re-login without a thundering herd.
"""
if self._session is not None:
self._session.cookie_jar.clear()
self.set_header("cookie", None)
self.set_header("x-csrf-token", None)
self._is_authenticated = False
await self.ensure_authenticated()
async def _update_last_token_cookie(self, response: aiohttp.ClientResponse) -> None:
"""Update the last token cookie."""
csrf_token = response.headers.get("x-csrf-token")
if (
csrf_token is not None
and self.headers
and csrf_token != self.headers.get("x-csrf-token")
):
self.set_header("x-csrf-token", csrf_token)
self._update_cookiename(response.cookies)
if (
token_cookie := response.cookies.get(self._cookiename)
) and token_cookie != self._last_token_cookie:
self._last_token_cookie = token_cookie
if self.store_sessions:
await self._update_auth_config(self._last_token_cookie)
self._last_token_cookie_decode = None
async def _update_auth_config(self, cookie: Morsel[str]) -> None:
"""Updates auth cookie on disk for persistent sessions."""
username = self._username
if self._last_token_cookie is None or self._public_only or username is None:
return
await aos.makedirs(self.config_dir, exist_ok=True)
config: dict[str, Any] = {}
session_hash = get_user_hash(str(self._url), username)
try:
async with aiofiles.open(self.config_file, "rb") as f:
config_data = await f.read()
if config_data:
try:
config = orjson.loads(config_data)
except Exception:
_LOGGER.warning("Invalid config file, ignoring.")
except FileNotFoundError:
pass
config["sessions"] = config.get("sessions", {})
config["sessions"][session_hash] = {
"metadata": dict(cookie),
"cookiename": self._cookiename,
"value": cookie.value,
"csrf": self.headers.get("x-csrf-token") if self.headers else None,
}
async with aiofiles.open(self.config_file, "wb") as f:
await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2))
async def _load_session(self) -> None:
if self._public_only:
return
if self._session is None:
await self.get_session()
assert self._session is not None
if not self._loaded_session and self.store_sessions:
session_cookie = await self._read_auth_config()
self._loaded_session = True
if session_cookie:
_LOGGER.debug("Successfully loaded session from config")
self._session.cookie_jar.update_cookies(session_cookie)
async def _read_auth_config(self) -> SimpleCookie | None:
"""Read auth cookie from config."""
config: dict[str, Any] = {}
try:
async with aiofiles.open(self.config_file, "rb") as f:
config_data = await f.read()
if config_data:
try:
config = orjson.loads(config_data)
except Exception:
_LOGGER.warning("Invalid config file, ignoring.")
return None
except FileNotFoundError:
_LOGGER.debug("no config file, not loading session")
return None
session_hash = get_user_hash(str(self._url), self._username or "")
session = config.get("sessions", {}).get(session_hash)
if not session:
_LOGGER.debug("No existing session for %s", session_hash)
return None
cookie = SimpleCookie()
cookie_name = session.get("cookiename")
if cookie_name is None:
return None
cookie[cookie_name] = session.get("value")
for key, value in session.get("metadata", {}).items():
cookie[cookie_name][key] = value
cookie_value = _COOKIE_RE.sub("", str(cookie[cookie_name]))
self._last_token_cookie = cookie[cookie_name]
self._last_token_cookie_decode = None
# Only mark as authenticated if we have both cookie and CSRF token
csrf_token = session.get("csrf")
if not csrf_token:
_LOGGER.debug("Session found but missing CSRF token, will re-authenticate")
return None
self._is_authenticated = True
self.set_header("cookie", cookie_value)
self.set_header("x-csrf-token", csrf_token)
return cookie
def is_authenticated(self) -> bool:
"""Check to see if we are already authenticated."""
if self._session is None:
return False
if self._is_authenticated is False:
return False
if self._last_token_cookie is None:
return False
# Lazy decode the token cookie
if self._last_token_cookie and self._last_token_cookie_decode is None:
self._last_token_cookie_decode = decode_token_cookie(
self._last_token_cookie,
)
if (
self._last_token_cookie_decode is None
or "exp" not in self._last_token_cookie_decode
):
return False
token_expires_at = cast("int", self._last_token_cookie_decode["exp"])
max_expire_time = time.time() + TOKEN_COOKIE_MAX_EXP_SECONDS
return token_expires_at >= max_expire_time
async def clear_session(self) -> None:
"""Clears stored session for this specific user/host combination."""
if not self.store_sessions:
return
username = self._username
if username is None:
return
config: dict[str, Any] = {}
session_hash = get_user_hash(str(self._url), username)
try:
async with aiofiles.open(self.config_file, "rb") as f:
config_data = await f.read()
if config_data:
try:
config = orjson.loads(config_data)
except orjson.JSONDecodeError:
_LOGGER.warning("Invalid config file, ignoring.")
return
except FileNotFoundError:
return
if "sessions" in config and session_hash in config["sessions"]:
del config["sessions"][session_hash]
async with aiofiles.open(self.config_file, "wb") as f:
await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2))
_LOGGER.debug("Cleared session for %s", session_hash)
# Clear authentication state only when session was actually removed
self._is_authenticated = False
self._last_token_cookie = None
self._last_token_cookie_decode = None
async def clear_all_sessions(self) -> None:
"""Clears all stored sessions from the config file."""
if not self.store_sessions:
return
try:
await aos.remove(self.config_file)
except FileNotFoundError:
# File already gone - either never existed or removed by another process
return
# If we get here, the file was successfully removed (no exception raised)
_LOGGER.debug("Cleared all sessions from config file")
# Clear authentication state only after successful deletion
self._is_authenticated = False
self._last_token_cookie = None
self._last_token_cookie_decode = None
def _get_websocket_url(self) -> URL:
"""Get Websocket URL."""
return self._ws_url_object
async def async_disconnect_ws(self) -> None:
"""Disconnect from Websocket."""
if self._private_websocket:
websocket = self._get_websocket()
websocket.stop()
await websocket.wait_closed()
self._private_websocket = None
if self._events_websocket:
events_websocket = self._get_events_websocket()
events_websocket.stop()
await events_websocket.wait_closed()
self._events_websocket = None
if self._devices_websocket:
devices_websocket = self._get_devices_websocket()
devices_websocket.stop()
await devices_websocket.wait_closed()
self._devices_websocket = None
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
raise NotImplementedError
def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None:
"""Process events websocket message - to be implemented by subclass."""
raise NotImplementedError
def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None:
"""Process devices websocket message - to be implemented by subclass."""
raise NotImplementedError
def _get_last_update_id(self) -> str | None:
raise NotImplementedError
async def update(self) -> Bootstrap:
raise NotImplementedError
def _on_websocket_state_change(self, state: WebsocketState) -> None:
"""Websocket state changed."""
_LOGGER.debug("Websocket state changed: %s", state)
def _on_events_websocket_state_change(self, state: WebsocketState) -> None:
"""Events websocket state changed."""
_LOGGER.debug("Events websocket state changed: %s", state)
def _on_devices_websocket_state_change(self, state: WebsocketState) -> None:
"""Devices websocket state changed."""
_LOGGER.debug("Devices websocket state changed: %s", state)
class ProtectApiClient(BaseApiClient):
"""
Main UFP API Client
UniFi Protect is a full async application. "normal" use of interacting with it is
to call `.update()` which will initialize the `.bootstrap` and create a Websocket
connection to UFP. This Websocket connection will emit messages that will automatically
update the `.bootstrap` over time.
You can use the `.get_` methods to one off pull devices from the UFP API, but should
not be used for building an application on top of.
All objects inside of `.bootstrap` have a reference back to the API client so they can
use `.save_device()` and update themselves using their own `.set_` methods on the object.
Args:
----
host: UFP hostname / IP address
port: UFP HTTPS port
username: UFP username
password: UFP password
api_key: API key for UFP
verify_ssl: Verify HTTPS certificate (default: `True`)
session: Optional aiohttp session to use (default: generate one)
override_connection_host: Use `host` as your `connection_host` for the
private RTSP(S) URLs and the public RTSPS URLs instead of using the
one provided by UniFi Protect.
minimum_score: minimum score for events (default: `0`)
subscribed_models: Model types you want to filter events for WS. You will need to manually check the bootstrap for updates for events that not subscibred.
ignore_stats: Ignore storage, system, etc. stats/metrics from NVR and cameras (default: false)
debug: Use full type validation (default: false)
"""
_minimum_score: int
_subscribed_models: set[ModelType]
# ``None`` means "inherit ``_subscribed_models``"; an explicit (possibly
# empty) set overrides the global filter for that websocket. An empty set
# therefore means "allow all models" — matching the private-WS behaviour.
_events_ws_subscribed_models: set[ModelType] | None
_devices_ws_subscribed_models: set[ModelType] | None
_ignore_stats: bool
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
_events_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
_devices_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
_events_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
_devices_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
_bootstrap: Bootstrap | None = None
_public_bootstrap: PublicBootstrap | None = None
# True after the first time the devices WS transitions to CONNECTED; used
# to distinguish the *initial* connect from a *reconnect*. Only the
# latter triggers an automatic `update_public()` resync.
_devices_ws_has_been_connected: bool = False
# True after the first time the events WS transitions to CONNECTED; only a
# *reconnect* (not the initial connect) force-ends events left open across
# the gap.
_events_ws_has_been_connected: bool = False
# Monotonic timestamp of the last successful/attempted reconnect resync,
# used to debounce a flapping websocket into a single refresh.
_last_public_resync: float = 0.0
# Set when another reconnect happens while a public resync task is
# still running; consumed in ``_resync_public_bootstrap`` to run one
# follow-up refresh.
_public_resync_pending: bool = False
_last_update_dt: datetime | None = None
_connection_host: IPv4Address | IPv6Address | str | None = None
_override_connection_host: bool = False
# Lazy dispatcher; ``subscribe_events`` materialises it.
_event_dispatcher: EventDispatcher | None = None
# Internal events-WS adapter unsubscribe; populated while the typed
# ``subscribe_events`` callback list is non-empty.
_event_ws_adapter_unsub: Callable[[], None] | None = None
# Lazy dispatcher; ``subscribe_devices`` materialises it.
_device_dispatcher: DeviceDispatcher | None = None
# Internal devices-WS adapter unsubscribe; populated while the typed
# ``subscribe_devices`` callback list is non-empty.
_device_ws_adapter_unsub: Callable[[], None] | None = None
# Monotonic timestamp of the last "REMOVE for unknown event" INFO log;
# throttled so a burst of unknown-id removes does not flood the log.
# Seeded to -inf (not 0.0) so the first unknown remove always logs: with
# 0.0 the gate ``now - last >= 60`` only opens once ``monotonic()`` has
# passed 60s, silently swallowing an early remove on a freshly-booted host.
_events_remove_unknown_last_log: float = float("-inf")
# Non-throttled running total of unknown-id REMOVE frames. The INFO log is
# rate-limited, so this counter keeps a sustained cache/server desync
# observable even when individual lines are suppressed.
_events_remove_unknown_count: int = 0
# Public events/devices WS frames that arrive while ``update_public`` is
# priming are captured here and replayed after the fresh snapshot is
# applied, so an update landing in the prime window is never lost to the
# snapshot replacing the cached objects. ``None`` outside a prime; primes
# are serialized by ``_public_update_lock``, so exactly one prime owns it.
_prime_ws_buffer: (
list[tuple[Callable[[aiohttp.WSMessage], None], aiohttp.WSMessage]] | None
) = None
# Devices whose add/remove a real WS frame already announced while
# ``update_public`` was running, so the membership diff does not
# announce the same change a second time. ``None`` outside a prime.
_public_membership_seen: set[tuple[ModelType, str]] | None = None
ignore_unadopted: bool
def __init__(
self,
host: str,
port: int,
username: str | None = None,
password: str | None = None,
api_key: str | None = None,
verify_ssl: bool = True,
session: aiohttp.ClientSession | None = None,
public_api_session: aiohttp.ClientSession | None = None,
ws_timeout: int = 30,
cache_dir: Path | None = None,
config_dir: Path | None = None,
store_sessions: bool = True,
override_connection_host: bool = False,
minimum_score: int = 0,
subscribed_models: set[ModelType] | None = None,
events_ws_subscribed_models: set[ModelType] | None = None,
devices_ws_subscribed_models: set[ModelType] | None = None,
ignore_stats: bool = False,
ignore_unadopted: bool = True,
debug: bool = False,
ws_receive_timeout: int | None = None,
max_retries: int = RETRY_DEFAULT_ATTEMPTS,
) -> None:
super().__init__(
host=host,
port=port,
username=username,
password=password,
api_key=api_key,
verify_ssl=verify_ssl,
session=session,
public_api_session=public_api_session,
ws_timeout=ws_timeout,
ws_receive_timeout=ws_receive_timeout,
cache_dir=cache_dir,
config_dir=config_dir,
store_sessions=store_sessions,
max_retries=max_retries,
)
self._minimum_score = minimum_score
self._subscribed_models = subscribed_models or set()
# Preserve ``None`` vs. empty-set distinction: ``None`` inherits from
# ``_subscribed_models``; an explicit empty set means "allow all".
self._events_ws_subscribed_models = events_ws_subscribed_models
self._devices_ws_subscribed_models = devices_ws_subscribed_models
self._ignore_stats = ignore_stats
self._ws_subscriptions = []
self._events_ws_subscriptions = []
self._devices_ws_subscriptions = []
self._ws_state_subscriptions = []
self._events_ws_state_subscriptions = []
self._devices_ws_state_subscriptions = []
self._event_dispatcher = None
self._device_dispatcher = None
self.ignore_unadopted = ignore_unadopted
self._update_lock = asyncio.Lock()
self._override_connection_host = override_connection_host
if override_connection_host:
self._connection_host = self._host
if debug:
set_debug()
@classmethod
def public_only(
cls,
host: str,
port: int,
api_key: str,
*,
verify_ssl: bool = True,
public_api_session: aiohttp.ClientSession | None = None,
ws_timeout: int = 30,
ws_receive_timeout: int | None = None,
subscribed_models: set[ModelType] | None = None,
events_ws_subscribed_models: set[ModelType] | None = None,
devices_ws_subscribed_models: set[ModelType] | None = None,
ignore_unadopted: bool = True,
override_connection_host: bool = False,
max_retries: int = RETRY_DEFAULT_ATTEMPTS,
) -> Self:
"""
Construct a client that operates entirely on the Public Integration API.
No private login is performed. Private-session entry points
(:meth:`authenticate`, :meth:`update`, :meth:`get_bootstrap`) raise
:class:`PublicOnlyModeError`; use :meth:`update_public`,
:meth:`subscribe_events`, :meth:`subscribe_devices`, the public
``get_*_public`` / ``update_*_public`` methods, and
:meth:`get_meta_info` instead. A revoked or invalid key surfaces as
:class:`NotAuthorized`. ``override_connection_host`` rewrites the host
of the public RTSPS URLs to ``host``; it needs no private bootstrap.
"""
return cls(
host,
port,
api_key=api_key,
verify_ssl=verify_ssl,
public_api_session=public_api_session,
ws_timeout=ws_timeout,
ws_receive_timeout=ws_receive_timeout,
subscribed_models=subscribed_models,
events_ws_subscribed_models=events_ws_subscribed_models,
devices_ws_subscribed_models=devices_ws_subscribed_models,
ignore_unadopted=ignore_unadopted,
override_connection_host=override_connection_host,
max_retries=max_retries,
)
def _set_connection_host_from_bootstrap(self) -> None:
"""
Set connection host from bootstrap NVR hosts (sync).
NOTE: Must stay in sync with _async_set_connection_host_from_bootstrap().
Sync version for property getter, async version for update() method.
"""
index = 0
try:
index = self.bootstrap.nvr.hosts.index(self._host)
except ValueError:
try:
host = ip_address(self._host)
with contextlib.suppress(ValueError):
index = self.bootstrap.nvr.hosts.index(host)
except ValueError:
pass
self._connection_host = self.bootstrap.nvr.hosts[index]
async def _async_set_connection_host_from_bootstrap(
self,
bootstrap: Bootstrap,
) -> None:
"""
Set connection host from bootstrap NVR hosts (async).
NOTE: Must stay in sync with _set_connection_host_from_bootstrap().
Async version allows DNS resolution via ip_from_host().
"""
index = 0
try:
index = bootstrap.nvr.hosts.index(self._host)
except ValueError:
try:
host_ip = await ip_from_host(self._host)
index = bootstrap.nvr.hosts.index(host_ip)
except ValueError:
pass
self._connection_host = bootstrap.nvr.hosts[index]
@cached_property
def bootstrap(self) -> Bootstrap:
if self._bootstrap is None:
raise BadRequest("Client not initialized, run `update` first")
return self._bootstrap
@property
def public_bootstrap(self) -> PublicBootstrap:
"""
In-memory cache of Public Integration API resources.
Must be populated via :meth:`update_public` first; raises
:class:`BadRequest` otherwise. This is a conscious mirror of
:attr:`bootstrap` so that merely *reading* the property never changes
WS-handler semantics (which keys off ``_public_bootstrap is not
None``).
"""
if self._public_bootstrap is None:
raise BadRequest(
"Public bootstrap not initialized, run `update_public` first"
)
return self._public_bootstrap
@property
def has_public_bootstrap(self) -> bool:
"""Whether :meth:`update_public` has been called at least once."""
return self._public_bootstrap is not None
@property
def override_connection_host(self) -> bool:
"""Whether `host` overrides the console-reported connection host."""
return self._override_connection_host
@property
def connection_host(self) -> IPv4Address | IPv6Address | str:
"""Connection host to use for generating RTSP URLs."""
if self._connection_host is None:
self._set_connection_host_from_bootstrap()
assert self._connection_host is not None
return self._connection_host
async def update(self) -> Bootstrap:
"""
Updates the state of devices, initializes `.bootstrap`
The websocket is auto connected once there are any
subscriptions to it. update must be called at least
once before subscribing to the websocket.
You can use the various other `get_` methods if you need one off data from UFP
"""
if self._public_only:
raise PublicOnlyModeError(
"Private bootstrap is unavailable on a public-only client; "
"use update_public() instead"
)
async with self._update_lock:
bootstrap = await self.get_bootstrap()
if bootstrap.nvr.version >= NFC_FINGERPRINT_SUPPORT_VERSION:
try:
keyrings = await self.api_request_list("keyrings")
except NotAuthorized as err:
_LOGGER.debug("No access to keyrings %s, skipping", err)
keyrings = []
try:
ulp_users = await self.api_request_list("ulp-users")
except NotAuthorized as err:
_LOGGER.debug("No access to ulp-users %s, skipping", err)
ulp_users = []
bootstrap.keyrings = Keyrings.from_list(
cast("list[Keyring]", list_from_unifi_list(self, keyrings))
)
bootstrap.ulp_users = UlpUsers.from_list(
cast("list[UlpUser]", list_from_unifi_list(self, ulp_users))
)
self.__dict__.pop("bootstrap", None)
self._bootstrap = bootstrap
# Set connection host if not set via override_connection_host
if self._connection_host is None:
await self._async_set_connection_host_from_bootstrap(bootstrap)
return bootstrap
async def poll_events(self) -> None:
"""Poll for events."""
now_dt = utc_now()
max_event_dt = now_dt - timedelta(hours=1)
events = await self.get_events(
start=self._last_update_dt or max_event_dt,
end=now_dt,
)
for event in events:
self.bootstrap.process_event(event)
self._last_update_dt = now_dt
def emit_message(self, msg: WSSubscriptionMessage) -> None:
"""Emit message to all subscriptions."""
if _LOGGER.isEnabledFor(logging.DEBUG):
if msg.new_obj is not None:
_LOGGER.debug(
"emitting message: %s:%s:%s:%s",
msg.action,
msg.new_obj.model,
msg.new_obj.id,
list(msg.changed_data),
)
elif msg.old_obj is not None:
_LOGGER.debug(
"emitting message: %s:%s:%s",
msg.action,
msg.old_obj.model,
msg.old_obj.id,
)
else:
_LOGGER.debug("emitting message: %s", msg.action)
for sub in self._ws_subscriptions:
try:
sub(msg)
except Exception:
_LOGGER.exception("Exception while running subscription handler")
def emit_events_message(self, msg: WSSubscriptionMessage) -> None:
"""Emit message to all events subscriptions."""
if _LOGGER.isEnabledFor(logging.DEBUG):
if msg.new_obj is not None:
_LOGGER.debug(
"emitting events message: %s:%s:%s:%s",
msg.action,
msg.new_obj.model,
msg.new_obj.id,
list(msg.changed_data),
)
elif msg.old_obj is not None:
_LOGGER.debug(
"emitting events message: %s:%s:%s",
msg.action,
msg.old_obj.model,
msg.old_obj.id,
)
else:
_LOGGER.debug("emitting events message: %s", msg.action)
for sub in self._events_ws_subscriptions:
try:
sub(msg)
except Exception:
_LOGGER.exception("Exception while running events subscription handler")
def emit_devices_message(self, msg: WSSubscriptionMessage) -> None:
"""Emit message to all devices subscriptions."""
if _LOGGER.isEnabledFor(logging.DEBUG):
if msg.new_obj is not None:
_LOGGER.debug(
"emitting devices message: %s:%s:%s:%s",
msg.action,
msg.new_obj.model,
msg.new_obj.id,
list(msg.changed_data),
)
elif msg.old_obj is not None:
_LOGGER.debug(
"emitting devices message: %s:%s:%s",
msg.action,
msg.old_obj.model,
msg.old_obj.id,
)
else:
_LOGGER.debug("emitting devices message: %s", msg.action)
for sub in self._devices_ws_subscriptions:
try:
sub(msg)
except Exception:
_LOGGER.exception(
"Exception while running devices subscription handler"
)
def _get_last_update_id(self) -> str | None:
if self._bootstrap is None:
return None
return self._bootstrap.last_update_id
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
packet = WSPacket(msg.data)
processed_message = self.bootstrap.process_ws_packet(
packet,
models=self._subscribed_models,
ignore_stats=self._ignore_stats,
)
if processed_message is None:
return
self.emit_message(processed_message)
def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None:
"""
Process events websocket message (Public API - JSON format).
``add`` payloads may be minimal/partial (e.g. motion start without
``score`` / ``smartDetect*``), but must still be constructable as an
:class:`Event` — see the defaults on :class:`Event` for the optional
fields. ``update`` messages carry partial diffs (typically ``end``)
that are merged into the cached event by :class:`PublicBootstrap`.
"""
if msg.type != aiohttp.WSMsgType.TEXT:
_LOGGER.debug("Ignoring non-text websocket message: %s", msg.type)
return
if self._prime_ws_buffer is not None:
self._prime_ws_buffer.append((self._process_events_ws_message, msg))
return
try:
data = orjson.loads(msg.data)
action_type = data.get("type")
item = data.get("item", {})
model_key = item.get("modelKey")
if not action_type or not model_key:
_LOGGER.debug("Invalid public API websocket message: %s", data)
return
model_type = ModelType.from_string(model_key)
if model_type is ModelType.UNKNOWN:
_LOGGER.debug("Unknown model type in public API message: %s", model_key)
return
# Respect ``subscribed_models`` for the events WS too.
# ``None`` => inherit the global filter; an explicit (possibly
# empty) per-WS set overrides it.
_events_filter = (
self._subscribed_models
if self._events_ws_subscribed_models is None
else self._events_ws_subscribed_models
)
if _events_filter and model_type not in _events_filter:
return
update_id = item.get("id", "")
new_obj: ProtectModelWithId | None = None
old_obj: ProtectModelWithId | None = None
# Device-model updates produced by processing the events frame — a
# first-class output (e.g. a camera whose derived detection booleans
# flipped). Emitted through the standard devices channel below with
# no event-specific branching here.
model_updates: list[WSSubscriptionMessage] = []
if self._public_bootstrap is not None and model_type is ModelType.EVENT:
result = self._public_bootstrap.process_events_ws_message(self, data)
new_obj = result.new_event
old_obj = result.old_event
model_updates = result.model_updates
msg_obj = WSSubscriptionMessage(
action=WSAction(action_type),
new_update_id=update_id,
changed_data=item,
new_obj=new_obj,
old_obj=old_obj,
)
self.emit_events_message(msg_obj)
for update in model_updates:
self.emit_devices_message(update)
except Exception:
_LOGGER.exception("Error processing public API events websocket message")
def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None:
"""Process devices websocket message (Public API - JSON format)."""
if msg.type != aiohttp.WSMsgType.TEXT:
_LOGGER.debug("Ignoring non-text websocket message: %s", msg.type)
return
if self._prime_ws_buffer is not None:
self._prime_ws_buffer.append((self._process_devices_ws_message, msg))
return
try:
data = orjson.loads(msg.data)
action_type = data.get("type") # "update", "add", "remove"
item = data.get("item", {})
model_key = item.get("modelKey")
if not action_type or not model_key:
_LOGGER.debug("Invalid public API websocket message: %s", data)
return
# Create a WSSubscriptionMessage similar to private WS
model_type = ModelType.from_string(model_key)
if model_type is ModelType.UNKNOWN:
_LOGGER.debug("Unknown model type in public API message: %s", model_key)
return
if self._devices_ws_filtered_out(model_type):
return
# Apply the change to the PublicBootstrap cache when it has been
# materialised via `update_public`. Without that opt-in, the
# subscription message carries ``new_obj=None`` and subscribers
# must fall back to ``changed_data`` (the raw payload). This
# preserves legacy behaviour exactly and avoids producing
# partially-validated model instances for consumers that haven't
# opted into the cache.
if self._public_bootstrap is not None:
# Bulk envelopes carry an ``id`` array sharing one payload;
# expand to one frame per device so every subscriber sees
# clean single-device messages regardless of batching.
for result in self._public_bootstrap.process_devices_ws_messages(
self, data
):
self._note_membership_frame(action_type, result)
self.emit_devices_message(
WSSubscriptionMessage(
action=WSAction(action_type),
new_update_id=result.item.get("id", ""),
changed_data=result.item,
new_obj=result.new_obj,
old_obj=result.old_obj,
)
)
return
self.emit_devices_message(
WSSubscriptionMessage(
action=WSAction(action_type),
new_update_id=item.get("id", ""),
changed_data=item,
new_obj=None,
old_obj=None,
)
)
except Exception:
_LOGGER.exception("Error processing public API devices websocket message")
def _devices_ws_filtered_out(self, model_type: ModelType) -> bool:
"""
Whether the devices-WS ``subscribed_models`` filter excludes a model.
Empty set means "all" (matches private-WS behaviour). ``None`` =>
inherit the global filter; an explicit (possibly empty) per-WS set
overrides it.
"""
model_filter = (
self._subscribed_models
if self._devices_ws_subscribed_models is None
else self._devices_ws_subscribed_models
)
return bool(model_filter) and model_type not in model_filter
def _note_membership_frame(self, action_type: str, result: DeviceWSResult) -> None:
"""Record a wire ``add``/``remove`` so the fetch diff does not repeat it."""
seen = self._public_membership_seen
if seen is None or result.model_type is None:
return
# An ``add`` whose model could not be built announces nothing a
# subscriber can act on, so it must not suppress the fetch diff's own
# announcement for the same device.
if action_type == "remove" or (
action_type == "add" and result.new_obj is not None
):
seen.add((result.model_type, str(result.item.get("id", ""))))
async def _get_event_paginate( # noqa: PLR0912
self,
params: dict[str, Any],
*,
start: datetime,
end: datetime | None,
) -> list[dict[str, Any]]:
start_int = to_js_time(start)
end_int = to_js_time(end) if end else None
offset = 0
current_start = sys.maxsize
events: list[dict[str, Any]] = []
request_count = 0
logged = False
params["limit"] = 100
# greedy algorithm
# always force desc to receive faster results in the vast majority of cases
params["orderDirection"] = "DESC"
_LOGGER.debug("paginate desc %s %s", start_int, end_int)
while current_start > start_int:
params["offset"] = offset
_LOGGER.debug("page desc %s %s", offset, current_start)
new_events = await self.api_request_list("events", params=params)
request_count += 1
if not new_events:
break
if end_int is not None:
_LOGGER.debug("page end %s (%s)", new_events[0]["end"], end_int)
for event in new_events:
if event["start"] <= end_int:
events.append(event)
else:
break
else:
events += new_events
offset += 100
if events:
current_start = events[-1]["start"]
if not logged and request_count > 5:
logged = True
_LOGGER.warning(TYPES_BUG_MESSAGE)
to_remove = 0
for event in reversed(events):
if event["start"] < start_int:
to_remove += 1
else:
break
if to_remove:
events = events[:-to_remove]
return events
async def get_events_raw( # noqa: PLR0912
self,
*,
start: datetime | None = None,
end: datetime | None = None,
limit: int | None = None,
offset: int | None = None,
types: list[EventType] | None = None,
smart_detect_types: list[SmartDetectObjectType] | None = None,
sorting: Literal["asc", "desc"] = "asc",
descriptions: bool = True,
all_cameras: bool | None = None,
category: EventCategories | None = None,
# used for testing
_allow_manual_paginate: bool = True,
) -> list[dict[str, Any]]:
"""
Get list of events from Protect
Args:
----
start: start time for events
end: end time for events
limit: max number of events to return
offset: offset to start fetching events from
types: list of EventTypes to get events for
smart_detect_types: Filters the Smart detection types for the events
sorting: sort events by ascending or descending, defaults to ascending (chronologic order)
description: included additional event metadata
category: event category, will provide additional category/subcategory fields
If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours.
If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or
`limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect
"""
# if no parameters are passed in, default to all events from last 24 hours
if limit is None and start is None and end is None:
end = utc_now() + timedelta(seconds=10)
start = end - timedelta(hours=1)
params: dict[str, Any] = {
"orderDirection": sorting.upper(),
"withoutDescriptions": str(not descriptions).lower(),
}
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
if start is not None:
params["start"] = to_js_time(start)
if end is not None:
params["end"] = to_js_time(end)
if types is not None:
params["types"] = [e.value for e in types]
if smart_detect_types is not None:
params["smartDetectTypes"] = [e.value for e in smart_detect_types]
if all_cameras is not None:
params["allCameras"] = str(all_cameras).lower()
if category is not None:
params["categories"] = category
# manual workaround for a UniFi Protect bug
# if types if missing from query params
if _allow_manual_paginate and "types" not in params and start is not None:
if sorting == "asc":
events = await self._get_event_paginate(
params,
start=start,
end=end,
)
events = list(reversed(events))
else:
events = await self._get_event_paginate(
params,
start=start,
end=end,
)
if limit:
offset = offset or 0
events = events[offset : limit + offset]
elif offset:
events = events[offset:]
return events
return await self.api_request_list("events", params=params)
async def get_events(
self,
start: datetime | None = None,
end: datetime | None = None,
limit: int | None = None,
offset: int | None = None,
types: list[EventType] | None = None,
smart_detect_types: list[SmartDetectObjectType] | None = None,
sorting: Literal["asc", "desc"] = "asc",
descriptions: bool = True,
category: EventCategories | None = None,
# used for testing
_allow_manual_paginate: bool = True,
) -> list[Event]:
"""
Same as `get_events_raw`, except
* returns actual `Event` objects instead of raw Python dictionaries
* filers out non-device events
* filters out events with too low of a score
Args:
----
start: start time for events
end: end time for events
limit: max number of events to return
offset: offset to start fetching events from
types: list of EventTypes to get events for
smart_detect_types: Filters the Smart detection types for the events
sorting: sort events by ascending or descending, defaults to ascending (chronologic order)
description: included additional event metadata
category: event category, will provide additional category/subcategory fields
If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours.
If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or
`limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect
"""
response = await self.get_events_raw(
start=start,
end=end,
limit=limit,
offset=offset,
types=types,
smart_detect_types=smart_detect_types,
sorting=sorting,
descriptions=descriptions,
category=category,
_allow_manual_paginate=_allow_manual_paginate,
)
events = []
for event_dict in response:
# ignore unknown events
if (
"type" not in event_dict
or event_dict["type"] not in EventType.values_set()
):
_LOGGER.debug("Unknown event type: %s", event_dict)
continue
event = create_from_unifi_dict(event_dict, api=self)
# should never happen
if not isinstance(event, Event):
continue
if (
event.type.value in EventType.device_events_set()
and event.score >= self._minimum_score
):
events.append(event)
return events
def subscribe_websocket(
self,
ws_callback: Callable[[WSSubscriptionMessage], None],
) -> Callable[[], None]:
"""
Subscribe to websocket events.
Returns a callback that will unsubscribe.
"""
_LOGGER.debug("Adding subscription: %s", ws_callback)
self._ws_subscriptions.append(ws_callback)
self._get_websocket().start()
return partial(self._unsubscribe_websocket, ws_callback)
def subscribe_events_websocket(
self,
ws_callback: Callable[[WSSubscriptionMessage], None],
) -> Callable[[], None]:
"""
Subscribe to events websocket events.
Returns a callback that will unsubscribe.
"""
_LOGGER.debug("Adding events subscription: %s", ws_callback)
self._events_ws_subscriptions.append(ws_callback)
self._get_events_websocket().start()
return partial(self._unsubscribe_events_websocket, ws_callback)
def subscribe_devices_websocket(
self,
ws_callback: Callable[[WSSubscriptionMessage], None],
) -> Callable[[], None]:
"""
Subscribe to devices websocket events.
Returns a callback that will unsubscribe.
"""
_LOGGER.debug("Adding devices subscription: %s", ws_callback)
self._devices_ws_subscriptions.append(ws_callback)
self._get_devices_websocket().start()
return partial(self._unsubscribe_devices_websocket, ws_callback)
def subscribe_events(
self,
callback: Callable[[ProtectEvent, EventChange], None],
) -> Callable[[], None]:
"""
Subscribe to typed public event lifecycle callbacks.
Only events whose ``EventType`` maps to a non-``OTHER``
``ProtectEventChannel`` (detection / sensor / alarm-hub /
access) are delivered; administrative events such as
``provision``, ``factoryReset`` and ``fwUpdate`` are dropped.
Callers needing the unfiltered stream should use
``subscribe_events_websocket`` instead.
The callback must not raise: an exception is caught and logged but
otherwise swallowed, so a raising callback silently loses that
delivery (e.g. an ``ENDED`` that never reaches the consumer). Do any
fallible work inside a guard the callback owns.
Identity and ``device_mac`` resolve with eventual consistency: the
first event for a freshly-enrolled ULP user can resolve to
``UnknownIdentity(reason="ulp_user_not_cached")``, and a device not
yet in the bootstrap yields ``device_mac=None``, until the next
``update_public()`` / reconnect resync refreshes
``public_bootstrap.ulp_users`` and the device stores. Smart-detect
detected attributes (license-plate text, face-match name) are not
exposed over the public API today — fall back to the private path
via ``event.raw`` when you need them.
A revoked or invalid API key surfaces as ``WebsocketState.AUTH_FAILED``
via ``subscribe_events_websocket_state`` after repeated 401 handshakes;
call ``set_api_key()`` with a fresh key to re-arm.
"""
if self._public_bootstrap is None:
raise RuntimeError(
"subscribe_events() requires update_public() to have been called"
" at least once"
)
return self._register_event_subscriber(callback)
def _register_event_subscriber(
self,
callback: Callable[[ProtectEvent, EventChange], None],
) -> Callable[[], None]:
"""Wire a typed events subscriber and connect the WS on the first one."""
# Local import to avoid circular import (events.dispatcher → api).
from .events.dispatcher import EventDispatcher # noqa: PLC0415
if self._event_dispatcher is None:
self._event_dispatcher = EventDispatcher(self)
dispatcher = self._event_dispatcher
first = dispatcher.subscriber_count == 0
dispatcher.add_subscriber(callback)
if first:
self._event_ws_adapter_unsub = self.subscribe_events_websocket(
self._adapt_events_ws_message
)
dispatcher.start_ttl_sweep()
return partial(self._unsubscribe_events, callback)
async def subscribe_events_and_prime(
self,
callback: Callable[[ProtectEvent, EventChange], None],
) -> Callable[[], None]:
"""
Subscribe to typed event changes and prime the public bootstrap,
order-independent.
The events-side analog of :meth:`subscribe_devices_and_prime`: connects
the events websocket *before* priming, then awaits :meth:`update_public`.
Frames arriving during the prime are buffered and replayed onto the
fresh snapshot, so ordering is moot and the bootstrap need not be primed
beforehand.
Returns the same unsubscribe callable as :meth:`subscribe_events`. If
priming raises, the subscription is rolled back before the error
propagates.
"""
unsub = self._register_event_subscriber(callback)
try:
await self.update_public()
except BaseException:
unsub()
raise
return unsub
def _unsubscribe_events(
self,
callback: Callable[[ProtectEvent, EventChange], None],
) -> None:
if self._event_dispatcher is None:
return
self._event_dispatcher.remove_subscriber(callback)
if self._event_dispatcher.subscriber_count == 0:
self._event_dispatcher.stop_ttl_sweep()
unsub = getattr(self, "_event_ws_adapter_unsub", None)
if unsub is not None:
unsub()
self._event_ws_adapter_unsub = None
def active_events(self, device_id: str | None = None) -> list[ProtectEvent]:
"""
Return the in-flight public events, optionally filtered by device.
Derived directly from ``public_bootstrap.events``, so it works
before any ``subscribe_events`` call (e.g. restoring state after a
reload). Returns ``[]`` until ``update_public()`` has primed the
public bootstrap.
"""
if self._public_bootstrap is None:
return []
if self._event_dispatcher is None:
# Local import to avoid circular import (events.dispatcher → api).
from .events.dispatcher import EventDispatcher # noqa: PLC0415
self._event_dispatcher = EventDispatcher(self)
return self._event_dispatcher.active_events(device_id=device_id)
def _adapt_events_ws_message(self, msg: WSSubscriptionMessage) -> None:
if self._event_dispatcher is None:
return
dispatcher = self._event_dispatcher
old_obj = msg.old_obj if isinstance(msg.old_obj, PublicEvent) else None
if msg.action in (WSAction.ADD, WSAction.UPDATE):
if msg.new_obj is None:
# Benign in normal desync/reconnect: an UPDATE for an event id
# the store has not cached (or a merge/construct failure already
# surfaced by PublicBootstrap) arrives without a merged Event.
# Not a server contract change — drop quietly.
event_id = msg.changed_data.get("id") if msg.changed_data else None
_LOGGER.debug(
"Events-WS %s without merged Event obj (id=%s) — dropping frame",
msg.action,
event_id,
)
return
if not isinstance(msg.new_obj, PublicEvent):
# A merged object of the wrong type is a genuine shape violation.
_LOGGER.warning(
"Events-WS %s merged obj is not an Event — dropping frame"
" (possible server contract change)",
msg.action,
)
return
action = msg.action
if action is WSAction.UPDATE and old_obj is None:
# Born-closed event: the store synthesized a brand-new Event
# from a lone ``update`` (no preceding ``add``). A normal
# unknown-id update yields no merged Event and was dropped
# above, so a merged Event with no prior snapshot can only be
# this promotion. Treat it as an ADD so the close-window logic
# emits STARTED + ENDED.
action = WSAction.ADD
dispatcher.dispatch(action, msg.new_obj, old_obj)
return
if msg.action is WSAction.REMOVE:
if old_obj is None:
# The store handed back no pre-removal object — a remove for
# an event we never cached.
event_id = msg.changed_data.get("id") if msg.changed_data else None
self._events_remove_unknown_count += 1
now = time.monotonic()
if now - self._events_remove_unknown_last_log >= 60.0:
_LOGGER.info(
"Events-WS remove for unknown event %s — skipping"
" (throttled, logged at most once per 60s; %d total"
" unknown removes so far)",
event_id,
self._events_remove_unknown_count,
)
self._events_remove_unknown_last_log = now
return
dispatcher.dispatch(WSAction.REMOVE, None, old_obj)
def subscribe_devices(
self,
callback: Callable[[ProtectDeviceChange], None],
) -> Callable[[], None]:
"""
Subscribe to typed public device-state lifecycle callbacks.
The device-side analog of ``subscribe_events``: each callback receives
a :class:`~uiprotect.devices.ProtectDeviceChange` describing an
``ADDED`` / ``UPDATED`` / ``REMOVED`` device, carrying the merged
public model (``None`` for ``REMOVED``) and, for ``UPDATED``, the
changed-field set. Single and bulk WS envelopes are expanded
transparently to one change per device; ``state`` / connection
transitions surface as ordinary ``UPDATED``s.
Requires ``update_public()`` to have primed the public bootstrap at
least once before subscribing (a ``RuntimeError`` is raised otherwise)
— the merged public models depend on that cache. Callers that need the
websocket live *during* priming should use the raw
``subscribe_devices_websocket`` instead, which has no such ordering
requirement.
The callback must not raise: an exception is caught and logged but
otherwise swallowed. ``device_mac`` resolves with eventual consistency
— a device not yet in the bootstrap yields ``None`` until the next
``update_public()`` / reconnect resync.
A revoked or invalid API key surfaces as ``WebsocketState.AUTH_FAILED``
via ``subscribe_devices_websocket_state`` after repeated 401 handshakes;
call ``set_api_key()`` with a fresh key to re-arm.
"""
if self._public_bootstrap is None:
raise RuntimeError(
"subscribe_devices() requires update_public() to have been called"
" at least once"
)
return self._register_device_subscriber(callback)
def _register_device_subscriber(
self,
callback: Callable[[ProtectDeviceChange], None],
) -> Callable[[], None]:
"""Wire a typed devices subscriber and connect the WS on the first one."""
# Local import to avoid circular import (devices.dispatcher → api).
from .devices.dispatcher import DeviceDispatcher # noqa: PLC0415
if self._device_dispatcher is None:
self._device_dispatcher = DeviceDispatcher(self)
dispatcher = self._device_dispatcher
first = dispatcher.subscriber_count == 0
dispatcher.add_subscriber(callback)
if first:
self._device_ws_adapter_unsub = self.subscribe_devices_websocket(
self._adapt_devices_ws_message
)
return partial(self._unsubscribe_devices, callback)
async def subscribe_devices_and_prime(
self,
callback: Callable[[ProtectDeviceChange], None],
) -> Callable[[], None]:
"""
Subscribe to typed device changes and prime the public bootstrap,
order-independent.
Connects the devices websocket *before* priming, then awaits
:meth:`update_public`. Frames arriving during the prime are buffered and
replayed onto the fresh snapshot, so no update is lost regardless of
when the caller invokes this — it makes the prime/subscribe ordering
question moot. Unlike :meth:`subscribe_devices`, it does not require the
bootstrap to be primed beforehand.
Returns the same unsubscribe callable as :meth:`subscribe_devices`. If
priming raises (e.g. :class:`NotAuthorized`), the subscription is rolled
back before the error propagates.
"""
unsub = self._register_device_subscriber(callback)
try:
await self.update_public()
except BaseException:
unsub()
raise
return unsub
def _unsubscribe_devices(
self,
callback: Callable[[ProtectDeviceChange], None],
) -> None:
if self._device_dispatcher is None:
return
self._device_dispatcher.remove_subscriber(callback)
if self._device_dispatcher.subscriber_count == 0:
unsub = getattr(self, "_device_ws_adapter_unsub", None)
if unsub is not None:
unsub()
self._device_ws_adapter_unsub = None
def _adapt_devices_ws_message(self, msg: WSSubscriptionMessage) -> None:
if self._device_dispatcher is None:
return
self._device_dispatcher.dispatch(msg)
def _unsubscribe_websocket(
self,
ws_callback: Callable[[WSSubscriptionMessage], None],
) -> None:
"""Unsubscribe to websocket events."""
_LOGGER.debug("Removing subscription: %s", ws_callback)
self._ws_subscriptions.remove(ws_callback)
if not self._ws_subscriptions:
self._get_websocket().stop()
def _unsubscribe_events_websocket(
self,
ws_callback: Callable[[WSSubscriptionMessage], None],
) -> None:
"""Unsubscribe to events websocket events."""
_LOGGER.debug("Removing events subscription: %s", ws_callback)
self._events_ws_subscriptions.remove(ws_callback)
if not self._events_ws_subscriptions:
self._get_events_websocket().stop()
def _unsubscribe_devices_websocket(
self,
ws_callback: Callable[[WSSubscriptionMessage], None],
) -> None:
"""Unsubscribe to devices websocket events."""
_LOGGER.debug("Removing devices subscription: %s", ws_callback)
self._devices_ws_subscriptions.remove(ws_callback)
if not self._devices_ws_subscriptions:
self._get_devices_websocket().stop()
def subscribe_websocket_state(
self,
ws_callback: Callable[[WebsocketState], None],
) -> Callable[[], None]:
"""
Subscribe to websocket state changes.
Returns a callback that will unsubscribe.
"""
self._ws_state_subscriptions.append(ws_callback)
return partial(self._unsubscribe_websocket_state, ws_callback)
def subscribe_events_websocket_state(
self,
ws_callback: Callable[[WebsocketState], None],
) -> Callable[[], None]:
"""
Subscribe to events websocket state changes.
Returns a callback that will unsubscribe.
"""
self._events_ws_state_subscriptions.append(ws_callback)
return partial(self._unsubscribe_events_websocket_state, ws_callback)
def subscribe_devices_websocket_state(
self,
ws_callback: Callable[[WebsocketState], None],
) -> Callable[[], None]:
"""
Subscribe to devices websocket state changes.
Returns a callback that will unsubscribe.
"""
self._devices_ws_state_subscriptions.append(ws_callback)
return partial(self._unsubscribe_devices_websocket_state, ws_callback)
def _unsubscribe_websocket_state(
self,
ws_callback: Callable[[WebsocketState], None],
) -> None:
"""Unsubscribe to websocket state changes."""
self._ws_state_subscriptions.remove(ws_callback)
def _unsubscribe_events_websocket_state(
self,
ws_callback: Callable[[WebsocketState], None],
) -> None:
"""Unsubscribe to events websocket state changes."""
self._events_ws_state_subscriptions.remove(ws_callback)
def _unsubscribe_devices_websocket_state(
self,
ws_callback: Callable[[WebsocketState], None],
) -> None:
"""Unsubscribe to devices websocket state changes."""
self._devices_ws_state_subscriptions.remove(ws_callback)
def _on_websocket_state_change(self, state: WebsocketState) -> None:
"""Websocket state changed."""
super()._on_websocket_state_change(state)
for sub in self._ws_state_subscriptions:
try:
sub(state)
except Exception:
_LOGGER.exception("Exception while running websocket state handler")
def _on_events_websocket_state_change(self, state: WebsocketState) -> None:
"""Events Websocket state changed."""
# Events (motion / smart / sensor / …) arrive *only* on this WS. On a
# reconnect an END missed during the gap leaves the event active, so a
# camera's derived ``is_*_currently_detected`` sticks ON until the TTL
# sweep (~45 min worst case). Force-end active detection events (and
# flush other stale channels) so the derived state clears immediately.
# No-op on the first connect and when the cache was never materialised.
if state is WebsocketState.CONNECTED:
if not self._events_ws_has_been_connected:
self._events_ws_has_been_connected = True
elif self._public_bootstrap is not None:
# Materialise the dispatcher if needed: the force-end fixes the
# camera model + emits devices-WS updates, which matter to
# subscribe_devices / pull consumers even with no events
# subscriber.
if self._event_dispatcher is None:
from .events.dispatcher import ( # noqa: PLC0415
EventDispatcher,
)
self._event_dispatcher = EventDispatcher(self)
# Never let a raising force-end abort the CONNECTED state
# delivery below — subscribers must always see the transition.
try:
count = self._event_dispatcher.force_end_on_events_reconnect()
if count > 0:
_LOGGER.warning(
"Events websocket reconnected after gap; some frames"
" may have been missed (force-ended %d active/stale"
" events).",
count,
)
except Exception:
_LOGGER.exception(
"Exception while force-ending events on events"
" websocket reconnect"
)
for sub in self._events_ws_state_subscriptions:
try:
sub(state)
except Exception:
_LOGGER.exception(
"Exception while running events websocket state handler"
)
def _on_devices_websocket_state_change(self, state: WebsocketState) -> None:
"""Devices Websocket state changed."""
# On *reconnect* any add/update/remove emitted while we were offline
# is lost — schedule a background `update_public()` to re-sync the
# public bootstrap cache. No-op when the cache was never
# materialised (callers not using the cache) or
# on the very first connect (the caller is expected to prime the
# cache via `update_public()` themselves). Flapping websockets are
# debounced via :attr:`PUBLIC_RESYNC_MIN_INTERVAL` so a reconnect
# storm collapses into a single refresh.
if state is WebsocketState.CONNECTED:
# Re-arm the siren timers from the cached status immediately: the
# resync that would refresh it is debounced and may not run at all,
# while the deadlines already in hand stay valid across the gap.
if self._public_bootstrap is not None:
self._reschedule_siren_offs(self._public_bootstrap)
if not self._devices_ws_has_been_connected:
self._devices_ws_has_been_connected = True
elif self._public_bootstrap is not None:
if (
self._public_resync_task is not None
and not self._public_resync_task.done()
):
self._public_resync_pending = True
else:
now = time.monotonic()
if now - self._last_public_resync >= PUBLIC_RESYNC_MIN_INTERVAL:
# Deliberately updated *before* the task runs (not after
# success) so a flapping WS combined with a persistently
# failing NVR cannot spin up a continuous resync storm.
# Trade-off: a single failed attempt suppresses the next
# reconnect within the debounce window.
self._last_public_resync = now
self._public_resync_task = asyncio.create_task(
self._resync_public_bootstrap()
)
else:
_LOGGER.debug(
"Skipping public bootstrap resync (debounced, last was %.1fs ago)",
now - self._last_public_resync,
)
self._flush_stale_events_on_reconnect()
else:
# The cached siren status stops being live once the frames do, so
# the armed expiry timers stop being trustworthy. Drop them; the
# reconnect resync re-arms from the refreshed snapshot.
self._cancel_all_siren_offs()
for sub in self._devices_ws_state_subscriptions:
try:
sub(state)
except Exception:
_LOGGER.exception(
"Exception while running devices websocket state handler"
)
def _flush_stale_events_on_reconnect(self) -> None:
"""
Force-end events that stayed open across a devices-websocket gap.
The reconnect resync refreshes identity/devices; events arrive only via
WS, so this sweep is what guarantees no stuck-active sensor. Gated on a
live subscriber so a reconnect after the last unsubscribe does not
mutate the shared event store.
"""
if (
self._event_dispatcher is None
or self._event_dispatcher.subscriber_count == 0
):
return
# Guard the flush so a raising sweep cannot skip the CONNECTED state
# delivery to devices-WS subscribers.
try:
count = self._event_dispatcher.flush_stale_on_reconnect()
except Exception:
_LOGGER.exception(
"Exception while flushing stale events on devices websocket reconnect"
)
else:
if count > 0:
_LOGGER.warning(
"Websocket reconnected after gap; some events may"
" have been missed (force-ended %d stale active"
" events).",
count,
)
async def _resync_public_bootstrap(self) -> None:
"""Re-sync the public bootstrap cache after a websocket reconnect."""
try:
await self.update_public()
# A reconnect gap can hide a full camera flap (the disconnect *and*
# the reconnect both missed), which rotates the ``rtsp_alias``
# without leaving a visible state transition for the WS-path
# refresh to catch. Re-fetch every camera's already-populated RTSPS
# streams in place so synchronous consumers reading
# ``camera.rtsps_streams`` never see an emptied field — the stale
# URLs are kept until the fresh ones overwrite them.
await self._refresh_all_cached_rtsps()
except Exception:
_LOGGER.exception("Failed to resync public bootstrap after reconnect")
finally:
if self._public_resync_pending:
self._public_resync_pending = False
self._last_public_resync = time.monotonic()
self._public_resync_task = asyncio.create_task(
self._resync_public_bootstrap()
)
def _schedule_rtsps_refresh(self, camera_id: str) -> None:
"""
Schedule a coalesced re-fetch of a cached camera's RTSPS streams.
Handles both a *refresh* of an already-populated camera and a *prime*
of a present-but-streamless one — a camera offline at
``update_public()`` time and brought online via the devices WS gets
filled here, not just on the next full resync.
"""
pb = self._public_bootstrap
if pb is None:
return
camera = pb.cameras.get(camera_id)
if camera is None:
return
existing = self._rtsps_refresh_tasks.get(camera_id)
if existing is not None and not existing.done():
return
self._rtsps_refresh_tasks[camera_id] = asyncio.create_task(
self._refresh_camera_rtsps(camera_id)
)
async def _refresh_camera_rtsps(self, camera_id: str) -> None:
"""Re-fetch one camera's RTSPS streams, writing the cached field in place."""
pb = self._public_bootstrap
camera = pb.cameras.get(camera_id) if pb is not None else None
initial = camera.rtsps_streams if camera is not None else None
try:
streams = await self.get_camera_rtsps_streams(camera_id)
except Exception:
_LOGGER.exception(
"Failed to refresh RTSPS streams for camera %s", camera_id
)
else:
# Write back only if the *same* camera instance is still cached and
# its ``rtsps_streams`` is the same object captured before the fetch.
# Every mutation (prime, create, delete) replaces the object, so an
# identity mismatch means a concurrent write landed mid-flight —
# back off rather than resurrect (delete) or clobber (create) it.
# The check and the write share no await, so they cannot race the
# eviction.
pb = self._public_bootstrap
if (
streams is not None
and pb is not None
and camera is not None
and pb.cameras.get(camera_id) is camera
and camera.rtsps_streams is initial
):
camera.rtsps_streams = streams
# The write is otherwise silent: no wire frame announces a
# library-primed field. Emit a synthetic devices-WS ``update``
# so subscribers observe the streams becoming available through
# the existing channel. Suppress it when the re-fetch is equal to
# the cached value so a no-op refresh stays quiet.
if streams != initial:
self.emit_devices_message(self._build_rtsps_update(camera, streams))
finally:
self._rtsps_refresh_tasks.pop(camera_id, None)
@staticmethod
def _build_rtsps_update(
camera: PublicCamera, streams: RTSPSStreams
) -> WSSubscriptionMessage:
"""Build a synthetic devices-WS ``update`` announcing a camera's streams."""
return WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=camera.id,
changed_data={
"modelKey": ModelType.CAMERA.value,
"id": camera.id,
"rtsps_streams": streams.unifi_dict(),
},
new_obj=camera,
old_obj=None,
)
def _cancel_rtsps_refresh(self, camera_id: str) -> None:
"""Cancel a pending background RTSPS refresh for a camera, if any."""
task = self._rtsps_refresh_tasks.pop(camera_id, None)
if task is not None and not task.done():
task.cancel()
def _schedule_siren_off(self, siren_id: str) -> None:
"""
Arm (or re-arm) the announcement of a timed siren run ending.
The console emits no frame when the run expires, so the deadline
derived from ``sirenStatus`` is the only signal there is. Any timer
already armed for the siren is dropped first: the newest status wins.
An idle siren (no ``turn_off_at``) simply disarms, and so does a call
from outside a running event loop.
"""
pb = self._public_bootstrap
siren = pb.sirens.get(siren_id) if pb is not None else None
self._cancel_siren_off(siren_id)
if siren is None:
return
turn_off_at = siren.siren_status.turn_off_at
if turn_off_at is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# ``process_devices_ws_message`` is synchronous and may be driven
# from outside the loop; a caller with no loop cannot be served a
# timer anyway.
_LOGGER.debug("No running event loop, siren %s expiry not armed", siren_id)
return
delay = (turn_off_at - datetime.now(UTC)).total_seconds()
# A deadline already in the past still goes through the task rather
# than announcing inline, so the synthetic frame lands *after* the
# frame that revealed the expired run instead of ahead of it.
self._siren_off_tasks[siren_id] = loop.create_task(
self._announce_siren_off_at(siren_id, turn_off_at, max(delay, 0.0))
)
async def _announce_siren_off_at(
self, siren_id: str, turn_off_at: datetime, delay: float
) -> None:
"""Wait out a siren's remaining run time, then announce it stopped."""
try:
await asyncio.sleep(delay)
pb = self._public_bootstrap
siren = pb.sirens.get(siren_id) if pb is not None else None
# Announce only while the cached status still describes the run this
# task was armed for. A snapshot merged mid-sleep may carry a later
# run, and clearing its flag would report a playing siren as stopped
# with nothing left to re-arm.
if siren is not None and siren.siren_status.turn_off_at == turn_off_at:
self._announce_siren_off(siren)
finally:
# A re-arm cancels this task and stores its replacement under the
# same key before this cleanup runs, so only clear the slot while
# it still points at this task.
if self._siren_off_tasks.get(siren_id) is asyncio.current_task():
del self._siren_off_tasks[siren_id]
def _announce_siren_off(self, siren: Siren) -> None:
"""Fold the expiry into the cached status and emit it like a server frame."""
# The timing fields are left as the server set them (a manual stop
# leaves them populated too); clearing the flag is what makes
# ``turn_off_at`` — and so this announcement — idempotent.
siren.siren_status.is_active = False
if self._devices_ws_filtered_out(ModelType.SIREN):
return
self.emit_devices_message(self._build_siren_off_update(siren))
@staticmethod
def _build_siren_off_update(siren: Siren) -> WSSubscriptionMessage:
"""Build a synthetic devices-WS ``update`` announcing a siren stopped."""
return WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=siren.id,
changed_data={
"modelKey": ModelType.SIREN.value,
"id": siren.id,
"sirenStatus": siren.siren_status.unifi_dict(),
},
new_obj=siren,
old_obj=None,
)
def _cancel_siren_off(self, siren_id: str) -> None:
"""Cancel a pending siren expiry announcement, if any."""
task = self._siren_off_tasks.pop(siren_id, None)
if task is not None and not task.done():
task.cancel()
def _reschedule_siren_offs(self, pb: PublicBootstrap) -> None:
"""Re-arm every siren expiry announcement against a freshly applied snapshot."""
for siren_id in list(self._siren_off_tasks):
if siren_id not in pb.sirens:
self._cancel_siren_off(siren_id)
for siren_id in pb.sirens:
self._schedule_siren_off(siren_id)
async def _refresh_all_cached_rtsps(self) -> None:
"""Re-fetch every camera that already has RTSPS streams in place, coalesced."""
pb = self._public_bootstrap
if pb is None:
return
for camera_id, camera in list(pb.cameras.items()):
if camera.rtsps_streams is not None:
self._schedule_rtsps_refresh(camera_id)
pending = [
task for task in self._rtsps_refresh_tasks.values() if not task.done()
]
if pending:
await asyncio.gather(*pending, return_exceptions=True)
async def get_bootstrap(self) -> Bootstrap:
"""
Gets bootstrap object from UFP instance
This is a great alternative if you need metadata about the NVR without connecting to the Websocket
"""
if self._public_only:
raise PublicOnlyModeError(
"Private bootstrap is unavailable on a public-only client; "
"use update_public() instead"
)
data = await self.api_request_obj("bootstrap")
await _async_warm_nvr_timezone(data["nvr"])
return Bootstrap.from_unifi_dict(**data, api=self)
async def get_devices_raw(self, model_type: ModelType) -> list[dict[str, Any]]:
"""Gets a raw device list given a model_type"""
return await self.api_request_list(model_type.devices_key)
async def get_devices(
self,
model_type: ModelType,
expected_type: type[ProtectModel] | None = None,
) -> list[ProtectModel]:
"""Gets a device list given a model_type, converted into Python objects"""
objs: list[ProtectModel] = []
for obj_dict in await self.get_devices_raw(model_type):
obj = create_from_unifi_dict(obj_dict, api=self)
if expected_type is not None and not isinstance(obj, expected_type):
raise NvrError(f"Unexpected model returned: {obj.model}")
if (
self.ignore_unadopted
and isinstance(obj, ProtectAdoptableDeviceModel)
and not obj.is_adopted
):
continue
objs.append(obj)
return objs
async def get_cameras(self) -> list[Camera]:
"""
Gets the list of cameras straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.cameras`
"""
return cast("list[Camera]", await self.get_devices(ModelType.CAMERA, Camera))
async def get_lights(self) -> list[Light]:
"""
Gets the list of lights straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.lights`
.. deprecated::
Use :meth:`get_lights_public` instead. This method uses the private API
and will be removed in a future version.
"""
return cast("list[Light]", await self.get_devices(ModelType.LIGHT, Light))
async def get_sensors(self) -> list[Sensor]:
"""
Gets the list of sensors straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.sensors`
"""
return cast("list[Sensor]", await self.get_devices(ModelType.SENSOR, Sensor))
async def get_chimes(self) -> list[Chime]:
"""
Gets the list of chimes straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.chimes`
"""
return cast("list[Chime]", await self.get_devices(ModelType.CHIME, Chime))
async def get_aiports(self) -> list[AiPort]:
"""
Gets the list of aiports straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.aiports`
"""
return cast("list[AiPort]", await self.get_devices(ModelType.AIPORT, AiPort))
async def get_viewers(self) -> list[Viewer]:
"""
Gets the list of viewers straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.viewers`
"""
return cast("list[Viewer]", await self.get_devices(ModelType.VIEWPORT, Viewer))
async def get_bridges(self) -> list[Bridge]:
"""
Gets the list of bridges straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.bridges`
"""
return cast("list[Bridge]", await self.get_devices(ModelType.BRIDGE, Bridge))
async def get_liveviews(self) -> list[Liveview]:
"""
Gets the list of liveviews straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews`
"""
return cast(
"list[Liveview]",
await self.get_devices(ModelType.LIVEVIEW, Liveview),
)
async def get_device_raw(
self,
model_type: ModelType,
device_id: str,
) -> dict[str, Any]:
"""Gets a raw device give the device model_type and id"""
return await self.api_request_obj(f"{model_type.value}s/{device_id}")
async def get_device(
self,
model_type: ModelType,
device_id: str,
expected_type: type[ProtectModelWithId] | None = None,
) -> ProtectModelWithId:
"""Gets a device give the device model_type and id, converted into Python object"""
obj = create_from_unifi_dict(
await self.get_device_raw(model_type, device_id),
api=self,
)
if expected_type is not None and not isinstance(obj, expected_type):
raise NvrError(f"Unexpected model returned: {obj.model}")
if (
self.ignore_unadopted
and isinstance(obj, ProtectAdoptableDeviceModel)
and not obj.is_adopted
):
raise NvrError("Device is not adopted")
return cast("ProtectModelWithId", obj)
async def get_nvr(self) -> NVR:
"""
Gets an NVR object straight from the NVR.
This is a great alternative if you need metadata about the NVR without connecting to the Websocket
"""
data = await self.api_request_obj("nvr")
await _async_warm_nvr_timezone(data)
return NVR.from_unifi_dict(**data, api=self)
async def get_event(self, event_id: str) -> Event:
"""
Gets an event straight from the NVR.
This is a great alternative if the event is no longer in the `self.bootstrap.events[event_id]` cache
"""
return cast("Event", await self.get_device(ModelType.EVENT, event_id, Event))
async def get_camera(self, device_id: str) -> Camera:
"""
Gets a camera straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.cameras[device_id]`
"""
return cast(
"Camera", await self.get_device(ModelType.CAMERA, device_id, Camera)
)
async def get_light(self, device_id: str) -> Light:
"""
Gets a light straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.lights[device_id]`
.. deprecated::
Use :meth:`get_light_public` instead. This method uses the private API
and will be removed in a future version.
"""
return cast("Light", await self.get_device(ModelType.LIGHT, device_id, Light))
async def get_sensor(self, device_id: str) -> Sensor:
"""
Gets a sensor straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.sensors[device_id]`
"""
return cast(
"Sensor", await self.get_device(ModelType.SENSOR, device_id, Sensor)
)
async def get_chime(self, device_id: str) -> Chime:
"""
Gets a chime straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.chimes[device_id]`
"""
return cast("Chime", await self.get_device(ModelType.CHIME, device_id, Chime))
async def get_aiport(self, device_id: str) -> AiPort:
"""
Gets a AiPort straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.aiport[device_id]`
"""
return cast(
"AiPort", await self.get_device(ModelType.AIPORT, device_id, AiPort)
)
async def get_viewer(self, device_id: str) -> Viewer:
"""
Gets a viewer straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.viewers[device_id]`
"""
return cast(
"Viewer",
await self.get_device(ModelType.VIEWPORT, device_id, Viewer),
)
async def get_bridge(self, device_id: str) -> Bridge:
"""
Gets a bridge straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.bridges[device_id]`
"""
return cast(
"Bridge", await self.get_device(ModelType.BRIDGE, device_id, Bridge)
)
async def get_liveview(self, device_id: str) -> Liveview:
"""
Gets a liveview straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews[device_id]`
"""
return cast(
"Liveview",
await self.get_device(ModelType.LIVEVIEW, device_id, Liveview),
)
async def get_camera_snapshot(
self,
camera_id: str,
width: int | None = None,
height: int | None = None,
dt: datetime | None = None,
) -> bytes | None:
"""
Gets snapshot for a camera.
Datetime of screenshot is approximate. It may be +/- a few seconds.
"""
params: dict[str, Any] = {}
if dt is not None:
path = "recording-snapshot"
params["ts"] = to_js_time(dt)
else:
path = "snapshot"
params["ts"] = int(time.time() * 1000)
params["force"] = "true"
if width is not None:
params["w"] = width
if height is not None:
params["h"] = height
return await self.api_request_raw(
f"cameras/{camera_id}/{path}",
params=params,
raise_exception=False,
)
async def get_public_api_camera_snapshot(
self,
camera_id: str,
high_quality: bool = False,
package: bool = False,
) -> bytes | None:
"""
Gets snapshot for a camera using public api.
Args:
camera_id: Camera ID.
high_quality: Force 1080P+ resolution.
package: If ``True``, fetch from the package camera (only supported
on cameras with ``hasPackageCamera: true``). Requires Protect
on the NVR.
"""
params: dict[str, Any] = {"highQuality": pybool_to_json_bool(high_quality)}
if package:
params["channel"] = "package"
return await self.api_request_raw(
public_api=True,
raise_exception=False,
url=f"/v1/cameras/{camera_id}/snapshot",
params=params,
)
async def create_camera_rtsps_streams(
self,
camera_id: str,
qualities: list[ChannelQuality | str] | ChannelQuality | str,
) -> RTSPSStreams | None:
"""
Creates RTSPS streams for a camera using public API.
On success the result is written through to the matching cached
:class:`PublicCamera`'s ``rtsps_streams`` field (when
``update_public()`` has primed it), since stream creation is not
signalled over the websocket.
"""
if isinstance(qualities, str):
qualities = [qualities]
data = {"qualities": [str(q) for q in qualities]}
response = await self.api_request_raw(
public_api=True,
url=f"/v1/cameras/{camera_id}/rtsps-stream",
method="POST",
json=data,
)
if response is None:
return None
try:
response_json = orjson.loads(response)
streams = RTSPSStreams(api=self, **response_json)
except (orjson.JSONDecodeError, TypeError) as ex:
_LOGGER.error(
"Could not decode JSON response for create RTSPS streams (camera %s): %s",
camera_id,
ex,
)
return None
pb = self._public_bootstrap
if pb is not None:
camera = pb.cameras.get(camera_id)
if camera is not None:
camera.rtsps_streams = streams
return streams
async def get_camera_rtsps_streams(
self,
camera_id: str,
) -> RTSPSStreams | None:
"""
Fetch a camera's RTSPS streams from the public API.
A flag-free fetch primitive with no cache side-effects: caching policy
lives in the population layer (:meth:`update_public` priming, the
WS-reconnect refresh, and create/delete write-through).
"""
response = await self.api_request_raw(
public_api=True,
url=f"/v1/cameras/{camera_id}/rtsps-stream",
method="GET",
)
if response is None:
return None
try:
response_json = orjson.loads(response)
return RTSPSStreams(api=self, **response_json)
except (orjson.JSONDecodeError, TypeError) as ex:
_LOGGER.error(
"Could not decode JSON response for get RTSPS streams (camera %s): %s",
camera_id,
ex,
)
return None
async def delete_camera_rtsps_streams(
self,
camera_id: str,
qualities: list[ChannelQuality | str] | ChannelQuality | str,
) -> bool:
"""
Deletes RTSPS streams for a camera using public API.
On success the deleted qualities are removed from the matching cached
:class:`PublicCamera`'s ``rtsps_streams`` (when present); a camera left
with no streams has the field cleared to ``None``.
"""
if isinstance(qualities, str):
qualities = [qualities]
quality_strs = [str(quality) for quality in qualities]
# Build query parameters for qualities
params = [("qualities", quality) for quality in quality_strs]
response = await self.api_request_raw(
public_api=True,
url=f"/v1/cameras/{camera_id}/rtsps-stream",
method="DELETE",
params=params,
)
success = response is not None
if success and self._public_bootstrap is not None:
camera = self._public_bootstrap.cameras.get(camera_id)
cached = camera.rtsps_streams if camera is not None else None
if camera is not None and cached is not None:
# Replace (never mutate in place) so every write to
# ``rtsps_streams`` swaps the object — that single invariant lets
# the in-flight refresh self-guard on object identity. Survivors
# are the *available* (keyed) qualities, dropping inactive
# keys too; an empty set clears the field to ``None``.
extra = cached.__pydantic_extra__ or {}
survivors = {
quality: url
for quality, url in extra.items()
if quality not in quality_strs
}
camera.rtsps_streams = (
RTSPSStreams(api=self, **survivors) if survivors else None
)
return success
async def get_package_camera_snapshot(
self,
camera_id: str,
width: int | None = None,
height: int | None = None,
dt: datetime | None = None,
) -> bytes | None:
"""
Gets snapshot from the package camera.
Datetime of screenshot is approximate. It may be +/- a few seconds.
"""
params: dict[str, Any] = {}
if dt is not None:
path = "recording-snapshot"
params["ts"] = to_js_time(dt)
params["lens"] = 2
else:
path = "package-snapshot"
params["ts"] = int(time.time() * 1000)
params["force"] = "true"
if width is not None:
params["w"] = width
if height is not None:
params["h"] = height
return await self.api_request_raw(
f"cameras/{camera_id}/{path}",
params=params,
raise_exception=False,
)
async def _stream_response(
self,
response: aiohttp.ClientResponse,
chunk_size: int,
iterator_callback: IteratorCallback | None = None,
progress_callback: ProgressCallback | None = None,
) -> None:
total = response.content_length or 0
current = 0
if iterator_callback is not None:
await iterator_callback(total, None)
async for chunk in response.content.iter_chunked(chunk_size):
step = len(chunk)
current += step
if iterator_callback is not None:
await iterator_callback(total, chunk)
if progress_callback is not None:
await progress_callback(step, current, total)
async def get_camera_video(
self,
camera_id: str,
start: datetime,
end: datetime,
channel_index: int = 0,
validate_channel_id: bool = True,
output_file: Path | None = None,
iterator_callback: IteratorCallback | None = None,
progress_callback: ProgressCallback | None = None,
chunk_size: int = 65536,
fps: int | None = None,
) -> bytes | None:
"""
Exports MP4 video from a given camera at a specific time.
Start/End of video export are approximate. It may be +/- a few seconds.
It is recommended to provide a output file or progress callback for larger
video clips, otherwise the full video must be downloaded to memory before
being written.
Providing the `fps` parameter creates a "timelapse" export with the given FPS
value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x
(fps=20), and 600x (fps=40).
"""
if validate_channel_id and self._bootstrap is not None:
camera = self._bootstrap.cameras[camera_id]
try:
camera.channels[channel_index]
except IndexError as e:
raise BadRequest from e
params = {
"camera": camera_id,
"start": to_js_time(start),
"end": to_js_time(end),
}
if fps is not None:
params["fps"] = fps
params["type"] = "timelapse"
if channel_index == 3:
params.update({"lens": 2})
else:
params.update({"channel": channel_index})
path = "video/export"
if (
iterator_callback is None
and progress_callback is None
and output_file is None
):
return await self.api_request_raw(
path,
params=params,
raise_exception=False,
)
_LOGGER.debug(
"Requesting camera video: %s%s %s", self.private_api_path, path, params
)
r = await self.request(
"get",
f"{self.private_api_path}{path}",
auto_close=False,
timeout=0,
params=params,
)
if r.status != 200:
await self._raise_for_status(r, True)
if output_file is not None:
async with aiofiles.open(output_file, "wb") as output:
async def callback(total: int, chunk: bytes | None) -> None:
if iterator_callback is not None:
await iterator_callback(total, chunk)
if chunk is not None:
await output.write(chunk)
await self._stream_response(r, chunk_size, callback, progress_callback)
else:
await self._stream_response(
r,
chunk_size,
iterator_callback,
progress_callback,
)
r.close()
return None
async def _get_image_with_retry(
self,
path: str,
retry_timeout: int = RETRY_TIMEOUT,
**kwargs: Any,
) -> bytes | None:
"""
Retries image request until it returns or timesout. Used for event images like thumbnails and heatmaps.
Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
your retry timeout will always return None.
"""
now = time.monotonic()
timeout = now + retry_timeout
data: bytes | None = None
while data is None and now < timeout:
data = await self.api_request_raw(path, raise_exception=False, **kwargs)
if data is None:
await asyncio.sleep(0.5)
now = time.monotonic()
return data
async def get_event_thumbnail(
self,
thumbnail_id: str,
width: int | None = None,
height: int | None = None,
retry_timeout: int = RETRY_TIMEOUT,
) -> bytes | None:
"""
Gets given thumbanail from a given event.
Thumbnail response is a JPEG image.
Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
your retry timeout will always return 404.
"""
params: dict[str, Any] = {}
if width is not None:
params.update({"w": width})
if height is not None:
params.update({"h": height})
# old thumbnail URL use thumbnail ID, which is just `e-{event_id}`
thumbnail_id = thumbnail_id.removeprefix("e-")
return await self._get_image_with_retry(
f"events/{thumbnail_id}/thumbnail",
params=params,
retry_timeout=retry_timeout,
)
async def get_event_animated_thumbnail(
self,
thumbnail_id: str,
width: int | None = None,
height: int | None = None,
*,
speedup: int = 10,
retry_timeout: int = RETRY_TIMEOUT,
) -> bytes | None:
"""
Gets given animated thumbanil from a given event.
Animated thumbnail response is a GIF image.
Note: thumbnails / do not generate _until after the event ends_. Events that last longer then
your retry timeout will always return 404.
"""
params: dict[str, Any] = {
"keyFrameOnly": "true",
"speedup": speedup,
}
if width is not None:
params.update({"w": width})
if height is not None:
params.update({"h": height})
# old thumbnail URL use thumbnail ID, which is just `e-{event_id}`
thumbnail_id = thumbnail_id.removeprefix("e-")
return await self._get_image_with_retry(
f"events/{thumbnail_id}/animated-thumbnail",
params=params,
retry_timeout=retry_timeout,
)
async def get_event_heatmap(
self,
heatmap_id: str,
retry_timeout: int = RETRY_TIMEOUT,
) -> bytes | None:
"""
Gets given heatmap from a given event.
Heatmap response is a PNG image.
Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
your retry timeout will always return None.
"""
# old heatmap URL use heatmap ID, which is just `e-{event_id}`
heatmap_id = heatmap_id.removeprefix("e-")
return await self._get_image_with_retry(
f"events/{heatmap_id}/heatmap",
retry_timeout=retry_timeout,
)
async def get_event_smart_detect_track_raw(self, event_id: str) -> dict[str, Any]:
"""Gets raw Smart Detect Track for a Smart Detection"""
return await self.api_request_obj(f"events/{event_id}/smartDetectTrack")
async def get_event_smart_detect_track(self, event_id: str) -> SmartDetectTrack:
"""Gets raw Smart Detect Track for a Smart Detection"""
data = await self.api_request_obj(f"events/{event_id}/smartDetectTrack")
return SmartDetectTrack.from_unifi_dict(api=self, **data)
async def update_device(
self,
model_type: ModelType,
device_id: str,
data: dict[str, Any],
) -> None:
"""
Sends an update for a device back to UFP
USE WITH CAUTION, all possible combinations of updating objects have not been fully tested.
May have unexpected side effects.
Tested updates have been added a methods on applicable devices.
"""
await self.api_request(
f"{model_type.value}s/{device_id}",
method="patch",
json=data,
)
async def update_nvr(self, data: dict[str, Any]) -> None:
"""
Sends an update for main UFP NVR device
USE WITH CAUTION, all possible combinations of updating objects have not been fully tested.
May have unexpected side effects.
Tested updates have been added a methods on applicable devices.
"""
await self.api_request("nvr", method="patch", json=data)
async def reboot_nvr(self) -> None:
"""Reboots NVR"""
await self.api_request("nvr/reboot", method="post")
async def reboot_device(self, model_type: ModelType, device_id: str) -> None:
"""Reboots an adopted device"""
await self.api_request(f"{model_type.value}s/{device_id}/reboot", method="post")
async def unadopt_device(self, model_type: ModelType, device_id: str) -> None:
"""Unadopt/Unmanage adopted device"""
await self.api_request(f"{model_type.value}s/{device_id}", method="delete")
async def adopt_device(self, model_type: ModelType, device_id: str) -> None:
"""Adopts a device"""
key = model_type.devices_key
data = await self.api_request_obj(
"devices/adopt",
method="post",
json={key: {device_id: {}}},
)
if not data.get(key, {}).get(device_id, {}).get("adopted", False):
raise BadRequest("Could not adopt device")
async def play_speaker(
self,
device_id: str,
*,
volume: int | None = None,
repeat_times: int | None = None,
ringtone_id: str | None = None,
track_no: int | None = None,
) -> None:
"""
Plays chime tones on a chime.
Args:
----
device_id: The chime device ID
volume: Volume level for playback (0-100)
repeat_times: Number of times to repeat the tone
ringtone_id: The ringtone ID (UUID) to play. Preferred over track_no.
track_no: Legacy track number from speakerTrackList.
.. deprecated::
Use ringtone_id instead. track_no maps to the speaker track
list but ringtone_id is the current API standard.
"""
data: dict[str, Any] | None = None
if (
volume is not None
or repeat_times is not None
or ringtone_id is not None
or track_no is not None
):
chime = self.bootstrap.chimes.get(device_id)
if chime is None:
raise BadRequest(f"Invalid chime ID {device_id}")
data = {
"volume": volume if volume is not None else chime.volume,
"repeatTimes": repeat_times
if repeat_times is not None
else chime.repeat_times,
}
if ringtone_id is not None:
data["ringtoneId"] = ringtone_id
elif track_no is not None:
warnings.warn(
"track_no is deprecated, use ringtone_id instead",
DeprecationWarning,
stacklevel=2,
)
data["trackNo"] = track_no
# If neither ringtone_id nor track_no provided, don't include in payload
await self.api_request(
f"chimes/{device_id}/play-speaker",
method="post",
json=data,
)
async def play_buzzer(self, device_id: str) -> None:
"""Plays chime tones on a chime"""
await self.api_request(f"chimes/{device_id}/play-buzzer", method="post")
async def set_light_is_led_force_on(
self, device_id: str, is_led_force_on: bool
) -> None:
"""
Sets isLedForceOn for light.
.. deprecated::
Use :meth:`update_light_public` instead. This method uses the private API
and will be removed in a future version.
"""
await self.api_request(
f"lights/{device_id}",
method="patch",
json={"lightOnSettings": {"isLedForceOn": is_led_force_on}},
)
async def clear_tamper_sensor(self, device_id: str) -> None:
"""Clears tamper status for sensor"""
await self.api_request(f"sensors/{device_id}/clear-tamper-flag", method="post")
async def _get_versions_from_api(
self,
url: str,
package: str = "unifi-protect",
) -> set[Version]:
session = await self.get_session()
versions: set[Version] = set()
try:
async with session.get(url) as response:
is_package = False
for line in (await response.text()).split("\n"):
if line.startswith("Package: "):
is_package = False
if line == f"Package: {package}":
is_package = True
if is_package and line.startswith("Version: "):
versions.add(Version(line.split(": ")[-1]))
except (
TimeoutError,
aiohttp.ServerDisconnectedError,
client_exceptions.ClientError,
) as err:
raise NvrError(f"Error packages from {url}: {err}") from err
return versions
async def create_api_key(self, name: str) -> str:
"""Create an API key with the given name and return the full API key."""
if not name:
raise BadRequest("API key name cannot be empty")
response = await self.api_request(
api_path="/proxy/users/api/v2",
url="/user/self/keys",
method="post",
json={"name": name},
)
if (
not isinstance(response, dict)
or "data" not in response
or not isinstance(response["data"], dict)
or "full_api_key" not in response["data"]
):
raise BadRequest("Failed to create API key")
return response["data"]["full_api_key"]
def set_api_key(self, api_key: str) -> None:
"""Set the API key for the NVR."""
if not api_key:
raise BadRequest("API key cannot be empty")
self._api_key = api_key
# The budget is per-key; a new key gets a fresh limiter (re-seeded from
# the next response's RateLimit-Policy header).
self._public_rate_limiter = PublicApiRateLimiter()
# A revoked key parks the public websockets on the long auth-failed
# backoff; a fresh key should recover them without waiting it out.
for websocket in (self._events_websocket, self._devices_websocket):
if websocket is not None:
websocket.reset_auth_failure()
def is_api_key_set(self) -> bool:
"""Check if the API key is set."""
return bool(self._api_key)
@public_get("/v1/meta/info", item=MetaInfo)
async def get_meta_info(self) -> MetaInfo:
"""Get NVR metadata via the Public Integration API."""
raise NotImplementedError
async def get_console_mac(self) -> str | None:
"""
Resolve the console/NVR mac via the UniFi-OS ``/api/system`` endpoint.
The public Protect Integration API only exposes the NVR ``mac`` on
Protect newer than 7.1; older firmware omits it (:attr:`PublicNVR.mac`
is then ``None``). This helper fills that gap so a :meth:`public_only`
client on older firmware can still derive the console's stable,
mac-based identity.
This is a **transitional workaround** for the hybrid/parallel phase in
which the private and public paths coexist: identity must stay
mac-based to match what the private path already produces, so a feature
toggling between the two paths doesn't churn entity ids. The end-state
identity is the public API's own primary key (``nvr.id``); this helper
is expected to be retired once the private path is dropped and
consumers migrate to ``id``.
``/api/system`` is an **off-contract UniFi-OS endpoint** (not the
public Protect API): unauthenticated, and it returns the mac of the
device at the configured Protect host. Returns the mac string (e.g.
``"AABBCCDDEEFF"``, matching the private ``NVR.mac`` format) or
``None`` when the endpoint is unreachable or carries no mac.
"""
# Off-contract UniFi-OS endpoint, the fallback when the bootstraps
# carry no NVR mac (older firmware). Unauthenticated; targets the
# configured Protect host. Transitional — retire once consumers
# migrate to nvr.id.
try:
data = await self.api_request(
url="/system",
api_path="/api",
require_auth=False,
raise_exception=False,
)
except (NvrError, TimeoutError) as err:
# A connection refusal surfaces as ClientError, which _do_request
# wraps into NvrError. A timeout (non-routable host / hanging
# connection) is not a ClientError and is never wrapped, so catch
# TimeoutError too to honour the "None when unreachable" contract.
_LOGGER.debug("Failed to resolve console mac from /api/system: %s", err)
return None
if not isinstance(data, dict):
return None
mac = data.get("mac")
return mac if isinstance(mac, str) and mac else None
async def resolve_nvr_mac(self) -> str | None:
"""
Resolve the NVR mac, normalized, by source priority: the public NVR
(cached ``_public_bootstrap`` if primed, else a direct ``/v1/nvrs``
fetch), then private bootstrap, then the ``/api/system`` console
fallback; ``None`` if none resolve.
"""
if self._public_bootstrap is not None:
nvr = self._public_bootstrap.nvr
if nvr is not None and nvr.mac:
return normalize_mac(nvr.mac)
else:
try:
public_nvr = await self.get_nvr_public()
except (ClientError, TimeoutError):
# The public session carries no ClientTimeout, so a hung
# /v1/nvrs raises a bare TimeoutError that is never wrapped
# into ClientError; fall through to the private/console tiers.
public_nvr = None
if public_nvr is not None and public_nvr.mac:
return normalize_mac(public_nvr.mac)
if self._bootstrap is not None and self._bootstrap.nvr.mac:
return normalize_mac(self._bootstrap.nvr.mac)
mac = await self.get_console_mac()
return normalize_mac(mac) if mac else None
# Public API Methods
@public_get("/v1/nvrs", item=PublicNVR)
async def get_nvr_public(self) -> PublicNVR:
"""Get NVR information using public API."""
raise NotImplementedError
@public_get("/v1/lights", items=PublicLight)
async def get_lights_public(self) -> list[PublicLight]:
"""Get all lights using public API."""
raise NotImplementedError
@public_get("/v1/lights/{light_id}", item=PublicLight)
async def get_light_public(self, light_id: str) -> PublicLight:
"""Get a specific light using public API."""
raise NotImplementedError
async def update_light_public(
self,
light_id: str,
*,
name: str | None = None,
is_light_force_enabled: bool | None = None,
light_mode_settings: LightModeSettings | PublicLightModeSettings | None = None,
light_device_settings: (
LightDeviceSettings | PublicLightDeviceSettings | None
) = None,
) -> PublicLight:
"""
Update light settings using public API.
Args:
----
light_id: The light's ID
name: Light name
is_light_force_enabled: Force enable the light
light_mode_settings: Light mode settings (mode, enable_at)
light_device_settings: Light device settings (LED level, PIR settings, etc.)
Returns:
-------
Updated Light object
Raises:
------
BadRequest: If no parameters are provided
"""
data: LightPatchRequest = {}
if name is not None:
data["name"] = name
if is_light_force_enabled is not None:
data["isLightForceEnabled"] = is_light_force_enabled
if light_mode_settings is not None:
data["lightModeSettings"] = light_mode_settings.unifi_dict()
if light_device_settings is not None:
# luxSensitivity may come from Private API but is not settable - filter it out
device_dict = light_device_settings.unifi_dict()
device_dict.pop("luxSensitivity", None)
data["lightDeviceSettings"] = device_dict
if not data:
raise BadRequest("At least one parameter must be provided")
result = await self.api_request_obj(
url=f"/v1/lights/{light_id}",
method="patch",
json=data,
public_api=True,
)
light = PublicLight.from_unifi_dict(**result, api=self)
self._write_through_public_twin(light)
return light
@public_get("/v1/cameras", items=PublicCamera)
async def get_cameras_public(self) -> list[PublicCamera]:
"""Get all cameras using public API."""
raise NotImplementedError
@public_get("/v1/cameras/{camera_id}", item=PublicCamera)
async def get_camera_public(self, camera_id: str) -> PublicCamera:
"""Get a specific camera using public API."""
raise NotImplementedError
async def update_camera_public(
self,
camera_id: str,
*,
name: str | None = None,
hdr_type: PublicHdrMode | None = None,
video_mode: VideoMode | None = None,
led_is_enabled: bool | None = None,
led_welcome_led: bool | None = None,
led_flood_led: bool | None = None,
mic_volume: int | None = None,
smart_detect_object_types: list[SmartDetectObjectType] | None = None,
smart_detect_audio_types: list[SmartDetectAudioType] | None = None,
lcd_message: CameraPublicApiLcdMessageRequest | None = None,
osd_name_enabled: bool | None = None,
osd_date_enabled: bool | None = None,
osd_logo_enabled: bool | None = None,
osd_nerd_mode_enabled: bool | None = None,
osd_overlay_location: OsdOverlayLocation | None = None,
) -> PublicCamera:
"""
Patch camera settings using public API.
Returns a fresh Camera object deserialized from the PATCH response. When
a public bootstrap is loaded and holds this camera, the response is also
written through into the cached twin (out-of-band ``rtsps_streams`` is
preserved); the private bootstrap, if any, is left to the private-tree
convenience methods.
"""
body = self._filter_none(
(
("name", name),
("hdrType", hdr_type),
("videoMode", video_mode),
("lcdMessage", lcd_message),
)
)
led = self._filter_none(
(
("isEnabled", led_is_enabled),
("welcomeLed", led_welcome_led),
("floodLed", led_flood_led),
)
)
if led:
body["ledSettings"] = led
if mic_volume is not None:
if not 0 <= mic_volume <= 100:
raise BadRequest("mic_volume must be between 0 and 100")
body["micVolume"] = mic_volume
detect: dict[str, Any] = {}
if smart_detect_object_types is not None:
detect["objectTypes"] = list(smart_detect_object_types)
if smart_detect_audio_types is not None:
detect["audioTypes"] = list(smart_detect_audio_types)
if detect:
body["smartDetectSettings"] = detect
osd = self._filter_none(
(
("isNameEnabled", osd_name_enabled),
("isDateEnabled", osd_date_enabled),
("isLogoEnabled", osd_logo_enabled),
("isDebugEnabled", osd_nerd_mode_enabled),
("overlayLocation", osd_overlay_location),
)
)
if osd:
body["osdSettings"] = osd
if not body:
raise BadRequest("At least one parameter must be provided")
result = await self.api_request_obj(
url=f"/v1/cameras/{camera_id}",
method="patch",
json=body,
public_api=True,
)
camera = PublicCamera.from_unifi_dict(**result, api=self)
self._write_through_public_twin(camera)
return camera
@public_post(
"/v1/pos/cameras/{camera_id}/transactions",
item=PublicPosTransactionResponse,
)
async def create_pos_transaction_public(
self,
camera_id: str,
*,
type: PosTransactionType, # noqa: A002 # wire key is ``type``
external_id: str,
amount: float,
currency: str | None = None,
line_items: list[PublicPosLineItemDict] | None = None,
location: PublicPosLocationDict | None = None,
payment_types: list[str] | None = None,
timestamp: int | None = None,
) -> PublicPosTransactionResponse:
"""
Ingest a point-of-sale transaction as a camera event using public API.
``external_id`` (1-255 chars) is unique per camera and drives
best-effort idempotency: a repeat returns ``created=False`` with
``event_id`` echoing the already-ingested event, while a concurrent
repeat raises ``BadRequest`` on the server's 409 and should be retried
shortly. Idempotency is in-memory and per-process, so it does not
survive a Protect restart.
``amount`` is >= 0, ``currency`` an uppercase ISO 4217 code,
``line_items`` holds at most 200 entries (each ``quantity`` >= 1),
``payment_types`` at most 20, and ``timestamp`` is epoch milliseconds
within the last 24 hours and no more than 5 minutes ahead of server time
(a value inside that skew is clamped to now).
"""
raise NotImplementedError
@public_get("/v1/chimes", items=PublicChime)
async def get_chimes_public(self) -> list[PublicChime]:
"""Get all chimes using public API."""
raise NotImplementedError
@public_get("/v1/chimes/{chime_id}", item=PublicChime)
async def get_chime_public(self, chime_id: str) -> PublicChime:
"""Get a specific chime using public API."""
raise NotImplementedError
@public_patch("/v1/chimes/{chime_id}", item=PublicChime)
async def update_chime_public(
self,
chime_id: str,
*,
name: str | None = None,
camera_ids: list[str] | None = None,
ring_settings: list[PublicApiChimeRingSettingRequest] | None = None,
) -> PublicChime:
"""Update chime settings using public API."""
raise NotImplementedError
# PTZ Control Private API Methods
async def get_presets_ptz_camera(self, device_id: str) -> list[PTZPreset]:
"""Get PTZ Presets for camera."""
presets = await self.api_request(f"cameras/{device_id}/ptz/preset")
if not presets:
return []
presets = cast("list[dict[str, Any]]", presets)
return [PTZPreset(**p) for p in presets]
async def get_patrols_ptz_camera(self, device_id: str) -> list[PTZPatrol]:
"""Get PTZ Patrols for camera."""
patrols = await self.api_request(f"cameras/{device_id}/ptz/patrol")
if not patrols:
return []
patrols = cast("list[dict[str, Any]]", patrols)
return [PTZPatrol(**p) for p in patrols]
# PTZ Control Public API Methods
@public_post("/v1/cameras/{camera_id}/ptz/goto/{slot}")
async def ptz_goto_preset_public(self, camera_id: str, *, slot: int) -> None:
"""Move PTZ camera to preset position using public API."""
raise NotImplementedError
@public_post("/v1/cameras/{camera_id}/ptz/patrol/start/{slot}")
async def ptz_patrol_start_public(self, camera_id: str, *, slot: int) -> None:
"""Start a PTZ patrol using public API."""
raise NotImplementedError
@public_post("/v1/cameras/{camera_id}/ptz/patrol/stop")
async def ptz_patrol_stop_public(self, camera_id: str) -> None:
"""Stop the active PTZ patrol using public API."""
raise NotImplementedError
async def create_talkback_session_public(self, camera_id: str) -> TalkbackSession:
"""
Create a talkback session for a camera using public API.
Returns the talkback stream URL and audio configuration.
"""
data = await self.api_request_obj(
url=f"/v1/cameras/{camera_id}/talkback-session",
method="post",
public_api=True,
)
return TalkbackSession.from_unifi_dict(**data)
async def disable_camera_mic_permanently_public(
self, camera_id: str
) -> PublicCamera:
"""
Permanently disable a camera's microphone.
Irreversible without a factory reset of the camera. The spec returns
the updated camera object (not a 204).
"""
data = await self.api_request_obj(
url=f"/v1/cameras/{camera_id}/disable-mic-permanently",
method="post",
public_api=True,
)
return PublicCamera.from_unifi_dict(**data, api=self)
@public_get("/v1/sensors", items=PublicSensor)
async def get_sensors_public(self) -> list[PublicSensor]:
"""Get all sensors using public API."""
raise NotImplementedError
@public_get("/v1/sensors/{sensor_id}", item=PublicSensor)
async def get_sensor_public(self, sensor_id: str) -> PublicSensor:
"""Get a specific sensor using public API."""
raise NotImplementedError
async def update_sensor_public(
self,
sensor_id: str,
*,
name: str | None = None,
light_settings: PublicSensorLightSettings | None = None,
humidity_settings: PublicSensorHumiditySettings | None = None,
temperature_settings: PublicSensorTemperatureSettings | None = None,
motion_settings: PublicSensorMotionSettings | None = None,
glass_break_settings: PublicSensorGlassBreakSettingsWrite | None = None,
alarm_settings: PublicSensorAlarmSettings | None = None,
schedule_mode: SensorScheduleMode | str | None = None,
arm_profile_ids: list[str] | None = None,
has_custom_sensitivity_when_armed: bool | None = None,
) -> PublicSensor:
"""
Patch sensor settings using public API.
Each ``*_settings`` argument is a :class:`~typing.TypedDict` with
``total=False`` — pass only the keys you want to change.
"""
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
if light_settings is not None:
body["lightSettings"] = dict(light_settings)
if humidity_settings is not None:
body["humiditySettings"] = dict(humidity_settings)
if temperature_settings is not None:
body["temperatureSettings"] = dict(temperature_settings)
if motion_settings is not None:
body["motionSettings"] = dict(motion_settings)
if glass_break_settings is not None:
body["glassBreakSettings"] = dict(glass_break_settings)
if alarm_settings is not None:
body["alarmSettings"] = dict(alarm_settings)
if schedule_mode is not None:
body["scheduleMode"] = str(schedule_mode)
if arm_profile_ids is not None:
body["armProfileIds"] = list(arm_profile_ids)
if has_custom_sensitivity_when_armed is not None:
body["hasCustomSensitivityWhenArmed"] = has_custom_sensitivity_when_armed
if not body:
raise BadRequest("At least one parameter must be provided")
result = await self.api_request_obj(
url=f"/v1/sensors/{sensor_id}",
method="patch",
json=body,
public_api=True,
)
sensor = PublicSensor.from_unifi_dict(**result, api=self)
self._write_through_public_twin(sensor)
return sensor
@staticmethod
def _build_named_led_patch_body(
*, name: str | None = None, led_is_enabled: bool | None = None
) -> dict[str, Any]:
"""Build common PATCH body for resources with name + ledSettings."""
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
if led_is_enabled is not None:
body["ledSettings"] = {"isEnabled": led_is_enabled}
return body
@staticmethod
def _filter_none(items: tuple[tuple[str, Any], ...]) -> dict[str, Any]:
"""Return a dict from key-value pairs, dropping any pair whose value is None."""
return {k: v for k, v in items if v is not None}
def _write_through_public_twin(self, obj: PublicDeviceModel) -> None:
"""
Refresh the cached public-bootstrap twin of ``obj`` from a PATCH response.
Keeps the public device cache fresh for every mutation path — both the
public-tree ``set_*`` setters and the private ``set_*_public`` methods
(which drive the same ``update_*_public`` endpoints) — without inserting
new devices. A no-op when no public bootstrap is loaded or the device is
not cached.
"""
pb = self._public_bootstrap
if pb is None:
return
model = obj.model
if model is None: # pragma: no cover - device models always carry a modelKey
return
twin = pb.get(model, obj.id)
if not isinstance(twin, PublicDeviceModel):
return
if twin is obj: # pragma: no cover - a fresh response is never the cached obj
return
twin._apply_from_response(obj)
@public_get("/v1/sirens", items=Siren)
async def get_sirens_public(self) -> list[Siren]:
"""Get all sirens using public API."""
raise NotImplementedError
@public_get("/v1/sirens/{siren_id}", item=Siren)
async def get_siren_public(self, siren_id: str) -> Siren:
"""Get a specific siren using public API."""
raise NotImplementedError
async def update_siren_public(
self,
siren_id: str,
*,
name: str | None = None,
volume: int | None = None,
led_is_enabled: bool | None = None,
) -> Siren:
"""Patch siren settings using public API."""
body = self._build_named_led_patch_body(
name=name,
led_is_enabled=led_is_enabled,
)
if volume is not None:
body["volume"] = volume
if not body:
raise BadRequest("At least one parameter must be provided")
result = await self.api_request_obj(
url=f"/v1/sirens/{siren_id}",
method="patch",
json=body,
public_api=True,
)
return Siren.from_unifi_dict(**result, api=self)
async def play_siren_public(
self, siren_id: str, *, duration: SirenDuration | int | None = None
) -> None:
"""Activate a siren. ``duration`` may be a supported integer or :class:`SirenDuration`; defaults to 5 seconds."""
if duration is None:
norm_duration = SirenDuration.FIVE
elif isinstance(duration, SirenDuration):
norm_duration = duration
else:
try:
norm_duration = SirenDuration(duration)
except ValueError as err:
raise BadRequest(
"duration must be one of the supported siren durations "
f"{', '.join(str(item.value) for item in SirenDuration)} seconds"
) from err
await self.api_request_raw(
url=f"/v1/sirens/{siren_id}/play",
method="post",
public_api=True,
json={"duration": norm_duration},
)
@public_post("/v1/sirens/{siren_id}/stop")
async def stop_siren_public(self, siren_id: str) -> None:
"""Stop an active siren."""
raise NotImplementedError
async def test_siren_sound_public(
self, siren_id: str, *, volume: int | None = None
) -> None:
"""Test the siren sound (5 seconds)."""
body: dict[str, Any] = {}
if volume is not None:
body["volume"] = volume
await self.api_request_raw(
url=f"/v1/sirens/{siren_id}/test-sound",
method="post",
public_api=True,
json=body,
)
@public_get("/v1/relays", items=Relay)
async def get_relays_public(self) -> list[Relay]:
"""Get all relays using public API."""
raise NotImplementedError
@public_get("/v1/relays/{relay_id}", item=Relay)
async def get_relay_public(self, relay_id: str) -> Relay:
"""Get a specific relay using public API."""
raise NotImplementedError
async def update_relay_public(
self,
relay_id: str,
*,
name: str | None = None,
led_is_enabled: bool | None = None,
) -> Relay:
"""Patch relay settings using public API."""
body = self._build_named_led_patch_body(
name=name,
led_is_enabled=led_is_enabled,
)
if not body:
raise BadRequest("At least one parameter must be provided")
result = await self.api_request_obj(
url=f"/v1/relays/{relay_id}",
method="patch",
json=body,
public_api=True,
)
return Relay.from_unifi_dict(**result, api=self)
async def activate_relay_output_public(
self,
relay_id: str,
output_id: int,
*,
state: Literal["on", "off"] | None = None,
pulse_duration_ms: int | None = None,
) -> None:
"""
Activate, toggle or pulse a relay output.
Omit ``state`` to toggle the current state. ``pulse_duration_ms`` is
only valid together with ``state='on'``; the output will auto-turn
off after the given milliseconds.
"""
if pulse_duration_ms is not None and state != "on":
raise BadRequest("pulse_duration_ms may only be combined with state='on'")
body: dict[str, Any] = {}
if state is not None:
body["state"] = state
if pulse_duration_ms is not None:
body["pulseDuration"] = pulse_duration_ms
await self.api_request_raw(
url=f"/v1/relays/{relay_id}/outputs/{output_id}/activate",
method="post",
public_api=True,
json=body or None,
)
@public_get("/v1/fobs", items=Fob)
async def get_fobs_public(self) -> list[Fob]:
"""Get all key fobs using public API."""
raise NotImplementedError
@public_get("/v1/fobs/{fob_id}", item=Fob)
async def get_fob_public(self, fob_id: str) -> Fob:
"""Get a specific key fob using public API."""
raise NotImplementedError
@public_patch("/v1/fobs/{fob_id}", item=Fob)
async def update_fob_public(self, fob_id: str, *, name: str | None = None) -> Fob:
"""Patch key-fob settings using public API."""
raise NotImplementedError
@public_get("/v1/speakers", items=Speaker)
async def get_speakers_public(self) -> list[Speaker]:
"""Get all speakers using public API."""
raise NotImplementedError
@public_get("/v1/speakers/{speaker_id}", item=Speaker)
async def get_speaker_public(self, speaker_id: str) -> Speaker:
"""Get a specific speaker using public API."""
raise NotImplementedError
@public_patch("/v1/speakers/{speaker_id}", item=Speaker)
async def update_speaker_public(
self,
speaker_id: str,
*,
name: str | None = None,
volume: int | None = None,
mic_volume: int | None = None,
is_mic_enabled: bool | None = None,
) -> Speaker:
"""Patch speaker settings using public API."""
raise NotImplementedError
async def test_speaker_sound_public(
self, speaker_id: str, *, volume: int | None = None
) -> None:
"""Test the speaker sound at the given volume."""
body: dict[str, Any] = {}
if volume is not None:
body["volume"] = volume
await self.api_request_raw(
url=f"/v1/speakers/{speaker_id}/test-sound",
method="post",
public_api=True,
json=body,
)
@public_get("/v1/link-stations", items=LinkStation)
async def get_link_stations_public(self) -> list[LinkStation]:
"""Get all link stations using public API."""
raise NotImplementedError
@public_get("/v1/alarm-hubs", items=LinkStation)
async def get_alarm_hubs_public(self) -> list[LinkStation]:
"""Get all alarm hubs using public API."""
raise NotImplementedError
@public_get("/v1/link-stations/{link_station_id}", item=LinkStation)
async def get_link_station_public(self, link_station_id: str) -> LinkStation:
"""Get a specific link station using public API."""
raise NotImplementedError
@public_get("/v1/alarm-hubs/{hub_id}", item=LinkStation)
async def get_alarm_hub_public(self, hub_id: str) -> LinkStation:
"""Get a specific alarm hub using public API."""
raise NotImplementedError
@public_patch("/v1/link-stations/{link_station_id}", item=LinkStation)
async def update_link_station_public(
self, link_station_id: str, *, name: str | None = None
) -> LinkStation:
"""Patch link station settings using public API."""
raise NotImplementedError
@public_patch("/v1/alarm-hubs/{hub_id}", item=LinkStation)
async def update_alarm_hub_public(
self, hub_id: str, *, name: str | None = None
) -> LinkStation:
"""Patch alarm hub settings using public API."""
raise NotImplementedError
async def trigger_alarm_hub_output_public(
self,
hub_id: str,
output_id: int,
*,
enable: bool | None = None,
delay: int | None = None,
duration: int | None = None,
) -> None:
"""Trigger an alarm-hub output channel using public API."""
if delay is not None and delay < 0:
raise BadRequest("delay must be >= 0")
if duration is not None and duration < 0:
raise BadRequest("duration must be >= 0")
body: dict[str, Any] = {}
if enable is not None:
body["enable"] = enable
if delay is not None:
body["delay"] = delay
if duration is not None:
body["duration"] = duration
await self.api_request_raw(
url=f"/v1/alarm-hubs/{hub_id}/outputs/{output_id}/trigger",
method="post",
public_api=True,
json=body or None,
)
@public_get("/v1/bridges", items=PublicBridge)
async def get_bridges_public(self) -> list[PublicBridge]:
"""Get all bridges using public API."""
raise NotImplementedError
async def get_bridge_public(self, bridge_id: str) -> PublicBridge:
"""Get a specific bridge using public API."""
data = await self.api_request_obj(
url=f"/v1/bridges/{bridge_id}", public_api=True
)
bridge = PublicBridge.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.bridges[bridge.id] = bridge
return bridge
async def update_bridge_public(
self, bridge_id: str, *, name: str | None = None
) -> PublicBridge:
"""Patch bridge settings using public API."""
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
if not body:
raise BadRequest("At least one parameter must be provided")
data = await self.api_request_obj(
url=f"/v1/bridges/{bridge_id}",
method="patch",
json=body,
public_api=True,
)
bridge = PublicBridge.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.bridges[bridge.id] = bridge
return bridge
@public_get("/v1/viewers", items=PublicViewer)
async def get_viewers_public(self) -> list[PublicViewer]:
"""Get all viewers using public API."""
raise NotImplementedError
async def get_viewer_public(self, viewer_id: str) -> PublicViewer:
"""Get a specific viewer using public API."""
data = await self.api_request_obj(
url=f"/v1/viewers/{viewer_id}", public_api=True
)
viewer = PublicViewer.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.viewers[viewer.id] = viewer
return viewer
async def update_viewer_public(
self,
viewer_id: str,
*,
name: str | None = None,
liveview: str | _UnsetType | None = _UNSET,
) -> PublicViewer:
"""Patch viewer settings using public API. Pass ``liveview=None`` to clear."""
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
# ``liveview`` is genuinely nullable on the wire — distinguish the
# default sentinel (no change) from an explicit ``None`` (clear).
if liveview is not _UNSET:
body["liveview"] = liveview
if not body:
raise BadRequest("At least one parameter must be provided")
data = await self.api_request_obj(
url=f"/v1/viewers/{viewer_id}",
method="patch",
json=body,
public_api=True,
)
viewer = PublicViewer.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.viewers[viewer.id] = viewer
return viewer
@public_get("/v1/liveviews", items=PublicLiveview)
async def get_liveviews_public(self) -> list[PublicLiveview]:
"""Get all liveviews using public API."""
raise NotImplementedError
async def get_liveview_public(self, liveview_id: str) -> PublicLiveview:
"""Get a specific liveview using public API."""
data = await self.api_request_obj(
url=f"/v1/liveviews/{liveview_id}", public_api=True
)
liveview = PublicLiveview.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.liveviews[liveview.id] = liveview
return liveview
async def create_liveview_public(
self,
*,
name: str,
is_default: bool,
is_global: bool,
owner: str,
layout: int,
slots: list[PublicLiveviewSlotDict],
) -> PublicLiveview:
"""Create a new liveview using public API."""
if not 1 <= layout <= 26:
raise BadRequest("layout must be between 1 and 26")
body: dict[str, Any] = {
"name": name,
"isDefault": is_default,
"isGlobal": is_global,
"owner": owner,
"layout": layout,
"slots": [dict(s) for s in slots],
}
data = await self.api_request_obj(
url="/v1/liveviews",
method="post",
json=body,
public_api=True,
)
liveview = PublicLiveview.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.liveviews[liveview.id] = liveview
return liveview
async def update_liveview_public(
self,
liveview_id: str,
*,
name: str | None = None,
is_default: bool | None = None,
is_global: bool | None = None,
owner: str | None = None,
layout: int | None = None,
slots: list[PublicLiveviewSlotDict] | None = None,
) -> PublicLiveview:
"""Patch an existing liveview (partial update) using public API."""
if layout is not None and not 1 <= layout <= 26:
raise BadRequest("layout must be between 1 and 26")
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
if is_default is not None:
body["isDefault"] = is_default
if is_global is not None:
body["isGlobal"] = is_global
if owner is not None:
body["owner"] = owner
if layout is not None:
body["layout"] = layout
if slots is not None:
body["slots"] = [dict(s) for s in slots]
if not body:
raise BadRequest("At least one parameter must be provided")
data = await self.api_request_obj(
url=f"/v1/liveviews/{liveview_id}",
method="patch",
json=body,
public_api=True,
)
liveview = PublicLiveview.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.liveviews[liveview.id] = liveview
return liveview
async def send_alarm_webhook_public(self, trigger_id: str) -> None:
"""Fire the alarm-manager webhook for the given trigger id."""
if not trigger_id:
raise BadRequest("trigger_id is required")
await self.api_request_raw(
url=f"/v1/alarm-manager/webhook/{quote(trigger_id, safe='')}",
method="post",
public_api=True,
)
async def get_arm_profiles_public(self) -> list[ArmProfile]:
"""Get all arm profiles."""
profiles = await self._fetch_arm_profiles()
self._apply_arm_profiles(profiles)
return profiles
async def _fetch_arm_profiles(self) -> list[ArmProfile]:
"""Fetch arm profiles without mutating the cached bootstrap."""
data = await self.api_request_list(url="/v1/arm-profiles", public_api=True)
return [ArmProfile.from_unifi_dict(**item, api=self) for item in data]
def _apply_arm_profiles(self, profiles: list[ArmProfile]) -> None:
"""Merge fetched arm profiles into the cache in place, preserving identity."""
if self._public_bootstrap is None:
return
# Update in place to preserve dict identity for consumers holding
# a reference to ``public_bootstrap.arm_profiles``.
arm_profiles = self._public_bootstrap.arm_profiles
arm_profiles.clear()
arm_profiles.update({p.id: p for p in profiles})
async def create_arm_profile_public(
self,
*,
name: str,
automations: list[str],
schedules: list[PublicArmScheduleDict],
record_everything: bool,
activation_delay: int,
) -> ArmProfile:
"""Create a new arm profile."""
body = {
"name": name,
"automations": automations,
"schedules": [dict(s) for s in schedules],
"recordEverything": record_everything,
"activationDelay": activation_delay,
}
data = await self.api_request_obj(
url="/v1/arm-profiles",
method="post",
json=body,
public_api=True,
)
profile = ArmProfile.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.arm_profiles[profile.id] = profile
return profile
async def update_arm_profile_public(
self,
profile_id: str,
*,
name: str | None = None,
automations: list[str] | None = None,
schedules: list[PublicArmScheduleDict] | None = None,
record_everything: bool | None = None,
activation_delay: int | None = None,
) -> ArmProfile:
"""Update an existing arm profile (partial update)."""
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
if automations is not None:
body["automations"] = automations
if schedules is not None:
body["schedules"] = [dict(s) for s in schedules]
if record_everything is not None:
body["recordEverything"] = record_everything
if activation_delay is not None:
body["activationDelay"] = activation_delay
if not body:
raise BadRequest("At least one parameter must be provided")
data = await self.api_request_obj(
url=f"/v1/arm-profiles/{profile_id}",
method="patch",
json=body,
public_api=True,
)
profile = ArmProfile.from_unifi_dict(**data, api=self)
if self._public_bootstrap is not None:
self._public_bootstrap.arm_profiles[profile.id] = profile
return profile
async def delete_arm_profile_public(self, profile_id: str) -> None:
"""Delete an arm profile."""
await self.api_request_raw(
url=f"/v1/arm-profiles/{profile_id}",
method="delete",
public_api=True,
)
if self._public_bootstrap is not None:
self._public_bootstrap.arm_profiles.pop(profile_id, None)
async def get_arm_manager_settings_public(self) -> NvrArmMode | None:
"""
Return current arm-manager state from the NVR cache.
The arm-manager state is embedded in the NVR object (``armMode``
field of ``GET /v1/nvrs``) — there is no dedicated GET endpoint.
If the bootstrap cache is not yet populated, fetches the NVR now
via :meth:`get_nvr_public` so the full ``PublicNVR`` is always
constructed through the same code path.
"""
if self._public_bootstrap is not None:
return self._public_bootstrap.arm_mode
nvr = await self.get_nvr_public()
return nvr.arm_mode
async def set_current_arm_profile_public(self, profile_id: str) -> None:
"""Set the currently selected arm profile."""
await self.api_request_raw(
url="/v1/arm-profiles/settings",
method="patch",
json={"armProfileId": profile_id},
public_api=True,
)
if (
self._public_bootstrap is not None
and self._public_bootstrap.arm_mode is not None
):
self._public_bootstrap.arm_mode.arm_profile_id = profile_id
async def enable_arm_alarm_public(self) -> None:
"""Enable the arm alarm with the currently selected profile."""
await self.api_request_raw(
url="/v1/arm-profiles/enable",
method="post",
public_api=True,
)
if (
self._public_bootstrap is not None
and self._public_bootstrap.arm_mode is not None
):
self._public_bootstrap.arm_mode.status = NvrArmModeStatus.ARMING
async def disable_arm_alarm_public(self) -> None:
"""Disable the arm alarm."""
await self.api_request_raw(
url="/v1/arm-profiles/disable",
method="post",
public_api=True,
)
if (
self._public_bootstrap is not None
and self._public_bootstrap.arm_mode is not None
):
self._public_bootstrap.arm_mode.status = NvrArmModeStatus.DISABLED
@public_get("/v1/users", items=PublicUser)
async def get_users_public(self) -> list[PublicUser]:
"""Get all Protect users using public API."""
raise NotImplementedError
@public_get("/v1/users/{user_id}", item=PublicUser)
async def get_user_public(self, user_id: str) -> PublicUser:
"""Get a specific Protect user using public API."""
raise NotImplementedError
@public_get("/v1/ulp-users", items=PublicUlpUser)
async def get_ulp_users_public(self) -> list[PublicUlpUser]:
"""Get all UniFi Identity users using public API."""
raise NotImplementedError
@public_get("/v1/ulp-users/{ulp_user_id}", item=PublicUlpUser)
async def get_ulp_user_public(self, ulp_user_id: str) -> PublicUlpUser:
"""Get a specific UniFi Identity user using public API."""
raise NotImplementedError
async def get_files_public(
self, file_type: AssetFileType | str = AssetFileType.ANIMATIONS
) -> list[PublicFile]:
"""List uploaded device asset files of the given type."""
data = await self.api_request_list(
url=f"/v1/files/{file_type}", public_api=True
)
return [PublicFile.from_unifi_dict(**item, api=self) for item in data]
async def upload_file_public(
self,
file_type: AssetFileType | str,
file: bytes,
original_name: str,
content_type: str = "image/png",
) -> PublicFile:
"""
Upload a device asset as ``multipart/form-data``.
The spec accepts ``image/gif``, ``image/jpeg``, ``image/png``,
``audio/mpeg``, ``audio/mp4``, ``audio/wave``, ``audio/x-caf``;
``content_type`` defaults to ``image/png`` for the common
doorbell-animation case and must be overridden for other MIME types.
``aiohttp`` sets the multipart ``Content-Type`` header (with boundary)
itself when ``data=`` is a :class:`aiohttp.FormData`, so this client
never sets a JSON content-type for this call.
"""
form = aiohttp.FormData()
form.add_field(
"file",
file,
filename=original_name,
content_type=content_type,
)
raw = await self.api_request_raw(
url=f"/v1/files/{file_type}",
method="post",
public_api=True,
data=form,
)
if not raw:
raise NvrError("Empty response from upload_file_public")
return PublicFile.from_unifi_dict(**orjson.loads(raw), api=self)
async def update_public(self) -> PublicBootstrap:
"""
Populate :attr:`public_bootstrap` from the Public Integration API.
This is opt-in and completely independent of :meth:`update` / the
private bootstrap. Safe to call multiple times — the
:class:`PublicBootstrap` instance is created on first use and then
updated in place on subsequent calls. All endpoint fetches run
concurrently.
Each endpoint is requested best-effort; endpoints that the NVR
doesn't (yet) expose (``BadRequest`` / ``NvrError``) are logged at
``DEBUG`` and ignored, and a partial public bootstrap is returned.
If an endpoint fails, its previously cached data is left unchanged
(not cleared). All results are classified before any are applied: an
unexpected exception (e.g. a validation error from a new server
payload) propagates to the caller with the snapshot left untouched,
never half-applied.
A revoked or invalid API key surfaces as :class:`NotAuthorized`; catch
it as the reauth signal.
Public events/devices WS frames that arrive while this method is
priming are captured and replayed onto the fresh snapshot before it
returns, so a subscriber that is already connected does not lose an
update to the prime window — the subscribe-then-prime ordering is a
library guarantee, not a caller obligation. To also make the
prime-then-subscribe ordering moot for the typed callbacks, use
:meth:`subscribe_devices_and_prime` / :meth:`subscribe_events_and_prime`.
After priming, ``public_bootstrap.nvr.mac`` carries the NVR mac
whenever it is resolvable. On firmware that omits ``mac`` from the
public payload it is backfilled from the console fallback and stored
in the native UniFi format (uppercase, no separators) — the same
format newer firmware already provides — so consumers can read a
self-consistent mac regardless of firmware.
Concurrent calls are serialized: an overlapping prime could otherwise
apply an older snapshot over a newer one (and over live WS merges in
between). Each caller returns the then-current bootstrap.
"""
async with self._public_update_lock:
return await self._update_public_locked()
async def _update_public_locked(self) -> PublicBootstrap:
"""Fetch and apply the public bootstrap; caller holds the prime lock."""
# Keep a candidate bootstrap local until Phase 1 classification
# succeeds; on a first refresh that fails, ``_public_bootstrap`` must
# stay ``None`` so the ``subscribe_events`` prime guard still fires.
pb = self._public_bootstrap
# The first prime turns every fetched device into an "add"; only a
# refresh of an already-materialised snapshot has a meaningful diff to
# announce.
was_primed = pb is not None
if pb is None:
pb = PublicBootstrap()
# Snapshot existing streams before the re-parse below replaces the
# camera objects with freshly-built ones (whose ``rtsps_streams``
# default to ``None``); the prime step carries them forward by id so
# the never-empty contract holds across a resync.
previous_streams = {
camera_id: camera.rtsps_streams
for camera_id, camera in pb.cameras.items()
if camera.rtsps_streams is not None
}
# Bind coroutines to their labels and attribute names to avoid
# manual index synchronization bugs.
# ``_fetch_arm_profiles`` fetches without self-applying so
# arm-profiles can be applied atomically in the batch phase below.
# ``armMode`` is part of the NVR response; no separate call needed.
endpoints = [
(self.get_nvr_public(), "nvr", "nvr"),
(self.get_cameras_public(), "cameras", "cameras"),
(self.get_lights_public(), "lights", "lights"),
(self.get_chimes_public(), "chimes", "chimes"),
(self.get_sensors_public(), "sensors", "sensors"),
(self.get_sirens_public(), "sirens", "sirens"),
(self.get_relays_public(), "relays", "relays"),
(self.get_fobs_public(), "fobs", "fobs"),
(self.get_speakers_public(), "speakers", "speakers"),
(self.get_link_stations_public(), "link-stations", "link_stations"),
(self.get_liveviews_public(), "liveviews", "liveviews"),
(self.get_bridges_public(), "bridges", "bridges"),
(self.get_viewers_public(), "viewers", "viewers"),
(self.get_ulp_users_public(), "ulp-users", "ulp_users"),
(self._fetch_arm_profiles(), "arm-profiles", "arm_profiles"),
]
# Capture any public-WS frame that lands while the snapshot is being
# fetched/applied so it can be replayed onto the fresh cache below,
# instead of being merged into (or dropped by) the about-to-be-replaced
# objects. The prime lock guarantees this prime is the only one.
self._prime_ws_buffer = []
# Live and replayed frames land on the same snapshot this fetch is
# merging into, so both must be able to preempt the diff below.
seen: set[tuple[ModelType, str]] = set()
self._public_membership_seen = seen
try:
await self._fetch_and_apply_public_snapshot(
pb, endpoints, previous_streams, seen, was_primed=was_primed
)
finally:
self._public_membership_seen = None
return pb
async def _fetch_and_apply_public_snapshot(
self,
pb: PublicBootstrap,
endpoints: list[tuple[Any, str, str]],
previous_streams: dict[str, RTSPSStreams],
seen: set[tuple[ModelType, str]],
*,
was_primed: bool,
) -> None:
"""Run the ``update_public`` fetch/apply/announce sequence for ``pb``."""
diffs: list[FetchDiff] = []
try:
results = await asyncio.gather(
*[coro for coro, _, _ in endpoints], return_exceptions=True
)
# Phase 1 — classify every result before touching the snapshot.
# Tolerated endpoint-unavailable errors (BadRequest/NvrError) are
# logged; any other exception re-raises here, leaving the previous
# consistent bootstrap intact instead of half-applied.
for (_, label, _attr), result in zip(endpoints, results, strict=True):
if isinstance(result, BaseException):
_log_or_raise(
label, result, tolerate_not_authorized=label == "ulp-users"
)
# Classification passed: publish the candidate.
# ``_apply_arm_profiles`` reads ``self._public_bootstrap``, so this
# must precede Phase 2.
self._public_bootstrap = pb
# Phase 2 — no unexpected error: apply the whole batch. No ``await``
# between writes, so a concurrent public-WS frame cannot interleave
# a torn state. Tolerated-missing endpoints keep their prior data.
diffs = self._apply_public_fetch_results(pb, endpoints, results)
# Re-derive the siren deadlines in the same await-free window the
# merge ran in: a timer armed for a superseded run must not outlive
# the status it was derived from.
self._reschedule_siren_offs(pb)
finally:
# Stop buffering and drain on success and failure alike: replayed
# frames land on the fresh snapshot when the prime succeeded, and
# on the previous cache when it raised (the pre-buffering live
# behavior, merely delayed) — a failed prime must not swallow them.
# Clearing the buffer first means replayed (and any re-entrant)
# frames process live rather than re-queueing.
replay = self._prime_ws_buffer or []
self._prime_ws_buffer = None
for handler, msg in replay:
handler(msg)
await self._prime_rtsps_streams(pb, previous_streams)
await self._backfill_public_nvr_mac(pb)
if was_primed:
self._emit_public_fetch_diffs(pb, diffs, seen)
def _apply_public_fetch_results(
self,
pb: PublicBootstrap,
endpoints: list[tuple[Any, str, str]],
results: list[Any],
) -> list[FetchDiff]:
"""Apply the classified ``update_public`` fetch results to ``pb``."""
diffs: list[FetchDiff] = []
for (_, _label, attr), result in zip(endpoints, results, strict=True):
if isinstance(result, BaseException):
continue
if attr == "arm_profiles":
self._apply_arm_profiles(cast("list[ArmProfile]", result))
elif attr == "nvr":
pb.nvr = result
else:
diffs.append(pb.apply_fetch_result(attr, result))
return diffs
def _emit_public_fetch_diffs(
self,
pb: PublicBootstrap,
diffs: list[FetchDiff],
seen: set[tuple[ModelType, str]],
) -> None:
"""
Announce the devices an ``update_public`` refresh added or removed.
A device that appears or disappears while the devices websocket is
down produces no wire frame, so the reconnect resync is the only place
subscribers can learn about it. Emitting here — after the whole batch
has merged and the RTSPS prime has run — means a subscriber reacting
to the frame already sees a consistent bootstrap.
"""
for diff in diffs:
store = cast("dict[str, ProtectModelWithId]", getattr(pb, diff.attr))
for obj_id in diff.added_ids:
# Re-read: a WS frame arriving during the fetch may have
# removed the object again since the merge.
if (obj := store.get(obj_id)) is not None:
self._emit_public_membership_frame(pb, WSAction.ADD, obj, seen)
for obj in diff.removed:
if obj.id not in store:
self._emit_public_membership_frame(pb, WSAction.REMOVE, obj, seen)
def _emit_public_membership_frame(
self,
pb: PublicBootstrap,
action: WSAction,
obj: ProtectModelWithId,
seen: set[tuple[ModelType, str]],
) -> None:
"""Emit one synthetic devices-WS ``add``/``remove`` for ``obj``."""
model = obj.model
# Non-device stores (ulp-users) and objects the devices WS never
# routes have no membership frame to speak of.
if model is None or not pb.supports_device(model):
return
# A real frame already announced this change, or the subscriber asked
# not to hear about this model at all.
if (model, obj.id) in seen or self._devices_ws_filtered_out(model):
return
is_add = action is WSAction.ADD
self.emit_devices_message(
WSSubscriptionMessage(
action=action,
new_update_id=obj.id,
# Identity only, like the other library-synthesized frames: the
# merged object travels in ``new_obj``/``old_obj``, and this
# path only runs once the cache is materialised.
changed_data={"modelKey": model.value, "id": obj.id},
new_obj=obj if is_add else None,
old_obj=None if is_add else obj,
)
)
async def _backfill_public_nvr_mac(self, pb: PublicBootstrap) -> None:
"""
Stamp the NVR mac onto ``pb`` when firmware omits it from the payload.
Protect newer than 7.1 already carries ``mac`` on ``GET /v1/nvrs`` in
native UniFi format (uppercase, no separators), so this is a no-op
there. On older firmware the field is ``None``; resolve it via the
console fallback and write it in that same native format so the value
does not drift across firmware versions.
"""
if pb.nvr is None or pb.nvr.mac:
return
resolved = await self.resolve_nvr_mac()
if not resolved:
return
# ``pb.nvr`` may have been replaced by a websocket write-through while
# awaiting, so re-read it and skip if it now carries a mac.
if (nvr := pb.nvr) is not None and not nvr.mac:
# resolve_nvr_mac() returns the normalized (lowercase, separator-
# stripped) form; the public field natively holds uppercase-no-
# separator on newer firmware, so upper() matches that exactly.
nvr.mac = resolved.upper()
async def _prime_rtsps_streams(
self,
pb: PublicBootstrap,
previous_streams: dict[str, RTSPSStreams],
) -> None:
"""
Populate each camera's ``rtsps_streams`` after an ``update_public`` fetch.
Carries forward the pre-re-parse streams by id, then fetches streams for
the genuinely-missing connected cameras under a bounded concurrency
semaphore. Each fetch is bounded by ``RTSPS_PRIME_TIMEOUT`` and retried
``RTSPS_PRIME_RETRIES`` times on transient failure. Best-effort per
camera: one slow/unreachable camera cannot abort the prime, and the
cameras that still fail are aggregated into a single ``WARNING`` so the
failure mode is diagnosable in the field. Disconnected cameras are
skipped — they yield no usable stream, only a per-camera timeout.
"""
for camera_id, camera in pb.cameras.items():
if camera.rtsps_streams is None and camera_id in previous_streams:
camera.rtsps_streams = previous_streams[camera_id]
to_prime = [
camera
for camera in pb.cameras.values()
if camera.rtsps_streams is None and camera.state is DeviceState.CONNECTED
]
if not to_prime:
return
semaphore = asyncio.Semaphore(RTSPS_PRIME_CONCURRENCY)
async def _prime_one(camera: PublicCamera) -> str | None:
async with semaphore:
streams: RTSPSStreams | None = None
for attempt in range(RTSPS_PRIME_RETRIES + 1):
try:
async with asyncio.timeout(RTSPS_PRIME_TIMEOUT):
streams = await self.get_camera_rtsps_streams(camera.id)
if streams is not None:
break
_LOGGER.debug(
"Primed no RTSPS streams for camera %s (attempt %d/%d)",
camera.id,
attempt + 1,
RTSPS_PRIME_RETRIES + 1,
)
except Exception:
_LOGGER.debug(
"Failed to prime RTSPS streams for camera %s "
"(attempt %d/%d)",
camera.id,
attempt + 1,
RTSPS_PRIME_RETRIES + 1,
exc_info=True,
)
else:
return camera.id
# Only write if the camera is still the cached instance and
# still streamless — a concurrent write-through (create) or a
# remove/replace during the fetch await must not be clobbered or
# resurrected (mirrors the ``_refresh_camera_rtsps`` guard).
if (
streams is not None
and pb.cameras.get(camera.id) is camera
and camera.rtsps_streams is None
):
camera.rtsps_streams = streams
return None
results = await asyncio.gather(*[_prime_one(camera) for camera in to_prime])
failed = [camera_id for camera_id in results if camera_id is not None]
if failed:
_LOGGER.warning(
"Could not prime RTSPS streams for %d camera(s): %s",
len(failed),
", ".join(failed),
)
uilibs-uiprotect-317a52c/src/uiprotect/cli/ 0000775 0000000 0000000 00000000000 15246377057 0020740 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/cli/__init__.py 0000664 0000000 0000000 00000035050 15246377057 0023054 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import ssl
import sys
from pathlib import Path
from typing import cast
import aiohttp
import orjson
import typer
from rich.progress import track
from uiprotect.api import MetaInfo, ProtectApiClient
from ..data import WSPacket
from ..exceptions import BadRequest
from ..test_util import SampleDataGenerator
from ..utils import get_local_timezone, run_async
from ..utils import profile_ws as profile_ws_job
from .aiports import app as aiports_app
from .arm import app as arm_app
from .base import CliContext, OutputFormatEnum
from .bridges import app as bridges_app
from .cameras import app as camera_app
from .chimes import app as chime_app
from .events import app as event_app
from .files_public import app as files_public_app
from .fobs import app as fob_app
from .lights import app as light_app
from .link_stations import app as link_station_app
from .liveviews import app as liveview_app
from .nvr import app as nvr_app
from .relays import app as relay_app
from .sensors import app as sensor_app
from .sirens import app as siren_app
from .speakers import app as speaker_app
from .ulp_users_public import app as ulp_users_public_app
from .users_public import app as users_public_app
from .viewers import app as viewer_app
from .viewers_public import app as viewer_public_app
try:
from .backup import app as backup_app
except ImportError:
backup_app = None # type: ignore[assignment]
_LOGGER = logging.getLogger("uiprotect")
try:
from IPython import embed
from termcolor import colored
from traitlets.config import get_config
except ImportError:
embed = termcolor = get_config = None # type: ignore[assignment]
# Sub-apps that only use the public API (API key) and do not need username/password
_PUBLIC_ONLY_COMMAND_NAMES: tuple[str, ...] = (
"sirens",
"relays",
"fobs",
"speakers",
"link-stations",
"liveviews",
"bridges",
"viewers-public",
"users-public",
"ulp-users-public",
"files-public",
"arm",
)
_PUBLIC_ONLY_COMMANDS: frozenset[str] = frozenset(_PUBLIC_ONLY_COMMAND_NAMES)
_PUBLIC_ONLY_COMMANDS_HELP: str = ", ".join(_PUBLIC_ONLY_COMMAND_NAMES)
OPTION_USERNAME = typer.Option(
None,
"--username",
"-U",
help=(
"UniFi Protect username (not required for public API commands: "
f"{_PUBLIC_ONLY_COMMANDS_HELP})"
),
envvar="UFP_USERNAME",
)
OPTION_PASSWORD = typer.Option(
None,
"--password",
"-P",
help=(
"UniFi Protect password (not required for public API commands: "
f"{_PUBLIC_ONLY_COMMANDS_HELP})"
),
hide_input=True,
envvar="UFP_PASSWORD",
)
OPTION_API_KEY = typer.Option(
None,
"--api-key",
"-k",
help="UniFi Protect API key (required for public API operations)",
envvar="UFP_API_KEY",
)
OPTION_ADDRESS = typer.Option(
...,
"--address",
"-a",
prompt=True,
help="UniFi Protect IP address or hostname",
envvar="UFP_ADDRESS",
)
OPTION_PORT = typer.Option(
443,
"--port",
"-p",
help="UniFi Protect Port",
envvar="UFP_PORT",
)
OPTION_SECONDS = typer.Option(15, "--seconds", "-s", help="Seconds to pull events")
OPTION_VERIFY_SSL = typer.Option(
True,
"--verify-ssl/--no-verify-ssl",
help="Verify SSL certificate. Disable for self-signed certificates.",
envvar="UFP_SSL_VERIFY",
)
OPTION_ANON = typer.Option(True, "--actual", help="Do not anonymize test data")
OPTION_ZIP = typer.Option(False, "--zip", help="Zip up data after generate")
OPTION_WAIT = typer.Option(
30,
"--wait",
"-w",
help="Time to wait for Websocket messages",
)
OPTION_OUTPUT = typer.Option(
None,
"--output",
"-o",
help="Output folder, defaults to `tests` folder one level above this file",
envvar="UFP_SAMPLE_DIR",
)
OPTION_OUT_FORMAT = typer.Option(
OutputFormatEnum.PLAIN,
"--output-format",
help="Preferred output format. Not all commands support both JSON and plain and may still output in one or the other.",
)
OPTION_WS_FILE = typer.Option(
None,
"--file",
"-f",
help="Path or raw binary Websocket message",
)
OPTION_UNADOPTED = typer.Option(
False,
"-u",
"--include-unadopted",
help="Include devices not adopted by this NVR.",
)
ARG_WS_DATA = typer.Argument(None, help="base64 encoded Websocket message")
SLEEP_INTERVAL = 2
app = typer.Typer(rich_markup_mode="rich")
app.add_typer(nvr_app, name="nvr")
app.add_typer(event_app, name="events")
app.add_typer(liveview_app, name="liveviews")
app.add_typer(camera_app, name="cameras")
app.add_typer(chime_app, name="chimes")
app.add_typer(light_app, name="lights")
app.add_typer(sensor_app, name="sensors")
app.add_typer(viewer_app, name="viewers")
app.add_typer(viewer_public_app, name="viewers-public")
app.add_typer(aiports_app, name="aiports")
app.add_typer(siren_app, name="sirens")
app.add_typer(relay_app, name="relays")
app.add_typer(fob_app, name="fobs")
app.add_typer(speaker_app, name="speakers")
app.add_typer(link_station_app, name="link-stations")
app.add_typer(bridges_app, name="bridges")
app.add_typer(users_public_app, name="users-public")
app.add_typer(ulp_users_public_app, name="ulp-users-public")
app.add_typer(files_public_app, name="files-public")
app.add_typer(arm_app, name="arm")
if backup_app is not None:
app.add_typer(backup_app, name="backup")
def _is_ssl_error(exc: BaseException) -> bool:
"""Check if an exception is an SSL certificate verification error."""
if isinstance(exc, aiohttp.ClientConnectorCertificateError):
return True
if isinstance(exc, aiohttp.ClientConnectorSSLError):
return True
if isinstance(exc, ssl.SSLCertVerificationError):
return True
# Check nested exceptions
if exc.__cause__ is not None:
return _is_ssl_error(exc.__cause__)
return False
def _get_cert_fingerprint(host: str, port: int) -> str | None:
"""Return the SHA-256 fingerprint of the server's leaf certificate, or None."""
try:
pem = ssl.get_server_certificate((host, port), timeout=5)
except (OSError, ssl.SSLError):
return None
if not pem:
return None
der = ssl.PEM_cert_to_DER_cert(pem)
digest = hashlib.sha256(der).hexdigest().upper()
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
async def _connect_and_bootstrap(protect: ProtectApiClient) -> None:
"""Connect to the Protect API and fetch bootstrap data."""
protect._bootstrap = await protect.get_bootstrap()
await protect.close_session()
await protect.close_public_api_session()
@app.callback()
def main(
ctx: typer.Context,
username: str | None = OPTION_USERNAME,
password: str | None = OPTION_PASSWORD,
api_key: str | None = OPTION_API_KEY,
address: str = OPTION_ADDRESS,
port: int = OPTION_PORT,
verify_ssl: bool = OPTION_VERIFY_SSL,
output_format: OutputFormatEnum = OPTION_OUT_FORMAT,
include_unadopted: bool = OPTION_UNADOPTED,
) -> None:
"""UniFi Protect CLI"""
# preload the timezone before any async code runs
get_local_timezone()
is_public_only = ctx.invoked_subcommand in _PUBLIC_ONLY_COMMANDS
if not is_public_only:
# Private API commands require username and password.
# Prompt interactively if not supplied via option/env.
if not username:
username = typer.prompt("Username")
if not password:
password = typer.prompt("Password", hide_input=True)
try:
protect = ProtectApiClient(
address,
port,
username=None if is_public_only else (username or ""),
password=None if is_public_only else (password or ""),
api_key=api_key,
verify_ssl=verify_ssl,
ignore_unadopted=not include_unadopted,
)
except BadRequest as err:
typer.secho(str(err), fg="red", err=True)
raise typer.Exit(code=1) from err
async def close_protect() -> None:
"""Close the Protect API client sessions."""
await protect.close_session()
await protect.close_public_api_session()
if not is_public_only:
try:
run_async(_connect_and_bootstrap(protect))
except Exception as exc:
# Always close the session on error to avoid "Unclosed client session" warning
run_async(close_protect())
if verify_ssl and _is_ssl_error(exc):
typer.secho(
f"SSL certificate verification failed for {address}:{port}.",
fg="red",
err=True,
)
fingerprint = _get_cert_fingerprint(address, port)
if fingerprint:
typer.secho(
f" Server certificate SHA-256: {fingerprint}",
err=True,
)
typer.secho(
"Refusing to retry with verification disabled — sending "
"credentials over an unauthenticated TLS channel would "
"expose them to any on-path attacker.",
fg="red",
err=True,
)
typer.secho(
"If you have verified the fingerprint above out-of-band "
"(e.g. via the UniFi Protect console), rerun the command "
"with --no-verify-ssl to skip verification for this "
"invocation.",
err=True,
)
raise typer.Exit(code=1) from exc
typer.secho(f"Connection failed: {exc}", fg="red")
raise typer.Exit(code=1) from exc
ctx.obj = CliContext(protect=protect, output_format=output_format)
def _setup_logger(level: int = logging.DEBUG, show_level: bool = False) -> None:
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
if show_level:
formatter = logging.Formatter("%(levelname)s: %(message)s")
console_handler.setFormatter(formatter)
_LOGGER.setLevel(logging.DEBUG)
_LOGGER.addHandler(console_handler)
async def _progress_bar(wait_time: int, label: str) -> None:
for i in track(range(wait_time // SLEEP_INTERVAL), description=label):
if i > 0:
await asyncio.sleep(SLEEP_INTERVAL)
@app.command()
def shell(ctx: typer.Context) -> None:
"""
Opens iPython shell with Protect client initialized.
Requires the `shell` extra to also be installed.
"""
if embed is None or colored is None:
typer.echo("ipython and termcolor required for shell subcommand")
sys.exit(1)
# locals passed to shell
protect = cast(
"ProtectApiClient",
ctx.obj.protect,
)
_setup_logger(show_level=True)
async def wait_forever() -> None:
await protect.update()
protect.subscribe_websocket(lambda _: None)
while True:
await asyncio.sleep(10)
await protect.update()
c = get_config()
c.InteractiveShellEmbed.colors = "Linux"
embed( # type: ignore[no-untyped-call]
header=colored("protect = ProtectApiClient(*args)", "green"),
config=c,
using="asyncio",
)
@app.command()
def generate_sample_data(
ctx: typer.Context,
anonymize: bool = OPTION_ANON,
wait_time: int = OPTION_WAIT,
output_folder: Path | None = OPTION_OUTPUT,
do_zip: bool = OPTION_ZIP,
) -> None:
"""Generates sample data for UniFi Protect instance."""
protect = cast("ProtectApiClient", ctx.obj.protect)
if output_folder is None:
tests_folder = Path(__file__).parent.parent / "tests"
if not tests_folder.exists():
typer.secho("Output folder required when not in dev-mode", fg="red")
sys.exit(1)
output_folder = (tests_folder / "sample_data").absolute()
def log(msg: str) -> None:
typer.echo(msg)
def log_warning(msg: str) -> None:
typer.secho(msg, fg="yellow")
SampleDataGenerator(
protect,
output_folder,
anonymize,
wait_time,
log=log,
log_warning=log_warning,
ws_progress=_progress_bar,
do_zip=do_zip,
).generate()
@app.command()
def profile_ws(
ctx: typer.Context,
wait_time: int = OPTION_WAIT,
output_path: Path | None = OPTION_OUTPUT,
) -> None:
"""Profiles Websocket messages for UniFi Protect instance."""
protect = cast("ProtectApiClient", ctx.obj.protect)
async def callback() -> None:
await protect.update()
unsub = protect.subscribe_websocket(lambda _: None)
await profile_ws_job(
protect,
wait_time,
output_path=output_path,
ws_progress=_progress_bar,
)
unsub()
await protect.async_disconnect_ws()
await protect.close_session()
await protect.close_public_api_session()
_setup_logger()
run_async(callback())
@app.command()
def decode_ws_msg(
ws_file: typer.FileBinaryRead = OPTION_WS_FILE,
ws_data: str | None = ARG_WS_DATA,
) -> None:
"""Decodes a base64 encoded UniFi Protect Websocket binary message."""
if ws_file is None and ws_data is None: # type: ignore[unreachable]
typer.secho("Websocket data required", fg="red") # type: ignore[unreachable]
sys.exit(1)
ws_data_raw = b""
if ws_file is not None:
ws_data_raw = ws_file.read()
elif ws_data is not None: # type: ignore[unreachable]
ws_data_raw = base64.b64decode(ws_data.encode("utf8"))
packet = WSPacket(ws_data_raw)
response = {"action": packet.action_frame.data, "data": packet.data_frame.data}
typer.echo(orjson.dumps(response).decode("utf-8"))
@app.command()
def create_api_key(
ctx: typer.Context,
name: str = typer.Argument(..., help="Name for the API key"),
) -> None:
"""Create a new API key for the current user."""
protect = cast("ProtectApiClient", ctx.obj.protect)
async def callback() -> str:
api_key = await protect.create_api_key(name)
await protect.close_session()
await protect.close_public_api_session()
return api_key
_setup_logger()
result = run_async(callback())
typer.echo(result)
@app.command()
def get_meta_info(ctx: typer.Context) -> None:
"""Get metadata about the current UniFi Protect instance."""
protect = cast("ProtectApiClient", ctx.obj.protect)
async def callback() -> MetaInfo:
meta = await protect.get_meta_info()
await protect.close_session()
await protect.close_public_api_session()
return meta
_setup_logger()
result = run_async(callback())
typer.echo(result.model_dump_json())
uilibs-uiprotect-317a52c/src/uiprotect/cli/aiports.py 0000664 0000000 0000000 00000003011 15246377057 0022766 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import AiPort
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(
None, help="ID of AiPort device to select for subcommands"
)
@dataclass
class AiPortContext(base.CliContext):
devices: dict[str, AiPort]
device: AiPort | None = None
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
"""
AiPort device CLI.
Returns full list of AiPorts without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = AiPortContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.aiports,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if device_id is not None and device_id not in ALL_COMMANDS:
if (device := protect.bootstrap.aiports.get(device_id)) is None:
typer.secho("Invalid aiport ID", fg="red")
raise typer.Exit(1)
ctx.obj.device = device
if not ctx.invoked_subcommand:
if device_id in ALL_COMMANDS:
ctx.invoke(ALL_COMMANDS[device_id], ctx)
return
if ctx.obj.device is not None:
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
return
base.print_unifi_dict(ctx.obj.devices)
uilibs-uiprotect-317a52c/src/uiprotect/cli/arm.py 0000664 0000000 0000000 00000003331 15246377057 0022071 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_PROFILE_ID = typer.Argument(..., help="Arm profile ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Arm profile and alarm commands (Public API)."""
@app.command("list")
def list_profiles(ctx: typer.Context) -> None:
"""List all arm profiles."""
async def _fetch() -> None:
profiles = await ctx.obj.protect.get_arm_profiles_public()
base.print_unifi_list(profiles)
base.run(ctx, _fetch())
@app.command()
def status(ctx: typer.Context) -> None:
"""Show current arm manager settings (active profile + alarm state)."""
async def _fetch() -> None:
arm_mode = await ctx.obj.protect.get_arm_manager_settings_public()
base.print_unifi_obj(arm_mode, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def set_profile(
ctx: typer.Context,
profile_id: str = ARG_PROFILE_ID,
) -> None:
"""Set the currently active arm profile."""
base.run(ctx, ctx.obj.protect.set_current_arm_profile_public(profile_id))
@app.command()
def enable_alarm(ctx: typer.Context) -> None:
"""Enable the arm alarm using the currently selected profile."""
base.run(ctx, ctx.obj.protect.enable_arm_alarm_public())
@app.command()
def disable_alarm(ctx: typer.Context) -> None:
"""Disable the arm alarm."""
base.run(ctx, ctx.obj.protect.disable_arm_alarm_public())
@app.command()
def trigger(
ctx: typer.Context,
trigger_id: str = typer.Argument(..., help="Webhook trigger ID"),
) -> None:
"""Fire the alarm-manager webhook for the given trigger ID."""
base.run(ctx, ctx.obj.protect.send_alarm_webhook_public(trigger_id))
uilibs-uiprotect-317a52c/src/uiprotect/cli/backup.py 0000664 0000000 0000000 00000112615 15246377057 0022565 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import logging
import math
import re
import sys
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import aiofiles
import aiofiles.os as aos
import av
import dateparser
import typer
from PIL import Image
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
track,
)
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func, or_, select
from sqlalchemy import event as saevent
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.orm import Mapped, declarative_base, relationship
from .. import data as d
from ..api import ProtectApiClient
from ..cli import base
from ..utils import (
format_duration,
get_local_timezone,
local_datetime,
utc_now,
)
if TYPE_CHECKING:
from click.core import Parameter
app = typer.Typer(rich_markup_mode="rich")
Base = declarative_base()
_LOGGER = logging.getLogger(__name__)
_SLUG_BAD = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_slug(value: str, sep: str) -> str:
"""Sanitize a server-supplied string for safe use in a filesystem path."""
cleaned = _SLUG_BAD.sub(sep, value).strip(sep + "./")
return cleaned or "unknown"
def _safe_join(base: Path, file_path: str) -> Path:
"""Join ``file_path`` onto ``base`` and refuse paths escaping ``base``."""
candidate = (base / file_path).resolve()
base_resolved = base.resolve()
if not candidate.is_relative_to(base_resolved):
raise ValueError(
f"refusing to use path outside output dir: {candidate}",
)
return candidate
def _safe_first_glob_match(base: Path, pattern: str) -> Path | None:
"""Return first ``base.glob(pattern)`` match contained in ``base``."""
if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
_LOGGER.warning(
"Ignoring unsafe glob pattern outside output dir: %s",
pattern,
)
return None
base_resolved = base.resolve()
for match in base.glob(pattern):
resolved = match.resolve()
if resolved.is_relative_to(base_resolved):
return resolved
_LOGGER.warning("Ignoring path outside output dir: %s", resolved)
return None
def _on_db_connect(dbapi_con, connection_record) -> None: # type: ignore[no-untyped-def]
cursor = dbapi_con.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
@dataclass
class BackupContext(base.CliContext):
start: datetime
end: datetime | None
output: Path
separator: str
thumbnail_format: str
gif_format: str
event_format: str
title_format: str
max_download: int
page_size: int
length_cutoff: timedelta
_db_engine: AsyncEngine | None = None
_db_session: AsyncSession | None = None
@property
def download_thumbnails(self) -> bool:
return self.thumbnail_format != ""
@property
def download_gifs(self) -> bool:
return self.gif_format != ""
@property
def download_videos(self) -> bool:
return self.event_format != ""
@property
def db_file(self) -> Path:
return self.output / "events.db"
@property
def db_engine(self) -> AsyncEngine:
if self._db_engine is None:
self._db_engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_file}")
self._db_session = None
saevent.listens_for(self._db_engine.sync_engine, "connect")(_on_db_connect)
return self._db_engine
def create_db_session(self) -> AsyncSession:
return AsyncSession(bind=self.db_engine, expire_on_commit=False)
async def create_db(self) -> None:
async with self.db_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
class EventTypeChoice(StrEnum):
MOTION = d.EventType.MOTION.value
RING = d.EventType.RING.value
SMART_DETECT = d.EventType.SMART_DETECT.value
SMART_DETECT_LINE = d.EventType.SMART_DETECT_LINE.value
class EventSmartType(Base): # type: ignore[valid-type,misc]
__tablename__ = "event_smart_type"
id = Column(Integer, primary_key=True)
event_id = Column(String(24), ForeignKey("event.id"), nullable=False)
smart_type = Column(String(32), index=True)
class Event(Base): # type: ignore[valid-type,misc]
__tablename__ = "event"
__allow_unmapped__ = True
id = Column(String(24), primary_key=True)
start_naive = Column(DateTime())
end_naive = Column(DateTime(), nullable=True)
camera_mac = Column(String(12), index=True)
event_type = Column(String(32), index=True)
smart_detect_types: Mapped[list[EventSmartType]] = relationship(
"EventSmartType",
lazy="joined",
uselist=True,
)
_start: datetime | None = None
_end: datetime | None = None
_smart_types: set[str] | None = None
_context: dict[str, str] | None = None
_glob_context: dict[str, str] | None = None
@property
def start(self) -> datetime:
if self._start is None:
self._start = self.start_naive.replace(tzinfo=UTC) # type: ignore[union-attr]
return self._start
@property
def end(self) -> datetime | None:
if self._end is None and self.end_naive is not None:
self._end = self.end_naive.replace(tzinfo=UTC)
return self._end
@property
def smart_types(self) -> set[str]:
if self._smart_types is None:
self._smart_types = {s.smart_type for s in self.smart_detect_types} # type: ignore[misc]
return self._smart_types
def get_file_context(self, ctx: BackupContext) -> dict[str, str]:
if self._context is None:
camera = ctx.protect.bootstrap.get_device_from_mac(self.camera_mac) # type: ignore[arg-type]
camera_slug = ""
display_name = ""
length = timedelta(seconds=0)
if camera is not None:
raw_name = camera.display_name
safe_name = _safe_slug(
raw_name.lower().replace(" ", ctx.separator),
ctx.separator,
)
camera_slug = safe_name + ctx.separator
display_name = raw_name
if self.end is not None:
length = self.end - self.start
event_type = str(self.event_type)
event_type_pretty = f"{event_type.title()} Event"
if event_type in {
d.EventType.SMART_DETECT.value,
d.EventType.SMART_DETECT_LINE.value,
}:
smart_types = list(self.smart_types)
smart_types.sort()
event_type = f"{event_type}[{','.join(smart_types)}]"
smart_types_title = [s.title() for s in smart_types]
event_type_pretty = f"Smart Detection ({', '.join(smart_types_title)})"
start_local = local_datetime(self.start)
self._context = {
"year": str(self.start.year),
"month": str(self.start.month),
"day": str(self.start.day),
"hour": str(self.start.hour),
"minute": str(self.start.minute),
"datetime": self.start.strftime("%Y-%m-%dT%H-%M-%S%z").replace(
"-",
ctx.separator,
),
"date": self.start.strftime("%Y-%m-%d").replace("-", ctx.separator),
"time": self.start.strftime("%H-%M-%S%z").replace("-", ctx.separator),
"time_sort_pretty": self.start.strftime("%H:%M:%S (%Z)"),
"time_pretty": self.start.strftime("%I:%M:%S %p (%Z)"),
"year_local": str(start_local.year),
"month_local": str(start_local.month),
"day_local": str(start_local.day),
"hour_local": str(start_local.hour),
"minute_local": str(start_local.minute),
"datetime_local": start_local.strftime("%Y-%m-%dT%H-%M-%S%z").replace(
"-",
ctx.separator,
),
"date_local": start_local.strftime("%Y-%m-%d").replace(
"-",
ctx.separator,
),
"time_local": start_local.strftime("%H-%M-%S%z").replace(
"-",
ctx.separator,
),
"time_sort_pretty_local": start_local.strftime("%H:%M:%S (%Z)"),
"time_pretty_local": start_local.strftime("%I:%M:%S %p (%Z)"),
"mac": str(self.camera_mac),
"camera_name": display_name,
"camera_slug": camera_slug,
"event_type": event_type,
"event_type_pretty": event_type_pretty,
"length_pretty": format_duration(length),
"sep": ctx.separator,
}
self._context["title"] = ctx.title_format.format(**self._context)
return self._context
def get_glob_file_context(self, ctx: BackupContext) -> dict[str, str]:
if self._glob_context is None:
self._glob_context = self.get_file_context(ctx).copy()
self._glob_context["camera_slug"] = "*"
self._glob_context["camera_name"] = "*"
return self._glob_context
def get_thumbnail_path(self, ctx: BackupContext) -> Path:
context = self.get_file_context(ctx)
file_path = ctx.thumbnail_format.format(**context)
return _safe_join(ctx.output, file_path)
def get_existing_thumbnail_path(self, ctx: BackupContext) -> Path | None:
context = self.get_glob_file_context(ctx)
file_path = ctx.thumbnail_format.format(**context)
return _safe_first_glob_match(ctx.output, file_path)
def get_gif_path(self, ctx: BackupContext) -> Path:
context = self.get_file_context(ctx)
file_path = ctx.gif_format.format(**context)
return _safe_join(ctx.output, file_path)
def get_existing_gif_path(self, ctx: BackupContext) -> Path | None:
context = self.get_glob_file_context(ctx)
file_path = ctx.gif_format.format(**context)
return _safe_first_glob_match(ctx.output, file_path)
def get_event_path(self, ctx: BackupContext) -> Path:
context = self.get_file_context(ctx)
file_path = ctx.event_format.format(**context)
return _safe_join(ctx.output, file_path)
def get_existing_event_path(self, ctx: BackupContext) -> Path | None:
context = self.get_glob_file_context(ctx)
file_path = ctx.event_format.format(**context)
return _safe_first_glob_match(ctx.output, file_path)
@dataclass
class QueuedDownload:
task: asyncio.Task[bool] | None
args: list[Any]
def relative_datetime(ctx: typer.Context, value: str, param: Parameter) -> datetime:
if dt := dateparser.parse(value):
return dt
raise typer.BadParameter(
"Must be a ISO 8601 format or human readable relative format",
ctx,
param,
)
_DownloadEventQueue = asyncio.Queue[QueuedDownload]
OPTION_OUTPUT = typer.Option(
None,
help="Base dir for creating files. Defaults to $PWD.",
envvar="UFP_BACKUP_OUTPUT",
)
OPTION_START = typer.Option(
None,
"-s",
"--start",
help="Cutoff for start of backup. Defaults to start of recording for NVR.",
envvar="UFP_BACKUP_START",
)
OPTION_PAGE_SIZE = typer.Option(
1000,
"--page-size",
help="Number of events fetched at once from local database. Increases memory usage.",
)
OPTION_LENGTH_CUTOFF = typer.Option(
timedelta(hours=1).total_seconds(),
"--length-cutoff",
help="Event size cutoff for detecting abnormal events (in seconds).",
)
OPTION_END = typer.Option(
None,
"-e",
"--end",
help="Cutoff for end of backup. Defaults to now.",
envvar="UFP_BACKUP_END",
)
OPTION_EVENT_TYPES = typer.Option(
list(EventTypeChoice),
"-t",
"--event-type",
help="Events to export. Can be used multiple time.",
)
OPTION_SMART_TYPES = typer.Option(
list(d.SmartDetectObjectType),
"-m",
"--smart-type",
help="Smart Detection types to export. Can be used multiple time.",
)
OPTION_SEPARATOR = typer.Option("-", "--sep", help="Separator used for formatting.")
OPTION_THUMBNAIL_FORMAT = typer.Option(
"{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}thumb.jpg",
"--thumb-format",
help='Filename format to save event thumbnails to. Set to empty string ("") to skip saving event thumbnails.',
)
OPTION_GIF_FORMAT = typer.Option(
"{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}animated.gif",
"--gif-format",
help='Filename format to save event gifs to. Set to empty string ("") to skip saving event gif.',
)
OPTION_EVENT_FORMAT = typer.Option(
"{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}.mp4",
"--event-format",
help='Filename format to save event gifs to. Set to empty string ("") to skip saving event videos.',
)
OPTION_TITLE_FORMAT = typer.Option(
"{time_sort_pretty_local} {sep} {camera_name} {sep} {event_type_pretty} {sep} {length_pretty}",
"--title-format",
help="Format to use to tag title for video metadata.",
)
OPTION_VERBOSE = typer.Option(False, "-v", "--verbose", help="Debug logging.")
OPTION_MAX_DOWNLOAD = typer.Option(
5,
"-d",
"--max-download",
help="Max number of concurrent downloads. Adds additional loads to NVR.",
)
def _setup_logger(verbose: bool) -> None:
console_handler = logging.StreamHandler()
log_format = "[%(asctime)s] %(levelname)s - %(message)s"
if verbose:
console_handler.setLevel(logging.DEBUG)
elif sys.stdout.isatty():
console_handler.setLevel(logging.WARNING)
log_format = "%(message)s"
else:
console_handler.setLevel(logging.INFO)
formatter = logging.Formatter(log_format)
console_handler.setFormatter(formatter)
root_logger = logging.getLogger("uiprotect")
root_logger.setLevel(logging.DEBUG)
root_logger.addHandler(console_handler)
@app.callback()
def main(
ctx: typer.Context,
start: str | None = OPTION_START,
end: str | None = OPTION_END,
output_folder: Path | None = OPTION_OUTPUT,
thumbnail_format: str = OPTION_THUMBNAIL_FORMAT,
gif_format: str = OPTION_GIF_FORMAT,
event_format: str = OPTION_EVENT_FORMAT,
title_format: str = OPTION_TITLE_FORMAT,
verbose: bool = OPTION_VERBOSE,
max_download: int = OPTION_MAX_DOWNLOAD,
page_size: int = OPTION_PAGE_SIZE,
length_cutoff: int = OPTION_LENGTH_CUTOFF,
separator: str = OPTION_SEPARATOR,
) -> None:
"""
Backup CLI.
The backup CLI is still very WIP in progress and consider experimental and potentially unstable (interface may change in the future).
"""
_setup_logger(verbose)
protect: ProtectApiClient = ctx.obj.protect
local_tz = get_local_timezone()
if start is None:
start_dt = protect.bootstrap.recording_start
else:
start_dt = relative_datetime(ctx, start, ctx.command.params[0])
start_dt = start_dt.replace(tzinfo=local_tz)
if start_dt is None:
start_dt = utc_now()
end_dt = None
if end is not None:
end_dt = relative_datetime(ctx, end, ctx.command.params[1])
end_dt = end_dt.replace(tzinfo=local_tz)
if output_folder is None:
output_folder = Path.cwd()
context = BackupContext(
protect=ctx.obj.protect,
start=start_dt,
end=end_dt,
output_format=ctx.obj.output_format,
output=output_folder,
thumbnail_format=thumbnail_format,
gif_format=gif_format,
event_format=event_format,
title_format=title_format,
max_download=max_download,
page_size=page_size,
length_cutoff=timedelta(seconds=length_cutoff),
separator=separator,
)
ctx.obj = context
def _wipe_files(ctx: BackupContext, no_input: bool) -> None:
if not no_input and not typer.confirm(
"Are you sure you want to delete all existing thumbnails and video clips?",
):
raise typer.Exit(1)
if ctx.db_file.exists():
ctx.db_file.unlink()
for path in track(ctx.output.glob("**/*.jpg"), description="Deleting Thumbnails"):
path.unlink()
for path in track(ctx.output.glob("**/*.mp4"), description="Deleting Clips"):
path.unlink()
async def _newest_event(ctx: BackupContext) -> Event | None:
db = ctx.create_db_session()
async with db:
result = await db.execute(select(Event).order_by(Event.start_naive.desc()))
return result.scalars().first()
async def _prune_events(ctx: BackupContext) -> int:
_LOGGER.debug("Pruning events before %s", ctx.start)
deleted = 0
db = ctx.create_db_session()
async with db:
result = await db.execute(
select(Event).join(EventSmartType).where(Event.start_naive < ctx.start),
)
for event in track(result.unique().scalars(), description="Pruning Events"):
try:
thumb_path = event.get_thumbnail_path(ctx)
except ValueError as exc:
_LOGGER.warning("Skipping prune of thumbnail: %s", exc)
else:
if thumb_path.exists():
_LOGGER.debug("Delete file %s", thumb_path)
await aos.remove(thumb_path)
try:
event_path = event.get_event_path(ctx)
except ValueError as exc:
_LOGGER.warning("Skipping prune of event video: %s", exc)
else:
if event_path.exists():
_LOGGER.debug("Delete file %s", event_path)
await aos.remove(event_path)
if event.event_type in {
d.EventType.SMART_DETECT.value,
d.EventType.SMART_DETECT_LINE.value,
}:
for smart_type in event.smart_detect_types:
await db.delete(smart_type)
await db.delete(event)
deleted += 1
await db.commit()
return deleted
async def _update_event(ctx: BackupContext, event: d.Event) -> None:
if event.camera is None:
return
db = ctx.create_db_session()
to_delete: list[EventSmartType] = []
async with db:
result = await db.execute(select(Event).where(Event.id == event.id))
db_event = result.scalars().first()
do_insert = False
if db_event is None:
db_event = Event(id=event.id)
do_insert = True
db_event.start_naive = event.start
db_event.end_naive = event.end
db_event.camera_mac = event.camera.mac
db_event.event_type = event.type.value
if event.type in {
d.EventType.SMART_DETECT.value,
d.EventType.SMART_DETECT_LINE.value,
}:
types = {e.value for e in event.smart_detect_types}
result = await db.execute(
select(EventSmartType).where(EventSmartType.event_id == event.id),
)
for event_smart_type in result.unique().scalars():
event_type = cast("EventSmartType", event_smart_type)
if event_type.smart_type not in types:
to_delete.append(event_type)
else:
types.remove(event_type.smart_type)
for smart_type_str in types:
db.add(EventSmartType(event_id=event.id, smart_type=smart_type_str))
if do_insert:
db.add(db_event)
for smart_type in to_delete:
await db.delete(smart_type)
await db.commit()
async def _update_ongoing_events(ctx: BackupContext) -> int:
db = ctx.create_db_session()
async with db:
result = await db.execute(
select(Event)
.where(Event.event_type != "ring")
.where(Event.end_naive is None), # type: ignore[arg-type]
)
events = list(result.unique().scalars())
if len(events) == 0:
return 0
for event in track(events, description="Updating Events"):
event_id = cast("str", event.id)
await _update_event(ctx, await ctx.protect.get_event(event_id))
return len(events)
async def _update_events(ctx: BackupContext) -> int:
# update any events that are still set as ongoing in the database
updated_ongoing = await _update_ongoing_events(ctx)
start = ctx.start
end = ctx.end or utc_now()
processed: set[str] = set()
total = int((end - ctx.start).total_seconds())
_LOGGER.debug("total: %s: %s %s", total, start, end)
prev_start = start
with Progress() as pb:
task_id = pb.add_task("Fetching New Events", total=total)
task = pb.tasks[0]
pb.refresh()
while not pb.finished:
progress = int((start - prev_start).total_seconds())
pb.update(task_id, advance=progress)
_LOGGER.debug(
"progress: +%s: %s/%s: %s %s",
progress,
task.completed,
task.total,
start,
end,
)
events = await ctx.protect.get_events(
start,
end,
limit=100,
types=[
d.EventType.MOTION,
d.EventType.RING,
d.EventType.SMART_DETECT,
d.EventType.SMART_DETECT_LINE,
],
)
prev_start = start
count = 0
for event in events:
start = event.start
if event.id not in processed:
count += 1
processed.add(event.id)
await _update_event(ctx, event)
if start == prev_start and count == 0:
pb.update(task_id, completed=total)
return updated_ongoing + len(processed)
async def _download_watcher(
count: int,
tasks: _DownloadEventQueue,
no_error_flag: asyncio.Event,
) -> int:
processed = 0
loop = asyncio.get_running_loop()
downloaded = 0
last_print = time.monotonic()
while processed < count:
download = await tasks.get()
task = download.task
if task is None:
processed += 1
continue
retries = 0
while True:
try:
await task
except asyncio.CancelledError:
return downloaded
except Exception:
pass
event: Event = download.args[1]
if exception := task.exception():
no_error_flag.clear()
if retries < 5:
wait = math.pow(2, retries)
_LOGGER.warning(
"Exception while downloading event (%s): %s. Retring in %s second(s)",
event.id,
exception,
wait,
)
await asyncio.sleep(wait)
retries += 1
task = loop.create_task(_download_event(*download.args))
else:
_LOGGER.error("Failed to download event %s", event.id)
if exception is None or retries >= 5:
no_error_flag.set()
processed += 1
now = time.monotonic()
if now - last_print > 60:
_LOGGER.info(
"Processed %s/%s (%.2f%%) events",
processed,
count,
processed / count,
)
last_print = now
if exception is None and task.result():
downloaded += 1
break
return downloaded
def _verify_thumbnail(path: Path) -> bool:
try:
image = Image.open(path)
image.verify()
# no docs on what exception could be
except Exception:
return False
return True
async def _download_event_thumb(
ctx: BackupContext,
event: Event,
verify: bool,
force: bool,
animated: bool = False,
) -> bool:
if animated:
thumb_type = "gif"
thumb_path = event.get_gif_path(ctx)
existing_thumb_path = event.get_existing_gif_path(ctx)
else:
thumb_type = "thumbnail"
thumb_path = event.get_thumbnail_path(ctx)
existing_thumb_path = event.get_existing_thumbnail_path(ctx)
if force and existing_thumb_path:
_LOGGER.debug("Delete file %s", existing_thumb_path)
await aos.remove(existing_thumb_path)
if existing_thumb_path and str(existing_thumb_path) != str(thumb_path):
_LOGGER.debug(
"Rename event %s file %s: %s %s %s: %s",
thumb_type,
event.id,
event.start,
event.end,
event.event_type,
thumb_path,
)
await aos.makedirs(thumb_path.parent, exist_ok=True)
await aos.rename(existing_thumb_path, thumb_path)
if (
verify
and thumb_path.exists()
and not await asyncio.get_running_loop().run_in_executor(
None, _verify_thumbnail, thumb_path
)
):
_LOGGER.warning(
"Corrupted event %s file for event (%s), redownloading",
thumb_type,
event.id,
)
await aos.remove(thumb_path)
if not thumb_path.exists():
_LOGGER.debug(
"Download event %s %s: %s %s: %s",
thumb_type,
event.id,
event.start,
event.event_type,
thumb_path,
)
event_id = str(event.id)
if animated:
thumbnail = await ctx.protect.get_event_animated_thumbnail(event_id)
else:
thumbnail = await ctx.protect.get_event_thumbnail(event_id)
if thumbnail is not None:
await aos.makedirs(thumb_path.parent, exist_ok=True)
async with aiofiles.open(thumb_path, mode="wb") as f:
await f.write(thumbnail)
return True
return False
def _verify_video_file( # type: ignore[return]
path: Path,
length: float,
width: int,
height: int,
title: str,
) -> tuple[bool, bool]:
try:
with av.open(str(path)) as video:
slength = float(
video.streams.video[0].duration * video.streams.video[0].time_base, # type: ignore[operator]
)
valid = (
(slength / length) > 0.80 # export is fuzzy
and video.streams.video[0].codec_context.width == width
and video.streams.video[0].codec_context.height == height
)
metadata_valid = False
if valid:
metadata_valid = bool(video.metadata["title"] == title)
return valid, metadata_valid
# no docs on what exception could be
except Exception:
return False, False
def _add_metadata(path: Path, creation: datetime, title: str) -> bool:
creation = local_datetime(creation)
output_path = path.parent / path.name.replace(".mp4", ".metadata.mp4")
success = True
try:
with (
av.open(str(path)) as input_file,
av.open(str(output_path), "w") as output_file,
):
for key, value in input_file.metadata.items():
output_file.metadata[key] = value
output_file.metadata["creation_time"] = creation.isoformat()
output_file.metadata["title"] = title
output_file.metadata["year"] = creation.date().isoformat()
output_file.metadata["release"] = creation.date().isoformat()
in_to_out: dict[str, Any] = {}
for stream in input_file.streams:
in_to_out[stream] = output_file.add_stream(template=stream) # type: ignore[index,call-overload]
in_to_out[stream].metadata["creation_time"] = creation.isoformat() # type: ignore[index]
for packet in input_file.demux(list(in_to_out)):
if packet.dts is None:
continue
packet.stream = in_to_out[packet.stream] # type: ignore[index]
try:
output_file.mux(packet) # type: ignore[arg-type]
# some frames may be corrupted on disk from NVR
except ValueError:
continue
# no docs on what exception could be
except Exception:
success = False
finally:
if success:
path.unlink()
output_path.rename(path)
elif output_path.exists():
output_path.unlink()
return success
async def _download_event_video(
ctx: BackupContext,
camera: d.Camera,
event: Event,
verify: bool,
force: bool,
) -> bool:
event_path = event.get_event_path(ctx)
existing_event_path = event.get_existing_event_path(ctx)
if force and existing_event_path:
_LOGGER.debug("Delete file %s", existing_event_path)
await aos.remove(existing_event_path)
if existing_event_path and str(existing_event_path) != str(event_path):
_LOGGER.debug(
"Rename event file %s: %s %s %s: %s",
event.id,
event.start,
event.end,
event.event_type,
event_path,
)
await aos.makedirs(event_path.parent, exist_ok=True)
await aos.rename(existing_event_path, event_path)
metadata_valid = True
if verify and event_path.exists():
valid = False
if event.end is not None:
valid, metadata_valid = await asyncio.get_running_loop().run_in_executor(
None,
_verify_video_file,
event_path,
(event.end - event.start).total_seconds(),
camera.channels[0].width,
camera.channels[0].height,
event.get_file_context(ctx)["title"],
)
if not valid:
_LOGGER.warning(
"Corrupted video file for event (%s), redownloading",
event.id,
)
await aos.remove(event_path)
downloaded = False
if not event_path.exists() and event.end is not None:
_LOGGER.debug(
"Download event %s: %s %s %s: %s",
event.id,
event.start,
event.end,
event.event_type,
event_path,
)
await aos.makedirs(event_path.parent, exist_ok=True)
await camera.get_video(event.start, event.end, output_file=event_path)
downloaded = True
if (downloaded or not metadata_valid) and event.end is not None:
file_context = event.get_file_context(ctx)
if not await asyncio.get_running_loop().run_in_executor(
None, _add_metadata, event_path, event.start, file_context["title"]
):
_LOGGER.warning("Failed to write metadata for event (%s)", event.id)
return downloaded
async def _download_event(
ctx: BackupContext,
event: Event,
verify: bool,
force: bool,
pb: Progress,
) -> bool:
downloaded = False
camera = ctx.protect.bootstrap.get_device_from_mac(event.camera_mac) # type: ignore[arg-type]
if camera is not None:
camera = cast("d.Camera", camera)
downloads = []
if ctx.download_thumbnails:
downloads.append(_download_event_thumb(ctx, event, verify, force))
if ctx.download_gifs:
downloads.append(
_download_event_thumb(ctx, event, verify, force, animated=True),
)
if ctx.download_thumbnails:
downloads.append(_download_event_video(ctx, camera, event, verify, force))
downloaded = any(await asyncio.gather(*downloads))
pb.update(pb.tasks[0].id, advance=1)
return downloaded
async def _download_events(
ctx: BackupContext,
event_types: list[d.EventType],
smart_types: list[d.SmartDetectObjectType],
verify: bool,
force: bool,
) -> tuple[int, int]:
start = ctx.start
end = ctx.end or utc_now()
db = ctx.create_db_session()
async with db:
count_query = (
select(func.count(Event.id))
.where(Event.event_type.in_([e.value for e in event_types]))
.where(Event.start_naive >= start)
.where(or_(Event.end_naive <= end, Event.end_naive is None)) # type: ignore[arg-type]
)
count = cast("int", (await db.execute(count_query)).scalar())
_LOGGER.info("Downloading %s events", count)
columns = [
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeRemainingColumn(),
]
with Progress(*columns) as pb:
task_id = pb.add_task("Downloading Events", total=count)
query = (
select(Event)
.where(Event.event_type.in_([e.value for e in event_types]))
.where(Event.start_naive >= start)
.where(or_(Event.end_naive <= end, Event.end_naive is None)) # type: ignore[arg-type]
.limit(ctx.page_size)
)
smart_types_set = {s.value for s in smart_types}
loop = asyncio.get_running_loop()
tasks: _DownloadEventQueue = asyncio.Queue(maxsize=ctx.max_download - 1)
no_error_flag = asyncio.Event()
no_error_flag.set()
watcher_task = loop.create_task(
_download_watcher(count, tasks, no_error_flag),
)
offset = 0
page = query
while offset < count:
result = await db.execute(page)
for event in result.unique().scalars():
if event.end is None:
continue
length = event.end - event.start
if length > ctx.length_cutoff:
_LOGGER.warning(
"Skipping event %s because it is too long (%s)",
event.id,
length,
)
await tasks.put(QueuedDownload(task=None, args=[]))
continue
# ensure no tasks are currently in a retry state
await no_error_flag.wait()
if event.event_type in {
d.EventType.SMART_DETECT.value,
d.EventType.SMART_DETECT_LINE.value,
} and not event.smart_types.intersection(smart_types_set):
continue
task = loop.create_task(
_download_event(ctx, event, verify, force, pb),
)
# waits for a free processing slot
await tasks.put(
QueuedDownload(task=task, args=[ctx, event, verify, force, pb]),
)
offset += ctx.page_size
page = query.offset(offset)
try:
await watcher_task
downloaded = watcher_task.result()
except asyncio.CancelledError:
downloaded = 0
pb.update(task_id, completed=count)
return count, downloaded
async def _events(
ctx: BackupContext,
event_types: list[d.EventType],
smart_types: list[d.SmartDetectObjectType],
prune: bool,
force: bool,
verify: bool,
no_input: bool,
) -> None:
try:
await ctx.create_db()
if prune and not force:
_LOGGER.warning("Pruned %s old event(s)", await _prune_events(ctx))
original_start = ctx.start
if not force:
event = await _newest_event(ctx)
if event is not None:
ctx.start = event.start
_LOGGER.warning("Updated %s event(s)", await _update_events(ctx))
ctx.start = original_start
count, downloaded = await _download_events(
ctx,
event_types,
smart_types,
verify,
force,
)
verified = count - downloaded
_LOGGER.warning(
"Total events: %s. Verified %s existing event(s). Downloaded %s new event(s)",
count,
verified,
downloaded,
)
finally:
_LOGGER.debug("Cleaning up Protect connection/database...")
await ctx.protect.close_session()
await ctx.protect.close_public_api_session()
await ctx.db_engine.dispose()
@app.command(name="events")
def events_cmd(
ctx: typer.Context,
event_types: list[EventTypeChoice] = OPTION_EVENT_TYPES,
smart_types: list[d.SmartDetectObjectType] = OPTION_SMART_TYPES,
prune: bool = typer.Option(
False,
"-p",
"--prune",
help="Prune events older then start.",
),
force: bool = typer.Option(
False,
"-f",
"--force",
help="Force update all events and redownload all clips.",
),
verify: bool = typer.Option(
False,
"-v",
"--verify",
help="Verifies files on disk.",
),
no_input: bool = typer.Option(False, "--no-input"),
) -> None:
"""Backup thumbnails and video clips for camera events."""
# suppress av logging messages
av.logging.set_level(av.logging.PANIC)
ufp_events = [d.EventType(e.value) for e in event_types]
if prune and force:
_wipe_files(ctx.obj, no_input)
asyncio.run(
_events(ctx.obj, ufp_events, smart_types, prune, force, verify, no_input),
)
uilibs-uiprotect-317a52c/src/uiprotect/cli/base.py 0000664 0000000 0000000 00000017747 15246377057 0022244 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, TypeVar
import orjson
import typer
from pydantic import ValidationError
from ..api import ProtectApiClient
from ..data import (
NVR,
AiPort,
Camera,
Chime,
Light,
ProtectAdoptableDeviceModel,
ProtectBaseObject,
Sensor,
Viewer,
)
from ..exceptions import BadRequest, NvrError, StreamError
from ..utils import run_async
T = TypeVar("T")
OPTION_FORCE = typer.Option(False, "-f", "--force", help="Skip confirmation prompt")
class OutputFormatEnum(StrEnum):
JSON = "json"
PLAIN = "plain"
@dataclass
class CliContext:
protect: ProtectApiClient
output_format: OutputFormatEnum
def run(ctx: typer.Context, func: Awaitable[T]) -> T:
"""Helper method to call async function and clean up API client"""
async def callback() -> T:
try:
return await func
finally:
await ctx.obj.protect.close_session()
await ctx.obj.protect.close_public_api_session()
try:
return run_async(callback())
except (BadRequest, ValidationError, StreamError, NvrError) as err:
typer.secho(str(err), fg="red")
raise typer.Exit(1) from err
def json_output(obj: Any) -> None:
typer.echo(orjson.dumps(obj, option=orjson.OPT_INDENT_2).decode("utf-8"))
def print_unifi_obj(
obj: ProtectBaseObject | None,
output_format: OutputFormatEnum,
) -> None:
"""Helper method to print a single protect object"""
if obj is not None:
json_output(obj.unifi_dict())
elif output_format == OutputFormatEnum.JSON:
json_output(None)
def print_unifi_list(
objs: Sequence[ProtectBaseObject],
output_format: OutputFormatEnum = OutputFormatEnum.JSON,
) -> None:
"""Helper method to print a list of protect objects"""
if objs:
json_output([o.unifi_dict() for o in objs])
elif output_format == OutputFormatEnum.JSON:
json_output([])
def print_unifi_dict(objs: Mapping[str, ProtectBaseObject]) -> None:
"""Helper method to print a dictionary of protect objects"""
data = {k: v.unifi_dict() for k, v in objs.items()}
json_output(data)
def require_device_id(ctx: typer.Context) -> None:
"""Requires device ID in context"""
if ctx.obj.device is None:
typer.secho("Requires a valid device ID to be selected")
raise typer.Exit(1)
def require_no_device_id(ctx: typer.Context) -> None:
"""Requires no device ID in context"""
if ctx.obj.device is not None:
typer.secho("Requires no device ID to be selected")
raise typer.Exit(1)
def list_ids(ctx: typer.Context) -> None:
"""Requires no device ID. Prints list of "id name" for each device."""
require_no_device_id(ctx)
objs: dict[str, ProtectAdoptableDeviceModel] = ctx.obj.devices
to_print: list[tuple[str, str | None]] = []
for obj in objs.values():
name = obj.display_name
if obj.is_adopted_by_other:
name = f"{name} [Managed by Another Console]"
elif obj.is_adopting:
name = f"{name} [Adopting]"
elif obj.can_adopt:
name = f"{name} [Unadopted]"
elif obj.is_rebooting:
name = f"{name} [Restarting]"
elif obj.is_updating:
name = f"{name} [Updating]"
elif not obj.is_connected:
name = f"{name} [Disconnected]"
to_print.append((obj.id, name))
if ctx.obj.output_format == OutputFormatEnum.JSON:
json_output(to_print)
else:
for item in to_print:
typer.echo(f"{item[0]}\t{item[1]}")
def protect_url(ctx: typer.Context) -> None:
"""Gets UniFi Protect management URL."""
require_device_id(ctx)
obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device
if ctx.obj.output_format == OutputFormatEnum.JSON:
json_output(obj.protect_url)
else:
typer.echo(obj.protect_url)
def is_wired(ctx: typer.Context) -> None:
"""Returns if the device is wired or not."""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
json_output(obj.is_wired)
def is_wifi(ctx: typer.Context) -> None:
"""Returns if the device has WiFi or not."""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
json_output(obj.is_wifi)
def is_bluetooth(ctx: typer.Context) -> None:
"""Returns if the device has Bluetooth or not."""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
json_output(obj.is_bluetooth)
def bridge(ctx: typer.Context) -> None:
"""Returns bridge device if connected via Bluetooth."""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
print_unifi_obj(obj.bridge, ctx.obj.output_format)
def set_ssh(ctx: typer.Context, enabled: bool) -> None:
"""
Sets the isSshEnabled value for device.
May not have an effect on many device types. Only seems to work for
Linux and BusyBox based devices (camera, light and viewport).
"""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
run(ctx, obj.set_ssh(enabled))
def set_name(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None:
"""Sets name for the device"""
require_device_id(ctx)
obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device
# AiPort subclasses Camera but has no public-API endpoint of its own, and
# the public API cannot express clearing a name, so both keep the private
# path.
if (
name is not None
and isinstance(obj, (Camera, Chime, Light, Sensor, Viewer))
and not isinstance(obj, AiPort)
):
run(ctx, obj.set_name_public(name))
return
run(ctx, obj.set_name(name))
def update(ctx: typer.Context, data: str) -> None:
"""
Updates the device.
Makes a raw PATCH request to update a device. Advanced usage and usually recommended not to use.
"""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
if obj.model is not None:
run(ctx, obj.api.update_device(obj.model, obj.id, orjson.loads(data)))
def reboot(ctx: typer.Context, force: bool = OPTION_FORCE) -> None:
"""Reboots the device."""
require_device_id(ctx)
obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device
if force or typer.confirm(f'Confirm reboot of "{obj.name}"" (id: {obj.id})'):
run(ctx, obj.reboot())
def unadopt(ctx: typer.Context, force: bool = OPTION_FORCE) -> None:
"""Unadopt/Unmanage adopted device."""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
if force or typer.confirm(f'Confirm undopt of "{obj.name}"" (id: {obj.id})'):
run(ctx, obj.unadopt())
def adopt(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None:
"""
Adopts a device.
By default, unadopted devices do not show up in the bootstrap. Use
`uiprotect -u` to show unadopted devices.
"""
require_device_id(ctx)
obj: ProtectAdoptableDeviceModel = ctx.obj.device
run(ctx, obj.adopt(name))
def init_common_commands(
app: typer.Typer,
) -> tuple[dict[str, Callable[..., Any]], dict[str, Callable[..., Any]]]:
deviceless_commands: dict[str, Callable[..., Any]] = {}
device_commands: dict[str, Callable[..., Any]] = {}
deviceless_commands["list-ids"] = app.command()(list_ids)
device_commands["is-wired"] = app.command()(is_wired)
device_commands["is-wifi"] = app.command()(is_wifi)
device_commands["is-bluetooth"] = app.command()(is_bluetooth)
device_commands["bridge"] = app.command()(bridge)
device_commands["set-ssh"] = app.command()(set_ssh)
device_commands["set-name"] = app.command()(set_name)
device_commands["update"] = app.command()(update)
device_commands["reboot"] = app.command()(reboot)
device_commands["unadopt"] = app.command()(unadopt)
device_commands["adopt"] = app.command()(adopt)
return deviceless_commands, device_commands
uilibs-uiprotect-317a52c/src/uiprotect/cli/bridges.py 0000664 0000000 0000000 00000002157 15246377057 0022736 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_BRIDGE_ID = typer.Argument(..., help="Bridge ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Bridge commands (Public API)."""
@app.command("list")
def list_bridges(ctx: typer.Context) -> None:
"""List bridges."""
async def _fetch() -> None:
items = await ctx.obj.protect.get_bridges_public()
base.print_unifi_list(items, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, bridge_id: str = ARG_BRIDGE_ID) -> None:
"""Show details for a specific bridge."""
async def _fetch() -> None:
obj = await ctx.obj.protect.get_bridge_public(bridge_id)
base.print_unifi_obj(obj, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def set_name(
ctx: typer.Context,
bridge_id: str = ARG_BRIDGE_ID,
name: str = typer.Argument(..., help="New name for the bridge"),
) -> None:
"""Rename a bridge."""
base.run(ctx, ctx.obj.protect.update_bridge_public(bridge_id, name=name))
uilibs-uiprotect-317a52c/src/uiprotect/cli/cameras.py 0000664 0000000 0000000 00000053557 15246377057 0022744 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
import typer
from rich.progress import Progress
from .. import data as d
from ..api import ProtectApiClient
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(None, help="ID of camera to select for subcommands")
@dataclass
class CameraContext(base.CliContext):
devices: dict[str, d.Camera]
device: d.Camera | None = None
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
"""
Camera device CLI.
Returns full list of Cameras without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = CameraContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.cameras,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if device_id is not None and device_id not in ALL_COMMANDS:
if (device := protect.bootstrap.cameras.get(device_id)) is None:
typer.secho("Invalid camera ID", fg="red")
raise typer.Exit(1)
ctx.obj.device = device
if not ctx.invoked_subcommand:
if device_id in ALL_COMMANDS:
ctx.invoke(ALL_COMMANDS[device_id], ctx)
return
if ctx.obj.device is not None:
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
return
base.print_unifi_dict(ctx.obj.devices)
@app.command()
def timelapse_url(ctx: typer.Context) -> None:
"""Returns UniFi Protect timelapse URL."""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
base.json_output(obj.timelapse_url)
else:
typer.echo(obj.timelapse_url)
@app.command()
def privacy_mode(
ctx: typer.Context,
enabled: bool | None = typer.Argument(None),
) -> None:
"""
Returns/sets library managed privacy mode.
Does not change the microphone sensitivity or recording mode.
It must be changed separately.
"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if enabled is None:
base.json_output(obj.is_privacy_on)
return
base.run(ctx, obj.set_privacy(enabled))
@app.command()
def chime_type(ctx: typer.Context, value: d.ChimeType | None = None) -> None:
"""Returns/sets the current chime type if the camera has a chime."""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if not obj.feature_flags.has_chime:
typer.secho("Camera does not have a chime", fg="red")
raise typer.Exit(1)
if value is None:
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
base.json_output(obj.chime_type)
elif obj.chime_type is not None:
typer.echo(obj.chime_type.name)
return
base.run(ctx, obj.set_chime_type(value))
@app.command()
def stream_urls(ctx: typer.Context) -> None:
"""Returns all of the enabled RTSP(S) URLs."""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
data: list[tuple[str, str]] = []
for channel in obj.channels:
if channel.is_rtsp_enabled:
rtsp_url = cast("str", channel.rtsp_url)
rtsps_url = cast("str", channel.rtsps_url)
data.extend(
(
(f"{channel.name} RTSP", rtsp_url),
(f"{channel.name} RTSPS", rtsps_url),
),
)
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
base.json_output(data)
else:
for name, url in data:
typer.echo(f"{name:20}\t{url}")
@app.command()
def save_snapshot(
ctx: typer.Context,
output_path: Path = typer.Argument(..., help="JPEG format"),
width: int | None = typer.Option(None, "-w", "--width"),
height: int | None = typer.Option(None, "-h", "--height"),
dt: datetime | None = typer.Option(None, "-t", "--timestamp"),
package: bool = typer.Option(False, "-p", "--package", help="Get package camera"),
) -> None:
"""
Takes snapshot of camera.
If you specify a timestamp, they are approximate. It will not export with down to the second
accuracy so it may be +/- a few seconds.
Timestamps use your locale timezone. If it is not configured correctly,
it will default to UTC. You can override your timezone with the
TZ environment variable.
"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if dt is not None:
local_tz = datetime.now(UTC).astimezone().tzinfo
dt = dt.replace(tzinfo=local_tz)
if package:
if not obj.feature_flags.has_package_camera:
typer.secho("Camera does not have package camera", fg="red")
raise typer.Exit(1)
snapshot = base.run(ctx, obj.get_package_snapshot(width, height, dt=dt))
else:
snapshot = base.run(ctx, obj.get_snapshot(width, height, dt=dt))
if snapshot is None:
typer.secho("Could not get snapshot", fg="red")
raise typer.Exit(1)
Path(output_path).write_bytes(snapshot)
@app.command()
def save_video(
ctx: typer.Context,
output_path: Path = typer.Argument(..., help="MP4 format"),
start: datetime = typer.Argument(...),
end: datetime = typer.Argument(...),
channel: int = typer.Option(
0,
"-c",
"--channel",
min=0,
max=3,
help="0 = High, 1 = Medium, 2 = Low, 3 = Package",
),
fps: int | None = typer.Option(
None,
"--fps",
min=1,
max=40,
help="Export as timelapse. 4 = 60x, 8 = 120x, 20 = 300x, 40 = 600x",
),
) -> None:
"""
Exports video of camera.
Exports are approximate. It will not export with down to the second
accuracy so it may be +/- a few seconds.
Uses your locale timezone. If it is not configured correctly,
it will default to UTC. You can override your timezone with the
TZ environment variable.
"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
local_tz = datetime.now(UTC).astimezone().tzinfo
start = start.replace(tzinfo=local_tz)
end = end.replace(tzinfo=local_tz)
if channel == 4 and not obj.feature_flags.has_package_camera:
typer.secho("Camera does not have package camera", fg="red")
raise typer.Exit(1)
with Progress() as pb:
task_id = pb.add_task("(1/2) Exporting", total=100)
async def callback(step: int, current: int, total: int) -> None:
pb.update(
task_id,
total=total,
completed=current,
description="(2/2) Downloading",
)
base.run(
ctx,
obj.get_video(
start,
end,
channel,
output_file=output_path,
progress_callback=callback,
fps=fps,
),
)
@app.command()
def play_audio(
ctx: typer.Context,
url: str = typer.Argument(..., help="URL or path to audio file"),
) -> None:
"""Plays audio file on camera speaker."""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.play_audio(url))
@app.command()
def smart_detects(
ctx: typer.Context,
values: list[d.SmartDetectObjectType] = typer.Argument(
None,
help="Set to [] to empty list of detect types.",
),
add: bool = typer.Option(False, "-a", "--add", help="Add values instead of set"),
remove: bool = typer.Option(
False,
"-r",
"--remove",
help="Remove values instead of set",
),
) -> None:
"""Returns/set smart detect types for camera."""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if add and remove:
typer.secho("Add and remove are mutually exclusive", fg="red")
raise typer.Exit(1)
if not obj.feature_flags.has_smart_detect:
typer.secho("Camera does not support smart detections", fg="red")
raise typer.Exit(1)
if len(values) == 0:
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
base.json_output(obj.smart_detect_settings.object_types)
else:
for value in obj.smart_detect_settings.object_types:
typer.echo(value.value)
return
if len(values) == 1 and values[0] == "[]":
values = []
for value in values:
if value not in obj.feature_flags.smart_detect_types:
typer.secho(f"Camera does not support {value}", fg="red")
raise typer.Exit(1)
if add:
values = list(set(obj.smart_detect_settings.object_types) | set(values))
elif remove:
values = list(set(obj.smart_detect_settings.object_types) - set(values))
protect: ProtectApiClient = ctx.obj.protect
base.run(
ctx,
protect.update_camera_public(obj.id, smart_detect_object_types=values),
)
@app.command()
def smart_audio_detects(
ctx: typer.Context,
values: list[d.SmartDetectAudioType] = typer.Argument(
None,
help="Set to [] to empty list of detect types.",
),
add: bool = typer.Option(False, "-a", "--add", help="Add values instead of set"),
remove: bool = typer.Option(
False,
"-r",
"--remove",
help="Remove values instead of set",
),
) -> None:
"""Returns/set smart detect types for camera."""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if add and remove:
typer.secho("Add and remove are mutually exclusive", fg="red")
raise typer.Exit(1)
if not obj.feature_flags.has_smart_detect:
typer.secho("Camera does not support smart detections", fg="red")
raise typer.Exit(1)
obj.smart_detect_settings.audio_types = obj.smart_detect_settings.audio_types or []
obj.smart_detect_settings.audio_types = obj.smart_detect_settings.audio_types or []
if len(values) == 0:
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
base.json_output(obj.smart_detect_settings.audio_types)
else:
for value in obj.smart_detect_settings.audio_types:
typer.echo(value.value)
return
if len(values) == 1 and values[0] == "[]":
values = []
for value in values:
if (
obj.feature_flags.smart_detect_audio_types is None
or value not in obj.feature_flags.smart_detect_audio_types
):
typer.secho(f"Camera does not support {value}", fg="red")
raise typer.Exit(1)
if add:
values = list(set(obj.smart_detect_settings.audio_types) | set(values))
elif remove:
values = list(set(obj.smart_detect_settings.audio_types) - set(values))
protect: ProtectApiClient = ctx.obj.protect
base.run(
ctx,
protect.update_camera_public(obj.id, smart_detect_audio_types=values),
)
@app.command()
def set_motion_detection(ctx: typer.Context, enabled: bool) -> None:
"""Sets motion detection on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_motion_detection(enabled))
@app.command()
def set_recording_mode(ctx: typer.Context, mode: d.RecordingMode) -> None:
"""Sets recording mode on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_recording_mode(mode))
@app.command()
def set_ir_led_mode(ctx: typer.Context, mode: d.IRLEDMode) -> None:
"""Sets IR LED mode on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_ir_led_model(mode))
@app.command()
def set_status_light(ctx: typer.Context, enabled: bool) -> None:
"""Sets status indicicator light on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_status_light_public(enabled))
@app.command()
def set_hdr(ctx: typer.Context, mode: d.PublicHdrMode) -> None:
"""Sets HDR (High Dynamic Range) mode on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_hdr_mode_public(mode))
@app.command()
def set_color_night_vision(ctx: typer.Context, enabled: bool) -> None:
"""Sets Color Night Vision on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_color_night_vision(enabled=enabled))
@app.command()
def set_person_track(ctx: typer.Context, enabled: bool) -> None:
"""Sets person tracking on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if not obj.feature_flags.is_ptz:
typer.secho("Camera does not support person tracking", fg="red")
raise typer.Exit(1)
base.run(ctx, (obj.set_person_track(enabled=enabled)))
@app.command()
def set_video_mode(ctx: typer.Context, mode: d.VideoMode) -> None:
"""Sets video mode on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_video_mode_public(mode))
@app.command()
def set_camera_zoom(
ctx: typer.Context,
level: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets zoom level for camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_camera_zoom(level))
@app.command()
def set_wdr_level(
ctx: typer.Context,
level: int = typer.Argument(..., min=0, max=3),
) -> None:
"""Sets WDR (Wide Dynamic Range) on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_wdr_level(level))
@app.command()
def set_mic_volume(
ctx: typer.Context,
level: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets the mic sensitivity level on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_mic_volume_public(level))
@app.command()
def set_speaker_volume(
ctx: typer.Context,
level: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets the speaker output volume on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_speaker_volume(level))
@app.command()
def set_volume(
ctx: typer.Context,
level: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets the general volume level on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_volume(level))
@app.command()
def set_ring_volume(
ctx: typer.Context,
level: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets the doorbell ring volume"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_ring_volume(level))
@app.command()
def set_system_sounds(ctx: typer.Context, enabled: bool) -> None:
"""Sets system sound playback through speakers"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_system_sounds(enabled))
@app.command()
def set_osd_name(ctx: typer.Context, enabled: bool) -> None:
"""Sets whether camera name is in the On Screen Display"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_osd_name_public(enabled))
@app.command()
def set_osd_date(ctx: typer.Context, enabled: bool) -> None:
"""Sets whether current date is in the On Screen Display"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_osd_date_public(enabled))
@app.command()
def set_osd_logo(ctx: typer.Context, enabled: bool) -> None:
"""Sets whether the UniFi logo is in the On Screen Display"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_osd_logo_public(enabled))
@app.command()
def set_osd_bitrate(ctx: typer.Context, enabled: bool) -> None:
"""Sets whether camera bitrate is in the On Screen Display"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_osd_nerd_mode_public(enabled))
@app.command()
def set_lcd_text(
ctx: typer.Context,
text_type: d.DoorbellMessageType | None = typer.Argument(
None,
help="No value sets it back to the global default doorbell message.",
),
text: str | None = typer.Argument(
None,
help="Only for CUSTOM_MESSAGE text type",
),
reset_at: datetime | None = typer.Option(
None,
"-r",
"--reset-time",
help="Does not apply to default message",
),
) -> None:
"""
Sets doorbell LCD text.
Uses your locale timezone. If it is not configured correctly,
it will default to UTC. You can override your timezone with the
TZ environment variable.
"""
if reset_at is not None:
local_tz = datetime.now(UTC).astimezone().tzinfo
reset_at = reset_at.replace(tzinfo=local_tz)
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if text_type is None:
# The public setter rejects a reset time on a clear; say so here rather
# than dropping the option on the floor.
if reset_at is not None:
typer.secho(
"--reset-time does not apply when clearing the message", fg="red"
)
raise typer.Exit(1)
base.run(ctx, obj.set_lcd_message_public(None))
return
base.run(ctx, obj.set_lcd_message_public(text_type, text, reset_at))
@app.command()
def create_rtsps_streams(
ctx: typer.Context,
qualities: list[str] = typer.Argument(
...,
help="List of stream qualities to create (e.g., high medium low)",
),
) -> None:
"""
Creates RTSPS streams for camera.
Available qualities are typically: high, medium, low, ultra.
Requires API key authentication and public API access.
"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
async def create_streams() -> None:
try:
result = await obj.create_rtsps_streams(qualities)
if result is None:
typer.secho("Failed to create RTSPS streams", fg="red")
raise typer.Exit(1)
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
stream_data = {
quality: result.get_stream_url(quality)
for quality in result.get_available_stream_qualities()
}
base.json_output(stream_data)
else:
for quality in result.get_available_stream_qualities():
url = result.get_stream_url(quality)
typer.echo(f"{quality:10}\t{url}")
except Exception as e:
typer.secho(f"Error creating RTSPS streams: {e}", fg="red")
raise typer.Exit(1) from e
base.run(ctx, create_streams())
@app.command()
def get_rtsps_streams(ctx: typer.Context) -> None:
"""
Gets existing RTSPS streams for camera.
Requires API key authentication and public API access.
"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
async def get_streams() -> None:
try:
result = await obj.get_rtsps_streams()
if result is None:
typer.secho("No RTSPS streams found or failed to retrieve", fg="yellow")
return
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
stream_data = {
quality: result.get_stream_url(quality)
for quality in result.get_available_stream_qualities()
}
base.json_output(stream_data)
else:
available_qualities = result.get_available_stream_qualities()
if not available_qualities:
typer.echo("No RTSPS streams available")
else:
for quality in available_qualities:
url = result.get_stream_url(quality)
typer.echo(f"{quality:10}\t{url}")
except Exception as e:
typer.secho(f"Error getting RTSPS streams: {e}", fg="red")
raise typer.Exit(1) from e
base.run(ctx, get_streams())
@app.command()
def delete_rtsps_streams(
ctx: typer.Context,
qualities: list[str] = typer.Argument(
...,
help="List of stream qualities to delete (e.g., high medium low)",
),
) -> None:
"""
Deletes RTSPS streams for camera.
Requires API key authentication and public API access.
"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
async def delete_streams() -> None:
try:
result = await obj.delete_rtsps_streams(qualities)
if result:
typer.secho(
f"Successfully deleted RTSPS streams: {', '.join(qualities)}",
fg="green",
)
else:
typer.secho(
f"Failed to delete RTSPS streams: {', '.join(qualities)}", fg="red"
)
raise typer.Exit(1)
except Exception as e:
typer.secho(f"Error deleting RTSPS streams: {e}", fg="red")
raise typer.Exit(1) from e
base.run(ctx, delete_streams())
@app.command("disable-mic-permanently")
def disable_mic_permanently(
ctx: typer.Context,
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip the interactive confirmation prompt",
),
) -> None:
"""Permanently disable the camera microphone (irreversible)."""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
if not yes and not typer.confirm(
f"Permanently disable the microphone on {obj.id}? "
"This cannot be undone without resetting the camera."
):
typer.secho("Aborted.", fg="yellow")
raise typer.Exit(1)
async def _disable() -> None:
await ctx.obj.protect.disable_camera_mic_permanently_public(obj.id)
base.run(ctx, _disable())
uilibs-uiprotect-317a52c/src/uiprotect/cli/chimes.py 0000664 0000000 0000000 00000012545 15246377057 0022571 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import Chime
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(None, help="ID of chime to select for subcommands")
ARG_REPEAT = typer.Argument(..., help="Repeat times count", min=1, max=6)
ARG_VOLUME = typer.Argument(..., help="Volume", min=1, max=100)
@dataclass
class ChimeContext(base.CliContext):
devices: dict[str, Chime]
device: Chime | None = None
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
"""
Chime device CLI.
Returns full list of Chimes without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = ChimeContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.chimes,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if device_id is not None and device_id not in ALL_COMMANDS:
if (device := protect.bootstrap.chimes.get(device_id)) is None:
typer.secho("Invalid chime ID", fg="red")
raise typer.Exit(1)
ctx.obj.device = device
if not ctx.invoked_subcommand:
if device_id in ALL_COMMANDS:
ctx.invoke(ALL_COMMANDS[device_id], ctx)
return
if ctx.obj.device is not None:
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
return
base.print_unifi_dict(ctx.obj.devices)
@app.command()
def cameras(
ctx: typer.Context,
camera_ids: list[str] = typer.Argument(
None,
help="Set to [] to empty list of cameras",
),
add: bool = typer.Option(False, "-a", "--add", help="Add cameras instead of set"),
remove: bool = typer.Option(
False,
"-r",
"--remove",
help="Remove cameras instead of set",
),
) -> None:
"""Returns or sets paired doorbells for the chime."""
base.require_device_id(ctx)
obj: Chime = ctx.obj.device
if add and remove:
typer.secho("Add and remove are mutually exclusive", fg="red")
raise typer.Exit(1)
if len(camera_ids) == 0:
base.print_unifi_list(obj.cameras)
return
protect: ProtectApiClient = ctx.obj.protect
if len(camera_ids) == 1 and camera_ids[0] == "[]":
camera_ids = []
for camera_id in camera_ids:
if (camera := protect.bootstrap.cameras.get(camera_id)) is None:
typer.secho(f"Invalid camera ID: {camera_id}", fg="red")
raise typer.Exit(1)
if not camera.feature_flags.is_doorbell:
typer.secho(f"Camera is not a doorbell: {camera_id}", fg="red")
raise typer.Exit(1)
if add:
camera_ids = list(set(obj.camera_ids) | set(camera_ids))
elif remove:
camera_ids = list(set(obj.camera_ids) - set(camera_ids))
base.run(ctx, protect.update_chime_public(obj.id, camera_ids=camera_ids))
@app.command()
def set_volume(
ctx: typer.Context,
value: int = ARG_VOLUME,
camera_id: str | None = typer.Option(
None,
"-c",
"--camera",
help="Camera ID to apply volume to",
),
) -> None:
"""Set volume level for chime rings."""
base.require_device_id(ctx)
obj: Chime = ctx.obj.device
protect: ProtectApiClient = ctx.obj.protect
if camera_id is None:
ring_settings = [s.to_api_dict(volume=value) for s in obj.ring_settings]
base.run(ctx, protect.update_chime_public(obj.id, ring_settings=ring_settings))
else:
camera = protect.bootstrap.cameras.get(camera_id)
if camera is None:
typer.secho(f"Invalid camera ID: {camera_id}", fg="red")
raise typer.Exit(1)
base.run(ctx, obj.set_volume_for_camera_public(camera, value))
@app.command()
def play(
ctx: typer.Context,
volume: int | None = typer.Option(None, "-v", "--volume", min=1, max=100),
repeat_times: int | None = typer.Option(None, "-r", "--repeat", min=1, max=6),
) -> None:
"""Plays chime tone."""
base.require_device_id(ctx)
obj: Chime = ctx.obj.device
base.run(ctx, obj.play(volume=volume, repeat_times=repeat_times))
@app.command()
def play_buzzer(ctx: typer.Context) -> None:
"""Plays chime buzzer."""
base.require_device_id(ctx)
obj: Chime = ctx.obj.device
base.run(ctx, obj.play_buzzer())
@app.command()
def set_repeat_times(
ctx: typer.Context,
value: int = ARG_REPEAT,
camera_id: str | None = typer.Option(
None,
"-c",
"--camera",
help="Camera ID to apply repeat times to",
),
) -> None:
"""Set number of times for a chime to repeat when doorbell is rang."""
base.require_device_id(ctx)
obj: Chime = ctx.obj.device
protect: ProtectApiClient = ctx.obj.protect
if camera_id is None:
ring_settings = [s.to_api_dict(repeat_times=value) for s in obj.ring_settings]
base.run(ctx, protect.update_chime_public(obj.id, ring_settings=ring_settings))
else:
camera = protect.bootstrap.cameras.get(camera_id)
if camera is None:
typer.secho(f"Invalid camera ID: {camera_id}", fg="red")
raise typer.Exit(1)
base.run(ctx, obj.set_repeat_times_for_camera_public(camera, value))
uilibs-uiprotect-317a52c/src/uiprotect/cli/events.py 0000664 0000000 0000000 00000015741 15246377057 0022626 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import typer
from rich.progress import Progress
from .. import data as d
from ..api import ProtectApiClient
from ..cli import base
from ..exceptions import NvrError
from ..utils import local_datetime
app = typer.Typer(rich_markup_mode="rich")
ARG_EVENT_ID = typer.Argument(None, help="ID of camera to select for subcommands")
OPTION_START = typer.Option(None, "-s", "--start")
OPTION_END = typer.Option(None, "-e", "--end")
OPTION_LIMIT = typer.Option(None, "-l", "--limit")
OPTION_OFFSET = typer.Option(None, "-o", "--offet")
OPTION_TYPES = typer.Option(None, "-t", "--type")
OPTION_SMART_TYPES = typer.Option(
None,
"-d",
"--smart-detect",
help="If provided, will only return smartDetectZone events",
)
@dataclass
class EventContext(base.CliContext):
events: dict[str, d.Event] | None = None
event: d.Event | None = None
ALL_COMMANDS: dict[str, Callable[..., None]] = {}
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
event_id: str | None = ARG_EVENT_ID,
start: datetime | None = OPTION_START,
end: datetime | None = OPTION_END,
limit: int | None = OPTION_LIMIT,
offset: int | None = OPTION_OFFSET,
types: list[d.EventType] | None = OPTION_TYPES,
smart_types: list[d.SmartDetectObjectType] | None = OPTION_SMART_TYPES,
) -> None:
"""
Events CLI.
Returns list of events from the last 24 hours without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = EventContext(
protect=ctx.obj.protect,
event=None,
events=None,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if event_id is not None and event_id not in ALL_COMMANDS:
try:
ctx.obj.event = base.run(ctx, protect.get_event(event_id))
except NvrError as err:
typer.secho("Invalid event ID", fg="red")
raise typer.Exit(1) from err
if not ctx.invoked_subcommand:
if ctx.obj.event is not None:
base.print_unifi_obj(ctx.obj.event, ctx.obj.output_format)
return
if types is not None and len(types) == 0:
types = None
if smart_types is not None and len(smart_types) == 0:
smart_types = None
events = base.run(
ctx,
protect.get_events(
start=start,
end=end,
limit=limit,
offset=offset,
types=types,
smart_detect_types=smart_types,
),
)
ctx.obj.events = {}
for event in events:
ctx.obj.events[event.id] = event
if event_id in ALL_COMMANDS:
ctx.invoke(ALL_COMMANDS[event_id], ctx)
return
base.print_unifi_dict(ctx.obj.events)
def require_event_id(ctx: typer.Context) -> None:
"""Requires event ID in context"""
if ctx.obj.event is None:
typer.secho("Requires a valid event ID to be selected")
raise typer.Exit(1)
def require_no_event_id(ctx: typer.Context) -> None:
"""Requires no device ID in context"""
if ctx.obj.event is not None or ctx.obj.events is None:
typer.secho("Requires no event ID to be selected")
raise typer.Exit(1)
@app.command()
def list_ids(ctx: typer.Context) -> None:
"""
Prints list of "id type timestamp" for each event.
Timestamps displayed in your locale timezone. If it is not configured
correctly, it will default to UTC. You can override your timezone with
the TZ environment variable.
"""
require_no_event_id(ctx)
objs: dict[str, d.Event] = ctx.obj.events
to_print: list[tuple[str, str, datetime]] = []
longest_event = 0
for obj in objs.values():
event_type = obj.type.value
if event_type in {
d.EventType.SMART_DETECT.value,
d.EventType.SMART_DETECT_LINE.value,
}:
event_type = f"{event_type}[{','.join(obj.smart_detect_types)}]"
longest_event = max(len(event_type), longest_event)
dt = obj.timestamp or obj.start
dt = local_datetime(dt)
to_print.append((obj.id, event_type, dt))
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
base.json_output(to_print)
else:
for item in to_print:
typer.echo(f"{item[0]}\t{item[1]:{longest_event}}\t{item[2]}")
ALL_COMMANDS["list-ids"] = list_ids
@app.command()
def save_thumbnail(
ctx: typer.Context,
output_path: Path = typer.Argument(..., help="JPEG format"),
) -> None:
"""
Saves thumbnail for event.
Only for ring, motion and smartDetectZone events.
"""
require_event_id(ctx)
event: d.Event = ctx.obj.event
thumbnail = base.run(ctx, event.get_thumbnail())
if thumbnail is None:
typer.secho("Could not get thumbnail", fg="red")
raise typer.Exit(1)
Path(output_path).write_bytes(thumbnail)
@app.command()
def save_animated_thumbnail(
ctx: typer.Context,
output_path: Path = typer.Argument(..., help="GIF format"),
) -> None:
"""
Saves animated thumbnail for event.
Only for ring, motion and smartDetectZone events.
"""
require_event_id(ctx)
event: d.Event = ctx.obj.event
thumbnail = base.run(ctx, event.get_animated_thumbnail())
if thumbnail is None:
typer.secho("Could not get thumbnail", fg="red")
raise typer.Exit(1)
Path(output_path).write_bytes(thumbnail)
@app.command()
def save_heatmap(
ctx: typer.Context,
output_path: Path = typer.Argument(..., help="PNG format"),
) -> None:
"""
Saves heatmap for event.
Only motion events have heatmaps.
"""
require_event_id(ctx)
event: d.Event = ctx.obj.event
heatmap = base.run(ctx, event.get_heatmap())
if heatmap is None:
typer.secho("Could not get heatmap", fg="red")
raise typer.Exit(1)
Path(output_path).write_bytes(heatmap)
@app.command()
def save_video(
ctx: typer.Context,
output_path: Path = typer.Argument(..., help="MP4 format"),
channel: int = typer.Option(
0,
"-c",
"--channel",
min=0,
max=3,
help="0 = High, 1 = Medium, 2 = Low, 3 = Package",
),
) -> None:
"""
Exports video for event.
Only for ring, motion and smartDetectZone events.
"""
require_event_id(ctx)
event: d.Event = ctx.obj.event
with Progress() as pb:
task_id = pb.add_task("(1/2) Exporting", total=100)
async def callback(step: int, current: int, total: int) -> None:
pb.update(
task_id,
total=total,
completed=current,
description="(2/2) Downloading",
)
base.run(
ctx,
event.get_video(
channel,
output_file=output_path,
progress_callback=callback,
),
)
uilibs-uiprotect-317a52c/src/uiprotect/cli/files_public.py 0000664 0000000 0000000 00000003670 15246377057 0023760 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from pathlib import Path
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_FILE_TYPE = typer.Argument(..., help="Device asset file type, e.g. 'animations'")
ARG_FILE_PATH = typer.Argument(
...,
help="Path to the file to upload",
exists=True,
dir_okay=False,
readable=True,
)
OPTION_FILE_TYPE_LIST = typer.Option(
"animations",
"--file-type",
"-t",
help="Device asset file type to list (default: animations)",
)
OPTION_CONTENT_TYPE = typer.Option(
"image/png",
"--content-type",
"-c",
help="MIME content type of the uploaded file",
)
OPTION_ORIGINAL_NAME = typer.Option(
None,
"--original-name",
"-n",
help="Original file name reported to the server (defaults to the file's basename)",
)
@app.callback()
def main(ctx: typer.Context) -> None:
"""Device asset file commands (Public API)."""
@app.command("list")
def list_files(
ctx: typer.Context,
file_type: str = OPTION_FILE_TYPE_LIST,
) -> None:
"""List uploaded device asset files of the given type."""
async def _fetch() -> None:
items = await ctx.obj.protect.get_files_public(file_type)
base.print_unifi_list(items, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def upload(
ctx: typer.Context,
file_type: str = ARG_FILE_TYPE,
path: Path = ARG_FILE_PATH,
content_type: str = OPTION_CONTENT_TYPE,
original_name: str | None = OPTION_ORIGINAL_NAME,
) -> None:
"""Upload a device asset file."""
payload = path.read_bytes()
name = original_name if original_name is not None else path.name
async def _upload() -> None:
obj = await ctx.obj.protect.upload_file_public(
file_type,
payload,
original_name=name,
content_type=content_type,
)
base.print_unifi_obj(obj, ctx.obj.output_format)
base.run(ctx, _upload())
uilibs-uiprotect-317a52c/src/uiprotect/cli/fobs.py 0000664 0000000 0000000 00000002063 15246377057 0022244 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_FOB_ID = typer.Argument(..., help="Fob ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Fob commands (Public API)."""
@app.command("list")
def list_fobs(ctx: typer.Context) -> None:
"""List all key fobs."""
async def _fetch() -> None:
fobs = await ctx.obj.protect.get_fobs_public()
base.print_unifi_list(fobs)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, fob_id: str = ARG_FOB_ID) -> None:
"""Show details for a specific key fob."""
async def _fetch() -> None:
fob = await ctx.obj.protect.get_fob_public(fob_id)
base.print_unifi_obj(fob, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def set_name(
ctx: typer.Context,
fob_id: str = ARG_FOB_ID,
name: str = typer.Argument(..., help="New name for the fob"),
) -> None:
"""Rename a key fob."""
base.run(ctx, ctx.obj.protect.update_fob_public(fob_id, name=name))
uilibs-uiprotect-317a52c/src/uiprotect/cli/lights.py 0000664 0000000 0000000 00000006755 15246377057 0022621 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import Light
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(None, help="ID of light to select for subcommands")
@dataclass
class LightContext(base.CliContext):
devices: dict[str, Light]
device: Light | None = None
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
"""
Lights device CLI.
Returns full list of Viewers without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = LightContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.lights,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if device_id is not None and device_id not in ALL_COMMANDS:
if (device := protect.bootstrap.lights.get(device_id)) is None:
typer.secho("Invalid light ID", fg="red")
raise typer.Exit(1)
ctx.obj.device = device
if not ctx.invoked_subcommand:
if device_id in ALL_COMMANDS:
ctx.invoke(ALL_COMMANDS[device_id], ctx)
return
if ctx.obj.device is not None:
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
return
base.print_unifi_dict(ctx.obj.devices)
@app.command()
def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None:
"""Returns or sets the paired camera for a light."""
base.require_device_id(ctx)
obj: Light = ctx.obj.device
if camera_id is None:
base.print_unifi_obj(obj.camera, ctx.obj.output_format)
else:
protect: ProtectApiClient = ctx.obj.protect
if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None:
typer.secho("Invalid camera ID")
raise typer.Exit(1)
base.run(ctx, obj.set_paired_camera(camera_obj))
@app.command()
def set_status_light(ctx: typer.Context, enabled: bool) -> None:
"""Sets status light for light device."""
base.require_device_id(ctx)
obj: Light = ctx.obj.device
base.run(ctx, obj.set_status_light_public(enabled))
@app.command()
def set_led_level(
ctx: typer.Context,
led_level: int = typer.Argument(..., min=1, max=6),
) -> None:
"""Sets brightness of LED on light."""
base.require_device_id(ctx)
obj: Light = ctx.obj.device
base.run(ctx, obj.set_led_level_public(led_level))
@app.command()
def set_sensitivity(
ctx: typer.Context,
sensitivity: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets motion sensitivity for the light."""
base.require_device_id(ctx)
obj: Light = ctx.obj.device
base.run(ctx, obj.set_sensitivity_public(sensitivity))
@app.command()
def set_duration(
ctx: typer.Context,
duration: int = typer.Argument(..., min=15, max=900),
) -> None:
"""Sets timeout duration (in seconds) for light."""
base.require_device_id(ctx)
obj: Light = ctx.obj.device
base.run(ctx, obj.set_duration_public(timedelta(seconds=duration)))
@app.command()
def set_flood_light(ctx: typer.Context, enabled: bool) -> None:
"""Sets flood light (force on) for light device."""
base.require_device_id(ctx)
obj: Light = ctx.obj.device
base.run(ctx, obj.set_flood_light_public(enabled))
uilibs-uiprotect-317a52c/src/uiprotect/cli/link_stations.py 0000664 0000000 0000000 00000005724 15246377057 0024203 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_LINK_STATION_ID = typer.Argument(..., help="Link station / alarm hub ID")
ARG_OUTPUT_ID = typer.Argument(..., help="Output channel ID (integer)")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Link station and alarm hub commands (Public API)."""
@app.command("list")
def list_link_stations(
ctx: typer.Context,
alarm_hubs_only: bool = typer.Option(
False,
"--alarm-hubs-only",
help="Only return entries with isAlarmHub=True (calls /v1/alarm-hubs).",
),
) -> None:
"""List link stations (and alarm hubs)."""
async def _fetch() -> None:
if alarm_hubs_only:
items = await ctx.obj.protect.get_alarm_hubs_public()
else:
items = await ctx.obj.protect.get_link_stations_public()
base.print_unifi_list(items)
base.run(ctx, _fetch())
@app.command()
def show(
ctx: typer.Context,
link_station_id: str = ARG_LINK_STATION_ID,
alarm_hub: bool = typer.Option(
False,
"--alarm-hub",
help="Use /v1/alarm-hubs/{id} instead of /v1/link-stations/{id}.",
),
) -> None:
"""Show details for a specific link station / alarm hub."""
async def _fetch() -> None:
if alarm_hub:
obj = await ctx.obj.protect.get_alarm_hub_public(link_station_id)
else:
obj = await ctx.obj.protect.get_link_station_public(link_station_id)
base.print_unifi_obj(obj, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def set_name(
ctx: typer.Context,
link_station_id: str = ARG_LINK_STATION_ID,
name: str = typer.Argument(..., help="New name for the link station / alarm hub"),
alarm_hub: bool = typer.Option(
False,
"--alarm-hub",
help="Patch via /v1/alarm-hubs/{id} instead of /v1/link-stations/{id}.",
),
) -> None:
"""Rename a link station / alarm hub."""
if alarm_hub:
base.run(
ctx,
ctx.obj.protect.update_alarm_hub_public(link_station_id, name=name),
)
else:
base.run(
ctx,
ctx.obj.protect.update_link_station_public(link_station_id, name=name),
)
@app.command("trigger-output")
def trigger_output(
ctx: typer.Context,
link_station_id: str = ARG_LINK_STATION_ID,
output_id: int = ARG_OUTPUT_ID,
enable: bool | None = typer.Option(None, "--enable/--disable", show_default=False),
delay: int | None = typer.Option(None, "--delay", min=0, help="Delay in ms"),
duration: int | None = typer.Option(
None, "--duration", min=0, help="Duration in ms"
),
) -> None:
"""Trigger an alarm-hub output channel."""
base.run(
ctx,
ctx.obj.protect.trigger_alarm_hub_output_public(
link_station_id,
output_id,
enable=enable,
delay=delay,
duration=duration,
),
)
uilibs-uiprotect-317a52c/src/uiprotect/cli/liveviews.py 0000664 0000000 0000000 00000010214 15246377057 0023325 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from typing import Any
import orjson
import typer
from ..cli import base
from ..data import PublicLiveviewSlotDict
from ..exceptions import BadRequest
app = typer.Typer(rich_markup_mode="rich")
ARG_LIVEVIEW_ID = typer.Argument(..., help="Liveview ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Liveview commands (Public API)."""
def _parse_slots(raw: str) -> list[PublicLiveviewSlotDict]:
try:
data = orjson.loads(raw)
except orjson.JSONDecodeError as err:
typer.secho(f"--slots must be valid JSON: {err}", fg="red")
raise typer.Exit(1) from err
if not isinstance(data, list):
typer.secho("--slots must be a JSON array of slot objects", fg="red")
raise typer.Exit(1)
if any(not isinstance(slot, dict) for slot in data):
typer.secho("--slots entries must be JSON objects", fg="red")
raise typer.Exit(1)
return data
@app.command("list")
def list_liveviews(ctx: typer.Context) -> None:
"""List all liveviews."""
async def _fetch() -> None:
liveviews = await ctx.obj.protect.get_liveviews_public()
base.print_unifi_list(liveviews)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, liveview_id: str = ARG_LIVEVIEW_ID) -> None:
"""Show details for a specific liveview."""
async def _fetch() -> None:
liveview = await ctx.obj.protect.get_liveview_public(liveview_id)
base.print_unifi_obj(liveview, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def create(
ctx: typer.Context,
name: str = typer.Option(..., "--name", help="Liveview name"),
owner: str = typer.Option(..., "--owner", help="Owner user ID"),
layout: int = typer.Option(
..., "--layout", help="Slot count (1-26)", min=1, max=26
),
slots: str = typer.Option(
...,
"--slots",
help=(
"JSON array of slot objects, e.g. "
'\'[{"cameras":["id1"],"cycleMode":"motion","cycleInterval":10}]\''
),
),
is_default: bool = typer.Option(False, "--default/--no-default"),
is_global: bool = typer.Option(False, "--global/--no-global"),
) -> None:
"""Create a new liveview."""
parsed = _parse_slots(slots)
async def _create() -> None:
liveview = await ctx.obj.protect.create_liveview_public(
name=name,
is_default=is_default,
is_global=is_global,
owner=owner,
layout=layout,
slots=parsed,
)
base.print_unifi_obj(liveview, ctx.obj.output_format)
base.run(ctx, _create())
@app.command()
def update(
ctx: typer.Context,
liveview_id: str = ARG_LIVEVIEW_ID,
name: str | None = typer.Option(None, "--name"),
owner: str | None = typer.Option(None, "--owner"),
layout: int | None = typer.Option(None, "--layout", min=1, max=26),
slots: str | None = typer.Option(
None, "--slots", help="JSON array of slot objects"
),
is_default: bool | None = typer.Option(
None, "--default/--no-default", show_default=False
),
is_global: bool | None = typer.Option(
None, "--global/--no-global", show_default=False
),
) -> None:
"""Patch an existing liveview (partial update)."""
kwargs: dict[str, Any] = {}
if name is not None:
kwargs["name"] = name
if owner is not None:
kwargs["owner"] = owner
if layout is not None:
kwargs["layout"] = layout
if slots is not None:
kwargs["slots"] = _parse_slots(slots)
if is_default is not None:
kwargs["is_default"] = is_default
if is_global is not None:
kwargs["is_global"] = is_global
if not kwargs:
typer.secho("At least one field must be provided", fg="red")
raise typer.Exit(1)
async def _update() -> None:
try:
liveview = await ctx.obj.protect.update_liveview_public(
liveview_id, **kwargs
)
except BadRequest as err:
typer.secho(str(err), fg="red")
raise typer.Exit(1) from err
base.print_unifi_obj(liveview, ctx.obj.output_format)
base.run(ctx, _update())
uilibs-uiprotect-317a52c/src/uiprotect/cli/nvr.py 0000664 0000000 0000000 00000010244 15246377057 0022120 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import orjson
import typer
from ..cli import base
from ..data import NVR, AnalyticsOption
app = typer.Typer(rich_markup_mode="rich")
ARG_TIMEOUT = typer.Argument(..., help="Timeout (in seconds)")
ARG_DOORBELL_MESSAGE = typer.Argument(..., help="ASCII only. Max length 30")
OPTION_ENABLE_SMART = typer.Option(
False,
"--enable-smart",
help="Automatically enable smart detections",
)
@dataclass
class NVRContext(base.CliContext):
device: NVR
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context) -> None:
"""
NVR device CLI.
Return NVR object without any arguments passed.
"""
context = NVRContext(
protect=ctx.obj.protect,
device=ctx.obj.protect.bootstrap.nvr,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if not ctx.invoked_subcommand:
base.print_unifi_obj(context.device, ctx.obj.output_format)
app.command(name="protect-url")(base.protect_url)
app.command(name="reboot")(base.reboot)
app.command(name="set-name")(base.set_name)
@app.command()
def set_analytics(ctx: typer.Context, value: AnalyticsOption) -> None:
"""Sets analytics collection for NVR."""
nvr: NVR = ctx.obj.device
base.run(ctx, nvr.set_analytics(value))
@app.command()
def set_default_reset_timeout(ctx: typer.Context, timeout: int = ARG_TIMEOUT) -> None:
"""
Sets default message reset timeout.
This is how long until a custom message is reset back to the default message if no
timeout is passed in when the custom message is set.
"""
nvr: NVR = ctx.obj.device
base.run(ctx, nvr.set_default_reset_timeout(timedelta(seconds=timeout)))
base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format)
@app.command()
def set_default_doorbell_message(
ctx: typer.Context,
msg: str = ARG_DOORBELL_MESSAGE,
) -> None:
"""
Sets default message for doorbell.
This is the message that is set when a custom doorbell message times out or an empty
one is set.
"""
nvr: NVR = ctx.obj.device
base.run(ctx, nvr.set_default_doorbell_message(msg))
base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format)
@app.command()
def add_custom_doorbell_message(
ctx: typer.Context,
msg: str = ARG_DOORBELL_MESSAGE,
) -> None:
"""Adds a custom doorbell message."""
nvr: NVR = ctx.obj.device
base.run(ctx, nvr.add_custom_doorbell_message(msg))
base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format)
@app.command()
def remove_custom_doorbell_message(
ctx: typer.Context,
msg: str = ARG_DOORBELL_MESSAGE,
) -> None:
"""Removes a custom doorbell message."""
nvr: NVR = ctx.obj.device
base.run(ctx, nvr.remove_custom_doorbell_message(msg))
base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format)
@app.command()
def update(ctx: typer.Context, data: str) -> None:
"""Updates the NVR."""
nvr: NVR = ctx.obj.device
base.run(ctx, nvr.api.update_nvr(orjson.loads(data)))
@app.command()
def set_smart_detections(ctx: typer.Context, value: bool) -> None:
"""Set if smart detections are globally enabled or not."""
nvr: NVR = ctx.obj.device
base.run(ctx, nvr.set_smart_detections(value))
@app.command()
def set_face_recognition(
ctx: typer.Context,
value: bool,
enable_smart: bool = OPTION_ENABLE_SMART,
) -> None:
"""Set if face detections is enabled. Requires smart detections to be enabled."""
nvr: NVR = ctx.obj.device
async def callback() -> None:
if enable_smart:
await nvr.set_smart_detections(True)
await nvr.set_face_recognition(value)
base.run(ctx, callback())
@app.command()
def set_license_plate_recognition(
ctx: typer.Context,
value: bool,
enable_smart: bool = OPTION_ENABLE_SMART,
) -> None:
"""Set if license plate detections is enabled. Requires smart detections to be enabled."""
nvr: NVR = ctx.obj.device
async def callback() -> None:
if enable_smart:
await nvr.set_smart_detections(True)
await nvr.set_license_plate_recognition(value)
base.run(ctx, callback())
uilibs-uiprotect-317a52c/src/uiprotect/cli/relays.py 0000664 0000000 0000000 00000005273 15246377057 0022620 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_RELAY_ID = typer.Argument(..., help="Relay ID")
ARG_OUTPUT_ID = typer.Argument(..., help="Output channel ID (integer)")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Relay commands (Public API)."""
@app.command("list")
def list_relays(ctx: typer.Context) -> None:
"""List all relays."""
async def _fetch() -> None:
relays = await ctx.obj.protect.get_relays_public()
base.print_unifi_list(relays)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, relay_id: str = ARG_RELAY_ID) -> None:
"""Show details for a specific relay."""
async def _fetch() -> None:
relay = await ctx.obj.protect.get_relay_public(relay_id)
base.print_unifi_obj(relay, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def activate(
ctx: typer.Context,
relay_id: str = ARG_RELAY_ID,
output_id: int = ARG_OUTPUT_ID,
state: str | None = typer.Option(
None,
"--state",
"-s",
help="Target state: 'on' or 'off'. Omit to toggle the current state.",
),
pulse_duration_ms: int | None = typer.Option(
None,
"--pulse-duration-ms",
"-p",
help="Auto-off duration in milliseconds. Only valid together with --state on.",
min=1,
),
) -> None:
"""
Activate, toggle, or pulse a relay output channel.
Omit --state to toggle. Use --pulse-duration-ms with --state on to pulse.
"""
if state is not None and state not in ("on", "off"):
typer.secho("--state must be 'on' or 'off'", fg="red")
raise typer.Exit(1)
if pulse_duration_ms is not None and state != "on":
typer.secho("--pulse-duration-ms requires --state on", fg="red")
raise typer.Exit(1)
base.run(
ctx,
ctx.obj.protect.activate_relay_output_public(
relay_id,
output_id,
state=state, # type: ignore[arg-type]
pulse_duration_ms=pulse_duration_ms,
),
)
@app.command()
def set_name(
ctx: typer.Context,
relay_id: str = ARG_RELAY_ID,
name: str = typer.Argument(..., help="New name for the relay"),
) -> None:
"""Rename a relay."""
base.run(ctx, ctx.obj.protect.update_relay_public(relay_id, name=name))
@app.command()
def set_status_light(
ctx: typer.Context,
relay_id: str = ARG_RELAY_ID,
enabled: bool = typer.Argument(..., help="Enable or disable the LED status light"),
) -> None:
"""Enable or disable the LED status light on a relay."""
base.run(ctx, ctx.obj.protect.update_relay_public(relay_id, led_is_enabled=enabled))
uilibs-uiprotect-317a52c/src/uiprotect/cli/sensors.py 0000664 0000000 0000000 00000035060 15246377057 0023012 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import MountType, Sensor, SensorScheduleMode
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(None, help="ID of sensor to select for subcommands")
@dataclass
class SensorContext(base.CliContext):
devices: dict[str, Sensor]
device: Sensor | None = None
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
"""
Sensors device CLI.
Returns full list of Sensors without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = SensorContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.sensors,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if device_id is not None and device_id not in ALL_COMMANDS:
if (device := protect.bootstrap.sensors.get(device_id)) is None:
typer.secho("Invalid sensor ID", fg="red")
raise typer.Exit(1)
ctx.obj.device = device
if not ctx.invoked_subcommand:
if device_id in ALL_COMMANDS:
ctx.invoke(ALL_COMMANDS[device_id], ctx)
return
if ctx.obj.device is not None:
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
return
base.print_unifi_dict(ctx.obj.devices)
@app.command()
def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None:
"""Returns or sets the paired camera for a sensor."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
if camera_id is None:
base.print_unifi_obj(obj.camera, ctx.obj.output_format)
else:
protect: ProtectApiClient = ctx.obj.protect
if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None:
typer.secho("Invalid camera ID")
raise typer.Exit(1)
base.run(ctx, obj.set_paired_camera(camera_obj))
@app.command()
def is_tampering_detected(ctx: typer.Context) -> None:
"""Returns if tampering is detected for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_tampering_detected)
@app.command()
def is_alarm_detected(ctx: typer.Context) -> None:
"""Returns if alarm is detected for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_alarm_detected)
@app.command()
def is_contact_enabled(ctx: typer.Context) -> None:
"""Returns if contact sensor is enabled for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_contact_sensor_enabled)
@app.command()
def is_motion_enabled(ctx: typer.Context) -> None:
"""Returns if motion sensor is enabled for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_contact_sensor_enabled)
@app.command()
def is_alarm_enabled(ctx: typer.Context) -> None:
"""Returns if alarm sensor is enabled for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_alarm_sensor_enabled)
@app.command()
def is_light_enabled(ctx: typer.Context) -> None:
"""Returns if light sensor is enabled for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_light_sensor_enabled)
@app.command()
def is_temperature_enabled(ctx: typer.Context) -> None:
"""Returns if temperature sensor is enabled for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_temperature_sensor_enabled)
@app.command()
def is_humidity_enabled(ctx: typer.Context) -> None:
"""Returns if humidity sensor is enabled for sensor"""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.json_output(obj.is_humidity_sensor_enabled)
@app.command()
def set_status_light(ctx: typer.Context, enabled: bool) -> None:
"""Sets status light for sensor device."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_status_light(enabled))
@app.command()
def set_mount_type(ctx: typer.Context, mount_type: MountType) -> None:
"""Sets mount type for sensor device."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_mount_type(mount_type))
@app.command()
def set_motion(ctx: typer.Context, enabled: bool) -> None:
"""Sets motion sensor status for sensor device."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_motion_status_public(enabled))
@app.command()
def set_temperature(ctx: typer.Context, enabled: bool) -> None:
"""Sets temperature sensor status for sensor device."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_temperature_status_public(enabled))
@app.command()
def set_humidity(ctx: typer.Context, enabled: bool) -> None:
"""Sets humidity sensor status for sensor device."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_humidity_status_public(enabled))
@app.command()
def set_light(ctx: typer.Context, enabled: bool) -> None:
"""Sets light sensor status for sensor device."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_light_status_public(enabled))
@app.command()
def set_alarm(ctx: typer.Context, enabled: bool) -> None:
"""Sets alarm sensor status for sensor device."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_alarm_public(enabled))
@app.command()
def set_motion_sensitivity(
ctx: typer.Context,
sensitivity: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets motion sensitivity for the sensor."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_motion_sensitivity_public(sensitivity))
@app.command()
def set_temperature_range(
ctx: typer.Context,
low: float = typer.Argument(..., min=0, max=44),
high: float = typer.Argument(..., min=1, max=45),
) -> None:
"""Sets temperature safe range (in °C). Anything out side of range will trigger event."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
_require_ordered_range(low, high)
base.run(
ctx,
obj.set_temperature_settings_public(low_threshold=low, high_threshold=high),
)
@app.command()
def set_humidity_range(
ctx: typer.Context,
low: float = typer.Argument(..., min=1, max=98),
high: float = typer.Argument(..., min=2, max=99),
) -> None:
"""Sets humidity safe range (in relative % humidity). Anything out side of range will trigger event."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
_require_ordered_range(low, high)
base.run(
ctx,
obj.set_humidity_settings_public(low_threshold=low, high_threshold=high),
)
@app.command()
def set_light_range(
ctx: typer.Context,
low: float = typer.Argument(..., min=1, max=999),
high: float = typer.Argument(..., min=2, max=1000),
) -> None:
"""Sets light safe range (in lux). Anything out side of range will trigger event."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
_require_ordered_range(low, high)
base.run(
ctx,
obj.set_light_settings_public(low_threshold=low, high_threshold=high),
)
@app.command()
def remove_temperature_range(ctx: typer.Context) -> None:
"""Removes temperature safe ranges so events will no longer fire."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.remove_temperature_safe_range())
@app.command()
def remove_humidity_range(ctx: typer.Context) -> None:
"""Removes humidity safe ranges so events will no longer fire."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.remove_humidity_safe_range())
@app.command()
def remove_light_range(ctx: typer.Context) -> None:
"""Removes light safe ranges so events will no longer fire."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.remove_light_safe_range())
@app.command()
def set_name_public(ctx: typer.Context, name: str) -> None:
"""Sets sensor name via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_name_public(name))
@app.command()
def set_temperature_settings_public(
ctx: typer.Context,
is_enabled: bool | None = typer.Option(None),
low_threshold: float | None = typer.Option(None),
high_threshold: float | None = typer.Option(None),
margin: float | None = typer.Option(None),
) -> None:
"""Updates temperature alert settings via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(
ctx,
obj.set_temperature_settings_public(
is_enabled=is_enabled,
low_threshold=low_threshold,
high_threshold=high_threshold,
margin=margin,
),
)
@app.command()
def set_humidity_settings_public(
ctx: typer.Context,
is_enabled: bool | None = typer.Option(None),
low_threshold: int | None = typer.Option(None),
high_threshold: int | None = typer.Option(None),
margin: int | None = typer.Option(None),
) -> None:
"""Updates humidity alert settings via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(
ctx,
obj.set_humidity_settings_public(
is_enabled=is_enabled,
low_threshold=low_threshold,
high_threshold=high_threshold,
margin=margin,
),
)
@app.command()
def set_light_settings_public(
ctx: typer.Context,
is_enabled: bool | None = typer.Option(None),
low_threshold: int | None = typer.Option(None),
high_threshold: int | None = typer.Option(None),
margin: int | None = typer.Option(None),
) -> None:
"""Updates light (lux) alert settings via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(
ctx,
obj.set_light_settings_public(
is_enabled=is_enabled,
low_threshold=low_threshold,
high_threshold=high_threshold,
margin=margin,
),
)
@app.command()
def set_motion_settings_public(
ctx: typer.Context,
is_enabled: bool | None = typer.Option(None),
sensitivity: int | None = typer.Option(None),
sensitivity_when_armed: int | None = typer.Option(None),
) -> None:
"""Updates motion detection settings via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(
ctx,
obj.set_motion_settings_public(
is_enabled=is_enabled,
sensitivity=sensitivity,
sensitivity_when_armed=sensitivity_when_armed,
),
)
@app.command()
def set_glass_break_settings_public(
ctx: typer.Context,
is_enabled: bool | None = typer.Option(None),
sensitivity: int | None = typer.Option(None),
sensitivity_when_armed: int | None = typer.Option(None),
) -> None:
"""Updates glass-break detection settings via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(
ctx,
obj.set_glass_break_settings_public(
is_enabled=is_enabled,
sensitivity=sensitivity,
sensitivity_when_armed=sensitivity_when_armed,
),
)
@app.command()
def set_alarm_public(ctx: typer.Context, enabled: bool) -> None:
"""Sets alarm detection setting via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_alarm_public(enabled))
@app.command()
def set_motion_public(ctx: typer.Context, enabled: bool) -> None:
"""Toggles motion detection via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_motion_status_public(enabled))
@app.command()
def set_motion_sensitivity_public(
ctx: typer.Context,
sensitivity: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets motion detection sensitivity via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_motion_sensitivity_public(sensitivity))
@app.command()
def set_temperature_public(ctx: typer.Context, enabled: bool) -> None:
"""Toggles temperature alerts via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_temperature_status_public(enabled))
@app.command()
def set_humidity_public(ctx: typer.Context, enabled: bool) -> None:
"""Toggles humidity alerts via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_humidity_status_public(enabled))
@app.command()
def set_light_public(ctx: typer.Context, enabled: bool) -> None:
"""Toggles light (lux) alerts via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_light_status_public(enabled))
@app.command()
def set_glass_break_public(ctx: typer.Context, enabled: bool) -> None:
"""Toggles glass-break detection via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_glass_break_status_public(enabled))
@app.command()
def set_schedule_mode_public(ctx: typer.Context, mode: SensorScheduleMode) -> None:
"""Sets the arm-schedule mode via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_schedule_mode_public(mode))
@app.command()
def set_arm_profiles_public(ctx: typer.Context, arm_profile_ids: list[str]) -> None:
"""Sets the arm-profile ids via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_arm_profile_ids_public(arm_profile_ids))
@app.command()
def set_custom_sensitivity_public(ctx: typer.Context, enabled: bool) -> None:
"""Toggles custom armed sensitivity via the public API."""
base.require_device_id(ctx)
obj: Sensor = ctx.obj.device
base.run(ctx, obj.set_custom_sensitivity_when_armed_public(enabled))
def _require_ordered_range(low: float, high: float) -> None:
"""Preserve the ordering check the private safe-range setters enforced."""
if high <= low:
typer.secho("High value must be above low value", fg="red")
raise typer.Exit(1)
uilibs-uiprotect-317a52c/src/uiprotect/cli/sirens.py 0000664 0000000 0000000 00000005312 15246377057 0022616 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_SIREN_ID = typer.Argument(..., help="Siren ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Siren commands (Public API)."""
@app.command("list")
def list_sirens(ctx: typer.Context) -> None:
"""List all sirens."""
async def _fetch() -> None:
sirens = await ctx.obj.protect.get_sirens_public()
base.print_unifi_list(sirens)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, siren_id: str = ARG_SIREN_ID) -> None:
"""Show details for a specific siren."""
async def _fetch() -> None:
siren = await ctx.obj.protect.get_siren_public(siren_id)
base.print_unifi_obj(siren, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def play(
ctx: typer.Context,
siren_id: str = ARG_SIREN_ID,
duration: int | None = typer.Option(
None,
"--duration",
"-d",
help="Duration in seconds (5, 10, 20, or 30). Omit to use server default.",
min=1,
),
) -> None:
"""Activate a siren."""
base.run(ctx, ctx.obj.protect.play_siren_public(siren_id, duration=duration))
@app.command()
def stop(ctx: typer.Context, siren_id: str = ARG_SIREN_ID) -> None:
"""Stop an active siren."""
base.run(ctx, ctx.obj.protect.stop_siren_public(siren_id))
@app.command()
def test_sound(
ctx: typer.Context,
siren_id: str = ARG_SIREN_ID,
volume: int | None = typer.Option(
None,
"--volume",
"-v",
help="Test volume (1-100). Omit to use the current siren volume.",
min=1,
max=100,
),
) -> None:
"""Play a 5-second test sound on a siren."""
base.run(ctx, ctx.obj.protect.test_siren_sound_public(siren_id, volume=volume))
@app.command()
def set_volume(
ctx: typer.Context,
siren_id: str = ARG_SIREN_ID,
volume: int = typer.Argument(..., help="Volume (1-100)", min=1, max=100),
) -> None:
"""Set the siren volume."""
base.run(ctx, ctx.obj.protect.update_siren_public(siren_id, volume=volume))
@app.command()
def set_name(
ctx: typer.Context,
siren_id: str = ARG_SIREN_ID,
name: str = typer.Argument(..., help="New name for the siren"),
) -> None:
"""Rename a siren."""
base.run(ctx, ctx.obj.protect.update_siren_public(siren_id, name=name))
@app.command()
def set_status_light(
ctx: typer.Context,
siren_id: str = ARG_SIREN_ID,
enabled: bool = typer.Argument(..., help="Enable or disable the LED status light"),
) -> None:
"""Enable or disable the LED status light on a siren."""
base.run(ctx, ctx.obj.protect.update_siren_public(siren_id, led_is_enabled=enabled))
uilibs-uiprotect-317a52c/src/uiprotect/cli/speakers.py 0000664 0000000 0000000 00000005104 15246377057 0023127 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_SPEAKER_ID = typer.Argument(..., help="Speaker ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Speaker commands (Public API)."""
@app.command("list")
def list_speakers(ctx: typer.Context) -> None:
"""List all speakers."""
async def _fetch() -> None:
speakers = await ctx.obj.protect.get_speakers_public()
base.print_unifi_list(speakers)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, speaker_id: str = ARG_SPEAKER_ID) -> None:
"""Show details for a specific speaker."""
async def _fetch() -> None:
speaker = await ctx.obj.protect.get_speaker_public(speaker_id)
base.print_unifi_obj(speaker, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def set_name(
ctx: typer.Context,
speaker_id: str = ARG_SPEAKER_ID,
name: str = typer.Argument(..., help="New name for the speaker"),
) -> None:
"""Rename a speaker."""
base.run(ctx, ctx.obj.protect.update_speaker_public(speaker_id, name=name))
@app.command()
def set_volume(
ctx: typer.Context,
speaker_id: str = ARG_SPEAKER_ID,
volume: int = typer.Argument(..., help="Volume (0-100)", min=0, max=100),
) -> None:
"""Set the speaker volume."""
base.run(ctx, ctx.obj.protect.update_speaker_public(speaker_id, volume=volume))
@app.command()
def set_mic_volume(
ctx: typer.Context,
speaker_id: str = ARG_SPEAKER_ID,
mic_volume: int = typer.Argument(..., help="Mic volume (0-100)", min=0, max=100),
) -> None:
"""Set the speaker microphone volume."""
base.run(
ctx,
ctx.obj.protect.update_speaker_public(speaker_id, mic_volume=mic_volume),
)
@app.command()
def set_mic_enabled(
ctx: typer.Context,
speaker_id: str = ARG_SPEAKER_ID,
enabled: bool = typer.Argument(..., help="Enable or disable the microphone"),
) -> None:
"""Enable or disable the speaker microphone."""
base.run(
ctx,
ctx.obj.protect.update_speaker_public(speaker_id, is_mic_enabled=enabled),
)
@app.command()
def test_sound(
ctx: typer.Context,
speaker_id: str = ARG_SPEAKER_ID,
volume: int | None = typer.Option(
None,
"--volume",
"-v",
help="Test volume (0-100). Omit to use the current speaker volume.",
min=0,
max=100,
),
) -> None:
"""Play a test sound on a speaker."""
base.run(
ctx,
ctx.obj.protect.test_speaker_sound_public(speaker_id, volume=volume),
)
uilibs-uiprotect-317a52c/src/uiprotect/cli/ulp_users_public.py 0000664 0000000 0000000 00000001630 15246377057 0024671 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_ULP_USER_ID = typer.Argument(..., help="ULP user ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""UniFi Identity (ULP) user commands (Public API)."""
@app.command("list")
def list_ulp_users(ctx: typer.Context) -> None:
"""List UniFi Identity users."""
async def _fetch() -> None:
items = await ctx.obj.protect.get_ulp_users_public()
base.print_unifi_list(items, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, ulp_user_id: str = ARG_ULP_USER_ID) -> None:
"""Show details for a specific UniFi Identity user."""
async def _fetch() -> None:
obj = await ctx.obj.protect.get_ulp_user_public(ulp_user_id)
base.print_unifi_obj(obj, ctx.obj.output_format)
base.run(ctx, _fetch())
uilibs-uiprotect-317a52c/src/uiprotect/cli/users_public.py 0000664 0000000 0000000 00000001535 15246377057 0024015 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_USER_ID = typer.Argument(..., help="User ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Protect user commands (Public API)."""
@app.command("list")
def list_users(ctx: typer.Context) -> None:
"""List Protect users."""
async def _fetch() -> None:
items = await ctx.obj.protect.get_users_public()
base.print_unifi_list(items, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, user_id: str = ARG_USER_ID) -> None:
"""Show details for a specific Protect user."""
async def _fetch() -> None:
obj = await ctx.obj.protect.get_user_public(user_id)
base.print_unifi_obj(obj, ctx.obj.output_format)
base.run(ctx, _fetch())
uilibs-uiprotect-317a52c/src/uiprotect/cli/viewers.py 0000664 0000000 0000000 00000004135 15246377057 0023001 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import Viewer
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(None, help="ID of viewer to select for subcommands")
@dataclass
class ViewerContext(base.CliContext):
devices: dict[str, Viewer]
device: Viewer | None = None
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
"""
Viewers device CLI.
Returns full list of Viewers without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = ViewerContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.viewers,
output_format=ctx.obj.output_format,
)
ctx.obj = context
if device_id is not None and device_id not in ALL_COMMANDS:
if (device := protect.bootstrap.viewers.get(device_id)) is None:
typer.secho("Invalid viewer ID", fg="red")
raise typer.Exit(1)
ctx.obj.device = device
if not ctx.invoked_subcommand:
if device_id in ALL_COMMANDS:
ctx.invoke(ALL_COMMANDS[device_id], ctx)
return
if ctx.obj.device is not None:
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
return
base.print_unifi_dict(ctx.obj.devices)
@app.command()
def liveview(
ctx: typer.Context,
liveview_id: str | None = typer.Argument(None),
) -> None:
"""Returns or sets the current liveview."""
base.require_device_id(ctx)
obj: Viewer = ctx.obj.device
if liveview_id is None:
base.print_unifi_obj(obj.liveview, ctx.obj.output_format)
else:
protect: ProtectApiClient = ctx.obj.protect
if liveview_id not in protect.bootstrap.liveviews:
typer.secho("Invalid liveview ID")
raise typer.Exit(1)
base.run(ctx, protect.update_viewer_public(obj.id, liveview=liveview_id))
uilibs-uiprotect-317a52c/src/uiprotect/cli/viewers_public.py 0000664 0000000 0000000 00000003022 15246377057 0024331 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typer
from ..cli import base
app = typer.Typer(rich_markup_mode="rich")
ARG_VIEWER_ID = typer.Argument(..., help="Viewer ID")
@app.callback()
def main(ctx: typer.Context) -> None:
"""Viewer commands (Public API)."""
@app.command("list")
def list_viewers(ctx: typer.Context) -> None:
"""List viewers."""
async def _fetch() -> None:
items = await ctx.obj.protect.get_viewers_public()
base.print_unifi_list(items, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def show(ctx: typer.Context, viewer_id: str = ARG_VIEWER_ID) -> None:
"""Show details for a specific viewer."""
async def _fetch() -> None:
obj = await ctx.obj.protect.get_viewer_public(viewer_id)
base.print_unifi_obj(obj, ctx.obj.output_format)
base.run(ctx, _fetch())
@app.command()
def set_name(
ctx: typer.Context,
viewer_id: str = ARG_VIEWER_ID,
name: str = typer.Argument(..., help="New name for the viewer"),
) -> None:
"""Rename a viewer."""
base.run(ctx, ctx.obj.protect.update_viewer_public(viewer_id, name=name))
@app.command()
def set_liveview(
ctx: typer.Context,
viewer_id: str = ARG_VIEWER_ID,
liveview_id: str = typer.Argument(
..., help="Liveview ID to assign, or 'null' to clear"
),
) -> None:
"""Assign a liveview to a viewer."""
target: str | None = None if liveview_id.lower() == "null" else liveview_id
base.run(ctx, ctx.obj.protect.update_viewer_public(viewer_id, liveview=target))
uilibs-uiprotect-317a52c/src/uiprotect/data/ 0000775 0000000 0000000 00000000000 15246377057 0021102 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/data/__init__.py 0000664 0000000 0000000 00000021637 15246377057 0023224 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from typing import TYPE_CHECKING
from .base import (
ProtectAdoptableDeviceModel,
ProtectBaseObject,
ProtectDeviceIdentity,
ProtectDeviceModel,
ProtectModel,
ProtectModelWithId,
)
from .bootstrap import Bootstrap
from .convert import create_from_unifi_dict
from .devices import (
CHANNEL_ID_BY_RTSPS_QUALITY,
RTSPS_QUALITY_BY_CHANNEL_ID,
AiPort,
Bridge,
Camera,
CameraChannel,
Chime,
LCDMessage,
Light,
RingSetting,
Sensor,
Viewer,
channel_id_for_quality,
quality_for_channel_id,
)
from .nvr import (
NVR,
DoorbellMessage,
Event,
Liveview,
NVRLocation,
SmartDetectItem,
SmartDetectTrack,
)
from .public_bootstrap import PublicBootstrap
from .public_devices import (
AlarmHubBattery,
AlarmHubCover,
AlarmHubInput,
AlarmHubOutput,
ArmProfile,
Fob,
LinkStation,
NvrArmMode,
PublicArmSchedule,
PublicArmScheduleDict,
PublicBatteryStatus,
PublicBridge,
PublicCamera,
PublicCameraFeatureFlags,
PublicCameraLedSettings,
PublicChime,
PublicDeviceModel,
PublicDoorbellCustomImage,
PublicDoorbellSettings,
PublicFile,
PublicFobFeatureFlags,
PublicLcdMessage,
PublicLedSettings,
PublicLight,
PublicLightDeviceSettings,
PublicLightModeSettings,
PublicLiveview,
PublicLiveviewSlot,
PublicLiveviewSlotDict,
PublicNVR,
PublicOsdSettings,
PublicPosLineItemDict,
PublicPosLocationDict,
PublicPosTransactionResponse,
PublicRelayInput,
PublicRelayOutput,
PublicRingSettings,
PublicSensor,
PublicSensorAlarmSettings,
PublicSensorAlarmSettingsRead,
PublicSensorFeatureCapability,
PublicSensorFeatureFlags,
PublicSensorGlassBreakSettings,
PublicSensorGlassBreakSettingsWrite,
PublicSensorHumiditySettings,
PublicSensorLeakSettings,
PublicSensorLightSettings,
PublicSensorMetric,
PublicSensorMotionSettings,
PublicSensorMotionSettingsRead,
PublicSensorStats,
PublicSensorTemperatureSettings,
PublicSensorThresholdSettings,
PublicSignalState,
PublicSirenStatus,
PublicSmartDetectSettings,
PublicSpeakerFeatureFlags,
PublicSpeakerState,
PublicUlpUser,
PublicUser,
PublicViewer,
PublicWirelessBatteryStatus,
PublicWirelessConnectionState,
Relay,
RTSPSStreams,
SensorFeatureCapability,
Siren,
Speaker,
)
from .public_event import (
PUBLIC_EVENT_TYPES,
PublicEvent,
PublicEventMetadata,
PublicFingerprintMetadata,
PublicNfcMetadata,
)
from .types import (
DEFAULT,
DEFAULT_TYPE,
PTZ_HOME_SLOT,
AlarmHubBatteryStatus,
AlarmHubConnectionState,
AlarmHubCoverStatus,
AlarmHubInputContactType,
AlarmHubInputStatus,
AlarmHubInputType,
AlarmHubOutputStatus,
AnalyticsOption,
AssetFileType,
AudioStyle,
ChannelQuality,
ChimeType,
Color,
CoordType,
DeviceState,
DoorbellMessageType,
DoorbellText,
EventCategories,
EventType,
FixSizeOrderedDict,
FobAwayState,
FobButton,
FobButtonLabels,
HDRMode,
ICRCustomValue,
ICRLuxValue,
IRLEDMode,
LensType,
LightModeEnableType,
LightModeType,
LiveviewCycleMode,
ModelType,
MountType,
NvrArmModeStatus,
OnOffState,
OsdOverlayLocation,
Percent,
PermissionNode,
PosTransactionType,
ProtectWSPayloadFormat,
PTZPatrol,
PTZPosition,
PTZPreset,
PublicHdrMode,
RecordingMode,
RelayInputActionTrigger,
RelayInputActionType,
RelayInputState,
RelayOutputRebootState,
RelayOutputState,
RelayOutputType,
SensorAlarmType,
SensorScheduleMode,
SensorStatusType,
SensorType,
SirenConnectionType,
SirenDuration,
SmartDetectAudioType,
SmartDetectObjectType,
SpeakerMode,
SpeakerStatus,
StateType,
StorageType,
UlpUserStatus,
Version,
VideoMode,
WDRLevel,
)
from .user import CloudAccount, Group, Permission, User, UserLocation
from .websocket import (
WS_HEADER_SIZE,
WSAction,
WSJSONPacketFrame,
WSPacket,
WSPacketFrameHeader,
WSRawPacketFrame,
WSSubscriptionMessage,
)
__all__ = [
"CHANNEL_ID_BY_RTSPS_QUALITY",
"DEFAULT",
"DEFAULT_TYPE",
"NVR",
"PTZ_HOME_SLOT",
"PUBLIC_EVENT_TYPES",
"RTSPS_QUALITY_BY_CHANNEL_ID",
"WS_HEADER_SIZE",
"AiPort",
"AlarmHubBattery",
"AlarmHubBatteryStatus",
"AlarmHubConnectionState",
"AlarmHubCover",
"AlarmHubCoverStatus",
"AlarmHubInput",
"AlarmHubInputContactType",
"AlarmHubInputStatus",
"AlarmHubInputType",
"AlarmHubOutput",
"AlarmHubOutputStatus",
"AnalyticsOption",
"ArmProfile",
"AssetFileType",
"AudioStyle",
"Bootstrap",
"Bridge",
"Camera",
"CameraChannel",
"ChannelQuality",
"Chime",
"ChimeType",
"CloudAccount",
"Color",
"CoordType",
"DeviceState",
"DoorbellMessage",
"DoorbellMessageType",
"DoorbellText",
"Event",
"EventCategories",
"EventType",
"FixSizeOrderedDict",
"Fob",
"FobAwayState",
"FobButton",
"FobButtonLabels",
"Group",
"HDRMode",
"ICRCustomValue",
"ICRLuxValue",
"IRLEDMode",
"LCDMessage",
"LensType",
"Light",
"LightModeEnableType",
"LightModeType",
"LinkStation",
"Liveview",
"LiveviewCycleMode",
"ModelType",
"MountType",
"NVRLocation",
"NvrArmMode",
"NvrArmModeStatus",
"OnOffState",
"OsdOverlayLocation",
"PTZPatrol",
"PTZPosition",
"PTZPreset",
"Percent",
"Permission",
"PermissionNode",
"PosTransactionType",
"ProtectAdoptableDeviceModel",
"ProtectBaseObject",
"ProtectDeviceIdentity",
"ProtectDeviceModel",
"ProtectModel",
"ProtectModelWithId",
"ProtectWSPayloadFormat",
"PublicArmSchedule",
"PublicArmScheduleDict",
"PublicBatteryStatus",
"PublicBootstrap",
"PublicBridge",
"PublicCamera",
"PublicCameraFeatureFlags",
"PublicCameraLedSettings",
"PublicChime",
"PublicDeviceModel",
"PublicDoorbellCustomImage",
"PublicDoorbellSettings",
"PublicEvent",
"PublicEventMetadata",
"PublicFile",
"PublicFingerprintMetadata",
"PublicFobFeatureFlags",
"PublicHdrMode",
"PublicLcdMessage",
"PublicLedSettings",
"PublicLight",
"PublicLightDeviceSettings",
"PublicLightModeSettings",
"PublicLiveview",
"PublicLiveviewSlot",
"PublicLiveviewSlotDict",
"PublicNVR",
"PublicNfcMetadata",
"PublicOsdSettings",
"PublicPosLineItemDict",
"PublicPosLocationDict",
"PublicPosTransactionResponse",
"PublicRelayInput",
"PublicRelayOutput",
"PublicRingSettings",
"PublicSensor",
"PublicSensorAlarmSettings",
"PublicSensorAlarmSettingsRead",
"PublicSensorFeatureCapability",
"PublicSensorFeatureFlags",
"PublicSensorGlassBreakSettings",
"PublicSensorGlassBreakSettingsWrite",
"PublicSensorHumiditySettings",
"PublicSensorLeakSettings",
"PublicSensorLightSettings",
"PublicSensorMetric",
"PublicSensorMotionSettings",
"PublicSensorMotionSettingsRead",
"PublicSensorStats",
"PublicSensorTemperatureSettings",
"PublicSensorThresholdSettings",
"PublicSignalState",
"PublicSirenStatus",
"PublicSmartDetectSettings",
"PublicSpeakerFeatureFlags",
"PublicSpeakerState",
"PublicUlpUser",
"PublicUser",
"PublicViewer",
"PublicWirelessBatteryStatus",
"PublicWirelessConnectionState",
"RTSPSStreams",
"RecordingMode",
"Relay",
"RelayInputActionTrigger",
"RelayInputActionType",
"RelayInputState",
"RelayOutputRebootState",
"RelayOutputState",
"RelayOutputType",
"RingSetting",
"Sensor",
"SensorAlarmType",
"SensorFeatureCapability",
"SensorScheduleMode",
"SensorStatusType",
"SensorType",
"Siren",
"SirenConnectionType",
"SirenDuration",
"SmartDetectAudioType",
"SmartDetectItem",
"SmartDetectObjectType",
"SmartDetectTrack",
"Speaker",
"SpeakerMode",
"SpeakerStatus",
"StateType",
"StorageType",
"UlpUserStatus",
"User",
"UserLocation",
"Version",
"VideoMode",
"Viewer",
"WDRLevel",
"WSAction",
"WSJSONPacketFrame",
"WSPacket",
"WSPacketFrameHeader",
"WSRawPacketFrame",
"WSSubscriptionMessage",
"channel_id_for_quality",
"create_from_unifi_dict",
"quality_for_channel_id",
]
if TYPE_CHECKING:
def _shared_identity_probe(
private_nvr: NVR,
private_camera: Camera,
public_nvr: PublicNVR,
public_camera: PublicCamera,
) -> list[ProtectDeviceIdentity]:
# Static guard, never executed: mypy fails here if a model change
# breaks the structural ``ProtectDeviceIdentity`` match of either tree.
return [private_nvr, private_camera, public_nvr, public_camera]
uilibs-uiprotect-317a52c/src/uiprotect/data/base.py 0000664 0000000 0000000 00000107202 15246377057 0022370 0 ustar 00root root 0000000 0000000 """UniFi Protect Data."""
from __future__ import annotations
import asyncio
import logging
from asyncio import timeout as asyncio_timeout
from collections.abc import Callable
from datetime import datetime, timedelta
from functools import cache
from ipaddress import IPv4Address, IPv6Address
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, runtime_checkable
from uuid import UUID
from convertertools import pop_dict_set_if_none, pop_dict_tuple
from pydantic import BaseModel, ConfigDict
from pydantic.fields import PrivateAttr
from .._compat import cached_property
from ..exceptions import BadRequest, ClientError, NotAuthorized
from ..utils import (
convert_to_datetime,
convert_unifi_data,
dict_diff,
is_debug,
serialize_unifi_obj,
to_snake_case,
)
from .types import (
ModelType,
PercentFloat,
PermissionNode,
ProtectWSPayloadFormat,
StateType,
get_field_type,
)
from .websocket import (
WSJSONPacketFrame,
WSPacket,
WSPacketFrameHeader,
)
if TYPE_CHECKING:
from asyncio.events import TimerHandle
from typing import Self # requires Python 3.11+
from ..api import ProtectApiClient
from ..data.devices import Bridge
from ..data.nvr import Event
from ..data.user import User
RECENT_EVENT_MAX = timedelta(seconds=30)
EVENT_PING_INTERVAL = timedelta(seconds=3)
EVENT_PING_INTERVAL_SECONDS = EVENT_PING_INTERVAL.total_seconds()
_EMPTY_EVENT_PING_BACK: dict[Any, Any] = {}
_LOGGER = logging.getLogger(__name__)
@cache
def _is_protect_base_object(cls: type[Any]) -> bool:
"""A cached version of `issubclass(cls, ProtectBaseObject)` to speed up the check."""
return issubclass(cls, ProtectBaseObject)
class _ProtectModelObjects(NamedTuple):
"""
Class to track all child of UFP objects.
objs are UFP objects
lists are lists of UFP objects
dicts are dicts of UFP objects
"""
objs: dict[str, type[ProtectBaseObject]]
has_objs: bool
lists: dict[str, type[ProtectBaseObject]]
has_lists: bool
dicts: dict[str, type[ProtectBaseObject]]
has_dicts: bool
class ProtectBaseObject(BaseModel):
"""
Base class for building Python objects from UniFi Protect JSON.
* Provides `.unifi_dict_to_dict` to convert UFP JSON to a more Pythonic formatted dict (camel case to snake case)
* Add attrs with matching Pythonic name and they will automatically be populated from the UFP JSON if passed in to the constructor
* Provides `.unifi_dict` to convert object back into UFP JSON
"""
_api: ProtectApiClient = PrivateAttr(None) # type: ignore[assignment]
model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
def __init__(self, api: ProtectApiClient | None = None, **data: Any) -> None:
"""
Base class for creating Python objects from UFP JSON data.
Use the static method `.from_unifi_dict()` to create objects from UFP JSON data from then the main class constructor.
"""
super().__init__(**data)
if api is not None:
self._api = api
@classmethod
def from_unifi_dict(
cls,
api: ProtectApiClient | None = None,
**data: Any,
) -> Self:
"""
Main constructor for `ProtectBaseObject`
Args:
----
api: Optional reference to the ProtectAPIClient that created generated the UFP JSON
**data: decoded UFP JSON
`api` is is expected as a `@property`. If it is `None` and accessed, a `BadRequest` will be raised.
API can be used for saving updates for the Protect object or fetching references to other objects
(cameras, users, etc.)
"""
if api is not None:
data["api"] = api
data = cls.unifi_dict_to_dict(data)
if is_debug():
data.pop("api", None)
return cls(api=api, **data)
return cls.model_construct(**data)
@classmethod
def model_construct(
cls, _fields_set: set[str] | None = None, **values: Any
) -> Self:
api: ProtectApiClient | None = values.pop("api", None)
(
unifi_objs,
has_unifi_objs,
unifi_lists,
has_unifi_lists,
unifi_dicts,
has_unifi_dicts,
) = cls._get_protect_model()
for key, value in values.items():
if has_unifi_objs and key in unifi_objs and isinstance(value, dict):
values[key] = unifi_objs[key].model_construct(**value)
elif has_unifi_lists and key in unifi_lists and isinstance(value, list):
values[key] = [
unifi_lists[key].model_construct(**v) if isinstance(v, dict) else v
for v in value
]
elif has_unifi_dicts and key in unifi_dicts and isinstance(value, dict):
values[key] = {
k: unifi_dicts[key].model_construct(**v)
if isinstance(v, dict)
else v
for k, v in value.items()
}
obj = super().model_construct(_fields_set=_fields_set, **values)
if api is not None:
obj._api = api
return obj
@classmethod
@cache
def _get_excluded_changed_fields(cls) -> set[str]:
"""
Helper method for override in child classes for fields that excluded from calculating "changed" state for a
model (`.get_changed()`)
"""
return set()
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
"""
Helper method for overriding in child classes for remapping UFP JSON keys to Python ones that do not fit the
simple camel case to snake case formula.
Return format is
{
"ufpJsonName": "python_name"
}
"""
return {}
@classmethod
@cache
def _get_to_unifi_remaps(cls) -> dict[str, str]:
"""
Helper method for overriding in child classes for reversing remap UFP
JSON keys to Python ones that do not fit the simple camel case to
snake case formula.
Return format is
{
"python_name": "ufpJsonName"
}
"""
return {
to_key: from_key for from_key, to_key in cls._get_unifi_remaps().items()
}
@classmethod
@cache
def _get_protect_model(cls) -> _ProtectModelObjects:
"""Helper method to detect attrs of current class that are UFP Objects themselves"""
objs: dict[str, type[ProtectBaseObject]] = {}
lists: dict[str, type[ProtectBaseObject]] = {}
dicts: dict[str, type[ProtectBaseObject]] = {}
for name, field in cls.model_fields.items():
try:
origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type]
if _is_protect_base_object(type_):
if origin is list:
lists[name] = type_
elif origin is dict:
dicts[name] = type_
else:
objs[name] = type_
except TypeError:
pass
return _ProtectModelObjects(
objs, bool(objs), lists, bool(lists), dicts, bool(dicts)
)
@classmethod
@cache
def _get_excluded_fields(cls) -> set[str]:
"""Helper method to get all excluded fields for the current object."""
protect_model = cls._get_protect_model()
return set(protect_model.objs) | set(protect_model.lists)
@classmethod
def _clean_protect_obj(
cls,
data: Any,
klass: type[ProtectBaseObject],
api: ProtectApiClient | None,
) -> Any:
if isinstance(data, dict):
if api is not None:
data["api"] = api
return klass.unifi_dict_to_dict(data=data)
return data
@classmethod
def _clean_protect_obj_list(
cls,
items: list[Any],
klass: type[ProtectBaseObject],
api: ProtectApiClient | None,
) -> list[Any]:
return [cls._clean_protect_obj(item, klass, api) for item in items]
@classmethod
def _clean_protect_obj_dict(
cls,
items: dict[Any, Any],
klass: type[ProtectBaseObject],
api: ProtectApiClient | None,
) -> dict[Any, Any]:
return {k: cls._clean_protect_obj(v, klass, api) for k, v in items.items()}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
"""
Helper method for overriding in child classes for converting UFP JSON data to Python data types.
Return format is
{
"ufpJsonName": Callable[[Any], Any]
}
"""
return {}
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
"""
Takes a decoded UFP JSON dict and converts it into a Python dict
* Remaps items from `._get_unifi_remaps()`
* Converts camelCase keys to snake_case keys
* Injects ProtectAPIClient into any child UFP object Dicts
* Runs `.unifi_dict_to_dict` for any child UFP objects
Args:
----
data: decoded UFP JSON dict
"""
# get the API client instance
api: ProtectApiClient | None = data.get("api") or (
cls._api if isinstance(cls, ProtectBaseObject) else None
)
conversions = cls.unifi_dict_conversions()
for key, convert in conversions.items():
if (val := data.get(key)) is not None:
data[key] = convert(val) # type: ignore[operator]
remaps = cls._get_unifi_remaps()
# convert to snake_case and remove extra fields
_fields = cls.model_fields
for key in data.copy():
if key not in data:
# key may have already been consumed by a previous
# iteration when two keys map to the same snake_case key
continue
current_key = key
if current_key in remaps:
# remap keys that will not be converted correctly by snake_case convert
remapped_key = remaps[current_key]
data[remapped_key] = data.pop(current_key)
current_key = remapped_key
new_key = to_snake_case(current_key)
data[new_key] = data.pop(current_key)
current_key = new_key
if current_key == "api":
continue
if current_key not in _fields:
del data[current_key]
continue
data[current_key] = convert_unifi_data(
data[current_key], _fields[current_key]
)
if not data:
return data
# clean child UFP objs
(
unifi_objs,
has_unifi_objs,
unifi_lists,
has_unifi_lists,
unifi_dicts,
has_unifi_dicts,
) = cls._get_protect_model()
for key, value in data.items():
if has_unifi_objs and key in unifi_objs:
data[key] = cls._clean_protect_obj(value, unifi_objs[key], api)
elif has_unifi_lists and key in unifi_lists and isinstance(value, list):
data[key] = cls._clean_protect_obj_list(value, unifi_lists[key], api)
elif has_unifi_dicts and key in unifi_dicts and isinstance(value, dict):
data[key] = cls._clean_protect_obj_dict(value, unifi_dicts[key], api)
return data
def _unifi_dict_protect_obj(
self,
data: dict[str, Any],
key: str,
use_obj: bool,
klass: type[ProtectBaseObject],
) -> Any:
value: Any | None = data.get(key)
if use_obj:
value = getattr(self, key)
if isinstance(value, ProtectBaseObject):
value = value.unifi_dict()
elif isinstance(value, dict):
value = klass.model_construct({}).unifi_dict(data=value) # type: ignore[arg-type]
return value
def _unifi_dict_protect_obj_list(
self,
data: dict[str, Any],
key: str,
use_obj: bool,
klass: type[ProtectBaseObject],
) -> Any:
value: Any | None = data.get(key)
if use_obj:
value = getattr(self, key)
if not isinstance(value, list):
return value
return [
item.unifi_dict()
if isinstance(item, ProtectBaseObject)
else klass.model_construct({}).unifi_dict(data=item) # type: ignore[arg-type]
for item in value
]
def _unifi_dict_protect_obj_dict(
self,
data: dict[str, Any],
key: str,
use_obj: bool,
) -> Any:
value: Any | None = data.get(key)
if use_obj:
value = getattr(self, key)
if not isinstance(value, dict):
return value
return {
obj_key: obj.unifi_dict() if isinstance(obj, ProtectBaseObject) else obj
for obj_key, obj in value.items()
}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
"""
Can either convert current Python object into UFP JSON dict or take the output of a `.dict()` call and convert it.
* Remaps items from `._get_unifi_remaps()` in reverse
* Converts snake_case to camelCase
* Automatically removes any ProtectApiClient instances that might still be in the data
* Automatically calls `.unifi_dict()` for any UFP Python objects that are detected
Args:
----
data: Optional output of `.dict()` for the Python object. If `None`, will call `.dict` first
exclude: Optional set of fields to exclude from convert. Useful for subclassing and having custom
processing for dumping to UFP JSON data.
"""
use_obj = False
if data is None:
excluded_fields = self._get_excluded_fields()
if exclude is not None:
excluded_fields = excluded_fields.copy() | exclude
data = self.model_dump(exclude=excluded_fields)
use_obj = True
(
unifi_objs,
has_unifi_objs,
unifi_lists,
has_unifi_lists,
unifi_dicts,
has_unifi_dicts,
) = self._get_protect_model()
if has_unifi_objs:
for key, klass in unifi_objs.items():
if use_obj or key in data:
data[key] = self._unifi_dict_protect_obj(data, key, use_obj, klass)
if has_unifi_lists:
for key, klass in unifi_lists.items():
if use_obj or key in data:
data[key] = self._unifi_dict_protect_obj_list(
data, key, use_obj, klass
)
if has_unifi_dicts:
for key in unifi_dicts:
if use_obj or key in data:
data[key] = self._unifi_dict_protect_obj_dict(data, key, use_obj)
# all child objects have been serialized correctly do not do it twice
new_data: dict[str, Any] = serialize_unifi_obj(data, levels=2)
remaps = self._get_to_unifi_remaps()
for to_key in set(new_data).intersection(remaps):
new_data[remaps[to_key]] = new_data.pop(to_key)
return new_data
def update_from_dict(self, data: dict[str, Any]) -> Self:
"""
Updates current object from a cleaned UFP JSON dict.
The api client is injected into each dict for any child
UFP objects that are detected.
"""
(
unifi_objs,
has_unifi_objs,
unifi_lists,
has_unifi_lists,
_unifi_dicts,
_has_unifi_dicts,
) = self._get_protect_model()
api = self._api
_fields = self.__class__.model_fields
unifi_obj: ProtectBaseObject | None
value: Any
for key, item in data.items():
if has_unifi_objs and key in unifi_objs and isinstance(item, dict):
if (unifi_obj := getattr(self, key)) is not None:
value = unifi_obj.update_from_dict(item)
else:
value = unifi_objs[key](**item, api=api)
elif has_unifi_lists and key in unifi_lists and isinstance(item, list):
klass = unifi_lists[key]
value = [
klass(**i, api=api) if isinstance(i, dict) else i
for i in item
if i is not None and isinstance(i, (dict, ProtectBaseObject))
]
else:
value = convert_unifi_data(item, _fields[key])
setattr(self, key, value)
return self
def dict_with_excludes(self) -> dict[str, Any]:
"""Returns a dict of the current object without any UFP objects converted to dicts."""
excludes = self.__class__._get_excluded_changed_fields()
return self.model_dump(exclude=excludes)
def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
return dict_diff(data_before_changes, self.model_dump())
@property
def api(self) -> ProtectApiClient:
"""
ProtectApiClient that the UFP object was created with. If no API Client was passed in time of
creation, will raise `BadRequest`
"""
if self._api is None:
raise BadRequest("API Client not initialized")
return self._api
class ProtectModel(ProtectBaseObject):
"""
Base class for UFP objects with a `modelKey` attr. Provides `.from_unifi_dict()` static helper method for
automatically decoding a `modelKey` object into the correct UFP object and type
"""
model: ModelType | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "modelKey": "model"}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"modelKey"})
return data
class UpdateSynchronization:
"""Helper class for managing updates to Protect devices."""
@cached_property
def lock(self) -> asyncio.Lock:
"""Lock to prevent multiple updates at once."""
return asyncio.Lock()
@cached_property
def queue(self) -> asyncio.Queue[Callable[[], None]]:
"""Queue to store device updates."""
return asyncio.Queue()
@cached_property
def event(self) -> asyncio.Event:
"""Event to signal when a device update has been queued."""
return asyncio.Event()
class ProtectModelWithId(ProtectModel):
id: str
_update_sync: UpdateSynchronization = PrivateAttr(None) # type: ignore[assignment]
def __init__(self, **data: Any) -> None:
update_sync = data.pop("update_sync", None)
super().__init__(**data)
self._update_sync = update_sync or UpdateSynchronization()
@classmethod
def model_construct(
cls, _fields_set: set[str] | None = None, **values: Any
) -> Self:
update_sync = values.pop("update_sync", None)
obj = super().model_construct(_fields_set=_fields_set, **values)
obj._update_sync = update_sync or UpdateSynchronization()
return obj
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return set()
async def _api_update(self, data: dict[str, Any]) -> None:
raise NotImplementedError
def revert_changes(self, data_before_changes: dict[str, Any]) -> None:
"""Reverts current changes to device and resets it back to initial state"""
changed = self.get_changed(data_before_changes)
for key in changed:
setattr(self, key, data_before_changes[key])
def can_create(self, user: User) -> bool:
if (model := self.model) is not None:
return user.can(model, PermissionNode.CREATE, self)
return True
def can_read(self, user: User) -> bool:
if (model := self.model) is not None:
return user.can(model, PermissionNode.READ, self)
return True
def can_write(self, user: User) -> bool:
if (model := self.model) is not None:
return user.can(model, PermissionNode.WRITE, self)
return True
def can_delete(self, user: User) -> bool:
if (model := self.model) is not None:
return user.can(model, PermissionNode.DELETE, self)
return True
async def queue_update(self, callback: Callable[[], None]) -> None:
"""
Queues a device update.
This allows aggregating devices updates so if multiple ones come in all at once,
they can be combined in a single PATCH.
"""
self._update_sync.queue.put_nowait(callback)
self._update_sync.event.set()
# release execution so other `queue_update` calls can abort
await asyncio.sleep(0.001)
self._update_sync.event.clear()
try:
async with asyncio_timeout(0.05):
await self._update_sync.event.wait()
self._update_sync.event.clear()
return
except TimeoutError:
async with self._update_sync.lock:
# Important! Now that we have the lock, we yield to the event loop so any
# updates from the websocket are processed before we generate the diff
await asyncio.sleep(0)
# Save the initial data before we generate the diff
data_before_changes = self.dict_with_excludes()
while not self._update_sync.queue.empty():
callback = self._update_sync.queue.get_nowait()
callback()
# Important, do not yield to the event loop before generating the diff
# otherwise we may miss updates from the websocket
await self._save_device_changes(
data_before_changes,
self.unifi_dict(data=self.get_changed(data_before_changes)),
)
async def save_device(
self,
data_before_changes: dict[str, Any],
force_emit: bool = False,
revert_on_fail: bool = True,
) -> None:
"""
Generates a diff for unsaved changed on the device and sends them back to UFP
USE WITH CAUTION, updates _all_ fields for the current object that have been changed.
May have unexpected side effects.
Tested updates have been added a methods on applicable devices.
Args:
----
force_emit: Emit a fake UFP WS message. Should only be use for when UFP does not properly emit a WS message
"""
# do not allow multiple save_device calls at once
release_lock = False
if not self._update_sync.lock.locked():
await self._update_sync.lock.acquire()
release_lock = True
try:
await self._save_device_changes(
data_before_changes,
self.unifi_dict(data=self.get_changed(data_before_changes)),
force_emit=force_emit,
revert_on_fail=revert_on_fail,
)
finally:
if release_lock:
self._update_sync.lock.release()
async def _save_device_changes(
self,
data_before_changes: dict[str, Any],
updated: dict[str, Any],
force_emit: bool = False,
revert_on_fail: bool = True,
) -> None:
"""Saves the current device changes to UFP."""
_LOGGER.debug(
"Saving device changes for %s (%s) data_before_changes=%s updated=%s",
self.id,
self.model,
data_before_changes,
updated,
)
assert self._update_sync.lock.locked(), (
"save_device_changes should only be called when the update lock is held"
)
read_only_fields = self.__class__._get_read_only_fields()
if self.model is None:
raise BadRequest("Unknown model type")
if not self._api.bootstrap.auth_user.can(
self.model, PermissionNode.WRITE, self
):
if revert_on_fail:
self.revert_changes(data_before_changes)
raise NotAuthorized(f"Do not have write permission for obj: {self.id}")
# do not patch when there are no updates
if updated == {}:
return
read_only_keys = read_only_fields.intersection(updated)
if len(read_only_keys) > 0:
self.revert_changes(data_before_changes)
raise BadRequest(
f"{type(self)} The following key(s) are read only: {read_only_keys}, updated: {updated}",
)
try:
await self._api_update(updated)
except ClientError:
if revert_on_fail:
self.revert_changes(data_before_changes)
raise
if force_emit:
self._emit_message(updated)
async def emit_message(self, updated: dict[str, Any]) -> None:
"""Emits fake WS message for ProtectApiClient to process."""
self._emit_message(updated)
def _emit_message(self, updated: dict[str, Any]) -> None:
"""Emits fake WS message for ProtectApiClient to process."""
if _is_ping_back := updated is _EMPTY_EVENT_PING_BACK:
_LOGGER.debug("Event ping callback started for %s", self.id)
if self.model is None:
raise BadRequest("Unknown model type")
header = WSPacketFrameHeader(
packet_type=1,
payload_format=ProtectWSPayloadFormat.JSON.value,
deflated=0,
unknown=1,
payload_size=1,
)
action_frame = WSJSONPacketFrame()
action_frame.header = header
action_frame.data = {
"action": "update",
"newUpdateId": None,
"modelKey": self.model.value,
"id": self.id,
}
data_frame = WSJSONPacketFrame()
data_frame.header = header
data_frame.data = updated
message = self._api.bootstrap.process_ws_packet(
WSPacket(action_frame.packed + data_frame.packed),
is_ping_back=_is_ping_back,
)
if message is not None:
self._api.emit_message(message)
@runtime_checkable
class ProtectDeviceIdentity(Protocol):
"""
Identity surface shared by the private and public device / NVR model trees.
Consumers that handle "the NVR" or "a camera" generically can type against
this instead of ``cast()``-ing between the unrelated private (``NVR`` /
``Camera`` / ...) and public (``PublicNVR`` / ``PublicCamera`` / ...) types.
``mac`` and ``type`` are ``Optional`` because the public tree omits them on
older firmware.
"""
@property
def id(self) -> str: ...
@property
def mac(self) -> str | None: ...
@property
def display_name(self) -> str: ...
@property
def type(self) -> str | None: ...
@property
def model(self) -> ModelType | None: ...
class ProtectDeviceModel(ProtectModelWithId):
name: str | None = None
type: str
mac: str
host: IPv4Address | IPv6Address | str | None = None
up_since: datetime | None = None
uptime: timedelta | None = None
last_seen: datetime | None = None
hardware_revision: str | None = None
firmware_version: str | None = None
is_updating: bool
is_ssh_enabled: bool
_callback_ping: TimerHandle | None = PrivateAttr(None)
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {
"mac",
"host",
"type",
"upSince",
"uptime",
"lastSeen",
"hardwareRevision",
"isUpdating",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"upSince": convert_to_datetime,
"uptime": lambda x: timedelta(milliseconds=int(x)),
"lastSeen": convert_to_datetime,
# hardware revisions for all devices are not simple numbers
# so cast them all to str to be consistent
"hardwareRevision": str,
} | super().unifi_dict_conversions()
def _event_callback_ping(self) -> None:
_LOGGER.debug("Event ping timer started for %s", self.id)
loop = asyncio.get_running_loop()
self._callback_ping = loop.call_later(
EVENT_PING_INTERVAL_SECONDS,
self._emit_message,
_EMPTY_EVENT_PING_BACK,
)
async def set_name(self, name: str | None) -> None:
"""Sets name for the device"""
def callback() -> None:
self.name = name
await self.queue_update(callback)
class WiredConnectionState(ProtectBaseObject):
phy_rate: float | None = None
class WirelessConnectionState(ProtectBaseObject):
signal_quality: int | None = None
signal_strength: int | None = None
class BluetoothConnectionState(WirelessConnectionState):
experience_score: PercentFloat | None = None
class WifiConnectionState(WirelessConnectionState):
phy_rate: float | None = None
channel: int | None = None
frequency: int | None = None
ssid: str | None = None
bssid: str | None = None
tx_rate: float | None = None
# requires 2.7.5+
ap_name: str | None = None
experience: str | None = None
# requires 2.7.15+
connectivity: str | None = None
class ProtectAdoptableDeviceModel(ProtectDeviceModel):
state: StateType
connection_host: IPv4Address | IPv6Address | str | None = None
connected_since: datetime | None = None
latest_firmware_version: str | None = None
firmware_build: str | None = None
is_adopting: bool
is_adopted: bool
is_adopted_by_other: bool
is_provisioned: bool
is_rebooting: bool
can_adopt: bool
is_attempting_to_connect: bool
is_connected: bool
# requires 1.21+
market_name: str | None = None
# requires 2.7.5+
fw_update_state: str | None = None
# requires 2.8.14+
nvr_mac: str | None = None
# requires 2.8.22+
guid: UUID | None = None
# requires 2.9.20+
is_restoring: bool | None = None
last_disconnect: datetime | None = None
anonymous_device_id: UUID | None = None
wired_connection_state: WiredConnectionState | None = None
wifi_connection_state: WifiConnectionState | None = None
bluetooth_connection_state: BluetoothConnectionState | None = None
bridge_id: str | None = None
is_downloading_firmware: bool | None = None
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {
"connectionHost",
"connectedSince",
"state",
"latestFirmwareVersion",
"firmwareBuild",
"isAdopting",
"isProvisioned",
"isRebooting",
"canAdopt",
"isAttemptingToConnect",
"bluetoothConnectionState",
"isDownloadingFirmware",
"anonymousDeviceId",
}
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"bridge": "bridgeId",
"isDownloadingFW": "isDownloadingFirmware",
}
async def _api_update(self, data: dict[str, Any]) -> None:
if (model := self.model) is not None:
return await self._api.update_device(model, self.id, data)
return None
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(
data,
{"wiredConnectionState", "wifiConnectionState", "bluetoothConnectionState"},
)
return data
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"lastDisconnect": convert_to_datetime,
} | super().unifi_dict_conversions()
@property
def display_name(self) -> str:
return self.name or self.market_name or self.type
@property
def is_wired(self) -> bool:
return self.wired_connection_state is not None
@property
def is_wifi(self) -> bool:
return self.wifi_connection_state is not None
@property
def is_bluetooth(self) -> bool:
return self.bluetooth_connection_state is not None
@property
def bridge(self) -> Bridge | None:
if (bridge_id := self.bridge_id) is not None:
return self._api.bootstrap.bridges[bridge_id]
return None
@property
def protect_url(self) -> str:
"""UFP Web app URL for this device"""
return f"{self._api.base_url}/protect/devices/{self.id}"
@property
def is_adopted_by_us(self) -> bool:
"""Verifies device is adopted and controlled by this NVR."""
return self.is_adopted and not self.is_adopted_by_other
def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
"""Gets dictionary of all changed fields"""
return dict_diff(data_before_changes, self.dict_with_excludes())
async def set_ssh(self, enabled: bool) -> None:
"""Sets ssh status for protect device"""
def callback() -> None:
self.is_ssh_enabled = enabled
await self.queue_update(callback)
async def reboot(self) -> None:
"""Reboots an adopted device"""
if self.model is not None:
if not self._api.bootstrap.auth_user.can(
self.model,
PermissionNode.WRITE,
self,
):
raise NotAuthorized("Do not have permission to reboot device")
await self._api.reboot_device(self.model, self.id)
async def unadopt(self) -> None:
"""Unadopt/Unmanage adopted device"""
if not self.is_adopted_by_us:
raise BadRequest("Device is not adopted")
if self.model is not None:
if not self._api.bootstrap.auth_user.can(
self.model,
PermissionNode.DELETE,
self,
):
raise NotAuthorized("Do not have permission to unadopt devices")
await self._api.unadopt_device(self.model, self.id)
async def adopt(self, name: str | None = None) -> None:
"""Adopts a device"""
if not self.can_adopt:
raise BadRequest("Device cannot be adopted")
if self.model is not None:
if not self._api.bootstrap.auth_user.can(self.model, PermissionNode.CREATE):
raise NotAuthorized("Do not have permission to adopt devices")
await self._api.adopt_device(self.model, self.id)
if name is not None:
await self.set_name(name)
class ProtectMotionDeviceModel(ProtectAdoptableDeviceModel):
last_motion: datetime | None = None
is_dark: bool
# not directly from UniFi
last_motion_event_id: str | None = None
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {"lastMotion", "isDark"}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_tuple(data, ("lastMotionEventId",))
return data
@property
def last_motion_event(self) -> Event | None:
if (last_motion_event_id := self.last_motion_event_id) is not None:
return self._api.bootstrap.events.get(last_motion_event_id)
return None
uilibs-uiprotect-317a52c/src/uiprotect/data/bootstrap.py 0000664 0000000 0000000 00000071027 15246377057 0023500 0 ustar 00root root 0000000 0000000 """UniFi Protect Bootstrap."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any, cast
from aiohttp.client_exceptions import ServerDisconnectedError
from convertertools import pop_dict_set, pop_dict_tuple
from pydantic import PrivateAttr, ValidationError
from ..exceptions import ClientError, DataDecodeError
from ..utils import normalize_mac, to_snake_case, utc_now
from .base import (
RECENT_EVENT_MAX,
ProtectBaseObject,
ProtectDeviceModel,
ProtectModel,
ProtectModelWithId,
)
from .convert import MODEL_TO_CLASS, create_from_unifi_dict
from .devices import (
AiPort,
Bridge,
Camera,
Chime,
Light,
ProtectAdoptableDeviceModel,
Ringtone,
Sensor,
Viewer,
)
from .nvr import MAX_EVENT_HISTORY_IN_STATE_MACHINE, NVR, Event, Liveview
from .types import EventType, FixSizeOrderedDict, ModelType, SmartDetectObjectType
from .user import Group, Keyrings, UlpUserKeyringBase, UlpUsers, User
from .websocket import (
WSAction,
WSPacket,
WSSubscriptionMessage,
)
if TYPE_CHECKING:
from ..api import ProtectApiClient
_LOGGER = logging.getLogger(__name__)
STATS_KEYS = {
"eventStats",
"storageStats",
"stats",
"systemInfo",
"phyRate",
"wifiConnectionState",
"upSince",
"uptime",
"lastSeen",
"recordingSchedules",
}
IGNORE_DEVICE_KEYS = {"nvrMac", "guid"}
STATS_AND_IGNORE_DEVICE_KEYS = STATS_KEYS | IGNORE_DEVICE_KEYS
_IGNORE_KEYS_BY_MODEL_TYPE = {
#
# `lastMotion` from cameras update every 100 milliseconds when a motion event is active
# this overrides the behavior to only update `lastMotion` when a new event starts
#
ModelType.CAMERA: {"lastMotion"},
#
# `cameraIds` is updated every 10s, but we don't need to process it since bootstrap
# is resynced every so often anyways.
#
ModelType.CHIME: {"cameraIds"},
}
IGNORE_DEVICE_KEYS_BY_MODEL_TYPE = {
model_type: IGNORE_DEVICE_KEYS | keys
for model_type, keys in _IGNORE_KEYS_BY_MODEL_TYPE.items()
}
STATS_AND_IGNORE_DEVICE_KEYS_BY_MODEL_TYPE = {
model_type: STATS_AND_IGNORE_DEVICE_KEYS | keys
for model_type, keys in _IGNORE_KEYS_BY_MODEL_TYPE.items()
}
CAMERA_EVENT_ATTR_MAP: dict[EventType, tuple[str, str]] = {
EventType.MOTION: ("last_motion", "last_motion_event_id"),
EventType.SMART_DETECT: ("last_smart_detect", "last_smart_detect_event_id"),
EventType.SMART_DETECT_LINE: ("last_smart_detect", "last_smart_detect_event_id"),
EventType.SMART_AUDIO_DETECT: (
"last_smart_audio_detect",
"last_smart_audio_detect_event_id",
),
EventType.RING: ("last_ring", "last_ring_event_id"),
EventType.NFC_CARD_SCANNED: (
"last_nfc_card_scanned",
"last_nfc_card_scanned_event_id",
),
EventType.FINGERPRINT_IDENTIFIED: (
"last_fingerprint_identified",
"last_fingerprint_identified_event_id",
),
}
def _process_light_event(event: Event, light: Light) -> None:
light.last_motion_event_id = event.id
def _process_sensor_event(event: Event, sensor: Sensor) -> None:
if event.type is EventType.MOTION_SENSOR:
sensor.last_motion_event_id = event.id
elif event.type in {EventType.SENSOR_CLOSED, EventType.SENSOR_OPENED}:
sensor.last_contact_event_id = event.id
elif event.type is EventType.SENSOR_EXTREME_VALUE:
sensor.extreme_value_detected_at = event.end
sensor.last_value_event_id = event.id
elif event.type is EventType.SENSOR_ALARM:
sensor.last_value_event_id = event.id
_CAMERA_SMART_AND_LINE_EVENTS = {
EventType.SMART_DETECT,
EventType.SMART_DETECT_LINE,
}
_CAMERA_SMART_AUDIO_EVENT = EventType.SMART_AUDIO_DETECT
def _find_active_smart_event(
camera: Camera,
smart_type: SmartDetectObjectType,
) -> Event | None:
"""
Find the first-registered still-active smart detection event for this type.
Returns the earliest-inserted non-ended event in
``camera._active_smart_detect_events[smart_type]``. Since Python dicts
preserve insertion order and active events are appended as they arrive,
this matches "oldest by processing order" — not "oldest by event start
time". Runs in O(active_events_per_type) and is independent of
``bootstrap.events``.
"""
active = camera._active_smart_detect_events.get(smart_type)
if not active:
return None
# Return the oldest still-active event; clean up stale entries along the way.
result: Event | None = None
stale_ids: list[str] | None = None
for eid, event in active.items():
if event.end is not None:
if stale_ids is None:
stale_ids = []
stale_ids.append(eid)
elif result is None:
result = event
if stale_ids:
for eid in stale_ids:
del active[eid]
if not active:
camera._active_smart_detect_events.pop(smart_type, None)
return result
def _find_any_active_smart_event(camera: Camera) -> Event | None:
"""Return any still-active smart detect event across all types for a camera."""
for active in camera._active_smart_detect_events.values():
for event in active.values():
if event.end is None:
return event
return None
def _singular_id_is_active(camera: Camera, singular_id: str) -> bool:
"""Check whether the singular last_smart_detect_event_id points to an active event."""
for active in camera._active_smart_detect_events.values():
if (event := active.get(singular_id)) is not None and event.end is None:
return True
return False
def _process_smart_detect_event(event: Event, camera: Camera) -> None:
"""Process smart detect / smart detect line per-type tracking."""
event_id = event.id
event_start = event.start
if event.end is None:
# Active event — becomes the new "last" for the camera and per-type.
camera.last_smart_detect_event_id = event_id
camera.last_smart_detect = event_start
for smart_type in event.smart_detect_types:
camera.last_smart_detect_event_ids[smart_type] = event_id
camera.last_smart_detects[smart_type] = event_start
# Add to per-camera active index while preserving processing order
camera._active_smart_detect_events.setdefault(smart_type, {})[event_id] = (
event
)
return
for smart_type in event.smart_detect_types:
# Event ended — remove from active index
active = camera._active_smart_detect_events.get(smart_type)
if active:
active.pop(event_id, None)
if not active:
camera._active_smart_detect_events.pop(smart_type, None)
current_id = camera.last_smart_detect_event_ids.get(smart_type)
if current_id == event_id or current_id is None:
# Tracked event ended, or no event tracked yet — find a replacement
replacement = _find_active_smart_event(camera, smart_type)
if replacement is not None:
camera.last_smart_detect_event_ids[smart_type] = replacement.id
camera.last_smart_detects[smart_type] = replacement.start
elif current_id is None:
# No replacement and nothing tracked — store ended event
# as last known state
camera.last_smart_detect_event_ids[smart_type] = event_id
camera.last_smart_detects[smart_type] = event_start
# If current_id is set and != event_id, another event
# is already tracked — do nothing
# Update the singular last_smart_detect_event_id only if it no longer
# references an active event. Protect v7 emits overlapping SMART_DETECT
# and SMART_DETECT_LINE events; letting an ended event overwrite the
# singular field would make is_smart_currently_detected flip OFF while
# the sibling event is still active.
singular_id = camera.last_smart_detect_event_id
if singular_id is None or not _singular_id_is_active(camera, singular_id):
replacement = _find_any_active_smart_event(camera)
if replacement is not None:
camera.last_smart_detect_event_id = replacement.id
camera.last_smart_detect = replacement.start
elif singular_id is None:
camera.last_smart_detect_event_id = event_id
camera.last_smart_detect = event_start
def _process_camera_event(event: Event, camera: Camera) -> None:
event_type = event.type
if event_type in _CAMERA_SMART_AND_LINE_EVENTS:
_process_smart_detect_event(event, camera)
return
dt_attr, event_attr = CAMERA_EVENT_ATTR_MAP[event_type]
event_id = event.id
event_start = event.start
setattr(camera, event_attr, event_id)
setattr(camera, dt_attr, event_start)
if event_type is _CAMERA_SMART_AUDIO_EVENT:
for smart_type in event.smart_detect_types:
if (audio_type := smart_type.audio_type) is None:
continue
camera.last_smart_audio_detect_event_ids[audio_type] = event_id
camera.last_smart_audio_detects[audio_type] = event_start
@dataclass
class WSStat:
model: str
action: str
keys: list[str]
keys_set: list[str]
size: int
filtered: bool
class ProtectDeviceRef(ProtectBaseObject):
model: ModelType
id: str
class Bootstrap(ProtectBaseObject):
auth_user_id: str
access_key: str
cameras: dict[str, Camera]
users: dict[str, User]
groups: dict[str, Group]
liveviews: dict[str, Liveview]
nvr: NVR
viewers: dict[str, Viewer]
lights: dict[str, Light] = {}
bridges: dict[str, Bridge] = {}
sensors: dict[str, Sensor] = {}
chimes: dict[str, Chime] = {}
aiports: dict[str, AiPort] = {}
ringtones: list[Ringtone]
last_update_id: str
# not directly from UniFi
keyrings: Keyrings = Keyrings()
ulp_users: UlpUsers = UlpUsers()
events: dict[str, Event] = FixSizeOrderedDict(
max_size=MAX_EVENT_HISTORY_IN_STATE_MACHINE
)
capture_ws_stats: bool = False
mac_lookup: dict[str, ProtectDeviceRef] = {}
id_lookup: dict[str, ProtectDeviceRef] = {}
_ws_stats: list[WSStat] = PrivateAttr([])
_has_doorbell: bool | None = PrivateAttr(None)
_has_smart: bool | None = PrivateAttr(None)
_has_media: bool | None = PrivateAttr(None)
_recording_start: datetime | None = PrivateAttr(None)
_refresh_tasks: set[asyncio.Task[None]] = PrivateAttr(set())
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
api: ProtectApiClient | None = data.get("api") or (
cls._api if isinstance(cls, ProtectBaseObject) else None
)
mac_lookup: dict[str, dict[str, str | ModelType]] = {}
id_lookup: dict[str, dict[str, str | ModelType]] = {}
data["idLookup"] = id_lookup
data["macLookup"] = mac_lookup
# Fields that are not (always?) available in newer Protect versions
optional_fields: set[str] = set()
for model_type in ModelType.bootstrap_models_types_set:
key = model_type.devices_key # type: ignore[attr-defined]
items: dict[str, ProtectModel] = {}
if key not in data:
# Optional fields with defaults don't need logging or setting
if key not in optional_fields:
data[key] = {}
_LOGGER.error(
"Missing key in bootstrap: %s. This may be fixed by updating Protect.",
key,
)
continue
for item in data[key]:
if (
api is not None
and api.ignore_unadopted
and not item.get("isAdopted", True)
):
continue
id_: str = item["id"]
ref = {"model": model_type, "id": id_}
items[id_] = item
id_lookup[id_] = ref
if "mac" in item:
cleaned_mac = normalize_mac(item["mac"])
mac_lookup[cleaned_mac] = ref
data[key] = items
return super().unifi_dict_to_dict(data)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_tuple(data, ("events", "captureWsStats", "macLookup", "idLookup"))
for model_type in ModelType.bootstrap_models_types_set:
attr = model_type.devices_key # type: ignore[attr-defined]
if attr in data and isinstance(data[attr], dict):
data[attr] = list(data[attr].values())
return data
@property
def ws_stats(self) -> list[WSStat]:
return self._ws_stats
def clear_ws_stats(self) -> None:
self._ws_stats = []
@property
def auth_user(self) -> User:
return self._api.bootstrap.users[self.auth_user_id]
@property
def has_doorbell(self) -> bool:
if self._has_doorbell is None:
self._has_doorbell = any(
c.feature_flags.is_doorbell for c in self.cameras.values()
)
return self._has_doorbell
@property
def recording_start(self) -> datetime | None:
"""Get earliest recording date."""
if self._recording_start is None:
try:
self._recording_start = min(
c.stats.video.recording_start
for c in self.cameras.values()
if c.stats.video.recording_start is not None
)
except ValueError:
return None
return self._recording_start
@property
def has_smart_detections(self) -> bool:
"""Check if any camera has smart detections."""
if self._has_smart is None:
self._has_smart = any(
c.feature_flags.has_smart_detect for c in self.cameras.values()
)
return self._has_smart
@property
def has_media(self) -> bool:
"""Checks if user can read media for any camera."""
if self._has_media is None:
if self.recording_start is None:
return False
self._has_media = any(
c.can_read_media(self.auth_user) for c in self.cameras.values()
)
return self._has_media
def get_device_from_mac(self, mac: str) -> ProtectAdoptableDeviceModel | None:
"""Retrieve a device from MAC address."""
return self._get_device_from_ref(self.mac_lookup.get(normalize_mac(mac)))
def get_device_from_id(self, device_id: str) -> ProtectAdoptableDeviceModel | None:
"""Retrieve a device from device ID (without knowing model type)."""
return self._get_device_from_ref(self.id_lookup.get(device_id))
def _get_device_from_ref(
self, ref: ProtectDeviceRef | None
) -> ProtectAdoptableDeviceModel | None:
if ref is None:
return None
devices_key = ref.model.devices_key
devices: dict[str, ProtectAdoptableDeviceModel] = getattr(self, devices_key)
return devices[ref.id]
def process_event(self, event: Event) -> None:
event_type = event.type
if event_type in CAMERA_EVENT_ATTR_MAP and (camera := event.camera):
_process_camera_event(event, camera)
elif event_type is EventType.MOTION_LIGHT and (light := event.light):
_process_light_event(event, light)
elif event_type is EventType.MOTION_SENSOR and (sensor := event.sensor):
_process_sensor_event(event, sensor)
self.events[event.id] = event
def _process_add_packet(
self,
model_type: ModelType,
data: dict[str, Any],
) -> WSSubscriptionMessage | None:
if (
model_type is ModelType.EVENT
and data.get("type") not in EventType.values_set()
):
# Newer firmware can introduce event types this client doesn't model
# yet. Mirror the REST ``get_events`` guard (``api.py``) and skip the
# frame up front instead of routing a benign forward-compat case
# through the generic ``ValidationError`` backstop below.
_LOGGER.debug("Unknown event type: %s", data)
return None
obj = create_from_unifi_dict(data, api=self._api, model_type=model_type)
if model_type is ModelType.EVENT:
if TYPE_CHECKING:
assert isinstance(obj, Event)
self.process_event(obj)
elif model_type is ModelType.NVR:
if TYPE_CHECKING:
assert isinstance(obj, NVR)
self.nvr = obj
elif model_type in ModelType.bootstrap_models_types_set:
if TYPE_CHECKING:
assert isinstance(obj, ProtectAdoptableDeviceModel)
if not self._api.ignore_unadopted or (
obj.is_adopted and not obj.is_adopted_by_other
):
id_ = obj.id
getattr(self, model_type.devices_key)[id_] = obj
ref = ProtectDeviceRef(model=model_type, id=id_)
self.id_lookup[id_] = ref
self.mac_lookup[normalize_mac(obj.mac)] = ref
else:
_LOGGER.debug("Unexpected bootstrap model type for add: %s", model_type)
return None
return WSSubscriptionMessage(
action=WSAction.ADD,
new_update_id=self.last_update_id,
changed_data=obj.model_dump(),
new_obj=obj,
)
def _process_remove_packet(
self, model_type: ModelType, action: dict[str, Any]
) -> WSSubscriptionMessage | None:
devices_key = model_type.devices_key
devices: dict[str, ProtectDeviceModel] | None = getattr(self, devices_key, None)
if devices is None:
return None
device_id: str = action["id"]
self.id_lookup.pop(device_id, None)
if (device := devices.pop(device_id, None)) is None:
return None
self.mac_lookup.pop(normalize_mac(device.mac), None)
return WSSubscriptionMessage(
action=WSAction.REMOVE,
new_update_id=self.last_update_id,
changed_data={},
old_obj=device,
)
def _process_ws_keyring_or_ulp_user_message(
self,
action: dict[str, Any],
data: dict[str, Any],
model_type: ModelType,
) -> WSSubscriptionMessage | None:
action_id = action["id"]
obj_from_bootstrap: UlpUserKeyringBase[ProtectModelWithId] = getattr(
self, to_snake_case(model_type.devices_key)
)
action_type = action["action"]
if action_type == "add":
add_obj = create_from_unifi_dict(data, api=self._api, model_type=model_type)
if TYPE_CHECKING:
model_class = MODEL_TO_CLASS.get(model_type)
assert model_class is not None and isinstance(add_obj, model_class)
add_obj = cast("ProtectModelWithId", add_obj)
obj_from_bootstrap.add(add_obj)
return WSSubscriptionMessage(
action=WSAction.ADD,
new_update_id=self.last_update_id,
changed_data=add_obj.model_dump(),
new_obj=add_obj,
)
if action_type == "remove":
to_remove = obj_from_bootstrap.by_id(action_id)
if to_remove is None:
return None
obj_from_bootstrap.remove(to_remove)
return WSSubscriptionMessage(
action=WSAction.REMOVE,
new_update_id=self.last_update_id,
changed_data={},
old_obj=to_remove,
)
if action_type == "update":
updated_obj = obj_from_bootstrap.by_id(action_id)
if updated_obj is None:
return None
old_obj = updated_obj.model_copy()
updated_data = {to_snake_case(k): v for k, v in data.items()}
updated_obj.update_from_dict(updated_data)
return WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=self.last_update_id,
changed_data=updated_data,
new_obj=updated_obj,
old_obj=old_obj,
)
_LOGGER.debug("Unexpected ws action for %s: %s", model_type, action_type)
return None
def _process_nvr_update(
self,
action: dict[str, Any],
data: dict[str, Any],
ignore_stats: bool,
) -> WSSubscriptionMessage | None:
if ignore_stats:
pop_dict_set(data, STATS_KEYS)
# nothing left to process
if not data:
return None
# for another NVR in stack
nvr_id: str | None = action.get("id")
if nvr_id and nvr_id != self.nvr.id:
return None
# nothing left to process
if not (data := self.nvr.unifi_dict_to_dict(data)):
return None
old_nvr = self.nvr.model_copy()
self.nvr = self.nvr.update_from_dict(data)
return WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=self.last_update_id,
changed_data=data,
new_obj=self.nvr,
old_obj=old_nvr,
)
def _process_device_update(
self,
model_type: ModelType,
action: dict[str, Any],
data: dict[str, Any],
ignore_stats: bool,
is_ping_back: bool,
) -> WSSubscriptionMessage | None:
"""
Process a device update packet.
If is_ping_back is True, the packet is an empty packet
that was generated internally as a result of an event
that will expire and result in a state change.
"""
if ignore_stats:
remove_keys = STATS_AND_IGNORE_DEVICE_KEYS_BY_MODEL_TYPE.get(
model_type, STATS_AND_IGNORE_DEVICE_KEYS
)
else:
remove_keys = IGNORE_DEVICE_KEYS_BY_MODEL_TYPE.get(
model_type, IGNORE_DEVICE_KEYS
)
pop_dict_set(data, remove_keys)
# nothing left to process
if not data and not is_ping_back:
return None
devices: dict[str, ProtectModelWithId] = getattr(self, model_type.devices_key)
action_id: str = action["id"]
if action_id not in devices:
# ignore updates to events that phase out
if model_type is not ModelType.EVENT:
_LOGGER.debug("Unexpected %s: %s", model_type, action_id)
return None
obj = devices[action_id]
data = obj.unifi_dict_to_dict(data)
if not data and not is_ping_back:
# nothing left to process
return None
old_obj = obj.model_copy()
obj = obj.update_from_dict(data)
if model_type is ModelType.EVENT:
if TYPE_CHECKING:
assert isinstance(obj, Event)
self.process_event(obj)
elif model_type is ModelType.SENSOR:
if TYPE_CHECKING:
assert isinstance(obj, Sensor)
if "alarm_triggered_at" in data and (trigged_at := obj.alarm_triggered_at):
if is_recent := trigged_at + RECENT_EVENT_MAX >= utc_now():
obj.set_alarm_timeout()
_LOGGER.debug("alarm_triggered_at for %s (%s)", obj.id, is_recent)
devices[action_id] = obj
return WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=self.last_update_id,
changed_data=data,
new_obj=obj,
old_obj=old_obj,
)
def process_ws_packet(
self,
packet: WSPacket,
models: set[ModelType] | None = None,
ignore_stats: bool = False,
is_ping_back: bool = False,
) -> WSSubscriptionMessage | None:
"""Process a WS packet."""
capture_ws_stats = self.capture_ws_stats
action = packet.action_frame.data
data = packet.data_frame.data
keys = list(data) if capture_ws_stats else None
new_update_id: str | None = action["newUpdateId"]
if new_update_id is not None:
self.last_update_id = new_update_id
message = self._make_ws_packet_message(
action, data, models, ignore_stats, is_ping_back
)
if capture_ws_stats:
if TYPE_CHECKING:
assert keys is not None
self._ws_stats.append(
WSStat(
model=action["modelKey"],
action=action["action"],
keys=keys,
keys_set=[] if message is None else list(message.changed_data),
size=len(packet.raw),
filtered=message is None,
),
)
return message
def _make_ws_packet_message( # noqa: PLR0911
self,
action: dict[str, Any],
data: dict[str, Any],
models: set[ModelType] | None,
ignore_stats: bool,
is_ping_back: bool,
) -> WSSubscriptionMessage | None:
"""Process a WS packet."""
model_key: str = action["modelKey"]
if (model_type := ModelType.from_string(model_key)) is ModelType.UNKNOWN:
_LOGGER.debug("Unknown model type: %s", model_key)
return None
if models and model_type not in models:
return None
action_action: str = action["action"]
try:
if model_type in {ModelType.KEYRING, ModelType.ULP_USER}:
return self._process_ws_keyring_or_ulp_user_message(
action, data, model_type
)
if action_action == "remove":
return self._process_remove_packet(model_type, action)
if not data and not is_ping_back:
return None
if action_action == "add":
return self._process_add_packet(model_type, data)
if action_action == "update":
if model_type is ModelType.NVR:
return self._process_nvr_update(action, data, ignore_stats)
if model_type in ModelType.bootstrap_models_types_and_event_set:
return self._process_device_update(
model_type, action, data, ignore_stats, is_ping_back
)
except (DataDecodeError, ValidationError, ValueError, KeyError) as err:
self._handle_ws_error(action_action, model_type, action, err)
_LOGGER.debug(
"Unexpected bootstrap model type deviceadoptedfor update: %s", model_key
)
return None
def _handle_ws_error(
self,
action_action: str,
model_type: ModelType,
action: dict[str, Any],
err: Exception,
) -> None:
msg = ""
device_id: str | None = action.get("id")
if model_type is ModelType.EVENT:
msg = f"Validation error processing event: {device_id}. Ignoring event."
elif device_id is not None:
task = asyncio.create_task(self.refresh_device(model_type, device_id))
self._refresh_tasks.add(task)
task.add_done_callback(self._refresh_tasks.discard)
msg = (
f"{action_action} packet caused invalid state. "
f"Refreshing device: {model_type} {device_id}"
)
else:
msg = (
f"{action_action} packet for {model_type} "
f"has no device ID, cannot refresh"
)
_LOGGER.debug("%s Error: %s", msg, err)
async def refresh_device(self, model_type: ModelType, device_id: str) -> None:
"""Refresh a device in the bootstrap."""
try:
if model_type is ModelType.NVR:
device: ProtectModelWithId = await self._api.get_nvr()
else:
device = await self._api.get_device(model_type, device_id)
except (
DataDecodeError,
ValidationError,
TimeoutError,
ClientError,
ServerDisconnectedError,
):
_LOGGER.warning("Failed to refresh model: %s %s", model_type, device_id)
return
if isinstance(device, NVR):
self.nvr = device
else:
devices_key = model_type.devices_key
devices: dict[str, ProtectModelWithId] = getattr(self, devices_key)
devices[device.id] = device
_LOGGER.debug("Successfully refresh model: %s %s", model_type, device_id)
async def get_is_prerelease(self) -> bool:
"""[DEPRECATED] Always returns False. Will be removed after HA 2025.8.0."""
return False
uilibs-uiprotect-317a52c/src/uiprotect/data/convert.py 0000664 0000000 0000000 00000006067 15246377057 0023145 0 ustar 00root root 0000000 0000000 """UniFi Protect Data Conversion."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from ..exceptions import DataDecodeError
from .devices import (
AiPort,
Bridge,
Camera,
Chime,
Light,
Sensor,
Viewer,
)
from .nvr import NVR, Event, Liveview
from .public_devices import Fob, LinkStation, Relay, Siren, Speaker
from .types import ModelType
from .user import CloudAccount, Group, Keyring, UlpUser, User, UserLocation
if TYPE_CHECKING:
from ..api import ProtectApiClient
from ..data.base import ProtectModel, ProtectModelWithId
MODEL_TO_CLASS: dict[str, type[ProtectModel]] = {
ModelType.EVENT: Event,
ModelType.GROUP: Group,
ModelType.USER_LOCATION: UserLocation,
ModelType.CLOUD_IDENTITY: CloudAccount,
ModelType.USER: User,
ModelType.NVR: NVR,
ModelType.LIGHT: Light,
ModelType.CAMERA: Camera,
ModelType.LIVEVIEW: Liveview,
ModelType.VIEWPORT: Viewer,
ModelType.BRIDGE: Bridge,
ModelType.SENSOR: Sensor,
ModelType.CHIME: Chime,
ModelType.AIPORT: AiPort,
ModelType.KEYRING: Keyring,
ModelType.ULP_USER: UlpUser,
# Public API only. These are not produced by the private
# bootstrap, so private-API callers remain unaffected.
ModelType.SIREN: Siren,
ModelType.RELAY: Relay,
ModelType.FOB: Fob,
ModelType.SPEAKER: Speaker,
ModelType.LINK_STATION: LinkStation,
}
def get_klass_from_dict(data: dict[str, Any]) -> type[ProtectModel]:
"""
Helper method to read the `modelKey` from a UFP JSON dict and get the correct Python class for conversion.
Will raise `DataDecodeError` if the `modelKey` is for an unknown object.
"""
if "modelKey" not in data:
raise DataDecodeError("No modelKey")
model = ModelType(data["modelKey"])
klass = MODEL_TO_CLASS.get(model)
if klass is None:
raise DataDecodeError("Unknown modelKey")
return klass
def create_from_unifi_dict(
data: dict[str, Any],
api: ProtectApiClient | None = None,
klass: type[ProtectModel] | None = None,
model_type: ModelType | None = None,
) -> ProtectModel:
"""
Convert a UFP JSON dict to the correct Python class.
When ``model_type`` is provided (e.g. from WS action headers), ``modelKey``
will be synthesised if absent (Protect 7+).
Raises ``DataDecodeError`` if the model cannot be resolved.
"""
if model_type is not None:
if klass is None:
klass = MODEL_TO_CLASS.get(model_type)
# Protect 7+ may omit modelKey from WS data payloads; add it if missing.
if "modelKey" not in data:
data["modelKey"] = model_type.value
if "modelKey" not in data:
raise DataDecodeError("No modelKey")
if klass is None:
klass = get_klass_from_dict(data)
return klass.from_unifi_dict(**data, api=api)
def list_from_unifi_list(
api: ProtectApiClient, unifi_list: list[dict[str, Any]]
) -> list[ProtectModelWithId]:
return [
cast("ProtectModelWithId", create_from_unifi_dict(obj_dict, api))
for obj_dict in unifi_list
]
uilibs-uiprotect-317a52c/src/uiprotect/data/devices.py 0000664 0000000 0000000 00000437471 15246377057 0023116 0 ustar 00root root 0000000 0000000 """UniFi Protect Data."""
from __future__ import annotations
import asyncio
import logging
import warnings
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from functools import cache, lru_cache
from ipaddress import IPv4Address, IPv6Address
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Literal, cast
from convertertools import pop_dict_set_if_none, pop_dict_tuple
from pydantic import model_validator
from pydantic.fields import PrivateAttr
from ..exceptions import BadRequest, NotAuthorized, StreamError
from ..stream import TalkbackSession, TalkbackStream
from ..utils import (
clamp_value,
convert_smart_audio_types,
convert_smart_types,
convert_to_datetime,
convert_video_modes,
format_host_for_url,
from_js_time,
serialize_point,
timedelta_total_seconds,
to_js_time,
utc_now,
)
from .base import (
EVENT_PING_INTERVAL,
ProtectAdoptableDeviceModel,
ProtectBaseObject,
ProtectMotionDeviceModel,
)
from .public_devices import (
_PUBLIC_HUMIDITY_LOW_RANGE,
_PUBLIC_LED_LEVEL_RANGE,
_PUBLIC_LIGHT_LUX_LOW_RANGE,
_PUBLIC_MIC_VOLUME_RANGE,
_PUBLIC_SENSITIVITY_RANGE,
_PUBLIC_TEMPERATURE_LOW_RANGE,
_build_public_lcd_message,
_coerce_public_int,
_validate_public_range,
)
from .types import (
DEFAULT,
DEFAULT_TYPE,
AudioCodecs,
AudioStyle,
AutoExposureMode,
ChannelQuality,
ChimeType,
Color,
DoorbellMessageType,
FocusMode,
GeofencingSetting,
HDRMode,
ICRCustomValue,
ICRLuxValue,
ICRSensitivity,
IRLEDMode,
IteratorCallback,
LEDLevel,
LensType,
LightModeEnableType,
LightModeType,
LowMedHigh,
ModelType,
MotionAlgorithm,
MountPosition,
MountType,
OsdOverlayLocation,
Percent,
PercentInt,
PermissionNode,
ProgressCallback,
PTZPatrol,
PTZPreset,
PublicHdrMode,
RecordingMode,
RepeatTimes,
SensorScheduleMode,
SensorStatusType,
SmartDetectAudioType,
SmartDetectObjectType,
TwoByteInt,
VideoMode,
WDRLevel,
)
from .user import User
if TYPE_CHECKING:
from ..api import (
PublicApiChimeRingSettingRequest,
RTSPSStreams,
)
from .nvr import Event, Liveview
from .public_devices import (
PublicCameraLedSettings,
PublicOsdSettings,
PublicSensorAlarmSettings,
PublicSensorGlassBreakSettingsWrite,
PublicSensorHumiditySettings,
PublicSensorLightSettings,
PublicSensorMotionSettings,
PublicSensorTemperatureSettings,
)
PRIVACY_ZONE_NAME = "pyufp_privacy_zone"
LUX_MAPPING_VALUES = [30, 25, 20, 15, 12, 10, 7, 5, 3, 1, 0]
# Protect switches the ICR on the lux threshold only in these modes; `auto`/
# `autoFilterOnly` switch on sensitivity and discard icrCustomValue, and
# `manual`/`on`/`off` force the IR cut filter, so the threshold is inert there.
_ICR_LUX_IR_LED_MODES = frozenset({IRLEDMode.CUSTOM, IRLEDMode.CUSTOM_FILTER_ONLY})
_LOGGER = logging.getLogger(__name__)
class LightDeviceSettings(ProtectBaseObject):
# Status LED
is_indicator_enabled: bool
# Brightness
led_level: LEDLevel
pir_duration: timedelta
pir_sensitivity: PercentInt
# lux_sensitivity exists in Private API but not in Public API - filtered when sending updates
lux_sensitivity: LowMedHigh | None = None
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"pirDuration": lambda x: timedelta(milliseconds=x)
} | super().unifi_dict_conversions()
class LightOnSettings(ProtectBaseObject):
# Manual toggle in UI
is_led_force_on: bool
class LightModeSettings(ProtectBaseObject):
# main "Lighting" settings
mode: LightModeType
enable_at: LightModeEnableType
class Light(ProtectMotionDeviceModel):
is_pir_motion_detected: bool
is_light_on: bool
is_locating: bool
light_device_settings: LightDeviceSettings
light_on_settings: LightOnSettings
light_mode_settings: LightModeSettings
camera_id: str | None = None
is_camera_paired: bool
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "camera": "cameraId"}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {
"isPirMotionDetected",
"isLightOn",
"isLocating",
}
@property
def camera(self) -> Camera | None:
"""Paired Camera will always be none if no camera is paired"""
if self.camera_id is None:
return None
return self._api.bootstrap.cameras[self.camera_id]
async def set_paired_camera(self, camera: Camera | None) -> None:
"""Sets the camera paired with the light"""
async with self._update_sync.lock:
# yield to the event loop once we have the lock to process any pending updates
await asyncio.sleep(0)
data_before_changes = self.dict_with_excludes()
if camera is None:
self.camera_id = None
else:
self.camera_id = camera.id
await self.save_device(data_before_changes, force_emit=True)
async def set_status_light(self, enabled: bool) -> None:
"""Sets the status indicator light for the light"""
def callback() -> None:
self.light_device_settings.is_indicator_enabled = enabled
await self.queue_update(callback)
async def set_name_public(self, name: str) -> None:
"""Set light name via public API."""
updated = await self._api.update_light_public(self.id, name=name)
self.name = updated.name
async def set_flood_light_public(self, enabled: bool) -> None:
"""Force the flood light on/off via public API."""
await self._api.update_light_public(self.id, is_light_force_enabled=enabled)
self.light_on_settings.is_led_force_on = enabled
async def set_status_light_public(self, enabled: bool) -> None:
"""Toggle the status indicator LED via public API."""
device_settings = self.light_device_settings.model_copy()
device_settings.is_indicator_enabled = enabled
await self._api.update_light_public(
self.id, light_device_settings=device_settings
)
self.light_device_settings = device_settings
async def set_led_level_public(self, led_level: float) -> None:
"""Set the LED brightness level via public API."""
led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE)
device_settings = self.light_device_settings.model_copy()
device_settings.led_level = LEDLevel(led_level)
await self._api.update_light_public(
self.id, light_device_settings=device_settings
)
self.light_device_settings = device_settings
async def set_light_public(
self, enabled: bool, led_level: float | None = None
) -> None:
"""Force the light on/off, optionally setting LED brightness (1-6), via public API."""
if led_level is None:
await self._api.update_light_public(self.id, is_light_force_enabled=enabled)
self.light_on_settings.is_led_force_on = enabled
return
led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE)
device_settings = self.light_device_settings.model_copy()
device_settings.led_level = LEDLevel(led_level)
await self._api.update_light_public(
self.id,
is_light_force_enabled=enabled,
light_device_settings=device_settings,
)
self.light_on_settings.is_led_force_on = enabled
self.light_device_settings = device_settings
async def set_sensitivity_public(self, sensitivity: int) -> None:
"""Set PIR motion sensitivity via public API."""
device_settings = self.light_device_settings.model_copy()
device_settings.pir_sensitivity = PercentInt(sensitivity)
await self._api.update_light_public(
self.id, light_device_settings=device_settings
)
self.light_device_settings = device_settings
async def set_duration_public(self, duration: timedelta) -> None:
"""Set how long the light stays on after motion (15s-900s) via public API."""
if duration.total_seconds() < 15 or duration.total_seconds() > 900:
raise BadRequest("Duration outside of 15s to 900s range")
device_settings = self.light_device_settings.model_copy()
device_settings.pir_duration = duration
await self._api.update_light_public(
self.id, light_device_settings=device_settings
)
self.light_device_settings = device_settings
async def set_light_mode_public(
self,
mode: LightModeType,
enable_at: LightModeEnableType | None = None,
) -> None:
"""Set the lighting trigger mode (and optional schedule) via public API."""
mode_settings = self.light_mode_settings.model_copy()
mode_settings.mode = mode
if enable_at is not None:
mode_settings.enable_at = enable_at
await self._api.update_light_public(self.id, light_mode_settings=mode_settings)
self.light_mode_settings = mode_settings
async def set_light_settings_public(
self,
mode: LightModeType,
enable_at: LightModeEnableType | None = None,
duration: timedelta | None = None,
sensitivity: int | None = None,
) -> None:
"""Update mode, enable schedule, duration and PIR sensitivity via public API."""
if duration is not None and (
duration.total_seconds() < 15 or duration.total_seconds() > 900
):
raise BadRequest("Duration outside of 15s to 900s range")
mode_settings = self.light_mode_settings.model_copy()
mode_settings.mode = mode
if enable_at is not None:
mode_settings.enable_at = enable_at
device_settings: LightDeviceSettings | None = None
if duration is not None or sensitivity is not None:
device_settings = self.light_device_settings.model_copy()
if duration is not None:
device_settings.pir_duration = duration
if sensitivity is not None:
device_settings.pir_sensitivity = PercentInt(sensitivity)
await self._api.update_light_public(
self.id,
light_mode_settings=mode_settings,
light_device_settings=device_settings,
)
self.light_mode_settings = mode_settings
if device_settings is not None:
self.light_device_settings = device_settings
RTSPS_QUALITY_BY_CHANNEL_ID: Mapping[int, ChannelQuality] = MappingProxyType(
{
0: ChannelQuality.HIGH,
1: ChannelQuality.MEDIUM,
2: ChannelQuality.LOW,
3: ChannelQuality.PACKAGE,
}
)
CHANNEL_ID_BY_RTSPS_QUALITY: Mapping[ChannelQuality, int] = MappingProxyType(
{quality: channel_id for channel_id, quality in RTSPS_QUALITY_BY_CHANNEL_ID.items()}
)
def quality_for_channel_id(channel_id: int) -> ChannelQuality | None:
"""RTSPS quality tier for a channel id (0→HIGH, 1→MEDIUM, 2→LOW, 3→PACKAGE)."""
return RTSPS_QUALITY_BY_CHANNEL_ID.get(channel_id)
def channel_id_for_quality(quality: ChannelQuality) -> int | None:
"""Channel id for an RTSPS quality tier (inverse of quality_for_channel_id)."""
return CHANNEL_ID_BY_RTSPS_QUALITY.get(quality)
class CameraChannel(ProtectBaseObject):
id: int # read only
video_id: str # read only
name: str # read only
enabled: bool # read only
is_rtsp_enabled: bool
rtsp_alias: str | None = None # read only
width: int
height: int
fps: int | None = None # read only
bitrate: int
min_bitrate: int | None = None # read only
max_bitrate: int | None = None # read only
min_client_adaptive_bit_rate: int | None = None # read only
min_motion_adaptive_bit_rate: int | None = None # read only
fps_values: list[int] # read only
idr_interval: int
# 3.0.22+
auto_bitrate: bool | None = None
auto_fps: bool | None = None
_parent: Camera | None = PrivateAttr(None)
_rtsp_url: str | None = PrivateAttr(None)
_rtsps_url: str | None = PrivateAttr(None)
_rtsps_no_srtp_url: str | None = PrivateAttr(None)
def _get_connection_host(self) -> IPv4Address | IPv6Address | str:
"""Get connection host (camera's for stacked NVR, otherwise NVR's)."""
if self._parent is not None and self._parent.connection_host is not None:
return self._parent.connection_host
return self._api.connection_host
@property
def rtsp_url(self) -> str | None:
if not self.is_rtsp_enabled or self.rtsp_alias is None:
return None
if self._rtsp_url is not None:
return self._rtsp_url
host = format_host_for_url(self._get_connection_host())
self._rtsp_url = (
f"rtsp://{host}:{self._api.bootstrap.nvr.ports.rtsp}/{self.rtsp_alias}"
)
return self._rtsp_url
@property
def rtsps_url(self) -> str | None:
if not self.is_rtsp_enabled or self.rtsp_alias is None:
return None
if self._rtsps_url is not None:
return self._rtsps_url
host = format_host_for_url(self._get_connection_host())
self._rtsps_url = f"rtsps://{host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}?enableSrtp"
return self._rtsps_url
@property
def rtsps_no_srtp_url(self) -> str | None:
if not self.is_rtsp_enabled or self.rtsp_alias is None:
return None
if self._rtsps_no_srtp_url is not None:
return self._rtsps_no_srtp_url
host = format_host_for_url(self._get_connection_host())
self._rtsps_no_srtp_url = (
f"rtsps://{host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}"
)
return self._rtsps_no_srtp_url
@property
def rtsps_quality(self) -> ChannelQuality | None:
"""RTSPS quality tier for this channel (id 0→HIGH, 1→MEDIUM, 2→LOW, 3→PACKAGE)."""
return quality_for_channel_id(self.id)
@property
def is_package(self) -> bool:
# NOTE: The previous logic (checking fps <= 2) stopped working with G6 Entry
# due to higher FPS values. This is now aligned with the logic used in
# package_camera_channel. Not optimal, but will be fixed with the switch
# to the public API.
return self.fps is not None and self.id == 3
class ISPSettings(ProtectBaseObject):
ae_mode: AutoExposureMode
ir_led_mode: IRLEDMode
ir_led_level: TwoByteInt
wdr: WDRLevel
icr_sensitivity: ICRSensitivity
brightness: int
contrast: int
hue: int
saturation: int
sharpness: int
denoise: int
is_flipped_vertical: bool
is_flipped_horizontal: bool
is_auto_rotate_enabled: bool
is_ldc_enabled: bool
is_3dnr_enabled: bool
is_external_ir_enabled: bool
is_aggressive_anti_flicker_enabled: bool
is_pause_motion_enabled: bool
d_zoom_center_x: int
d_zoom_center_y: int
d_zoom_scale: int
d_zoom_stream_id: int
focus_mode: FocusMode | None = None
focus_position: int
touch_focus_x: int | None = None
touch_focus_y: int | None = None
zoom_position: PercentInt
mount_position: MountPosition | None = None
# requires 2.8.14+
is_color_night_vision_enabled: bool | None = None
# 3.0.22+
hdr_mode: HDRMode | None = None
icr_custom_value: ICRCustomValue | None = None
icr_switch_mode: str | None = None
spotlight_duration: int | None = None
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"focusMode"})
return data
class OSDSettings(ProtectBaseObject):
# Overlay Information
is_name_enabled: bool
is_date_enabled: bool
is_logo_enabled: bool
is_debug_enabled: bool
overlay_location: OsdOverlayLocation | None = None
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
# overlayLocation is public-API-only; always strip it from private-API payloads
# to avoid rejection by endpoints that don't accept unknown fields.
data.pop("overlayLocation", None)
return data
class LEDSettings(ProtectBaseObject):
# Status Light
is_enabled: bool
blink_rate: int | None = (
None # in milliseconds between blinks, 0 = solid (removed in Protect 6.x)
)
# 6.2+
welcome_led: bool | None = None
flood_led: bool | None = None
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"blinkRate", "welcomeLed", "floodLed"})
return data
class SpeakerSettings(ProtectBaseObject):
is_enabled: bool
# Status Sounds
are_system_sounds_enabled: bool
volume: PercentInt
# Doorbell ring volume (for doorbells)
ring_volume: PercentInt | None = None
ringtone_id: str | None = None
repeat_times: int | None = None
# Actual speaker output volume (for cameras with speakers)
speaker_volume: PercentInt | None = None
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(
data, {"ringVolume", "ringtoneId", "repeatTimes", "speakerVolume"}
)
return data
class RecordingSettings(ProtectBaseObject):
# Seconds to record before Motion
pre_padding: timedelta
# Seconds to record after Motion
post_padding: timedelta
# Seconds of Motion Needed
min_motion_event_trigger: timedelta
end_motion_event_delay: timedelta
suppress_illumination_surge: bool
# High Frame Rate Mode
mode: RecordingMode
geofencing: GeofencingSetting
motion_algorithm: MotionAlgorithm
enable_motion_detection: bool | None = None
use_new_motion_algorithm: bool
# requires 2.9.20+
in_schedule_mode: str | None = None
out_schedule_mode: str | None = None
# 2.11.13+
retention_duration: datetime | None = None
smart_detect_post_padding: timedelta | None = None
smart_detect_pre_padding: timedelta | None = None
# requires 5.2.39+
create_access_event: bool | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"retentionDurationMs": "retentionDuration",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"minMotionEventTrigger": lambda x: timedelta(seconds=x),
"endMotionEventDelay": lambda x: timedelta(seconds=x),
} | super().unifi_dict_conversions()
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
if "prePaddingSecs" in data:
data["prePadding"] = timedelta(seconds=data.pop("prePaddingSecs"))
if "postPaddingSecs" in data:
data["postPadding"] = timedelta(seconds=data.pop("postPaddingSecs"))
if "smartDetectPrePaddingSecs" in data:
data["smartDetectPrePadding"] = timedelta(
seconds=data.pop("smartDetectPrePaddingSecs"),
)
if "smartDetectPostPaddingSecs" in data:
data["smartDetectPostPadding"] = timedelta(
seconds=data.pop("smartDetectPostPaddingSecs"),
)
return super().unifi_dict_to_dict(data)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
if "prePadding" in data:
data["prePaddingSecs"] = data.pop("prePadding") // 1000
if "postPadding" in data:
data["postPaddingSecs"] = data.pop("postPadding") // 1000
if (
"smartDetectPrePadding" in data
and data["smartDetectPrePadding"] is not None
):
data["smartDetectPrePaddingSecs"] = (
data.pop("smartDetectPrePadding") // 1000
)
if (
"smartDetectPostPadding" in data
and data["smartDetectPostPadding"] is not None
):
data["smartDetectPostPaddingSecs"] = (
data.pop("smartDetectPostPadding") // 1000
)
if "minMotionEventTrigger" in data:
data["minMotionEventTrigger"] = data.pop("minMotionEventTrigger") // 1000
if "endMotionEventDelay" in data:
data["endMotionEventDelay"] = data.pop("endMotionEventDelay") // 1000
return data
class SmartDetectSettings(ProtectBaseObject):
object_types: list[SmartDetectObjectType]
audio_types: list[SmartDetectAudioType] | None = None
# requires 2.8.22+
auto_tracking_object_types: list[SmartDetectObjectType] | None = None
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"audioTypes": convert_smart_audio_types,
"objectTypes": convert_smart_types,
"autoTrackingObjectTypes": convert_smart_types,
} | super().unifi_dict_conversions()
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
if audio_types := data.get("audioTypes"):
# SMOKE_CMONX is not supported for audio types
# and should not be sent to the camera
data["audioTypes"] = [
t for t in audio_types if t != SmartDetectAudioType.SMOKE_CMONX.value
]
return data
class LCDMessage(ProtectBaseObject):
type: DoorbellMessageType
# Some doorbell firmwares send lcdMessage with a type but no text; a
# default keeps model_construct from producing a text-less instance.
text: str = ""
reset_at: datetime | None = None
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"resetAt": convert_to_datetime,
} | super().unifi_dict_conversions()
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
if "text" in data:
# UniFi Protect bug: some times LCD messages can get into a bad state where message = DEFAULT MESSAGE, but no type
if "type" not in data:
data["type"] = DoorbellMessageType.CUSTOM_MESSAGE.value
data["text"] = cls._fix_text(data["text"], data["type"])
return super().unifi_dict_to_dict(data)
@classmethod
def _fix_text(cls, text: str, text_type: str | None) -> str:
if text_type is None:
text_type = cls.type.value
if text_type != DoorbellMessageType.CUSTOM_MESSAGE.value:
text = text_type.replace("_", " ")
return text
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
if "text" in data:
try:
msg_type = self.type.value
except AttributeError:
msg_type = None
data["text"] = self._fix_text(data["text"], data.get("type", msg_type))
if "resetAt" in data:
data["resetAt"] = to_js_time(data["resetAt"])
return data
class TalkbackSettings(ProtectBaseObject):
type_fmt: AudioCodecs
type_in: str
bind_addr: IPv4Address | IPv6Address | str | None = None
bind_port: int
filter_addr: str | None = None # can be used to restrict sender address
filter_port: int | None = None # can be used to restrict sender port
channels: int # 1 or 2
sampling_rate: int # 8000, 11025, 22050, 44100, 48000
bits_per_sample: int
quality: PercentInt # only for vorbis
class WifiStats(ProtectBaseObject):
channel: int | None = None
frequency: int | None = None
link_speed_mbps: int | str | None = None
signal_quality: PercentInt
signal_strength: int
class VideoStats(ProtectBaseObject):
recording_start: datetime | None = None
recording_end: datetime | None = None
recording_start_lq: datetime | None = None
recording_end_lq: datetime | None = None
timelapse_start: datetime | None = None
timelapse_end: datetime | None = None
timelapse_start_lq: datetime | None = None
timelapse_end_lq: datetime | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"recordingStartLQ": "recordingStartLq",
"recordingEndLQ": "recordingEndLq",
"timelapseStartLQ": "timelapseStartLq",
"timelapseEndLQ": "timelapseEndLq",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return (
dict.fromkeys(
(
"recordingStart",
"recordingEnd",
"recordingStartLQ",
"recordingEndLQ",
"timelapseStart",
"timelapseEnd",
"timelapseStartLQ",
"timelapseEndLQ",
),
convert_to_datetime,
)
| super().unifi_dict_conversions()
)
class StorageStats(ProtectBaseObject):
used: int | None = None # bytes
rate: float | None = None # bytes / millisecond
@property
def rate_per_second(self) -> float | None:
if self.rate is None:
return None
return self.rate * 1000
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
if "rate" not in data:
data["rate"] = None
return super().unifi_dict_to_dict(data)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"rate"})
return data
class CameraStats(ProtectBaseObject):
rx_bytes: int | None = None # deprecated: removed in Protect 6.1+
tx_bytes: int | None = None # deprecated: removed in Protect 6.1+
wifi: WifiStats
video: VideoStats
storage: StorageStats | None = None
wifi_quality: PercentInt
wifi_strength: int
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
if "storage" in data and data["storage"] == {}:
del data["storage"]
return super().unifi_dict_to_dict(data)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
if "storage" in data and data["storage"] is None:
data["storage"] = {}
return data
class CameraZone(ProtectBaseObject):
id: int
name: str
color: Color
points: list[tuple[Percent, Percent]]
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"points": lambda x: [(p[0], p[1]) for p in x],
} | super().unifi_dict_conversions()
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
if "points" in data:
data["points"] = [serialize_point(p) for p in data["points"]]
if "color" in data and isinstance(data["color"], dict):
# Serialize Color object to hex string to avoid Pydantic serialization warnings
data["color"] = self.color.as_hex().upper()
return data
@staticmethod
def create_privacy_zone(zone_id: int) -> CameraZone:
return CameraZone(
id=zone_id,
name=PRIVACY_ZONE_NAME,
color=Color("#85BCEC"),
points=[[0, 0], [1, 0], [1, 1], [0, 1]], # type: ignore[list-item]
)
class MotionZone(CameraZone):
sensitivity: PercentInt
class SmartMotionZone(MotionZone):
object_types: list[SmartDetectObjectType]
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"objectTypes": convert_smart_types,
} | super().unifi_dict_conversions()
class PrivacyMaskCapability(ProtectBaseObject):
max_masks: int | None = None
rectangle_only: bool
class HotplugExtender(ProtectBaseObject):
has_flash: bool | None = None
has_ir: bool | None = None
has_radar: bool | None = None
is_attached: bool | None = None
# 3.0.22+
flash_range: Any | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "hasIR": "hasIr"}
class Hotplug(ProtectBaseObject):
audio: bool | None = None
video: bool | None = None
extender: HotplugExtender | None = None
# 2.8.35+
standalone_adoption: bool | None = None
class PTZRangeSingle(ProtectBaseObject):
max: float | None = None
min: float | None = None
step: float | None = None
class PTZRange(ProtectBaseObject):
steps: PTZRangeSingle
degrees: PTZRangeSingle
def to_native_value(self, degree_value: float, is_relative: bool = False) -> float:
"""Convert degree values to step values."""
if (
self.degrees.max is None
or self.degrees.min is None
or self.degrees.step is None
or self.steps.max is None
or self.steps.min is None
or self.steps.step is None
):
raise BadRequest("degree to step conversion not supported.")
if not is_relative:
degree_value -= self.degrees.min
step_range = self.steps.max - self.steps.min
degree_range = self.degrees.max - self.degrees.min
ratio = step_range / degree_range
step_value = clamp_value(degree_value * ratio, self.steps.step)
if not is_relative:
step_value = self.steps.min + step_value
return step_value
class PTZZoomRange(PTZRange):
ratio: int
def to_native_value(self, zoom_value: float, is_relative: bool = False) -> float:
"""Convert zoom values to step values."""
if self.steps.max is None or self.steps.min is None or self.steps.step is None:
raise BadRequest("step conversion not supported.")
step_range = self.steps.max - self.steps.min
# zoom levels start at 1
ratio = step_range / (self.ratio - 1)
if not is_relative:
zoom_value -= 1
step_value = clamp_value(zoom_value * ratio, self.steps.step)
if not is_relative:
step_value = self.steps.min + step_value
return step_value
class CameraFeatureFlags(ProtectBaseObject):
can_adjust_ir_led_level: bool
can_magic_zoom: bool
can_optical_zoom: bool
can_touch_focus: bool
has_accelerometer: bool
has_aec: bool
has_bluetooth: bool
has_chime: bool
has_external_ir: bool
has_icr_sensitivity: bool
has_ldc: bool
has_led_ir: bool
has_led_status: bool
has_line_in: bool
has_mic: bool
has_privacy_mask: bool
has_rtc: bool
has_sd_card: bool
has_speaker: bool
has_wifi: bool
has_hdr: bool
has_auto_icr_only: bool
video_modes: list[VideoMode]
video_mode_max_fps: list[int]
has_motion_zones: bool
has_lcd_screen: bool
smart_detect_types: list[SmartDetectObjectType]
motion_algorithms: list[MotionAlgorithm]
has_square_event_thumbnail: bool
has_package_camera: bool
privacy_mask_capability: PrivacyMaskCapability
has_smart_detect: bool
audio: list[str] = []
audio_codecs: list[AudioCodecs] = []
mount_positions: list[MountPosition] = []
has_infrared: bool | None = None
lens_type: LensType | None = None
hotplug: Hotplug | None = None
smart_detect_audio_types: list[SmartDetectAudioType] | None = None
# 2.7.18+
is_doorbell: bool
# 2.8.22+
lens_model: str | None = None
# 2.9.20+
has_color_lcd_screen: bool | None = None
has_line_crossing: bool | None = None
has_line_crossing_counting: bool | None = None
has_liveview_tracking: bool | None = None
# 2.10.10+
has_flash: bool | None = None
is_ptz: bool | None = None
# 2.11.13+
audio_style: list[AudioStyle] | None = None
has_vertical_flip: bool | None = None
# 3.0.22+
flash_range: Any | None = None
# 4.73.71+
support_nfc: bool | None = None
has_fingerprint_sensor: bool | None = None
# 6.0.0+
support_full_hd_snapshot: bool | None = None
focus: PTZRange
pan: PTZRange
tilt: PTZRange
zoom: PTZZoomRange
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"smartDetectTypes": convert_smart_types,
"smartDetectAudioTypes": convert_smart_audio_types,
"videoModes": convert_video_modes,
} | super().unifi_dict_conversions()
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
# backport support for `is_doorbell` to older versions of Protect
if "hasChime" in data and "isDoorbell" not in data:
data["isDoorbell"] = data["hasChime"]
return super().unifi_dict_to_dict(data)
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "hasAutoICROnly": "hasAutoIcrOnly"}
@property
def has_highfps(self) -> bool:
return VideoMode.HIGH_FPS in self.video_modes
@property
def has_wdr(self) -> bool:
return not self.has_hdr
class CameraLenses(ProtectBaseObject):
id: int
video: VideoStats
class CameraHomekitSettings(ProtectBaseObject):
microphone_muted: bool
speaker_muted: bool
stream_in_progress: bool
talkback_settings_active: bool
class CameraAudioSettings(ProtectBaseObject):
style: list[AudioStyle]
@lru_cache
def _chime_type_from_total_seconds(total_seconds: float) -> ChimeType:
if total_seconds == 0.3:
return ChimeType.MECHANICAL
if total_seconds > 0.3:
return ChimeType.DIGITAL
return ChimeType.NONE
class Camera(ProtectMotionDeviceModel):
is_deleting: bool
# Microphone Sensitivity
mic_volume: PercentInt
is_mic_enabled: bool
is_recording: bool
is_motion_detected: bool
is_smart_detected: bool
phy_rate: float | None = None
hdr_mode: bool
# Recording Quality -> High Frame
video_mode: VideoMode
is_probing_for_wifi: bool
chime_duration: timedelta
last_ring: datetime | None = None
is_live_heatmap_enabled: bool
video_reconfiguration_in_progress: bool
channels: list[CameraChannel]
isp_settings: ISPSettings
talkback_settings: TalkbackSettings
osd_settings: OSDSettings
led_settings: LEDSettings
speaker_settings: SpeakerSettings
recording_settings: RecordingSettings
smart_detect_settings: SmartDetectSettings
motion_zones: list[MotionZone]
privacy_zones: list[CameraZone]
smart_detect_zones: list[SmartMotionZone]
stats: CameraStats
feature_flags: CameraFeatureFlags
lcd_message: LCDMessage | None = None
lenses: list[CameraLenses]
platform: str | None = None
has_speaker: bool
has_wifi: bool
audio_bitrate: int
can_manage: bool
is_managed: bool
voltage: float | None = None
# requires 1.21+
is_poor_network: bool | None = None
is_wireless_uplink_enabled: bool | None = None
# requires 2.6.13+
homekit_settings: CameraHomekitSettings | None = None
# requires 2.6.17+
ap_mgmt_ip: IPv4Address | IPv6Address | None = None
# requires 2.7.5+
is_waterproof_case_attached: bool | None = None
last_disconnect: datetime | None = None
# requires 2.8.14+
is_2k: bool | None = None
is_4k: bool | None = None
use_global: bool | None = None
# requires 2.8.22+
user_configured_ap: bool | None = None
# requires 2.9.20+
has_recordings: bool | None = None
# requires 2.10.10+
is_ptz: bool | None = None
active_patrol_slot: int | None = None
# requires 2.11.13+
audio_settings: CameraAudioSettings | None = None
# requires 5.0.33+
is_third_party_camera: bool | None = None
# requires 5.1.78+
is_paired_with_ai_port: bool | None = None
# requires 5.2.39+
is_adopted_by_access_app: bool | None = None
# not directly from UniFi
last_ring_event_id: str | None = None
last_nfc_card_scanned_event_id: str | None = None
last_nfc_card_scanned: datetime | None = None
last_fingerprint_identified_event_id: str | None = None
last_fingerprint_identified: datetime | None = None
last_smart_detect: datetime | None = None
last_smart_audio_detect: datetime | None = None
last_smart_detect_event_id: str | None = None
last_smart_audio_detect_event_id: str | None = None
last_smart_detects: dict[SmartDetectObjectType, datetime] = {}
last_smart_audio_detects: dict[SmartDetectAudioType, datetime] = {}
last_smart_detect_event_ids: dict[SmartDetectObjectType, str] = {}
last_smart_audio_detect_event_ids: dict[SmartDetectAudioType, str] = {}
talkback_stream: TalkbackStream | None = None
_active_smart_detect_events: dict[SmartDetectObjectType, dict[str, Event]] = (
PrivateAttr(default_factory=dict)
)
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "is2K": "is2k", "is4K": "is4k"}
@classmethod
@cache
def _get_excluded_changed_fields(cls) -> set[str]:
return super()._get_excluded_changed_fields() | {
"last_ring_event_id",
"last_nfc_card_scanned",
"last_nfc_card_scanned_event_id",
"last_fingerprint_identified",
"last_fingerprint_identified_event_id",
"last_smart_detect",
"last_smart_audio_detect",
"last_smart_detect_event_id",
"last_smart_audio_detect_event_id",
"last_smart_detects",
"last_smart_audio_detects",
"last_smart_detect_event_ids",
"last_smart_audio_detect_event_ids",
"talkback_stream",
}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {
"stats",
"isDeleting",
"isRecording",
"isMotionDetected",
"isSmartDetected",
"phyRate",
"isProbingForWifi",
"lastRing",
"isLiveHeatmapEnabled",
"videoReconfigurationInProgress",
"lenses",
"isPoorNetwork",
"featureFlags",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"chimeDuration": lambda x: timedelta(milliseconds=x),
} | super().unifi_dict_conversions()
@model_validator(mode="after")
def _set_channel_parents(self) -> Camera:
"""Set parent camera reference in channels after initialization."""
for channel in getattr(self, "channels", ()):
channel._parent = self
return self
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
# LCD messages comes back as empty dict {}
if "lcdMessage" in data and len(data["lcdMessage"]) == 0:
del data["lcdMessage"]
return super().unifi_dict_to_dict(data)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
if data is not None:
if "motion_zones" in data:
data["motion_zones"] = [
MotionZone(**z).unifi_dict() for z in data["motion_zones"]
]
if "privacy_zones" in data:
data["privacy_zones"] = [
CameraZone(**z).unifi_dict() for z in data["privacy_zones"]
]
if "smart_detect_zones" in data:
data["smart_detect_zones"] = [
SmartMotionZone(**z).unifi_dict()
for z in data["smart_detect_zones"]
]
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_tuple(
data,
(
"lastFingerprintIdentified",
"lastFingerprintIdentifiedEventId",
"lastNfcCardScanned",
"lastNfcCardScannedEventId",
"lastRingEventId",
"lastSmartDetect",
"lastSmartAudioDetect",
"lastSmartDetectEventId",
"lastSmartAudioDetectEventId",
"lastSmartDetects",
"lastSmartAudioDetects",
"lastSmartDetectEventIds",
"lastSmartAudioDetectEventIds",
"talkbackStream",
),
)
if "lcdMessage" in data and data["lcdMessage"] is None:
data["lcdMessage"] = {}
return data
def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
updated = super().get_changed(data_before_changes)
if "lcd_message" in updated:
lcd_message = updated["lcd_message"]
# to "clear" LCD message, set reset_at to a time in the past
if lcd_message is None:
updated["lcd_message"] = {"reset_at": utc_now() - timedelta(seconds=10)}
# otherwise, pass full LCD message to prevent issues
elif self.lcd_message is not None:
updated["lcd_message"] = self.lcd_message.model_dump()
# if reset_at is not passed in, it will default to reset in 1 minute
if lcd_message is not None and "reset_at" not in lcd_message:
if self.lcd_message is None:
updated["lcd_message"]["reset_at"] = None
else:
updated["lcd_message"]["reset_at"] = self.lcd_message.reset_at
return updated
def update_from_dict(self, data: dict[str, Any]) -> Camera:
# a message in the past is actually a signal to wipe the message
if (
reset_at := data.get("lcd_message", {}).get("reset_at")
) is not None and utc_now() > from_js_time(reset_at):
# Important: Make a copy of the data before modifying it
# since unifi_dict_to_dict will otherwise report incorrect changes
data = data.copy()
data["lcd_message"] = None
return super().update_from_dict(data)
@property
def last_ring_event(self) -> Event | None:
if (last_ring_event_id := self.last_ring_event_id) is None:
return None
return self._api.bootstrap.events.get(last_ring_event_id)
@property
def last_smart_detect_event(self) -> Event | None:
"""Get the last smart detect event id."""
if (last_smart_detect_event_id := self.last_smart_detect_event_id) is None:
return None
# Check per-camera active index first (immune to bootstrap.events eviction)
for active in self._active_smart_detect_events.values():
if event := active.get(last_smart_detect_event_id):
return event
return self._api.bootstrap.events.get(last_smart_detect_event_id)
@property
def last_nfc_card_scanned_event(self) -> Event | None:
if (
last_nfc_card_scanned_event_id := self.last_nfc_card_scanned_event_id
) is None:
return None
return self._api.bootstrap.events.get(last_nfc_card_scanned_event_id)
@property
def last_fingerprint_identified_event(self) -> Event | None:
if (
last_fingerprint_identified_event_id
:= self.last_fingerprint_identified_event_id
) is None:
return None
return self._api.bootstrap.events.get(last_fingerprint_identified_event_id)
@property
def hdr_mode_display(self) -> Literal["auto", "off", "always"]:
"""Get HDR mode similar to how Protect interface works."""
if not self.hdr_mode:
return "off"
if self.isp_settings.hdr_mode == HDRMode.NORMAL:
return "auto"
return "always"
@property
def icr_lux_display(self) -> int | None:
"""Get ICR Custom Lux value similar to how the Protect interface works."""
if self.isp_settings.icr_custom_value is None:
return None
return LUX_MAPPING_VALUES[10 - self.isp_settings.icr_custom_value]
def get_last_smart_detect_event(
self,
smart_type: SmartDetectObjectType,
) -> Event | None:
"""Get the last smart detect event for given type."""
event_id = self.last_smart_detect_event_ids.get(smart_type)
if not event_id:
return None
# Prefer the per-camera active index (immune to bootstrap.events eviction)
active = self._active_smart_detect_events.get(smart_type)
if active and (event := active.get(event_id)):
return event
return self._api.bootstrap.events.get(event_id)
@property
def last_smart_audio_detect_event(self) -> Event | None:
"""Get the last smart audio detect event id."""
if (
last_smart_audio_detect_event_id := self.last_smart_audio_detect_event_id
) is None:
return None
return self._api.bootstrap.events.get(last_smart_audio_detect_event_id)
def get_last_smart_audio_detect_event(
self,
smart_type: SmartDetectAudioType,
) -> Event | None:
"""Get the last smart audio detect event for given type."""
if (event_id := self.last_smart_audio_detect_event_ids.get(smart_type)) is None:
return None
return self._api.bootstrap.events.get(event_id)
@property
def timelapse_url(self) -> str:
return f"{self._api.base_url}/protect/timelapse/{self.id}"
@property
def is_privacy_on(self) -> bool:
index, _ = self.get_privacy_zone()
return index is not None
@property
def is_ptz_patrolling(self) -> bool:
"""Is PTZ camera currently running a patrol?"""
return self.active_patrol_slot is not None
@property
def is_recording_enabled(self) -> bool:
"""
Is recording footage/events from the camera enabled?
If recording is not enabled, cameras will not produce any footage, thumbnails,
motion/smart detection events.
"""
if self.use_global:
return self._api.bootstrap.nvr.is_global_recording_enabled
return self.recording_settings.mode is not RecordingMode.NEVER
@property
def is_smart_detections_allowed(self) -> bool:
"""Is smart detections allowed for this camera?"""
return (
self.is_recording_enabled
and self._api.bootstrap.nvr.is_smart_detections_enabled
)
@property
def is_license_plate_detections_allowed(self) -> bool:
"""Is license plate detections allowed for this camera?"""
return (
self.is_recording_enabled
and self._api.bootstrap.nvr.is_license_plate_detections_enabled
)
@property
def is_face_detections_allowed(self) -> bool:
"""Is face detections allowed for this camera?"""
return (
self.is_recording_enabled
and self._api.bootstrap.nvr.is_face_detections_enabled
)
@property
def active_recording_settings(self) -> RecordingSettings:
"""Get active recording settings."""
if self.use_global and self._api.bootstrap.nvr.global_camera_settings:
return self._api.bootstrap.nvr.global_camera_settings.recording_settings
return self.recording_settings
@property
def active_smart_detect_settings(self) -> SmartDetectSettings:
"""Get active smart detection settings."""
if self.use_global and self._api.bootstrap.nvr.global_camera_settings:
return self._api.bootstrap.nvr.global_camera_settings.smart_detect_settings
return self.smart_detect_settings
@property
def active_smart_detect_types(self) -> set[SmartDetectObjectType]:
"""Get active smart detection types."""
if self.use_global:
return set(self.smart_detect_settings.object_types).intersection(
self.feature_flags.smart_detect_types
)
return set(self.smart_detect_settings.object_types)
@property
def active_audio_detect_types(self) -> set[SmartDetectAudioType]:
"""Get active audio detection types."""
if not (enabled_audio_types := self.smart_detect_settings.audio_types):
return set()
if self.use_global:
if not (feature_audio_types := self.feature_flags.smart_detect_audio_types):
return set()
return set(feature_audio_types).intersection(enabled_audio_types)
return set(enabled_audio_types)
@property
def is_motion_detection_on(self) -> bool:
"""Is Motion Detection available and enabled (camera will produce motion events)?"""
return (
self.is_recording_enabled
and self.active_recording_settings.enable_motion_detection is not False
)
@property
def is_motion_currently_detected(self) -> bool:
"""Is motion currently being detected"""
return (
self.is_motion_detection_on
and self.is_motion_detected
and self.last_motion_event is not None
and self.last_motion_event.end is None
)
async def set_motion_detection(self, enabled: bool) -> None:
"""Sets motion detection on camera"""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.recording_settings.enable_motion_detection = enabled
await self.queue_update(callback)
async def set_use_global(self, enabled: bool) -> None:
"""Sets if camera should use global recording settings or not."""
def callback() -> None:
self.use_global = enabled
await self.queue_update(callback)
def can_detect(self, smart_type: SmartDetectObjectType) -> bool:
"""Whether the camera advertises ``smart_type`` (audio types via ``audio_type``)."""
if smart_type.audio_type is not None:
return self._can_detect_audio(smart_type)
return smart_type in self.feature_flags.smart_detect_types
# region Object Smart Detections
def _is_smart_enabled(self, smart_type: SmartDetectObjectType) -> bool:
return (
self.is_recording_enabled and smart_type in self.active_smart_detect_types
)
def _is_smart_detected(self, smart_type: SmartDetectObjectType) -> bool:
event = self.get_last_smart_detect_event(smart_type)
return (
self._is_smart_enabled(smart_type)
and self.is_smart_detected
and event is not None
and event.end is None
and smart_type in event.smart_detect_types
)
@property
def is_smart_currently_detected(self) -> bool:
"""Is smart detection currently being detected"""
return (
self.is_recording_enabled
and bool(self.active_smart_detect_types)
and self.is_smart_detected
and self.last_smart_detect_event is not None
and self.last_smart_detect_event.end is None
)
# region Person
@property
def can_detect_person(self) -> bool:
return SmartDetectObjectType.PERSON in self.feature_flags.smart_detect_types
@property
def is_person_detection_on(self) -> bool:
"""
Is Person Detection available and enabled (camera will produce person smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.PERSON)
@property
def last_person_detect_event(self) -> Event | None:
"""Get the last person smart detection event."""
return self.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
@property
def last_person_detect(self) -> datetime | None:
"""Get the last person smart detection event."""
return self.last_smart_detects.get(SmartDetectObjectType.PERSON)
@property
def is_person_currently_detected(self) -> bool:
"""Is person currently being detected"""
return self._is_smart_detected(SmartDetectObjectType.PERSON)
@property
def is_person_tracking_enabled(self) -> bool:
"""Is person tracking enabled"""
return (
self.active_smart_detect_settings.auto_tracking_object_types is not None
and SmartDetectObjectType.PERSON
in self.active_smart_detect_settings.auto_tracking_object_types
)
# endregion
# region Vehicle
@property
def can_detect_vehicle(self) -> bool:
return SmartDetectObjectType.VEHICLE in self.feature_flags.smart_detect_types
@property
def is_vehicle_detection_on(self) -> bool:
"""
Is Vehicle Detection available and enabled (camera will produce vehicle smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.VEHICLE)
@property
def last_vehicle_detect_event(self) -> Event | None:
"""Get the last vehicle smart detection event."""
return self.get_last_smart_detect_event(SmartDetectObjectType.VEHICLE)
@property
def last_vehicle_detect(self) -> datetime | None:
"""Get the last vehicle smart detection event."""
return self.last_smart_detects.get(SmartDetectObjectType.VEHICLE)
@property
def is_vehicle_currently_detected(self) -> bool:
"""Is vehicle currently being detected"""
return self._is_smart_detected(SmartDetectObjectType.VEHICLE)
# endregion
# region Face
@property
def can_detect_face(self) -> bool:
return SmartDetectObjectType.FACE in self.feature_flags.smart_detect_types
@property
def is_face_detection_on(self) -> bool:
"""Is Face Detection available and enabled?"""
return self._is_smart_enabled(SmartDetectObjectType.FACE)
@property
def last_face_detect_event(self) -> Event | None:
"""Get the last face smart detection event."""
return self.get_last_smart_detect_event(SmartDetectObjectType.FACE)
@property
def last_face_detect(self) -> datetime | None:
"""Get the last face smart detection event."""
return self.last_smart_detects.get(SmartDetectObjectType.FACE)
@property
def is_face_currently_detected(self) -> bool:
"""Is face currently being detected"""
return self._is_smart_detected(SmartDetectObjectType.FACE)
# endregion
# region License Plate
@property
def can_detect_license_plate(self) -> bool:
return (
SmartDetectObjectType.LICENSE_PLATE in self.feature_flags.smart_detect_types
)
@property
def is_license_plate_detection_on(self) -> bool:
"""
Is License Plate Detection available and enabled (camera will produce face license
plate detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.LICENSE_PLATE)
@property
def last_license_plate_detect_event(self) -> Event | None:
"""Get the last license plate smart detection event."""
return self.get_last_smart_detect_event(SmartDetectObjectType.LICENSE_PLATE)
@property
def last_license_plate_detect(self) -> datetime | None:
"""Get the last license plate smart detection event."""
return self.last_smart_detects.get(SmartDetectObjectType.LICENSE_PLATE)
@property
def is_license_plate_currently_detected(self) -> bool:
"""Is license plate currently being detected"""
return self._is_smart_detected(SmartDetectObjectType.LICENSE_PLATE)
# endregion
# region Package
@property
def can_detect_package(self) -> bool:
return SmartDetectObjectType.PACKAGE in self.feature_flags.smart_detect_types
@property
def is_package_detection_on(self) -> bool:
"""
Is Package Detection available and enabled (camera will produce package smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.PACKAGE)
@property
def last_package_detect_event(self) -> Event | None:
"""Get the last package smart detection event."""
return self.get_last_smart_detect_event(SmartDetectObjectType.PACKAGE)
@property
def last_package_detect(self) -> datetime | None:
"""Get the last package smart detection event."""
return self.last_smart_detects.get(SmartDetectObjectType.PACKAGE)
@property
def is_package_currently_detected(self) -> bool:
"""Is package currently being detected"""
return self._is_smart_detected(SmartDetectObjectType.PACKAGE)
# endregion
# region Animal
@property
def can_detect_animal(self) -> bool:
return SmartDetectObjectType.ANIMAL in self.feature_flags.smart_detect_types
@property
def is_animal_detection_on(self) -> bool:
"""
Is Animal Detection available and enabled (camera will produce package smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.ANIMAL)
@property
def last_animal_detect_event(self) -> Event | None:
"""Get the last animal smart detection event."""
return self.get_last_smart_detect_event(SmartDetectObjectType.ANIMAL)
@property
def last_animal_detect(self) -> datetime | None:
"""Get the last animal smart detection event."""
return self.last_smart_detects.get(SmartDetectObjectType.ANIMAL)
@property
def is_animal_currently_detected(self) -> bool:
"""Is animal currently being detected"""
return self._is_smart_detected(SmartDetectObjectType.ANIMAL)
# endregion
# endregion
# region Audio Smart Detections
def _can_detect_audio(self, smart_type: SmartDetectObjectType) -> bool:
audio_type = smart_type.audio_type
return (
audio_type is not None
and (
smart_detect_audio_types := self.feature_flags.smart_detect_audio_types
)
is not None
and audio_type in smart_detect_audio_types
)
def _is_audio_enabled(self, smart_type: SmartDetectObjectType) -> bool:
audio_type = smart_type.audio_type
return (
audio_type is not None
and self.is_recording_enabled
and audio_type in self.active_audio_detect_types
)
def _is_audio_detected(self, smart_type: SmartDetectObjectType) -> bool:
audio_type = smart_type.audio_type
if audio_type is None:
return False
event = self.get_last_smart_audio_detect_event(audio_type)
return (
self._is_audio_enabled(smart_type)
and event is not None
and event.end is None
and smart_type in event.smart_detect_types
)
@property
def is_audio_currently_detected(self) -> bool:
"""Is audio detection currently being detected"""
return (
self.is_recording_enabled
and bool(self.active_audio_detect_types)
and (last_smart_audio_detect_event := self.last_smart_audio_detect_event)
is not None
and last_smart_audio_detect_event.end is None
)
# region Smoke Alarm
@property
def can_detect_smoke(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.SMOKE)
@property
def is_smoke_detection_on(self) -> bool:
"""
Is Smoke Alarm Detection available and enabled (camera will produce smoke
smart detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.SMOKE)
@property
def last_smoke_detect_event(self) -> Event | None:
"""Get the last person smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SMOKE)
@property
def last_smoke_detect(self) -> datetime | None:
"""Get the last smoke smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.SMOKE)
@property
def is_smoke_currently_detected(self) -> bool:
"""Is smoke alarm currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.SMOKE)
# endregion
# region CO Alarm
@property
def can_detect_co(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.CMONX)
@property
def is_co_detection_on(self) -> bool:
"""
Is CO Alarm Detection available and enabled (camera will produce smoke smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.CMONX)
@property
def last_cmonx_detect_event(self) -> Event | None:
"""Get the last CO alarm smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.CMONX)
@property
def last_cmonx_detect(self) -> datetime | None:
"""Get the last CO alarm smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.CMONX)
@property
def is_cmonx_currently_detected(self) -> bool:
"""Is CO alarm currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.CMONX)
async def set_cmonx_detection(self, enabled: bool) -> None:
"""Toggles smoke smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.CMONX, enabled)
# endregion
# region Siren
@property
def can_detect_siren(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.SIREN)
@property
def is_siren_detection_on(self) -> bool:
"""
Is Siren Detection available and enabled (camera will produce siren smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.SIREN)
@property
def last_siren_detect_event(self) -> Event | None:
"""Get the last Siren smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SIREN)
@property
def last_siren_detect(self) -> datetime | None:
"""Get the last Siren smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.SIREN)
@property
def is_siren_currently_detected(self) -> bool:
"""Is Siren currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.SIREN)
# endregion
# region Baby Cry
@property
def can_detect_baby_cry(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.BABY_CRY)
@property
def is_baby_cry_detection_on(self) -> bool:
"""
Is Baby Cry Detection available and enabled (camera will produce baby cry smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.BABY_CRY)
@property
def last_baby_cry_detect_event(self) -> Event | None:
"""Get the last Baby Cry smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BABY_CRY)
@property
def last_baby_cry_detect(self) -> datetime | None:
"""Get the last Baby Cry smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.BABY_CRY)
@property
def is_baby_cry_currently_detected(self) -> bool:
"""Is Baby Cry currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.BABY_CRY)
# endregion
# region Speaking
@property
def can_detect_speaking(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.SPEAK)
@property
def is_speaking_detection_on(self) -> bool:
"""
Is Speaking Detection available and enabled (camera will produce speaking smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.SPEAK)
@property
def last_speaking_detect_event(self) -> Event | None:
"""Get the last Speaking smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SPEAK)
@property
def last_speaking_detect(self) -> datetime | None:
"""Get the last Speaking smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.SPEAK)
@property
def is_speaking_currently_detected(self) -> bool:
"""Is Speaking currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.SPEAK)
# endregion
# region Bark
@property
def can_detect_bark(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.BARK)
@property
def is_bark_detection_on(self) -> bool:
"""
Is Bark Detection available and enabled (camera will produce barking smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.BARK)
@property
def last_bark_detect_event(self) -> Event | None:
"""Get the last Bark smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BARK)
@property
def last_bark_detect(self) -> datetime | None:
"""Get the last Bark smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.BARK)
@property
def is_bark_currently_detected(self) -> bool:
"""Is Bark currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.BARK)
# endregion
# region Car Alarm
# (burglar in code, car alarm in Protect UI)
@property
def can_detect_car_alarm(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.BURGLAR)
@property
def is_car_alarm_detection_on(self) -> bool:
"""
Is Car Alarm Detection available and enabled (camera will produce car alarm smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.BURGLAR)
@property
def last_car_alarm_detect_event(self) -> Event | None:
"""Get the last Car Alarm smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BURGLAR)
@property
def last_car_alarm_detect(self) -> datetime | None:
"""Get the last Car Alarm smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.BURGLAR)
@property
def is_car_alarm_currently_detected(self) -> bool:
"""Is Car Alarm currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.BURGLAR)
async def set_car_alarm_detection(self, enabled: bool) -> None:
"""Toggles car_alarm smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.BURGLAR, enabled)
# endregion
# region Car Horn
@property
def can_detect_car_horn(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.CAR_HORN)
@property
def is_car_horn_detection_on(self) -> bool:
"""
Is Car Horn Detection available and enabled (camera will produce car horn smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.CAR_HORN)
@property
def last_car_horn_detect_event(self) -> Event | None:
"""Get the last Car Horn smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.CAR_HORN)
@property
def last_car_horn_detect(self) -> datetime | None:
"""Get the last Car Horn smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.CAR_HORN)
@property
def is_car_horn_currently_detected(self) -> bool:
"""Is Car Horn currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.CAR_HORN)
# endregion
# region Glass Break
@property
def can_detect_glass_break(self) -> bool:
return self._can_detect_audio(SmartDetectObjectType.GLASS_BREAK)
@property
def is_glass_break_detection_on(self) -> bool:
"""
Is Glass Break available and enabled (camera will produce glass break smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.GLASS_BREAK)
@property
def last_glass_break_detect_event(self) -> Event | None:
"""Get the last Glass Break smart detection event."""
return self.get_last_smart_audio_detect_event(SmartDetectAudioType.GLASS_BREAK)
@property
def last_glass_break_detect(self) -> datetime | None:
"""Get the last Glass Break smart detection event."""
return self.last_smart_audio_detects.get(SmartDetectAudioType.GLASS_BREAK)
@property
def is_glass_break_currently_detected(self) -> bool:
"""Is Glass Break currently being detected"""
return self._is_audio_detected(SmartDetectObjectType.GLASS_BREAK)
# endregion
# endregion
@property
def chime_type(self) -> ChimeType:
return _chime_type_from_total_seconds(
timedelta_total_seconds(self.chime_duration)
)
@property
def chime_duration_seconds(self) -> float:
return timedelta_total_seconds(self.chime_duration)
@property
def is_digital_chime(self) -> bool:
return self.chime_type is ChimeType.DIGITAL
@property
def high_camera_channel(self) -> CameraChannel | None:
if len(self.channels) >= 3:
return self.channels[0]
return None
@property
def medium_camera_channel(self) -> CameraChannel | None:
if len(self.channels) >= 3:
return self.channels[1]
return None
@property
def low_camera_channel(self) -> CameraChannel | None:
if len(self.channels) >= 3:
return self.channels[2]
return None
@property
def default_camera_channel(self) -> CameraChannel | None:
for channel in [
self.high_camera_channel,
self.medium_camera_channel,
self.low_camera_channel,
]:
if channel is not None and channel.is_rtsp_enabled:
return channel
return self.high_camera_channel
@property
def package_camera_channel(self) -> CameraChannel | None:
if self.feature_flags.has_package_camera and len(self.channels) == 4:
return self.channels[3]
return None
@property
def is_high_fps_enabled(self) -> bool:
return self.video_mode is VideoMode.HIGH_FPS
@property
def is_video_ready(self) -> bool:
return (
lens_type := self.feature_flags.lens_type
) is None or lens_type is not LensType.NONE
@property
def has_removable_lens(self) -> bool:
return (
hotplug := self.feature_flags.hotplug
) is not None and hotplug.video is not None
@property
def has_removable_speaker(self) -> bool:
return (
hotplug := self.feature_flags.hotplug
) is not None and hotplug.audio is not None
@property
def has_mic(self) -> bool:
return self.feature_flags.has_mic or self.has_removable_speaker
@property
def has_color_night_vision(self) -> bool:
if (
(hotplug := self.feature_flags.hotplug) is not None
and (extender := hotplug.extender) is not None
and (is_attached := extender.is_attached) is not None
):
return is_attached
return False
def get_privacy_zone(self) -> tuple[int | None, CameraZone | None]:
for index, zone in enumerate(self.privacy_zones):
if zone.name == PRIVACY_ZONE_NAME:
return index, zone
return None, None
def add_privacy_zone(self) -> None:
index, _ = self.get_privacy_zone()
if index is None:
zone_id = 0
privacy_zones = self.privacy_zones
if len(privacy_zones) > 0:
zone_id = privacy_zones[-1].id + 1
privacy_zones.append(CameraZone.create_privacy_zone(zone_id))
def remove_privacy_zone(self) -> None:
index, _ = self.get_privacy_zone()
if index is not None:
self.privacy_zones.pop(index)
async def get_snapshot(
self,
width: int | None = None,
height: int | None = None,
dt: datetime | None = None,
) -> bytes | None:
"""
Gets snapshot for camera.
Datetime of screenshot is approximate. It may be +/- a few seconds.
"""
# Use READ_LIVE if dt is None, otherwise READ_MEDIA
auth_user = self._api.bootstrap.auth_user
if dt is None:
if not (
auth_user.can(ModelType.CAMERA, PermissionNode.READ_LIVE, self)
or auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self)
):
raise NotAuthorized(
f"Do not have permission to read live or media for camera: {self.id}"
)
elif not auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self):
raise NotAuthorized(
f"Do not have permission to read media for camera: {self.id}"
)
if height is None and width is None and self.high_camera_channel is not None:
height = self.high_camera_channel.height
return await self._api.get_camera_snapshot(self.id, width, height, dt=dt)
async def get_public_api_snapshot(
self, high_quality: bool | None = None, package: bool = False
) -> bytes | None:
"""Gets snapshot for camera using public API; ``package=True`` fetches the package camera."""
if self._api._api_key is None:
raise NotAuthorized("Cannot get public API snapshot without an API key.")
if high_quality is None:
high_quality = self.feature_flags.support_full_hd_snapshot or False
return await self._api.get_public_api_camera_snapshot(
camera_id=self.id, high_quality=high_quality, package=package
)
async def create_rtsps_streams(
self, qualities: list[ChannelQuality | str] | ChannelQuality | str
) -> RTSPSStreams | None:
"""Creates RTSPS streams for camera using public API."""
if self._api._api_key is None:
raise NotAuthorized("Cannot create RTSPS streams without an API key.")
return await self._api.create_camera_rtsps_streams(self.id, qualities)
async def get_rtsps_streams(self) -> RTSPSStreams | None:
"""Gets existing RTSPS streams for camera using public API."""
if self._api._api_key is None:
raise NotAuthorized("Cannot get RTSPS streams without an API key.")
return await self._api.get_camera_rtsps_streams(self.id)
async def delete_rtsps_streams(
self, qualities: list[ChannelQuality | str] | ChannelQuality | str
) -> bool:
"""Deletes RTSPS streams for camera using public API."""
if self._api._api_key is None:
raise NotAuthorized("Cannot delete RTSPS streams without an API key.")
return await self._api.delete_camera_rtsps_streams(self.id, qualities)
async def get_package_snapshot(
self,
width: int | None = None,
height: int | None = None,
dt: datetime | None = None,
) -> bytes | None:
"""
Gets snapshot from the package camera.
Datetime of screenshot is approximate. It may be +/- a few seconds.
"""
if not self.feature_flags.has_package_camera:
raise BadRequest("Device does not have package camera")
auth_user = self._api.bootstrap.auth_user
# Use READ_LIVE if dt is None, otherwise READ_MEDIA
if dt is None:
if not (
auth_user.can(ModelType.CAMERA, PermissionNode.READ_LIVE, self)
or auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self)
):
raise NotAuthorized(
f"Do not have permission to read live or media for camera: {self.id}"
)
elif not auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self):
raise NotAuthorized(
f"Do not have permission to read media for camera: {self.id}"
)
if height is None and width is None and self.package_camera_channel is not None:
height = self.package_camera_channel.height
return await self._api.get_package_camera_snapshot(
self.id, width, height, dt=dt
)
async def get_video(
self,
start: datetime,
end: datetime,
channel_index: int = 0,
output_file: Path | None = None,
iterator_callback: IteratorCallback | None = None,
progress_callback: ProgressCallback | None = None,
chunk_size: int = 65536,
fps: int | None = None,
) -> bytes | None:
"""
Exports MP4 video from a given camera at a specific time.
Start/End of video export are approximate. It may be +/- a few seconds.
It is recommended to provide a output file or progress callback for larger
video clips, otherwise the full video must be downloaded to memory before
being written.
Providing the `fps` parameter creates a "timelapse" export with the given FPS
value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x
(fps=20), and 600x (fps=40).
"""
if not self._api.bootstrap.auth_user.can(
ModelType.CAMERA,
PermissionNode.READ_MEDIA,
self,
):
raise NotAuthorized(
f"Do not have permission to read media for camera: {self.id}",
)
return await self._api.get_camera_video(
self.id,
start,
end,
channel_index,
output_file=output_file,
iterator_callback=iterator_callback,
progress_callback=progress_callback,
chunk_size=chunk_size,
fps=fps,
)
async def set_recording_mode(self, mode: RecordingMode) -> None:
"""Sets recording mode on camera"""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.recording_settings.mode = mode
await self.queue_update(callback)
async def set_ir_led_model(self, mode: IRLEDMode) -> None:
"""Sets IR LED mode on camera"""
if not self.feature_flags.has_led_ir:
raise BadRequest("Camera does not have an LED IR")
def callback() -> None:
self.isp_settings.ir_led_mode = mode
await self.queue_update(callback)
async def set_icr_custom_lux(self, value: ICRLuxValue) -> None:
"""Set ICRCustomValue from lux value."""
if not self.feature_flags.has_led_ir:
raise BadRequest("Camera does not have an LED IR")
icr_value = 0
for index, threshold in enumerate(LUX_MAPPING_VALUES):
if value >= threshold:
icr_value = 10 - index
break
def callback() -> None:
self.isp_settings.icr_custom_value = cast("ICRCustomValue", icr_value)
await self.queue_update(callback)
@property
def is_ir_led_slider_enabled(self) -> bool:
"""Return if IR LED custom slider is enabled."""
return (
self.feature_flags.has_led_ir
and self.isp_settings.ir_led_mode in _ICR_LUX_IR_LED_MODES
)
async def set_status_light(self, enabled: bool) -> None:
"""Sets status indicicator light on camera"""
if not self.feature_flags.has_led_status:
raise BadRequest("Camera does not have status light")
def callback() -> None:
self.led_settings.is_enabled = enabled
# blink_rate was removed in Protect 6.x
if self.led_settings.blink_rate is not None:
self.led_settings.blink_rate = 0
await self.queue_update(callback)
async def set_color_night_vision(self, enabled: bool) -> None:
"""Sets Color Night Vision on camera"""
if not self.has_color_night_vision:
raise BadRequest("Camera does not have Color Night Vision")
def callback() -> None:
self.isp_settings.is_color_night_vision_enabled = enabled
await self.queue_update(callback)
async def set_camera_zoom(self, level: int) -> None:
"""Sets zoom level for camera"""
if not self.feature_flags.can_optical_zoom:
raise BadRequest("Camera cannot optical zoom")
def callback() -> None:
self.isp_settings.zoom_position = PercentInt(level)
await self.queue_update(callback)
async def set_wdr_level(self, level: int) -> None:
"""Sets WDR (Wide Dynamic Range) on camera"""
if self.feature_flags.has_hdr:
raise BadRequest("Cannot set WDR on cameras with HDR")
def callback() -> None:
self.isp_settings.wdr = WDRLevel(level)
await self.queue_update(callback)
async def set_speaker_volume(self, level: int) -> None:
"""Sets the speaker output volume on camera. Requires camera to have speakers"""
if not self.feature_flags.has_speaker:
raise BadRequest("Camera does not have speaker")
def callback() -> None:
self.speaker_settings.speaker_volume = PercentInt(level)
await self.queue_update(callback)
async def set_volume(self, level: int) -> None:
"""Sets the general volume level on camera. Requires camera to have speakers"""
if not self.feature_flags.has_speaker:
raise BadRequest("Camera does not have speaker")
def callback() -> None:
self.speaker_settings.volume = PercentInt(level)
await self.queue_update(callback)
async def set_ring_volume(self, level: int) -> None:
"""Sets the doorbell ring volume. Requires camera to be a doorbell"""
if not self.feature_flags.is_doorbell:
raise BadRequest("Camera is not a doorbell")
def callback() -> None:
self.speaker_settings.ring_volume = PercentInt(level)
await self.queue_update(callback)
async def set_chime_type(self, chime_type: ChimeType) -> None:
"""Sets chime type for doorbell. Requires camera to be a doorbell"""
await self.set_chime_duration(timedelta(milliseconds=chime_type.value))
async def set_chime_duration(self, duration: timedelta | float) -> None:
"""Sets chime duration for doorbell. Requires camera to be a doorbell"""
if not self.feature_flags.has_chime:
raise BadRequest("Camera does not have a chime")
if isinstance(duration, (float, int)):
if duration < 0:
raise BadRequest("Chime duration must be a positive number of seconds")
duration_td = timedelta(seconds=duration)
else:
duration_td = duration
if duration_td.total_seconds() > 10:
raise BadRequest("Chime duration is too long")
def callback() -> None:
self.chime_duration = duration_td
await self.queue_update(callback)
async def set_system_sounds(self, enabled: bool) -> None:
"""Sets system sound playback through speakers. Requires camera to have speakers"""
if not self.feature_flags.has_speaker:
raise BadRequest("Camera does not have speaker")
def callback() -> None:
self.speaker_settings.are_system_sounds_enabled = enabled
await self.queue_update(callback)
async def set_osd_bitrate(self, enabled: bool) -> None:
"""Sets whether camera bitrate is in the On Screen Display"""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
# mismatch between UI internal data structure debug = bitrate data
self.osd_settings.is_debug_enabled = enabled
await self.queue_update(callback)
async def set_smart_detect_types(self, types: list[SmartDetectObjectType]) -> None:
"""Sets current enabled smart detection types. Requires camera to have smart detection"""
if not self.feature_flags.has_smart_detect:
raise BadRequest("Camera does not have a smart detections")
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.smart_detect_settings.object_types = types
await self.queue_update(callback)
async def set_smart_audio_detect_types(
self,
types: list[SmartDetectAudioType],
) -> None:
"""Sets current enabled smart audio detection types. Requires camera to have smart detection"""
if not self.feature_flags.has_smart_detect:
raise BadRequest("Camera does not have a smart detections")
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.smart_detect_settings.audio_types = types
await self.queue_update(callback)
async def _set_audio_detect(
self,
obj_to_mod: SmartDetectAudioType,
enabled: bool,
) -> None:
if (
self.feature_flags.smart_detect_audio_types is None
or obj_to_mod not in self.feature_flags.smart_detect_audio_types
):
raise BadRequest(f"Camera does not support the {obj_to_mod} detection type")
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
objects = self.smart_detect_settings.audio_types or []
if enabled:
if obj_to_mod not in objects:
objects = [*objects, obj_to_mod]
objects.sort()
elif obj_to_mod in objects:
objects.remove(obj_to_mod)
self.smart_detect_settings.audio_types = objects
await self.queue_update(callback)
async def set_lcd_text(
self,
text_type: DoorbellMessageType | None,
text: str | None = None,
reset_at: datetime | DEFAULT_TYPE | None = None,
) -> None:
"""Sets doorbell LCD text. Requires camera to be doorbell"""
if not self.feature_flags.has_lcd_screen:
raise BadRequest("Camera does not have an LCD screen")
if text_type is None:
async with self._update_sync.lock:
# yield to the event loop once we have the lock to process any pending updates
await asyncio.sleep(0)
data_before_changes = self.dict_with_excludes()
self.lcd_message = None
# UniFi Protect bug: clearing LCD text message does _not_ emit a WS message
await self.save_device(data_before_changes, force_emit=True)
return
if text_type != DoorbellMessageType.CUSTOM_MESSAGE:
if text is not None:
raise BadRequest("Can only set text if text_type is CUSTOM_MESSAGE")
text = text_type.value.replace("_", " ")
if reset_at == DEFAULT:
reset_at = (
utc_now()
+ self._api.bootstrap.nvr.doorbell_settings.default_message_reset_timeout
)
def callback() -> None:
self.lcd_message = LCDMessage( # type: ignore[call-arg]
api=self._api,
type=text_type,
text=text, # type: ignore[arg-type]
reset_at=reset_at, # type: ignore[arg-type]
)
await self.queue_update(callback)
async def set_privacy(
self,
enabled: bool,
mic_level: int | None = None,
recording_mode: RecordingMode | None = None,
reenable_global: bool = False,
) -> None:
"""Adds/removes a privacy zone that blacks out the whole camera."""
if not self.feature_flags.has_privacy_mask:
raise BadRequest("Camera does not allow privacy zones")
def callback() -> None:
if enabled:
self.use_global = False
self.add_privacy_zone()
else:
if reenable_global:
self.use_global = True
self.remove_privacy_zone()
if not reenable_global:
if mic_level is not None:
self.mic_volume = PercentInt(mic_level)
if recording_mode is not None:
self.recording_settings.mode = recording_mode
await self.queue_update(callback)
async def set_person_track(self, enabled: bool) -> None:
"""Sets person tracking on camera"""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support person tracking")
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.smart_detect_settings.auto_tracking_object_types = (
[SmartDetectObjectType.PERSON] if enabled else []
)
await self.queue_update(callback)
async def create_talkback_stream(
self,
content_url: str,
ffmpeg_path: Path | None = None, # Deprecated: no longer used
*,
use_public_api: bool = True,
) -> TalkbackStream:
"""
Creates a stream to play audio to a camera through its speaker.
Uses PyAV (libav) for audio encoding - compatible with Home Assistant.
Args:
----
content_url: Either a URL accessible by python or a path to a file
ffmpeg_path: Deprecated, no longer used (PyAV handles encoding)
use_public_api: Use the public API to get talkback session (default: True)
Use either `await stream.run_until_complete()` or `await stream.start()` to start streaming
after getting the stream.
`.play_audio()` is a helper that wraps this method and automatically runs the stream as well
"""
if ffmpeg_path is not None:
_LOGGER.warning(
"ffmpeg_path is deprecated and ignored, PyAV is used instead"
)
if self.talkback_stream is not None and self.talkback_stream.is_running:
raise BadRequest("Camera is already playing audio")
session: TalkbackSession | None = None
if use_public_api:
if self._api._api_key is None:
raise NotAuthorized(
"Cannot create talkback session without an API key."
)
session = await self._api.create_talkback_session_public(self.id)
self.talkback_stream = TalkbackStream(self, content_url, session)
return self.talkback_stream
async def play_audio(
self,
content_url: str,
ffmpeg_path: Path | None = None, # Deprecated: no longer used
blocking: bool = True,
*,
use_public_api: bool = True,
) -> None:
"""
Plays audio to a camera through its speaker.
Uses PyAV (libav) for audio encoding - compatible with Home Assistant.
Args:
----
content_url: Either a URL accessible by python or a path to a file
ffmpeg_path: Deprecated, no longer used (PyAV handles encoding)
blocking: Awaits stream completion
use_public_api: Use the public API to get talkback session (default: True)
"""
stream = await self.create_talkback_stream(
content_url, ffmpeg_path, use_public_api=use_public_api
)
await stream.start()
if blocking:
await self.wait_until_audio_completes()
async def wait_until_audio_completes(self) -> None:
"""Awaits stream completion of audio."""
stream = self.talkback_stream
if stream is None:
raise StreamError("No audio playing to wait for")
await stream.run_until_complete()
async def stop_audio(self) -> None:
"""Stop currently playing audio."""
if (stream := self.talkback_stream) is None:
raise StreamError("No audio playing to stop")
await stream.stop()
def can_read_media(self, user: User) -> bool:
if self.model is None:
return True
return user.can(self.model, PermissionNode.READ_MEDIA, self)
def can_delete_media(self, user: User) -> bool:
if self.model is None:
return True
return user.can(self.model, PermissionNode.DELETE_MEDIA, self)
# region PTZ
async def get_ptz_presets(self) -> list[PTZPreset]:
"""Get PTZ Presets for camera."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
return await self._api.get_presets_ptz_camera(self.id)
async def get_ptz_patrols(self) -> list[PTZPatrol]:
"""Get PTZ Patrols for camera."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
return await self._api.get_patrols_ptz_camera(self.id)
def _check_ptz_public_api(self) -> None:
"""Check prerequisites for PTZ public API calls."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
async def ptz_goto_preset_public(self, *, slot: int) -> None:
"""Move PTZ camera to preset position using public API."""
self._check_ptz_public_api()
await self._api.ptz_goto_preset_public(self.id, slot=slot)
async def ptz_patrol_start_public(self, *, slot: int) -> None:
"""Start a PTZ patrol using public API."""
self._check_ptz_public_api()
await self._api.ptz_patrol_start_public(self.id, slot=slot)
async def ptz_patrol_stop_public(self) -> None:
"""Stop the active PTZ patrol using public API."""
self._check_ptz_public_api()
await self._api.ptz_patrol_stop_public(self.id)
async def set_name_public(self, name: str) -> None:
"""Set camera name via public API."""
updated = await self._api.update_camera_public(self.id, name=name)
self.name = updated.name
async def set_status_light_public(self, enabled: bool) -> None:
"""Set status LED via public API."""
if not self.feature_flags.has_led_status:
raise BadRequest("Camera does not have status light")
updated = await self._api.update_camera_public(self.id, led_is_enabled=enabled)
self._merge_public_led_settings(updated.led_settings)
async def set_welcome_led_public(self, enabled: bool) -> None:
"""Set welcome LED via public API."""
if not self.feature_flags.has_led_status:
raise BadRequest("Camera does not have status light")
if self.led_settings.welcome_led is None:
raise BadRequest("Camera does not have welcome LED")
updated = await self._api.update_camera_public(self.id, led_welcome_led=enabled)
self._merge_public_led_settings(updated.led_settings)
async def set_flood_led_public(self, enabled: bool) -> None:
"""Set flood LED via public API."""
if not self.feature_flags.has_led_status:
raise BadRequest("Camera does not have status light")
if self.led_settings.flood_led is None:
raise BadRequest("Camera does not have flood LED")
updated = await self._api.update_camera_public(self.id, led_flood_led=enabled)
self._merge_public_led_settings(updated.led_settings)
def _merge_public_led_settings(self, led: PublicCameraLedSettings) -> None:
"""Sync the private LED sub-model from a public PATCH response in place."""
# Only the public-API fields; private-only fields (e.g. ``blink_rate``)
# are left untouched.
self.led_settings.is_enabled = led.is_enabled
self.led_settings.welcome_led = led.welcome_led
self.led_settings.flood_led = led.flood_led
def _merge_public_osd_settings(self, osd: PublicOsdSettings) -> None:
"""Sync the private OSD sub-model from a public PATCH response in place."""
self.osd_settings.is_name_enabled = osd.is_name_enabled
self.osd_settings.is_date_enabled = osd.is_date_enabled
self.osd_settings.is_logo_enabled = osd.is_logo_enabled
self.osd_settings.is_debug_enabled = osd.is_debug_enabled
self.osd_settings.overlay_location = osd.overlay_location
async def set_hdr_mode_public(self, mode: PublicHdrMode) -> None:
"""Set HDR mode via public API."""
if not self.feature_flags.has_hdr:
raise BadRequest("Camera does not have HDR")
await self._api.update_camera_public(self.id, hdr_type=mode)
# The public API response uses 'hdrType', not 'hdrMode'/'ispSettings',
# so we derive the local state update directly from the mode we sent.
hdr_on = mode != PublicHdrMode.OFF
if hasattr(self, "hdr_mode"):
self.hdr_mode = hdr_on
isp = getattr(self, "isp_settings", None)
if isp is not None:
isp_hdr = getattr(isp, "hdr_mode", None)
if isp_hdr is not None:
isp.hdr_mode = (
HDRMode.ALWAYS_ON if mode == PublicHdrMode.ON else HDRMode.NORMAL
)
async def set_video_mode_public(self, mode: VideoMode) -> None:
"""Set video mode via public API."""
if mode not in self.feature_flags.video_modes:
raise BadRequest(f"Camera does not have {mode}")
updated = await self._api.update_camera_public(self.id, video_mode=mode)
self.video_mode = updated.video_mode
async def set_mic_volume_public(self, level: float) -> None:
"""Set microphone volume via public API."""
if not self.feature_flags.has_mic:
raise BadRequest("Camera does not have mic")
level = _coerce_public_int("mic_volume", level, _PUBLIC_MIC_VOLUME_RANGE)
updated = await self._api.update_camera_public(self.id, mic_volume=level)
self.mic_volume = updated.mic_volume
async def set_lcd_message_public(
self,
text_type: DoorbellMessageType | None,
text: str | None = None,
reset_at: datetime | DEFAULT_TYPE | None = DEFAULT,
) -> None:
"""
Set doorbell LCD message via public API.
Pass ``None`` for ``text_type`` to clear the message, with ``text`` and
``reset_at`` omitted. ``text`` is required for CUSTOM_MESSAGE and
IMAGE, and must be omitted for DO_NOT_DISTURB and
LEAVE_PACKAGE_AT_DOOR. ``reset_at`` controls when the message is
cleared: omit for the NVR default, pass ``None`` for "forever", or pass
a specific datetime.
"""
if not self.feature_flags.has_lcd_screen:
raise BadRequest("Camera does not have an LCD screen")
message = _build_public_lcd_message(text_type, text, reset_at)
updated = await self._api.update_camera_public(self.id, lcd_message=message)
if text_type is None:
# The response is not read back: the console only runs its sweep for
# a provisioned, connected camera, so an offline one echoes the
# request straight back and would rebuild the message just cleared.
# The faked frame below only reaches an instance the bootstrap owns,
# so set the local state here as ``set_lcd_text`` does.
self.lcd_message = None
# UniFi Protect bug: clearing the LCD message does _not_ emit a WS
# message, so fake one the way ``set_lcd_text`` does. A reset time
# in the past is how the console signals a wiped message.
reset = to_js_time(utc_now() - timedelta(seconds=10))
await self.emit_message({"lcdMessage": {"resetAt": reset}})
return
pub = updated.lcd_message
# The public response carries a ``PublicLcdMessage``; rebuild the private
# ``LCDMessage`` from it (``None`` when the message was cleared).
self.lcd_message = (
None
if pub is None or pub.type is None
else LCDMessage.from_unifi_dict(
type=pub.type,
text=pub.text or "",
resetAt=pub.reset_at,
api=self._api,
)
)
async def set_osd_name_public(self, enabled: bool) -> None:
"""Toggle name overlay (OSD) via public API."""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
updated = await self._api.update_camera_public(
self.id, osd_name_enabled=enabled
)
self._merge_public_osd_settings(updated.osd_settings)
async def set_osd_date_public(self, enabled: bool) -> None:
"""Toggle date overlay (OSD) via public API."""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
updated = await self._api.update_camera_public(
self.id, osd_date_enabled=enabled
)
self._merge_public_osd_settings(updated.osd_settings)
async def set_osd_logo_public(self, enabled: bool) -> None:
"""Toggle logo overlay (OSD) via public API."""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
updated = await self._api.update_camera_public(
self.id, osd_logo_enabled=enabled
)
self._merge_public_osd_settings(updated.osd_settings)
async def set_osd_nerd_mode_public(self, enabled: bool) -> None:
"""Toggle bitrate/debug overlay (OSD) via public API."""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
updated = await self._api.update_camera_public(
self.id, osd_nerd_mode_enabled=enabled
)
self._merge_public_osd_settings(updated.osd_settings)
async def set_osd_overlay_location_public(
self,
location: OsdOverlayLocation,
) -> None:
"""Set OSD overlay location via public API."""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
updated = await self._api.update_camera_public(
self.id, osd_overlay_location=location
)
self._merge_public_osd_settings(updated.osd_settings)
async def set_person_detection_public(self, enabled: bool) -> None:
"""Toggle person smart detection via public API."""
await self._set_smart_detect_type_public(SmartDetectObjectType.PERSON, enabled)
async def set_vehicle_detection_public(self, enabled: bool) -> None:
"""Toggle vehicle smart detection via public API."""
await self._set_smart_detect_type_public(SmartDetectObjectType.VEHICLE, enabled)
async def set_package_detection_public(self, enabled: bool) -> None:
"""Toggle package smart detection via public API."""
await self._set_smart_detect_type_public(SmartDetectObjectType.PACKAGE, enabled)
async def set_license_plate_detection_public(self, enabled: bool) -> None:
"""Toggle license plate smart detection via public API."""
await self._set_smart_detect_type_public(
SmartDetectObjectType.LICENSE_PLATE, enabled
)
async def set_animal_detection_public(self, enabled: bool) -> None:
"""Toggle animal smart detection via public API."""
await self._set_smart_detect_type_public(SmartDetectObjectType.ANIMAL, enabled)
async def set_face_detection_public(self, enabled: bool) -> None:
"""Toggle face smart detection via public API."""
await self._set_smart_detect_type_public(SmartDetectObjectType.FACE, enabled)
async def _set_smart_detect_type_public(
self, obj_type: SmartDetectObjectType, enabled: bool
) -> None:
if not getattr(self.feature_flags, "has_smart_detect", True):
raise BadRequest("Camera does not have smart detections")
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
if obj_type not in self.feature_flags.smart_detect_types:
raise BadRequest(f"Camera does not support {obj_type} detection")
# Lock serializes concurrent callers that each read-modify-PATCH the full
# list. Without the lock two concurrent calls could clobber each other's
# change (last PATCH wins). Same pattern as the private queue_update path.
async with self._update_sync.lock:
types = list(self.smart_detect_settings.object_types)
if enabled and obj_type not in types:
types.append(obj_type)
elif not enabled and obj_type in types:
types.remove(obj_type)
updated = await self._api.update_camera_public(
self.id, smart_detect_object_types=types
)
self.smart_detect_settings.object_types = (
updated.smart_detect_settings.object_types
)
async def set_smoke_detection_public(self, enabled: bool) -> None:
"""Toggle smoke audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.SMOKE, enabled
)
async def set_co_detection_public(self, enabled: bool) -> None:
"""Toggle CO audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.CMONX, enabled
)
async def set_siren_detection_public(self, enabled: bool) -> None:
"""Toggle siren audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.SIREN, enabled
)
async def set_baby_cry_detection_public(self, enabled: bool) -> None:
"""Toggle baby cry audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.BABY_CRY, enabled
)
async def set_speaking_detection_public(self, enabled: bool) -> None:
"""Toggle speaking audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.SPEAK, enabled
)
async def set_bark_detection_public(self, enabled: bool) -> None:
"""Toggle bark audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.BARK, enabled
)
async def set_burglar_detection_public(self, enabled: bool) -> None:
"""Toggle burglar audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.BURGLAR, enabled
)
async def set_car_horn_detection_public(self, enabled: bool) -> None:
"""Toggle car horn audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.CAR_HORN, enabled
)
async def set_glass_break_detection_public(self, enabled: bool) -> None:
"""Toggle glass break audio detection via public API."""
await self._set_smart_detect_audio_type_public(
SmartDetectAudioType.GLASS_BREAK, enabled
)
async def _set_smart_detect_audio_type_public(
self, audio_type: SmartDetectAudioType, enabled: bool
) -> None:
if not getattr(self.feature_flags, "has_smart_detect", True):
raise BadRequest("Camera does not have smart detections")
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
audio_types = self.feature_flags.smart_detect_audio_types
if audio_types is None or audio_type not in audio_types:
raise BadRequest(f"Camera does not support {audio_type} audio detection")
# Lock serializes concurrent callers; same rationale as
# _set_smart_detect_type_public above.
async with self._update_sync.lock:
types = list(self.smart_detect_settings.audio_types or [])
if enabled and audio_type not in types:
types.append(audio_type)
elif not enabled and audio_type in types:
types.remove(audio_type)
updated = await self._api.update_camera_public(
self.id, smart_detect_audio_types=types
)
self.smart_detect_settings.audio_types = (
updated.smart_detect_settings.audio_types
)
# endregion
class Viewer(ProtectAdoptableDeviceModel):
stream_limit: int
software_version: str
liveview_id: str
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "liveview": "liveviewId"}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {"softwareVersion"}
@property
def liveview(self) -> Liveview | None:
# user may not have permission to see the liveview
return self._api.bootstrap.liveviews.get(self.liveview_id)
async def set_liveview(self, liveview: Liveview) -> None:
"""
Set the liveview for this viewer.
.. deprecated::
Use :meth:`ProtectApiClient.update_viewer_public` instead; the
public-API counterpart is feature-complete for renaming and
liveview assignment.
"""
warnings.warn(
"Viewer.set_liveview is deprecated; use "
"ProtectApiClient.update_viewer_public(viewer_id, liveview=...) "
"instead.",
DeprecationWarning,
stacklevel=2,
)
if self._api is not None and liveview.id not in self._api.bootstrap.liveviews:
raise BadRequest("Unknown liveview")
async with self._update_sync.lock:
# yield to the event loop once we have the lock to process any pending updates
await asyncio.sleep(0)
data_before_changes = self.dict_with_excludes()
self.liveview_id = liveview.id
# UniFi Protect bug: changing the liveview does _not_ emit a WS message
await self.save_device(data_before_changes, force_emit=True)
async def set_name_public(self, name: str) -> None:
"""Set viewer name via public API."""
updated = await self._api.update_viewer_public(self.id, name=name)
self.name = updated.name
async def set_liveview_public(self, liveview_id: str | None) -> None:
"""Set the viewer liveview via public API. Pass ``None`` to clear."""
updated = await self._api.update_viewer_public(self.id, liveview=liveview_id)
# ``liveview_id`` is non-optional on the private model; the public API
# represents a cleared liveview as ``None`` on the wire.
self.liveview_id = updated.liveview_id or ""
class Bridge(ProtectAdoptableDeviceModel):
platform: str
class SensorSettingsBase(ProtectBaseObject):
is_enabled: bool
class SensorThresholdSettings(SensorSettingsBase):
margin: float # read only
# "safe" thresholds for alerting
# anything below/above will trigger alert
low_threshold: float | None = None
high_threshold: float | None = None
class SensorSensitivitySettings(SensorSettingsBase):
sensitivity: PercentInt
# Armed-mode sensitivity override. Absent on older firmware / not carried by
# every console, so it is optional and dropped from the wire dict when unset.
sensitivity_when_armed: PercentInt | None = None
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"sensitivityWhenArmed"})
return data
class SensorBatteryStatus(ProtectBaseObject):
percentage: PercentInt | None = None
is_low: bool
class SensorStat(ProtectBaseObject):
value: float | None = None
status: SensorStatusType
class SensorStats(ProtectBaseObject):
light: SensorStat
humidity: SensorStat
temperature: SensorStat
class Sensor(ProtectAdoptableDeviceModel):
alarm_settings: SensorSettingsBase
alarm_triggered_at: datetime | None = None
battery_status: SensorBatteryStatus
camera_id: str | None = None
humidity_settings: SensorThresholdSettings
is_motion_detected: bool
is_opened: bool
leak_detected_at: datetime | None = None
led_settings: LEDSettings
light_settings: SensorThresholdSettings
motion_detected_at: datetime | None = None
motion_settings: SensorSensitivitySettings
open_status_changed_at: datetime | None = None
stats: SensorStats
tampering_detected_at: datetime | None = None
temperature_settings: SensorThresholdSettings
mount_type: MountType
# not directly from UniFi
last_motion_event_id: str | None = None
last_contact_event_id: str | None = None
last_value_event_id: str | None = None
last_alarm_event_id: str | None = None
extreme_value_detected_at: datetime | None = None
_tamper_timeout: datetime | None = PrivateAttr(None)
_alarm_timeout: datetime | None = PrivateAttr(None)
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "camera": "cameraId"}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {
"batteryStatus",
"isMotionDetected",
"leakDetectedAt",
"tamperingDetectedAt",
"isOpened",
"openStatusChangedAt",
"alarmTriggeredAt",
"motionDetectedAt",
"stats",
}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_tuple(
data,
(
"lastMotionEventId",
"lastContactEventId",
"lastValueEventId",
"lastAlarmEventId",
"extremeValueDetectedAt",
),
)
return data
@property
def camera(self) -> Camera | None:
"""Paired Camera will always be none if no camera is paired"""
if (camera_id := self.camera_id) is None:
return None
return self._api.bootstrap.cameras[camera_id]
@property
def is_tampering_detected(self) -> bool:
return self.tampering_detected_at is not None
@property
def is_alarm_detected(self) -> bool:
if self._alarm_timeout is None:
return False
return utc_now() < self._alarm_timeout
@property
def is_contact_sensor_enabled(self) -> bool:
return self.mount_type in {MountType.DOOR, MountType.WINDOW, MountType.GARAGE}
@property
def is_motion_sensor_enabled(self) -> bool:
return self.mount_type is not MountType.LEAK and self.motion_settings.is_enabled
@property
def is_alarm_sensor_enabled(self) -> bool:
return self.mount_type is not MountType.LEAK and self.alarm_settings.is_enabled
@property
def is_light_sensor_enabled(self) -> bool:
return self.mount_type is not MountType.LEAK and self.light_settings.is_enabled
@property
def is_temperature_sensor_enabled(self) -> bool:
return (
self.mount_type is not MountType.LEAK
and self.temperature_settings.is_enabled
)
@property
def is_humidity_sensor_enabled(self) -> bool:
return (
self.mount_type is not MountType.LEAK and self.humidity_settings.is_enabled
)
@property
def is_leak_sensor_enabled(self) -> bool:
return self.mount_type is MountType.LEAK
def set_alarm_timeout(self) -> None:
self._alarm_timeout = utc_now() + EVENT_PING_INTERVAL
self._event_callback_ping()
@property
def last_motion_event(self) -> Event | None:
if (last_motion_event_id := self.last_motion_event_id) is None:
return None
return self._api.bootstrap.events.get(last_motion_event_id)
@property
def last_contact_event(self) -> Event | None:
if (last_contact_event_id := self.last_contact_event_id) is None:
return None
return self._api.bootstrap.events.get(last_contact_event_id)
@property
def last_value_event(self) -> Event | None:
if (last_value_event_id := self.last_value_event_id) is None:
return None
return self._api.bootstrap.events.get(last_value_event_id)
@property
def last_alarm_event(self) -> Event | None:
if (last_alarm_event_id := self.last_alarm_event_id) is None:
return None
return self._api.bootstrap.events.get(last_alarm_event_id)
@property
def is_leak_detected(self) -> bool:
return self.leak_detected_at is not None
async def set_status_light(self, enabled: bool) -> None:
"""Sets the status indicator light for the sensor"""
def callback() -> None:
self.led_settings.is_enabled = enabled
await self.queue_update(callback)
async def set_mount_type(self, mount_type: MountType) -> None:
"""Sets current mount type for sensor"""
def callback() -> None:
self.mount_type = mount_type
await self.queue_update(callback)
async def set_motion_status(self, enabled: bool) -> None:
"""Sets the motion detection type for the sensor"""
def callback() -> None:
self.motion_settings.is_enabled = enabled
await self.queue_update(callback)
async def set_temperature_status(self, enabled: bool) -> None:
"""Sets the temperature detection type for the sensor"""
def callback() -> None:
self.temperature_settings.is_enabled = enabled
await self.queue_update(callback)
async def set_temperature_safe_range(self, low: float, high: float) -> None:
"""Sets the temperature safe range for the sensor"""
if low < 0.0:
raise BadRequest("Minimum value is 0°C")
if high > 45.0:
raise BadRequest("Maximum value is 45°C")
if high <= low:
raise BadRequest("High value must be above low value")
def callback() -> None:
self.temperature_settings.low_threshold = low
self.temperature_settings.high_threshold = high
await self.queue_update(callback)
async def remove_temperature_safe_range(self) -> None:
"""Removes the temperature safe range for the sensor"""
def callback() -> None:
self.temperature_settings.low_threshold = None
self.temperature_settings.high_threshold = None
await self.queue_update(callback)
async def set_humidity_status(self, enabled: bool) -> None:
"""Sets the humidity detection type for the sensor"""
def callback() -> None:
self.humidity_settings.is_enabled = enabled
await self.queue_update(callback)
async def set_humidity_safe_range(self, low: float, high: float) -> None:
"""Sets the humidity safe range for the sensor"""
if low < 1.0:
raise BadRequest("Minimum value is 1%")
if high > 99.0:
raise BadRequest("Maximum value is 99%")
if high <= low:
raise BadRequest("High value must be above low value")
def callback() -> None:
self.humidity_settings.low_threshold = low
self.humidity_settings.high_threshold = high
await self.queue_update(callback)
async def remove_humidity_safe_range(self) -> None:
"""Removes the humidity safe range for the sensor"""
def callback() -> None:
self.humidity_settings.low_threshold = None
self.humidity_settings.high_threshold = None
await self.queue_update(callback)
async def set_light_status(self, enabled: bool) -> None:
"""Sets the light detection type for the sensor"""
def callback() -> None:
self.light_settings.is_enabled = enabled
await self.queue_update(callback)
async def set_light_safe_range(self, low: float, high: float) -> None:
"""Sets the light safe range for the sensor"""
if low < 1.0:
raise BadRequest("Minimum value is 1 lux")
if high > 1000.0:
raise BadRequest("Maximum value is 1000 lux")
if high <= low:
raise BadRequest("High value must be above low value")
def callback() -> None:
self.light_settings.low_threshold = low
self.light_settings.high_threshold = high
await self.queue_update(callback)
async def remove_light_safe_range(self) -> None:
"""Removes the light safe range for the sensor"""
def callback() -> None:
self.light_settings.low_threshold = None
self.light_settings.high_threshold = None
await self.queue_update(callback)
async def set_alarm_status(self, enabled: bool) -> None:
"""Sets the alarm detection type for the sensor"""
def callback() -> None:
self.alarm_settings.is_enabled = enabled
await self.queue_update(callback)
async def set_paired_camera(self, camera: Camera | None) -> None:
"""Sets the camera paired with the sensor"""
def callback() -> None:
if camera is None:
self.camera_id = None
else:
self.camera_id = camera.id
await self.queue_update(callback)
async def clear_tamper(self) -> None:
"""Clears tamper status for sensor"""
if not self._api.bootstrap.auth_user.can(
ModelType.SENSOR,
PermissionNode.WRITE,
self,
):
raise NotAuthorized(
f"Do not have permission to clear tamper for sensor: {self.id}",
)
await self._api.clear_tamper_sensor(self.id)
async def set_name_public(self, name: str) -> None:
"""Set sensor name via public API."""
updated = await self._api.update_sensor_public(self.id, name=name)
self.name = updated.name
async def set_temperature_settings_public(
self,
*,
is_enabled: bool | None = None,
low_threshold: float | None = None,
high_threshold: float | None = None,
margin: float | None = None,
) -> None:
"""Update temperature alert settings via public API."""
low, high = _PUBLIC_TEMPERATURE_LOW_RANGE
settings: PublicSensorTemperatureSettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if low_threshold is not None:
_validate_public_range("low_threshold", low_threshold, (low, high))
settings["lowThreshold"] = low_threshold
if high_threshold is not None:
settings["highThreshold"] = high_threshold
if margin is not None:
settings["margin"] = margin
if not settings:
raise BadRequest("At least one parameter must be provided")
await self._api.update_sensor_public(self.id, temperature_settings=settings)
self._merge_threshold_local(
"temperature_settings",
is_enabled=is_enabled,
low_threshold=low_threshold,
high_threshold=high_threshold,
margin=margin,
)
async def set_humidity_settings_public(
self,
*,
is_enabled: bool | None = None,
low_threshold: float | None = None,
high_threshold: float | None = None,
margin: int | None = None,
) -> None:
"""Update humidity alert settings via public API."""
low, high = _PUBLIC_HUMIDITY_LOW_RANGE
settings: PublicSensorHumiditySettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if low_threshold is not None:
_validate_public_range("low_threshold", low_threshold, (low, high))
settings["lowThreshold"] = low_threshold
if high_threshold is not None:
settings["highThreshold"] = high_threshold
if margin is not None:
settings["margin"] = margin
if not settings:
raise BadRequest("At least one parameter must be provided")
await self._api.update_sensor_public(self.id, humidity_settings=settings)
self._merge_threshold_local(
"humidity_settings",
is_enabled=is_enabled,
low_threshold=low_threshold,
high_threshold=high_threshold,
margin=margin,
)
async def set_light_settings_public(
self,
*,
is_enabled: bool | None = None,
low_threshold: float | None = None,
high_threshold: float | None = None,
margin: int | None = None,
) -> None:
"""Update light (lux) alert settings via public API."""
low, high = _PUBLIC_LIGHT_LUX_LOW_RANGE
settings: PublicSensorLightSettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if low_threshold is not None:
_validate_public_range("low_threshold", low_threshold, (low, high))
settings["lowThreshold"] = low_threshold
if high_threshold is not None:
settings["highThreshold"] = high_threshold
if margin is not None:
settings["margin"] = margin
if not settings:
raise BadRequest("At least one parameter must be provided")
await self._api.update_sensor_public(self.id, light_settings=settings)
self._merge_threshold_local(
"light_settings",
is_enabled=is_enabled,
low_threshold=low_threshold,
high_threshold=high_threshold,
margin=margin,
)
async def set_motion_settings_public(
self,
*,
is_enabled: bool | None = None,
sensitivity: float | None = None,
sensitivity_when_armed: float | None = None,
) -> None:
"""Update motion detection settings via public API."""
settings: PublicSensorMotionSettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if sensitivity is not None:
sensitivity = _coerce_public_int(
"sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE
)
settings["sensitivity"] = sensitivity
if sensitivity_when_armed is not None:
settings["sensitivityWhenArmed"] = _coerce_public_int(
"sensitivity_when_armed",
sensitivity_when_armed,
_PUBLIC_SENSITIVITY_RANGE,
)
if not settings:
raise BadRequest("At least one parameter must be provided")
await self._api.update_sensor_public(self.id, motion_settings=settings)
current = self.motion_settings.model_copy()
if is_enabled is not None:
current.is_enabled = is_enabled
if sensitivity is not None:
current.sensitivity = PercentInt(sensitivity)
if sensitivity_when_armed is not None:
current.sensitivity_when_armed = PercentInt(sensitivity_when_armed)
self.motion_settings = current
async def set_glass_break_settings_public(
self,
*,
is_enabled: bool | None = None,
sensitivity: float | None = None,
sensitivity_when_armed: float | None = None,
) -> None:
"""Update glass-break detection settings via public API."""
settings: PublicSensorGlassBreakSettingsWrite = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if sensitivity is not None:
settings["sensitivity"] = _coerce_public_int(
"sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE
)
if sensitivity_when_armed is not None:
settings["sensitivityWhenArmed"] = _coerce_public_int(
"sensitivity_when_armed",
sensitivity_when_armed,
_PUBLIC_SENSITIVITY_RANGE,
)
if not settings:
raise BadRequest("At least one parameter must be provided")
await self._api.update_sensor_public(self.id, glass_break_settings=settings)
async def set_alarm_public(self, enabled: bool) -> None:
"""Toggle the (audio) alarm detection setting via public API."""
alarm_settings: PublicSensorAlarmSettings = {"isEnabled": enabled}
await self._api.update_sensor_public(self.id, alarm_settings=alarm_settings)
current = self.alarm_settings.model_copy()
current.is_enabled = enabled
self.alarm_settings = current
async def set_schedule_mode_public(self, mode: SensorScheduleMode) -> None:
"""Set the arm-schedule mode via public API."""
await self._api.update_sensor_public(self.id, schedule_mode=mode)
async def set_arm_profile_ids_public(self, arm_profile_ids: list[str]) -> None:
"""Set the arm-profile ids associated with the sensor via public API."""
await self._api.update_sensor_public(self.id, arm_profile_ids=arm_profile_ids)
async def set_custom_sensitivity_when_armed_public(self, enabled: bool) -> None:
"""Toggle custom armed sensitivity via public API."""
await self._api.update_sensor_public(
self.id, has_custom_sensitivity_when_armed=enabled
)
async def set_motion_status_public(self, enabled: bool) -> None:
"""Toggle motion detection via public API."""
await self.set_motion_settings_public(is_enabled=enabled)
async def set_motion_sensitivity_public(self, sensitivity: float) -> None:
"""Set motion detection sensitivity via public API."""
await self.set_motion_settings_public(sensitivity=sensitivity)
async def set_temperature_status_public(self, enabled: bool) -> None:
"""Toggle temperature alerts via public API."""
await self.set_temperature_settings_public(is_enabled=enabled)
async def set_humidity_status_public(self, enabled: bool) -> None:
"""Toggle humidity alerts via public API."""
await self.set_humidity_settings_public(is_enabled=enabled)
async def set_light_status_public(self, enabled: bool) -> None:
"""Toggle light (lux) alerts via public API."""
await self.set_light_settings_public(is_enabled=enabled)
async def set_glass_break_status_public(self, enabled: bool) -> None:
"""Toggle glass-break detection via public API."""
await self.set_glass_break_settings_public(is_enabled=enabled)
def _merge_threshold_local(
self,
attr: str,
*,
is_enabled: bool | None,
low_threshold: float | None,
high_threshold: float | None,
margin: float | None,
) -> None:
current: SensorThresholdSettings = getattr(self, attr).model_copy()
if is_enabled is not None:
current.is_enabled = is_enabled
if low_threshold is not None:
current.low_threshold = low_threshold
if high_threshold is not None:
current.high_threshold = high_threshold
if margin is not None:
current.margin = margin
setattr(self, attr, current)
class ChimeFeatureFlags(ProtectBaseObject):
has_wifi: bool
# 2.9.20+
has_https_client_ota: bool | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "hasHttpsClientOTA": "hasHttpsClientOta"}
class RingSetting(ProtectBaseObject):
"""Ring settings for a paired doorbell camera."""
camera_id: str
repeat_times: RepeatTimes
ringtone_id: str | None = None
track_no: int | None = None # deprecated: use ringtone_id
volume: int
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "camera": "cameraId"}
def to_api_dict(
self, volume: int | None = None, repeat_times: int | None = None
) -> PublicApiChimeRingSettingRequest:
"""
Convert to API dict format for Public API requests.
Args:
----
volume: Override volume value. If None, uses current volume.
repeat_times: Override repeat-times value. If None, uses current
repeat_times.
Returns:
-------
Dict with cameraId, volume, repeatTimes, and optionally ringtoneId keys.
"""
result: PublicApiChimeRingSettingRequest = {
"cameraId": self.camera_id,
"volume": volume if volume is not None else self.volume,
"repeatTimes": repeat_times
if repeat_times is not None
else self.repeat_times,
}
if self.ringtone_id is not None:
result["ringtoneId"] = self.ringtone_id
return result
@property
def camera(self) -> Camera | None:
"""Paired Camera will always be none if no camera is paired."""
if self.camera_id is None:
return None # type: ignore[unreachable]
return self._api.bootstrap.cameras[self.camera_id]
class ChimeTrack(ProtectBaseObject):
md5: str
name: str
state: str
track_no: int
volume: int
size: int
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "track_no": "trackNo"}
class Chime(ProtectAdoptableDeviceModel):
volume: PercentInt
is_probing_for_wifi: bool
last_ring: datetime | None = None
is_wireless_uplink_enabled: bool
camera_ids: list[str]
# requires 2.6.17+
ap_mgmt_ip: IPv4Address | IPv6Address | None = None
# requires 2.7.15+
feature_flags: ChimeFeatureFlags | None = None
# requires 2.8.22+
user_configured_ap: bool | None = None
# requires 3.0.22+
has_https_client_ota: bool | None = None
platform: str | None = None
repeat_times: RepeatTimes | None = None
ring_settings: list[RingSetting] = []
speaker_track_list: list[ChimeTrack] = []
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "hasHttpsClientOTA": "hasHttpsClientOta"}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {"isProbingForWifi", "lastRing"}
@property
def cameras(self) -> list[Camera]:
"""Paired Cameras for chime"""
if len(self.camera_ids) == 0:
return []
return [self._api.bootstrap.cameras[c] for c in self.camera_ids]
async def set_volume(self, level: int) -> None:
"""
Set the speaker volume on chime.
.. deprecated::
Use :meth:`set_volume_for_camera_public` instead. This method
updates the speaker volume but not the doorbell ring volume.
"""
warnings.warn(
"set_volume is deprecated, use set_volume_for_camera_public instead",
DeprecationWarning,
stacklevel=2,
)
old_value = self.volume
new_value = PercentInt(level)
def callback() -> None:
self.volume = new_value
for setting in self.ring_settings:
if setting.volume == old_value:
setting.volume = new_value
await self.queue_update(callback)
async def add_camera(self, camera: Camera) -> None:
"""Adds new paired camera to chime"""
if not camera.feature_flags.is_doorbell:
raise BadRequest("Camera does not have a chime")
if camera.id in self.camera_ids:
raise BadRequest("Camera is already paired")
def callback() -> None:
self.camera_ids.append(camera.id)
await self.queue_update(callback)
async def remove_camera(self, camera: Camera) -> None:
"""Removes paired camera from chime"""
if camera.id not in self.camera_ids:
raise BadRequest("Camera is not paired")
def callback() -> None:
self.camera_ids.remove(camera.id)
await self.queue_update(callback)
async def play(
self,
*,
volume: int | None = None,
repeat_times: int | None = None,
ringtone_id: str | None = None,
track_no: int | None = None,
) -> None:
"""
Plays chime tone.
Args:
----
volume: Volume level for playback (0-100). Uses chime's current volume if None.
repeat_times: Number of times to repeat the tone.
ringtone_id: The ringtone ID (UUID) to play. If None, uses default tone.
track_no: Legacy track number from speakerTrackList.
.. deprecated::
Use ringtone_id instead.
"""
if track_no is not None:
warnings.warn(
"track_no is deprecated, use ringtone_id instead",
DeprecationWarning,
stacklevel=2,
)
await self._api.play_speaker(
self.id,
volume=volume,
repeat_times=repeat_times,
ringtone_id=ringtone_id,
track_no=track_no,
)
async def play_buzzer(self) -> None:
"""Plays chime buzzer"""
await self._api.play_buzzer(self.id)
async def set_repeat_times(self, value: int) -> None:
"""
Set repeat times on chime.
.. deprecated::
Use :meth:`set_ring_settings_public` instead. This method uses
the private API.
"""
warnings.warn(
"set_repeat_times is deprecated, use set_ring_settings_public instead",
DeprecationWarning,
stacklevel=2,
)
old_value = self.repeat_times
def callback() -> None:
self.repeat_times = cast("RepeatTimes", value)
for setting in self.ring_settings:
if setting.repeat_times == old_value:
setting.repeat_times = cast("RepeatTimes", value)
await self.queue_update(callback)
async def set_name_public(self, name: str) -> None:
"""Set chime name via public API."""
updated = await self._api.update_chime_public(self.id, name=name)
self.name = updated.name
async def set_ring_settings_public(
self,
ring_settings: list[PublicApiChimeRingSettingRequest],
) -> None:
"""
Update ring settings using public API.
This is the preferred method to change ring volume per camera as it uses
the official public API.
Args:
----
ring_settings: List of ring settings per camera. Each dict should contain:
- cameraId: The camera ID this setting applies to
- volume: Ring volume (0-100)
- repeatTimes: How many times to repeat (1-10)
- ringtoneId (optional): The ringtone ID to use
Example:
-------
>>> await chime.set_ring_settings_public([
... {
... "cameraId": "camera123",
... "volume": 80,
... "repeatTimes": 2,
... "ringtoneId": "ringtone456"
... }
... ])
"""
updated = await self._api.update_chime_public(
self.id,
ring_settings=ring_settings,
)
self._api._write_through_public_twin(updated)
# The public response carries ``PublicRingSettings`` (every field typed
# optional); rebuild the strict private ``RingSetting`` list from it,
# skipping entries the wire left incomplete so a partial response can't
# raise ``ValidationError`` (``RingSetting`` requires camera_id /
# repeat_times / volume).
self.ring_settings = [
RingSetting.from_unifi_dict(**rs.unifi_dict(), api=self._api)
for rs in updated.ring_settings
if rs.camera_id is not None
and rs.repeat_times is not None
and rs.volume is not None
]
async def set_volume_for_camera_public(
self,
camera: Camera,
level: int,
) -> None:
"""
Set the ring volume for a specific camera using public API.
This is the preferred method to change ring volume as it uses
the official public API and properly updates the doorbell ring volume.
Args:
----
camera: The doorbell camera to set volume for
level: Volume level (0-100)
Raises:
------
BadRequest: If the camera is not paired with this chime
ValidationError: If level is not in range 0-100
"""
# Validate level using PercentInt (raises ValidationError if invalid)
PercentInt(level)
# Hold the lock across the whole read-modify-write: two concurrent
# callers must not each read the pre-mutation list and clobber each
# other's change (last PATCH wins).
async with self._update_sync.lock:
# Find the current ring setting for this camera
ring_setting = None
for setting in self.ring_settings:
if setting.camera_id == camera.id:
ring_setting = setting
break
if ring_setting is None:
raise BadRequest(f"Camera {camera.id} is not paired with chime")
# Build the update payload preserving original order
ring_settings_update: list[PublicApiChimeRingSettingRequest] = [
setting.to_api_dict(volume=level)
if setting.camera_id == camera.id
else setting.to_api_dict()
for setting in self.ring_settings
]
await self.set_ring_settings_public(ring_settings_update)
async def set_repeat_times_for_camera_public(
self,
camera: Camera,
value: int,
) -> None:
"""
Set the ring repeat times for a specific camera using public API.
This is the preferred method to change ring repeat times as it uses
the official public API.
Args:
----
camera: The doorbell camera to set repeat times for
value: Number of times to repeat the ring (1-6)
Raises:
------
BadRequest: If the camera is not paired with this chime
ValidationError: If value is not in range 1-6
"""
# Validate value using RepeatTimes (raises ValidationError if invalid)
RepeatTimes(value)
# Hold the lock across the whole read-modify-write (see
# ``set_volume_for_camera_public``).
async with self._update_sync.lock:
# Find the current ring setting for this camera
ring_setting = None
for setting in self.ring_settings:
if setting.camera_id == camera.id:
ring_setting = setting
break
if ring_setting is None:
raise BadRequest(f"Camera {camera.id} is not paired with chime")
# Build the update payload preserving original order
ring_settings_update: list[PublicApiChimeRingSettingRequest] = [
setting.to_api_dict(repeat_times=value)
if setting.camera_id == camera.id
else setting.to_api_dict()
for setting in self.ring_settings
]
await self.set_ring_settings_public(ring_settings_update)
class AiPort(Camera):
paired_cameras: list[str]
class Ringtone(ProtectBaseObject):
id: str
name: str
size: int
is_default: bool
nvr_mac: str
model_key: str
uilibs-uiprotect-317a52c/src/uiprotect/data/nvr.py 0000664 0000000 0000000 00000146065 15246377057 0022275 0 ustar 00root root 0000000 0000000 """UniFi Protect Data."""
from __future__ import annotations
import asyncio
import logging
import zoneinfo
from collections.abc import Callable
from datetime import datetime, timedelta, tzinfo
from functools import cache
from ipaddress import IPv4Address, IPv6Address
from pathlib import Path
from typing import Any, ClassVar, Literal
from uuid import UUID
import aiofiles
import orjson
from convertertools import pop_dict_set_if_none, pop_dict_tuple
from pydantic import ConfigDict, Field
from pydantic.fields import PrivateAttr
from ..exceptions import BadRequest, NotAuthorized
from ..utils import convert_smart_types, convert_to_datetime
from .base import (
ProtectBaseObject,
ProtectDeviceModel,
ProtectModelWithId,
)
from .devices import (
Camera,
CameraZone,
Light,
OSDSettings,
RecordingSettings,
Sensor,
SmartDetectSettings,
)
from .types import (
AnalyticsOption,
DoorbellMessageType,
DoorbellText,
EventCategories,
EventType,
FirmwareReleaseChannel,
IteratorCallback,
ModelType,
MountType,
PercentFloat,
PermissionNode,
ProgressCallback,
RecordingMode,
RecordingType,
ResolutionStorageType,
SensorAlarmType,
SensorStatusType,
SensorType,
SmartDetectObjectType,
StorageType,
Version,
)
from .user import User, UserLocation
_LOGGER = logging.getLogger(__name__)
MAX_SUPPORTED_CAMERAS = 256
MAX_EVENT_HISTORY_IN_STATE_MACHINE = MAX_SUPPORTED_CAMERAS * 2
DELETE_KEYS_THUMB = {"color", "vehicleType"}
DELETE_KEYS_EVENT = {"deletedAt", "category", "subCategory", "device"}
class MetaInfo(ProtectBaseObject):
application_version: str
@property
def version(self) -> Version:
"""Parsed application version, comparable to the private ``NVR.version``."""
return Version(self.application_version)
class NVRLocation(UserLocation):
is_geofencing_enabled: bool
radius: int
model: ModelType | None = None
class SmartDetectItemAttribute(ProtectBaseObject):
"""Attribute value with confidence for smart detect items (e.g., color, vehicle type)."""
val: str
confidence: int
class SmartDetectItem(ProtectBaseObject):
id: str
timestamp: datetime
coord: tuple[int, int, int, int]
object_type: SmartDetectObjectType
zone_ids: list[int]
duration: timedelta
confidence: int
first_shown_time_ms: int
idle_since_time_ms: int
stationary: bool
license_plate: str | None = None # only populated for vehicle object_type
depth: float | None = None
speed: float | None = None
attributes: dict[str, SmartDetectItemAttribute] | None = None
lines: list[int] | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"zones": "zone_ids",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"duration": lambda x: timedelta(milliseconds=x),
} | super().unifi_dict_conversions()
class SmartDetectTrack(ProtectBaseObject):
id: str
payload: list[SmartDetectItem]
camera_id: str
event_id: str
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"camera": "cameraId",
"event": "eventId",
}
@property
def camera(self) -> Camera:
return self._api.bootstrap.cameras[self.camera_id]
@property
def event(self) -> Event | None:
return self._api.bootstrap.events.get(self.event_id)
class EventThumbnailGroup(ProtectBaseObject):
"""Group information for detected thumbnails (e.g., license plate recognition)."""
id: str
name: str | None = None
matched_name: str | None = None
confidence: int
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"matchedName", "name"})
return data
class EventThumbnailAttribute(ProtectBaseObject):
confidence: int
val: str
class NfcMetadata(ProtectBaseObject):
nfc_id: str | None = None
user_id: str | None = None
ulp_id: str | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"nfcId": "nfc_id",
"userId": "user_id",
"ulpId": "ulp_id",
}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"nfcId", "userId", "ulpId"})
return data
class FingerprintMetadata(ProtectBaseObject):
ulp_id: str | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"ulpId": "ulp_id",
}
class EventThumbnailAttributes(ProtectBaseObject):
"""
Dynamic attributes for detected thumbnails.
All attributes are stored as extra fields for full flexibility.
Common attribute types:
- color, vehicleType, faceMask: EventThumbnailAttribute (with val and confidence)
- zone: list[int] - Zone IDs where detection occurred
- trackerId: int - Unique tracker ID for this detection
Allows any attributes for forward compatibility with new UFP features.
Example usage:
>>> attrs = thumbnail.attributes
>>> if attrs:
... # Access EventThumbnailAttribute objects
... color = attrs.color # EventThumbnailAttribute
... if color:
... print(f"Color: {color.val} (confidence: {color.confidence})")
... # Access primitive types
... zones = attrs.zone # list[int] | None
... tracker = attrs.trackerId # int | None
"""
model_config = ConfigDict(extra="allow")
def get_value(self, key: str) -> str | None:
"""Get the string value from an EventThumbnailAttribute field, if it exists."""
attr = getattr(self, key, None)
if isinstance(attr, EventThumbnailAttribute):
return attr.val
return None
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
# Convert nested attribute objects to EventThumbnailAttribute instances
return {
key: EventThumbnailAttribute.from_unifi_dict(**value)
if isinstance(value, dict) and "val" in value and "confidence" in value
else value
for key, value in data.items()
}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
# Remove None values from extra fields
return {k: v for k, v in data.items() if v is not None}
class EventDetectedThumbnail(ProtectBaseObject):
clock_best_wall: datetime | None = None
type: str
cropped_id: str
attributes: EventThumbnailAttributes | None = None
name: str | None = None
coord: list[int] | None = None
confidence: int | None = None
# requires 6.0.0+
group: EventThumbnailGroup | None = None
object_id: str | None = None
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {"clockBestWall": convert_to_datetime} | super().unifi_dict_conversions()
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"name", "group", "objectId", "coord", "confidence"})
return data
class EventMetadata(ProtectBaseObject):
client_platform: str | None = None
reason: str | None = None
app_update: str | None = None
light_id: str | None = None
light_name: str | None = None
type: str | None = None
sensor_id: str | None = None
sensor_name: str | None = None
sensor_type: SensorType | None = None
from_value: str | None = None
to_value: str | None = None
mount_type: MountType | None = None
status: SensorStatusType | None = None
alarm_type: SensorAlarmType | None = None
device_id: str | None = None
mac: str | None = None
# requires 2.11.13+
detected_thumbnails: list[EventDetectedThumbnail] | None = None
# requires 5.1.34+
nfc: NfcMetadata | None = None
fingerprint: FingerprintMetadata | None = None
_collapse_keys: ClassVar[set[str]] = {
"lightId",
"lightName",
"type",
"sensorId",
"sensorName",
"sensorType",
"mountType",
"status",
"alarmType",
"deviceId",
"mac",
}
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"from": "fromValue",
"to": "toValue",
}
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
for key in cls._collapse_keys.intersection(data):
if isinstance(data[key], dict):
if "text" in data[key]:
data[key] = data[key]["text"]
else:
_LOGGER.debug(
"Unexpected format for EventMetadata key %s: %s",
key,
data[key],
)
del data[key]
return super().unifi_dict_to_dict(data)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
# all metadata keys optionally appear
for key, value in list(data.items()):
if value is None:
del data[key]
for key in self._collapse_keys.intersection(data):
# AI Theta/Hotplug exception
if key != "type" or data[key] not in {"audio", "video", "extender"}:
data[key] = {"text": data[key]}
return data
class Event(ProtectModelWithId):
type: EventType
start: datetime
end: datetime | None = None
# ``score`` / ``smart_detect_*`` are always present on the private API
# but the Public Integration API websocket omits them on minimal
# payloads (motion start, doorbell ring, etc.). Defaults keep the
# strict model constructable from either source.
score: int = 0
heatmap_id: str | None = None
camera_id: str | None = None
# Public Integration API ``add`` payloads carry the originating device
# identifier under a top-level ``device`` field; private-API payloads
# never populate this. Distinct from ``camera_id`` because non-camera
# events (sensors, alarm hubs) flow through the public WS.
device_id: str | None = None
smart_detect_types: list[SmartDetectObjectType] = Field(default_factory=list)
smart_detect_event_ids: list[str] = Field(default_factory=list)
thumbnail_id: str | None = None
user_id: str | None = None
timestamp: datetime | None = None
metadata: EventMetadata | None = None
# requires 2.7.5+
deleted_at: datetime | None = None
deletion_type: Literal["manual", "automatic"] | None = None
# only appears if `get_events` is called with category
category: EventCategories | None = None
sub_category: str | None = None
# requires 6.0.0+
is_favorite: bool | None = None
favorite_object_ids: list[str] | None = None
_smart_detect_events: list[Event] | None = PrivateAttr(None)
_smart_detect_track: SmartDetectTrack | None = PrivateAttr(None)
_smart_detect_zones: dict[int, CameraZone] | None = PrivateAttr(None)
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"camera": "cameraId",
"heatmap": "heatmapId",
"user": "userId",
"thumbnail": "thumbnailId",
"smartDetectEvents": "smartDetectEventIds",
"device": "deviceId",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return (
dict.fromkeys(
("start", "end", "timestamp", "deletedAt"), convert_to_datetime
)
| {"smartDetectTypes": convert_smart_types}
| super().unifi_dict_conversions()
)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, DELETE_KEYS_EVENT)
return data
@property
def camera(self) -> Camera | None:
if self.camera_id is None:
return None
return self._api.bootstrap.cameras.get(self.camera_id)
@property
def light(self) -> Light | None:
if self.metadata is None or self.metadata.light_id is None:
return None
return self._api.bootstrap.lights.get(self.metadata.light_id)
@property
def sensor(self) -> Sensor | None:
if self.metadata is None or self.metadata.sensor_id is None:
return None
return self._api.bootstrap.sensors.get(self.metadata.sensor_id)
@property
def user(self) -> User | None:
if self.user_id is None:
return None
return self._api.bootstrap.users.get(self.user_id)
@property
def smart_detect_events(self) -> list[Event]:
if self._smart_detect_events is not None:
return self._smart_detect_events
self._smart_detect_events = [
self._api.bootstrap.events[g]
for g in self.smart_detect_event_ids
if g in self._api.bootstrap.events
]
return self._smart_detect_events
def get_detected_thumbnail(self) -> EventDetectedThumbnail | None:
"""
Gets best detected thumbnail for event (UFP 6.x+).
Returns the thumbnail marked with clockBestWall, which indicates
the optimal frame for this detection (highest confidence, best angle, etc.).
Returns:
EventDetectedThumbnail with the best detection frame, or None if:
- Event has no metadata
- No detected thumbnails available
- No thumbnail has clockBestWall set
Example usage:
>>> # License Plate Recognition
>>> thumbnail = event.get_detected_thumbnail()
>>> if thumbnail and thumbnail.group:
... plate = thumbnail.group.matched_name # "ABC123"
... confidence = thumbnail.group.confidence # 95
... if thumbnail.attributes:
... color = thumbnail.attributes.get_value("color") # "white"
... vehicle = thumbnail.attributes.get_value("vehicleType") # "sedan"
>>> # Face Detection
>>> thumbnail = event.get_detected_thumbnail()
>>> if thumbnail and thumbnail.group:
... face_name = thumbnail.group.matched_name # "John Doe"
... confidence = thumbnail.group.confidence # 87
"""
if not self.metadata or not self.metadata.detected_thumbnails:
return None
for thumbnail in self.metadata.detected_thumbnails:
if thumbnail.clock_best_wall:
return thumbnail
return None
async def get_thumbnail(
self,
width: int | None = None,
height: int | None = None,
) -> bytes | None:
"""Gets thumbnail for event"""
if self.thumbnail_id is None:
return None
if not self._api.bootstrap.auth_user.can(
ModelType.CAMERA,
PermissionNode.READ_MEDIA,
self.camera,
):
raise NotAuthorized(
f"Do not have permission to read media for camera: {self.id}",
)
return await self._api.get_event_thumbnail(self.thumbnail_id, width, height)
async def get_animated_thumbnail(
self,
width: int | None = None,
height: int | None = None,
*,
speedup: int = 10,
) -> bytes | None:
"""Gets animated thumbnail for event"""
if self.thumbnail_id is None:
return None
if not self._api.bootstrap.auth_user.can(
ModelType.CAMERA,
PermissionNode.READ_MEDIA,
self.camera,
):
raise NotAuthorized(
f"Do not have permission to read media for camera: {self.id}",
)
return await self._api.get_event_animated_thumbnail(
self.thumbnail_id,
width,
height,
speedup=speedup,
)
async def get_heatmap(self) -> bytes | None:
"""Gets heatmap for event"""
if self.heatmap_id is None:
return None
if not self._api.bootstrap.auth_user.can(
ModelType.CAMERA,
PermissionNode.READ_MEDIA,
self.camera,
):
raise NotAuthorized(
f"Do not have permission to read media for camera: {self.id}",
)
return await self._api.get_event_heatmap(self.heatmap_id)
async def get_video(
self,
channel_index: int = 0,
output_file: Path | None = None,
iterator_callback: IteratorCallback | None = None,
progress_callback: ProgressCallback | None = None,
chunk_size: int = 65536,
) -> bytes | None:
"""
Get the MP4 video clip for this given event
Args:
----
channel_index: index of `CameraChannel` on the camera to use to retrieve video from
Will raise an exception if event does not have a camera, end time or the channel index is wrong.
"""
if self.camera is None:
raise BadRequest("Event does not have a camera")
if self.end is None:
raise BadRequest("Event is ongoing")
if not self._api.bootstrap.auth_user.can(
ModelType.CAMERA,
PermissionNode.READ_MEDIA,
self.camera,
):
raise NotAuthorized(
f"Do not have permission to read media for camera: {self.id}",
)
return await self._api.get_camera_video(
self.camera.id,
self.start,
self.end,
channel_index,
output_file=output_file,
iterator_callback=iterator_callback,
progress_callback=progress_callback,
chunk_size=chunk_size,
)
async def get_smart_detect_track(self) -> SmartDetectTrack:
"""
Gets smart detect track for given smart detect event.
If event is not a smart detect event, it will raise a `BadRequest`
"""
if self.type not in {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE}:
raise BadRequest("Not a smart detect event")
if self._smart_detect_track is None:
self._smart_detect_track = await self._api.get_event_smart_detect_track(
self.id,
)
return self._smart_detect_track
async def get_smart_detect_zones(self) -> dict[int, CameraZone]:
"""Gets the triggering zones for the smart detection"""
if self.camera is None:
raise BadRequest("No camera on event")
if self._smart_detect_zones is None:
smart_track = await self.get_smart_detect_track()
ids: set[int] = set()
for item in smart_track.payload:
ids |= set(item.zone_ids)
self._smart_detect_zones = {
z.id: z for z in self.camera.smart_detect_zones if z.id in ids
}
return self._smart_detect_zones
class PortConfig(ProtectBaseObject):
ump: int
http: int
https: int
rtsp: int
rtsps: int
rtmp: int
devices_wss: int
camera_https: int
live_ws: int
live_wss: int
tcp_streams: int
playback: int
ems_cli: int
ems_live_flv: int
camera_events: int
tcp_bridge: int
ucore: int
discovery_client: int
piongw: int | None = None
ems_json_cli: int | None = None
stacking: int | None = None
# 3.0.22+
ai_feature_console: int | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"emsCLI": "emsCli",
"emsLiveFLV": "emsLiveFlv",
"emsJsonCLI": "emsJsonCli",
}
class CPUInfo(ProtectBaseObject):
average_load: float
temperature: float
class MemoryInfo(ProtectBaseObject):
available: int | None = None
free: int | None = None
total: int | None = None
class StorageDevice(ProtectBaseObject):
model: str | None = None
size: int | None = None
healthy: bool | str | None = None
class StorageInfo(ProtectBaseObject):
available: int
is_recycling: bool
size: int
type: StorageType
used: int
devices: list[StorageDevice]
# requires 2.8.14+
capability: str | None = None
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
if "type" in data:
storage_type = data.pop("type")
try:
data["type"] = StorageType(storage_type)
except ValueError:
_LOGGER.warning("Unknown storage type: %s", storage_type)
data["type"] = StorageType.UNKNOWN
return super().unifi_dict_to_dict(data)
class StorageSpace(ProtectBaseObject):
total: int
used: int
available: int
class TMPFSInfo(ProtectBaseObject):
available: int
total: int
used: int
path: Path
class UOSDisk(ProtectBaseObject):
slot: int
state: str
type: Literal["SSD", "HDD"] | None = None
model: str | None = None
serial: str | None = None
firmware: str | None = None
rpm: int | None = None
ata: str | None = None
sata: str | None = None
action: str | None = None
healthy: str | None = None
reason: list[Any] | None = None
temperature: int | None = None
power_on_hours: int | None = None
life_span: PercentFloat | None = None
bad_sector: int | None = None
threshold: int | None = None
progress: PercentFloat | None = None
estimate: timedelta | None = None
# 2.10.10+
size: int | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"poweronhrs": "powerOnHours",
"life_span": "lifeSpan",
"bad_sector": "badSector",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"estimate": lambda x: timedelta(seconds=x)
} | super().unifi_dict_conversions()
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
# estimate is actually in seconds, not milliseconds
if "estimate" in data and data["estimate"] is not None:
data["estimate"] /= 1000
if "state" in data and data["state"] == "nodisk":
pop_dict_tuple(
data,
(
"action",
"ata",
"bad_sector",
"estimate",
"firmware",
"healthy",
"life_span",
"model",
"poweronhrs",
"progress",
"reason",
"rpm",
"sata",
"serial",
"tempature",
"temperature",
"threshold",
"type",
),
)
return data
@property
def has_disk(self) -> bool:
return self.state != "nodisk"
@property
def is_healthy(self) -> bool:
return self.state in {
"initializing",
"expanding",
"spare",
"normal",
}
class UOSSpace(ProtectBaseObject):
device: str
total_bytes: int
used_bytes: int
action: str
progress: PercentFloat | None = None
estimate: timedelta | None = None
# requires 2.8.14+
health: str | None = None
# requires 2.8.22+
space_type: str | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"total_bytes": "totalBytes",
"used_bytes": "usedBytes",
"space_type": "spaceType",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"estimate": lambda x: timedelta(seconds=x)
} | super().unifi_dict_conversions()
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
# estimate is actually in seconds, not milliseconds
if "estimate" in data and data["estimate"] is not None:
data["estimate"] /= 1000
return data
class UOSStorage(ProtectBaseObject):
disks: list[UOSDisk]
space: list[UOSSpace]
class SystemInfo(ProtectBaseObject):
cpu: CPUInfo
memory: MemoryInfo
storage: StorageInfo
tmpfs: TMPFSInfo
ustorage: UOSStorage | None = None
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
pop_dict_set_if_none(data, {"ustorage"})
return data
class DoorbellMessage(ProtectBaseObject):
type: DoorbellMessageType
text: DoorbellText
class DoorbellSettings(ProtectBaseObject):
default_message_text: DoorbellText
default_message_reset_timeout: timedelta
all_messages: list[DoorbellMessage]
custom_messages: list[DoorbellText]
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"defaultMessageResetTimeoutMs": "defaultMessageResetTimeout",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
# defaultMessageResetTimeoutMs is remapped to defaultMessageResetTimeout
"defaultMessageResetTimeoutMs": lambda x: timedelta(milliseconds=x),
} | super().unifi_dict_conversions()
class RecordingTypeDistribution(ProtectBaseObject):
recording_type: RecordingType
size: int
percentage: float
class ResolutionDistribution(ProtectBaseObject):
resolution: ResolutionStorageType
size: int
percentage: float
class StorageDistribution(ProtectBaseObject):
recording_type_distributions: list[RecordingTypeDistribution]
resolution_distributions: list[ResolutionDistribution]
_recording_type_dict: dict[RecordingType, RecordingTypeDistribution] | None = (
PrivateAttr(None)
)
_resolution_dict: dict[ResolutionStorageType, ResolutionDistribution] | None = (
PrivateAttr(None)
)
def _get_recording_type_dict(
self,
) -> dict[RecordingType, RecordingTypeDistribution]:
if self._recording_type_dict is None:
self._recording_type_dict = {}
for recording_type in self.recording_type_distributions:
self._recording_type_dict[recording_type.recording_type] = (
recording_type
)
return self._recording_type_dict
def _get_resolution_dict(
self,
) -> dict[ResolutionStorageType, ResolutionDistribution]:
if self._resolution_dict is None:
self._resolution_dict = {}
for resolution in self.resolution_distributions:
self._resolution_dict[resolution.resolution] = resolution
return self._resolution_dict
@property
def timelapse_recordings(self) -> RecordingTypeDistribution | None:
return self._get_recording_type_dict().get(RecordingType.TIMELAPSE)
@property
def continuous_recordings(self) -> RecordingTypeDistribution | None:
return self._get_recording_type_dict().get(RecordingType.CONTINUOUS)
@property
def detections_recordings(self) -> RecordingTypeDistribution | None:
return self._get_recording_type_dict().get(RecordingType.DETECTIONS)
@property
def uhd_usage(self) -> ResolutionDistribution | None:
return self._get_resolution_dict().get(ResolutionStorageType.UHD)
@property
def hd_usage(self) -> ResolutionDistribution | None:
return self._get_resolution_dict().get(ResolutionStorageType.HD)
@property
def free(self) -> ResolutionDistribution | None:
return self._get_resolution_dict().get(ResolutionStorageType.FREE)
def update_from_dict(self, data: dict[str, Any]) -> StorageDistribution:
# reset internal look ups when data changes
self._recording_type_dict = None
self._resolution_dict = None
return super().update_from_dict(data)
class StorageStats(ProtectBaseObject):
utilization: float
capacity: timedelta | None = None
remaining_capacity: timedelta | None = None
recording_space: StorageSpace
storage_distribution: StorageDistribution
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"capacity": lambda x: timedelta(milliseconds=x),
"remainingCapacity": lambda x: timedelta(milliseconds=x),
} | super().unifi_dict_conversions()
class NVRFeatureFlags(ProtectBaseObject):
beta: bool
dev: bool
notifications_v2: bool
homekit_paired: bool | None = None
ulp_role_management: bool | None = None
# 2.9.20+
detection_labels: bool | None = None
has_two_way_audio_media_streams: bool | None = None
class NVRSmartDetection(ProtectBaseObject):
enable: bool
face_recognition: bool
license_plate_recognition: bool
class GlobalRecordingSettings(ProtectBaseObject):
osd_settings: OSDSettings
recording_settings: RecordingSettings
smart_detect_settings: SmartDetectSettings
class NVR(ProtectDeviceModel):
can_auto_update: bool
is_stats_gathering_enabled: bool
timezone: tzinfo
version: Version
ucore_version: str
hardware_platform: str
ports: PortConfig
last_update_at: datetime | None = None
is_station: bool
enable_automatic_backups: bool
enable_stats_reporting: bool
release_channel: FirmwareReleaseChannel
hosts: list[IPv4Address | IPv6Address | str]
enable_bridge_auto_adoption: bool
hardware_id: UUID
host_type: int
host_shortname: str
is_hardware: bool
is_wireless_uplink_enabled: bool | None = None
time_format: Literal["12h", "24h"]
temperature_unit: Literal["C", "F"]
recording_retention_duration: timedelta | None = None
enable_crash_reporting: bool
disable_audio: bool
analytics_data: AnalyticsOption
anonymous_device_id: UUID | None = None
camera_utilization: int
is_recycling: bool
disable_auto_link: bool
skip_firmware_update: bool
location_settings: NVRLocation
feature_flags: NVRFeatureFlags
system_info: SystemInfo
doorbell_settings: DoorbellSettings
storage_stats: StorageStats
is_away: bool
is_setup: bool
network: str
max_camera_capacity: dict[Literal["4K", "2K", "HD"], int]
market_name: str | None = None
stream_sharing_available: bool | None = None
is_db_available: bool | None = None
is_insights_enabled: bool | None = None
is_recording_disabled: bool | None = None
is_recording_motion_only: bool | None = None
ui_version: str | None = None
sso_channel: FirmwareReleaseChannel | None = None
is_stacked: bool | None = None
is_primary: bool | None = None
last_drive_slow_event: datetime | None = None
is_u_core_setup: bool | None = None
vault_camera_ids: list[str] = []
# requires 2.8.14+
corruption_state: str | None = None
country_code: str | None = None
has_gateway: bool | None = None
is_vault_registered: bool | None = None
public_ip: IPv4Address | IPv6Address | None = None
ulp_version: str | None = None
wan_ip: IPv4Address | IPv6Address | None = None
# requires 2.9.20+
hard_drive_state: str | None = None
is_network_installed: bool | None = None
is_protect_updatable: bool | None = None
is_ucore_updatable: bool | None = None
# requires 2.11.13+
last_device_fw_updates_checked_at: datetime | None = None
# requires 3.0.22+
smart_detection: NVRSmartDetection | None = None
is_ucore_stacked: bool | None = None
global_camera_settings: GlobalRecordingSettings | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"recordingRetentionDurationMs": "recordingRetentionDuration",
"vaultCameras": "vaultCameraIds",
"lastDeviceFWUpdatesCheckedAt": "lastDeviceFwUpdatesCheckedAt",
"isUCoreStacked": "isUcoreStacked",
}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {
"version",
"uiVersion",
"hardwarePlatform",
"ports",
"lastUpdateAt",
"isStation",
"hosts",
"hostShortname",
"isDbAvailable",
"isRecordingDisabled",
"isRecordingMotionOnly",
"cameraUtilization",
"storageStats",
"isRecycling",
"avgMotions",
"streamSharingAvailable",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"lastUpdateAt": convert_to_datetime,
"lastDeviceFwUpdatesCheckedAt": convert_to_datetime,
"timezone": zoneinfo.ZoneInfo,
# recordingRetentionDurationMs is remapped to recordingRetentionDuration
"recordingRetentionDurationMs": lambda x: timedelta(milliseconds=x),
} | super().unifi_dict_conversions()
async def _api_update(self, data: dict[str, Any]) -> None:
return await self._api.update_nvr(data)
@property
def is_analytics_enabled(self) -> bool:
return self.analytics_data is not AnalyticsOption.NONE
@property
def protect_url(self) -> str:
return f"{self._api.base_url}/protect/devices/{self._api.bootstrap.nvr.id}"
@property
def display_name(self) -> str:
return self.name or self.market_name or self.type
@property
def vault_cameras(self) -> list[Camera]:
"""Vault Cameras for NVR"""
if not self.vault_camera_ids:
return []
return [self._api.bootstrap.cameras[c] for c in self.vault_camera_ids]
@property
def is_global_recording_enabled(self) -> bool:
"""
Is recording footage/events from the camera enabled?
If recording is not enabled, cameras will not produce any footage, thumbnails,
motion/smart detection events.
"""
return (
(global_camera_settings := self.global_camera_settings) is not None
and global_camera_settings.recording_settings.mode
is not RecordingMode.NEVER
)
@property
def is_smart_detections_enabled(self) -> bool:
"""If smart detected enabled globally."""
return (
smart_detection := self.smart_detection
) is not None and smart_detection.enable
@property
def is_license_plate_detections_enabled(self) -> bool:
"""If smart detected enabled globally."""
return (
(smart_detection := self.smart_detection) is not None
and smart_detection.enable
and smart_detection.license_plate_recognition
)
@property
def is_face_detections_enabled(self) -> bool:
"""If smart detected enabled globally."""
return (
(smart_detection := self.smart_detection) is not None
and smart_detection.enable
and smart_detection.face_recognition
)
def update_all_messages(self) -> None:
"""Updates doorbell_settings.all_messages after adding/removing custom message"""
messages = self.doorbell_settings.custom_messages
self.doorbell_settings.all_messages = [
DoorbellMessage(
type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR,
text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "), # type: ignore[arg-type]
),
DoorbellMessage(
type=DoorbellMessageType.DO_NOT_DISTURB,
text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), # type: ignore[arg-type]
),
*(
DoorbellMessage(
type=DoorbellMessageType.CUSTOM_MESSAGE,
text=message,
)
for message in messages
),
]
async def set_insights(self, enabled: bool) -> None:
"""Sets analytics collection for NVR"""
def callback() -> None:
self.is_insights_enabled = enabled
await self.queue_update(callback)
async def set_analytics(self, value: AnalyticsOption) -> None:
"""Sets analytics collection for NVR"""
def callback() -> None:
self.analytics_data = value
await self.queue_update(callback)
async def set_anonymous_analytics(self, enabled: bool) -> None:
"""Enables or disables anonymous analytics for NVR"""
if enabled:
await self.set_analytics(AnalyticsOption.ANONYMOUS)
else:
await self.set_analytics(AnalyticsOption.NONE)
async def set_default_reset_timeout(self, timeout: timedelta) -> None:
"""Sets the default message reset timeout"""
def callback() -> None:
self.doorbell_settings.default_message_reset_timeout = timeout
await self.queue_update(callback)
async def set_default_doorbell_message(self, message: str) -> None:
"""Sets default doorbell message"""
def callback() -> None:
self.doorbell_settings.default_message_text = DoorbellText(message)
await self.queue_update(callback)
async def add_custom_doorbell_message(self, message: str) -> None:
"""Adds custom doorbell message"""
if len(message) > 30:
raise BadRequest("Message length over 30 characters")
if message in self.doorbell_settings.custom_messages:
raise BadRequest("Custom doorbell message already exists")
await self._update_doorbell_messages(
lambda: self.doorbell_settings.custom_messages.append(
DoorbellText(message)
),
)
async def remove_custom_doorbell_message(self, message: str) -> None:
"""Removes custom doorbell message"""
if message not in self.doorbell_settings.custom_messages:
raise BadRequest("Custom doorbell message does not exists")
await self._update_doorbell_messages(
lambda: self.doorbell_settings.custom_messages.remove(
DoorbellText(message)
),
)
async def _update_doorbell_messages(
self, update_callback: Callable[[], None]
) -> None:
"""Updates doorbell messages and saves to Protect."""
async with self._update_sync.lock:
# yield to the event loop once we have the lock to ensure websocket updates are processed
await asyncio.sleep(0)
data_before_changes = self.dict_with_excludes()
update_callback()
await self.save_device(data_before_changes)
self.update_all_messages()
async def reboot(self) -> None:
"""Reboots the NVR"""
await self._api.reboot_nvr()
async def _read_cache_file(self, file_path: Path) -> set[Version] | None:
versions: set[Version] | None = None
try:
_LOGGER.debug("Reading release cache file: %s", file_path)
async with aiofiles.open(file_path, "rb") as cache_file:
versions = {Version(v) for v in orjson.loads(await cache_file.read())}
except FileNotFoundError:
# ignore missing file
pass
except Exception:
_LOGGER.warning("Failed to parse cache file: %s", file_path)
return versions
async def get_is_prerelease(self) -> bool:
"""[DEPRECATED] Always returns False. Will be removed after HA 2025.8.0."""
return False
async def set_smart_detections(self, value: bool) -> None:
"""Set if smart detections are enabled."""
def callback() -> None:
if self.smart_detection is not None:
self.smart_detection.enable = value
await self.queue_update(callback)
async def set_face_recognition(self, value: bool) -> None:
"""Set if face detections are enabled. Requires smart detections to be enabled."""
if self.smart_detection is None or not self.smart_detection.enable:
raise BadRequest("Smart detections are not enabled.")
def callback() -> None:
if self.smart_detection is not None:
self.smart_detection.face_recognition = value
await self.queue_update(callback)
async def set_license_plate_recognition(self, value: bool) -> None:
"""Set if license plate detections are enabled. Requires smart detections to be enabled."""
if self.smart_detection is None or not self.smart_detection.enable:
raise BadRequest("Smart detections are not enabled.")
def callback() -> None:
if self.smart_detection is not None:
self.smart_detection.license_plate_recognition = value
await self.queue_update(callback)
async def set_global_osd_name(self, enabled: bool) -> None:
"""Sets whether camera name is in the On Screen Display"""
def callback() -> None:
if self.global_camera_settings:
self.global_camera_settings.osd_settings.is_name_enabled = enabled
await self.queue_update(callback)
async def set_global_osd_date(self, enabled: bool) -> None:
"""Sets whether current date is in the On Screen Display"""
def callback() -> None:
if self.global_camera_settings:
self.global_camera_settings.osd_settings.is_date_enabled = enabled
await self.queue_update(callback)
async def set_global_osd_logo(self, enabled: bool) -> None:
"""Sets whether the UniFi logo is in the On Screen Display"""
def callback() -> None:
if self.global_camera_settings:
self.global_camera_settings.osd_settings.is_logo_enabled = enabled
await self.queue_update(callback)
async def set_global_osd_bitrate(self, enabled: bool) -> None:
"""Sets whether camera bitrate is in the On Screen Display"""
def callback() -> None:
# mismatch between UI internal data structure debug = bitrate data
if self.global_camera_settings:
self.global_camera_settings.osd_settings.is_debug_enabled = enabled
await self.queue_update(callback)
async def set_global_motion_detection(self, enabled: bool) -> None:
"""Sets motion detection on camera"""
def callback() -> None:
if self.global_camera_settings:
self.global_camera_settings.recording_settings.enable_motion_detection = enabled
await self.queue_update(callback)
async def set_global_recording_mode(self, mode: RecordingMode) -> None:
"""Sets recording mode on camera"""
def callback() -> None:
if self.global_camera_settings:
self.global_camera_settings.recording_settings.mode = mode
await self.queue_update(callback)
# object smart detections
def _is_smart_enabled(self, smart_type: SmartDetectObjectType) -> bool:
return (
self.is_global_recording_enabled
and (global_camera_settings := self.global_camera_settings) is not None
and smart_type in global_camera_settings.smart_detect_settings.object_types
)
@property
def is_global_person_detection_on(self) -> bool:
"""
Is Person Detection available and enabled (camera will produce person smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.PERSON)
@property
def is_global_person_tracking_enabled(self) -> bool:
"""Is person tracking enabled"""
return (
(global_camera_settings := self.global_camera_settings) is not None
and (
auto_tracking_object_types
:= global_camera_settings.smart_detect_settings.auto_tracking_object_types
)
is not None
and SmartDetectObjectType.PERSON in auto_tracking_object_types
)
@property
def is_global_vehicle_detection_on(self) -> bool:
"""
Is Vehicle Detection available and enabled (camera will produce vehicle smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.VEHICLE)
@property
def is_global_face_detection_on(self) -> bool:
"""Is Face Detection available and enabled?"""
return self._is_smart_enabled(SmartDetectObjectType.FACE)
@property
def is_global_license_plate_detection_on(self) -> bool:
"""
Is License Plate Detection available and enabled (camera will produce face license
plate detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.LICENSE_PLATE)
@property
def is_global_package_detection_on(self) -> bool:
"""
Is Package Detection available and enabled (camera will produce package smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.PACKAGE)
@property
def is_global_animal_detection_on(self) -> bool:
"""
Is Animal Detection available and enabled (camera will produce package smart
detection events)?
"""
return self._is_smart_enabled(SmartDetectObjectType.ANIMAL)
def _is_audio_enabled(self, smart_type: SmartDetectObjectType) -> bool:
return (
(audio_type := smart_type.audio_type) is not None
and self.is_global_recording_enabled
and (global_camera_settings := self.global_camera_settings) is not None
and (
audio_types := global_camera_settings.smart_detect_settings.audio_types
)
is not None
and audio_type in audio_types
)
@property
def is_global_smoke_detection_on(self) -> bool:
"""
Is Smoke Alarm Detection available and enabled (camera will produce smoke
smart detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.SMOKE)
@property
def is_global_co_detection_on(self) -> bool:
"""
Is CO Alarm Detection available and enabled (camera will produce smoke smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.CMONX)
@property
def is_global_siren_detection_on(self) -> bool:
"""
Is Siren Detection available and enabled (camera will produce siren smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.SIREN)
@property
def is_global_baby_cry_detection_on(self) -> bool:
"""
Is Baby Cry Detection available and enabled (camera will produce baby cry smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.BABY_CRY)
@property
def is_global_speaking_detection_on(self) -> bool:
"""
Is Speaking Detection available and enabled (camera will produce speaking smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.SPEAK)
@property
def is_global_bark_detection_on(self) -> bool:
"""
Is Bark Detection available and enabled (camera will produce barking smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.BARK)
@property
def is_global_car_alarm_detection_on(self) -> bool:
"""
Is Car Alarm Detection available and enabled (camera will produce car alarm smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.BURGLAR)
@property
def is_global_car_horn_detection_on(self) -> bool:
"""
Is Car Horn Detection available and enabled (camera will produce car horn smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.CAR_HORN)
@property
def is_global_glass_break_detection_on(self) -> bool:
"""
Is Glass Break available and enabled (camera will produce glass break smart
detection events)?
"""
return self._is_audio_enabled(SmartDetectObjectType.GLASS_BREAK)
class LiveviewSlot(ProtectBaseObject):
camera_ids: list[str]
cycle_mode: str
cycle_interval: int
_cameras: list[Camera] | None = PrivateAttr(None)
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "cameras": "cameraIds"}
@property
def cameras(self) -> list[Camera]:
if self._cameras is not None:
return self._cameras
# user may not have permission to see the cameras in the liveview
self._cameras = [
self._api.bootstrap.cameras[g]
for g in self.camera_ids
if g in self._api.bootstrap.cameras
]
return self._cameras
class Liveview(ProtectModelWithId):
name: str
is_default: bool
is_global: bool
layout: int
slots: list[LiveviewSlot]
owner_id: str
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "owner": "ownerId"}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {"isDefault", "owner"}
@property
def owner(self) -> User | None:
"""
Owner of liveview.
Will be none if the user only has read only access and it was not made by their user.
"""
return self._api.bootstrap.users.get(self.owner_id)
@property
def protect_url(self) -> str:
return f"{self._api.base_url}/protect/liveview/{self.id}"
uilibs-uiprotect-317a52c/src/uiprotect/data/public_bootstrap.py 0000664 0000000 0000000 00000115164 15246377057 0025037 0 ustar 00root root 0000000 0000000 """
UniFi Protect Public Integration API bootstrap.
This is an opt-in, separate cache for the Public Integration API. It is
*not* populated by :meth:`ProtectApiClient.update` and does not
touch the private :class:`~uiprotect.data.bootstrap.Bootstrap` in any way.
Prime/subscribe ordering is a **library guarantee**, not a caller obligation:
``update_public()`` buffers any public WS frame that arrives while it is
priming and replays it onto the fresh snapshot before returning, so a
subscriber that is already connected never loses an update to the prime
window. Consumers may subscribe and prime in either order:
* **Typed** (``subscribe_devices`` / ``subscribe_events``): deliver merged
public models, so they require a primed cache and raise ``RuntimeError`` if
it is missing. Use ``subscribe_devices_and_prime`` /
``subscribe_events_and_prime`` to subscribe and prime in one call — these
connect the websocket *before* priming so the buffer catches every frame,
making the prime-then-subscribe question moot.
* **Raw** (``subscribe_devices_websocket`` / ``subscribe_events_websocket``):
subscribe in any order relative to ``update_public()``. Frames that land
while priming are held and replayed once the snapshot is applied, so they
reach subscribers with a merged ``new_obj`` instead of being dropped.
WS messages that arrive *after* priming are merged into the fresh cache
normally. An ``update`` for an object still not in the cache after a replay
is dropped — the cache catches up on the next ``update_public()`` / reconnect
refresh.
WS messages carry partial diffs for ``update`` actions; the cache therefore
*merges* updates into the existing in-memory object instead of reconstructing
it from the partial payload. Only ``add`` messages are treated as full
payloads.
"""
from __future__ import annotations
import logging
from collections import OrderedDict
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from .base import ProtectModelWithId
from .convert import create_from_unifi_dict
from .public_devices import (
ArmProfile,
Fob,
LinkStation,
NvrArmMode,
PublicBridge,
PublicCamera,
PublicChime,
PublicLight,
PublicLiveview,
PublicNVR,
PublicSensor,
PublicUlpUser,
PublicViewer,
Relay,
Siren,
Speaker,
)
from .public_event import PublicEvent
from .types import DeviceState, ModelType
from .websocket import WSAction, WSSubscriptionMessage
if TYPE_CHECKING:
from ..api import ProtectApiClient
_LOGGER = logging.getLogger(__name__)
class EventsFrameResult(NamedTuple):
"""Outputs of applying one public events-WS frame to the cache."""
new_event: PublicEvent | None
old_event: PublicEvent | None
model_updates: list[WSSubscriptionMessage]
# Default cap on the public event cache. Matches the private Bootstrap
# default so memory behaviour is symmetric between the two caches.
DEFAULT_PUBLIC_EVENT_CACHE_SIZE = 1000
# Fields a lone ``update`` event frame must carry (all non-``None``) before it
# can be promoted to a synthesized ``add`` — only *completed* events whose
# payload is self-sufficient qualify, so no dangling open event is ever created.
_BORN_CLOSED_REQUIRED_FIELDS = ("id", "type", "start", "end", "device")
# Single source of truth: ``ModelType`` -> (store attribute, public model
# class | None). Only the device types the Public Integration API actually
# exposes are listed; unsupported keys (``doorlock`` etc.) resolve to ``None``
# and are logged at DEBUG in :meth:`process_devices_ws_message`.
#
# ``public_cls`` is set for the four types whose ``ModelType`` collides with a
# private ``MODEL_TO_CLASS`` entry (``camera``/``light``/``sensor``/``chime``):
# the generic ``create_from_unifi_dict`` path would build the private model
# (reintroducing phantom fields), so they get a dedicated public-API factory
# slot via :meth:`_public_device_slot`. ``None`` means the generic path builds
# the correct class. Every store here carries a ``mac`` field, so
# :meth:`get_device_mac` iterates this registry directly.
_PUBLIC_STORES: dict[ModelType, tuple[str, type[ProtectModelWithId] | None]] = {
ModelType.CAMERA: ("cameras", PublicCamera),
ModelType.LIGHT: ("lights", PublicLight),
ModelType.SENSOR: ("sensors", PublicSensor),
ModelType.CHIME: ("chimes", PublicChime),
ModelType.SIREN: ("sirens", None),
ModelType.RELAY: ("relays", None),
ModelType.FOB: ("fobs", None),
ModelType.SPEAKER: ("speakers", None),
ModelType.LINK_STATION: ("link_stations", None),
}
# Collision-routed types that keep a dedicated ``_X_slot()`` factory (their
# ``ModelType`` is owned by a private class in ``MODEL_TO_CLASS``). They carry
# no ``mac`` and so are excluded from :data:`_PUBLIC_STORES`.
_DEDICATED_SLOT_STORE_ATTRS: dict[ModelType, str] = {
ModelType.LIVEVIEW: "liveviews",
ModelType.BRIDGE: "bridges",
ModelType.VIEWPORT: "viewers",
}
@dataclass(frozen=True, slots=True)
class DeviceWSResult:
"""One expanded per-device outcome of a public devices WS frame."""
model_type: ModelType | None
new_obj: ProtectModelWithId | None
old_obj: ProtectModelWithId | None
# Per-id raw payload (``id`` is always scalar here, even for bulk frames).
item: dict[str, Any]
@dataclass(frozen=True, slots=True)
class FetchDiff:
"""Membership change a single :meth:`PublicBootstrap.apply_fetch_result` made."""
# Attribute name of the store the diff applies to.
attr: str
added_ids: list[str]
removed: list[ProtectModelWithId]
@dataclass
class PublicBootstrap:
"""
In-memory cache of the Public Integration API resources.
All device dicts map ``id`` -> object. Empty by default; populated by
:meth:`ProtectApiClient.update_public`.
**Staleness on websocket reconnect.** While the devices/events websocket
is connected, incoming ``add``/``update``/``remove`` messages are merged
into the cache. If the websocket drops and reconnects, any messages
emitted during the gap are lost. :class:`ProtectApiClient` automatically
schedules an :meth:`~ProtectApiClient.update_public` refresh on
*reconnect* (the initial connect does not trigger a refresh), and
announces the devices that refresh added or removed as synthetic
devices-WS ``add``/``remove`` frames.
"""
# NVR metadata (populated by ``update_public``). Useful for HA's
# ``DeviceInfo``. Merged in place by :meth:`process_devices_ws_message`
# when an ``nvr`` update arrives.
nvr: PublicNVR | None = None
# Device stores (indexed by id). Only the device classes that the
# Public Integration API actually exposes are kept here.
#
# ``cameras``/``lights``/``sensors``/``chimes`` hold dedicated ``Public*``
# models — they share a ``ModelType`` with the private classes, so they are
# routed via factory slots (see :meth:`_custom_slot_for`), not the generic
# ``MODEL_TO_CLASS`` path.
cameras: dict[str, PublicCamera] = field(default_factory=dict)
lights: dict[str, PublicLight] = field(default_factory=dict)
sensors: dict[str, PublicSensor] = field(default_factory=dict)
chimes: dict[str, PublicChime] = field(default_factory=dict)
sirens: dict[str, Siren] = field(default_factory=dict)
relays: dict[str, Relay] = field(default_factory=dict)
fobs: dict[str, Fob] = field(default_factory=dict)
speakers: dict[str, Speaker] = field(default_factory=dict)
# Link stations *and* alarm hubs share one schema (``modelKey: "linkstation"``),
# so a single store catches WS frames for both. Use :attr:`alarm_hubs` for the
# filtered view.
link_stations: dict[str, LinkStation] = field(default_factory=dict)
# ``liveviews``, ``bridges`` and ``viewers`` are deliberately kept out of
# :data:`_DEVICE_STORES` because their ``ModelType`` values
# (``LIVEVIEW`` / ``BRIDGE`` / ``VIEWPORT``) are already owned by the
# private ``Liveview`` / ``Bridge`` / ``Viewer`` classes in
# ``MODEL_TO_CLASS``. The WS handler routes each to its own store via an
# explicit branch with a public-API-aware factory.
liveviews: dict[str, PublicLiveview] = field(default_factory=dict)
bridges: dict[str, PublicBridge] = field(default_factory=dict)
viewers: dict[str, PublicViewer] = field(default_factory=dict)
# Events received via the events websocket (:meth:`process_events_ws_message`).
# Bounded by :attr:`max_event_cache_size`; oldest events are evicted first.
# Entries may be synthetically end-marked (``end`` set) by the public
# events TTL/reconnect sweep, not only by a server-sent close frame — so a
# non-``None`` ``end`` here does not always correspond to a WS payload.
events: OrderedDict[str, PublicEvent] = field(default_factory=OrderedDict)
max_event_cache_size: int = DEFAULT_PUBLIC_EVENT_CACHE_SIZE
# UniFi Identity (ULP) users, indexed by ulp id. Single source of truth
# for event-identity enrichment; refreshed by ``update_public`` (and so,
# via the reconnect resync, refreshed on reconnect too). Empty when the
# Identity service is disabled.
ulp_users: dict[str, PublicUlpUser] = field(default_factory=dict)
# Arm manager state.
arm_profiles: dict[str, ArmProfile] = field(default_factory=dict)
# Per-instance one-shot warning dedupe for merge/add failures.
# This must not be module-global because callers can have multiple
# Protect servers in one process.
_warned_merge_failures: set[tuple[str, str]] = field(
default_factory=set,
init=False,
repr=False,
)
# Per-camera detection-state snapshots taken before the first mutation of an
# events-WS frame, keyed by camera id. Populated while a single
# :meth:`process_events_ws_message` call applies its frame (including any
# eviction it triggers) and drained by :meth:`_drain_detection_updates`.
# Transient within one synchronous call, so per-instance is sufficient.
_detection_state_before: dict[str, dict[str, bool]] = field(
default_factory=dict,
init=False,
repr=False,
)
@property
def alarm_hubs(self) -> dict[str, LinkStation]:
"""Subset of :attr:`link_stations` filtered to alarm hubs."""
return {k: v for k, v in self.link_stations.items() if v.is_alarm_hub}
@property
def arm_mode(self) -> NvrArmMode | None:
"""
Current arm-manager state. Shortcut for ``nvr.arm_mode``.
Returns ``None`` when the NVR cache hasn't been primed yet, when the
firmware does not expose the alarm manager, or when the alarm
manager is set to global.
"""
return self.nvr.arm_mode if self.nvr is not None else None
def __post_init__(self) -> None:
"""Validate cache bounds used by event eviction logic."""
if self.max_event_cache_size < 0:
raise ValueError("max_event_cache_size must be >= 0")
def store_for(self, model_type: ModelType) -> dict[str, ProtectModelWithId] | None:
"""
Return the live cache store for a model type, or ``None`` if unsupported.
The returned dict is the cache itself, keyed by device id — not a copy.
Both device registries are covered, so the collision-routed
liveview/bridge/viewer slots resolve here too.
"""
store = _PUBLIC_STORES.get(model_type)
if store is not None:
attr: str | None = store[0]
else:
attr = _DEDICATED_SLOT_STORE_ATTRS.get(model_type)
if attr is None:
return None
return cast("dict[str, ProtectModelWithId]", getattr(self, attr))
def get(self, model_type: ModelType, obj_id: str) -> ProtectModelWithId | None:
"""Look up a cached device by model type and id."""
store = self.store_for(model_type)
if store is None:
return None
return store.get(obj_id)
def get_device_mac(self, device_id: str) -> str | None:
"""Resolve a device id to its mac across the public device stores."""
for store_attr, _cls in _PUBLIC_STORES.values():
obj = getattr(self, store_attr).get(device_id)
if obj is not None:
return getattr(obj, "mac", None)
return None
def all_devices(self, *, include_nvr: bool = False) -> Iterator[ProtectModelWithId]:
"""
Iterate every cached device across all model types.
Walks both device registries (:data:`_PUBLIC_STORES` and
:data:`_DEDICATED_SLOT_STORE_ATTRS`), so new public device families are
covered automatically as they join those registries. Liveviews are
skipped — a :class:`PublicLiveview` is a saved-view configuration, not a
physical device, and carries no ``mac``. The NVR lives in a dedicated
single-object slot and is excluded unless ``include_nvr`` is set, in
which case it is yielded first.
"""
if include_nvr and self.nvr is not None:
yield self.nvr
for store_attr, _cls in _PUBLIC_STORES.values():
yield from getattr(self, store_attr).values()
for model_type, store_attr in _DEDICATED_SLOT_STORE_ATTRS.items():
if model_type is ModelType.LIVEVIEW:
continue
yield from getattr(self, store_attr).values()
def apply_fetch_result(
self, attr: str, objs: list[ProtectModelWithId]
) -> FetchDiff:
"""
Merge fetched objects into ``self.`` without wholesale replace.
Used by :meth:`ProtectApiClient.update_public` so that ``add``/
``update``/``remove`` WS messages arriving concurrently with the
HTTP fetch are not clobbered. IDs present in ``objs`` overwrite
the cache entry; IDs absent from ``objs`` but present in the
cache are removed.
The returned :class:`FetchDiff` reports which ids the merge added and
which objects it dropped, so the caller can announce them through the
devices websocket once the whole fetch has been applied.
"""
store = cast("dict[str, ProtectModelWithId]", getattr(self, attr))
fetched_ids = {obj.id for obj in objs}
# Remove objects no longer reported by the API.
removed = [
store.pop(stale) for stale in [k for k in store if k not in fetched_ids]
]
added_ids = [obj.id for obj in objs if obj.id not in store]
# Upsert — newer fetched payload wins over in-place WS merges. This
# is the intended semantic because `update_public` is the ground
# truth at the moment it returns.
for obj in objs:
store[obj.id] = obj
return FetchDiff(attr, added_ids, removed)
def supports_device(self, model_type: ModelType) -> bool:
"""Return whether ``model_type`` maps to a public device store."""
return model_type in _PUBLIC_STORES or model_type in _DEDICATED_SLOT_STORE_ATTRS
def process_devices_ws_messages(
self,
api: ProtectApiClient,
data: dict[str, Any],
) -> list[DeviceWSResult]:
"""
Apply a public *devices* WS frame to the cache, expanding bulk forms.
The bulk envelopes (``devicesAdd`` / ``devicesBulkUpdate`` /
``devicesBulkRemove``) share a single ``item`` whose ``id`` is a list
of ids with one shared ``modelKey`` and payload. This fans that out to
one :class:`DeviceWSResult` per id (each carrying a scalar-``id``
``item``); a single-id frame yields a one-element list. An empty
``id`` array yields an empty list.
"""
action_type, item, _obj_id = _parse_ws_envelope(data)
if action_type is None:
return []
raw_id = item.get("id")
if isinstance(raw_id, list):
expanded = [{**item, "id": one_id} for one_id in raw_id]
else:
expanded = [item]
return [self._apply_one(api, action_type, one) for one in expanded]
def process_devices_ws_message(
self,
api: ProtectApiClient,
data: dict[str, Any],
) -> tuple[ModelType | None, ProtectModelWithId | None, ProtectModelWithId | None]:
"""
Apply a public *devices* WS payload to the cache.
Back-compat façade over :meth:`process_devices_ws_messages`: returns
the first expanded result's ``(model_type, new_obj, old_obj)`` 3-tuple,
or ``(None, None, None)`` when nothing was produced. Callers that need
per-device frames for bulk envelopes should use the plural form.
``update`` messages carry **partial diffs** — only the changed
fields. They are merged into the existing cached object via
:meth:`ProtectBaseObject.update_from_dict`; reconstructing from a
partial payload would fail strict validation for required fields.
"""
results = self.process_devices_ws_messages(api, data)
if not results:
return None, None, None
first = results[0]
return first.model_type, first.new_obj, first.old_obj
def _apply_one(
self,
api: ProtectApiClient,
action_type: str,
item: dict[str, Any],
) -> DeviceWSResult:
"""Route a single scalar-``id`` device item to its cache slot."""
model_key = item["modelKey"]
model_type = ModelType.from_string(model_key)
# NVR is cached in a dedicated single-object slot; collision-routed
# types (Liveview / Bridge / Viewer share their ``ModelType`` with the
# private ``MODEL_TO_CLASS`` entries) get their own factory-equipped
# slot. Everything else routes through the generic ``store_for`` dict.
# Snapshot the pre-apply camera state: ``update`` merges in place, so
# the ``old`` returned by ``_apply_action`` is the same (now mutated)
# instance as ``new`` and can't be compared against it afterwards.
prev_state = self._camera_state_before_apply(model_type, item)
custom_slot = self._custom_slot_for(model_type)
if custom_slot is not None:
new, old = self._apply_action(
api, action_type, item, model_type, custom_slot
)
self._refresh_rtsps_on_reconnect(api, model_type, new, prev_state)
self._evict_rtsps_on_remove(api, model_type, action_type, item)
return DeviceWSResult(model_type, new, old, item)
store = self.store_for(model_type)
if store is None:
_LOGGER.debug(
"Public WS message for unsupported model %s ignored", model_key
)
return DeviceWSResult(model_type, None, None, item)
new, old = self._apply_action(
api, action_type, item, model_type, _dict_slot(store)
)
self._track_siren_expiry(api, model_type, action_type, item)
return DeviceWSResult(model_type, new, old, item)
def _camera_state_before_apply(
self, model_type: ModelType, item: dict[str, Any]
) -> DeviceState | None:
"""Return the cached camera's ``state`` prior to applying this frame."""
if model_type is not ModelType.CAMERA:
return None
cached = self.cameras.get(item["id"])
return cached.state if cached is not None else None
def _refresh_rtsps_on_reconnect(
self,
api: ProtectApiClient,
model_type: ModelType,
new: ProtectModelWithId | None,
prev_state: DeviceState | None,
) -> None:
"""
Schedule an RTSPS prime/refresh when a camera transitions to CONNECTED.
A camera that was offline at ``update_public()`` time carries no streams;
the CONNECTED transition primes it. An already-populated one is refreshed
in place.
"""
if model_type is not ModelType.CAMERA or new is None:
return
if (
getattr(new, "state", None) is DeviceState.CONNECTED
and prev_state is not DeviceState.CONNECTED
):
api._schedule_rtsps_refresh(new.id)
def _evict_rtsps_on_remove(
self,
api: ProtectApiClient,
model_type: ModelType,
action_type: str,
item: dict[str, Any],
) -> None:
"""
Cancel any in-flight RTSPS refresh for a removed camera.
A ``remove`` frame deletes the camera from the device store, taking its
``rtsps_streams`` field with it. Any in-flight background refresh for the
camera is cancelled so it cannot resurrect a now-removed camera.
"""
if model_type is ModelType.CAMERA and action_type == "remove":
api._cancel_rtsps_refresh(item["id"])
def _track_siren_expiry(
self,
api: ProtectApiClient,
model_type: ModelType,
action_type: str,
item: dict[str, Any],
) -> None:
"""
Keep a siren's timed-run expiry announcement in step with its status.
The console never reports the end of a timed run, so every siren frame
re-derives the deadline from the merged status; a removed siren drops
its timer with the device.
"""
if model_type is not ModelType.SIREN:
return
if action_type == "remove":
api._cancel_siren_off(item["id"])
else:
api._schedule_siren_off(item["id"])
def _custom_slot_for(self, model_type: ModelType) -> _Slot | None:
"""Return the dedicated public-API slot for collision-routed types."""
if model_type is ModelType.NVR:
return self._nvr_slot()
if model_type is ModelType.LIVEVIEW:
return self._liveviews_slot()
if model_type is ModelType.BRIDGE:
return self._bridges_slot()
if model_type is ModelType.VIEWPORT:
return self._viewers_slot()
store = _PUBLIC_STORES.get(model_type)
if store is not None:
store_attr, cls = store
if cls is not None:
return self._public_device_slot(getattr(self, store_attr), cls)
return None
@staticmethod
def _public_device_slot(
store: dict[str, Any],
cls: type[ProtectModelWithId],
) -> _Slot:
"""Return a slot around a public device store with a public-API factory."""
def _factory(item: dict[str, Any], api: ProtectApiClient) -> ProtectModelWithId:
return cls.from_unifi_dict(api=api, **item)
return _dict_slot(
cast("dict[str, ProtectModelWithId]", store),
factory=_factory,
)
def process_events_ws_message(
self,
api: ProtectApiClient,
data: dict[str, Any],
) -> EventsFrameResult:
"""
Apply a public *events* WS payload to the event cache.
Returns an :class:`EventsFrameResult` (a tuple-compatible
``NamedTuple`` of ``new_event``, ``old_event``, ``model_updates``).
``new_event`` /
``old_event`` may be ``None``; ``old_event`` is a **pre-merge snapshot**
of the cached event taken before this frame is applied — ``update``
merges its partial diff into the cached object *in place*, so without
the snapshot ``old`` and ``new`` would be the same mutated instance.
The snapshot lets a consumer detect the open→closed transition
(``old.end is None`` and ``new.end is not None``) and reject retransmits
idempotently.
``model_updates`` are devices-WS ``update`` messages for any camera
whose derived detection booleans net-flipped over this frame — the
first-class device-model outputs of processing the events frame. The
api handler emits them through the standard devices channel with no
knowledge that they originated from a detection event.
"""
action_type, item, _obj_id = _parse_ws_envelope(data)
if action_type is None or item.get("modelKey") != ModelType.EVENT.value:
return EventsFrameResult(None, None, [])
obj_id = item.get("id")
cached = self.events.get(obj_id) if obj_id else None
# Intentionally a shallow copy: the idempotency chokepoint only reads
# the top-level ``end`` (captured pre-merge here). Nested objects are
# shared, but a deep copy on every event frame is avoided on this hot
# path.
old_snapshot = cached.model_copy() if cached is not None else None
self._detection_state_before.clear()
new, _old = self._apply_action(
api, action_type, item, ModelType.EVENT, self._events_slot()
)
return EventsFrameResult(
cast("PublicEvent | None", new),
old_snapshot,
self._drain_detection_updates(),
)
def _drain_detection_updates(self) -> list[WSSubscriptionMessage]:
"""
Build devices-WS ``update`` messages for cameras whose detection flipped.
Diffs each camera's pre-frame snapshot against its current derived
booleans, yielding one ``update`` (``new_obj=camera``, ``changed_data``
naming only the flipped fields) per camera whose flags actually changed.
Clears the pending snapshots, so a second call returns an empty list.
"""
updates: list[WSSubscriptionMessage] = []
for camera_id, before in self._detection_state_before.items():
camera = self.cameras.get(camera_id)
if camera is None:
continue
after = camera._detection_state()
changed = {k: v for k, v in after.items() if v != before[k]}
if changed:
updates.append(
WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=camera.id,
changed_data=cast("dict[str, Any]", changed),
new_obj=camera,
old_obj=None,
)
)
self._detection_state_before.clear()
return updates
def _snapshot_camera_detection_state(self, camera: PublicCamera) -> None:
"""Record a camera's pre-mutation detection snapshot once per events frame."""
if camera.id not in self._detection_state_before:
self._detection_state_before[camera.id] = camera._detection_state()
def _sync_camera_detection_state(self, event: PublicEvent) -> None:
"""Fold a cached event into its owning camera's active detection set."""
if event.device_id is None:
return
camera = self.cameras.get(event.device_id)
if camera is not None:
self._snapshot_camera_detection_state(camera)
camera._apply_detection_event(event)
def _clear_camera_detection_event(self, event: PublicEvent) -> None:
"""Drop an evicted/removed event from its owning camera's active set."""
if event.device_id is None:
return
camera = self.cameras.get(event.device_id)
if camera is not None:
self._snapshot_camera_detection_state(camera)
camera._clear_detection_event(event.id)
def _sync_force_ended_events(
self, events: list[PublicEvent]
) -> list[WSSubscriptionMessage]:
"""
Clear force-ended events from their cameras' active detection sets.
Returns one devices-WS ``update`` per camera whose derived detection
flags flipped, so a sweep that force-ends events out-of-band (TTL /
reconnect) keeps the camera model and its subscribers in sync through
the same choke point the normal events path uses.
"""
self._detection_state_before.clear()
for event in events:
self._clear_camera_detection_event(event)
return self._drain_detection_updates()
def _apply_action(
self,
api: ProtectApiClient,
action_type: str,
item: dict[str, Any],
model_type: ModelType,
slot: _Slot,
) -> tuple[ProtectModelWithId | None, ProtectModelWithId | None]:
"""
Apply ``add`` / ``update`` / ``remove`` to a cache slot.
``slot`` abstracts over the storage shape (single-object NVR vs.
id-keyed dict) so all three handlers share one code path.
Return value semantics ``(new, old)``:
* ``add`` success → ``(new, old)`` (``old`` is whatever was cached).
* ``add`` failure → ``(None, old)`` — cache untouched.
* ``remove`` → ``(None, old)`` — ``old`` removed from cache.
* ``update`` on unknown id → ``(None, None)`` — cache untouched.
* ``update`` merge success → ``(merged, old)``.
* ``update`` merge failure → ``(None, old)`` — cache still contains
``old``; subscribers should not treat this as a delete.
"""
obj_id = item["id"]
old = slot.get(obj_id)
if action_type == "remove":
if old is not None:
slot.delete(obj_id)
return None, old
if action_type == "add":
try:
if slot.factory is not None:
new = slot.factory(item, api)
else:
new = cast(
"ProtectModelWithId",
create_from_unifi_dict(item, api=api, model_type=model_type),
)
except Exception as err:
_warn_once(
self._warned_merge_failures,
("add", model_type.value),
"Could not create %s from public API add payload: %s",
model_type.value,
err,
)
return None, old
slot.put(obj_id, new)
return new, old
if action_type == "update":
if old is None:
return self._apply_unknown_update(api, item, model_type, slot, obj_id)
merged = _merge(old, item, self._warned_merge_failures)
if merged is not None:
slot.put(obj_id, merged)
return merged, old
return None, old
def _apply_unknown_update(
self,
api: ProtectApiClient,
item: dict[str, Any],
model_type: ModelType,
slot: _Slot,
obj_id: str,
) -> tuple[ProtectModelWithId | None, ProtectModelWithId | None]:
"""
Handle an ``update`` whose id is not in the cache.
Born-closed event: a lone ``update`` for an unseen event id whose
payload is self-sufficient and already closed (package smart-detect
arrives this way — one closed frame, no preceding ``add``) is promoted
to an ``add`` so the dispatcher's close-window logic can emit STARTED +
ENDED. Returns ``(new, None)`` on promotion.
Otherwise drops: returns ``(None, None)`` — distinct from a merge
failure on a *known* id, which returns ``(None, old)`` (cache entry
preserved; only the diff could not be applied). The reconnect hook /
next ``update_public`` refetches full state.
"""
synthesized = self._synthesize_born_closed_event(api, item, model_type)
if synthesized is not None:
slot.put(obj_id, synthesized)
return synthesized, None
_LOGGER.debug(
"Public WS update for unknown %s id=%s; needs full refresh",
model_type.value,
obj_id,
)
return None, None
def _synthesize_born_closed_event(
self,
api: ProtectApiClient,
item: dict[str, Any],
model_type: ModelType,
) -> ProtectModelWithId | None:
"""
Build a closed :class:`PublicEvent` from a lone ``update`` for an unseen
id.
Returns the event only when scoped to the EVENT store and the payload
is self-sufficient and already closed (every field in
:data:`_BORN_CLOSED_REQUIRED_FIELDS` set); otherwise ``None`` so the
caller keeps the drop-and-refresh behaviour. Devices/NVR are never
synthesized from a partial diff.
"""
if model_type is not ModelType.EVENT:
return None
if any(
item.get(field_name) is None for field_name in _BORN_CLOSED_REQUIRED_FIELDS
):
return None
try:
return PublicEvent.from_unifi_dict(api=api, **item)
except Exception as err:
_warn_once(
self._warned_merge_failures,
("born_closed", model_type.value),
"Could not synthesize %s from public API born-closed update: %s",
model_type.value,
err,
)
return None
def _nvr_slot(self) -> _Slot:
"""Return a slot that stores the NVR in :attr:`nvr`."""
def _get(_id: str) -> ProtectModelWithId | None:
return self.nvr
def _put(_id: str, obj: ProtectModelWithId) -> None:
self.nvr = cast("PublicNVR", obj)
def _delete(_id: str) -> None:
self.nvr = None
def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicNVR:
return PublicNVR.from_unifi_dict(api=api, **item)
return _Slot(_get, _put, _delete, factory=_factory)
def _liveviews_slot(self) -> _Slot:
"""Return a slot around :attr:`liveviews` with a public-API factory."""
def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicLiveview:
return PublicLiveview.from_unifi_dict(api=api, **item)
return _dict_slot(
cast("dict[str, ProtectModelWithId]", self.liveviews),
factory=_factory,
)
def _bridges_slot(self) -> _Slot:
"""Return a slot around :attr:`bridges` with a public-API factory."""
def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicBridge:
return PublicBridge.from_unifi_dict(api=api, **item)
return _dict_slot(
cast("dict[str, ProtectModelWithId]", self.bridges),
factory=_factory,
)
def _viewers_slot(self) -> _Slot:
"""Return a slot around :attr:`viewers` with a public-API factory."""
def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicViewer:
return PublicViewer.from_unifi_dict(api=api, **item)
return _dict_slot(
cast("dict[str, ProtectModelWithId]", self.viewers),
factory=_factory,
)
def _events_slot(self) -> _Slot:
"""Return a slot around :attr:`events` with LRU eviction."""
events = self.events
limit = self.max_event_cache_size
def _get(obj_id: str) -> ProtectModelWithId | None:
return events.get(obj_id)
def _put(obj_id: str, obj: ProtectModelWithId) -> None:
event = cast("PublicEvent", obj)
events[obj_id] = event
events.move_to_end(obj_id)
# Sync the owning camera's active set at the single cache choke
# point: an open event turns its flag(s) on, an ended one off.
self._sync_camera_detection_state(event)
while events and len(events) > limit:
_evicted_id, evicted = events.popitem(last=False)
# An evicted open event can never receive its close update, so
# clear it here to keep the camera flag from sticking on (and
# the active set bounded to the cache).
self._clear_camera_detection_event(cast("PublicEvent", evicted))
def _delete(obj_id: str) -> None:
evicted = events.pop(obj_id, None)
if evicted is not None:
self._clear_camera_detection_event(evicted)
def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicEvent:
return PublicEvent.from_unifi_dict(api=api, **item)
return _Slot(_get, _put, _delete, factory=_factory)
@dataclass(frozen=True, slots=True)
class _Slot:
"""Uniform get/put/delete interface over heterogeneous storage."""
get: Callable[[str], ProtectModelWithId | None]
put: Callable[[str, ProtectModelWithId], None]
delete: Callable[[str], None]
# Optional override for the ``add`` action; receives the raw item dict and
# the ``ProtectApiClient`` instance. When ``None`` the generic
# ``create_from_unifi_dict`` path is used.
factory: Callable[[dict[str, Any], ProtectApiClient], ProtectModelWithId] | None = (
None
)
def _dict_slot(
store: dict[str, ProtectModelWithId],
factory: Callable[[dict[str, Any], ProtectApiClient], ProtectModelWithId]
| None = None,
) -> _Slot:
"""Return a slot backed by an id-keyed dict."""
def _delete(obj_id: str) -> None:
store.pop(obj_id, None)
return _Slot(
store.get,
store.__setitem__,
_delete,
factory=factory,
)
def _parse_ws_envelope(
data: dict[str, Any],
) -> tuple[str | None, dict[str, Any], str | None]:
"""
Extract ``(action_type, item, id)`` from a WS envelope.
Returns ``(None, {}, None)`` if required fields are missing.
"""
action_type = data.get("type")
item = data.get("item") or {}
model_key = item.get("modelKey")
obj_id = item.get("id")
if not action_type or not model_key or not obj_id:
return None, {}, None
return action_type, item, obj_id
def _warn_once(
warned_keys: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: Any
) -> None:
"""Emit ``msg % args`` at WARNING on first occurrence, DEBUG afterwards."""
if key in warned_keys:
_LOGGER.debug(msg, *args)
return
warned_keys.add(key)
_LOGGER.warning(msg, *args)
def _merge(
old_obj: ProtectModelWithId,
item: dict[str, Any],
warned_keys: set[tuple[str, str]],
) -> ProtectModelWithId | None:
"""
Merge a partial WS payload into ``old_obj`` in place.
Drops ``id`` / ``modelKey`` (identity fields, never changed via update),
feeds the remaining camelCase payload through the object's own
:meth:`ProtectBaseObject.unifi_dict_to_dict` (which remaps keys, snake-
cases and coerces types) and then applies the cleaned diff via
:meth:`update_from_dict`. Returns the updated object, the original
object (for empty / no-op payloads), or ``None`` if the payload could
not be applied.
"""
payload = {k: v for k, v in item.items() if k not in ("id", "modelKey")}
if not payload:
return old_obj
try:
cleaned = type(old_obj).unifi_dict_to_dict(dict(payload))
if not cleaned:
return old_obj
old_obj.update_from_dict(cleaned)
except Exception as err:
# First occurrence per (ModelType, first-diff-key) is WARNING so a
# schema break is visible in HA logs; subsequent occurrences drop
# to DEBUG to avoid flooding.
first_key = next(iter(payload), "?")
_warn_once(
warned_keys,
(type(old_obj).__name__, first_key),
"Failed to merge public WS update for %s (fields=%s): %s",
type(old_obj).__name__,
list(payload),
err,
)
return None
return old_obj
uilibs-uiprotect-317a52c/src/uiprotect/data/public_devices.py 0000664 0000000 0000000 00000274376 15246377057 0024457 0 ustar 00root root 0000000 0000000 """
UniFi Protect Public API (Integration) data models.
These models represent resources that are exposed only through the Public
Integration API. They live in a separate module so
that the existing private-API data models (in :mod:`uiprotect.data.devices`)
are not affected and older Protect installations remain unaffected.
Fields the NVR is contractually required to return are modelled as required;
only fields that are genuinely optional on the wire (bridge link, battery
status on mains-powered devices, siren activation metadata while idle, ...)
are ``Optional``.
"""
from __future__ import annotations
import math
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from functools import cache
from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, Self, TypedDict
from urllib.parse import urlsplit, urlunsplit
from pydantic import Field
from pydantic.fields import PrivateAttr
from ..exceptions import BadRequest
from ..utils import (
convert_smart_audio_types,
convert_smart_types,
convert_to_datetime,
convert_video_modes,
format_host_for_url,
from_js_time,
to_js_time,
)
from .base import ProtectBaseObject, ProtectModelWithId
from .types import (
DEFAULT,
DEFAULT_TYPE,
AlarmHubBatteryStatus,
AlarmHubConnectionState,
AlarmHubCoverStatus,
AlarmHubInputContactType,
AlarmHubInputStatus,
AlarmHubInputType,
AlarmHubOutputStatus,
AssetFileType,
ChannelQuality,
DeviceState,
DoorbellMessageType,
EventType,
FobAwayState,
FobButton,
FobButtonLabels,
LightModeEnableType,
LightModeType,
LiveviewCycleMode,
ModelType,
MountType,
NvrArmModeStatus,
OnOffState,
OsdOverlayLocation,
PublicHdrMode,
RelayInputActionTrigger,
RelayInputActionType,
RelayInputState,
RelayOutputRebootState,
RelayOutputState,
RelayOutputType,
SensorScheduleMode,
SensorStatusType,
SirenConnectionType,
SirenDuration,
SmartDetectAudioType,
SmartDetectObjectType,
SpeakerMode,
SpeakerStatus,
UlpUserStatus,
VideoMode,
)
if TYPE_CHECKING:
from ..api import (
CameraPublicApiLcdMessageRequest,
ProtectApiClient,
PublicApiChimeRingSettingRequest,
)
from .public_event import PublicEvent
# Public Integration API numeric bounds, taken from the OpenAPI spec. The
# public schema is the source of truth for these and they differ from the
# private/UI limits (e.g. ``PercentInt`` allows 101). The sensor
# ``highThreshold`` fields are spec-unbounded, so they are not range-checked.
_PUBLIC_SENSITIVITY_RANGE: tuple[float, float] = (0, 100)
_PUBLIC_LED_LEVEL_RANGE: tuple[float, float] = (1, 6)
_PUBLIC_MIC_VOLUME_RANGE: tuple[float, float] = (0, 100)
_PUBLIC_TEMPERATURE_LOW_RANGE: tuple[float, float] = (-39, 124)
_PUBLIC_HUMIDITY_LOW_RANGE: tuple[float, float] = (1, 99)
_PUBLIC_LIGHT_LUX_LOW_RANGE: tuple[float, float] = (1, 503192)
_PUBLIC_RING_VOLUME_RANGE: tuple[float, float] = (0, 100)
_PUBLIC_RING_REPEAT_RANGE: tuple[float, float] = (1, 6)
def _validate_public_range(
name: str,
value: float,
bounds: tuple[float, float],
) -> None:
"""Range-check a public-API number against the spec bounds."""
if not math.isfinite(value):
raise BadRequest(f"{name} must be a finite number, got {value}")
minimum, maximum = bounds
if value < minimum or value > maximum:
raise BadRequest(f"{name} must be between {minimum} and {maximum}, got {value}")
def _coerce_public_int(
name: str,
value: float,
bounds: tuple[float, float],
) -> int:
"""Validate a public-API number against the spec bounds and coerce it to ``int``."""
_validate_public_range(name, value, bounds)
return int(value)
# Doorbell LCD message types that require accompanying ``text``; the rest must
# omit it.
_LCD_TYPES_REQUIRING_TEXT: frozenset[DoorbellMessageType] = frozenset(
{DoorbellMessageType.CUSTOM_MESSAGE, DoorbellMessageType.IMAGE}
)
def _build_public_lcd_message(
text_type: DoorbellMessageType | None,
text: str | None,
reset_at: datetime | DEFAULT_TYPE | None,
) -> CameraPublicApiLcdMessageRequest:
"""Build the ``lcdMessage`` PATCH body; ``text_type`` of ``None`` clears it."""
if text_type is None:
if text is not None or reset_at is not DEFAULT:
raise BadRequest("Clearing the LCD message does not accept text/reset_at")
# The public API validates the request against a ``oneOf`` whose variants
# all require ``type``, so an empty object is rejected before any handler
# runs. A message whose ``resetAt`` has already passed is removed instead
# of being pushed to the camera; ``0`` avoids any client/console clock
# skew, and the type is never delivered so any text-free one will do.
return {"type": DoorbellMessageType.DO_NOT_DISTURB, "resetAt": 0}
if text_type in _LCD_TYPES_REQUIRING_TEXT:
if text is None:
raise BadRequest(f"{text_type} requires text")
elif text is not None:
raise BadRequest(f"{text_type} does not accept text")
message: CameraPublicApiLcdMessageRequest = {"type": text_type}
if text is not None:
message["text"] = text
if isinstance(reset_at, datetime):
message["resetAt"] = to_js_time(reset_at)
elif reset_at is None:
message["resetAt"] = None
return message
# Smart-detect object events. Protect 7.x emits overlapping ``smartDetectZone``
# and ``smartDetectLine`` frames for the same detection, so both drive the smart
# flags — mirroring the private ``Camera`` model (see ``CAMERA_EVENT_ATTR_MAP``
# in ``bootstrap.py``, which maps both to the same ``last_smart_detect`` state).
_SMART_DETECT_EVENT_TYPES = frozenset(
{EventType.SMART_DETECT, EventType.SMART_DETECT_LINE}
)
_MOTION_EVENT_TYPES = frozenset({EventType.MOTION})
_SMART_AUDIO_EVENT_TYPES = frozenset({EventType.SMART_AUDIO_DETECT})
# Public events-WS event types that drive the derived detection-state booleans
# on :class:`PublicCamera`. Other event types are ignored by that machinery.
_DETECTION_EVENT_TYPES = (
_MOTION_EVENT_TYPES | _SMART_AUDIO_EVENT_TYPES | _SMART_DETECT_EVENT_TYPES
)
# Derived detection-state boolean property names on :class:`PublicCamera`. A
# transition in any of these (after an events-WS frame is folded into the active
# set) is surfaced as a synthetic devices-WS update — see
# :meth:`PublicCamera._detection_state` and the events-WS handler.
_DETECTION_STATE_FIELDS = (
"is_motion_detected",
"is_smart_currently_detected",
"is_person_currently_detected",
"is_vehicle_currently_detected",
"is_animal_currently_detected",
"is_audio_currently_detected",
"is_smoke_currently_detected",
"is_cmonx_currently_detected",
"is_siren_currently_detected",
"is_baby_cry_currently_detected",
"is_speaking_currently_detected",
"is_bark_currently_detected",
"is_car_alarm_currently_detected",
"is_car_horn_currently_detected",
"is_glass_break_currently_detected",
)
class PublicArmScheduleDict(TypedDict):
"""Arm-profile schedule entry (write shape). Fields are cron expressions."""
start: str
end: str
class PublicSensorLightSettings(TypedDict, total=False):
isEnabled: bool
lowThreshold: float
highThreshold: float
margin: int
class PublicSensorHumiditySettings(TypedDict, total=False):
isEnabled: bool
lowThreshold: float
highThreshold: float
margin: int
class PublicSensorTemperatureSettings(TypedDict, total=False):
isEnabled: bool
lowThreshold: float
highThreshold: float
margin: float
class PublicSensorMotionSettings(TypedDict, total=False):
isEnabled: bool
sensitivity: int
sensitivityWhenArmed: int
class PublicSensorGlassBreakSettingsWrite(TypedDict, total=False):
# Write shape for ``glassBreakSettings``; the read model is
# ``PublicSensorGlassBreakSettings``.
isEnabled: bool
sensitivity: int
sensitivityWhenArmed: int
class PublicSensorAlarmSettings(TypedDict, total=False):
isEnabled: bool
class PublicLiveviewSlotDict(TypedDict):
"""Liveview slot (write shape)."""
cameras: list[str]
cycleMode: LiveviewCycleMode
cycleInterval: int
class PublicPosLineItemDict(TypedDict):
"""One purchased line item of a POS transaction (write shape)."""
title: str
quantity: int
class PublicPosLocationDict(TypedDict):
"""Location or register a POS transaction was rung up at (write shape)."""
id: str
name: NotRequired[str]
class PublicSignalState(ProtectBaseObject):
# Nullable on the wire: a freshly-paired wireless device (e.g. a key fob)
# has not yet reported its Bluetooth signal.
signal_quality: int | None = None
signal_strength: int | None = None
class PublicWirelessBatteryStatus(ProtectBaseObject):
# Nullable on the wire until the device first reports its battery level.
percentage: int | None = None
is_low: bool
class PublicWirelessConnectionState(ProtectBaseObject):
# ``signal_state`` is only present while the device is online; battery
# status is absent on mains-powered devices; bridge may be unset.
signal_state: PublicSignalState | None = None
battery_status: PublicWirelessBatteryStatus | None = None
bridge: str | None = None
class PublicLedSettings(ProtectBaseObject):
is_enabled: bool
# Sub-object internals are not ``required`` in the spec, so every leaf field
# carries a default; that keeps partial WS update diffs (which omit unchanged
# nested keys) parseable without strict-validation failures.
class PublicOsdSettings(ProtectBaseObject):
is_name_enabled: bool = False
is_date_enabled: bool = False
is_logo_enabled: bool = False
is_debug_enabled: bool = False
overlay_location: OsdOverlayLocation | None = None
class PublicCameraLedSettings(ProtectBaseObject):
# Distinct from the single-field :class:`PublicLedSettings` used by
# siren/relay. ``welcome_led`` / ``flood_led`` are ``None`` on cameras
# without a doorbell/floodlight.
is_enabled: bool = False
welcome_led: bool | None = None
flood_led: bool | None = None
class PublicLcdMessage(ProtectBaseObject):
# Response shape, not request shape: the spec models these as separate
# schemas. Responses use ``lcdMessageUnion``, where every field is
# optional, so all three are optional here. Requests use ``lcdMessage``, a
# ``oneOf`` whose four variants all require ``type`` — an empty object is
# rejected there, and a camera with no message omits the key entirely.
type: DoorbellMessageType | None = None
reset_at: int | None = None
text: str | None = None
@property
def reset_at_dt(self) -> datetime | None:
"""``reset_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.reset_at)
class PublicCameraFeatureFlags(ProtectBaseObject):
support_full_hd_snapshot: bool = False
has_hdr: bool = False
smart_detect_types: list[SmartDetectObjectType] = Field(default_factory=list)
smart_detect_audio_types: list[SmartDetectAudioType] = Field(default_factory=list)
video_modes: list[VideoMode] = Field(default_factory=list)
has_mic: bool = False
has_led_status: bool = False
has_speaker: bool = False
@property
def has_highfps(self) -> bool:
"""Does the camera support high-FPS video mode."""
return VideoMode.HIGH_FPS in self.video_modes
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"smartDetectTypes": convert_smart_types,
"smartDetectAudioTypes": convert_smart_audio_types,
"videoModes": convert_video_modes,
} | super().unifi_dict_conversions()
class PublicSmartDetectSettings(ProtectBaseObject):
object_types: list[SmartDetectObjectType] = Field(default_factory=list)
audio_types: list[SmartDetectAudioType] = Field(default_factory=list)
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"objectTypes": convert_smart_types,
"audioTypes": convert_smart_audio_types,
} | super().unifi_dict_conversions()
def _replace_url_host(url: str, host: str) -> str:
"""Replace the host component of ``url``, keeping port, path and query."""
parts = urlsplit(url)
try:
port = parts.port
except ValueError:
return url
netloc = f"{host}:{port}" if port is not None else host
return urlunsplit(parts._replace(netloc=netloc))
class RTSPSStreams(ProtectBaseObject):
"""RTSPS stream URLs for a camera."""
model_config = {"extra": "allow"}
# Intentionally no variables like 'high', 'medium', 'low' are defined here.
# The API naming appears inconsistent - what's called "quality" might actually be "channels".
# Besides standard qualities (high/medium/low), there are special cases like "package" for doorbells
# and unclear implementation for 180° cameras with dual sensors. Dynamic handling via __pydantic_extra__ is safer.
def __init__(self, api: ProtectApiClient | None = None, **data: Any) -> None:
"""Bind to ``api`` so URLs can honour ``override_connection_host``."""
# Runtime-identical to ProtectBaseObject.__init__, but mypy synthesises a
# dataclass_transform __init__ per model subclass from declared fields only;
# without this redeclaration every ``RTSPSStreams(api=...)`` is a
# call-arg error.
super().__init__(api=api, **data)
def get_stream_url(self, quality: str, srtp: bool = True) -> str | None:
"""Get stream URL for a quality level; ``srtp=False`` strips ``?enableSrtp``."""
url = getattr(self, quality, None)
if not isinstance(url, str):
return url
if not srtp:
# Strip only the exact ?enableSrtp suffix the server appends (mirrors the
# private rtsps_url construction); go2rtc rejects the SRTP variant. A
# generic query strip would be wrong if Protect ever adds other params.
url = url.removesuffix("?enableSrtp")
api = self._api
if api is None or not api.override_connection_host:
return url
# A multi-homed console does not answer with the address the request
# arrived on, so the server URL can name an unreachable interface.
return _replace_url_host(url, format_host_for_url(api.connection_host))
def get_available_stream_qualities(self) -> list[str]:
"""
List available RTSPS quality keys from the server.
Returns raw strings; may include values not in :class:`ChannelQuality`.
"""
if self.__pydantic_extra__ is None:
return []
return list(self.__pydantic_extra__.keys())
def get_active_stream_qualities(self) -> list[str]:
"""Get list of currently active RTSPS stream quality levels (only those with stream URLs)."""
if self.__pydantic_extra__ is None:
return []
return [
key
for key, value in self.__pydantic_extra__.items()
if isinstance(value, str) and value is not None
]
def get_inactive_stream_qualities(self) -> list[str]:
"""Get list of inactive RTSPS stream quality levels (supported but not currently active)."""
if self.__pydantic_extra__ is None:
return []
return [
key
for key, value in self.__pydantic_extra__.items()
if not (isinstance(value, str) and value is not None)
]
class PublicIdentifiedModel(ProtectModelWithId):
"""Public model carrying the optional device self-description (``type`` / ``guid``)."""
# Present on newer firmware; older consoles omit both, so they default to
# ``None`` and ``from_unifi_dict`` must not raise on the old shape.
# ``device_type`` is the human model name (wire ``type``), distinct from
# ``model`` (the modelKey enum); ``device_guid`` (wire ``guid``) is a stable
# identifier.
device_type: str | None = None
device_guid: str | None = None
name: str | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"type": "deviceType",
"guid": "deviceGuid",
}
@property
def type(self) -> str | None:
"""Alias for ``device_type`` mirroring the private tree's ``type`` field."""
return self.device_type
@property
def display_name(self) -> str:
"""Human-facing name, falling back ``name -> type`` (no ``market_name`` here)."""
return self.name or self.type or ""
class PublicDeviceModel(PublicIdentifiedModel):
"""Shared base for dedicated public device models carrying ``mac`` / ``state``."""
state: DeviceState
mac: str
# Fields a write-through merge must never overwrite from a PATCH response:
# stable identity plus any library-owned out-of-band state. Subclasses
# extend this (see :class:`PublicCamera`). ``_update_sync.lock`` (inherited
# from :class:`~uiprotect.data.base.ProtectModelWithId`) is what serializes
# the read-modify-write setters below — no separate lock is introduced.
_WRITE_THROUGH_SKIP: ClassVar[frozenset[str]] = frozenset({"id"})
def _apply_from_response(self, response: Self) -> None:
"""
Merge a fresh PATCH-response model into this cached instance in place.
Lock-free by design: it performs a run of plain attribute assignments
with no ``await`` in between, so it is atomic with respect to the event
loop. Read-modify-write setters take ``self._update_sync.lock`` around
their read+patch+apply themselves; this helper must not, or a setter
holding that lock would deadlock on the non-reentrant ``asyncio.Lock``.
Private ``PrivateAttr`` state (detection caches, ...) is not part of
``model_fields`` and is therefore preserved untouched.
"""
skip = type(self)._WRITE_THROUGH_SKIP
for name in type(self).model_fields:
if name in skip:
continue
setattr(self, name, getattr(response, name))
class PublicCamera(PublicDeviceModel):
"""Public API camera device (``GET /v1/cameras``)."""
# ``rtsps_streams`` is primed out-of-band by the library (see the field
# docstring below) and is never carried on a PATCH response, so a
# write-through merge must skip it or a setter call would null it.
_WRITE_THROUGH_SKIP: ClassVar[frozenset[str]] = frozenset({"id", "rtsps_streams"})
model: ModelType | None = ModelType.CAMERA
# Nullable on the wire (spec: ``oneOf [string, null]``).
name: str | None = None
is_mic_enabled: bool
osd_settings: PublicOsdSettings
led_settings: PublicCameraLedSettings
lcd_message: PublicLcdMessage | None = None
mic_volume: int
# ``null`` when no patrol is running.
active_patrol_slot: int | None = None
video_mode: VideoMode
hdr_type: PublicHdrMode
feature_flags: PublicCameraFeatureFlags
smart_detect_settings: PublicSmartDetectSettings
has_package_camera: bool
# RTSPS stream URLs for this camera. Owned and primed entirely by the
# library: filled by ``ProtectApiClient.update_public`` (and kept fresh by
# the WS-reconnect refresh + create/delete write-through). The Public
# Integration API does not yet carry these on the camera payload, so the
# field defaults to ``None`` and is populated out-of-band. Consumers read
# it synchronously.
rtsps_streams: RTSPSStreams | None = None
# Open (not-yet-ended) detection events keyed by event id, maintained from
# the public events websocket. Per-instance (one process may drive several
# clients against different consoles), so it is a ``PrivateAttr`` with a
# per-instance default factory rather than a shared class attribute.
_active_detection_events: dict[str, PublicEvent] = PrivateAttr(default_factory=dict)
# Memoized derived detection booleans. ``None`` means "stale"; the next
# ``_detection_state`` call rebuilds a fresh dict and caches it. The two
# mutators of ``_active_detection_events`` invalidate by setting this back to
# ``None`` rather than mutating the dict in place, so a snapshot already
# captured by the events-WS diff stays a distinct object from the rebuild.
_detection_state_cache: dict[str, bool] | None = PrivateAttr(default=None)
def hardware_stream_qualities(self) -> list[ChannelQuality]:
"""Stream qualities the camera hardware supports (not the server's ``available`` list)."""
qualities = [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW]
if self.has_package_camera:
qualities.append(ChannelQuality.PACKAGE)
return qualities
def can_detect(self, smart_type: SmartDetectObjectType) -> bool:
"""Whether the camera advertises ``smart_type`` (audio types via ``audio_type``)."""
if (audio_type := smart_type.audio_type) is not None:
return audio_type in self.feature_flags.smart_detect_audio_types
return smart_type in self.feature_flags.smart_detect_types
# The Public Integration API does not carry live detection booleans on the
# camera payload; they are derived here from the public events websocket,
# mirroring the private :class:`~uiprotect.data.devices.Camera` accessor
# names. An event turns the matching flag(s) on at start (``end is None``)
# and off once it ends, so overlapping detections of the same kind are
# handled by the per-id active-event set.
@property
def is_motion_detected(self) -> bool:
"""Is a motion event currently active."""
return self._detection_state()["is_motion_detected"]
@property
def is_smart_currently_detected(self) -> bool:
"""Is a smart-detect event currently active."""
return self._detection_state()["is_smart_currently_detected"]
@property
def is_person_currently_detected(self) -> bool:
"""Is a person currently being detected."""
return self._detection_state()["is_person_currently_detected"]
@property
def is_vehicle_currently_detected(self) -> bool:
"""Is a vehicle currently being detected."""
return self._detection_state()["is_vehicle_currently_detected"]
@property
def is_animal_currently_detected(self) -> bool:
"""Is an animal currently being detected."""
return self._detection_state()["is_animal_currently_detected"]
@property
def is_audio_currently_detected(self) -> bool:
"""Is an audio smart-detect event currently active."""
return self._detection_state()["is_audio_currently_detected"]
@property
def is_smoke_currently_detected(self) -> bool:
"""Is a smoke alarm currently being detected."""
return self._detection_state()["is_smoke_currently_detected"]
@property
def is_cmonx_currently_detected(self) -> bool:
"""Is a CO alarm currently being detected."""
return self._detection_state()["is_cmonx_currently_detected"]
@property
def is_siren_currently_detected(self) -> bool:
"""Is a siren currently being detected."""
return self._detection_state()["is_siren_currently_detected"]
@property
def is_baby_cry_currently_detected(self) -> bool:
"""Is a baby cry currently being detected."""
return self._detection_state()["is_baby_cry_currently_detected"]
@property
def is_speaking_currently_detected(self) -> bool:
"""Is speaking currently being detected."""
return self._detection_state()["is_speaking_currently_detected"]
@property
def is_bark_currently_detected(self) -> bool:
"""Is a bark currently being detected."""
return self._detection_state()["is_bark_currently_detected"]
@property
def is_car_alarm_currently_detected(self) -> bool:
"""Is a car alarm currently being detected."""
return self._detection_state()["is_car_alarm_currently_detected"]
@property
def is_car_horn_currently_detected(self) -> bool:
"""Is a car horn currently being detected."""
return self._detection_state()["is_car_horn_currently_detected"]
@property
def is_glass_break_currently_detected(self) -> bool:
"""Is glass breaking currently being detected."""
return self._detection_state()["is_glass_break_currently_detected"]
# Config-derived parity accessors mirroring the private
# :class:`~uiprotect.data.devices.Camera` names. Unlike the private model,
# the public payload has no per-type feature-flag/global-settings
# indirection, so each ``is_*_detection_on`` is a plain membership test over
# ``smart_detect_settings`` — object types against ``object_types``, audio
# types against ``audio_types``. ``is_motion_detection_on`` is deliberately
# absent: its private semantics depend on ``recording_settings``, which the
# public payload does not expose.
@property
def is_person_detection_on(self) -> bool:
"""Is person smart detection enabled."""
return SmartDetectObjectType.PERSON in self.smart_detect_settings.object_types
@property
def is_vehicle_detection_on(self) -> bool:
"""Is vehicle smart detection enabled."""
return SmartDetectObjectType.VEHICLE in self.smart_detect_settings.object_types
@property
def is_face_detection_on(self) -> bool:
"""Is face smart detection enabled."""
return SmartDetectObjectType.FACE in self.smart_detect_settings.object_types
@property
def is_license_plate_detection_on(self) -> bool:
"""Is license plate smart detection enabled."""
return (
SmartDetectObjectType.LICENSE_PLATE
in self.smart_detect_settings.object_types
)
@property
def is_package_detection_on(self) -> bool:
"""Is package smart detection enabled."""
return SmartDetectObjectType.PACKAGE in self.smart_detect_settings.object_types
@property
def is_animal_detection_on(self) -> bool:
"""Is animal smart detection enabled."""
return SmartDetectObjectType.ANIMAL in self.smart_detect_settings.object_types
@property
def is_smoke_detection_on(self) -> bool:
"""Is smoke alarm smart detection enabled."""
return SmartDetectAudioType.SMOKE in self.smart_detect_settings.audio_types
@property
def is_co_detection_on(self) -> bool:
"""Is CO alarm smart detection enabled."""
return SmartDetectAudioType.CMONX in self.smart_detect_settings.audio_types
@property
def is_siren_detection_on(self) -> bool:
"""Is siren smart detection enabled."""
return SmartDetectAudioType.SIREN in self.smart_detect_settings.audio_types
@property
def is_baby_cry_detection_on(self) -> bool:
"""Is baby cry smart detection enabled."""
return SmartDetectAudioType.BABY_CRY in self.smart_detect_settings.audio_types
@property
def is_speaking_detection_on(self) -> bool:
"""Is speaking smart detection enabled."""
return SmartDetectAudioType.SPEAK in self.smart_detect_settings.audio_types
@property
def is_bark_detection_on(self) -> bool:
"""Is bark smart detection enabled."""
return SmartDetectAudioType.BARK in self.smart_detect_settings.audio_types
@property
def is_car_alarm_detection_on(self) -> bool:
"""Is car alarm smart detection enabled."""
return SmartDetectAudioType.BURGLAR in self.smart_detect_settings.audio_types
@property
def is_car_horn_detection_on(self) -> bool:
"""Is car horn smart detection enabled."""
return SmartDetectAudioType.CAR_HORN in self.smart_detect_settings.audio_types
@property
def is_glass_break_detection_on(self) -> bool:
"""Is glass break smart detection enabled."""
return (
SmartDetectAudioType.GLASS_BREAK in self.smart_detect_settings.audio_types
)
@property
def is_high_fps_enabled(self) -> bool:
"""Is the camera running in high-FPS video mode."""
return self.video_mode is VideoMode.HIGH_FPS
@property
def hdr_mode_display(self) -> Literal["auto", "off", "always"]:
"""HDR mode as shown in the Protect interface (inverse of ``hdr_type``)."""
if self.hdr_type is PublicHdrMode.OFF:
return "off"
if self.hdr_type is PublicHdrMode.ON:
return "always"
return "auto"
def _apply_detection_event(self, event: PublicEvent) -> None:
"""Add/remove a detection event from the active set based on its ``end``."""
if event.type not in _DETECTION_EVENT_TYPES:
return
if event.end is None:
self._active_detection_events[event.id] = event
else:
self._active_detection_events.pop(event.id, None)
self._detection_state_cache = None
def _clear_detection_event(self, event_id: str) -> None:
"""Drop an event from the active set (eviction / server ``remove`` frame)."""
self._active_detection_events.pop(event_id, None)
self._detection_state_cache = None
def _detection_state(self) -> dict[str, bool]:
"""
Snapshot every derived detection boolean in one pass over the active set.
Single source of truth for the per-type predicates: the nine public
``is_*`` properties read their value out of this dict, so they and the
snapshot used for transition diffing can never drift apart. The result
is memoized until the active set mutates, so a single-property read is an
O(1) cache lookup rather than a fresh pass over every active event.
"""
cached = self._detection_state_cache
if cached is not None:
return cached
motion = smart = person = vehicle = animal = False
audio = smoke = cmonx = siren = False
baby_cry = speaking = bark = car_alarm = car_horn = glass_break = False
for event in self._active_detection_events.values():
event_type = event.type
if event_type in _MOTION_EVENT_TYPES:
motion = True
elif event_type in _SMART_DETECT_EVENT_TYPES:
smart = True
smart_types = event.smart_detect_types
person = person or SmartDetectObjectType.PERSON in smart_types
vehicle = vehicle or SmartDetectObjectType.VEHICLE in smart_types
animal = animal or SmartDetectObjectType.ANIMAL in smart_types
elif event_type in _SMART_AUDIO_EVENT_TYPES:
audio = True
smart_types = event.smart_detect_types
smoke = smoke or SmartDetectObjectType.SMOKE in smart_types
cmonx = cmonx or SmartDetectObjectType.CMONX in smart_types
siren = siren or SmartDetectObjectType.SIREN in smart_types
baby_cry = baby_cry or SmartDetectObjectType.BABY_CRY in smart_types
speaking = speaking or SmartDetectObjectType.SPEAK in smart_types
bark = bark or SmartDetectObjectType.BARK in smart_types
car_alarm = car_alarm or SmartDetectObjectType.BURGLAR in smart_types
car_horn = car_horn or SmartDetectObjectType.CAR_HORN in smart_types
glass_break = (
glass_break or SmartDetectObjectType.GLASS_BREAK in smart_types
)
state = dict(
zip(
_DETECTION_STATE_FIELDS,
(
motion,
smart,
person,
vehicle,
animal,
audio,
smoke,
cmonx,
siren,
baby_cry,
speaking,
bark,
car_alarm,
car_horn,
glass_break,
),
strict=True,
)
)
self._detection_state_cache = state
return state
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"Camera mutations must go through the dedicated public API helpers "
"(update_camera_public)."
)
async def set_status_light(self, enabled: bool) -> PublicCamera:
"""Set the status LED via the public API."""
if not self.feature_flags.has_led_status:
raise BadRequest("Camera does not have status light")
updated = await self._api.update_camera_public(self.id, led_is_enabled=enabled)
self._apply_from_response(updated)
return self
async def set_welcome_led(self, enabled: bool) -> PublicCamera:
"""Set the welcome LED via the public API."""
if not self.feature_flags.has_led_status:
raise BadRequest("Camera does not have status light")
if self.led_settings.welcome_led is None:
raise BadRequest("Camera does not have welcome LED")
updated = await self._api.update_camera_public(self.id, led_welcome_led=enabled)
self._apply_from_response(updated)
return self
async def set_flood_led(self, enabled: bool) -> PublicCamera:
"""Set the flood LED via the public API."""
if not self.feature_flags.has_led_status:
raise BadRequest("Camera does not have status light")
if self.led_settings.flood_led is None:
raise BadRequest("Camera does not have flood LED")
updated = await self._api.update_camera_public(self.id, led_flood_led=enabled)
self._apply_from_response(updated)
return self
async def set_hdr_mode(self, mode: PublicHdrMode) -> PublicCamera:
"""Set HDR mode via the public API."""
if not self.feature_flags.has_hdr:
raise BadRequest("Camera does not have HDR")
updated = await self._api.update_camera_public(self.id, hdr_type=mode)
self._apply_from_response(updated)
return self
async def set_video_mode(self, mode: VideoMode) -> PublicCamera:
"""Set the video mode via the public API."""
if mode not in self.feature_flags.video_modes:
raise BadRequest(f"Camera does not have {mode}")
updated = await self._api.update_camera_public(self.id, video_mode=mode)
self._apply_from_response(updated)
return self
async def set_mic_volume(self, level: int) -> PublicCamera:
"""Set microphone volume (0-100) via the public API."""
if not self.feature_flags.has_mic:
raise BadRequest("Camera does not have mic")
level = _coerce_public_int("mic_volume", level, _PUBLIC_MIC_VOLUME_RANGE)
updated = await self._api.update_camera_public(self.id, mic_volume=level)
self._apply_from_response(updated)
return self
async def set_lcd_message(
self,
text_type: DoorbellMessageType | None,
text: str | None = None,
reset_at: datetime | DEFAULT_TYPE | None = DEFAULT,
) -> PublicCamera:
"""
Set the doorbell LCD message via the public API.
Pass ``None`` for ``text_type`` to clear the message, with ``text`` and
``reset_at`` omitted. ``text`` is required for CUSTOM_MESSAGE and IMAGE
and must be omitted otherwise. ``reset_at`` controls when the message
clears: omit for the NVR default, pass ``None`` for "forever", or a
specific datetime.
"""
message = _build_public_lcd_message(text_type, text, reset_at)
updated = await self._api.update_camera_public(self.id, lcd_message=message)
self._apply_from_response(updated)
return self
async def set_osd_name(self, enabled: bool) -> PublicCamera:
"""Toggle the name overlay (OSD) via the public API."""
updated = await self._api.update_camera_public(
self.id, osd_name_enabled=enabled
)
self._apply_from_response(updated)
return self
async def set_osd_date(self, enabled: bool) -> PublicCamera:
"""Toggle the date overlay (OSD) via the public API."""
updated = await self._api.update_camera_public(
self.id, osd_date_enabled=enabled
)
self._apply_from_response(updated)
return self
async def set_osd_logo(self, enabled: bool) -> PublicCamera:
"""Toggle the logo overlay (OSD) via the public API."""
updated = await self._api.update_camera_public(
self.id, osd_logo_enabled=enabled
)
self._apply_from_response(updated)
return self
async def set_osd_nerd_mode(self, enabled: bool) -> PublicCamera:
"""Toggle the bitrate/debug overlay (OSD) via the public API."""
updated = await self._api.update_camera_public(
self.id, osd_nerd_mode_enabled=enabled
)
self._apply_from_response(updated)
return self
async def set_osd_overlay_location(
self, location: OsdOverlayLocation
) -> PublicCamera:
"""Set the OSD overlay location via the public API."""
updated = await self._api.update_camera_public(
self.id, osd_overlay_location=location
)
self._apply_from_response(updated)
return self
async def set_person_detection(self, enabled: bool) -> PublicCamera:
"""Toggle person smart detection via the public API."""
return await self._set_smart_detect_object(
SmartDetectObjectType.PERSON, enabled
)
async def set_vehicle_detection(self, enabled: bool) -> PublicCamera:
"""Toggle vehicle smart detection via the public API."""
return await self._set_smart_detect_object(
SmartDetectObjectType.VEHICLE, enabled
)
async def set_package_detection(self, enabled: bool) -> PublicCamera:
"""Toggle package smart detection via the public API."""
return await self._set_smart_detect_object(
SmartDetectObjectType.PACKAGE, enabled
)
async def set_license_plate_detection(self, enabled: bool) -> PublicCamera:
"""Toggle license plate smart detection via the public API."""
return await self._set_smart_detect_object(
SmartDetectObjectType.LICENSE_PLATE, enabled
)
async def set_animal_detection(self, enabled: bool) -> PublicCamera:
"""Toggle animal smart detection via the public API."""
return await self._set_smart_detect_object(
SmartDetectObjectType.ANIMAL, enabled
)
async def set_face_detection(self, enabled: bool) -> PublicCamera:
"""Toggle face smart detection via the public API."""
return await self._set_smart_detect_object(SmartDetectObjectType.FACE, enabled)
async def set_smoke_detection(self, enabled: bool) -> PublicCamera:
"""Toggle smoke audio detection via the public API."""
return await self._set_smart_detect_audio(SmartDetectAudioType.SMOKE, enabled)
async def set_co_detection(self, enabled: bool) -> PublicCamera:
"""Toggle CO audio detection via the public API."""
return await self._set_smart_detect_audio(SmartDetectAudioType.CMONX, enabled)
async def set_siren_detection(self, enabled: bool) -> PublicCamera:
"""Toggle siren audio detection via the public API."""
return await self._set_smart_detect_audio(SmartDetectAudioType.SIREN, enabled)
async def set_baby_cry_detection(self, enabled: bool) -> PublicCamera:
"""Toggle baby cry audio detection via the public API."""
return await self._set_smart_detect_audio(
SmartDetectAudioType.BABY_CRY, enabled
)
async def set_speaking_detection(self, enabled: bool) -> PublicCamera:
"""Toggle speaking audio detection via the public API."""
return await self._set_smart_detect_audio(SmartDetectAudioType.SPEAK, enabled)
async def set_bark_detection(self, enabled: bool) -> PublicCamera:
"""Toggle bark audio detection via the public API."""
return await self._set_smart_detect_audio(SmartDetectAudioType.BARK, enabled)
async def set_burglar_detection(self, enabled: bool) -> PublicCamera:
"""Toggle burglar audio detection via the public API."""
return await self._set_smart_detect_audio(SmartDetectAudioType.BURGLAR, enabled)
async def set_car_horn_detection(self, enabled: bool) -> PublicCamera:
"""Toggle car horn audio detection via the public API."""
return await self._set_smart_detect_audio(
SmartDetectAudioType.CAR_HORN, enabled
)
async def set_glass_break_detection(self, enabled: bool) -> PublicCamera:
"""Toggle glass break audio detection via the public API."""
return await self._set_smart_detect_audio(
SmartDetectAudioType.GLASS_BREAK, enabled
)
async def _set_smart_detect_object(
self, obj_type: SmartDetectObjectType, enabled: bool
) -> PublicCamera:
if obj_type not in self.feature_flags.smart_detect_types:
raise BadRequest(f"Camera does not support {obj_type} detection")
async with self._update_sync.lock:
types = list(self.smart_detect_settings.object_types)
if enabled and obj_type not in types:
types.append(obj_type)
elif not enabled and obj_type in types:
types.remove(obj_type)
updated = await self._api.update_camera_public(
self.id, smart_detect_object_types=types
)
self._apply_from_response(updated)
return self
async def _set_smart_detect_audio(
self, audio_type: SmartDetectAudioType, enabled: bool
) -> PublicCamera:
if audio_type not in self.feature_flags.smart_detect_audio_types:
raise BadRequest(f"Camera does not support {audio_type} detection")
async with self._update_sync.lock:
types = list(self.smart_detect_settings.audio_types)
if enabled and audio_type not in types:
types.append(audio_type)
elif not enabled and audio_type in types:
types.remove(audio_type)
updated = await self._api.update_camera_public(
self.id, smart_detect_audio_types=types
)
self._apply_from_response(updated)
return self
class PublicLightModeSettings(ProtectBaseObject):
mode: LightModeType | None = None
enable_at: LightModeEnableType | None = None
class PublicLightDeviceSettings(ProtectBaseObject):
is_indicator_enabled: bool = False
pir_duration: int | None = None
pir_sensitivity: int | None = None
led_level: int | None = None
class PublicLight(PublicDeviceModel):
"""Public API light device (``GET /v1/lights``)."""
model: ModelType | None = ModelType.LIGHT
# Nullable on the wire (spec: ``oneOf [string, null]``).
name: str | None = None
light_mode_settings: PublicLightModeSettings
light_device_settings: PublicLightDeviceSettings
is_dark: bool
is_light_on: bool
is_light_force_enabled: bool
last_motion: int | None = None
is_pir_motion_detected: bool
# Flat ``cameraId`` string of the paired camera, or ``null``.
camera: str | None = None
@property
def last_motion_dt(self) -> datetime | None:
"""``last_motion`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.last_motion)
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"Light mutations must go through the dedicated public API helpers "
"(update_light_public)."
)
async def set_name(self, name: str) -> PublicLight:
"""Set the light name via the public API."""
updated = await self._api.update_light_public(self.id, name=name)
self._apply_from_response(updated)
return self
async def set_flood_light(self, enabled: bool) -> PublicLight:
"""Force the flood light on/off via the public API."""
updated = await self._api.update_light_public(
self.id, is_light_force_enabled=enabled
)
self._apply_from_response(updated)
return self
async def set_status_light(self, enabled: bool) -> PublicLight:
"""Toggle the status indicator LED via the public API."""
async with self._update_sync.lock:
settings = self.light_device_settings.model_copy()
settings.is_indicator_enabled = enabled
updated = await self._api.update_light_public(
self.id, light_device_settings=settings
)
self._apply_from_response(updated)
return self
async def set_led_level(self, led_level: int) -> PublicLight:
"""Set the indicator LED brightness (1-6) via the public API."""
led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE)
async with self._update_sync.lock:
settings = self.light_device_settings.model_copy()
settings.led_level = led_level
updated = await self._api.update_light_public(
self.id, light_device_settings=settings
)
self._apply_from_response(updated)
return self
async def set_light(
self, enabled: bool, led_level: float | None = None
) -> PublicLight:
"""Force the light on/off, optionally setting LED brightness (1-6), in one call."""
if led_level is None:
updated = await self._api.update_light_public(
self.id, is_light_force_enabled=enabled
)
self._apply_from_response(updated)
return self
led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE)
async with self._update_sync.lock:
settings = self.light_device_settings.model_copy()
settings.led_level = led_level
updated = await self._api.update_light_public(
self.id,
is_light_force_enabled=enabled,
light_device_settings=settings,
)
self._apply_from_response(updated)
return self
async def set_sensitivity(self, sensitivity: int) -> PublicLight:
"""Set PIR motion sensitivity (0-100) via the public API."""
sensitivity = _coerce_public_int(
"sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE
)
async with self._update_sync.lock:
settings = self.light_device_settings.model_copy()
settings.pir_sensitivity = sensitivity
updated = await self._api.update_light_public(
self.id, light_device_settings=settings
)
self._apply_from_response(updated)
return self
async def set_duration(self, duration: timedelta) -> PublicLight:
"""Set how long the light stays on after motion (15s-900s) via the public API."""
if duration.total_seconds() < 15 or duration.total_seconds() > 900:
raise BadRequest("Duration outside of 15s to 900s range")
async with self._update_sync.lock:
settings = self.light_device_settings.model_copy()
settings.pir_duration = int(duration.total_seconds() * 1000)
updated = await self._api.update_light_public(
self.id, light_device_settings=settings
)
self._apply_from_response(updated)
return self
async def set_light_mode(
self,
mode: LightModeType,
enable_at: LightModeEnableType | None = None,
) -> PublicLight:
"""Set the lighting trigger mode (and optional schedule) via the public API."""
async with self._update_sync.lock:
settings = self.light_mode_settings.model_copy()
settings.mode = mode
if enable_at is not None:
settings.enable_at = enable_at
updated = await self._api.update_light_public(
self.id, light_mode_settings=settings
)
self._apply_from_response(updated)
return self
async def set_light_settings(
self,
mode: LightModeType,
enable_at: LightModeEnableType | None = None,
duration: timedelta | None = None,
sensitivity: int | None = None,
) -> PublicLight:
"""Update mode, schedule, duration and PIR sensitivity via the public API."""
if duration is not None and (
duration.total_seconds() < 15 or duration.total_seconds() > 900
):
raise BadRequest("Duration outside of 15s to 900s range")
async with self._update_sync.lock:
mode_settings = self.light_mode_settings.model_copy()
mode_settings.mode = mode
if enable_at is not None:
mode_settings.enable_at = enable_at
device_settings: PublicLightDeviceSettings | None = None
if duration is not None or sensitivity is not None:
device_settings = self.light_device_settings.model_copy()
if duration is not None:
device_settings.pir_duration = int(duration.total_seconds() * 1000)
if sensitivity is not None:
device_settings.pir_sensitivity = _coerce_public_int(
"sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE
)
updated = await self._api.update_light_public(
self.id,
light_mode_settings=mode_settings,
light_device_settings=device_settings,
)
self._apply_from_response(updated)
return self
class PublicBatteryStatus(ProtectBaseObject):
percentage: int | None = None
is_low: bool = False
class PublicSensorMetric(ProtectBaseObject):
# ``value`` is ``null`` until the sensor first reports the metric.
value: float | None = None
status: SensorStatusType | None = None
class PublicSensorStats(ProtectBaseObject):
light: PublicSensorMetric | None = None
humidity: PublicSensorMetric | None = None
temperature: PublicSensorMetric | None = None
class PublicSensorThresholdSettings(ProtectBaseObject):
# Shared read-shape for the ``lightSettings`` / ``humiditySettings`` /
# ``temperatureSettings`` leaves — identical field set in the spec.
is_enabled: bool = False
margin: float | None = None
low_threshold: float | None = None
high_threshold: float | None = None
class PublicSensorMotionSettingsRead(ProtectBaseObject):
is_enabled: bool = False
sensitivity: int | None = None
sensitivity_when_armed: int | None = None
class PublicSensorAlarmSettingsRead(ProtectBaseObject):
is_enabled: bool = False
class PublicSensorGlassBreakSettings(ProtectBaseObject):
is_enabled: bool = False
sensitivity: int | None = None
sensitivity_when_armed: int | None = None
class PublicSensorLeakSettings(ProtectBaseObject):
is_internal_enabled: bool = False
is_external_enabled: bool = False
class SensorFeatureCapability(StrEnum):
"""A capability a sensor can advertise in :class:`PublicSensorFeatureFlags`."""
TEMPERATURE = "temperature"
HUMIDITY = "humidity"
LIGHT = "light"
MOTION = "motion"
WATER_LEAK = "water_leak"
OPEN = "open"
TAMPER = "tamper"
SMOKE = "smoke"
class PublicSensorFeatureCapability(ProtectBaseObject):
"""One capability slice; ``channel_count`` defaults to ``0`` when the wire omits it."""
channel_count: int = 0
class PublicSensorFeatureFlags(ProtectBaseObject):
"""Per-sensor capability map. An absent key means the capability is unsupported."""
temperature: PublicSensorFeatureCapability | None = None
humidity: PublicSensorFeatureCapability | None = None
light: PublicSensorFeatureCapability | None = None
motion: PublicSensorFeatureCapability | None = None
water_leak: PublicSensorFeatureCapability | None = None
open: PublicSensorFeatureCapability | None = None
tamper: PublicSensorFeatureCapability | None = None
smoke: PublicSensorFeatureCapability | None = None
class PublicSensor(PublicDeviceModel):
"""Public API sensor device (``GET /v1/sensors``)."""
model: ModelType | None = ModelType.SENSOR
# Nullable on the wire (spec: ``oneOf [string, null]``).
name: str | None = None
mount_type: MountType
battery_status: PublicBatteryStatus
stats: PublicSensorStats
light_settings: PublicSensorThresholdSettings
humidity_settings: PublicSensorThresholdSettings
temperature_settings: PublicSensorThresholdSettings
is_opened: bool | None = None
open_status_changed_at: int | None = None
is_motion_detected: bool
motion_detected_at: int | None = None
motion_settings: PublicSensorMotionSettingsRead
alarm_triggered_at: int | None = None
alarm_settings: PublicSensorAlarmSettingsRead
leak_detected_at: int | None = None
external_leak_detected_at: int | None = None
leak_settings: PublicSensorLeakSettings
tampering_detected_at: int | None = None
wireless_connection_state: PublicWirelessConnectionState
# Firmware-new fields (Protect 7.1.76+): older consoles (e.g. 7.1.69) omit
# them, so default them rather than require — the wire shape shifts across
# releases and ``from_unifi_dict`` must not raise on the older shape.
schedule_mode: SensorScheduleMode = SensorScheduleMode.UNKNOWN
glass_break_settings: PublicSensorGlassBreakSettings = Field(
default_factory=PublicSensorGlassBreakSettings
)
arm_profile_ids: list[str] | None = None
has_custom_sensitivity_when_armed: bool = False
# Capability map, present only on newer firmware (older consoles omit it).
feature_flags: PublicSensorFeatureFlags | None = None
@property
def open_status_changed_at_dt(self) -> datetime | None:
"""``open_status_changed_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.open_status_changed_at)
@property
def motion_detected_at_dt(self) -> datetime | None:
"""``motion_detected_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.motion_detected_at)
@property
def alarm_triggered_at_dt(self) -> datetime | None:
"""``alarm_triggered_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.alarm_triggered_at)
@property
def leak_detected_at_dt(self) -> datetime | None:
"""``leak_detected_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.leak_detected_at)
@property
def external_leak_detected_at_dt(self) -> datetime | None:
"""``external_leak_detected_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.external_leak_detected_at)
@property
def tampering_detected_at_dt(self) -> datetime | None:
"""``tampering_detected_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.tampering_detected_at)
@property
def has_feature_flags(self) -> bool:
"""Whether a capability map was reported; ``False`` means unavailable, not empty."""
return self.feature_flags is not None
def supports(self, capability: SensorFeatureCapability) -> bool:
"""Whether the sensor advertises ``capability`` (``False`` without a feature map)."""
if self.feature_flags is None:
return False
return getattr(self.feature_flags, capability.value, None) is not None
@property
def is_leak_detected(self) -> bool:
"""Whether a leak is detected on either the internal or external channel."""
return (
self.leak_detected_at is not None
or self.external_leak_detected_at is not None
)
@property
def is_tampering_detected(self) -> bool:
"""Whether the tamper switch is currently triggered."""
return self.tampering_detected_at is not None
# Environmental metrics are suppressed on leak mounts; the leak metric only
# applies there.
@property
def is_temperature_sensor_enabled(self) -> bool:
return (
self.mount_type is not MountType.LEAK
and self.temperature_settings.is_enabled
)
@property
def is_humidity_sensor_enabled(self) -> bool:
return (
self.mount_type is not MountType.LEAK and self.humidity_settings.is_enabled
)
@property
def is_light_sensor_enabled(self) -> bool:
return self.mount_type is not MountType.LEAK and self.light_settings.is_enabled
@property
def is_motion_sensor_enabled(self) -> bool:
return self.mount_type is not MountType.LEAK and self.motion_settings.is_enabled
@property
def is_alarm_sensor_enabled(self) -> bool:
return self.mount_type is not MountType.LEAK and self.alarm_settings.is_enabled
@property
def is_leak_sensor_enabled(self) -> bool:
return self.mount_type is MountType.LEAK
@property
def is_contact_sensor_enabled(self) -> bool:
return self.mount_type in {MountType.DOOR, MountType.WINDOW, MountType.GARAGE}
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"Sensor mutations must go through the dedicated public API helpers "
"(update_sensor_public)."
)
async def set_name(self, name: str) -> PublicSensor:
"""Set the sensor name via the public API."""
updated = await self._api.update_sensor_public(self.id, name=name)
self._apply_from_response(updated)
return self
async def set_temperature_settings(
self,
*,
is_enabled: bool | None = None,
low_threshold: float | None = None,
high_threshold: float | None = None,
margin: float | None = None,
) -> PublicSensor:
"""Update temperature alert settings via the public API."""
settings: PublicSensorTemperatureSettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if low_threshold is not None:
_validate_public_range(
"low_threshold", low_threshold, _PUBLIC_TEMPERATURE_LOW_RANGE
)
settings["lowThreshold"] = low_threshold
if high_threshold is not None:
settings["highThreshold"] = high_threshold
if margin is not None:
settings["margin"] = margin
if not settings:
raise BadRequest("At least one parameter must be provided")
updated = await self._api.update_sensor_public(
self.id, temperature_settings=settings
)
self._apply_from_response(updated)
return self
async def set_humidity_settings(
self,
*,
is_enabled: bool | None = None,
low_threshold: float | None = None,
high_threshold: float | None = None,
margin: int | None = None,
) -> PublicSensor:
"""Update humidity alert settings via the public API."""
settings: PublicSensorHumiditySettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if low_threshold is not None:
_validate_public_range(
"low_threshold", low_threshold, _PUBLIC_HUMIDITY_LOW_RANGE
)
settings["lowThreshold"] = low_threshold
if high_threshold is not None:
settings["highThreshold"] = high_threshold
if margin is not None:
settings["margin"] = margin
if not settings:
raise BadRequest("At least one parameter must be provided")
updated = await self._api.update_sensor_public(
self.id, humidity_settings=settings
)
self._apply_from_response(updated)
return self
async def set_light_settings(
self,
*,
is_enabled: bool | None = None,
low_threshold: float | None = None,
high_threshold: float | None = None,
margin: int | None = None,
) -> PublicSensor:
"""Update light (lux) alert settings via the public API."""
settings: PublicSensorLightSettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if low_threshold is not None:
_validate_public_range(
"low_threshold", low_threshold, _PUBLIC_LIGHT_LUX_LOW_RANGE
)
settings["lowThreshold"] = low_threshold
if high_threshold is not None:
settings["highThreshold"] = high_threshold
if margin is not None:
settings["margin"] = margin
if not settings:
raise BadRequest("At least one parameter must be provided")
updated = await self._api.update_sensor_public(self.id, light_settings=settings)
self._apply_from_response(updated)
return self
async def set_motion_settings(
self,
*,
is_enabled: bool | None = None,
sensitivity: float | None = None,
sensitivity_when_armed: float | None = None,
) -> PublicSensor:
"""Update motion detection settings via the public API."""
settings: PublicSensorMotionSettings = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if sensitivity is not None:
settings["sensitivity"] = _coerce_public_int(
"sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE
)
if sensitivity_when_armed is not None:
settings["sensitivityWhenArmed"] = _coerce_public_int(
"sensitivity_when_armed",
sensitivity_when_armed,
_PUBLIC_SENSITIVITY_RANGE,
)
if not settings:
raise BadRequest("At least one parameter must be provided")
updated = await self._api.update_sensor_public(
self.id, motion_settings=settings
)
self._apply_from_response(updated)
return self
async def set_glass_break_settings(
self,
*,
is_enabled: bool | None = None,
sensitivity: float | None = None,
sensitivity_when_armed: float | None = None,
) -> PublicSensor:
"""Update glass-break detection settings via the public API."""
settings: PublicSensorGlassBreakSettingsWrite = {}
if is_enabled is not None:
settings["isEnabled"] = is_enabled
if sensitivity is not None:
settings["sensitivity"] = _coerce_public_int(
"sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE
)
if sensitivity_when_armed is not None:
settings["sensitivityWhenArmed"] = _coerce_public_int(
"sensitivity_when_armed",
sensitivity_when_armed,
_PUBLIC_SENSITIVITY_RANGE,
)
if not settings:
raise BadRequest("At least one parameter must be provided")
updated = await self._api.update_sensor_public(
self.id, glass_break_settings=settings
)
self._apply_from_response(updated)
return self
async def set_alarm(self, enabled: bool) -> PublicSensor:
"""Toggle the (audio) alarm detection setting via the public API."""
alarm_settings: PublicSensorAlarmSettings = {"isEnabled": enabled}
updated = await self._api.update_sensor_public(
self.id, alarm_settings=alarm_settings
)
self._apply_from_response(updated)
return self
async def set_schedule_mode(self, mode: SensorScheduleMode) -> PublicSensor:
"""Set the arm-schedule mode via the public API."""
updated = await self._api.update_sensor_public(self.id, schedule_mode=mode)
self._apply_from_response(updated)
return self
async def set_arm_profile_ids(self, arm_profile_ids: list[str]) -> PublicSensor:
"""Set the arm-profile ids associated with the sensor via the public API."""
updated = await self._api.update_sensor_public(
self.id, arm_profile_ids=arm_profile_ids
)
self._apply_from_response(updated)
return self
async def set_custom_sensitivity_when_armed(self, enabled: bool) -> PublicSensor:
"""Toggle custom armed sensitivity via the public API."""
updated = await self._api.update_sensor_public(
self.id, has_custom_sensitivity_when_armed=enabled
)
self._apply_from_response(updated)
return self
async def set_motion_status(self, enabled: bool) -> PublicSensor:
"""Toggle motion detection via the public API."""
return await self.set_motion_settings(is_enabled=enabled)
async def set_motion_sensitivity(self, sensitivity: float) -> PublicSensor:
"""Set motion detection sensitivity via the public API."""
return await self.set_motion_settings(sensitivity=sensitivity)
async def set_temperature_status(self, enabled: bool) -> PublicSensor:
"""Toggle temperature alerts via the public API."""
return await self.set_temperature_settings(is_enabled=enabled)
async def set_humidity_status(self, enabled: bool) -> PublicSensor:
"""Toggle humidity alerts via the public API."""
return await self.set_humidity_settings(is_enabled=enabled)
async def set_light_status(self, enabled: bool) -> PublicSensor:
"""Toggle light (lux) alerts via the public API."""
return await self.set_light_settings(is_enabled=enabled)
async def set_glass_break_status(self, enabled: bool) -> PublicSensor:
"""Toggle glass-break detection via the public API."""
return await self.set_glass_break_settings(is_enabled=enabled)
class PublicRingSettings(ProtectBaseObject):
camera_id: str | None = None
repeat_times: int | None = None
ringtone_id: str | None = None
volume: int | None = None
class PublicChime(PublicDeviceModel):
"""Public API chime device (``GET /v1/chimes``)."""
model: ModelType | None = ModelType.CHIME
# Nullable on the wire (spec: ``oneOf [string, null]``).
name: str | None = None
camera_ids: list[str] = Field(default_factory=list)
ring_settings: list[PublicRingSettings] = Field(default_factory=list)
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"Chime mutations must go through the dedicated public API helpers "
"(update_chime_public)."
)
async def set_ring_settings(
self,
ring_settings: list[PublicApiChimeRingSettingRequest],
) -> PublicChime:
"""Replace the full per-camera ring settings via the public API."""
updated = await self._api.update_chime_public(
self.id, ring_settings=ring_settings
)
self._apply_from_response(updated)
return self
async def set_volume_for_camera(self, camera_id: str, level: int) -> PublicChime:
"""Set the ring volume for one paired camera via the public API."""
level = _coerce_public_int("volume", level, _PUBLIC_RING_VOLUME_RANGE)
# The whole read-modify-write is held under the lock: two concurrent
# callers must not each read the pre-mutation list and clobber each
# other (last PATCH wins).
async with self._update_sync.lock:
body = self._ring_settings_body(camera_id, volume=level)
updated = await self._api.update_chime_public(self.id, ring_settings=body)
self._apply_from_response(updated)
return self
async def set_repeat_times_for_camera(
self, camera_id: str, value: int
) -> PublicChime:
"""Set the ring repeat count for one paired camera via the public API."""
value = _coerce_public_int("repeat_times", value, _PUBLIC_RING_REPEAT_RANGE)
async with self._update_sync.lock:
body = self._ring_settings_body(camera_id, repeat_times=value)
updated = await self._api.update_chime_public(self.id, ring_settings=body)
self._apply_from_response(updated)
return self
def _ring_settings_body(
self,
camera_id: str,
*,
volume: int | None = None,
repeat_times: int | None = None,
) -> list[PublicApiChimeRingSettingRequest]:
"""Build a full ring-settings PATCH body overriding one camera's entry."""
if not any(rs.camera_id == camera_id for rs in self.ring_settings):
raise BadRequest(f"Camera {camera_id} is not paired with chime")
body: list[PublicApiChimeRingSettingRequest] = []
for rs in self.ring_settings:
override = rs.camera_id == camera_id
entry: PublicApiChimeRingSettingRequest = {
"cameraId": rs.camera_id or "",
"volume": volume
if override and volume is not None
else (rs.volume or 0),
"repeatTimes": (
repeat_times
if override and repeat_times is not None
else (rs.repeat_times or 1)
),
}
if rs.ringtone_id is not None:
entry["ringtoneId"] = rs.ringtone_id
body.append(entry)
return body
class PublicSirenStatus(ProtectBaseObject):
is_active: bool
# Only populated while the siren is active.
activated_at: int | None = None
duration: int | None = None
@property
def activated_at_dt(self) -> datetime | None:
"""``activated_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.activated_at)
@property
def turn_off_at(self) -> datetime | None:
"""
When the siren is expected to stop playing.
Returns ``None`` when the siren is idle — either because ``activated_at``
/ ``duration`` are not set, or because ``is_active`` is ``False`` (e.g.
after a manual stop, where the server may leave the timing fields
populated). Derived from the websocket payload so no extra API call is
needed — HA can compare against ``datetime.now(UTC)`` instead of
maintaining its own timer.
``duration`` is in milliseconds here even though
:meth:`ProtectApiClient.play_siren_public` takes seconds: the spec
types the status field as ``sirenDuration`` (5000/10000/20000/30000 ms)
and the play request body as ``sirenDurationSeconds`` (5/10/20/30 s).
"""
if not self.is_active or self.activated_at is None or self.duration is None:
return None
return from_js_time(self.activated_at) + timedelta(milliseconds=self.duration)
class Siren(PublicDeviceModel):
"""Public API siren device."""
model: ModelType | None = ModelType.SIREN
name: str
volume: int
led_settings: PublicLedSettings
siren_status: PublicSirenStatus
connection_type: SirenConnectionType
wireless_connection_state: PublicWirelessConnectionState | None = None
async def _api_update(self, data: dict[str, Any]) -> None:
# The generic private-API mutation path (``queue_update`` /
# ``save_device``) does not know how to route patches to the Public
# Integration endpoints; consumers must use the dedicated
# ``update_siren_public`` / ``play`` / ``stop`` / ``test_sound``
# helpers instead.
raise BadRequest(
"Siren mutations must go through the dedicated public API helpers "
"(e.g. play/stop/set_name/set_volume/set_status_light)."
)
@property
def is_active(self) -> bool:
"""
Whether the siren is currently playing.
The server does not emit a stop event when the siren finishes its
timed run, so ``sirenStatus.isActive`` in the WS payload stays
``True`` until the next update. ``turn_off_at`` is only set while
the server flag is true and the timing fields are populated, so a
clock check against it catches the timed-expiry case. A manual stop
clears the server flag and ``turn_off_at`` becomes ``None``,
falling back to the (now-false) server flag.
"""
turn_off_at = self.siren_status.turn_off_at
if turn_off_at is not None:
return datetime.now(UTC) < turn_off_at
return self.siren_status.is_active
async def play(self, duration: int | SirenDuration | None = None) -> None:
"""Play the siren. ``duration`` may be a supported integer or :class:`SirenDuration`; defaults to 5 seconds."""
await self._api.play_siren_public(self.id, duration=duration)
async def stop(self) -> None:
"""Stop an active siren."""
await self._api.stop_siren_public(self.id)
async def test_sound(self, volume: int | None = None) -> None:
"""Test the siren sound at the given volume."""
await self._api.test_siren_sound_public(self.id, volume=volume)
async def set_name(self, name: str) -> Siren:
return await self._api.update_siren_public(self.id, name=name)
async def set_volume(self, volume: int) -> Siren:
return await self._api.update_siren_public(self.id, volume=volume)
async def set_status_light(self, enabled: bool) -> Siren:
return await self._api.update_siren_public(self.id, led_is_enabled=enabled)
class PublicRelayOutput(ProtectBaseObject):
id: int
name: str | None = None
type: RelayOutputType | None = None
delay: int | None = None
pulse_duration: int | None = None
state: RelayOutputState | None = None
reboot_state: RelayOutputRebootState | None = None
class PublicRelayInput(ProtectBaseObject):
id: int
name: str | None = None
state: RelayInputState | None = None
action_trigger: RelayInputActionTrigger | None = None
action_type: RelayInputActionType | None = None
action_output_id: int | None = None
class Relay(PublicDeviceModel):
"""
Public API relay device.
Use :meth:`__getitem__` (``relay[output_id]``) for "fail loud" lookup or
:meth:`get_output` for the ``None``-returning variant.
"""
model: ModelType | None = ModelType.RELAY
name: str
led_settings: PublicLedSettings
outputs: list[PublicRelayOutput]
inputs: list[PublicRelayInput]
wireless_connection_state: PublicWirelessConnectionState | None = None
async def _api_update(self, data: dict[str, Any]) -> None:
# See :meth:`Siren._api_update` — consumers must use
# ``update_relay_public`` / ``activate_relay_output_public`` (or the
# helper methods on this class).
raise BadRequest(
"Relay mutations must go through the dedicated public API helpers "
"(e.g. activate_output/set_name/set_status_light)."
)
def get_output(self, output_id: int) -> PublicRelayOutput | None:
"""Return the output with the given id, or ``None`` if not found."""
for out in self.outputs:
if out.id == output_id:
return out
return None
def __getitem__(self, output_id: int) -> PublicRelayOutput:
"""Return the output with the given id; raises :class:`KeyError` if unknown."""
if (out := self.get_output(output_id)) is None:
raise KeyError(output_id)
return out
async def activate_output(
self,
output_id: int,
state: Literal["on", "off"] | None = None,
pulse_duration_ms: int | None = None,
) -> None:
"""Activate / toggle a relay output channel."""
await self._api.activate_relay_output_public(
self.id,
output_id,
state=state,
pulse_duration_ms=pulse_duration_ms,
)
async def set_name(self, name: str) -> Relay:
return await self._api.update_relay_public(self.id, name=name)
async def set_status_light(self, enabled: bool) -> Relay:
return await self._api.update_relay_public(self.id, led_is_enabled=enabled)
class PublicFobFeatureFlags(ProtectBaseObject):
# ``FobButton`` carries an ``unknown`` member, so button kinds added by
# newer firmware coerce to ``FobButton.UNKNOWN`` instead of raising.
buttons: list[FobButton]
class Fob(PublicDeviceModel):
"""Public API key fob device."""
model: ModelType | None = ModelType.FOB
# Nullable on the wire and in WS partial-updates.
name: str | None = None
# ``FobAwayState`` carries an ``unknown`` member, so values added by newer
# firmware coerce to the ``UNKNOWN`` member rather than raising.
away_state: FobAwayState
button_labels: FobButtonLabels
feature_flags: PublicFobFeatureFlags
# Required by the spec — a fob is always a wireless battery device.
wireless_connection_state: PublicWirelessConnectionState
class PublicSpeakerFeatureFlags(ProtectBaseObject):
has_mic: bool
class PublicSpeakerState(ProtectBaseObject):
# ``SpeakerStatus`` / ``SpeakerMode`` carry an ``unknown`` member, so values
# added by newer firmware coerce to ``UNKNOWN`` instead of raising.
status: SpeakerStatus
mode: SpeakerMode
class Speaker(PublicDeviceModel):
"""Public API speaker device."""
model: ModelType | None = ModelType.SPEAKER
# Nullable on the wire and in WS partial-updates.
name: str | None = None
volume: int
mic_volume: int
is_mic_enabled: bool
speaker_state: PublicSpeakerState
feature_flags: PublicSpeakerFeatureFlags
async def _api_update(self, data: dict[str, Any]) -> None:
# See :meth:`Siren._api_update` — consumers must use
# ``update_speaker_public`` / ``test_speaker_sound_public`` (or the
# helper methods on this class).
raise BadRequest(
"Speaker mutations must go through the dedicated public API helpers "
"(e.g. set_name/set_volume/set_mic_volume/set_mic_enabled/test_sound)."
)
async def set_name(self, name: str) -> Speaker:
return await self._api.update_speaker_public(self.id, name=name)
async def set_volume(self, volume: int) -> Speaker:
return await self._api.update_speaker_public(self.id, volume=volume)
async def set_mic_volume(self, mic_volume: int) -> Speaker:
return await self._api.update_speaker_public(self.id, mic_volume=mic_volume)
async def set_mic_enabled(self, enabled: bool) -> Speaker:
return await self._api.update_speaker_public(self.id, is_mic_enabled=enabled)
async def test_sound(self, volume: int | None = None) -> None:
"""Test the speaker sound at the given volume."""
await self._api.test_speaker_sound_public(self.id, volume=volume)
class AlarmHubBattery(ProtectBaseObject):
"""
Backup battery status of an alarm hub (``alarmHub.battery`` sub-schema).
The ``battery`` object declares no ``required`` array in the OpenAPI spec,
and a real hub with the backup battery disconnected emits only
``connection`` (omitting ``charging``/``voltage``/``batteryStatus``), so
every field is optional.
"""
charging: OnOffState | None = None
connection: AlarmHubConnectionState | None = None
voltage: float | None = None
battery_status: AlarmHubBatteryStatus | None = None
class AlarmHubCover(ProtectBaseObject):
"""
Tamper cover status of an alarm hub (``alarmHub.cover`` sub-schema).
The ``cover`` object declares no ``required`` array in the OpenAPI spec, so
both fields are optional.
"""
status: AlarmHubCoverStatus | None = None
distance: int | None = None
class AlarmHubInput(ProtectBaseObject):
"""A single alarm-hub input zone (``alarmHub.input[]`` sub-schema)."""
enable: OnOffState
type: AlarmHubInputContactType
status: AlarmHubInputStatus
name: str | None = None
# ``inputType`` is the categorical zone kind (motion/entry/smoke/...).
# ``AlarmHubInputType`` carries an ``UNKNOWN`` member, so values added
# by newer firmware coerce to ``UNKNOWN`` instead of raising.
input_type: AlarmHubInputType | None = None
last_triggered_at: int | None = None
camera_id: str | None = None
@property
def last_triggered_at_dt(self) -> datetime | None:
"""``last_triggered_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.last_triggered_at)
class AlarmHubOutput(ProtectBaseObject):
"""A single alarm-hub output channel (``alarmHub.output[]`` sub-schema)."""
active: OnOffState
enable: OnOffState
status: AlarmHubOutputStatus
name: str | None = None
delay: int | None = None
duration: int | None = None
class LinkStation(PublicDeviceModel):
"""
Public API link station / alarm hub.
A single wire schema (``modelKey: "linkstation"``) covers both the
``/v1/link-stations`` and ``/v1/alarm-hubs`` endpoints. The
:attr:`is_alarm_hub` flag distinguishes the two; ``alarm_hub`` is only
populated when :attr:`is_alarm_hub` is ``True``.
"""
model: ModelType | None = ModelType.LINK_STATION
# Nullable on the wire (spec: ``oneOf [string, null]``).
name: str | None = None
is_alarm_hub: bool
led_settings: PublicLedSettings
# Top-level nullable timestamp of the last event, NOT an Event object.
# Sent on the wire as epoch-ms; converted to ``datetime`` via
# ``unifi_dict_conversions`` to match every other Protect timestamp field.
last_event: datetime | None = None
# Retained as an opaque dict so the electrical sub-sections
# (``connector``, ``*MeterStatus``, ``*TerminalStatus``, ``buckboost``,
# ...) — whose keys are not valid Python identifiers (``"12v"``, ``"+"``,
# ``"-"``) and which the OpenAPI spec itself models as
# ``additionalProperties`` maps — survive untouched alongside any
# unmodeled / future top-level keys. The well-formed slice
# (``armed``/``battery``/``cover``/``input``/``output``) is exposed via
# the typed accessors on this class (``alarm_hub_armed``,
# ``alarm_hub_battery``, ``alarm_hub_cover``, ``alarm_hub_inputs``,
# ``alarm_hub_outputs``).
alarm_hub: dict[str, Any] | None = None
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
"""Parse the ``lastEvent`` epoch-ms wire value into ``datetime``."""
return {"lastEvent": convert_to_datetime} | super().unifi_dict_conversions()
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"LinkStation mutations must go through the dedicated public API helpers "
"(update_link_station_public / update_alarm_hub_public / "
"trigger_alarm_hub_output_public)."
)
async def set_name(self, name: str) -> LinkStation:
"""Rename via the matching endpoint for the device's role."""
if self.is_alarm_hub:
return await self._api.update_alarm_hub_public(self.id, name=name)
return await self._api.update_link_station_public(self.id, name=name)
async def trigger_output(
self,
output_id: int,
*,
enable: bool | None = None,
delay: int | None = None,
duration: int | None = None,
) -> None:
"""Trigger an alarm-hub output channel. Raises if this is not an alarm hub."""
if not self.is_alarm_hub:
raise BadRequest("Not an alarm hub")
await self._api.trigger_alarm_hub_output_public(
self.id,
output_id,
enable=enable,
delay=delay,
duration=duration,
)
# Re-derived from ``alarm_hub`` on every access so they stay live against
# WS updates (which mutate the stored dict in place). The electrical
# sub-sections (``connector``, ``*MeterStatus``, ``*TerminalStatus``,
# ``buckboost``, ...) remain accessible via the raw ``alarm_hub`` dict.
@property
def alarm_hub_armed(self) -> OnOffState | None:
"""Return the typed armed flag, or ``None`` when the field is absent."""
if not self.is_alarm_hub or self.alarm_hub is None:
return None
armed = self.alarm_hub.get("armed")
if not isinstance(armed, str):
return None
return OnOffState(armed)
@property
def alarm_hub_battery(self) -> AlarmHubBattery | None:
"""Return the typed backup-battery status, or ``None`` when absent."""
if not self.is_alarm_hub or self.alarm_hub is None:
return None
battery = self.alarm_hub.get("battery")
if not isinstance(battery, dict):
return None
return AlarmHubBattery.from_unifi_dict(**battery)
@property
def alarm_hub_cover(self) -> AlarmHubCover | None:
"""Return the typed tamper-cover status, or ``None`` when absent."""
if not self.is_alarm_hub or self.alarm_hub is None:
return None
cover = self.alarm_hub.get("cover")
if not isinstance(cover, dict):
return None
return AlarmHubCover.from_unifi_dict(**cover)
@property
def alarm_hub_inputs(self) -> dict[int, AlarmHubInput]:
"""Return input zones keyed by numeric id; non-integer keys are skipped."""
if not self.is_alarm_hub or self.alarm_hub is None:
return {}
raw = self.alarm_hub.get("input")
if not isinstance(raw, dict):
return {}
return {
int(key): AlarmHubInput.from_unifi_dict(**value)
for key, value in raw.items()
if isinstance(key, str) and key.isdigit() and isinstance(value, dict)
}
@property
def alarm_hub_outputs(self) -> dict[int, AlarmHubOutput]:
"""Return output channels keyed by numeric id; non-integer keys are skipped."""
if not self.is_alarm_hub or self.alarm_hub is None:
return {}
raw = self.alarm_hub.get("output")
if not isinstance(raw, dict):
return {}
return {
int(key): AlarmHubOutput.from_unifi_dict(**value)
for key, value in raw.items()
if isinstance(key, str) and key.isdigit() and isinstance(value, dict)
}
class PublicArmSchedule(ProtectBaseObject):
start: str
end: str
class ArmProfile(ProtectBaseObject):
"""
Public API arm profile (local alarm manager).
Arm profiles are configuration objects, not devices — the
``/v1/arm-profiles`` payload has no ``modelKey``. The class therefore
inherits directly from :class:`ProtectBaseObject` and carries its own
``id`` field.
"""
id: str
name: str
automations: list[str]
schedules: list[PublicArmSchedule]
record_everything: bool
activation_delay: int
creator: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class NvrArmMode(ProtectBaseObject):
"""
Current arm-manager state embedded in the NVR object (``armMode`` field).
Returned by ``GET /v1/nvrs`` as part of the NVR payload. All fields
that may legitimately be ``null`` on the wire are typed as ``Optional``
for forward-compatibility.
"""
status: NvrArmModeStatus
arm_profile_id: str | None = None
armed_at: int | None = None
will_be_armed_at: int | None = None
breach_detected_at: int | None = None
breach_event_count: int = 0
breach_trigger_event_id: str | None = None
breach_event_id: str | None = None
@property
def armed_at_dt(self) -> datetime | None:
"""``armed_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.armed_at)
@property
def will_be_armed_at_dt(self) -> datetime | None:
"""``will_be_armed_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.will_be_armed_at)
@property
def breach_detected_at_dt(self) -> datetime | None:
"""``breach_detected_at`` as a timezone-aware UTC ``datetime``."""
return convert_to_datetime(self.breach_detected_at)
class PublicDoorbellCustomImage(ProtectBaseObject):
"""A custom doorbell image entry (preview GIF + full sprite PNG)."""
preview: str
sprite: str
class PublicDoorbellSettings(ProtectBaseObject):
"""
Doorbell settings exposed by the Public Integration API (``GET /v1/nvrs``).
Intentionally separate from the private :class:`~uiprotect.data.nvr.DoorbellSettings`
which carries additional private-API-only fields (``allMessages``, typed
timedelta, etc.).
All fields have defaults because the ``doorbellSettings`` OpenAPI schema
declares no ``required`` array — fields may be absent on older firmware or
in partial WS update diffs.
Read-only: the Public Integration API exposes ``GET /v1/nvrs`` but no
``PATCH`` counterpart (nor a dedicated doorbell-message endpoint), so there
is no public write path for these settings. Confirmed absent through
Protect ``7.1.87``; changing the default message still requires the private
API (:class:`~uiprotect.data.nvr.DoorbellSettings`).
"""
default_message_text: str = ""
default_message_reset_timeout_ms: int = 0
custom_messages: list[str] = Field(default_factory=list)
custom_images: list[PublicDoorbellCustomImage] = Field(default_factory=list)
class PublicNVR(PublicIdentifiedModel):
"""
NVR device as exposed by the Public Integration API (``GET /v1/nvrs``).
This model reflects the public schema: ``id``, ``modelKey``, ``name``,
``doorbellSettings``, and optionally ``armMode`` / ``mac``.
``name`` is nullable — the API schema declares it as ``oneOf: [string, null]``.
``mac`` is exposed on ``GET /v1/nvrs`` from Protect newer than 7.1 and is
``None`` on older firmware that omits the key (and absent from WS
partial-update diffs).
``doorbell_settings`` is ``None`` on older firmware that does not yet
expose the ``doorbellSettings`` key, and is absent from WS partial-update
diffs (which only require ``id`` + ``modelKey``).
``arm_mode`` is ``None`` when the firmware does not yet expose the alarm
manager (older releases) and also ``None`` when the alarm manager is set
to global (server returns ``armMode: null``). WS device-update diffs that
include ``armMode`` are handled automatically by
:meth:`~uiprotect.data.base.ProtectBaseObject.update_from_dict` without
any manual extraction.
"""
model: ModelType | None = ModelType.NVR
name: str | None = None
mac: str | None = None
doorbell_settings: PublicDoorbellSettings | None = None
arm_mode: NvrArmMode | None = None
class PublicLiveviewSlot(ProtectBaseObject):
"""One slot in a public-API liveview (read shape)."""
camera_ids: list[str]
# ``LiveviewCycleMode`` carries an ``unknown`` member, so values added by
# newer firmware coerce to ``UNKNOWN`` instead of raising.
cycle_mode: LiveviewCycleMode
cycle_interval: int
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "cameras": "cameraIds"}
class PublicLiveview(ProtectModelWithId):
"""
Public API liveview.
``owner`` is a flat ``userId`` string (the spec types it as ``$ref: userId``,
which is ``type: string``) — not an embedded user object. ``layout`` is the
number of slots the liveview contains (spec: number, 1..26); the field name
matches the wire key exactly.
"""
model: ModelType | None = ModelType.LIVEVIEW
name: str
is_default: bool
is_global: bool
owner: str
layout: int
slots: list[PublicLiveviewSlot]
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"Liveview mutations must go through the dedicated public API helpers "
"(create_liveview_public / update_liveview_public)."
)
class PublicBridge(PublicDeviceModel):
"""
Public API bridge device.
``ModelType.BRIDGE`` is already owned by the private :class:`Bridge` class in
``MODEL_TO_CLASS``; this public counterpart is routed via a dedicated factory
on :class:`~uiprotect.data.public_bootstrap.PublicBootstrap` instead.
"""
model: ModelType | None = ModelType.BRIDGE
name: str | None = None
# ``bridgePlatform`` is typed ``[string, null]`` in the spec.
platform: str | None = None
clients: list[str]
max_clients: int
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"Bridge mutations must go through the dedicated public API helpers "
"(update_bridge_public)."
)
async def set_name(self, name: str) -> PublicBridge:
return await self._api.update_bridge_public(self.id, name=name)
class PublicViewer(PublicDeviceModel):
"""
Public API viewer device.
``ModelType.VIEWPORT`` is already owned by the private :class:`Viewer` class
in ``MODEL_TO_CLASS``; this public counterpart is routed via a dedicated
factory on :class:`~uiprotect.data.public_bootstrap.PublicBootstrap` instead.
The wire field ``liveview`` is a flat ``liveviewId`` string (nullable);
snake-cased to :attr:`liveview_id` via :meth:`_get_unifi_remaps`.
"""
model: ModelType | None = ModelType.VIEWPORT
name: str | None = None
liveview_id: str | None = None
stream_limit: int
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "liveview": "liveviewId"}
async def _api_update(self, data: dict[str, Any]) -> None:
raise BadRequest(
"Viewer mutations must go through the dedicated public API helpers "
"(update_viewer_public)."
)
async def set_name(self, name: str) -> PublicViewer:
return await self._api.update_viewer_public(self.id, name=name)
async def set_liveview(self, liveview_id: str | None) -> PublicViewer:
return await self._api.update_viewer_public(self.id, liveview=liveview_id)
class PublicUser(ProtectModelWithId):
"""Public API Protect user (read-only)."""
model: ModelType | None = ModelType.USER
name: str
# ``firstName``/``lastName``/``email``/``ucoreUserId`` are spec-``required``
# but typed ``oneOf [string, null]`` — the server returns the key but the
# value may be ``null``. Pydantic field types are therefore ``str | None``.
first_name: str | None = None
last_name: str | None = None
email: str | None = None
ucore_user_id: str | None = None
class PublicUlpUser(ProtectModelWithId):
"""Public API UniFi Identity (ULP) user (read-only)."""
model: ModelType | None = ModelType.ULP_USER
first_name: str
last_name: str
full_name: str
# Spec-required and non-nullable — an empty string when UniFi Identity
# holds no address — but ``sample_ulp_users.json``, captured from a real
# console, carries no ``email`` key at all, so older firmware omits it and
# the field stays optional.
email: str | None = None
status: UlpUserStatus
class PublicPosTransactionResponse(ProtectBaseObject):
"""Result of ingesting a POS transaction for a camera."""
created: bool
event_id: str | None = None
class PublicFile(ProtectBaseObject):
"""Public API device asset file (``/v1/files/{fileType}``)."""
name: str
type: AssetFileType
path: str
original_name: str | None = None
uilibs-uiprotect-317a52c/src/uiprotect/data/public_event.py 0000664 0000000 0000000 00000016141 15246377057 0024136 0 ustar 00root root 0000000 0000000 """
Public Integration API event model.
Parsed 1:1 from the public ``event`` payload (the ``oneOf`` over the
``*Event`` schemas). This is the source of truth for the public events
path; it deliberately shares no structure with the private
:class:`~uiprotect.data.nvr.Event` / ``EventMetadata`` models so the two
paths can diverge as their respective specs do.
Metadata is a single model of all-optional, strongly-typed fields — the
union across every public event type. The ``{"text": }`` (and the
lone ``{"number": }``) envelopes the spec wraps each metadata value
in are collapsed to the inner value on parse and re-wrapped on serialise,
mirroring the private ``EventMetadata`` shape without importing it.
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from functools import cache
from typing import Any, ClassVar
from pydantic import Field
from ..utils import convert_smart_types, convert_to_datetime
from .base import ProtectBaseObject, ProtectModelWithId
from .types import (
EventButtonType,
EventType,
ModelType,
MountType,
RelayInputCircuitState,
SensorAlarmType,
SensorExtremeMetricType,
SensorStatusType,
SmartDetectObjectType,
SmokeTestSource,
)
# Every ``type`` the public ``event`` union can carry. ``EventType`` is a
# superset — it also carries private-API-only events — so this is the subset a
# public-API consumer can actually see. ``scripts/validate_spec.py`` pins it
# against the spec's ``event`` ``oneOf`` discriminator.
PUBLIC_EVENT_TYPES: frozenset[EventType] = frozenset(
{
EventType.RING,
EventType.SENSOR_EXTREME_VALUE,
EventType.SENSOR_WATER_LEAK,
EventType.SENSOR_TAMPER,
EventType.SENSOR_BATTERY_LOW,
EventType.SENSOR_ALARM,
EventType.SENSOR_VAPE,
EventType.SENSOR_OPENED,
EventType.SENSOR_CLOSED,
EventType.SENSOR_SMOKE_TEST,
EventType.SENSOR_SMOKE_BATTERY_LOW,
EventType.SENSOR_SMOKE_NEEDS_CLEANING,
EventType.SENSOR_SMOKE_FAULT,
EventType.SENSOR_CO_FAULT,
EventType.SENSOR_SMOKE_END_OF_LIFE,
EventType.MOTION_SENSOR,
EventType.SENSOR_BUTTON_PRESSED,
EventType.MOTION_LIGHT,
EventType.MOTION,
EventType.SMART_AUDIO_DETECT,
EventType.SMART_DETECT,
EventType.SMART_DETECT_LINE,
EventType.SMART_DETECT_LOITER,
EventType.RELAY_INPUT_CHANGED,
EventType.CAMERA_DIGITAL_INPUT_CHANGED,
EventType.ALARM_HUB_MOTION,
EventType.ALARM_HUB_ENTRY_OPENED,
EventType.ALARM_HUB_ENTRY_CLOSED,
EventType.ALARM_HUB_SMOKE,
EventType.ALARM_HUB_GLASS_BREAK,
EventType.ALARM_HUB_BUTTON_PRESS,
EventType.ALARM_HUB_TAMPER,
EventType.ALARM_HUB_DEVICE_TAMPER,
EventType.ALARM_HUB_RELAY_SWITCHED,
EventType.ALARM_HUB_BATTERY_LOW,
EventType.ALARM_HUB_BATTERY_CONNECTED,
EventType.NFC_CARD_SCANNED,
EventType.FINGERPRINT_IDENTIFIED,
}
)
class PublicNfcMetadata(ProtectBaseObject):
"""``nfc`` block of an ``nfcCardScanned`` event (public spec)."""
ulp_id: str | None = None
class PublicFingerprintMetadata(ProtectBaseObject):
"""``fingerprint`` block of a ``fingerprintIdentified`` event (public spec)."""
ulp_id: str | None = None
class PublicEventMetadata(ProtectBaseObject):
"""Union of every public ``*Event`` metadata field, all optional."""
# ``{"text": }`` envelopes.
sensor_type: SensorExtremeMetricType | None = None
sensor_value: float | None = None
status: SensorStatusType | None = None
sensor_mount_type: MountType | None = None
alarm_type: SensorAlarmType | None = None
button: EventButtonType | None = None
input_state: RelayInputCircuitState | None = None
input_token: str | None = None
input_channel: str | None = None
pin: str | None = None
device_id: str | None = None
device_name: str | None = None
# ``{"number": }`` envelope.
sensor_battery_percentage: float | None = None
# Flat string (no envelope).
source: SmokeTestSource | None = None
# Nested identity holders (public-only, never imported from ``nvr.py``).
nfc: PublicNfcMetadata | None = None
fingerprint: PublicFingerprintMetadata | None = None
# Wire keys carrying a ``{"text": ...}`` envelope around their value.
_text_keys: ClassVar[set[str]] = {
"sensorType",
"sensorValue",
"status",
"sensorMountType",
"alarmType",
"button",
"inputState",
"inputToken",
"inputChannel",
"pin",
"deviceId",
"deviceName",
}
# Wire keys carrying a ``{"number": ...}`` envelope around their value.
_number_keys: ClassVar[set[str]] = {"sensorBatteryPercentage"}
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
"""Collapse the ``{text|number: value}`` metadata envelopes to inner values."""
for inner_key, wire_keys in (
("text", cls._text_keys),
("number", cls._number_keys),
):
for wire_key in wire_keys.intersection(data):
value = data[wire_key]
if isinstance(value, dict):
if inner_key in value:
data[wire_key] = value[inner_key]
else:
del data[wire_key]
return super().unifi_dict_to_dict(data)
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
"""Serialise, dropping ``None`` fields and re-wrapping metadata envelopes."""
data = super().unifi_dict(data=data, exclude=exclude)
for key, value in list(data.items()):
if value is None:
del data[key]
for key in self._text_keys.intersection(data):
data[key] = {"text": data[key]}
for key in self._number_keys.intersection(data):
data[key] = {"number": data[key]}
return data
class PublicEvent(ProtectModelWithId):
"""Event delivered over the Public Integration API events websocket."""
model: ModelType | None = ModelType.EVENT
type: EventType
start: datetime
end: datetime | None = None
# Public ``add`` payloads carry the originating device under ``device``.
device_id: str | None = None
smart_detect_types: list[SmartDetectObjectType] = Field(default_factory=list)
metadata: PublicEventMetadata | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
"""Remap the wire ``device`` key onto ``deviceId``."""
return {**super()._get_unifi_remaps(), "device": "deviceId"}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
"""Parse the ``start`` / ``end`` wire timestamps into ``datetime``."""
return (
dict.fromkeys(("start", "end"), convert_to_datetime)
| {"smartDetectTypes": convert_smart_types}
| super().unifi_dict_conversions()
)
uilibs-uiprotect-317a52c/src/uiprotect/data/types.py 0000664 0000000 0000000 00000074143 15246377057 0022631 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import enum
import logging
import types
from collections.abc import Callable, Coroutine, Sequence
from functools import cache, lru_cache
from typing import Annotated, Any, Literal, TypeVar, Union, get_args, get_origin
from packaging.version import Version as BaseVersion
from pydantic import BaseModel, Field
from pydantic.types import StringConstraints
from pydantic_extra_types.color import Color # noqa: F401
from .._compat import cached_property
_LOGGER = logging.getLogger(__name__)
KT = TypeVar("KT")
VT = TypeVar("VT")
@lru_cache(maxsize=512)
def get_field_type(annotation: type[Any] | None) -> tuple[type | None, Any]:
"""Extract the origin and type from an annotation."""
if annotation is None:
raise ValueError("Type annotation cannot be None")
origin = get_origin(annotation)
args: Sequence[Any]
if origin in (list, set):
if not (args := get_args(annotation)):
raise ValueError(f"Unable to determine args of type: {annotation}")
return origin, args[0]
if origin is dict:
if not (args := get_args(annotation)):
raise ValueError(f"Unable to determine args of type: {annotation}")
return origin, args[1]
if origin is Annotated:
if not (args := get_args(annotation)):
raise ValueError(f"Unable to determine args of type: {annotation}")
return None, args[0]
if origin is Union or origin is types.UnionType:
if not (args := get_args(annotation)):
raise ValueError(f"Unable to determine args of type: {annotation}")
args = [get_field_type(arg) for arg in args]
if len(args) == 2 and type(None) in list(zip(*args, strict=False))[1]:
# Strip '| None' type from Union
return next(arg for arg in args if arg[1] is not type(None))
return None, annotation
return origin, annotation
DEFAULT: Literal["DEFAULT_VALUE"] = "DEFAULT_VALUE"
DEFAULT_TYPE = Literal["DEFAULT_VALUE"]
EventCategories = Literal[
"critical",
"update",
"admin",
"ring",
"motion",
"smart",
"iot",
]
ProgressCallback = Callable[[int, int, int], Coroutine[Any, Any, None]]
IteratorCallback = Callable[[int, bytes | None], Coroutine[Any, Any, None]]
class FixSizeOrderedDict(dict[KT, VT]):
"""A fixed size ordered dict."""
def __init__(self, *args: Any, max_size: int = 0, **kwargs: Any) -> None:
"""Create the FixSizeOrderedDict."""
self._max_size = max_size
super().__init__(*args, **kwargs)
def __setitem__(self, key: KT, value: VT) -> None:
"""Set an update up to the max size."""
dict.__setitem__(self, key, value)
if self._max_size > 0 and len(self) > 0 and len(self) > self._max_size:
del self[next(iter(self))]
class ValuesEnumMixin:
_values: list[str] | None = None
_values_normalized: dict[str, str] | None = None
@classmethod
@cache
def from_string(cls, value: str) -> Any:
return cls(value) # type: ignore[call-arg]
@classmethod
@cache
def values(cls) -> list[str]:
if cls._values is None:
cls._values = [e.value for e in cls] # type: ignore[attr-defined]
return cls._values
@classmethod
@cache
def values_set(cls) -> set[str]:
return set(cls.values())
@classmethod
def _missing_(cls, value: Any) -> Any | None:
if cls._values_normalized is None:
cls._values_normalized = {e.value.lower(): e for e in cls} # type: ignore[attr-defined]
value_normal = value
if isinstance(value, str):
value_normal = value.lower()
return cls._values_normalized.get(value_normal)
class UnknownValuesEnumMixin(ValuesEnumMixin):
@classmethod
def _missing_(cls, value: Any) -> Any | None:
result = super()._missing_(value)
if result is None:
_LOGGER.debug("Unknown %s value: %s", cls.__name__, value)
result = cls._values_normalized.get("unknown") # type: ignore[union-attr]
return result
@enum.unique
class ModelType(UnknownValuesEnumMixin, enum.StrEnum):
CAMERA = "camera"
CLOUD_IDENTITY = "cloudIdentity"
EVENT = "event"
GROUP = "group"
LIGHT = "light"
LIVEVIEW = "liveview"
NVR = "nvr"
USER = "user"
USER_LOCATION = "userLocation"
VIEWPORT = "viewer"
BRIDGE = "bridge"
SENSOR = "sensor"
SCHEDULE = "schedule"
CHIME = "chime"
AIPORT = "aiport"
DEVICE_GROUP = "deviceGroup"
RECORDING_SCHEDULE = "recordingSchedule"
ULP_USER = "ulpUser"
RINGTONE = "ringtone"
KEYRING = "keyring"
# Public API only. Intentionally NOT part of
# `_bootstrap_model_types` so the private bootstrap path is unchanged.
SIREN = "siren"
RELAY = "relay"
FOB = "fob"
SPEAKER = "speaker"
LINK_STATION = "linkstation"
AI_PROCESSOR = "aiprocessor"
UNKNOWN = "unknown"
bootstrap_model_types: tuple[ModelType, ...]
bootstrap_models: tuple[str, ...]
bootstrap_models_set: set[str]
bootstrap_models_types_set: set[ModelType]
bootstrap_models_types_and_event_set: set[ModelType]
@cached_property
def devices_key(self) -> str:
"""Return the devices key."""
return f"{self.value}s"
@cached_property
def name(self) -> str:
"""Return the name."""
return self._name_
@cached_property
def value(self) -> str:
"""Return the value."""
return self._value_
@classmethod
@cache
def from_string(cls, value: str) -> ModelType:
return cls(value)
@classmethod
def _bootstrap_model_types(cls) -> tuple[ModelType, ...]:
"""Return the bootstrap models as a tuple."""
return (
ModelType.CAMERA,
ModelType.USER,
ModelType.GROUP,
ModelType.LIVEVIEW,
ModelType.VIEWPORT,
ModelType.LIGHT,
ModelType.BRIDGE,
ModelType.SENSOR,
ModelType.CHIME,
ModelType.AIPORT,
)
@classmethod
def _bootstrap_models(cls) -> tuple[str, ...]:
"""Return the bootstrap models strings as a tuple."""
return tuple(
model_type.value for model_type in ModelType._bootstrap_model_types()
)
@classmethod
def _bootstrap_models_set(cls) -> set[str]:
"""Return the set of bootstrap models strings as a set."""
return set(ModelType._bootstrap_models())
@classmethod
def _bootstrap_models_types_set(cls) -> set[ModelType]:
"""Return the set of bootstrap models as a set."""
return set(ModelType._bootstrap_model_types())
@classmethod
def _bootstrap_models_types_and_event_set(cls) -> set[ModelType]:
"""Return the set of bootstrap models and the event model as a set."""
return ModelType._bootstrap_models_types_set() | {ModelType.EVENT}
def _immutable(self, name: str, value: Any) -> None:
raise AttributeError("Cannot modify ModelType")
ModelType.bootstrap_model_types = ModelType._bootstrap_model_types()
ModelType.bootstrap_models = ModelType._bootstrap_models()
ModelType.bootstrap_models_set = ModelType._bootstrap_models_set()
ModelType.bootstrap_models_types_set = ModelType._bootstrap_models_types_set()
ModelType.bootstrap_models_types_and_event_set = (
ModelType._bootstrap_models_types_and_event_set()
)
ModelType.__setattr__ = ModelType._immutable # type: ignore[method-assign, assignment]
@enum.unique
class EventType(ValuesEnumMixin, enum.StrEnum):
DISCONNECT = "disconnect"
FACTORY_RESET = "factoryReset"
PROVISION = "provision"
UPDATE = "update"
CAMERA_POWER_CYCLE = "cameraPowerCycling"
RING = "ring"
DOOR_ACCESS = "doorAccess"
RESOLUTION_LOWERED = "resolutionLowered"
POOR_CONNECTION = "poorConnection"
STREAM_RECOVERY = "streamRecovery"
MOTION = "motion"
NFC_CARD_SCANNED = "nfcCardScanned"
FINGERPRINT_IDENTIFIED = "fingerprintIdentified"
RECORDING_DELETED = "recordingDeleted"
SMART_AUDIO_DETECT = "smartAudioDetect"
SMART_DETECT = "smartDetectZone"
SMART_DETECT_LINE = "smartDetectLine"
NO_SCHEDULE = "nonScheduledRecording"
RECORDING_MODE_CHANGED = "recordingModeChanged"
HOTPLUG = "hotplug"
FACE_GROUP_DETECTED = "faceGroupDetected"
CONSOLIDATED_RESOLUTION_LOWERED = "consolidatedResolutionLowered"
CONSOLIDATED_POOR_CONNECTION = "consolidatedPoorConnection"
CAMERA_CONNECTED = "cameraConnected"
CAMERA_REBOOTED = "cameraRebooted"
CAMERA_DISCONNECTED = "cameraDisconnected"
INSTALLED_DISK = "installed"
CORRUPTED_DB_RECOVERED = "corruptedDbRecovered"
OFFLINE = "offline"
OFF = "off"
REBOOT = "reboot"
FIRMWARE_UPDATE = "fwUpdate"
APP_UPDATE = "applicationUpdate"
APPLICATION_UPDATABLE = "applicationUpdatable"
ACCESS = "access"
DRIVE_FAILED = "driveFailed"
CAMERA_UTILIZATION_LIMIT_REACHED = "cameraUtilizationLimitReached"
CAMERA_UTILIZATION_LIMIT_EXCEEDED = "cameraUtilizationLimitExceeded"
DRIVE_SLOW = "driveSlow"
GLOBAL_RECORDING_MODE_CHANGED = "globalRecordingModeChanged"
NVR_SETTINGS_CHANGED = "nvrSettingsChanged"
UNADOPTED_DEVICE_DISCOVERED = "unadoptedDeviceDiscovered"
MULTIPLE_UNADOPTED_DEVICE_DISCOVERED = "multipleUnadoptedDeviceDiscovered"
DEVICE_ADOPTED = "deviceAdopted"
DEVICE_UNADOPTED = "deviceUnadopted"
UVF_DISCOVERED = "ufvDiscovered"
DEVICE_PASSWORD_UPDATE = "devicesPasswordUpdated" # noqa: S105
DEVICE_UPDATABLE = "deviceUpdatable"
MULTIPLE_DEVICE_UPDATABLE = "multipleDeviceUpdatable"
DEVICE_CONNECTED = "deviceConnected"
DEVICE_REBOOTED = "deviceRebooted"
DEVICE_DISCONNECTED = "deviceDisconnected"
NETWORK_DEVICE_OFFLINE = "networkDeviceOffline"
USER_LEFT = "userLeft"
USER_ARRIVED = "userArrived"
VIDEO_EXPORTED = "videoExported"
MIC_DISABLED = "microphoneDisabled"
VIDEO_DELETED = "videoDeleted"
SCHEDULE_CHANGED = "recordingScheduleChanged"
MOTION_SENSOR = "sensorMotion"
SENSOR_BUTTON_PRESSED = "sensorButtonPressed"
SENSOR_OPENED = "sensorOpened"
SENSOR_CLOSED = "sensorClosed"
SENSOR_ALARM = "sensorAlarm"
SENSOR_EXTREME_VALUE = "sensorExtremeValues"
SENSOR_WATER_LEAK = "sensorWaterLeak"
SENSOR_BATTERY_LOW = "sensorBatteryLow"
SENSOR_SMOKE_TEST = "sensorSmokeTest"
SENSOR_TAMPER = "sensorTamper"
SENSOR_VAPE = "sensorVape"
SENSOR_SMOKE_BATTERY_LOW = "sensorSmokeBatteryLow"
SENSOR_SMOKE_NEEDS_CLEANING = "sensorSmokeNeedsCleaning"
SENSOR_SMOKE_FAULT = "sensorSmokeFault"
SENSOR_CO_FAULT = "sensorCoFault"
SENSOR_SMOKE_END_OF_LIFE = "sensorSmokeEndOfLife"
RELAY_INPUT_CHANGED = "relayInputChanged"
CAMERA_DIGITAL_INPUT_CHANGED = "cameraDigitalInputChanged"
ALARM_HUB_MOTION = "alarmHubMotion"
ALARM_HUB_ENTRY_OPENED = "alarmHubEntryOpened"
ALARM_HUB_ENTRY_CLOSED = "alarmHubEntryClosed"
ALARM_HUB_RELAY_SWITCHED = "alarmHubRelaySwitched"
ALARM_HUB_BUTTON_PRESS = "alarmHubButtonPress"
ALARM_HUB_SMOKE = "alarmHubSmoke"
ALARM_HUB_GLASS_BREAK = "alarmHubGlassBreak"
ALARM_HUB_TAMPER = "alarmHubTamper"
ALARM_HUB_DEVICE_TAMPER = "alarmHubDeviceTamper"
ALARM_HUB_BATTERY_CONNECTED = "alarmHubBatteryConnected"
ALARM_HUB_BATTERY_LOW = "alarmHubBatteryLow"
SMART_DETECT_LOITER = "smartDetectLoiterZone"
MOTION_LIGHT = "lightMotion"
DISRUPTED_CONDITIONS = "ringDisruptedConditions"
RECORDING_OFF = "recordingOff"
@staticmethod
@cache
def device_events() -> list[str]:
return [
EventType.MOTION.value,
EventType.NFC_CARD_SCANNED.value,
EventType.FINGERPRINT_IDENTIFIED.value,
EventType.RING.value,
EventType.SMART_DETECT.value,
EventType.SMART_AUDIO_DETECT.value,
EventType.SMART_DETECT_LINE.value,
]
@staticmethod
@cache
def device_events_set() -> set[str]:
return set(EventType.device_events())
@staticmethod
@cache
def motion_events() -> list[str]:
return [EventType.MOTION.value, EventType.SMART_DETECT.value]
@enum.unique
class StateType(ValuesEnumMixin, enum.StrEnum):
CONNECTED = "CONNECTED"
CONNECTING = "CONNECTING"
DISCONNECTED = "DISCONNECTED"
@enum.unique
class ProtectWSPayloadFormat(int, enum.Enum):
"""Websocket Payload formats."""
JSON = 1
UTF8String = 2
NodeBuffer = 3
@enum.unique
class SmartDetectObjectType(ValuesEnumMixin, enum.StrEnum):
PERSON = "person"
ANIMAL = "animal"
VEHICLE = "vehicle"
LICENSE_PLATE = "licensePlate"
PACKAGE = "package"
SMOKE = "alrmSmoke"
CMONX = "alrmCmonx"
SIREN = "alrmSiren"
BABY_CRY = "alrmBabyCry"
SPEAK = "alrmSpeak"
BARK = "alrmBark"
BURGLAR = "alrmBurglar"
CAR_HORN = "alrmCarHorn"
GLASS_BREAK = "alrmGlassBreak"
FACE = "face"
# old?
CAR = "car"
PET = "pet"
@cached_property
def audio_type(self) -> SmartDetectAudioType | None:
return OBJECT_TO_AUDIO_MAP.get(self)
@enum.unique
class SmartDetectAudioType(ValuesEnumMixin, enum.StrEnum):
SMOKE = "alrmSmoke"
CMONX = "alrmCmonx"
SMOKE_CMONX = "smoke_cmonx"
SIREN = "alrmSiren"
BABY_CRY = "alrmBabyCry"
SPEAK = "alrmSpeak"
BARK = "alrmBark"
BURGLAR = "alrmBurglar"
CAR_HORN = "alrmCarHorn"
GLASS_BREAK = "alrmGlassBreak"
@enum.unique
class DetectionColor(ValuesEnumMixin, enum.StrEnum):
BLACK = "black"
BLUE = "blue"
BROWN = "brown"
GRAY = "gray"
GREEN = "green"
ORANGE = "orange"
PINK = "pink"
PURPLE = "purple"
RED = "red"
WHITE = "white"
YELLOW = "yellow"
OBJECT_TO_AUDIO_MAP = {
SmartDetectObjectType.SMOKE: SmartDetectAudioType.SMOKE,
SmartDetectObjectType.CMONX: SmartDetectAudioType.CMONX,
SmartDetectObjectType.SIREN: SmartDetectAudioType.SIREN,
SmartDetectObjectType.BABY_CRY: SmartDetectAudioType.BABY_CRY,
SmartDetectObjectType.SPEAK: SmartDetectAudioType.SPEAK,
SmartDetectObjectType.BARK: SmartDetectAudioType.BARK,
SmartDetectObjectType.BURGLAR: SmartDetectAudioType.BURGLAR,
SmartDetectObjectType.CAR_HORN: SmartDetectAudioType.CAR_HORN,
SmartDetectObjectType.GLASS_BREAK: SmartDetectAudioType.GLASS_BREAK,
}
@enum.unique
class DoorbellMessageType(ValuesEnumMixin, enum.StrEnum):
LEAVE_PACKAGE_AT_DOOR = "LEAVE_PACKAGE_AT_DOOR"
DO_NOT_DISTURB = "DO_NOT_DISTURB"
CUSTOM_MESSAGE = "CUSTOM_MESSAGE"
IMAGE = "IMAGE"
@enum.unique
class LightModeEnableType(ValuesEnumMixin, enum.StrEnum):
DARK = "dark"
ALWAYS = "fulltime"
NIGHT = "night"
@enum.unique
class LightModeType(ValuesEnumMixin, enum.StrEnum):
MOTION = "motion"
WHEN_DARK = "always"
MANUAL = "off"
SCHEDULE = "schedule"
@enum.unique
class VideoMode(UnknownValuesEnumMixin, enum.StrEnum):
"""Camera video pipeline mode; unknown wire values coerce to ``UNKNOWN``."""
DEFAULT = "default"
HIGH_FPS = "highFps"
HOMEKIT = "homekit"
SPORT = "sport"
SLOW_SHUTTER = "slowShutter"
LPR_NONE_REFLEX = "lprNoneReflex"
LPR_REFLEX = "lprReflex"
UNKNOWN = "unknown"
@enum.unique
class AudioStyle(UnknownValuesEnumMixin, enum.StrEnum):
NATURE = "nature"
NOISE_REDUCED = "noiseReduced"
UNKNOWN = "unknown"
@enum.unique
class RecordingMode(ValuesEnumMixin, enum.StrEnum):
ADAPTIVE = "adaptive"
ALWAYS = "always"
NEVER = "never"
SCHEDULE = "schedule"
DETECTIONS = "detections"
@enum.unique
class AnalyticsOption(ValuesEnumMixin, enum.StrEnum):
NONE = "none"
ANONYMOUS = "anonymous"
FULL = "full"
@enum.unique
class RecordingType(ValuesEnumMixin, enum.StrEnum):
TIMELAPSE = "timelapse"
CONTINUOUS = "rotating"
DETECTIONS = "detections"
@enum.unique
class ResolutionStorageType(ValuesEnumMixin, enum.StrEnum):
UHD = "4K"
HD = "HD"
FREE = "free"
@enum.unique
class IRLEDMode(UnknownValuesEnumMixin, enum.StrEnum):
AUTO = "auto"
ON = "on"
AUTO_NO_LED = "autoFilterOnly"
OFF = "off"
MANUAL = "manual"
CUSTOM = "custom"
CUSTOM_FILTER_ONLY = "customFilterOnly"
UNKNOWN = "unknown"
@enum.unique
class MountType(UnknownValuesEnumMixin, enum.StrEnum):
NONE = "none"
LEAK = "leak"
DOOR = "door"
WINDOW = "window"
GARAGE = "garage"
UNKNOWN = "unknown"
@enum.unique
class SensorType(UnknownValuesEnumMixin, enum.StrEnum):
TEMPERATURE = "temperature"
LIGHT = "light"
HUMIDITY = "humidity"
UNKNOWN = "unknown"
@enum.unique
class SensorStatusType(UnknownValuesEnumMixin, enum.StrEnum):
OFFLINE = "offline"
UNKNOWN = "unknown"
SAFE = "safe"
NEUTRAL = "neutral"
LOW = "low"
HIGH = "high"
@enum.unique
class SensorScheduleMode(UnknownValuesEnumMixin, enum.StrEnum):
ALWAYS = "always"
WHEN_ARMED = "when_armed"
UNKNOWN = "unknown"
@enum.unique
class SleepStateType(ValuesEnumMixin, enum.StrEnum):
DISCONNECTED = "disconnected"
AWAKE = "awake"
START_SLEEP = "goingToSleep"
ASLEEP = "asleep"
WAKING = "waking"
@enum.unique
class AutoExposureMode(ValuesEnumMixin, enum.StrEnum):
MANUAL = "manual"
AUTO = "auto"
NONE = "none"
SHUTTER = "shutter"
FLICK50 = "flick50"
FLICK60 = "flick60"
@enum.unique
class FocusMode(ValuesEnumMixin, enum.StrEnum):
MANUAL = "manual"
AUTO = "auto"
NONE = "none"
ZTRIG = "ztrig"
TOUCH = "touch"
@enum.unique
class MountPosition(UnknownValuesEnumMixin, enum.StrEnum):
CEILING = "ceiling"
WALL = "wall"
DESK = "desk"
NONE = "none"
UNKNOWN = "unknown"
@enum.unique
class GeofencingSetting(ValuesEnumMixin, enum.StrEnum):
OFF = "off"
ALL_AWAY = "allAway"
@enum.unique
class MotionAlgorithm(ValuesEnumMixin, enum.StrEnum):
STABLE = "stable"
ENHANCED = "enhanced"
@enum.unique
class AudioCodecs(ValuesEnumMixin, enum.StrEnum):
AAC = "aac"
VORBIS = "vorbis"
OPUS = "opus"
@enum.unique
class LowMedHigh(ValuesEnumMixin, enum.StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
@enum.unique
class StorageType(UnknownValuesEnumMixin, enum.StrEnum):
DISK = "hdd"
RAID = "raid"
SD_CARD = "sdcard"
INTERNAL_SSD = "internalSSD"
UNKNOWN = "UNKNOWN"
@enum.unique
class FirmwareReleaseChannel(ValuesEnumMixin, enum.StrEnum):
INTERNAL = "internal"
ALPHA = "alpha"
BETA = "beta"
RELEASE_CANDIDATE = "release-candidate"
RELEASE = "release"
@enum.unique
class ChimeType(int, enum.Enum):
NONE = 0
MECHANICAL = 300
DIGITAL = 1000
@enum.unique
class SirenDuration(int, enum.Enum):
"""Valid siren play durations in seconds (as accepted by the public API)."""
FIVE = 5
TEN = 10
TWENTY = 20
THIRTY = 30
@enum.unique
class SirenConnectionType(UnknownValuesEnumMixin, enum.StrEnum):
"""Public-API siren wireless connection type (``connectionType`` field)."""
UCP4 = "ucp4"
LORA = "lora"
UNKNOWN = "unknown"
@enum.unique
class NvrArmModeStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Arm-manager status values embedded in the NVR ``armMode`` field."""
ARMING = "arming"
ARMED = "armed"
BREACH = "breach"
DISABLED = "disabled"
UNKNOWN = "unknown"
@enum.unique
class SensorAlarmType(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm sound of a ``sensorAlarm`` / ``alarmHub*`` event (``metadata.alarmType.text``)."""
SMOKE = "smoke"
CO = "CO"
GLASS_BREAK = "glassBreak"
SENSOR_BUTTON_PRESS = "sensorButtonPress"
TAMPER = "tamper"
SHORT = "short"
CUT = "cut"
UNKNOWN = "unknown"
@enum.unique
class SensorExtremeMetricType(UnknownValuesEnumMixin, enum.StrEnum):
"""Metric of a ``sensorExtremeValues`` event (``metadata.sensorType.text``)."""
TEMPERATURE = "temperature"
LIGHT = "light"
HUMIDITY = "humidity"
AQI = "aqi"
VAPE = "vape"
TVOC = "tvoc"
PM1P0 = "pm1p0"
PM2P5 = "pm2p5"
PM4P0 = "pm4p0"
PM10P0 = "pm10p0"
NOX = "nox"
CO2 = "co2"
VOC = "voc"
UNKNOWN = "unknown"
@enum.unique
class RelayInputCircuitState(UnknownValuesEnumMixin, enum.StrEnum):
"""Relay input circuit state (``relayInputChanged`` ``metadata.inputState.text``)."""
CIRCUIT_CLOSED = "circuitClosed"
CIRCUIT_OPEN = "circuitOpen"
UNKNOWN = "unknown"
@enum.unique
class SmokeTestSource(UnknownValuesEnumMixin, enum.StrEnum):
"""Origin of a ``sensorSmokeTest`` event (flat ``metadata.source``)."""
LOCAL = "local"
REMOTE = "remote"
UNKNOWN = "unknown"
@enum.unique
class EventButtonType(UnknownValuesEnumMixin, enum.StrEnum):
"""
Button of a ``sensorButtonPressed`` / ``alarmHubButtonPress`` event.
Sourced from ``metadata.button.text``.
"""
FUNCTION = "function"
ALARM_HUB_BUTTON = "alarmHubButton"
ARM = "arm"
DISARM = "disarm"
NIGHT = "night"
PANIC = "panic"
LEFT = "left"
RIGHT = "right"
INPUT1 = "input1"
INPUT2 = "input2"
MAIN = "main"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubInputType(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub input zone kind (``alarmHub.input[].inputType`` field)."""
MOTION = "MOTION"
ENTRY = "ENTRY"
SMOKE = "SMOKE"
GLASS_BREAK = "GLASS_BREAK"
EMERGENCY_BUTTON = "EMERGENCY_BUTTON"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubBatteryStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub backup-battery health (``alarmHub.battery.batteryStatus`` field)."""
OK = "ok"
LOW = "low"
CRITICAL = "critical"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubInputStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub input zone status (``alarmHub.input[].status`` field)."""
NORMAL = "normal"
ALARM = "alarm"
FAULT = "fault"
SHORT = "short"
CUT = "cut"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubCoverStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub tamper-cover status (``alarmHub.cover.status`` field)."""
OPEN = "open"
CLOSE = "close"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubOutputStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub output channel status (``alarmHub.output[].status`` field)."""
WET = "wet"
DRY = "dry"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubConnectionState(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub backup-battery wiring state (``alarmHub.battery.connection`` field)."""
CONNECTED = "connected"
DISCONNECTED = "disconnected"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubInputContactType(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub input contact wiring (``alarmHub.input[].type`` field)."""
NO = "no"
NC = "nc"
UNKNOWN = "unknown"
@enum.unique
class AlarmHubTamperStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Alarm-hub tamper state (``alarmHubDeviceTamper`` ``metadata.status.text``)."""
TAMPERED = "tampered"
RESTORED = "restored"
UNKNOWN = "unknown"
@enum.unique
class OnOffState(UnknownValuesEnumMixin, enum.StrEnum):
"""Generic on/off wire state used by several alarm-hub fields."""
ON = "on"
OFF = "off"
UNKNOWN = "unknown"
@enum.unique
class RelayOutputState(UnknownValuesEnumMixin, enum.StrEnum):
"""State of a single relay output channel (``state`` field)."""
ON = "on"
OFF = "off"
OFF_OTP = "offOtp"
UNKNOWN = "unknown"
@enum.unique
class RelayOutputRebootState(UnknownValuesEnumMixin, enum.StrEnum):
"""State the relay output is set to after a reboot/power cycle (``rebootState`` field)."""
RESTORE = "restore"
ON = "on"
OFF = "off"
UNKNOWN = "unknown"
@enum.unique
class RelayInputState(UnknownValuesEnumMixin, enum.StrEnum):
"""State of a single relay input channel."""
ON = "on"
OFF = "off"
UNKNOWN = "unknown"
@enum.unique
class RelayOutputType(UnknownValuesEnumMixin, enum.StrEnum):
"""Relay output device type (``outputs[].type`` field)."""
GARAGE_DOOR = "garageDoor"
GATE = "gate"
VALVE = "valve"
SIREN = "siren"
CUSTOM = "custom"
UNKNOWN = "unknown"
@enum.unique
class RelayInputActionTrigger(UnknownValuesEnumMixin, enum.StrEnum):
"""Relay input action trigger condition (``inputs[].actionTrigger`` field)."""
SWITCHED_ON = "switchedOn"
SWITCHED_OFF = "switchedOff"
UNKNOWN = "unknown"
@enum.unique
class RelayInputActionType(UnknownValuesEnumMixin, enum.StrEnum):
"""Relay input action effect (``inputs[].actionType`` field)."""
SET_OUTPUT_ON = "setOutputOn"
SET_OUTPUT_OFF = "setOutputOff"
TOGGLE_OUTPUT = "toggleOutput"
FOLLOW_INPUT = "followInput"
UNKNOWN = "unknown"
@enum.unique
class DeviceState(UnknownValuesEnumMixin, enum.StrEnum):
"""Public-API device connection state (``state`` field)."""
CONNECTED = "CONNECTED"
CONNECTING = "CONNECTING"
DISCONNECTED = "DISCONNECTED"
UNKNOWN = "unknown"
@enum.unique
class UlpUserStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Public-API UniFi Identity (ULP) user status (``status`` field)."""
ACTIVE = "ACTIVE"
DEACTIVATED = "DEACTIVATED"
UNKNOWN = "unknown"
@enum.unique
class ChannelQuality(UnknownValuesEnumMixin, enum.StrEnum):
"""RTSPS channel quality (``qualities`` field on rtsps-stream endpoints)."""
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
PACKAGE = "package"
UNKNOWN = "unknown"
@enum.unique
class FobAwayState(UnknownValuesEnumMixin, enum.StrEnum):
"""Key-fob presence/away state (``awayState`` field)."""
ONLINE = "ONLINE"
RECENTLY_SEEN = "RECENTLY_SEEN"
NO_RECENT_HEARTBEAT = "NO_RECENT_HEARTBEAT"
DEVICE_LOST = "DEVICE_LOST"
UNKNOWN = "unknown"
@enum.unique
class FobButton(UnknownValuesEnumMixin, enum.StrEnum):
"""Key-fob button kinds (``featureFlags.buttons`` items)."""
FUNCTION = "function"
ALARM_HUB_BUTTON = "alarmHubButton"
ARM = "arm"
DISARM = "disarm"
NIGHT = "night"
PANIC = "panic"
LEFT = "left"
RIGHT = "right"
INPUT1 = "input1"
INPUT2 = "input2"
MAIN = "main"
UNKNOWN = "unknown"
@enum.unique
class FobButtonLabels(UnknownValuesEnumMixin, enum.StrEnum):
"""Label style used when rendering a fob in button lists (``buttonLabels``)."""
SECURITY_ACTIONS = "securityActions"
POSITION_HINT = "positionHint"
UNKNOWN = "unknown"
@enum.unique
class SpeakerStatus(UnknownValuesEnumMixin, enum.StrEnum):
"""Public-API speaker runtime status (``speakerState.status`` field)."""
IDLE = "idle"
STREAMING = "streaming"
PLAYING = "playing"
TTS_PLAYING = "tts_playing"
UPLOADING = "uploading"
UNKNOWN = "unknown"
@enum.unique
class SpeakerMode(UnknownValuesEnumMixin, enum.StrEnum):
"""Public-API speaker mode (``speakerState.mode`` field)."""
LISTEN = "listen"
TALK = "talk"
UNKNOWN = "unknown"
@enum.unique
class LiveviewCycleMode(UnknownValuesEnumMixin, enum.StrEnum):
"""Public-API liveview slot cycle mode (``slots[].cycleMode`` field)."""
MOTION = "motion"
TIME = "time"
UNKNOWN = "unknown"
@enum.unique
class PermissionNode(UnknownValuesEnumMixin, enum.StrEnum):
CREATE = "create"
READ = "read"
WRITE = "write"
DELETE = "delete"
READ_MEDIA = "readmedia"
DELETE_MEDIA = "deletemedia"
READ_LIVE = "readlive"
UNKNOWN = "unknown"
@cached_property
def name(self) -> str:
"""Return the name."""
return self._name_
@cached_property
def value(self) -> str:
"""Return the value."""
return self._value_
@enum.unique
class HDRMode(UnknownValuesEnumMixin, enum.StrEnum):
NONE = "none"
NORMAL = "normal"
ALWAYS_ON = "superHdr"
UNKNOWN = "unknown"
@enum.unique
class PublicHdrMode(enum.StrEnum):
AUTO = "auto"
ON = "on"
OFF = "off"
@enum.unique
class AssetFileType(UnknownValuesEnumMixin, enum.StrEnum):
"""Public-API device asset file kind (``/v1/files/{fileType}``)."""
ANIMATIONS = "animations"
UNKNOWN = "unknown"
@enum.unique
class PosTransactionType(enum.StrEnum):
"""Kind of point-of-sale transaction ingested for a camera."""
SALE = "sale"
REFUND = "refund"
@enum.unique
class LensType(enum.StrEnum):
NONE = "none"
FULL_360 = "360"
WIDE = "wide"
TELESCOPIC = "tele"
DLSR_17 = "m43"
@enum.unique
class OsdOverlayLocation(UnknownValuesEnumMixin, enum.StrEnum):
"""
On-screen-display overlay location (``osdSettings.overlayLocation``).
Carries an ``UNKNOWN`` member so values added by newer firmware coerce
to ``UNKNOWN`` instead of raising during ``Camera`` deserialization.
"""
TOP_LEFT = "topLeft"
TOP_MIDDLE = "topMiddle"
TOP_RIGHT = "topRight"
BOTTOM_LEFT = "bottomLeft"
BOTTOM_MIDDLE = "bottomMiddle"
BOTTOM_RIGHT = "bottomRight"
UNKNOWN = "unknown"
DoorbellText = Annotated[str, StringConstraints(max_length=30)]
ICRCustomValue = Annotated[int, Field(ge=0, le=11)]
ICRLuxValue = Annotated[int, Field(ge=1, le=30)]
LEDLevel = Annotated[int, Field(ge=0, le=6)]
PercentInt = Annotated[int, Field(ge=0, le=101)]
TwoByteInt = Annotated[int, Field(ge=1, le=256)]
PercentFloat = Annotated[float, Field(ge=0, le=100)]
WDRLevel = Annotated[int, Field(ge=0, le=4)]
ICRSensitivity = Annotated[int, Field(ge=0, le=4)]
Percent = Annotated[float, Field(ge=0, le=1)]
RepeatTimes = Annotated[int, Field(ge=1, le=6)]
class PTZPositionDegree(BaseModel):
pan: float
tilt: float
zoom: float
class PTZPositionSteps(BaseModel):
focus: int
pan: int
tilt: int
zoom: int
class PTZPosition(BaseModel):
degree: PTZPositionDegree
steps: PTZPositionSteps
class PTZPresetPosition(BaseModel):
pan: int
tilt: int
zoom: int
# PTZ slot constant for home position
PTZ_HOME_SLOT: int = -1
class PTZPreset(BaseModel):
id: str
name: str
slot: int
ptz: PTZPresetPosition
class PTZPatrol(BaseModel):
id: str
name: str
slot: int
presets: list[int]
preset_movement_speed: int | None = Field(None, alias="presetMovementSpeed")
preset_duration_seconds: int = Field(alias="presetDurationSeconds")
camera: str
model_key: str = Field("ptzPatrol", alias="modelKey")
model_config = {"populate_by_name": True}
CoordType = Union[Percent, int, float]
class Version(BaseVersion):
def __str__(self) -> str:
super_str = super().__str__()
if self.pre is not None and self.pre[0] == "b":
super_str = super_str.replace("b", "-beta.")
return super_str
uilibs-uiprotect-317a52c/src/uiprotect/data/user.py 0000664 0000000 0000000 00000024102 15246377057 0022431 0 ustar 00root root 0000000 0000000 """UniFi Protect User models."""
from __future__ import annotations
from abc import abstractmethod
from datetime import datetime
from functools import cache
from typing import Any, Generic, Self, TypeVar
from pydantic.fields import PrivateAttr
from .base import ProtectBaseObject, ProtectModel, ProtectModelWithId
from .types import ModelType, PermissionNode
class Permission(ProtectBaseObject):
raw_permission: str
model: ModelType
nodes: set[PermissionNode]
obj_ids: set[str] | None = None
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
permission = data.get("rawPermission", "")
parts = permission.split(":")
if len(parts) < 2:
raise ValueError(f"Invalid permission: {permission}")
data["model"] = ModelType(parts[0])
if parts[1] == "*":
data["nodes"] = list(PermissionNode)
else:
data["nodes"] = [PermissionNode(n) for n in parts[1].split(",")]
if len(parts) == 3 and parts[2] != "*":
if parts[2] == "$":
data["obj_ids"] = ["self"]
else:
data["obj_ids"] = parts[2].split(",")
return super().unifi_dict_to_dict(data)
def unifi_dict( # type: ignore[override]
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> str:
return self.raw_permission
@property
def objs(self) -> list[ProtectModelWithId] | None:
if self.obj_ids == {"self"} or self.obj_ids is None:
return None
devices = getattr(self._api.bootstrap, self.model.devices_key)
return [devices[oid] for oid in self.obj_ids]
class Group(ProtectModelWithId):
name: str
permissions: list[Permission]
type: str
is_default: bool
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
if "permissions" in data:
permissions = data.pop("permissions")
data["permissions"] = [{"rawPermission": p} for p in permissions]
return super().unifi_dict_to_dict(data)
class UserLocation(ProtectModel):
is_away: bool
latitude: float | None = None
longitude: float | None = None
class CloudAccount(ProtectModelWithId):
first_name: str
last_name: str
email: str
user_id: str
name: str
location: UserLocation | None = None
profile_img: str | None = None
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "user": "userId"}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
# id and cloud ID are always the same
if "id" in data:
data["cloudId"] = data["id"]
if "location" in data and data["location"] is None:
del data["location"]
return data
@property
def user(self) -> User:
return self._api.bootstrap.users[self.user_id]
class UserFeatureFlags(ProtectBaseObject):
notifications_v2: bool
class User(ProtectModelWithId):
permissions: list[Permission]
last_login_ip: str | None = None
last_login_time: datetime | None = None
is_owner: bool
enable_notifications: bool
has_accepted_invite: bool
all_permissions: list[Permission]
scopes: list[str] | None = None
location: UserLocation | None = None
name: str
first_name: str
last_name: str
email: str | None = None
local_username: str
group_ids: list[str]
cloud_account: CloudAccount | None = None
feature_flags: UserFeatureFlags
_groups: list[Group] | None = PrivateAttr(None)
_perm_cache: dict[str, bool] = PrivateAttr({})
def __init__(self, **data: Any) -> None:
if "permissions" in data:
permissions = data.pop("permissions")
data["permissions"] = [
{"raw_permission": p} if isinstance(p, str) else p for p in permissions
]
if "allPermissions" in data:
permissions = data.pop("allPermissions")
data["allPermissions"] = [
{"raw_permission": p} if isinstance(p, str) else p for p in permissions
]
super().__init__(**data)
@classmethod
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
if "permissions" in data:
permissions = data.pop("permissions")
data["permissions"] = [{"rawPermission": p} for p in permissions]
if "allPermissions" in data:
permissions = data.pop("allPermissions")
data["allPermissions"] = [{"rawPermission": p} for p in permissions]
return super().unifi_dict_to_dict(data)
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "groups": "groupIds"}
def unifi_dict(
self,
data: dict[str, Any] | None = None,
exclude: set[str] | None = None,
) -> dict[str, Any]:
data = super().unifi_dict(data=data, exclude=exclude)
if "location" in data and data["location"] is None:
del data["location"]
return data
@property
def groups(self) -> list[Group]:
"""
Groups the user is in
Will always be empty if the user only has read only access.
"""
if self._groups is not None:
return self._groups
self._groups = [
self._api.bootstrap.groups[g]
for g in self.group_ids
if g in self._api.bootstrap.groups
]
return self._groups
def can(
self,
model: ModelType,
node: PermissionNode,
obj: ProtectModelWithId | None = None,
) -> bool:
"""Checks if a user can do a specific action"""
check_self = False
if model is self.model and obj is not None and obj.id == self.id:
perm_str = f"{model.value}:{node.value}:$"
check_self = True
else:
perm_str = (
f"{model.value}:{node.value}:{obj.id if obj is not None else '*'}"
)
if perm_str in self._perm_cache:
return self._perm_cache[perm_str]
for perm in self.all_permissions:
if model is not perm.model or node not in perm.nodes:
continue
if perm.obj_ids is None:
self._perm_cache[perm_str] = True
return True
if check_self and perm.obj_ids == {"self"}:
self._perm_cache[perm_str] = True
return True
if perm.obj_ids is not None and obj is not None and obj.id in perm.obj_ids:
self._perm_cache[perm_str] = True
return True
self._perm_cache[perm_str] = False
return False
T = TypeVar("T", bound="ProtectModelWithId")
class UlpUserKeyringBase(Generic[T]):
"""Base class for collections of ULP users and keyrings."""
def __init__(self) -> None:
self._id_to_item: dict[str, T] = {}
def __len__(self) -> int:
return len(self._id_to_item)
@classmethod
def from_list(cls, items: list[T]) -> Self:
instance = cls()
for item in items:
instance.add(item)
return instance
def add(self, item: T) -> None:
"""Add an item to the collection."""
self._id_to_item[item.id] = item
def remove(self, item: T) -> None:
"""Remove an item from the collection."""
self._id_to_item.pop(item.id, None)
def by_id(self, item_id: str) -> T | None:
"""Retrieve an item by its ID."""
return self._id_to_item.get(item_id)
@abstractmethod
def by_ulp_id(self, item_id: str) -> T | None:
"""Retrieve an item by its ULP ID."""
def as_list(self) -> list[T]:
return list(self._id_to_item.values())
def __eq__(self, other: object) -> bool:
if not isinstance(other, UlpUserKeyringBase):
return NotImplemented
return self._id_to_item == other._id_to_item
class Keyring(ProtectModelWithId):
device_type: str
device_id: str
registry_type: str
registry_id: str
last_activity: datetime | None = None
ulp_user: str
class Keyrings(UlpUserKeyringBase[Keyring]):
def __init__(self) -> None:
super().__init__()
self._keyrings_by_registry_id: dict[str, Keyring] = {}
self._keyrings_by_ulp_user: dict[str, Keyring] = {}
def add(self, keyring: Keyring) -> None:
super().add(keyring)
self._keyrings_by_registry_id[keyring.registry_id] = keyring
self._keyrings_by_ulp_user[keyring.ulp_user] = keyring
def remove(self, keyring: Keyring) -> None:
super().remove(keyring)
self._keyrings_by_registry_id.pop(keyring.registry_id, None)
self._keyrings_by_ulp_user.pop(keyring.ulp_user, None)
def by_ulp_id(self, ulp_id: str) -> Keyring | None:
return self._keyrings_by_ulp_user.get(ulp_id)
def by_registry_id(self, registry_id: str) -> Keyring | None:
return self._keyrings_by_registry_id.get(registry_id)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Keyrings):
return NotImplemented
return super().__eq__(other)
class UlpUser(ProtectModelWithId):
ulp_id: str
first_name: str
last_name: str
full_name: str
avatar: str
status: str
class UlpUsers(UlpUserKeyringBase[UlpUser]):
def __init__(self) -> None:
super().__init__()
self._users_by_ulp_id: dict[str, UlpUser] = {}
def add(self, user: UlpUser) -> None:
super().add(user)
self._users_by_ulp_id[user.ulp_id] = user
def remove(self, user: UlpUser) -> None:
super().remove(user)
self._users_by_ulp_id.pop(user.ulp_id, None)
def by_ulp_id(self, ulp_id: str) -> UlpUser | None:
return self._users_by_ulp_id.get(ulp_id)
def __eq__(self, other: object) -> bool:
if not isinstance(other, UlpUsers):
return NotImplemented
return super().__eq__(other)
uilibs-uiprotect-317a52c/src/uiprotect/data/websocket.py 0000664 0000000 0000000 00000015205 15246377057 0023445 0 ustar 00root root 0000000 0000000 """Classes for decoding/encoding data from UniFi OS Websocket"""
from __future__ import annotations
import base64
import enum
import struct
import zlib
from dataclasses import dataclass
from functools import cache
from typing import TYPE_CHECKING, Any
import orjson
from .._compat import cached_property
from ..exceptions import WSDecodeError, WSEncodeError
from .types import ProtectWSPayloadFormat
if TYPE_CHECKING:
from .base import ProtectModelWithId
WS_HEADER_SIZE = 8
@dataclass(slots=True)
class WSPacketFrameHeader:
packet_type: int
payload_format: int
deflated: int
unknown: int
payload_size: int
@enum.unique
class WSAction(enum.StrEnum):
ADD = "add"
UPDATE = "update"
REMOVE = "remove"
@dataclass(slots=True)
class WSSubscriptionMessage:
action: WSAction
new_update_id: str
changed_data: dict[str, Any]
new_obj: ProtectModelWithId | None = None
old_obj: ProtectModelWithId | None = None
_PACKET_STRUCT = struct.Struct("!bbbbi")
class BaseWSPacketFrame:
unpack = _PACKET_STRUCT.unpack
pack = _PACKET_STRUCT.pack
data: Any
position: int = 0
header: WSPacketFrameHeader | None = None
payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer
is_deflated: bool = False
length: int = 0
def __repr__(self) -> str:
return f"<{self.__class__.__name__} header={self.header} data={self.data}>"
def set_data_from_binary(self, data: bytes) -> None:
self.data = data
if self.header is not None and self.header.deflated:
self.data = zlib.decompress(self.data)
def get_binary_from_data(self) -> bytes:
raise NotImplementedError
@staticmethod
@cache
def klass_from_format(format_raw: int) -> type[BaseWSPacketFrame]:
payload_format = ProtectWSPayloadFormat(format_raw)
if payload_format == ProtectWSPayloadFormat.JSON:
return WSJSONPacketFrame
return WSRawPacketFrame
@staticmethod
def from_binary(
data: bytes,
position: int = 0,
klass: type[WSRawPacketFrame] | None = None,
) -> BaseWSPacketFrame:
"""
Decode a unifi updates websocket frame.
The format of the frame is
b: packet_type
b: payload_format
b: deflated
b: unknown
i: payload_size
"""
header_end = position + WS_HEADER_SIZE
payload_size: int
try:
(
packet_type,
payload_format,
deflated,
unknown,
payload_size,
) = BaseWSPacketFrame.unpack(
data[position:header_end],
)
except struct.error as e:
raise WSDecodeError from e
if klass is None:
frame = WSRawPacketFrame.klass_from_format(payload_format)()
else:
frame = klass()
frame.payload_format = ProtectWSPayloadFormat(payload_format)
frame.header = WSPacketFrameHeader(
packet_type=packet_type,
payload_format=payload_format,
deflated=deflated,
unknown=unknown,
payload_size=payload_size,
)
frame.length = WS_HEADER_SIZE + payload_size
frame.is_deflated = bool(deflated)
frame_end = header_end + payload_size
frame.set_data_from_binary(data[header_end:frame_end])
return frame
@property
def packed(self) -> bytes:
if self.header is None:
raise WSEncodeError("No header to encode")
data = self.get_binary_from_data()
header = self.pack(
self.header.packet_type,
self.header.payload_format,
self.header.deflated,
self.header.unknown,
len(data),
)
return header + data
class WSRawPacketFrame(BaseWSPacketFrame):
data: bytes = b""
def get_binary_from_data(self) -> bytes:
data = self.data
if self.is_deflated:
data = zlib.compress(data)
return data
class WSJSONPacketFrame(BaseWSPacketFrame):
data: dict[str, Any] = {}
payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer
def set_data_from_binary(self, data: bytes) -> None:
if self.header is not None and self.header.deflated:
data = zlib.decompress(data)
self.data = orjson.loads(data)
def get_binary_from_data(self) -> bytes:
data = self.json
if self.is_deflated:
data = zlib.compress(data)
return data
@property
def json(self) -> bytes:
return orjson.dumps(self.data)
class WSPacket:
"""Class to handle a unifi protect websocket packet."""
_raw: bytes
_raw_encoded: str | None = None
_action_frame: BaseWSPacketFrame | None = None
_data_frame: BaseWSPacketFrame | None = None
def __init__(self, data: bytes) -> None:
self._raw = data
def __repr__(self) -> str:
return f"<{self.__class__.__name__} action_frame={self.action_frame} data_frame={self.data_frame}>"
def decode(self) -> None:
data = self._raw
self._action_frame = WSRawPacketFrame.from_binary(data)
length = self._action_frame.length
self._data_frame = WSRawPacketFrame.from_binary(data, length)
@cached_property
def action_frame(self) -> BaseWSPacketFrame:
if self._action_frame is None:
self.decode()
if TYPE_CHECKING:
assert self._action_frame is not None
assert self._data_frame is not None
self.__dict__["data_frame"] = self._data_frame
return self._action_frame
@cached_property
def data_frame(self) -> BaseWSPacketFrame:
if self._data_frame is None:
self.decode()
if TYPE_CHECKING:
assert self._action_frame is not None
assert self._data_frame is not None
self.__dict__["action_frame"] = self._action_frame
return self._data_frame
@property
def raw(self) -> bytes:
return self._raw
@raw.setter
def raw(self, data: bytes) -> None:
self._raw = data
self._action_frame = None
self._data_frame = None
self._raw_encoded = None
self.__dict__.pop("data_frame", None)
self.__dict__.pop("action_frame", None)
@property
def raw_base64(self) -> str:
if self._raw_encoded is None:
self._raw_encoded = base64.b64encode(self._raw).decode("utf-8")
return self._raw_encoded
def pack_frames(self) -> bytes:
self._raw_encoded = None
self._raw = self.action_frame.packed + self.data_frame.packed
return self._raw
uilibs-uiprotect-317a52c/src/uiprotect/devices/ 0000775 0000000 0000000 00000000000 15246377057 0021613 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/devices/__init__.py 0000664 0000000 0000000 00000000437 15246377057 0023730 0 ustar 00root root 0000000 0000000 """Public device-state contract for UniFi Protect."""
from __future__ import annotations
from .dispatcher import DeviceDispatcher
from .protect_device_change import DeviceChange, ProtectDeviceChange
__all__ = [
"DeviceChange",
"DeviceDispatcher",
"ProtectDeviceChange",
]
uilibs-uiprotect-317a52c/src/uiprotect/devices/dispatcher.py 0000664 0000000 0000000 00000014371 15246377057 0024321 0 ustar 00root root 0000000 0000000 """Stateless public devices dispatcher over the PublicBootstrap device stores."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from ..data.types import ModelType
from ..data.websocket import WSAction
from ..utils import to_snake_case
from .protect_device_change import DeviceChange, ProtectDeviceChange
if TYPE_CHECKING:
from collections.abc import Callable
from ..api import ProtectApiClient
from ..data.base import ProtectModelWithId
from ..data.websocket import WSSubscriptionMessage
_LOGGER = logging.getLogger(__name__)
class DeviceDispatcher:
"""Map a single per-device WS message to a typed ``ProtectDeviceChange``."""
def __init__(self, api: ProtectApiClient) -> None:
self._api = api
self._subscribers: list[Callable[[ProtectDeviceChange], None]] = []
def add_subscriber(self, cb: Callable[[ProtectDeviceChange], None]) -> None:
self._subscribers.append(cb)
def remove_subscriber(self, cb: Callable[[ProtectDeviceChange], None]) -> None:
# Idempotent: the unsubscribe callable may fire more than once
# (e.g. double cleanup on HA reload).
if cb in self._subscribers:
self._subscribers.remove(cb)
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
def dispatch(self, msg: WSSubscriptionMessage) -> None:
change = self._derive(msg)
if change is not None:
self._fan_out(change)
def _derive(self, msg: WSSubscriptionMessage) -> ProtectDeviceChange | None:
changed = msg.changed_data or {}
model_key = changed.get("modelKey")
if model_key:
model_type = ModelType.from_string(model_key)
else:
# Synthetic frames (e.g. the detection-transition devices-WS update)
# carry only the flipped fields in ``changed_data`` and no
# ``modelKey``; ``new_obj`` is then the authoritative source of the
# model type. Real device-WS frames always include ``modelKey``.
model_type = getattr(msg.new_obj, "model", None) or ModelType.UNKNOWN
if msg.action is WSAction.REMOVE:
return self._derive_remove(msg, changed, model_type)
return self._derive_upsert(msg, changed, model_type)
def _derive_remove(
self,
msg: WSSubscriptionMessage,
changed: dict[str, Any],
model_type: ModelType,
) -> ProtectDeviceChange | None:
# Only surface removals for types the public API actually exposes;
# unsupported model keys (e.g. doorlock) never reach a store.
if not self._api.public_bootstrap.supports_device(model_type):
return None
old = msg.old_obj
device_id = self._object_id(old) or changed.get("id")
if device_id is None:
_LOGGER.debug(
"Devices-WS REMOVE without resolvable id (modelKey=%s) — "
"dropping frame",
changed.get("modelKey"),
)
return None
return ProtectDeviceChange(
change=DeviceChange.REMOVED,
model_type=model_type,
device_id=str(device_id),
device_mac=getattr(old, "mac", None) if old is not None else None,
model=None,
)
def _derive_upsert(
self,
msg: WSSubscriptionMessage,
changed: dict[str, Any],
model_type: ModelType,
) -> ProtectDeviceChange | None:
new = msg.new_obj
if new is None:
# Benign desync: an ADD/UPDATE whose merged model is missing (update
# for an uncached id, or a merge/construct failure already surfaced
# by PublicBootstrap). Not a contract change — drop quietly.
_LOGGER.debug(
"Devices-WS %s without merged model (id=%s) — dropping frame",
msg.action,
changed.get("id"),
)
return None
device_id = self._object_id(new) or changed.get("id")
if device_id is None:
return None
device_mac = self._api.public_bootstrap.get_device_mac(str(device_id))
if msg.action is WSAction.ADD:
return ProtectDeviceChange(
change=DeviceChange.ADDED,
model_type=model_type,
device_id=str(device_id),
device_mac=device_mac,
model=new,
)
return ProtectDeviceChange(
change=DeviceChange.UPDATED,
model_type=model_type,
device_id=str(device_id),
device_mac=device_mac,
model=new,
changed_fields=self._changed_fields(new, changed),
)
@staticmethod
def _object_id(obj: ProtectModelWithId | None) -> str | None:
return getattr(obj, "id", None) if obj is not None else None
@staticmethod
def _changed_fields(
model: ProtectModelWithId, changed: dict[str, object]
) -> frozenset[str]:
# ``new_obj is old_obj`` for UPDATE (in-place merge), so models can't be
# diffed — derive the changed set from the payload keys instead, cleaned
# through the model's own remap/snake-case pass.
payload = {k: v for k, v in changed.items() if k not in ("id", "modelKey")}
if not payload:
return frozenset()
try:
cleaned = type(model).unifi_dict_to_dict(dict(payload))
except Exception:
# Keep the key space stable for membership checks: best-effort
# snake_case the raw payload keys instead of leaking camelCase.
_LOGGER.debug(
"changed_fields conversion failed for %s — falling back to "
"snake_cased payload keys",
type(model).__name__,
exc_info=True,
)
return frozenset(to_snake_case(k) for k in payload)
return frozenset(cleaned)
def _fan_out(self, change: ProtectDeviceChange) -> None:
# Snapshot: a subscriber may unsubscribe mid-delivery, mutating the list.
for cb in tuple(self._subscribers):
try:
cb(change)
except Exception:
_LOGGER.exception(
"Exception while running subscribe_devices subscriber"
)
uilibs-uiprotect-317a52c/src/uiprotect/devices/protect_device_change.py 0000664 0000000 0000000 00000002355 15246377057 0026476 0 ustar 00root root 0000000 0000000 """Value types for the public device-state contract."""
from __future__ import annotations
import enum
from pydantic import BaseModel, ConfigDict, Field
from ..data.base import ProtectModelWithId
from ..data.types import ModelType
class DeviceChange(enum.StrEnum):
"""Lifecycle tag for a ProtectDeviceChange dispatch."""
ADDED = "added"
UPDATED = "updated"
REMOVED = "removed"
class ProtectDeviceChange(BaseModel):
"""Frozen public value type emitted by ``subscribe_devices``."""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
change: DeviceChange
model_type: ModelType
device_id: str
# Resolved from ``device_id`` against the public bootstrap device stores at
# emit time; ``None`` until the device lands in the cache on the next
# ``update_public()`` / reconnect, or for a REMOVED device with no cached
# pre-removal object.
device_mac: str | None = None
# Merged post-``process_devices_ws_messages`` public model. ``None`` for
# REMOVED (only an id / modelKey reference is delivered).
model: ProtectModelWithId | None = Field(default=None, repr=False)
# Populated for UPDATED; empty for ADDED / REMOVED.
changed_fields: frozenset[str] = frozenset()
uilibs-uiprotect-317a52c/src/uiprotect/events/ 0000775 0000000 0000000 00000000000 15246377057 0021475 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/events/__init__.py 0000664 0000000 0000000 00000000771 15246377057 0023613 0 ustar 00root root 0000000 0000000 """Public events contract for UniFi Protect."""
from __future__ import annotations
from .protect_event import (
EVENT_TYPE_TO_CHANNEL,
INSTANTANEOUS_EVENT_TYPES,
EventChange,
EventIdentity,
ProtectEvent,
ProtectEventChannel,
UlpUserIdentity,
UnknownIdentity,
)
__all__ = [
"EVENT_TYPE_TO_CHANNEL",
"INSTANTANEOUS_EVENT_TYPES",
"EventChange",
"EventIdentity",
"ProtectEvent",
"ProtectEventChannel",
"UlpUserIdentity",
"UnknownIdentity",
]
uilibs-uiprotect-317a52c/src/uiprotect/events/_mapping.py 0000664 0000000 0000000 00000002317 15246377057 0023644 0 ustar 00root root 0000000 0000000 """Map public-API ``PublicEvent`` instances onto the public ``ProtectEvent``."""
from __future__ import annotations
from typing import TYPE_CHECKING
from .protect_event import (
EventIdentity,
ProtectEvent,
ProtectEventChannel,
)
if TYPE_CHECKING:
from datetime import datetime
from ..data.public_event import PublicEvent
def event_to_protect_event(
raw: PublicEvent,
channel: ProtectEventChannel,
identity: EventIdentity | None,
*,
end_override: datetime | None = None,
device_mac: str | None = None,
) -> ProtectEvent:
"""Build a frozen ``ProtectEvent`` from a validated ``PublicEvent``."""
if raw.device_id is None:
raise ValueError("device_id must be present")
end = end_override if end_override is not None else raw.end
metadata = raw.metadata
return ProtectEvent(
id=raw.id,
type=raw.type,
channel=channel,
device_id=raw.device_id,
device_mac=device_mac,
start=raw.start,
end=end,
smart_detect_types=tuple(raw.smart_detect_types),
identity=identity,
alarm_type=metadata.alarm_type if metadata is not None else None,
metadata=metadata,
raw=raw,
)
uilibs-uiprotect-317a52c/src/uiprotect/events/dispatcher.py 0000664 0000000 0000000 00000024241 15246377057 0024200 0 ustar 00root root 0000000 0000000 """Stateless public events dispatcher over the single PublicBootstrap store."""
from __future__ import annotations
import asyncio
import logging
from datetime import timedelta
from typing import TYPE_CHECKING
from ..data.websocket import WSAction
from ..utils import utc_now
from ._mapping import event_to_protect_event
from .enricher import EventEnricher
from .protect_event import (
EVENT_TYPE_TO_CHANNEL,
INSTANTANEOUS_EVENT_TYPES,
EventChange,
ProtectEvent,
ProtectEventChannel,
)
if TYPE_CHECKING:
from collections.abc import Callable
from ..api import ProtectApiClient
from ..data.public_event import PublicEvent
_LOGGER = logging.getLogger(__name__)
EVENTS_ACTIVE_TTL: timedelta = timedelta(minutes=30)
EVENTS_TTL_SWEEP_INTERVAL: timedelta = timedelta(minutes=15)
EVENTS_RECONNECT_STALENESS_WINDOW: timedelta = timedelta(hours=1)
class EventDispatcher:
"""Derive ``(ProtectEvent, EventChange)`` from the single event store."""
def __init__(self, api: ProtectApiClient) -> None:
self._api = api
self._enricher = EventEnricher(api)
self._subscribers: list[Callable[[ProtectEvent, EventChange], None]] = []
self._sweep_task: asyncio.Task[None] | None = None
def add_subscriber(self, cb: Callable[[ProtectEvent, EventChange], None]) -> None:
self._subscribers.append(cb)
def remove_subscriber(
self, cb: Callable[[ProtectEvent, EventChange], None]
) -> None:
# Idempotent: the unsubscribe callable returned by ``subscribe_events``
# may be invoked more than once (e.g. double cleanup on HA reload).
if cb in self._subscribers:
self._subscribers.remove(cb)
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
def _device_mac(self, device_id: str | None) -> str | None:
if device_id is None:
return None
return self._api.public_bootstrap.get_device_mac(device_id)
def active_events(self, device_id: str | None = None) -> list[ProtectEvent]:
out: list[ProtectEvent] = []
for raw in self._api.public_bootstrap.events.values():
if raw.end is not None:
continue
channel = EVENT_TYPE_TO_CHANNEL.get(raw.type, ProtectEventChannel.OTHER)
if channel is ProtectEventChannel.OTHER or raw.device_id is None:
continue
if device_id is not None and raw.device_id != device_id:
continue
out.append(
event_to_protect_event(
raw,
channel,
self._enricher.enrich(raw),
device_mac=self._device_mac(raw.device_id),
)
)
return out
def dispatch(
self,
action: WSAction,
new_event: PublicEvent | None,
old_event: PublicEvent | None,
) -> None:
# ``new_event`` is the post-merge cached object (``None`` only on
# REMOVE); ``old_event`` is the pre-merge snapshot the store handed
# back. The subject is whichever one carries the event payload.
subject = new_event if new_event is not None else old_event
if subject is None:
return
channel = EVENT_TYPE_TO_CHANNEL.get(subject.type, ProtectEventChannel.OTHER)
# Drop administrative events (provision, factoryReset, fwUpdate, …)
# that land in the OTHER channel while keeping detection / sensor /
# alarm-hub / access events that consumers care about.
if channel is ProtectEventChannel.OTHER:
return
if subject.device_id is None:
_LOGGER.warning(
"Public event %s missing required 'device' field — dropping",
subject.id,
)
return
for event, change in self._derive(
action, subject, channel, new_event, old_event
):
self._fan_out(event, change)
def _derive(
self,
action: WSAction,
subject: PublicEvent,
channel: ProtectEventChannel,
new_event: PublicEvent | None,
old_event: PublicEvent | None,
) -> list[tuple[ProtectEvent, EventChange]]:
identity = self._enricher.enrich(subject)
device_mac = self._device_mac(subject.device_id)
# The single idempotency guard: was the event already terminal in the
# store before this frame? A retransmit/replay finds it so and is
# suppressed.
old_terminal = old_event is not None and old_event.end is not None
instantaneous = subject.type in INSTANTANEOUS_EVENT_TYPES
if action is WSAction.ADD and (subject.end is not None or instantaneous):
# Close-window: emit STARTED + ENDED on first sight only.
if old_terminal:
return []
end_value = subject.end if subject.end is not None else subject.start
# Close the event in the store so a replay ADD is suppressed (its
# snapshot will then carry ``end``) and ``active_events`` does not
# surface an instantaneous event as still open.
if subject.end is None and new_event is not None:
new_event.end = end_value
event = event_to_protect_event(
subject,
channel,
identity,
end_override=end_value,
device_mac=device_mac,
)
return [(event, EventChange.STARTED), (event, EventChange.ENDED)]
if action is WSAction.ADD:
change = EventChange.STARTED
elif action is WSAction.REMOVE:
if old_terminal:
return []
change = EventChange.REMOVED
elif subject.end is not None:
if old_terminal:
return []
change = EventChange.ENDED
else:
change = EventChange.UPDATED
return [
(
event_to_protect_event(
subject, channel, identity, device_mac=device_mac
),
change,
)
]
def _fan_out(self, event: ProtectEvent, change: EventChange) -> None:
# Snapshot: a subscriber may unsubscribe (itself or another) mid-delivery,
# mutating the list and skipping callbacks if iterated live.
for cb in tuple(self._subscribers):
try:
cb(event, change)
except Exception:
_LOGGER.exception("Exception while running subscribe_events subscriber")
def start_ttl_sweep(self) -> None:
if self._sweep_task is not None and not self._sweep_task.done():
return
self._sweep_task = asyncio.create_task(self._ttl_sweep_loop())
self._sweep_task.add_done_callback(self._on_sweep_task_done)
def stop_ttl_sweep(self) -> None:
if self._sweep_task is not None:
self._sweep_task.cancel()
self._sweep_task = None
@staticmethod
def _on_sweep_task_done(task: asyncio.Task[None]) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
_LOGGER.error("TTL sweep loop terminated unexpectedly", exc_info=exc)
def sweep_stale(self) -> int:
"""Force-end events open past :data:`EVENTS_ACTIVE_TTL`."""
count = self._sweep(EVENTS_ACTIVE_TTL)
if count > 0:
_LOGGER.warning("TTL-swept %d stale active events", count)
return count
def flush_stale_on_reconnect(self) -> int:
"""Force-end events open past the reconnect staleness window."""
return self._sweep(EVENTS_RECONNECT_STALENESS_WINDOW)
def force_end_on_events_reconnect(self) -> int:
"""
Reconcile the store after an events-WS reconnect.
Events arrive *only* on the events WS, so an END missed during the gap
leaves the event active and a camera's derived ``is_*_currently_detected``
stuck ON. The staleness window is 1 h, which would miss a short blip, so
detection-channel events are force-ended regardless of age — a genuinely
still-active detection re-asserts on its next frame. Other channels keep
the age gate.
"""
return self._sweep(EVENTS_RECONNECT_STALENESS_WINDOW, force_detection=True)
def _sweep(
self, staleness_window: timedelta, *, force_detection: bool = False
) -> int:
now = utc_now()
cutoff = now - staleness_window
pb = self._api.public_bootstrap
ended: list[PublicEvent] = []
for raw in list(pb.events.values()):
if raw.end is not None:
continue
channel = EVENT_TYPE_TO_CHANNEL.get(raw.type, ProtectEventChannel.OTHER)
if channel is ProtectEventChannel.OTHER or raw.device_id is None:
continue
forced = force_detection and channel is ProtectEventChannel.DETECTION
if not forced and raw.start >= cutoff:
continue
# Mark the stored event ended so a later close retransmit is
# suppressed by the dispatch chokepoint and derivation stays
# consistent.
raw.end = now
event = event_to_protect_event(
raw,
channel,
self._enricher.enrich(raw),
device_mac=self._device_mac(raw.device_id),
)
self._fan_out(event, EventChange.ENDED)
ended.append(raw)
# Sync the owning cameras' derived detection state through the store's
# single choke point and push the resulting device updates so pull and
# subscribe_devices consumers see the flag drop, not just the TTL sweep.
for update in pb._sync_force_ended_events(ended):
self._api.emit_devices_message(update)
return len(ended)
async def _ttl_sweep_loop(self) -> None:
try:
while True:
await asyncio.sleep(EVENTS_TTL_SWEEP_INTERVAL.total_seconds())
try:
self.sweep_stale()
except Exception:
_LOGGER.exception("TTL sweep iteration failed — continuing loop")
except asyncio.CancelledError:
return
uilibs-uiprotect-317a52c/src/uiprotect/events/enricher.py 0000664 0000000 0000000 00000003740 15246377057 0023652 0 ustar 00root root 0000000 0000000 """Identity enrichment for public events (ULP-only, read-only)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..data.types import EventType
from .protect_event import EventIdentity, UlpUserIdentity, UnknownIdentity
if TYPE_CHECKING:
from ..api import ProtectApiClient
from ..data.public_event import PublicEvent
class EventEnricher:
"""Resolve ``EventIdentity`` for credential events."""
def __init__(self, api: ProtectApiClient) -> None:
self._api = api
def enrich(self, raw: PublicEvent) -> EventIdentity | None:
if raw.type is EventType.NFC_CARD_SCANNED:
return self._resolve_nfc(raw)
if raw.type is EventType.FINGERPRINT_IDENTIFIED:
return self._resolve_fingerprint(raw)
return None
def _resolve_nfc(self, raw: PublicEvent) -> EventIdentity:
metadata = raw.metadata
if metadata is None or metadata.nfc is None:
return UnknownIdentity(reason="no_metadata")
ulp_id = metadata.nfc.ulp_id
if ulp_id is None:
return UnknownIdentity(reason="ulp_id_null")
return self._lookup_ulp_user(ulp_id)
def _resolve_fingerprint(self, raw: PublicEvent) -> EventIdentity:
metadata = raw.metadata
if metadata is None or metadata.fingerprint is None:
return UnknownIdentity(reason="no_metadata")
ulp_id = metadata.fingerprint.ulp_id
if ulp_id is None:
return UnknownIdentity(reason="ulp_id_null")
return self._lookup_ulp_user(ulp_id)
def _lookup_ulp_user(self, ulp_id: str) -> EventIdentity:
# Pure read against the single identity store; a miss self-heals on
# the next ``update_public`` / reconnect resync, no hot-path
# scheduling.
cached = self._api.public_bootstrap.ulp_users.get(ulp_id)
if cached is None:
return UnknownIdentity(reason="ulp_user_not_cached")
return UlpUserIdentity(ulp_id=ulp_id, user=cached)
uilibs-uiprotect-317a52c/src/uiprotect/events/protect_event.py 0000664 0000000 0000000 00000013010 15246377057 0024723 0 ustar 00root root 0000000 0000000 """Value types for the public events contract."""
from __future__ import annotations
import enum
from datetime import datetime
from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field
from ..data.public_devices import PublicUlpUser
from ..data.public_event import PublicEvent, PublicEventMetadata
from ..data.types import EventType, SensorAlarmType, SmartDetectObjectType
class ProtectEventChannel(enum.StrEnum):
"""High-level grouping for public events."""
DETECTION = "detection"
SENSOR = "sensor"
ALARM_HUB = "alarm_hub"
ACCESS = "access"
OTHER = "other"
_DETECTION: frozenset[EventType] = frozenset(
{
EventType.MOTION,
EventType.RING,
EventType.SMART_DETECT,
EventType.SMART_AUDIO_DETECT,
EventType.SMART_DETECT_LINE,
EventType.SMART_DETECT_LOITER,
EventType.FACE_GROUP_DETECTED,
EventType.MOTION_LIGHT,
}
)
_SENSOR: frozenset[EventType] = frozenset(
{
EventType.MOTION_SENSOR,
EventType.SENSOR_BUTTON_PRESSED,
EventType.SENSOR_OPENED,
EventType.SENSOR_CLOSED,
EventType.SENSOR_ALARM,
EventType.SENSOR_EXTREME_VALUE,
EventType.SENSOR_WATER_LEAK,
EventType.SENSOR_BATTERY_LOW,
EventType.SENSOR_SMOKE_TEST,
EventType.SENSOR_TAMPER,
EventType.SENSOR_VAPE,
EventType.SENSOR_SMOKE_BATTERY_LOW,
EventType.SENSOR_SMOKE_NEEDS_CLEANING,
EventType.SENSOR_SMOKE_FAULT,
EventType.SENSOR_CO_FAULT,
EventType.SENSOR_SMOKE_END_OF_LIFE,
}
)
_ALARM_HUB: frozenset[EventType] = frozenset(
{
EventType.ALARM_HUB_MOTION,
EventType.ALARM_HUB_ENTRY_OPENED,
EventType.ALARM_HUB_ENTRY_CLOSED,
EventType.ALARM_HUB_RELAY_SWITCHED,
EventType.ALARM_HUB_BUTTON_PRESS,
EventType.ALARM_HUB_SMOKE,
EventType.ALARM_HUB_GLASS_BREAK,
EventType.ALARM_HUB_TAMPER,
EventType.ALARM_HUB_BATTERY_CONNECTED,
EventType.ALARM_HUB_BATTERY_LOW,
}
)
_ACCESS: frozenset[EventType] = frozenset(
{
EventType.NFC_CARD_SCANNED,
EventType.FINGERPRINT_IDENTIFIED,
EventType.DOOR_ACCESS,
EventType.ACCESS,
}
)
def _build_channel_map() -> dict[EventType, ProtectEventChannel]:
channels: dict[EventType, ProtectEventChannel] = {}
for et in EventType:
if et in _DETECTION:
channels[et] = ProtectEventChannel.DETECTION
elif et in _SENSOR:
channels[et] = ProtectEventChannel.SENSOR
elif et in _ALARM_HUB:
channels[et] = ProtectEventChannel.ALARM_HUB
elif et in _ACCESS:
channels[et] = ProtectEventChannel.ACCESS
else:
channels[et] = ProtectEventChannel.OTHER
return channels
EVENT_TYPE_TO_CHANNEL: dict[EventType, ProtectEventChannel] = _build_channel_map()
# Schema-level instantaneous types: the server never emits an "end" later
# (one-shot frame). Dispatcher synthesises end == start so subscribers always
# see a paired STARTED / ENDED pair.
INSTANTANEOUS_EVENT_TYPES: frozenset[EventType] = frozenset({EventType.MOTION_LIGHT})
class EventChange(enum.StrEnum):
"""Lifecycle tag for a ProtectEvent dispatch."""
STARTED = "started"
UPDATED = "updated"
ENDED = "ended"
REMOVED = "removed"
class UlpUserIdentity(BaseModel):
"""ULP-cached identity attached to a credential event."""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
kind: Literal["ulp_user"] = "ulp_user"
ulp_id: str
user: PublicUlpUser | None = None
class UnknownIdentity(BaseModel):
"""Identity unresolvable from the cached public bootstrap."""
model_config = ConfigDict(frozen=True)
kind: Literal["unknown"] = "unknown"
reason: Literal["no_metadata", "ulp_user_not_cached", "ulp_id_null"]
EventIdentity = Annotated[
Union[UlpUserIdentity, UnknownIdentity],
Field(discriminator="kind"),
]
class ProtectEvent(BaseModel):
"""Frozen public value type emitted by ``subscribe_events``."""
model_config = ConfigDict(
frozen=True,
arbitrary_types_allowed=True,
)
id: str
type: EventType
channel: ProtectEventChannel
device_id: str
# Resolved from ``device_id`` against the public bootstrap device stores
# at emit time. ``None`` when the device is not (yet) in the bootstrap —
# the same eventual-consistency property as ``identity``: a device unknown
# at first sight resolves on the next ``update_public()`` / reconnect.
device_mac: str | None = None
start: datetime
end: datetime | None = None
smart_detect_types: tuple[SmartDetectObjectType, ...] = ()
identity: EventIdentity | None = None
# Sensor alarm sound (``sensorAlarm`` events), collapsed from the public
# ``metadata.alarmType.text``; ``None`` for other event types.
alarm_type: SensorAlarmType | None = None
# Strongly-typed metadata union (all fields optional). Consumers read typed
# values off here (``metadata.sensor_type``, ``metadata.input_state``, …)
# rather than parsing raw strings. ``None`` for events without metadata.
# Live typed view over the cached ``PublicEvent`` (not a deep snapshot),
# matching the ``raw`` semantics below.
metadata: PublicEventMetadata | None = None
# Live view onto the cached source event, not a deep snapshot: it aliases
# the same ``PublicEvent`` the bootstrap cache holds and mutates in place.
raw: PublicEvent | None = Field(default=None, repr=False, exclude=True)
uilibs-uiprotect-317a52c/src/uiprotect/exceptions.py 0000664 0000000 0000000 00000002431 15246377057 0022724 0 ustar 00root root 0000000 0000000 from __future__ import annotations
class UnifiProtectError(Exception):
"""Base class for all other UniFi Protect errors"""
class StreamError(UnifiProtectError):
"""Exception raised when trying to stream content"""
class DataDecodeError(UnifiProtectError):
"""Exception raised when trying to decode a UniFi Protect object"""
class WSDecodeError(UnifiProtectError):
"""Exception raised when decoding Websocket packet"""
class WSEncodeError(UnifiProtectError):
"""Exception raised when encoding Websocket packet"""
class ClientError(UnifiProtectError):
"""Base Class for all other UniFi Protect client errors"""
class BadRequest(ClientError):
"""Invalid request from API Client"""
class PublicOnlyModeError(BadRequest):
"""Private-API operation attempted on a public-only (API-key-only) client."""
class Invalid(ClientError):
"""Invalid return from Authorization Request."""
class NotAuthorized(PermissionError, BadRequest):
"""Wrong username, password or permission error."""
class GlobalAlarmManagerError(BadRequest):
"""Operation not available when global alarm manager is enabled."""
class ArmedModeError(BadRequest):
"""Operation not available while the arm alarm is armed."""
class NvrError(ClientError):
"""Other error."""
uilibs-uiprotect-317a52c/src/uiprotect/py.typed 0000664 0000000 0000000 00000000000 15246377057 0021656 0 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/release_cache.json 0000664 0000000 0000000 00000000602 15246377057 0023625 0 ustar 00root root 0000000 0000000 ["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"]
uilibs-uiprotect-317a52c/src/uiprotect/stream.py 0000664 0000000 0000000 00000026463 15246377057 0022051 0 ustar 00root root 0000000 0000000 """Audio streaming utilities for UniFi Protect cameras using PyAV."""
from __future__ import annotations
import asyncio
import functools
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast
from urllib.parse import ParseResult, urlparse
import av
from .exceptions import BadRequest, StreamError
from .utils import format_host_for_url
if TYPE_CHECKING:
from collections.abc import Callable
from av.audio import AudioStream
from .data import Camera
def _convert_av_errors(
func: Callable[[TalkbackStream], None],
) -> Callable[[TalkbackStream], None]:
"""Decorator to convert av.FFmpegError to StreamError on self._error."""
@functools.wraps(func)
def wrapper(self: TalkbackStream) -> None:
try:
func(self)
except av.FFmpegError as e:
self._error = StreamError(f"Audio streaming failed: {e}")
return wrapper
_LOGGER = logging.getLogger(__name__)
#: Default UDP port for talkback streaming.
DEFAULT_TALKBACK_PORT = 7004
#: Input timeout (open, read) in seconds.
INPUT_TIMEOUT = 5.0
#: Output timeout (open) in seconds. None = no read timeout for UDP.
OUTPUT_TIMEOUT: tuple[float, float | None] = (5.0, None)
class CodecConfig(NamedTuple):
"""
Audio codec configuration for talkback streaming.
Attributes:
encoder: PyAV/libav encoder name (e.g., "aac", "libopus").
format: Output container format (e.g., "adts", "rtp").
"""
encoder: str
format: str
#: Supported audio codecs for talkback. Maps codec name to encoder/format config.
CODEC_MAP: dict[str, CodecConfig] = {
"aac": CodecConfig("aac", "adts"),
"opus": CodecConfig("libopus", "rtp"),
"vorbis": CodecConfig("libvorbis", "ogg"),
}
@dataclass(slots=True)
class TalkbackSession:
"""
Talkback session configuration from the UniFi Protect public API.
Attributes:
url: Streaming URL (UDP or RTP, e.g., "rtp://192.168.1.1:7004").
codec: Audio codec name ("aac", "opus", or "vorbis").
sampling_rate: Audio sampling rate in Hz.
"""
url: str
codec: str
sampling_rate: int
bits_per_sample: int = 16
_parsed_url: ParseResult = field(init=False, repr=False, compare=False)
def __post_init__(self) -> None:
"""Parse URL on initialization."""
self._parsed_url = urlparse(self.url)
@classmethod
def from_unifi_dict(cls, **data: object) -> TalkbackSession:
"""
Create from UniFi API response.
Args:
**data: Raw API response fields (url, codec, samplingRate).
Returns:
Configured TalkbackSession instance.
"""
return cls(
url=str(data.get("url", "")),
codec=str(data.get("codec", "")),
sampling_rate=int(cast("Any", data.get("samplingRate", 0))),
bits_per_sample=int(cast("Any", data.get("bitsPerSample", 16))),
)
@property
def host(self) -> str:
"""Get hostname from talkback URL."""
return self._parsed_url.hostname or ""
@property
def port(self) -> int:
"""Get port from talkback URL, defaults to 7004."""
return self._parsed_url.port or DEFAULT_TALKBACK_PORT
class TalkbackStream:
"""
Stream audio to a UniFi Protect camera's speaker using PyAV.
This class handles audio transcoding and UDP streaming to camera speakers.
It runs the actual streaming in a separate thread to avoid blocking the event loop.
Example:
```python
stream = TalkbackStream(camera, "/path/to/audio.wav", session)
await stream.run_until_complete()
```
"""
__slots__ = (
"_error",
"_lock",
"_stop_event",
"_thread",
"camera",
"content_url",
"session",
)
def __init__(
self,
camera: Camera,
content_url: str,
session: TalkbackSession | None = None,
) -> None:
"""
Initialize talkback stream.
Args:
camera: Camera device to stream audio to.
content_url: URL or file path of audio source.
session: Optional talkback session from public API.
Raises:
BadRequest: If camera does not have a speaker.
"""
if not camera.feature_flags.has_speaker:
raise BadRequest("Camera does not have a speaker for talkback")
self.camera = camera
self.content_url = content_url
self.session = session
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = asyncio.Lock()
self._error: BaseException | None = None
async def __aenter__(self) -> Self:
"""Start streaming when entering async context."""
await self.start()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object,
) -> None:
"""Stop streaming when exiting async context."""
await self.stop()
@property
def is_running(self) -> bool:
"""Check if the stream is currently running."""
return self._thread is not None and self._thread.is_alive()
@_convert_av_errors
def _stream_audio_sync(self) -> None:
"""Stream audio to the camera (runs in executor thread)."""
if self.session:
output_url = self.session.url
codec = self.session.codec
sample_rate = self.session.sampling_rate
bits_per_sample = self.session.bits_per_sample
else:
ts = self.camera.talkback_settings
host = format_host_for_url(str(self.camera.host))
output_url = f"udp://{host}:{ts.bind_port}"
codec = ts.type_fmt.value
sample_rate = ts.sampling_rate
bits_per_sample = ts.bits_per_sample
config = CODEC_MAP.get(codec)
if not config:
self._error = StreamError(f"Unsupported codec: {codec}")
return
_LOGGER.debug(
"Talkback: %s codec=%s rate=%d bits=%d",
output_url,
codec,
sample_rate,
bits_per_sample,
)
with av.open(
self.content_url,
timeout=(INPUT_TIMEOUT, INPUT_TIMEOUT),
) as input_container:
if not input_container.streams.audio:
self._error = StreamError("No audio stream found in input")
return
input_stream = input_container.streams.audio[0]
with av.open(
output_url,
"w",
format=config.format,
timeout=OUTPUT_TIMEOUT,
) as output_container:
output_stream = cast(
"AudioStream",
output_container.add_stream(config.encoder, rate=sample_rate),
)
output_stream.layout = "mono"
# Map bits_per_sample to av format (8->u8, 16->s16, 32->s32)
audio_format = {8: "u8", 16: "s16", 32: "s32"}.get(
bits_per_sample, "s16"
)
resampler = av.AudioResampler(
format=audio_format, layout="mono", rate=sample_rate
)
# Real-time pacing implementation:
#
# Unlike FFmpeg subprocess which handles timing internally, PyAV
# returns encoded packets as fast as the CPU can process them.
# Without pacing, we'd flood the camera's UDP buffer causing
# garbled audio or dropped packets (UDP has no flow control).
#
# We use absolute time reference (start_time + samples/rate) rather
# than relative delays to prevent cumulative drift from processing
# overhead. The stop_event.wait(timeout) provides both the delay
# and immediate cancellation when stop() is called.
start_time = time.monotonic()
samples_sent = 0
for frame in input_container.decode(input_stream):
if self._stop_event.is_set():
break
for resampled in resampler.resample(frame):
for packet in output_stream.encode(resampled):
output_container.mux(packet)
# Calculate how long we should have taken vs actual elapsed
samples_sent += resampled.samples
target_time = start_time + (samples_sent / sample_rate)
sleep_time = target_time - time.monotonic()
if sleep_time > 0 and self._stop_event.wait(sleep_time):
break # Stop requested during pacing delay
# Flush encoder only if completed normally
if not self._stop_event.is_set():
for packet in output_stream.encode(None):
output_container.mux(packet)
def _start_thread_if_needed(self, *, raise_if_running: bool) -> None:
"""Start streaming thread if not already running. Must hold lock."""
if self._thread is not None and self._thread.is_alive():
if raise_if_running:
raise StreamError("Stream already started")
return
# Don't clear a pending stop signal — stop() sets the event before
# acquiring the lock, so a concurrent start() must not clobber it.
if self._stop_event.is_set():
return
self._error = None
self._thread = threading.Thread(
target=self._stream_audio_sync,
name="TalkbackStream",
daemon=True,
)
self._thread.start()
async def _wait_for_thread(self) -> None:
"""Wait for the thread to complete without blocking the event loop."""
if self._thread is not None:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._thread.join)
async def start(self) -> None:
"""Start the audio stream."""
async with self._lock:
self._start_thread_if_needed(raise_if_running=True)
async def stop(self) -> None:
"""Stop the audio stream gracefully and wait for completion."""
# Signal the thread to stop BEFORE acquiring the lock.
# run_until_complete() holds the lock while awaiting thread.join(),
# so acquiring the lock first would deadlock when both are called
# concurrently (e.g. HA play_audio + stop from media player).
self._stop_event.set()
async with self._lock:
if self._thread is not None:
await self._wait_for_thread()
self._thread = None
self._stop_event.clear()
async def run_until_complete(self) -> None:
"""Run the stream until it completes naturally."""
async with self._lock:
self._start_thread_if_needed(raise_if_running=False)
await self._wait_for_thread()
if self._error is not None:
error = self._error
self._error = None
if isinstance(error, BaseException):
raise error
raise StreamError(f"Unexpected error: {error}")
uilibs-uiprotect-317a52c/src/uiprotect/test_util/ 0000775 0000000 0000000 00000000000 15246377057 0022205 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/src/uiprotect/test_util/__init__.py 0000664 0000000 0000000 00000056516 15246377057 0024333 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import logging
import shutil
import time
from collections.abc import Callable, Coroutine
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, overload
import aiohttp
import av
import orjson
from PIL import Image
from ..data import EventType, WSJSONPacketFrame, WSPacket
from ..exceptions import BadRequest
from ..test_util.anonymize import (
anonymize_data,
anonymize_prefixed_event_id,
)
from ..utils import from_js_time, is_online, run_async, write_json
if TYPE_CHECKING:
from ..api import ProtectApiClient
def generate_blank_video(output_path: Path, length: int) -> None:
"""
Generate a blank video with silent audio using PyAV.
Args:
output_path: Path to output MP4 file.
length: Duration in seconds.
"""
width, height = 1280, 720
fps = 25
sample_rate = 44100
samples_per_frame = 1024
with av.open(str(output_path), "w") as container:
video_stream = container.add_stream("h264", rate=fps)
video_stream.width = width
video_stream.height = height
video_stream.pix_fmt = "yuv420p"
audio_stream = container.add_stream("aac", rate=sample_rate)
audio_stream.layout = "stereo" # type: ignore[assignment]
# Create black frame (YUV420p: Y=16 is black, U=V=128 is neutral)
black_frame = av.VideoFrame(width, height, "yuv420p")
# Y plane (full resolution)
y_size = width * height
black_frame.planes[0].update(bytes([16] * y_size))
# U and V planes (half resolution each direction)
uv_size = (width // 2) * (height // 2)
black_frame.planes[1].update(bytes([128] * uv_size))
black_frame.planes[2].update(bytes([128] * uv_size))
# Create silent audio frame
silent_samples = bytes(
samples_per_frame * 2 * 2
) # 2 channels, 2 bytes per sample
total_frames = fps * length
total_audio_frames = (sample_rate * length) // samples_per_frame
for i in range(total_frames):
black_frame.pts = i
for packet in video_stream.encode(black_frame):
container.mux(packet)
for i in range(total_audio_frames):
audio_frame = av.AudioFrame(
format="s16", layout="stereo", samples=samples_per_frame
)
audio_frame.planes[0].update(silent_samples)
audio_frame.rate = sample_rate
audio_frame.pts = i * samples_per_frame
for packet in audio_stream.encode(audio_frame):
container.mux(packet)
# Flush encoders
for packet in video_stream.encode(None):
container.mux(packet)
for packet in audio_stream.encode(None):
container.mux(packet)
def placeholder_image(
output_path: Path,
width: int,
height: int | None = None,
) -> None:
if height is None:
height = width
image = Image.new("RGB", (width, height), (128, 128, 128))
image.save(output_path, "PNG")
_LOGGER = logging.getLogger(__name__)
LOG_CALLABLE = Callable[[str], None]
PROGRESS_CALLABLE = Callable[[int, str], Coroutine[Any, Any, None]]
class SampleDataGenerator:
"""Generate sample data for debugging and testing purposes"""
_record_num_ws: int = 0
_record_ws_start_time: float = time.monotonic()
_record_listen_for_events: bool = False
_record_ws_messages: dict[str, dict[str, Any]] = {}
_record_events_ws_messages: dict[str, Any] = {}
_record_devices_ws_messages: dict[str, Any] = {}
_log: LOG_CALLABLE | None = None
_log_warning: LOG_CALLABLE | None = None
_ws_progress: PROGRESS_CALLABLE | None = None
constants: dict[str, Any] = {}
client: ProtectApiClient
output_folder: Path
do_zip: bool
anonymize: bool
wait_time: int
def __init__(
self,
client: ProtectApiClient,
output: Path,
anonymize: bool,
wait_time: int,
log: LOG_CALLABLE | None = None,
log_warning: LOG_CALLABLE | None = None,
ws_progress: PROGRESS_CALLABLE | None = None,
do_zip: bool = False,
) -> None:
self.client = client
self.output_folder = output
self.do_zip = do_zip
self.anonymize = anonymize
self.wait_time = wait_time
self._log = log
self._log_warning = log_warning
self._ws_progress = ws_progress
if self._log_warning is None and self._log is not None:
self._log_warning = self._log
def log(self, msg: str) -> None:
if self._log is not None:
self._log(msg)
else:
_LOGGER.debug(msg)
def log_warning(self, msg: str) -> None:
if self._log_warning is not None:
self._log_warning(msg)
else:
_LOGGER.warning(msg)
def generate(self) -> None:
run_async(self.async_generate())
async def async_generate(self, close_session: bool = True) -> None:
self.log(f"Output folder: {self.output_folder}")
self.output_folder.mkdir(parents=True, exist_ok=True)
websocket = self.client._get_websocket()
websocket.start()
self.log("Websocket started...")
websocket._subscription = self._handle_ws_message
self.log("Updating devices...")
await self.client.update()
bootstrap: dict[str, Any] = await self.client.api_request_obj("bootstrap")
bootstrap = await self.write_json_file("sample_bootstrap", bootstrap)
self.constants["server_name"] = bootstrap["nvr"]["name"]
self.constants["server_id"] = bootstrap["nvr"]["mac"]
self.constants["server_version"] = bootstrap["nvr"]["version"]
self.constants["server_ip"] = bootstrap["nvr"]["host"]
self.constants["server_model"] = bootstrap["nvr"]["type"]
self.constants["last_update_id"] = bootstrap["lastUpdateId"]
self.constants["user_id"] = bootstrap["authUserId"]
self.constants["counts"] = {
"camera": len(bootstrap["cameras"]),
"user": len(bootstrap["users"]),
"group": len(bootstrap["groups"]),
"liveview": len(bootstrap["liveviews"]),
"viewer": len(bootstrap["viewers"]),
"light": len(bootstrap.get("lights", [])),
"bridge": len(bootstrap.get("bridges", [])),
"sensor": len(bootstrap.get("sensors", [])),
"chime": len(bootstrap.get("chimes", [])),
"aiport": len(bootstrap.get("aiports", [])),
}
self.log("Generating event data...")
motion_event, smart_detection = await self.generate_event_data()
await self.generate_device_data(motion_event, smart_detection)
self.log("Recording websocket events...")
await self.record_ws_events()
if close_session:
await self.client.close_session()
await self.client.close_public_api_session()
await self.write_json_file("sample_constants", self.constants, anonymize=False)
if self.do_zip:
self.log("Zipping files...")
def zip_files() -> None:
shutil.make_archive(str(self.output_folder), "zip", self.output_folder)
shutil.rmtree(self.output_folder)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, zip_files)
async def record_ws_events(self) -> None:
if self.wait_time <= 0:
self.log("Skipping recording Websocket messages...")
return
self._record_num_ws = 0
self._record_ws_start_time = time.monotonic()
self._record_listen_for_events = True
self._record_ws_messages = {}
self._record_events_ws_messages = {}
self._record_devices_ws_messages = {}
# Start public API websockets if API key is available
has_public_api = self.client.is_api_key_set()
if has_public_api:
self.log("Starting public API websockets...")
events_ws = self.client._get_events_websocket()
events_ws._subscription = self._handle_events_ws_message
events_ws.start()
devices_ws = self.client._get_devices_websocket()
devices_ws._subscription = self._handle_devices_ws_message
devices_ws.start()
else:
self.log("No API key set, skipping public API websocket recording...")
self.log(f"Waiting {self.wait_time} seconds for WS messages...")
if self._ws_progress is not None:
await self._ws_progress(self.wait_time, "Waiting for WS messages")
else:
await asyncio.sleep(self.wait_time)
self._record_listen_for_events = False
await self.client.async_disconnect_ws()
if has_public_api:
events_ws.stop()
devices_ws.stop()
await self.write_json_file(
"sample_ws_messages",
self._record_ws_messages,
anonymize=False,
)
if has_public_api:
await self.write_json_file(
"sample_ws_events_public",
self._record_events_ws_messages,
anonymize=False,
)
await self.write_json_file(
"sample_ws_devices_public",
self._record_devices_ws_messages,
anonymize=False,
)
@overload
async def write_json_file(
self,
name: str,
data: list[Any],
anonymize: bool | None = None,
) -> list[Any]: ...
@overload
async def write_json_file(
self,
name: str,
data: dict[str, Any],
anonymize: bool | None = None,
) -> dict[str, Any]: ...
async def write_json_file(
self,
name: str,
data: list[Any] | dict[str, Any],
anonymize: bool | None = None,
) -> list[Any] | dict[str, Any]:
if anonymize is None:
anonymize = self.anonymize
if anonymize:
data = anonymize_data(data)
self.log(f"Writing {name}...")
await write_json(self.output_folder / f"{name}.json", data)
return data
async def write_binary_file(
self,
name: str,
ext: str,
raw: bytes | None,
) -> None:
def write() -> None:
if raw is None:
self.log(f"No image data, skipping {name}...")
return
self.log(f"Writing {name}...")
Path(self.output_folder / f"{name}.{ext}").write_bytes(raw)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, write)
async def write_image_file(self, name: str, raw: bytes | None) -> None:
await self.write_binary_file(name, "png", raw)
async def generate_event_data(
self,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
data = await self.client.get_events_raw()
self.constants["time"] = datetime.now(tz=UTC).isoformat()
self.constants["event_count"] = len(data)
motion_event: dict[str, Any] | None = None
smart_detection: dict[str, Any] | None = None
for event_dict in reversed(data):
if (
motion_event is None
and event_dict["type"] == EventType.MOTION.value
and event_dict["camera"] is not None
and event_dict["thumbnail"] is not None
and event_dict["heatmap"] is not None
and event_dict["end"] is not None
):
motion_event = deepcopy(event_dict)
self.log(f"Using motion event: {motion_event['id']}...")
elif (
smart_detection is None
and event_dict["type"] == EventType.SMART_DETECT.value
and event_dict["camera"] is not None
and event_dict["end"] is not None
):
smart_detection = deepcopy(event_dict)
self.log(f"Using smart detection event: {smart_detection['id']}...")
if motion_event is not None and smart_detection is not None:
break
# anonymize data after pulling events
data = await self.write_json_file("sample_raw_events", data)
return motion_event, smart_detection
async def generate_device_data(
self,
motion_event: dict[str, Any] | None,
smart_detection: dict[str, Any] | None,
) -> None:
await asyncio.gather(
self.generate_camera_data(),
self.generate_motion_data(motion_event),
self.generate_smart_detection_data(smart_detection),
self.generate_light_data(),
self.generate_viewport_data(),
self.generate_sensor_data(),
self.generate_chime_data(),
self.generate_aiport_data(),
self.generate_bridge_data(),
self.generate_liveview_data(),
)
async def generate_camera_data(self) -> None:
objs = await self.client.api_request_list("cameras")
device_id: str | None = None
camera_is_online = False
for obj_dict in objs:
device_id = obj_dict["id"]
if is_online(obj_dict):
camera_is_online = True
break
if device_id is None:
self.log("No camera found. Skipping camera endpoints...")
return
# json data
obj = await self.client.api_request_obj(f"cameras/{device_id}")
await self.write_json_file("sample_camera", deepcopy(obj))
self.constants["camera_online"] = camera_is_online
# Check if camera has channels
if not obj.get("channels") or len(obj["channels"]) == 0:
self.log(
"Camera has no channels, skipping snapshot, thumbnail and heatmap generation",
)
return
if not camera_is_online:
self.log(
"Camera is not online, skipping snapshot, thumbnail and heatmap generation",
)
return
# snapshot
width = obj["channels"][0]["width"]
height = obj["channels"][0]["height"]
filename = "sample_camera_snapshot"
if self.anonymize:
self.log(f"Writing {filename}...")
placeholder_image(self.output_folder / f"{filename}.png", width, height)
else:
snapshot = await self.client.get_camera_snapshot(obj["id"], width, height)
await self.write_image_file(filename, snapshot)
# public api snapshot
pub_filename = "sample_camera_public_api_snapshot"
if self.anonymize:
self.log(f"Writing {pub_filename}...")
placeholder_image(self.output_folder / f"{pub_filename}.png", width, height)
else:
pub_snapshot = await self.client.get_public_api_camera_snapshot(obj["id"])
await self.write_image_file(pub_filename, pub_snapshot)
async def generate_motion_data(
self,
motion_event: dict[str, Any] | None,
) -> None:
if motion_event is None:
self.log("No motion event, skipping thumbnail and heatmap generation...")
return
# event thumbnail
filename = "sample_camera_thumbnail"
thumbnail_id = motion_event["thumbnail"]
if self.anonymize:
self.log(f"Writing {filename}...")
placeholder_image(self.output_folder / f"{filename}.png", 640, 360)
thumbnail_id = anonymize_prefixed_event_id(thumbnail_id)
else:
img = await self.client.get_event_thumbnail(thumbnail_id)
await self.write_image_file(filename, img)
self.constants["camera_thumbnail"] = thumbnail_id
# event heatmap
filename = "sample_camera_heatmap"
heatmap_id = motion_event["heatmap"]
if self.anonymize:
self.log(f"Writing {filename}...")
placeholder_image(self.output_folder / f"{filename}.png", 640, 360)
heatmap_id = anonymize_prefixed_event_id(heatmap_id)
else:
img = await self.client.get_event_heatmap(heatmap_id)
await self.write_image_file(filename, img)
self.constants["camera_heatmap"] = heatmap_id
# event video
filename = "sample_camera_video"
length = int((motion_event["end"] - motion_event["start"]) / 1000)
if self.anonymize:
generate_blank_video(
self.output_folder / f"{filename}.mp4",
length,
)
else:
video = await self.client.get_camera_video(
motion_event["camera"],
from_js_time(motion_event["start"]),
from_js_time(motion_event["end"]),
2,
)
await self.write_binary_file(filename, "mp4", video)
self.constants["camera_video_length"] = length
async def generate_smart_detection_data(
self,
smart_detection: dict[str, Any] | None,
) -> None:
if smart_detection is None:
self.log("No smart detection event, skipping smart detection data...")
return
try:
data = await self.client.get_event_smart_detect_track_raw(
smart_detection["id"],
)
except BadRequest:
self.log_warning("Event smart tracking missing")
else:
await self.write_json_file("sample_event_smart_track", data)
async def generate_light_data(self) -> None:
objs = await self.client.api_request_list("lights")
device_id: str | None = None
for obj_dict in objs:
device_id = obj_dict["id"]
if is_online(obj_dict):
break
if device_id is None:
self.log("No light found. Skipping light endpoints...")
return
obj = await self.client.api_request_obj(f"lights/{device_id}")
await self.write_json_file("sample_light", obj)
async def generate_viewport_data(self) -> None:
objs = await self.client.api_request_list("viewers")
device_id: str | None = None
for obj_dict in objs:
device_id = obj_dict["id"]
if is_online(obj_dict):
break
if device_id is None:
self.log("No viewer found. Skipping viewer endpoints...")
return
obj = await self.client.api_request_obj(f"viewers/{device_id}")
await self.write_json_file("sample_viewport", obj)
async def generate_sensor_data(self) -> None:
objs = await self.client.api_request_list("sensors")
device_id: str | None = None
for obj_dict in objs:
device_id = obj_dict["id"]
if is_online(obj_dict):
break
if device_id is None:
self.log("No sensor found. Skipping sensor endpoints...")
return
obj = await self.client.api_request_obj(f"sensors/{device_id}")
await self.write_json_file("sample_sensor", obj)
async def generate_chime_data(self) -> None:
objs = await self.client.api_request_list("chimes")
device_id: str | None = None
for obj_dict in objs:
device_id = obj_dict["id"]
if is_online(obj_dict):
break
if device_id is None:
self.log("No chime found. Skipping doorlock endpoints...")
return
obj = await self.client.api_request_obj(f"chimes/{device_id}")
await self.write_json_file("sample_chime", obj)
async def generate_aiport_data(self) -> None:
objs = await self.client.api_request_list("aiports")
device_id: str | None = None
for obj_dict in objs:
device_id = obj_dict["id"]
if is_online(obj_dict):
break
if device_id is None:
self.log("No aiport found. Skipping aiport endpoints...")
return
obj = await self.client.api_request_obj(f"aiports/{device_id}")
await self.write_json_file("sample_aiport", obj)
async def generate_bridge_data(self) -> None:
objs = await self.client.api_request_list("bridges")
device_id: str | None = None
for obj_dict in objs:
device_id = obj_dict["id"]
if is_online(obj_dict):
break
if device_id is None:
self.log("No bridge found. Skipping bridge endpoints...")
return
obj = await self.client.api_request_obj(f"bridges/{device_id}")
await self.write_json_file("sample_bridge", obj)
async def generate_liveview_data(self) -> None:
objs = await self.client.api_request_list("liveviews")
device_id: str | None = None
for obj_dict in objs:
device_id = obj_dict["id"]
break
if device_id is None:
self.log("No liveview found. Skipping liveview endpoints...")
return
obj = await self.client.api_request_obj(f"liveviews/{device_id}")
await self.write_json_file("sample_liveview", obj)
def _handle_ws_message(self, msg: aiohttp.WSMessage) -> None:
if not self._record_listen_for_events:
return
now = time.monotonic()
self._record_num_ws += 1
time_offset = now - self._record_ws_start_time
if msg.type == aiohttp.WSMsgType.BINARY:
packet = WSPacket(msg.data)
if not isinstance(packet.action_frame, WSJSONPacketFrame):
self.log_warning(
f"Got non-JSON action frame: {packet.action_frame.payload_format}",
)
return
if not isinstance(packet.data_frame, WSJSONPacketFrame):
self.log_warning(
f"Got non-JSON data frame: {packet.data_frame.payload_format}",
)
return
if self.anonymize:
packet.action_frame.data = anonymize_data(packet.action_frame.data)
packet.data_frame.data = anonymize_data(packet.data_frame.data)
packet.pack_frames()
self._record_ws_messages[str(time_offset)] = {
"raw": packet.raw_base64,
"action": packet.action_frame.data,
"data": packet.data_frame.data,
}
else:
self.log_warning(f"Got non-binary message: {msg.type}")
def _handle_public_api_ws_message(
self,
msg: aiohttp.WSMessage,
store: dict[str, Any],
) -> None:
if not self._record_listen_for_events:
return
now = time.monotonic()
time_offset = now - self._record_ws_start_time
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = orjson.loads(msg.data)
except (orjson.JSONDecodeError, TypeError) as exc:
preview = str(msg.data)[:200]
self.log_warning(
f"Failed to parse public API WS message: {preview} ({exc})"
)
return
if self.anonymize:
data = anonymize_data(data)
store[str(time_offset)] = data
else:
self.log_warning(
f"Got non-text public API WS message: {msg.type}",
)
def _handle_events_ws_message(self, msg: aiohttp.WSMessage) -> None:
self._handle_public_api_ws_message(msg, self._record_events_ws_messages)
def _handle_devices_ws_message(self, msg: aiohttp.WSMessage) -> None:
self._handle_public_api_ws_message(msg, self._record_devices_ws_messages)
uilibs-uiprotect-317a52c/src/uiprotect/test_util/anonymize.py 0000664 0000000 0000000 00000021306 15246377057 0024572 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import secrets
import string
import uuid
import warnings
from typing import Any
from urllib.parse import urlparse
from ..data import ModelType
object_id_mapping: dict[str, str] = {}
def anonymize_data(value: Any, name: str | None = None) -> Any:
if isinstance(value, list):
value = anonymize_list(value, name=name)
elif isinstance(value, dict):
value = anonymize_dict(value, name=name)
else:
value = anonymize_value(value, name=name)
return value
def anonymize_user(user_dict: dict[str, Any]) -> dict[str, Any]:
for index, group_id in enumerate(user_dict.get("groups", [])):
user_dict["groups"][index] = anonymize_object_id(group_id)
user_dict["id"] = anonymize_object_id(user_dict["id"])
if "firstName" in user_dict:
user_dict["firstName"] = random_word().title()
user_dict["lastName"] = random_word().title()
user_dict["name"] = f"{user_dict['firstName']} {user_dict['lastName']}"
user_dict["localUsername"] = random_word()
user_dict["email"] = f"{user_dict['localUsername']}@example.com"
if "cloudAccount" in user_dict and user_dict["cloudAccount"] is not None:
user_dict["cloudAccount"]["firstName"] = user_dict["firstName"]
user_dict["cloudAccount"]["lastName"] = user_dict["lastName"]
user_dict["cloudAccount"]["name"] = user_dict["name"]
user_dict["cloudAccount"]["email"] = user_dict["email"]
user_dict["cloudAccount"]["user"] = anonymize_object_id(
user_dict["cloudAccount"]["user"],
)
user_dict["cloudAccount"]["id"] = anonymize_uuid(
user_dict["cloudAccount"]["id"],
)
user_dict["cloudAccount"]["cloudId"] = anonymize_uuid(
user_dict["cloudAccount"]["cloudId"],
)
camera_order = (user_dict.get("settings") or {}).get("cameraOrder")
if camera_order is not None:
for index, camera_id in enumerate(camera_order):
camera_order[index] = anonymize_object_id(camera_id)
user_dict["settings"]["cameraOrder"] = camera_order
if "allPermissions" in user_dict:
user_dict["allPermissions"] = anonymize_list(
user_dict["allPermissions"],
"allPermissions",
)
if "permissions" in user_dict:
user_dict["permissions"] = anonymize_list(
user_dict["permissions"],
"permissions",
)
return user_dict
def anonymize_value(value: Any, name: str | None = None) -> Any: # noqa: PLR0912
if isinstance(value, str):
if name == "accessKey":
value = f"{random_number(13)}:{random_hex(24)}:{random_hex(128)}"
elif name == "credentials":
value = f"{random_hex(64)}"
elif name == "privateToken":
value = f"{random_alphanum(192)}"
elif name in {"host", "connectionHost", "bindAddr"}:
value = anonymize_ip(value)
elif name in {"anonymousDeviceId", "hardwareId"}:
value = random_identifier()
elif name in {"rtspAlias", "ssid"}:
value = random_alphanum(16)
elif name in {"mac", "server_id"}:
value = anonymize_peristent_string(value, random_hex(12).upper())
elif name == "bssid":
value = anonymize_peristent_string(value, random_seperated_mac())
elif name in {"latitude", "longitude"}:
value = "0.0"
elif name == "name" and value != "Default":
value = f"{random_word()} {random_word()}".title()
elif name in {"owner", "user", "camera", "liveview", "authUserId", "event"}:
value = anonymize_object_id(value)
elif name == "rtsp":
value = anonymize_rstp_url(value)
elif value.startswith("liveview:*:"):
liveview_id = value.split(":")[-1]
value = f"liveview:*:{anonymize_object_id(liveview_id)}"
return value
def anonymize_dict(obj: dict[str, Any], name: str | None = None) -> dict[str, Any]: # noqa: PLR0912
obj_type = None
if "modelKey" in obj:
if obj["modelKey"] in [m.value for m in ModelType]:
obj_type = ModelType(obj["modelKey"])
else:
warnings.warn(f"Unknown modelKey: {obj['modelKey']}", stacklevel=2)
if obj_type == ModelType.USER:
return anonymize_user(obj)
if name == "rtspsStreams":
for key, value in obj.items():
if isinstance(value, str):
obj[key] = anonymize_rstp_url(value)
else:
obj[key] = anonymize_data(value, name=key)
return obj
for key, value in obj.items():
handled = False
if obj_type is not None or "payload" in obj:
if key == "id":
obj[key] = anonymize_object_id(value)
handled = True
elif obj_type == ModelType.EVENT:
if key in {"thumbnail", "heatmap"}:
obj[key] = anonymize_prefixed_event_id(value)
handled = True
elif key == "metadata":
if "sensorId" in obj[key]:
obj[key]["sensorId"]["text"] = anonymize_object_id(
obj[key]["sensorId"]["text"],
)
if "sensorName" in obj[key]:
obj[key]["sensorName"]["text"] = (
f"{random_word()} {random_word()}".title()
)
if not handled:
obj[key] = anonymize_data(value, name=key)
return obj
def anonymize_list(items: list[Any], name: str | None = None) -> list[Any]:
for index, value in enumerate(items):
handled = False
if isinstance(value, str) and name in {
"hosts",
"smartDetectEvents",
"camera",
"cameras",
"rtspsStreams",
}:
handled = True
if name == "hosts":
items[index] = anonymize_ip(items[index])
elif name == "rtspsStreams":
items[index] = anonymize_rstp_url(value)
elif name in {"smartDetectEvents", "camera", "cameras"}:
items[index] = anonymize_object_id(value)
if not handled:
items[index] = anonymize_data(value)
return items
def anonymize_prefixed_event_id(event_id: str) -> str:
event_id = event_id[2:]
return f"e-{anonymize_object_id(event_id)}"
def anonymize_ip(ip: Any) -> Any:
if not isinstance(ip, str):
return ip
if ip in {"0.0.0.0", "127.0.0.1", "255.255.255.255"}: # noqa: S104
return ip
return anonymize_peristent_string(ip, random_ip(ip))
def anonymize_uuid(uuid_str: str) -> str:
return anonymize_peristent_string(uuid_str, random_identifier())
def anonymize_object_id(obj_id: str) -> str:
return anonymize_peristent_string(obj_id, random_hex(24))
def anonymize_peristent_string(value: str, default: str) -> str:
if value not in object_id_mapping:
object_id_mapping[value] = default
return object_id_mapping[value]
def anonymize_rstp_url(url: str) -> str:
parts = urlparse(url)
port = ""
if parts.port is not None and parts.port != 554:
port = f":{parts.port}"
return f"{parts.scheme}://{anonymize_ip(url)}{port}/{random_alphanum(16)}"
def random_hex(length: int) -> str:
return secrets.token_hex(length // 2)
def random_seperated_mac() -> str:
return ":".join(random_hex(2) for _ in range(6))
def random_str(length: int, choices: str) -> str:
return "".join(secrets.choice(choices) for _ in range(length))
def random_number(length: int) -> str:
return random_str(length, string.digits)
def random_word() -> str:
return random_char(secrets.randbelow(5) + 3)
def random_char(length: int) -> str:
return random_str(length, string.ascii_letters)
def random_alphanum(length: int) -> str:
choices = string.ascii_letters + string.ascii_letters.upper() + string.digits
return random_str(length, choices)
def random_ip(input_ip: str) -> str:
ip = ""
try:
octals = [int(i) for i in input_ip.split(".")]
except ValueError:
pass
else:
if octals[0] == 10:
ip = f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
elif octals[0] == 172 and 16 <= octals[1] <= 31:
ip = f"172.{secrets.randbelow(16) + 16}.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
elif octals[0] == 192 and octals[1] == 168:
ip = f"192.168.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
if not ip:
ip = f"{secrets.randbelow(255) + 1}.{secrets.randbelow(256)}.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
return ip
def random_identifier() -> str:
return str(uuid.uuid4())
uilibs-uiprotect-317a52c/src/uiprotect/utils.py 0000664 0000000 0000000 00000054013 15246377057 0021706 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import math
import os
import re
import socket
import time
import zoneinfo
from collections import Counter
from collections.abc import Callable, Coroutine, Iterable
from copy import deepcopy
from datetime import UTC, datetime, timedelta, tzinfo
from decimal import Decimal
from enum import Enum
from functools import cache, lru_cache, partial
from hashlib import sha224
from inspect import isclass
from ipaddress import IPv4Address, IPv6Address, ip_address
from operator import attrgetter
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeVar, Union, get_args, overload
from uuid import UUID
import jwt
from .data.types import (
Color,
SmartDetectAudioType,
SmartDetectObjectType,
Version,
VideoMode,
get_field_type,
)
from .exceptions import NvrError
if TYPE_CHECKING:
from http.cookies import Morsel
from aiohttp import ClientResponse
from pydantic.fields import FieldInfo
from uiprotect.api import ProtectApiClient
from uiprotect.data import CoordType, Event
from uiprotect.data.bootstrap import WSStat
T = TypeVar("T")
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
DEBUG_ENV = "UFP_DEBUG"
PROGRESS_CALLABLE = Callable[[int, str], Coroutine[Any, Any, None]]
SNAKE_CASE_KEYS = [
"life_span",
"bad_sector",
"total_bytes",
"used_bytes",
"space_type",
]
TIMEZONE_GLOBAL: tzinfo | None = None
SNAKE_CASE_MATCH_1 = re.compile("(.)([A-Z0-9][a-z]+)")
SNAKE_CASE_MATCH_2 = re.compile("__([A-Z0-9])")
SNAKE_CASE_MATCH_3 = re.compile("([a-z0-9])([A-Z])")
_LOGGER = logging.getLogger(__name__)
_CREATE_TYPES = {UUID, Color, Decimal, Path, Version}
_BAD_UUID = "00000000-0000-00 0- 000-000000000000"
# All IP-related types that need special handling via _cached_ip_address
# Note: Union is order-independent, so Union[A, B] == Union[B, A]
_IP_TYPES = frozenset(
{
# Individual types (from get_field_type extracting first Union arg)
IPv4Address,
IPv6Address,
# Union types (matched against field.annotation directly)
Union[IPv4Address, None],
Union[IPv4Address, str],
Union[IPv4Address, str, None],
Union[IPv6Address, None],
Union[IPv6Address, str],
Union[IPv6Address, str, None],
Union[IPv4Address, IPv6Address],
Union[IPv4Address, IPv6Address, None],
Union[IPv4Address, IPv6Address, str],
Union[IPv4Address, IPv6Address, str, None],
}
)
# Precompute which _IP_TYPES allow str to determine empty string handling
_IP_TYPES_WITH_STR = frozenset(t for t in _IP_TYPES if str in get_args(t))
@lru_cache
def to_camel(string: str) -> str:
return "".join(word.capitalize() for word in string.split("_"))
def set_debug() -> None:
"""Sets ENV variable for UFP_DEBUG to on (True)"""
os.environ[DEBUG_ENV] = str(True)
is_debug.cache_clear()
def set_no_debug() -> None:
"""Sets ENV variable for UFP_DEBUG to off (False)"""
os.environ[DEBUG_ENV] = str(False)
is_debug.cache_clear()
@cache
def is_debug() -> bool:
"""Returns if debug ENV is on (True)"""
return os.environ.get(DEBUG_ENV) == str(True)
async def get_response_reason(response: ClientResponse) -> str:
reason = str(response.reason)
try:
data = await response.json()
reason = data.get("error", str(data))
except Exception:
with contextlib.suppress(Exception):
reason = await response.text()
return reason
@overload
def to_js_time(dt: datetime | int) -> int: ...
@overload
def to_js_time(dt: None) -> None: ...
def to_js_time(dt: datetime | int | None) -> int | None:
"""Converts Python datetime to Javascript timestamp"""
if dt is None:
return None
if isinstance(dt, int):
return dt
if dt.tzinfo is None:
return int(time.mktime(dt.timetuple()) * 1000)
return int(dt.astimezone(UTC).timestamp() * 1000)
def to_ms(duration: timedelta | None) -> int | None:
"""Converts python timedelta to Milliseconds"""
if duration is None:
return None
return round(duration.total_seconds() * 1000)
def utc_now() -> datetime:
return datetime.now(tz=UTC)
def from_js_time(num: float | str | datetime) -> datetime:
"""Converts Javascript timestamp or ISO-8601 string to Python datetime"""
if isinstance(num, datetime):
return num
if isinstance(num, str) and "T" in num:
# ISO-8601 path — Python 3.11+ handles a trailing "Z" natively.
dt = datetime.fromisoformat(num)
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
return datetime.fromtimestamp(int(num) / 1000, tz=UTC)
@lru_cache(maxsize=1024)
def convert_to_datetime(source_time: float | str | datetime | None) -> datetime | None:
"""Converts timestamp to datetime object"""
return None if source_time is None else from_js_time(source_time)
def format_datetime(
dt: datetime | None,
default: str | None = None,
) -> str | None:
"""Formats a datetime object in a consistent format"""
return default if dt is None else dt.strftime(DATETIME_FORMAT)
def is_online(data: dict[str, Any]) -> bool:
return bool(data["state"] == "CONNECTED")
def is_doorbell(data: dict[str, Any]) -> bool:
return "doorbell" in str(data["type"]).lower()
@lru_cache(maxsize=1024)
def to_snake_case(name: str) -> str:
"""Converts string to snake_case"""
name = SNAKE_CASE_MATCH_1.sub(r"\1_\2", name)
name = SNAKE_CASE_MATCH_2.sub(r"_\1", name)
name = SNAKE_CASE_MATCH_3.sub(r"\1_\2", name)
return name.lower()
def to_camel_case(name: str) -> str:
"""Converts string to camelCase"""
# repeated runs through should not keep lowercasing
if "_" in name:
name = to_camel(name)
return name[0].lower() + name[1:]
return name
_EMPTY_UUID = UUID("0" * 32)
def convert_unifi_data(value: Any, field: FieldInfo) -> Any: # noqa: PLR0911, PLR0912
"""Converts value from UFP data into pydantic field class"""
origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type]
if type_ is Any:
return value
if origin is not None:
if origin is list and isinstance(value, list):
return [convert_unifi_data(v, field) for v in value]
if origin is set and isinstance(value, list):
return {convert_unifi_data(v, field) for v in value}
if origin is dict and isinstance(value, dict):
return {k: convert_unifi_data(v, field) for k, v in value.items()}
if value is not None:
if type_ in _IP_TYPES:
# Handle IP addresses - use _cached_ip_address to support cases where
# UniFi returns IPv6 for a field typed as IPv4Address or vice versa
if value == "":
# Return empty string only if str is in the union, otherwise None
return value if type_ in _IP_TYPES_WITH_STR else None
return _cached_ip_address(value)
if type_ is datetime:
return from_js_time(value)
if type_ in _CREATE_TYPES:
# cannot do this check too soon because some types cannot be used in isinstance
if isinstance(value, type_):
return value
if type_ is UUID:
if not value:
return None
# handle edge case for improperly formatted UUIDs
# 00000000-0000-00 0- 000-000000000000
if value == _BAD_UUID:
return _EMPTY_UUID
return type_(value)
if _is_enum_type(type_):
if _is_from_string_enum(type_):
return type_.from_string(value)
return type_(value)
return value
@lru_cache
def _cached_ip_address(value: str) -> IPv4Address | IPv6Address | str:
try:
return ip_address(value)
except ValueError:
return value
@lru_cache
def _is_enum_type(type_: Any) -> bool:
"""Checks if type is an Enum."""
return isclass(type_) and issubclass(type_, Enum)
@lru_cache
def _is_from_string_enum(type_: Any) -> bool:
"""Checks if Enum has from_string method."""
return hasattr(type_, "from_string")
def serialize_unifi_obj(value: Any, levels: int = -1) -> Any: # noqa: PLR0911
"""Serializes UFP data"""
if unifi_dict := getattr(value, "unifi_dict", None):
value = unifi_dict()
if levels != 0 and isinstance(value, dict):
return serialize_dict(value, levels=levels - 1)
if levels != 0 and isinstance(value, Iterable) and not isinstance(value, str):
return serialize_list(value, levels=levels - 1)
if isinstance(value, Enum):
return value.value
if isinstance(value, (IPv4Address, IPv6Address, UUID, Path, tzinfo, Version)):
return str(value)
if isinstance(value, datetime):
return to_js_time(value)
if isinstance(value, timedelta):
return to_ms(value)
if isinstance(value, Color):
return value.as_hex().upper()
return value
def serialize_dict(data: dict[str, Any], levels: int = -1) -> dict[str, Any]:
"""Serializes UFP data dict"""
for key in list(data):
set_key = key
if set_key not in SNAKE_CASE_KEYS:
set_key = to_camel_case(set_key)
data[set_key] = serialize_unifi_obj(data.pop(key), levels=levels)
return data
def serialize_coord(coord: CoordType) -> int | float:
"""Serializes UFP zone coordinate"""
if not isinstance(coord, float):
return coord
if math.isclose(coord, 0) or math.isclose(coord, 1):
return int(coord)
return coord
def serialize_point(point: tuple[CoordType, CoordType]) -> list[int | float]:
"""Serializes UFP zone coordinate point"""
return [
serialize_coord(point[0]),
serialize_coord(point[1]),
]
def serialize_list(items: Iterable[Any], levels: int = -1) -> list[Any]:
"""Serializes UFP data list"""
return [serialize_unifi_obj(i, levels=levels) for i in items]
def convert_smart_types(items: Iterable[str]) -> list[SmartDetectObjectType]:
"""Converts list of str into SmartDetectObjectType. Any unknown values will be ignored and logged."""
types = []
for smart_type in items:
try:
types.append(SmartDetectObjectType(smart_type))
except ValueError:
_LOGGER.warning("Unknown smart detect type: %s", smart_type)
return types
def convert_smart_audio_types(items: Iterable[str]) -> list[SmartDetectAudioType]:
"""Converts list of str into SmartDetectAudioType. Any unknown values will be ignored and logged."""
types = []
for smart_type in items:
try:
types.append(SmartDetectAudioType(smart_type))
except ValueError:
_LOGGER.warning("Unknown smart detect audio type: %s", smart_type)
return types
def convert_video_modes(items: Iterable[str]) -> list[VideoMode]:
"""Converts list of str into VideoMode. Any unknown values will be ignored and logged."""
types = []
for video_mode in items:
mode = VideoMode(video_mode)
if mode is VideoMode.UNKNOWN and video_mode != VideoMode.UNKNOWN.value:
_LOGGER.warning("Unknown video mode: %s", video_mode)
continue
types.append(mode)
return types
async def ip_from_host(host: str) -> IPv4Address | IPv6Address:
"""
Resolve hostname to IP address (IPv4 or IPv6).
Raises:
ValueError: If host cannot be resolved to IP address
"""
try:
return ip_address(host)
except ValueError:
pass
try:
loop = asyncio.get_running_loop()
addr_info = await loop.getaddrinfo(host, None)
ip_str = addr_info[0][4][0]
except (socket.gaierror, OSError) as err:
raise ValueError(f"Cannot resolve hostname '{host}' to IP address") from err
return ip_address(ip_str)
def format_host_for_url(host: IPv4Address | IPv6Address | str) -> str:
"""Format host for URLs. IPv6 addresses are wrapped in brackets."""
if isinstance(host, str):
try:
parsed_host = ip_address(host)
except ValueError:
return host
host = parsed_host
if isinstance(host, IPv6Address):
return f"[{host}]"
return str(host)
def dict_diff(orig: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
changed: dict[str, Any] = {}
if orig is None:
return new
for key, value in new.items():
if key not in orig:
changed[key] = deepcopy(value)
continue
if isinstance(value, dict):
sub_changed = dict_diff(orig[key], value)
if sub_changed:
changed[key] = sub_changed
elif value != orig[key]:
changed[key] = deepcopy(value)
return changed
def ws_stat_summmary(
stats: list[WSStat],
) -> tuple[list[WSStat], float, Counter[str], Counter[str], Counter[str]]:
if len(stats) == 0:
raise ValueError("No stats to summarize")
unfiltered = [s for s in stats if not s.filtered]
percent = (1 - len(unfiltered) / len(stats)) * 100
keys = Counter(k for s in unfiltered for k in s.keys_set)
models = Counter(k.model for k in unfiltered)
actions = Counter(k.action for k in unfiltered)
return unfiltered, percent, keys, models, actions
async def write_json(output_path: Path, data: list[Any] | dict[str, Any]) -> None:
def write() -> None:
with output_path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
f.write("\n")
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, write)
def print_ws_stat_summary(
stats: list[WSStat],
output: Callable[[Any], Any] | None = None,
) -> None:
if output is None:
# typer<0.4.1 is incompatible with click>=8.1.0, so the import is
# deferred to keep that breakage CLI-only. typer is also an optional
# dependency (the `cli` extra); fall back to print when it is absent.
try:
import typer # noqa: PLC0415
output = typer.echo
except ImportError:
output = print
unfiltered, percent, keys, models, actions = ws_stat_summmary(stats)
title = " ws stat summary "
side_length = int((80 - len(title)) / 2)
lines = [
"-" * side_length + title + "-" * side_length,
f"packet count: {len(stats)}",
f"filtered packet count: {len(unfiltered)} ({percent:.4}%)",
"-" * 80,
]
for key, count in models.most_common():
lines.append(f"{key}: {count}")
lines.append("-" * 80)
for key, count in actions.most_common():
lines.append(f"{key}: {count}")
lines.append("-" * 80)
for key, count in keys.most_common(10):
lines.append(f"{key}: {count}")
lines.append("-" * 80)
output("\n".join(lines))
async def profile_ws(
protect: ProtectApiClient,
duration: int,
output_path: Path | None = None,
ws_progress: PROGRESS_CALLABLE | None = None,
do_print: bool = True,
print_output: Callable[[Any], Any] | None = None,
) -> None:
if protect.bootstrap.capture_ws_stats:
raise NvrError("Profile already in progress")
_LOGGER.debug("Starting profile...")
protect.bootstrap.clear_ws_stats()
protect.bootstrap.capture_ws_stats = True
if ws_progress is not None:
await ws_progress(duration, "Waiting for WS messages")
else:
await asyncio.sleep(duration)
protect.bootstrap.capture_ws_stats = False
_LOGGER.debug("Finished profile...")
if output_path:
json_data = [s.__dict__ for s in protect.bootstrap.ws_stats]
await write_json(output_path, json_data)
if do_print:
print_ws_stat_summary(protect.bootstrap.ws_stats, output=print_output)
def decode_token_cookie(token_cookie: Morsel[str]) -> dict[str, Any] | None:
"""Decode a token cookie if it is still valid."""
try:
return jwt.decode(
token_cookie.value,
options={"verify_signature": False, "verify_exp": True},
)
except jwt.ExpiredSignatureError:
_LOGGER.debug("Authentication token has expired.")
return None
except Exception as broad_ex:
_LOGGER.debug("Authentication token decode error: %s", broad_ex)
return None
def format_duration(duration: timedelta) -> str:
"""Formats a timedelta as a string."""
seconds = int(duration.total_seconds())
hours = seconds // 3600
seconds -= hours * 3600
minutes = seconds // 60
seconds -= minutes * 60
output = ""
if hours > 0:
output = f"{hours}h"
if minutes > 0:
output = f"{output}{minutes}m"
return f"{output}{seconds}s"
def _set_timezone(tz: tzinfo | str) -> tzinfo:
global TIMEZONE_GLOBAL # noqa: PLW0603
if isinstance(tz, str):
tz = zoneinfo.ZoneInfo(tz)
TIMEZONE_GLOBAL = tz
return TIMEZONE_GLOBAL
def get_local_timezone() -> tzinfo:
"""Gets Olson timezone name for localizing datetimes"""
if TIMEZONE_GLOBAL is not None:
return TIMEZONE_GLOBAL
try:
from homeassistant.util import ( # noqa: PLC0415
dt as dt_util, # type: ignore[import-not-found]
)
return _set_timezone(dt_util.DEFAULT_TIME_ZONE)
except ImportError:
pass
timezone_name = os.environ.get("TZ")
if timezone_name:
return _set_timezone(timezone_name)
timezone_name = "UTC"
timezone_locale = Path("/etc/localtime")
if timezone_locale.exists():
tzfile_digest = sha224(Path(timezone_locale).read_bytes()).hexdigest()
zoneinfo_root = Path("/usr/share/zoneinfo")
for root, _, filenames in os.walk(zoneinfo_root):
for filename in filenames:
fullname = Path(root) / filename
digest = sha224(fullname.read_bytes()).hexdigest()
if digest == tzfile_digest:
timezone_name = fullname.relative_to(zoneinfo_root).as_posix()
return _set_timezone(timezone_name)
def local_datetime(dt: datetime | None = None) -> datetime:
"""Returns datetime in local timezone"""
if dt is None:
dt = datetime.now(tz=UTC)
local_tz = get_local_timezone()
if dt.tzinfo is None:
return dt.replace(tzinfo=local_tz)
return dt.astimezone(local_tz)
def log_event(event: Event) -> None:
from uiprotect.data import EventType # noqa: PLC0415
_LOGGER.debug("event WS msg: %s", event.model_dump())
if "smart" not in event.type.value:
return
camera = event.camera
if camera is None:
return
if event.end is not None:
_LOGGER.debug(
"%s (%s): Smart detection ended for %s (%s)",
camera.name,
camera.mac,
event.smart_detect_types,
event.id,
)
return
_LOGGER.debug(
"%s (%s): New smart detection started for %s (%s)",
camera.name,
camera.mac,
event.smart_detect_types,
event.id,
)
smart_settings = camera.smart_detect_settings
for smart_type in event.smart_detect_types:
is_audio = event.type is EventType.SMART_AUDIO_DETECT
if is_audio:
if smart_type.audio_type is None:
return
is_enabled = (
smart_settings.audio_types is not None
and smart_type.audio_type in smart_settings.audio_types
)
last_event = camera.get_last_smart_audio_detect_event(smart_type.audio_type)
else:
is_enabled = smart_type in smart_settings.object_types
last_event = camera.get_last_smart_detect_event(smart_type)
_LOGGER.debug(
"Event info (%s):\n"
" is_smart_detected: %s\n"
" is_recording_enabled: %s\n"
" is_enabled: %s\n"
" event: %s",
smart_type,
camera.is_smart_detected,
camera.is_recording_enabled,
is_enabled,
last_event,
)
def run_async(callback: Coroutine[Any, Any, T]) -> T:
"""Run async coroutine."""
return asyncio.run(callback)
def clamp_value(value: float, step_size: float) -> float:
"""Clamps value to multiples of step size."""
ratio = 1 / step_size
return int(value * ratio) / ratio
@lru_cache(maxsize=1024)
def normalize_mac(mac: str) -> str:
"""Normalize MAC address."""
return mac.lower().replace(":", "").replace("-", "").replace("_", "")
_SENTINEL = object()
def get_nested_attr(attrs: tuple[str, ...], obj: Any) -> Any:
"""Fetch a nested attribute."""
value = obj
for key in attrs:
if (value := getattr(value, key, _SENTINEL)) is _SENTINEL:
return None
return value
def get_nested_attr_as_bool(attrs: tuple[str, ...], obj: Any) -> bool:
"""Fetch a nested attribute as a bool."""
value = obj
for key in attrs:
if (value := getattr(value, key, _SENTINEL)) is _SENTINEL:
return False
return bool(value)
def get_top_level_attr_as_bool(attr: str, obj: Any) -> Any:
"""Fetch a top level attribute as a bool."""
return bool(getattr(obj, attr))
def make_value_getter(ufp_value: str) -> Callable[[T], Any]:
"""Return a function to get a value from a Protect device."""
if "." not in ufp_value:
return attrgetter(ufp_value)
return partial(get_nested_attr, tuple(ufp_value.split(".")))
def make_enabled_getter(ufp_enabled: str) -> Callable[[T], bool]:
"""Return a function to get a value from a Protect device."""
if "." not in ufp_enabled:
return attrgetter(ufp_enabled)
return partial(get_nested_attr, tuple(ufp_enabled.split(".")))
def make_required_getter(ufp_required_field: str) -> Callable[[T], bool]:
"""Return a function to get a value from a Protect device."""
if "." not in ufp_required_field:
return partial(get_top_level_attr_as_bool, ufp_required_field)
return partial(get_nested_attr_as_bool, tuple(ufp_required_field.split(".")))
@lru_cache
def timedelta_total_seconds(td: timedelta) -> float:
return td.total_seconds()
def pybool_to_json_bool(value: bool) -> str:
"""Convert a Python bool to a JSON boolean string ('true'/'false')."""
return "true" if value else "false"
uilibs-uiprotect-317a52c/src/uiprotect/websocket.py 0000664 0000000 0000000 00000025427 15246377057 0022543 0 ustar 00root root 0000000 0000000 """UniFi Protect Websockets."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Awaitable, Callable, Coroutine
from enum import Enum
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
import aiohttp
from aiohttp import (
ClientError,
ClientSession,
ClientWebSocketResponse,
WSMessage,
WSMsgType,
WSServerHandshakeError,
)
from .exceptions import NotAuthorized, NvrError
if TYPE_CHECKING:
from yarl import URL
_LOGGER = logging.getLogger(__name__)
AuthCallbackType = Callable[..., Coroutine[Any, Any, dict[str, str] | None]]
GetSessionCallbackType = Callable[[], Awaitable[ClientSession]]
UpdateBootstrapCallbackType = Callable[[], None]
_CLOSE_MESSAGE_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED}
# Consecutive 401 handshakes before AUTH_FAILED is surfaced. One retry lets a
# private-WS forced re-auth heal a transient/expired session before we cry wolf;
# a static public key still fails the second attempt and surfaces immediately.
_AUTH_FAILURE_THRESHOLD = 2
class WebsocketState(Enum):
CONNECTED = True
DISCONNECTED = False
# A 401 handshake that repeated past the retry threshold. For a public
# (API-key) websocket the key is static and cannot self-recover, so this
# is the reauth trigger a subscribe-only consumer never got before.
AUTH_FAILED = "auth_failed"
class Websocket:
"""UniFi Protect Websocket manager."""
_running = False
_headers: dict[str, str] | None = None
_websocket_loop_task: asyncio.Task[None] | None = None
_stop_task: asyncio.Task[None] | None = None
_ws_connection: ClientWebSocketResponse | None = None
def __init__(
self,
get_url: Callable[[], URL],
auth_callback: AuthCallbackType,
update_bootstrap: UpdateBootstrapCallbackType,
get_session: GetSessionCallbackType,
subscription: Callable[[WSMessage], None],
state_callback: Callable[[WebsocketState], None],
*,
timeout: float = 30.0,
backoff: int = 10,
auth_failed_backoff: int = 120,
verify: bool = True,
receive_timeout: float | None = None,
heartbeat: float | None = None,
) -> None:
"""Init Websocket."""
self.get_url = get_url
self.timeout = timeout
self.receive_timeout = receive_timeout
self.heartbeat = heartbeat
self.backoff = backoff
self.auth_failed_backoff = auth_failed_backoff
self.verify = verify
self._get_session = get_session
self._auth = auth_callback
self._update_bootstrap = update_bootstrap
self._subscription = subscription
self._seen_non_close_message = False
self._websocket_state = state_callback
self._current_state: WebsocketState = WebsocketState.DISCONNECTED
self._consecutive_auth_failures = 0
self._reconnect_now = asyncio.Event()
@property
def is_connected(self) -> bool:
"""Return if the websocket connection is open."""
return self._ws_connection is not None and not self._ws_connection.closed
async def _websocket_loop(self) -> None:
"""Running loop for websocket."""
await self.wait_closed()
while True:
url = self.get_url()
auth_failed = await self._run_inner_loop(url)
if (
auth_failed
and self._consecutive_auth_failures >= _AUTH_FAILURE_THRESHOLD
):
# AUTH_FAILED implies "not connected". On entry, emit the
# DISCONNECTED transition first so a consumer that only reacts
# to connect/disconnect edges observes the loss before
# AUTH_FAILED latches (a no-op dedupe when already
# disconnected). Skipping it once AUTH_FAILED is latched keeps
# the backoff loop from flapping DISCONNECTED/AUTH_FAILED.
if self._current_state is not WebsocketState.AUTH_FAILED:
self._state_changed(WebsocketState.DISCONNECTED)
self._state_changed(WebsocketState.AUTH_FAILED)
backoff = self.auth_failed_backoff
else:
self._state_changed(WebsocketState.DISCONNECTED)
backoff = self.backoff
if self._running is False:
break
_LOGGER.debug("Reconnecting websocket in %s seconds", backoff)
try:
await asyncio.wait_for(self._reconnect_now.wait(), timeout=backoff)
except TimeoutError:
pass
finally:
self._reconnect_now.clear()
async def _run_inner_loop(self, url: URL) -> bool:
"""Run one connect/receive cycle; return whether it ended on a 401."""
try:
await self._websocket_inner_loop(url)
except ClientError as ex:
level = logging.ERROR if self._seen_non_close_message else logging.DEBUG
if isinstance(ex, WSServerHandshakeError):
if ex.status == HTTPStatus.UNAUTHORIZED.value:
_LOGGER.log(
level, "Websocket authentication error: %s: %s", url, ex
)
await self._attempt_auth(True)
self._consecutive_auth_failures += 1
return True
_LOGGER.log(level, "Websocket handshake error: %s: %s", url, ex)
else:
_LOGGER.log(level, "Websocket disconnect error: %s: %s", url, ex)
except TimeoutError:
level = logging.ERROR if self._seen_non_close_message else logging.DEBUG
_LOGGER.log(level, "Websocket timeout: %s", url)
except Exception:
_LOGGER.exception("Unexpected error in websocket loop")
self._consecutive_auth_failures = 0
return False
def _state_changed(self, state: WebsocketState) -> None:
"""State changed."""
if self._current_state is state:
return
self._current_state = state
self._websocket_state(state)
async def _websocket_inner_loop(self, url: URL) -> None:
_LOGGER.debug("Connecting WS to %s", url)
await self._attempt_auth(False)
msg: WSMessage | None = None
self._seen_non_close_message = False
session = await self._get_session()
# catch any and all errors for Websocket so we can clean up correctly
try:
self._ws_connection = await session.ws_connect(
url,
ssl=self.verify,
headers=self._headers,
timeout=aiohttp.ClientWSTimeout(ws_close=self.timeout),
heartbeat=self.heartbeat,
)
# Connectivity, not data receipt, defines CONNECTED: ws_connect +
# auth have succeeded here, so emit it before the receive loop.
# On a quiet channel the first data frame can be minutes away;
# gating CONNECTED behind it flapped consumers. _seen_non_close_message
# stays purely for the bootstrap-invalidation heuristic below.
self._state_changed(WebsocketState.CONNECTED)
self._consecutive_auth_failures = 0
while True:
msg = await self._ws_connection.receive(self.receive_timeout)
msg_type = msg.type
if msg_type is WSMsgType.ERROR:
_LOGGER.error("Error from Websocket: %s", msg.data)
break
if msg_type in _CLOSE_MESSAGE_TYPES:
_LOGGER.debug("Websocket closed: %s", msg)
break
self._seen_non_close_message = True
try:
self._subscription(msg)
except Exception:
_LOGGER.exception("Error processing websocket message")
finally:
if (
msg is not None
and msg.type is WSMsgType.CLOSE
# If it closes right away or lastUpdateId is in the extra
# its an indication that we should update the bootstrap
# since lastUpdateId is invalid
and (
not self._seen_non_close_message
or (msg.extra and "lastUpdateId" in msg.extra)
)
):
self._update_bootstrap()
_LOGGER.debug("Websocket disconnected: last message: %s", msg)
if self._ws_connection is not None and not self._ws_connection.closed:
await self._ws_connection.close()
self._ws_connection = None
async def _attempt_auth(self, force: bool) -> None:
"""Attempt to authenticate."""
try:
self._headers = await self._auth(force)
except (NotAuthorized, NvrError) as ex:
_LOGGER.debug("Error authenticating websocket: %s", ex)
except Exception:
_LOGGER.exception("Unknown error authenticating websocket")
def start(self) -> None:
"""Start the websocket."""
if self._running:
return
self._running = True
self._websocket_loop_task = asyncio.create_task(self._websocket_loop())
def stop(self) -> None:
"""Disconnect the websocket."""
_LOGGER.debug("Disconnecting websocket...")
if not self._running:
return
if self._websocket_loop_task:
self._websocket_loop_task.cancel()
self._running = False
ws_connection = self._ws_connection
websocket_loop_task = self._websocket_loop_task
self._ws_connection = None
self._websocket_loop_task = None
self._stop_task = asyncio.create_task(
self._stop(ws_connection, websocket_loop_task)
)
self._state_changed(WebsocketState.DISCONNECTED)
def reset_auth_failure(self) -> None:
"""
Clear the auth-failure backoff and wake the reconnect loop.
Call after installing a fresh credential (e.g. a new API key) so a
websocket parked on the long ``auth_failed_backoff`` retries at once.
"""
self._consecutive_auth_failures = 0
self._reconnect_now.set()
async def wait_closed(self) -> None:
"""Wait for the websocket to close."""
if self._stop_task and not self._stop_task.done():
with contextlib.suppress(asyncio.CancelledError):
await self._stop_task
self._stop_task = None
async def _stop(
self,
ws_connection: ClientWebSocketResponse | None,
websocket_loop_task: asyncio.Task[None] | None,
) -> None:
"""Stop the websocket."""
if ws_connection:
await ws_connection.close()
if websocket_loop_task:
with contextlib.suppress(asyncio.CancelledError):
await websocket_loop_task
uilibs-uiprotect-317a52c/templates/ 0000775 0000000 0000000 00000000000 15246377057 0017362 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/templates/CHANGELOG.md.j2 0000664 0000000 0000000 00000001235 15246377057 0021506 0 ustar 00root root 0000000 0000000 # Changelog
{%- for version, release in context.history.released.items() %}
## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }})
{%- for category, commits in release["elements"].items() %}
{# Category title: Breaking, Fix, Documentation #}
### {{ category | capitalize }}
{# List actual changes in the category #}
{%- for commit in commits %}
{% if commit is not none and commit.descriptions is defined %}
- {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
{% endif %}
{%- endfor %}{# for commit #}
{%- endfor %}{# for category, commits #}
{%- endfor %}{# for version, release #}
uilibs-uiprotect-317a52c/tests/ 0000775 0000000 0000000 00000000000 15246377057 0016526 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/__init__.py 0000664 0000000 0000000 00000000000 15246377057 0020625 0 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/benchmarks/ 0000775 0000000 0000000 00000000000 15246377057 0020643 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/benchmarks/__init__.py 0000664 0000000 0000000 00000000240 15246377057 0022750 0 ustar 00root root 0000000 0000000 """
CodSpeed benchmarks for uiprotect.
These benchmarks target hot paths that have historically had regressions —
primarily websocket packet processing.
"""
uilibs-uiprotect-317a52c/tests/benchmarks/conftest.py 0000664 0000000 0000000 00000006332 15246377057 0023046 0 ustar 00root root 0000000 0000000 """
Fixtures for websocket processing benchmarks.
Builds a fully populated Bootstrap once per session and pre-decodes the
sample websocket message stream into WSPacket objects. Each benchmark then
runs tight loops against already-constructed objects so the measurement
reflects the production hot path rather than fixture overhead.
"""
from __future__ import annotations
import asyncio
import base64
import copy
from typing import TYPE_CHECKING, Any
import pytest
from tests.conftest import (
SimpleMockWebsocket,
read_json_file,
setup_client,
)
from uiprotect import ProtectApiClient
from uiprotect.data import WSPacket
if TYPE_CHECKING:
from uiprotect.data import Bootstrap
def _build_client() -> ProtectApiClient:
return ProtectApiClient(
"127.0.0.1",
0,
"username",
"password",
ws_timeout=0.1,
store_sessions=False,
)
@pytest.fixture(scope="session")
def benchmark_bootstrap() -> Bootstrap:
"""Return a fully bootstrapped client's Bootstrap, shared across tests."""
loop = asyncio.new_event_loop()
try:
client = _build_client()
loop.run_until_complete(setup_client(client, SimpleMockWebsocket()))
bootstrap = client.bootstrap
bootstrap.capture_ws_stats = False
# The client is intentionally leaked for the session — cleanup_client
# would tear down the websocket/session that the bootstrap references.
return bootstrap
finally:
loop.close()
@pytest.fixture(scope="session")
def ws_raw_frames() -> list[bytes]:
"""Return the raw websocket frame bytes from the sample stream."""
messages = read_json_file("sample_ws_messages")
return [base64.b64decode(entry["raw"]) for entry in messages.values()]
@pytest.fixture(scope="session")
def ws_packets(ws_raw_frames: list[bytes]) -> list[WSPacket]:
"""
Pre-decoded WSPackets. Frames are eagerly populated so that
``process_ws_packet`` measurements exclude both wrapper construction
and msgpack/json decoding.
"""
packets: list[WSPacket] = []
for raw in ws_raw_frames:
packet = WSPacket(raw)
# Force decode so later access is a no-op.
_ = packet.action_frame
_ = packet.data_frame
packets.append(packet)
return packets
@pytest.fixture(scope="session")
def ws_packet_templates(
ws_packets: list[WSPacket],
) -> list[tuple[Any, Any]]:
"""
Deep-copied snapshots of each packet's decoded action/data dicts.
``process_ws_packet`` mutates these dicts in place during type
conversion (e.g. ``lastSeen`` int → datetime). Benchmarks use these
snapshots in an untimed ``setup`` hook to restore fresh state between
rounds without re-paying decode cost.
"""
return [
(copy.deepcopy(p.action_frame.data), copy.deepcopy(p.data_frame.data))
for p in ws_packets
]
def reset_ws_packets(
packets: list[WSPacket],
templates: list[tuple[Any, Any]],
) -> None:
"""Restore each pre-decoded packet's dicts from its template snapshot."""
for packet, (action_data, frame_data) in zip(packets, templates, strict=True):
packet.action_frame.data = copy.deepcopy(action_data)
packet.data_frame.data = copy.deepcopy(frame_data)
uilibs-uiprotect-317a52c/tests/benchmarks/test_process_ws_packet.py 0000664 0000000 0000000 00000007670 15246377057 0026004 0 ustar 00root root 0000000 0000000 """
Benchmarks for Bootstrap.process_ws_packet.
Websocket packet processing is the hottest path in uiprotect — every Protect
event flows through it and historically this path has regressed silently.
These benchmarks exercise the dispatch pipeline against a real recorded
message stream.
The timed region calls ``process_ws_packet`` only. Packet wrapper
construction and JSON decoding are paid once at session setup; an untimed
``setup`` hook restores the decoded dicts between rounds to undo the
in-place mutation (e.g. ``lastSeen`` int → datetime) that would otherwise
corrupt state across iterations.
The tests are async so ``process_ws_packet``'s error recovery path — which
schedules refreshes via ``asyncio.create_task`` — has a running loop.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from tests.benchmarks.conftest import reset_ws_packets
from uiprotect.data import ModelType
if TYPE_CHECKING:
from pytest_codspeed import BenchmarkFixture
from uiprotect.data import Bootstrap, WSPacket
_SUBSCRIBED_MODELS: set[ModelType] = {
ModelType.EVENT,
ModelType.CAMERA,
ModelType.LIGHT,
ModelType.VIEWPORT,
ModelType.SENSOR,
ModelType.LIVEVIEW,
}
_ROUNDS = 5
@pytest.mark.asyncio
async def test_process_ws_packet_all(
benchmark: BenchmarkFixture,
benchmark_bootstrap: Bootstrap,
ws_packets: list[WSPacket],
ws_packet_templates: list[tuple[Any, Any]],
) -> None:
"""Process every recorded packet with no model filter."""
bootstrap = benchmark_bootstrap
bootstrap.capture_ws_stats = False
process = bootstrap.process_ws_packet
def _setup() -> None:
reset_ws_packets(ws_packets, ws_packet_templates)
def _run() -> None:
for packet in ws_packets:
process(packet)
benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS)
@pytest.mark.asyncio
async def test_process_ws_packet_filtered(
benchmark: BenchmarkFixture,
benchmark_bootstrap: Bootstrap,
ws_packets: list[WSPacket],
ws_packet_templates: list[tuple[Any, Any]],
) -> None:
"""Process every recorded packet with the default HA subscription filter."""
bootstrap = benchmark_bootstrap
bootstrap.capture_ws_stats = False
process = bootstrap.process_ws_packet
models = _SUBSCRIBED_MODELS
def _setup() -> None:
reset_ws_packets(ws_packets, ws_packet_templates)
def _run() -> None:
for packet in ws_packets:
process(packet, models)
benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS)
@pytest.mark.asyncio
async def test_process_ws_packet_with_stats(
benchmark: BenchmarkFixture,
benchmark_bootstrap: Bootstrap,
ws_packets: list[WSPacket],
ws_packet_templates: list[tuple[Any, Any]],
) -> None:
"""Process every recorded packet with WS stats capture enabled."""
bootstrap = benchmark_bootstrap
bootstrap.capture_ws_stats = True
process = bootstrap.process_ws_packet
def _setup() -> None:
bootstrap.clear_ws_stats()
reset_ws_packets(ws_packets, ws_packet_templates)
def _run() -> None:
for packet in ws_packets:
process(packet)
try:
benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS)
finally:
bootstrap.capture_ws_stats = False
bootstrap.clear_ws_stats()
@pytest.mark.asyncio
async def test_process_ws_packet_ignore_stats(
benchmark: BenchmarkFixture,
benchmark_bootstrap: Bootstrap,
ws_packets: list[WSPacket],
ws_packet_templates: list[tuple[Any, Any]],
) -> None:
"""Process every recorded packet with ignore_stats set."""
bootstrap = benchmark_bootstrap
bootstrap.capture_ws_stats = False
process = bootstrap.process_ws_packet
def _setup() -> None:
reset_ws_packets(ws_packets, ws_packet_templates)
def _run() -> None:
for packet in ws_packets:
process(packet, None, True)
benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS)
uilibs-uiprotect-317a52c/tests/common.py 0000664 0000000 0000000 00000001377 15246377057 0020400 0 ustar 00root root 0000000 0000000 """Common test utils."""
from uiprotect.data.base import ProtectModel
def assert_equal_dump(
obj1: list[ProtectModel] | dict[str, ProtectModel] | ProtectModel,
obj2: list[ProtectModel] | dict[str, ProtectModel] | ProtectModel,
) -> bool:
if isinstance(obj1, dict):
obj1_dumped = {k: v.model_dump() for k, v in obj1.items()}
elif isinstance(obj1, list):
obj1_dumped = [item.model_dump() for item in obj1]
else:
obj1_dumped = obj1.model_dump()
if isinstance(obj2, dict):
obj2_dumped = {k: v.model_dump() for k, v in obj2.items()}
elif isinstance(obj2, list):
obj2_dumped = [item.model_dump() for item in obj2]
else:
obj2_dumped = obj2.model_dump()
assert obj1_dumped == obj2_dumped
uilibs-uiprotect-317a52c/tests/conftest.py 0000664 0000000 0000000 00000105421 15246377057 0020730 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import base64
import json
import math
import os
from copy import deepcopy
from datetime import UTC, datetime
from functools import cache
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, Mock
import aiofiles
import aiohttp
import av
import pytest
import pytest_asyncio
from tests.sample_data.constants import CONSTANTS
from uiprotect import ProtectApiClient
from uiprotect.data import NVR, Camera, ModelType
from uiprotect.data.devices import PTZRange, PTZZoomRange
from uiprotect.data.nvr import Event
from uiprotect.data.types import EventType
from uiprotect.utils import _BAD_UUID, set_debug, set_no_debug
if TYPE_CHECKING:
from collections.abc import Iterator
# ``scripts/`` is not an importable package; expose it so tests can import the
# spec-validation helpers (``scripts/validate_spec.py``).
import sys
_SCRIPTS_DIR = str(Path(__file__).resolve().parents[1] / "scripts")
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
try:
from blockbuster import BlockBuster, blockbuster_ctx
except ImportError:
BlockBuster = None # type: ignore[assignment,misc]
blockbuster_ctx = None # type: ignore[assignment]
_BENCHMARKS_DIR = "tests/benchmarks"
# Tests that perform sync IO inside the asyncio event loop and trip
# blockbuster. Marked xfail so CI is green; pop entries as they get
# fixed so the underlying blocking call is gone for good.
_KNOWN_BLOCKING: frozenset[str] = frozenset()
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
"""Mark known-blocking tests xfail so CI is green while we work through them."""
if blockbuster_ctx is None:
return
marker = pytest.mark.xfail(
reason="blockbuster: blocking call in asyncio path, to be fixed",
strict=False,
)
for item in items:
if item.nodeid in _KNOWN_BLOCKING:
item.add_marker(marker)
@pytest.fixture(autouse=True)
def blockbuster(
request: pytest.FixtureRequest,
) -> Iterator[BlockBuster | None]:
"""Fail any test that performs a blocking call inside the asyncio loop."""
if blockbuster_ctx is None or _BENCHMARKS_DIR in str(request.node.fspath):
yield None
return
with blockbuster_ctx() as bb:
yield bb
UFP_SAMPLE_DIR = os.environ.get("UFP_SAMPLE_DIR")
if UFP_SAMPLE_DIR:
SAMPLE_DATA_DIRECTORY = Path(UFP_SAMPLE_DIR)
else:
SAMPLE_DATA_DIRECTORY = Path(__file__).parent / "sample_data"
TEST_CAMERA_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera.json").exists()
TEST_SNAPSHOT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera_snapshot.png").exists()
TEST_PUBLIC_API_SNAPSHOT_EXISTS = (
SAMPLE_DATA_DIRECTORY / "sample_public_api_camera_snapshot.png"
).exists()
TEST_VIDEO_EXISTS = (
SAMPLE_DATA_DIRECTORY / "sample_camera_video.mp4"
).exists() or "camera_video_length" not in CONSTANTS
TEST_THUMBNAIL_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera_thumbnail.png").exists()
TEST_HEATMAP_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera_heatmap.png").exists()
TEST_SMART_TRACK_EXISTS = (
SAMPLE_DATA_DIRECTORY / "sample_event_smart_track.json"
).exists()
TEST_LIGHT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_light.json").exists()
TEST_SENSOR_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_sensor.json").exists()
TEST_VIEWPORT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_viewport.json").exists()
TEST_BRIDGE_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_bridge.json").exists()
TEST_LIVEVIEW_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_liveview.json").exists()
TEST_CHIME_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_chime.json").exists()
TEST_AIPORT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_aiport.json").exists()
ANY_NONE = [[None], None, []]
@cache
def _read_binary_file_cached(name: str, ext: str) -> bytes:
with (SAMPLE_DATA_DIRECTORY / f"{name}.{ext}").open("rb") as f:
return f.read()
def read_binary_file(name: str, ext: str = "png") -> bytes:
return _read_binary_file_cached(name, ext)
@cache
def _read_json_file_cached(name: str) -> Any:
with (SAMPLE_DATA_DIRECTORY / f"{name}.json").open(encoding="utf8") as f:
return json.load(f)
def read_json_file(name: str) -> Any:
return deepcopy(_read_json_file_cached(name))
# Preload sample data at module import (outside the asyncio loop) so the
# read_*_file helpers never touch the disk from inside a test or fixture
# and trip blockbuster.
for _path in SAMPLE_DATA_DIRECTORY.glob("*.json"):
_read_json_file_cached(_path.stem)
for _ext in ("png", "mp4"):
for _path in SAMPLE_DATA_DIRECTORY.glob(f"*.{_ext}"):
_read_binary_file_cached(_path.stem, _ext)
CONSTANTS.data() # force the sample_constants.json read out of any loop
async def async_write_bytes(path: Path, data: bytes) -> None:
"""Write ``data`` to ``path`` without blocking the event loop."""
async with aiofiles.open(path, "wb") as f:
await f.write(data)
async def async_read_bytes(path: Path) -> bytes:
"""Read ``path`` as bytes without blocking the event loop."""
async with aiofiles.open(path, "rb") as f:
return await f.read()
async def async_write_text(path: Path, text: str) -> None:
"""Write ``text`` to ``path`` without blocking the event loop."""
async with aiofiles.open(path, "w") as f:
await f.write(text)
async def async_read_text(path: Path) -> str:
"""Read ``path`` as text without blocking the event loop."""
async with aiofiles.open(path) as f:
return await f.read()
def read_bootstrap_json_file():
# tests expect global recording settings to be off
bootstrap = read_json_file("sample_bootstrap")
cameras = []
for camera in bootstrap["cameras"]:
if camera.get("useGlobal"):
camera["useGlobal"] = False
cameras.append(camera)
bootstrap["cameras"] = cameras
return bootstrap
def read_camera_json_file():
# tests expect global recording settings to be off
camera = read_json_file("sample_camera")
if camera.get("useGlobal"):
camera["useGlobal"] = False
return camera
def read_aiport_json_file():
# tests expect global recording settings to be off
aiport = read_json_file("sample_aiport")
if aiport.get("useGlobal"):
aiport["useGlobal"] = False
return aiport
def get_now():
return datetime.fromisoformat(CONSTANTS["time"]).replace(microsecond=0)
def get_time():
return datetime.fromisoformat(CONSTANTS["time"]).replace(microsecond=0).timestamp()
def validate_video_file(filepath: Path, length: int):
"""Validate video file using PyAV."""
with av.open(str(filepath)) as container:
# Check that video stream exists
assert len(container.streams.video) > 0, "No video stream found"
# Check duration (in seconds)
# container.duration is in av.time_base units (microseconds, 1000000/sec)
# so we divide by av.time_base to convert to seconds
duration = float(container.duration) / av.time_base if container.duration else 0
# it looks like UFP does not always generate a video of exact length
assert length - 10 < duration < length + 10
async def mock_api_request_raw(url: str, *args, **kwargs):
if url.startswith("thumbnails/") or url.endswith("thumbnail"):
return read_binary_file("sample_camera_thumbnail")
if url.startswith("cameras/"):
return read_binary_file("sample_camera_snapshot")
if url.startswith("/v1/cameras/"):
return read_binary_file("sample_public_api_camera_snapshot")
if url.startswith("heatmaps/") or url.endswith("heatmap"):
return read_binary_file("sample_camera_heatmap")
if url == "video/export":
return read_binary_file("sample_camera_video", "mp4")
return b""
async def mock_api_request(url: str, *args, **kwargs):
if url == "bootstrap":
return read_bootstrap_json_file()
if url == "nvr":
return read_bootstrap_json_file()["nvr"]
if url == "events":
return read_json_file("sample_raw_events")
if url == "cameras":
return [read_camera_json_file()]
if url == "lights":
return [read_json_file("sample_light")]
if url == "sensors":
return [read_json_file("sample_sensor")]
if url == "viewers":
return [read_json_file("sample_viewport")]
if url == "bridges":
return [read_json_file("sample_bridge")]
if url == "liveviews":
return [read_json_file("sample_liveview")]
if url == "chimes":
return [read_json_file("sample_chime")]
if url == "aiports":
return [read_json_file("sample_aiport")]
if url.endswith("ptz/preset"):
return {
"id": "test-id",
"name": "Test",
"slot": 0,
"ptz": {
"pan": 100,
"tilt": 100,
"zoom": 0,
},
}
if url.endswith("ptz/home"):
return {
"id": "test-id",
"name": "Home",
"slot": -1,
"ptz": {
"pan": 100,
"tilt": 100,
"zoom": 0,
},
}
if url.startswith("cameras/"):
return read_camera_json_file()
if url.startswith("lights/"):
return read_json_file("sample_light")
if url.startswith("sensors/"):
return read_json_file("sample_sensor")
if url.startswith("viewers/"):
return read_json_file("sample_viewport")
if url.startswith("bridges/"):
return read_json_file("sample_bridge")
if url.startswith("liveviews/"):
return read_json_file("sample_liveview")
if url.startswith("chimes"):
return read_json_file("sample_chime")
if url.startswith("aiports"):
return read_json_file("sample_aiport")
if "smartDetectTrack" in url:
return read_json_file("sample_event_smart_track")
return {}
class SimpleMockWebsocket:
is_closed: bool = False
now: float = 0
events: dict[str, Any]
count = 0
def __init__(self):
self.events = []
@property
def closed(self):
return self.is_closed
async def close(self):
self.is_closed = True
def __aiter__(self):
return self
async def __anext__(self):
if len(self.events) == 0 or self.is_closed:
raise StopAsyncIteration
key = next(iter(self.events.keys()))
next_time = float(key)
await asyncio.sleep(next_time - self.now)
self.now = next_time
data = self.events.pop(key)
self.count += 1
return aiohttp.WSMessage(
aiohttp.WSMsgType.BINARY,
base64.b64decode(data["raw"]),
None,
)
async def receive(self, timeout):
return await self.__anext__()
class MockWebsocket(SimpleMockWebsocket):
def __init__(self):
super().__init__()
self.events = read_json_file("sample_ws_messages")
MockDatetime = Mock()
MockDatetime.now.return_value = get_now()
@pytest.fixture(autouse=True)
def _ensure_debug():
set_debug()
async def setup_client(
client: ProtectApiClient,
websocket: SimpleMockWebsocket,
timeout: int = 0,
):
mock_cs = AsyncMock()
mock_session = AsyncMock()
mock_session.ws_connect = AsyncMock(return_value=websocket)
mock_cs.return_value = mock_session
ws = client._get_websocket()
ws.timeout = timeout
ws._get_session = mock_cs # type: ignore[method-assign]
client.api_request = AsyncMock(side_effect=mock_api_request) # type: ignore[method-assign]
client.api_request_raw = AsyncMock(side_effect=mock_api_request_raw) # type: ignore[method-assign]
client.ensure_authenticated = AsyncMock() # type: ignore[method-assign]
await client.update()
# make sure global recording settings are disabled for all cameras (test expect it)
for camera in client.bootstrap.cameras.values():
camera.use_global = False
return client
async def cleanup_client(client: ProtectApiClient):
await client.async_disconnect_ws()
await client.close_session()
await client.close_public_api_session()
@pytest_asyncio.fixture
async def simple_api_client():
"""Create a simple ProtectApiClient for unit testing without mocked bootstrap."""
client = ProtectApiClient("test.com", 443, "username", "password")
yield client
await cleanup_client(client)
@pytest_asyncio.fixture(name="protect_client")
async def protect_client_fixture():
client = ProtectApiClient(
"127.0.0.1",
0,
"username",
"password",
ws_timeout=0.1,
store_sessions=False,
)
yield await setup_client(client, SimpleMockWebsocket())
await cleanup_client(client)
@pytest_asyncio.fixture
async def protect_client_no_debug():
set_no_debug()
client = ProtectApiClient(
"127.0.0.1",
0,
"username",
"password",
ws_timeout=0.1,
store_sessions=False,
)
yield await setup_client(client, SimpleMockWebsocket())
await cleanup_client(client)
@pytest_asyncio.fixture
async def protect_client_ws():
set_no_debug()
client = ProtectApiClient(
"127.0.0.1",
0,
"username",
"password",
ws_timeout=0.1,
ws_receive_timeout=0.1,
store_sessions=False,
)
yield await setup_client(client, MockWebsocket(), timeout=30)
await cleanup_client(client)
@pytest_asyncio.fixture
async def smart_dectect_obj(protect_client: ProtectApiClient, raw_events):
event_dict = None
for event in raw_events:
if event["type"] == EventType.SMART_DETECT.value:
event_dict = event
break
if event_dict is None:
yield None
else:
yield Event.from_unifi_dict(api=protect_client, **event_dict)
@pytest_asyncio.fixture
async def nvr_obj(protect_client: ProtectApiClient):
yield protect_client.bootstrap.nvr
@pytest_asyncio.fixture
async def camera_obj(protect_client: ProtectApiClient):
if not TEST_CAMERA_EXISTS:
return None
return next(iter(protect_client.bootstrap.cameras.values()))
@pytest_asyncio.fixture
async def ptz_camera(protect_client: ProtectApiClient):
if not TEST_CAMERA_EXISTS:
return None
camera = next(iter(protect_client.bootstrap.cameras.values()))
# G4 PTZ
camera.is_ptz = True
camera.feature_flags.is_ptz = True
camera.feature_flags.focus = PTZRange(
steps={ # type: ignore[arg-type]
"max": 1560,
"min": 0,
"step": 1,
},
degrees={ # type: ignore[arg-type]
"max": None,
"min": None,
"step": None,
},
)
camera.feature_flags.pan = PTZRange(
steps={ # type: ignore[arg-type]
"max": 35200,
"min": 0,
"step": 1,
},
degrees={ # type: ignore[arg-type]
"max": 360,
"min": 0,
"step": 0.1,
},
)
camera.feature_flags.tilt = PTZRange(
steps={ # type: ignore[arg-type]
"max": 9777,
"min": 1,
"step": 1,
},
degrees={ # type: ignore[arg-type]
"max": 90,
"min": -20,
"step": 0.1,
},
)
camera.feature_flags.zoom = PTZZoomRange(
ratio=22,
steps={ # type: ignore[arg-type]
"max": 2010,
"min": 0,
"step": 1,
},
degrees={ # type: ignore[arg-type]
"max": None,
"min": None,
"step": None,
},
)
protect_client.bootstrap.cameras[camera.id] = camera
return camera
@pytest_asyncio.fixture
async def light_obj(protect_client: ProtectApiClient):
if not TEST_LIGHT_EXISTS:
return None
return next(iter(protect_client.bootstrap.lights.values()))
@pytest_asyncio.fixture
async def viewer_obj(protect_client: ProtectApiClient):
if not TEST_VIEWPORT_EXISTS:
return None
return next(iter(protect_client.bootstrap.viewers.values()))
@pytest_asyncio.fixture
async def sensor_obj(protect_client: ProtectApiClient):
if not TEST_SENSOR_EXISTS:
return None
return next(iter(protect_client.bootstrap.sensors.values()))
@pytest_asyncio.fixture(name="chime_obj")
async def chime_obj_fixture(protect_client: ProtectApiClient):
if not TEST_CHIME_EXISTS:
return None
return next(iter(protect_client.bootstrap.chimes.values()))
@pytest_asyncio.fixture(name="aiport_obj")
async def aiport_obj_fixture(protect_client: ProtectApiClient):
if not TEST_AIPORT_EXISTS:
return None
return next(iter(protect_client.bootstrap.aiports.values()))
@pytest_asyncio.fixture
async def liveview_obj(protect_client: ProtectApiClient):
if not TEST_LIVEVIEW_EXISTS:
return None
return next(iter(protect_client.bootstrap.liveviews.values()))
@pytest_asyncio.fixture
async def user_obj(protect_client: ProtectApiClient):
return protect_client.bootstrap.auth_user
@pytest.fixture()
def liveview():
if not TEST_LIVEVIEW_EXISTS:
return None
return read_json_file("sample_liveview")
@pytest.fixture()
def viewport():
if not TEST_VIEWPORT_EXISTS:
return None
return read_json_file("sample_viewport")
@pytest.fixture()
def light():
if not TEST_LIGHT_EXISTS:
return None
return read_json_file("sample_light")
@pytest.fixture()
def camera():
if not TEST_CAMERA_EXISTS:
return None
return read_camera_json_file()
@pytest.fixture()
def aiport():
if not TEST_CAMERA_EXISTS:
return None
return read_aiport_json_file()
@pytest.fixture()
def sensor():
if not TEST_SENSOR_EXISTS:
return None
return read_json_file("sample_sensor")
@pytest.fixture()
def chime():
if not TEST_CHIME_EXISTS:
return None
return read_json_file("sample_chime")
@pytest.fixture()
def bridge():
if not TEST_BRIDGE_EXISTS:
return None
return read_json_file("sample_bridge")
@pytest.fixture()
def liveviews():
if not TEST_LIVEVIEW_EXISTS:
return []
return [read_json_file("sample_liveview")]
@pytest.fixture()
def viewports():
if not TEST_VIEWPORT_EXISTS:
return []
return [read_json_file("sample_viewport")]
@pytest.fixture()
def aiports():
if not TEST_AIPORT_EXISTS:
return []
return [read_json_file("sample_aiport")]
@pytest.fixture()
def lights():
if not TEST_LIGHT_EXISTS:
return []
return [read_json_file("sample_light")]
@pytest.fixture()
def cameras():
if not TEST_CAMERA_EXISTS:
return []
return [read_camera_json_file()]
@pytest.fixture()
def sensors():
if not TEST_SENSOR_EXISTS:
return []
return [read_json_file("sample_sensor")]
@pytest.fixture()
def chimes():
if not TEST_CHIME_EXISTS:
return []
return [read_json_file("sample_chime")]
@pytest.fixture()
def bridges():
if not TEST_BRIDGE_EXISTS:
return []
return [read_json_file("sample_bridge")]
@pytest.fixture()
def ws_messages():
return read_json_file("sample_ws_messages")
@pytest.fixture(name="raw_events")
def raw_events_fixture():
return read_json_file("sample_raw_events")
@pytest.fixture()
def bootstrap():
return read_bootstrap_json_file()
@pytest.fixture()
def nvr():
return read_bootstrap_json_file()["nvr"]
@pytest.fixture()
def smart_track():
if not TEST_SMART_TRACK_EXISTS:
return None
return read_json_file("sample_event_smart_track")
@pytest.fixture()
def now():
return get_now().replace(tzinfo=UTC)
@pytest.fixture()
def tmp_binary_file():
with NamedTemporaryFile(mode="wb", delete=False) as tmp_file:
yield tmp_file
Path(tmp_file.name).unlink()
# new values added for newer versions of UFP (for backwards compat tests)
NEW_FIELDS = {
# 1.20.1
"voltage",
# 1.21.0-beta1
"timestamp",
"isWirelessUplinkEnabled",
"marketName",
# 1.21.0-beta3
"isPoorNetwork",
# 2.0-beta2
"scopes",
"streamSharingAvailable",
"isDbAvailable",
"isRecordingDisabled",
"isRecordingMotionOnly",
# 2.1.1-beta3
"anonymousDeviceId", # added to viewport
"isStacked",
"isPrimary",
"lastDriveSlowEvent",
"isUCoreSetup",
# 2.2.1-beta2
"isInsightsEnabled",
# 2.2.2
"isDownloadingFW",
# 2.6.13
"vaultCameras",
"homekitSettings",
# 2.6.17
"apMgmtIp",
# 2.7.5
"fwUpdateState",
"isWaterproofCaseAttached",
"deletedAt",
"deletionType",
"lastDisconnect",
# 2.7.15
"featureFlags", # added to chime
# 2.8.14+
"nvrMac",
"useGlobal",
"is2K",
"is4K",
"ulpVersion",
"wanIp",
"publicIp",
"isVaultRegistered",
"hasGateway",
"corruptionState",
"countryCode",
# 2.8.22+
"guid",
"userConfiguredAp",
# 2.9.20+
"isRestoring",
"hasRecordings",
"hardDriveState",
"isNetworkInstalled",
"isProtectUpdatable",
"isUcoreUpdatable",
# 2.10.10+
"isPtz",
# 2.11.13+
"lastDeviceFWUpdatesCheckedAt",
"audioSettings",
# 3.0.22+
"smartDetection",
"platform",
"repeatTimes",
"ringSettings",
"speakerTrackList",
"hasHttpsClientOTA",
"isUCoreStacked",
# 5.0.33+
"isThirdPartyCamera",
# 6.0.0+
"isFavorite",
"favoriteObjectIds",
}
NEW_CAMERA_FEATURE_FLAGS = {
"audio",
"audioCodecs",
"hasInfrared",
"hotplug",
"smartDetectAudioTypes",
"lensType",
# 2.7.18+
"isDoorbell",
# 2.8.22+
"lensModel",
# 2.9.20+
"hasColorLcdScreen",
"hasLineCrossing",
"hasLineCrossingCounting",
"hasLiveviewTracking",
# 2.10.10+
"hasFlash",
"isPtz",
# 2.11.13+
"audioStyle",
"hasVerticalFlip",
# 3.0.22+
"flashRange",
# 4.73.71+
"supportNfc",
"hasFingerprintSensor",
# 6.0.0+
"supportFullHdSnapshot",
}
NEW_ISP_SETTINGS = {
# 3.0.22+
"hdrMode",
"icrCustomValue",
"icrSwitchMode",
"spotlightDuration",
}
NEW_NVR_FEATURE_FLAGS = {
# 2.8.14+
"ulpRoleManagement",
}
OLD_FIELDS = {
# remove in 2.7.12
"avgMotions",
# removed in 2.10.11
"eventStats",
# removed in 3.0.22
"pirSettings",
}
pytest.register_assert_rewrite("tests.common")
def compare_objs(obj_type, expected, actual):
expected = deepcopy(expected)
actual = deepcopy(actual)
if obj_type in (ModelType.CAMERA.value, ModelType.AIPORT.value):
# fields does not always exist (G4 Instant)
expected.pop("apMac", None)
# field no longer exists on newer cameras
expected.pop("elementInfo", None)
del expected["apRssi"]
del expected["lastPrivacyZonePositionId"]
expected.pop("recordingSchedules", None)
del expected["smartDetectLines"]
expected.pop("streamSharing", None)
expected.pop("stopStreamLevel", None)
expected.pop("uplinkDevice", None)
expected.pop("recordingSchedulesV2", None)
expected["stats"].pop("battery", None)
expected["recordingSettings"].pop("enablePirTimelapse", None)
expected["featureFlags"].pop("hasBattery", None)
# do not compare detect zones because float math sucks
assert len(expected["motionZones"]) == len(actual["motionZones"])
assert len(expected["privacyZones"]) == len(actual["privacyZones"])
assert len(expected["smartDetectZones"]) == len(actual["smartDetectZones"])
expected["motionZones"] = actual["motionZones"] = []
expected["privacyZones"] = actual["privacyZones"] = []
expected["smartDetectZones"] = actual["smartDetectZones"] = []
if "isColorNightVisionEnabled" not in expected["ispSettings"]:
actual["ispSettings"].pop("isColorNightVisionEnabled", None)
if (
"audioTypes" in actual["smartDetectSettings"]
and "audioTypes" not in expected["smartDetectSettings"]
):
del actual["smartDetectSettings"]["audioTypes"]
if (
"autoTrackingObjectTypes" in actual["smartDetectSettings"]
and "autoTrackingObjectTypes" not in expected["smartDetectSettings"]
):
del actual["smartDetectSettings"]["autoTrackingObjectTypes"]
exp_settings = expected["recordingSettings"]
act_settings = actual["recordingSettings"]
exp_settings["enableMotionDetection"] = exp_settings.get(
"enableMotionDetection",
)
if act_settings and "inScheduleMode" not in exp_settings:
del act_settings["inScheduleMode"]
if "outScheduleMode" in act_settings and "outScheduleMode" not in exp_settings:
del act_settings["outScheduleMode"]
if "retentionDurationMs" not in exp_settings:
act_settings.pop("retentionDurationMs", None)
if "smartDetectPostPadding" not in exp_settings:
act_settings.pop("smartDetectPostPadding", None)
if "smartDetectPrePadding" not in exp_settings:
act_settings.pop("smartDetectPrePadding", None)
if (
"talkbackSettings" in expected
and expected["talkbackSettings"].get("bindAddr") == ""
):
actual["talkbackSettings"]["bindAddr"] = ""
if "createAccessEvent" not in expected["recordingSettings"]:
actual["recordingSettings"].pop("createAccessEvent", None)
for flag in NEW_CAMERA_FEATURE_FLAGS:
if flag not in expected["featureFlags"]:
del actual["featureFlags"][flag]
for setting in NEW_ISP_SETTINGS:
if setting not in expected["ispSettings"]:
del actual["ispSettings"][setting]
# ignore changes to motion for live tests
assert isinstance(actual["isMotionDetected"], bool)
expected["isMotionDetected"] = actual["isMotionDetected"]
if "isAdoptedByAccessApp" not in expected:
actual.pop("isAdoptedByAccessApp", None)
for index, channel in enumerate(expected["channels"]):
if "bitrate" not in channel:
actual["channels"][index].pop("bitrate", None)
if "minBitrate" not in channel:
actual["channels"][index].pop("minBitrate", None)
if "maxBitrate" not in channel:
actual["channels"][index].pop("maxBitrate", None)
if "autoBitrate" not in channel:
actual["channels"][index].pop("autoBitrate", None)
if "autoFps" not in channel:
actual["channels"][index].pop("autoFps", None)
elif obj_type == ModelType.USER.value:
expected.pop("settings", None)
expected.pop("cloudProviders", None)
del expected["alertRules"]
del expected["notificationsV2"]
expected.pop("notifications", None)
# lastLoginIp/lastLoginTime is not always present
if "lastLoginIp" not in expected:
actual.pop("lastLoginIp", None)
if "lastLoginTime" not in expected:
actual.pop("lastLoginTime", None)
if "email" not in expected and "email" in actual and actual["email"] is None:
actual.pop("email", None)
elif obj_type == ModelType.EVENT.value:
expected.pop("partition", None)
expected.pop("deletionType", None)
expected.pop("description", None)
if "category" in expected and expected["category"] is None:
expected.pop("category", None)
exp_thumbnails = expected.get("metadata", {}).pop("detectedThumbnails", [])
act_thumbnails = actual.get("metadata", {}).pop("detectedThumbnails", [])
for index, exp_thumb in enumerate(exp_thumbnails):
if "attributes" not in exp_thumb:
del act_thumbnails[index]["attributes"]
if "clockBestWall" not in exp_thumb:
del act_thumbnails[index]["clockBestWall"]
assert exp_thumbnails == act_thumbnails
expected_keys = (expected.get("metadata") or {}).keys()
actual_keys = (actual.get("metadata") or {}).keys()
# delete all extra metadata keys, many of which are not modeled
for key in set(expected_keys).difference(actual_keys):
del expected["metadata"][key]
elif obj_type == ModelType.SENSOR.value:
del expected["bridgeCandidates"]
actual.pop("host", None)
expected.pop("host", None)
elif obj_type == ModelType.CHIME.value:
del expected["apMac"]
del expected["apRssi"]
del expected["elementInfo"]
elif obj_type == ModelType.NVR.value:
del expected["errorCode"]
del expected["wifiSettings"]
del expected["smartDetectAgreement"]
expected.pop("dbRecoveryOptions", None)
expected.pop("portStatus", None)
expected.pop("cameraCapacity", None)
expected.pop("deviceFirmwareSettings", None)
# removed fields
expected["ports"].pop("cameraTcp", None)
expected["ports"]["piongw"] = expected["ports"].get("piongw")
expected["ports"]["stacking"] = expected["ports"].get("stacking")
expected["ports"]["emsJsonCLI"] = expected["ports"].get("emsJsonCLI")
expected["ports"]["aiFeatureConsole"] = expected["ports"].get(
"aiFeatureConsole",
)
expected["globalCameraSettings"] = expected.get("globalCameraSettings")
if expected["globalCameraSettings"]:
settings = expected["globalCameraSettings"]["recordingSettings"]
settings["retentionDurationMs"] = settings.get(
"retentionDurationMs",
)
expected["globalCameraSettings"].pop("recordingSchedulesV2", None)
if (
"homekitPaired" in actual["featureFlags"]
and "homekitPaired" not in expected["featureFlags"]
):
del actual["featureFlags"]["homekitPaired"]
if (
"detectionLabels" in actual["featureFlags"]
and "detectionLabels" not in expected["featureFlags"]
):
del actual["featureFlags"]["detectionLabels"]
if (
"hasTwoWayAudioMediaStreams" in actual["featureFlags"]
and "hasTwoWayAudioMediaStreams" not in expected["featureFlags"]
):
del actual["featureFlags"]["hasTwoWayAudioMediaStreams"]
if "capability" not in expected["systemInfo"]["storage"]:
actual["systemInfo"]["storage"].pop("capability", None)
# float math...
cpu_fields = ["averageLoad", "temperature"]
for key in cpu_fields:
if math.isclose(
expected["systemInfo"]["cpu"][key],
actual["systemInfo"]["cpu"][key],
rel_tol=0.01,
):
expected["systemInfo"]["cpu"][key] = actual["systemInfo"]["cpu"][key]
if expected["systemInfo"].get("ustorage") is not None:
actual_ustor = actual["systemInfo"]["ustorage"]
expected_ustor = expected["systemInfo"]["ustorage"]
expected_ustor.pop("sdcards", None)
for index, disk in enumerate(expected_ustor["disks"]):
actual_disk = actual_ustor["disks"][index]
estimate = disk.get("estimate")
actual_estimate = actual_disk.get("estimate")
if (
estimate is not None
and actual_estimate is not None
and math.isclose(estimate, actual_estimate, rel_tol=0.01)
):
actual_ustor["disks"][index]["estimate"] = estimate
for index, device in enumerate(expected_ustor["space"]):
actual_device = actual_ustor["space"][index]
estimate = device.get("estimate")
actual_estimate = actual_device.get("estimate")
if (
estimate is not None
and actual_estimate is not None
and math.isclose(estimate, actual_estimate, rel_tol=0.01)
):
actual_ustor["space"][index]["estimate"] = estimate
if "space_type" not in device:
del actual_device["space_type"]
if "size" in device:
actual_device["size"] = actual_device.pop("size", None)
if "reasons" in device:
del device["reasons"]
for flag in NEW_NVR_FEATURE_FLAGS:
if flag not in expected["featureFlags"]:
del actual["featureFlags"][flag]
if "bridge" not in expected and "bridge" in actual and actual["bridge"] is None:
actual.pop("bridge", None)
if "bluetoothConnectionState" in expected:
expected["bluetoothConnectionState"]["experienceScore"] = expected[
"bluetoothConnectionState"
].get(
"experienceScore",
)
if "wifiConnectionState" in expected:
expected["wifiConnectionState"]["bssid"] = expected["wifiConnectionState"].get(
"bssid",
)
expected["wifiConnectionState"]["txRate"] = expected["wifiConnectionState"].get(
"txRate",
)
expected["wifiConnectionState"]["experience"] = expected[
"wifiConnectionState"
].get("experience")
expected["wifiConnectionState"]["apName"] = expected["wifiConnectionState"].get(
"apName",
)
expected["wifiConnectionState"]["connectivity"] = expected[
"wifiConnectionState"
].get("connectivity")
# sometimes uptime comes back as a str...
if "uptime" in expected and expected["uptime"] is not None:
expected["uptime"] = int(expected["uptime"])
# force hardware revision to str to make sure types line up
if "hardwareRevision" in expected and expected["hardwareRevision"] is not None:
expected["hardwareRevision"] = str(expected["hardwareRevision"])
actual["hardwareRevision"] = str(actual["hardwareRevision"])
# edge case with broken UUID from Protect
if (
"guid" in expected
and expected["guid"] == _BAD_UUID
and actual["guid"] == "00000000-0000-0000-0000-000000000000"
):
actual["guid"] = expected["guid"]
for key in NEW_FIELDS.intersection(actual.keys()):
if key not in expected:
del actual[key]
for key in OLD_FIELDS.intersection(expected.keys()):
del expected[key]
if "anonymousDeviceId" in expected and not expected["anonymousDeviceId"]:
expected["anonymousDeviceId"] = None
assert expected == actual
@pytest.fixture()
def _disable_camera_validation():
Camera.model_config["validate_assignment"] = False
yield
Camera.model_config["validate_assignment"] = True
@pytest.fixture()
def _disable_nvr_validation():
original_validate_assignment = NVR.model_config.get("validate_assignment", True)
NVR.model_config["validate_assignment"] = False
yield
NVR.model_config["validate_assignment"] = original_validate_assignment
class MockTalkback:
def __init__(self) -> None:
self.start = AsyncMock()
self.stop = AsyncMock()
self.run_until_complete = AsyncMock()
uilibs-uiprotect-317a52c/tests/data/ 0000775 0000000 0000000 00000000000 15246377057 0017437 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/data/__init__.py 0000664 0000000 0000000 00000000000 15246377057 0021536 0 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/data/test_camera.py 0000664 0000000 0000000 00000156462 15246377057 0022316 0 ustar 00root root 0000000 0000000 # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, arg-type"
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from pydantic import ValidationError
from tests.conftest import TEST_CAMERA_EXISTS
from uiprotect import ProtectApiClient
from uiprotect.data import (
CHANNEL_ID_BY_RTSPS_QUALITY,
RTSPS_QUALITY_BY_CHANNEL_ID,
Camera,
ChannelQuality,
ChimeType,
DoorbellMessageType,
HDRMode,
IRLEDMode,
LCDMessage,
PTZPatrol,
RecordingMode,
SmartDetectAudioType,
channel_id_for_quality,
quality_for_channel_id,
)
from uiprotect.data.devices import (
CameraChannel,
CameraZone,
Hotplug,
HotplugExtender,
WifiStats,
)
from uiprotect.data.types import DEFAULT, PermissionNode, SmartDetectObjectType
from uiprotect.data.websocket import WSAction, WSSubscriptionMessage
from uiprotect.exceptions import BadRequest, NotAuthorized
from uiprotect.utils import to_js_time
@pytest.mark.parametrize(
("channel_id", "fps", "expected"),
[
(3, 2, True), # Package channel with low fps (legacy behavior)
(3, 15, True), # Package channel with higher fps (G6 Entry)
(3, None, False), # Package channel with no fps
(0, 2, False), # Non-package channel with low fps
(1, 15, False), # Non-package channel
(2, None, False), # Non-package channel with no fps
],
)
def test_camera_channel_is_package(channel_id: int, fps: int | None, expected: bool):
"""Test CameraChannel.is_package uses channel id == 3 for detection."""
channel = CameraChannel.from_unifi_dict(
id=channel_id,
videoId="test",
name="test",
enabled=True,
isRtspEnabled=False,
width=1920,
height=1080,
fps=fps,
bitrate=1000,
fpsValues=[],
idrInterval=1,
)
assert channel.is_package is expected
@pytest.mark.parametrize(
("channel_id", "expected"),
[
(0, ChannelQuality.HIGH),
(1, ChannelQuality.MEDIUM),
(2, ChannelQuality.LOW),
(3, ChannelQuality.PACKAGE),
(4, None),
],
)
def test_camera_channel_rtsps_quality(channel_id: int, expected: ChannelQuality | None):
"""Test CameraChannel.rtsps_quality maps channel id to RTSPS quality tier."""
channel = CameraChannel.from_unifi_dict(
id=channel_id,
videoId="test",
name="test",
enabled=True,
isRtspEnabled=False,
width=1920,
height=1080,
fps=15,
bitrate=1000,
fpsValues=[],
idrInterval=1,
)
assert channel.rtsps_quality is expected
@pytest.mark.parametrize(
("channel_id", "expected"),
[
(0, ChannelQuality.HIGH),
(1, ChannelQuality.MEDIUM),
(2, ChannelQuality.LOW),
(3, ChannelQuality.PACKAGE),
(4, None),
],
)
def test_quality_for_channel_id(channel_id: int, expected: ChannelQuality | None):
"""Test quality_for_channel_id maps channel id to RTSPS quality tier."""
assert quality_for_channel_id(channel_id) is expected
@pytest.mark.parametrize(
("quality", "expected"),
[
(ChannelQuality.HIGH, 0),
(ChannelQuality.MEDIUM, 1),
(ChannelQuality.LOW, 2),
(ChannelQuality.PACKAGE, 3),
(ChannelQuality.UNKNOWN, None),
],
)
def test_channel_id_for_quality(quality: ChannelQuality, expected: int | None):
"""Test channel_id_for_quality inverts the RTSPS quality mapping."""
assert channel_id_for_quality(quality) == expected
def test_rtsps_quality_mappings_are_inverses():
"""Test the forward and inverse RTSPS quality mappings agree."""
assert {
quality: channel_id
for channel_id, quality in RTSPS_QUALITY_BY_CHANNEL_ID.items()
} == dict(CHANNEL_ID_BY_RTSPS_QUALITY)
@pytest.mark.parametrize(
("link_speed", "expected_type"),
[
(300, int), # new Protect behavior
("300 Mbps", str), # legacy behavior
(None, type(None)),
],
)
def test_wifi_stats_link_speed_mbps(link_speed: int | str | None, expected_type: type):
"""Test WifiStats accepts int, str, and None for link_speed_mbps (Protect 6.x+)."""
stats = WifiStats.from_unifi_dict(
linkSpeedMbps=link_speed,
signalQuality=50,
signalStrength=0,
)
assert stats.link_speed_mbps == link_speed
assert isinstance(stats.link_speed_mbps, expected_type)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_set_motion_detection(camera_obj: Camera | None, status: bool):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.recording_settings.enable_motion_detection = not status
await camera_obj.set_motion_detection(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"recordingSettings": {"enableMotionDetection": status}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("mode", [RecordingMode.ALWAYS, RecordingMode.DETECTIONS])
@pytest.mark.asyncio()
async def test_camera_set_recording_mode(
camera_obj: Camera | None,
mode: RecordingMode,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.recording_settings.mode = RecordingMode.NEVER
await camera_obj.set_recording_mode(mode)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"recordingSettings": {"mode": mode.value}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_ir_led_model_no_ir(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_led_ir = False
with pytest.raises(BadRequest):
await camera_obj.set_ir_led_model(IRLEDMode.AUTO)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("mode", [IRLEDMode.AUTO, IRLEDMode.ON])
@pytest.mark.asyncio()
async def test_camera_set_ir_led_model(camera_obj: Camera | None, mode: IRLEDMode):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_led_ir = True
camera_obj.isp_settings.ir_led_mode = IRLEDMode.OFF
await camera_obj.set_ir_led_model(mode)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"ispSettings": {"irLedMode": mode.value}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_status_light_no_status(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_led_status = False
with pytest.raises(BadRequest):
await camera_obj.set_status_light(True)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_set_status_light(camera_obj: Camera | None, status: bool):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_led_status = True
camera_obj.led_settings.is_enabled = not status
camera_obj.led_settings.blink_rate = 10
await camera_obj.set_status_light(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"ledSettings": {"isEnabled": status, "blinkRate": 0}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("state", "expected"),
[
((True, HDRMode.NORMAL), "auto"),
((False, HDRMode.NORMAL), "off"),
((False, HDRMode.ALWAYS_ON), "off"),
((True, HDRMode.ALWAYS_ON), "always"),
],
)
def test_camera_hdr_mode_display(
camera_obj: Camera | None,
state: tuple[bool, HDRMode],
expected: str,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.hdr_mode, camera_obj.isp_settings.hdr_mode = state
assert camera_obj.hdr_mode_display == expected
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_set_color_night_vision(
camera_obj: Camera | None,
status: bool,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.hotplug = Hotplug()
camera_obj.feature_flags.hotplug.extender = HotplugExtender()
camera_obj.feature_flags.hotplug.extender.is_attached = True
camera_obj.isp_settings.is_color_night_vision_enabled = not status
await camera_obj.set_color_night_vision(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"ispSettings": {"isColorNightVisionEnabled": status}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_color_night_vision_no_color_night_vision(
camera_obj: Camera | None,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
with pytest.raises(BadRequest):
await camera_obj.set_color_night_vision(True)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_set_ssh(camera_obj: Camera | None, status: bool):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.is_ssh_enabled = not status
await camera_obj.set_ssh(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"isSshEnabled": status},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_camera_zoom_no_zoom(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.can_optical_zoom = False
with pytest.raises(BadRequest):
await camera_obj.set_camera_zoom(True)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [-1, 0, 100, 200])
@pytest.mark.asyncio()
async def test_camera_set_camera_zoom(camera_obj: Camera | None, level: int):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.can_optical_zoom = True
camera_obj.isp_settings.zoom_position = 10
if level in {-1, 200}:
with pytest.raises(ValidationError):
await camera_obj.set_camera_zoom(level)
assert not camera_obj.api.api_request.called
else:
await camera_obj.set_camera_zoom(level)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"ispSettings": {"zoomPosition": level}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [-1, 0, 3, 5])
@pytest.mark.asyncio()
async def test_camera_set_wdr_level(camera_obj: Camera | None, level: int):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_hdr = False
camera_obj.isp_settings.wdr = 2
if level in {-1, 5}:
with pytest.raises(ValidationError):
await camera_obj.set_wdr_level(level)
assert not camera_obj.api.api_request.called
else:
await camera_obj.set_wdr_level(level)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"ispSettings": {"wdr": level}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_wdr_level_hdr(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_hdr = True
with pytest.raises(BadRequest):
await camera_obj.set_wdr_level(1)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_speaker_volume_no_speaker(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_speaker = False
with pytest.raises(BadRequest):
await camera_obj.set_speaker_volume(True)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [-1, 0, 100, 200])
@pytest.mark.asyncio()
async def test_camera_set_speaker_volume(camera_obj: Camera | None, level: int):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_speaker = True
camera_obj.speaker_settings.speaker_volume = 10
if level in {-1, 200}:
with pytest.raises(ValidationError):
await camera_obj.set_speaker_volume(level)
assert not camera_obj.api.api_request.called
else:
await camera_obj.set_speaker_volume(level)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"speakerSettings": {"speakerVolume": level}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_volume_no_speaker(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_speaker = False
with pytest.raises(BadRequest):
await camera_obj.set_volume(50)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [-1, 0, 100, 200])
@pytest.mark.asyncio()
async def test_camera_set_volume(camera_obj: Camera | None, level: int):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_speaker = True
camera_obj.speaker_settings.volume = 10
if level in {-1, 200}:
with pytest.raises(ValidationError):
await camera_obj.set_volume(level)
assert not camera_obj.api.api_request.called
else:
await camera_obj.set_volume(level)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"speakerSettings": {"volume": level}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_ring_volume_no_doorbell(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_doorbell = False
with pytest.raises(BadRequest):
await camera_obj.set_ring_volume(50)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [-1, 0, 100, 200])
@pytest.mark.asyncio()
async def test_camera_set_ring_volume(camera_obj: Camera | None, level: int):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_doorbell = True
camera_obj.speaker_settings.ring_volume = 10
if level in {-1, 200}:
with pytest.raises(ValidationError):
await camera_obj.set_ring_volume(level)
assert not camera_obj.api.api_request.called
else:
await camera_obj.set_ring_volume(level)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"speakerSettings": {"ringVolume": level}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_chime_duration_no_chime(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_chime = False
with pytest.raises(BadRequest):
await camera_obj.set_chime_duration(1000)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_mechanical_chime(
camera_obj: Camera | None,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.has_chime = True
camera_obj.chime_duration = timedelta(seconds=0.3)
assert camera_obj.chime_duration_seconds == 0.3
assert camera_obj.chime_type is ChimeType.MECHANICAL
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_no_chime(
camera_obj: Camera | None,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.has_chime = True
camera_obj.chime_duration = timedelta(seconds=0)
assert camera_obj.chime_duration_seconds == 0
assert camera_obj.chime_type is ChimeType.NONE
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("duration", [-1, 0, 0.5, 1, 20])
@pytest.mark.asyncio()
async def test_camera_set_chime_duration_duration(
camera_obj: Camera | None,
duration: int,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_chime = True
camera_obj.chime_duration = timedelta(seconds=300)
assert camera_obj.chime_duration_seconds == 300
assert camera_obj.chime_type is ChimeType.DIGITAL
camera_obj.mic_volume = 10
if duration in {-1, 20}:
with pytest.raises(BadRequest):
await camera_obj.set_chime_duration(duration)
assert not camera_obj.api.api_request.called
else:
await camera_obj.set_chime_duration(duration)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"chimeDuration": duration * 1000},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_system_sounds_no_speaker(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_speaker = False
with pytest.raises(BadRequest):
await camera_obj.set_system_sounds(True)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_set_system_sounds(camera_obj: Camera | None, status: bool):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_speaker = True
camera_obj.speaker_settings.are_system_sounds_enabled = not status
await camera_obj.set_system_sounds(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"speakerSettings": {"areSystemSoundsEnabled": status}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_set_osd_bitrate(camera_obj: Camera | None, status: bool):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.osd_settings.is_debug_enabled = not status
await camera_obj.set_osd_bitrate(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"osdSettings": {"isDebugEnabled": status}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_smart_detect_types_no_smart(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_smart_detect = False
with pytest.raises(BadRequest):
await camera_obj.set_smart_detect_types([])
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_smart_detect_types(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_smart_detect = True
camera_obj.smart_detect_settings.object_types = []
await camera_obj.set_smart_detect_types([SmartDetectObjectType.PERSON])
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"smartDetectSettings": {"objectTypes": ["person"]}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_lcd_text_no_lcd(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_lcd_screen = False
with pytest.raises(BadRequest):
await camera_obj.set_lcd_text(DoorbellMessageType.DO_NOT_DISTURB)
assert not camera_obj.api.api_request.called
def test_lcd_message_missing_text_defaults_to_empty():
msg = LCDMessage.model_construct(type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR)
assert msg.text == ""
def test_lcd_message_from_unifi_dict_missing_text():
msg = LCDMessage.from_unifi_dict(
type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value
)
assert msg.text == ""
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_lcd_text_custom(camera_obj: Camera | None):
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_lcd_screen = True
camera_obj.lcd_message = LCDMessage(
type=DoorbellMessageType.DO_NOT_DISTURB,
text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "),
reset_at=None,
)
now = datetime.now(tz=UTC)
await camera_obj.set_lcd_text(DoorbellMessageType.CUSTOM_MESSAGE, "Test", now)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={
"lcdMessage": {
"type": DoorbellMessageType.CUSTOM_MESSAGE.value,
"text": "Test",
"resetAt": to_js_time(now),
},
},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_lcd_text_custom_to_custom(camera_obj: Camera | None):
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_lcd_screen = True
camera_obj.lcd_message = LCDMessage(
type=DoorbellMessageType.CUSTOM_MESSAGE,
text="Welcome",
reset_at=None,
)
now = datetime.now(tz=UTC)
await camera_obj.set_lcd_text(DoorbellMessageType.CUSTOM_MESSAGE, "Test", now)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={
"lcdMessage": {
"type": DoorbellMessageType.CUSTOM_MESSAGE.value,
"text": "Test",
"resetAt": to_js_time(now),
},
},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_lcd_text_invalid_text(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_lcd_screen = True
with pytest.raises(BadRequest):
await camera_obj.set_lcd_text(DoorbellMessageType.DO_NOT_DISTURB, "Test")
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_lcd_text(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_lcd_screen = True
camera_obj.lcd_message = LCDMessage(
type=DoorbellMessageType.DO_NOT_DISTURB,
text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "),
reset_at=None,
)
await camera_obj.set_lcd_text(DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={
"lcdMessage": {
"type": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value,
"text": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace(
"_",
" ",
),
"resetAt": None,
},
},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
@patch("uiprotect.data.devices.utc_now")
async def test_camera_set_lcd_text_none(
mock_now,
camera_obj: Camera | None,
now: datetime,
):
mock_now.return_value = now
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.emit_message = Mock()
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_lcd_screen = True
camera_obj.lcd_message = LCDMessage(
type=DoorbellMessageType.DO_NOT_DISTURB,
text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "),
reset_at=None,
)
await camera_obj.set_lcd_text(None)
expected_dt = now - timedelta(seconds=10)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={
"lcdMessage": {
"resetAt": to_js_time(expected_dt),
},
},
)
# old/new is actually the same here since the client
# generating the message is the one that changed it
camera_obj.api.emit_message.assert_called_with(
WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=camera_obj.api.bootstrap.last_update_id,
changed_data={"lcd_message": {"reset_at": expected_dt}},
old_obj=camera_obj,
new_obj=camera_obj,
),
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
@patch("uiprotect.data.devices.utc_now")
async def test_camera_set_lcd_text_default(
mock_now,
camera_obj: Camera | None,
now: datetime,
):
mock_now.return_value = now
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_lcd_screen = True
camera_obj.lcd_message = LCDMessage(
type=DoorbellMessageType.DO_NOT_DISTURB,
text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "),
reset_at=None,
)
await camera_obj.set_lcd_text(
DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR,
reset_at=DEFAULT,
)
expected_dt = (
now
+ camera_obj.api.bootstrap.nvr.doorbell_settings.default_message_reset_timeout
)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={
"lcdMessage": {
"type": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value,
"text": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace(
"_",
" ",
),
"resetAt": to_js_time(expected_dt),
},
},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_privacy_no_privacy(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_privacy_mask = False
with pytest.raises(BadRequest):
await camera_obj.set_privacy(True)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("actual_enabled", [True, False])
@pytest.mark.parametrize("enabled", [True, False])
@pytest.mark.parametrize("level", [None, -1, 0, 100, 200])
@pytest.mark.parametrize("mode", [None, RecordingMode.ALWAYS])
@pytest.mark.asyncio()
async def test_camera_set_privacy(
camera_obj: Camera | None,
actual_enabled: bool,
enabled: bool,
level: int | None,
mode: RecordingMode | None,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.has_privacy_mask = True
camera_obj.privacy_zones = []
if actual_enabled:
camera_obj.add_privacy_zone()
camera_obj.mic_volume = 10
camera_obj.recording_settings.mode = RecordingMode.NEVER
if level in {-1, 200}:
with pytest.raises(ValidationError):
await camera_obj.set_privacy(enabled, level, mode)
assert not camera_obj.api.api_request.called
else:
expected = {}
if level is not None:
expected.update({"micVolume": level})
if mode is not None:
expected.update(
{
"recordingSettings": {
"mode": mode.value,
},
},
)
if actual_enabled != enabled:
if enabled:
expected.update(
{"privacyZones": [CameraZone.create_privacy_zone(0).unifi_dict()]},
)
else:
expected.update({"privacyZones": []})
await camera_obj.set_privacy(enabled, level, mode)
if not expected:
assert not camera_obj.api.api_request.called
else:
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json=expected,
)
if enabled:
assert camera_obj.is_privacy_on
else:
assert not camera_obj.is_privacy_on
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_person_track_no_ptz(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_ptz = False
with pytest.raises(BadRequest):
await camera_obj.set_person_track(True)
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_set_person_track(camera_obj: Camera | None, status: bool):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.is_ptz = True
camera_obj.recording_settings.mode = RecordingMode.ALWAYS
if status:
camera_obj.smart_detect_settings.auto_tracking_object_types = []
else:
camera_obj.smart_detect_settings.auto_tracking_object_types = [
SmartDetectObjectType.PERSON,
]
camera_obj.api.api_request.reset_mock()
await camera_obj.set_person_track(status)
assert camera_obj.is_person_tracking_enabled is status
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json=(
{"smartDetectSettings": {"autoTrackingObjectTypes": ["person"]}}
if status
else {"smartDetectSettings": {"autoTrackingObjectTypes": []}}
),
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_camera_disable_co(camera_obj: Camera | None, status: bool):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.is_ptz = True
camera_obj.recording_settings.mode = RecordingMode.ALWAYS
if status:
camera_obj.smart_detect_settings.audio_types = []
else:
camera_obj.smart_detect_settings.audio_types = [
SmartDetectAudioType.SMOKE,
SmartDetectAudioType.CMONX,
SmartDetectAudioType.SMOKE_CMONX,
]
camera_obj.api.api_request.reset_mock()
await camera_obj.set_smart_audio_detect_types(
[SmartDetectAudioType.SMOKE, SmartDetectAudioType.SMOKE_CMONX]
)
assert camera_obj.smart_detect_settings.audio_types == [
SmartDetectAudioType.SMOKE,
SmartDetectAudioType.SMOKE_CMONX,
]
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"smartDetectSettings": {"audioTypes": ["alrmSmoke"]}},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("value", "lux"),
[
(0, 0),
(1, 1),
(2, 3),
(3, 5),
(4, 7),
(5, 10),
(6, 12),
(7, 15),
(8, 20),
(9, 25),
(10, 30),
],
)
@pytest.mark.asyncio()
async def test_camera_set_icr_custom_lux(
camera_obj: Camera | None,
value: int,
lux: int,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.has_led_ir = True
if (
value == 0
): # without this there is no change that gets send if the test value is 0
camera_obj.isp_settings.icr_custom_value = 1
else:
camera_obj.isp_settings.icr_custom_value = 0
camera_obj.api.api_request.reset_mock()
await camera_obj.set_icr_custom_lux(lux)
assert camera_obj.isp_settings.icr_custom_value == value
assert camera_obj.icr_lux_display == lux
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json=({"ispSettings": {"icrCustomValue": value}}),
)
@pytest.mark.parametrize(
("mode", "expected"),
[
(IRLEDMode.CUSTOM, True),
(IRLEDMode.CUSTOM_FILTER_ONLY, True),
(IRLEDMode.AUTO, False),
(IRLEDMode.AUTO_NO_LED, False),
(IRLEDMode.MANUAL, False),
(IRLEDMode.ON, False),
(IRLEDMode.OFF, False),
],
)
def test_camera_is_ir_led_slider_enabled(
camera_obj: Camera | None,
mode: IRLEDMode,
expected: bool,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.has_led_ir = True
camera_obj.isp_settings.ir_led_mode = mode
assert camera_obj.is_ir_led_slider_enabled is expected
def test_camera_is_ir_led_slider_enabled_no_led_ir(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.has_led_ir = False
camera_obj.isp_settings.ir_led_mode = IRLEDMode.CUSTOM
assert camera_obj.is_ir_led_slider_enabled is False
@pytest.mark.asyncio
async def test_get_snapshot_read_live_granted(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data")
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return permission == PermissionNode.READ_LIVE
with patch.object(auth_user, "can", side_effect=mock_can):
snapshot = await camera_obj.get_snapshot()
assert snapshot == b"snapshot_data"
@pytest.mark.asyncio
async def test_get_snapshot_read_media_granted(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data")
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return permission == PermissionNode.READ_MEDIA
with patch.object(auth_user, "can", side_effect=mock_can):
snapshot = await camera_obj.get_snapshot()
assert snapshot == b"snapshot_data"
@pytest.mark.asyncio
async def test_get_snapshot_no_permissions(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data")
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return False
with (
patch.object(auth_user, "can", side_effect=mock_can),
pytest.raises(
NotAuthorized,
match=f"Do not have permission to read live or media for camera: {camera_obj.id}",
),
):
await camera_obj.get_snapshot()
@pytest.mark.asyncio
async def test_get_public_api_snapshot_no_api_key(camera_obj: Camera):
"""Test that get_public_api_snapshot fails with NotAuthorized if no API key is set in the API client."""
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api._api_key = None
# get_public_api_snapshot should raise NotAuthorized if api_key is missing
with pytest.raises(
NotAuthorized, match=r"Cannot get public API snapshot without an API key\."
):
await camera_obj.get_public_api_snapshot()
@pytest.mark.asyncio
@pytest.mark.parametrize("high_quality", [False, True])
async def test_get_public_api_snapshot_hq(camera_obj: Camera, high_quality: bool):
"""Test that get_public_api_snapshot returns the correct snapshot quality."""
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_public_api_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
snapshot = await camera_obj.get_public_api_snapshot(high_quality=high_quality)
assert snapshot == b"snapshot_data"
camera_obj._api.get_public_api_camera_snapshot.assert_called_once_with(
camera_id=camera_obj.id, high_quality=high_quality, package=False
)
@pytest.mark.parametrize("high_quality", [False, True])
@pytest.mark.asyncio
async def test_get_public_api_snapshot_default_quality(
camera_obj: Camera, high_quality: bool
):
"""Test get_public_api_snapshot without high_quality argument uses feature flag."""
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj.feature_flags.support_full_hd_snapshot = high_quality
camera_obj._api.get_public_api_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
snapshot = await camera_obj.get_public_api_snapshot()
assert snapshot == b"snapshot_data"
camera_obj._api.get_public_api_camera_snapshot.assert_called_once_with(
camera_id=camera_obj.id, high_quality=high_quality, package=False
)
@pytest.mark.parametrize("package", [False, True])
@pytest.mark.asyncio
async def test_get_public_api_snapshot_package(camera_obj: Camera, package: bool):
"""Test get_public_api_snapshot forwards the package flag to the api layer."""
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj.feature_flags.support_full_hd_snapshot = False
camera_obj._api.get_public_api_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
snapshot = await camera_obj.get_public_api_snapshot(package=package)
assert snapshot == b"snapshot_data"
camera_obj._api.get_public_api_camera_snapshot.assert_called_once_with(
camera_id=camera_obj.id, high_quality=False, package=package
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing test data")
@pytest.mark.asyncio
async def test_get_snapshot_with_dt(camera_obj: Camera):
camera_obj.api.api_request.reset_mock()
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data")
now = datetime.now(tz=UTC)
snapshot = await camera_obj.get_snapshot(dt=now)
assert snapshot == b"snapshot_data"
camera_obj._api.get_camera_snapshot.assert_called_once_with(
camera_obj.id, None, camera_obj.high_camera_channel.height, dt=now
)
@pytest.mark.asyncio
async def test_get_snapshot_with_dt_no_read_media(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data")
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return permission != PermissionNode.READ_MEDIA
with (
patch.object(auth_user, "can", side_effect=mock_can),
pytest.raises(
NotAuthorized,
match=f"Do not have permission to read media for camera: {camera_obj.id}",
),
):
await camera_obj.get_snapshot(dt=datetime.now())
@pytest.mark.asyncio
async def test_get_package_snapshot_read_live_granted(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_package_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
camera_obj.feature_flags.has_package_camera = True
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return permission == PermissionNode.READ_LIVE
with patch.object(auth_user, "can", side_effect=mock_can):
snapshot = await camera_obj.get_package_snapshot()
assert snapshot == b"snapshot_data"
@pytest.mark.asyncio
async def test_get_package_snapshot_read_media_granted(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_package_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
camera_obj.feature_flags.has_package_camera = True
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return permission == PermissionNode.READ_MEDIA
with patch.object(auth_user, "can", side_effect=mock_can):
snapshot = await camera_obj.get_package_snapshot()
assert snapshot == b"snapshot_data"
@pytest.mark.asyncio
async def test_get_package_snapshot_no_permissions(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_package_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
camera_obj.feature_flags.has_package_camera = True
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return False
with (
patch.object(auth_user, "can", side_effect=mock_can),
pytest.raises(
NotAuthorized,
match=f"Do not have permission to read live or media for camera: {camera_obj.id}",
),
):
await camera_obj.get_package_snapshot()
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing test data")
@pytest.mark.asyncio
async def test_get_package_snapshot_with_dt(camera_obj: Camera):
camera_obj.api.api_request.reset_mock()
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_package_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
camera_obj.feature_flags.has_package_camera = True
now = datetime.now(tz=UTC)
snapshot = await camera_obj.get_package_snapshot(dt=now)
assert snapshot == b"snapshot_data"
camera_obj._api.get_package_camera_snapshot.assert_called_once_with(
camera_obj.id, None, None, dt=now
)
@pytest.mark.asyncio
async def test_get_package_snapshot_no_package_camera(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_package_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
# Simulate a device without a package camera
camera_obj.feature_flags.has_package_camera = False
with pytest.raises(
BadRequest,
match="Device does not have package camera",
):
await camera_obj.get_package_snapshot()
@pytest.mark.asyncio
async def test_get_package_snapshot_dt_no_read_media(camera_obj: Camera | None):
camera_obj._api = MagicMock(spec=ProtectApiClient)
camera_obj._api.get_package_camera_snapshot = AsyncMock(
return_value=b"snapshot_data"
)
camera_obj.feature_flags.has_package_camera = True
auth_user = camera_obj._api.bootstrap.auth_user
def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool:
return (
permission != PermissionNode.READ_MEDIA
) # Simulate missing READ_MEDIA permission
with (
patch.object(auth_user, "can", side_effect=mock_can),
pytest.raises(
NotAuthorized,
match=f"Do not have permission to read media for camera: {camera_obj.id}",
),
):
await camera_obj.get_package_snapshot(dt=datetime.now())
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_camera_can_detect_face(camera_obj: Camera) -> None:
# Test when face detection is supported
camera_obj.feature_flags.smart_detect_types = [SmartDetectObjectType.FACE]
assert camera_obj.can_detect_face is True
# Test when face detection is not supported
camera_obj.feature_flags.smart_detect_types = [SmartDetectObjectType.PERSON]
assert camera_obj.can_detect_face is False
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_camera_is_face_detection_on(camera_obj: Camera) -> None:
# Test when face detection is enabled
camera_obj.feature_flags.smart_detect_types = [SmartDetectObjectType.FACE]
camera_obj.smart_detect_settings.object_types = [SmartDetectObjectType.FACE]
assert camera_obj.is_face_detection_on is True
# Test when face detection is disabled
camera_obj.smart_detect_settings.object_types = []
assert camera_obj.is_face_detection_on is False
# Test when face detection is not supported
camera_obj.feature_flags.smart_detect_types = []
assert camera_obj.is_face_detection_on is False
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_camera_is_face_currently_detected(camera_obj: Camera) -> None:
# Set up camera to support face detection
camera_obj.feature_flags.can_optical_zoom = True
camera_obj.smart_detect_settings.object_types = [SmartDetectObjectType.FACE]
# Test when face is currently detected
camera_obj.is_smart_detected = True
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.FACE] = "test_event_id"
# Create mock event that's ongoing (end=None) with face detection
mock_event = Mock()
mock_event.end = None
mock_event.smart_detect_types = [SmartDetectObjectType.FACE]
with patch.object(camera_obj.api.bootstrap.events, "get", return_value=mock_event):
assert camera_obj.is_face_currently_detected is True
# Test when face is not currently detected (no event)
camera_obj.last_smart_detect_event_ids.pop(SmartDetectObjectType.FACE, None)
assert camera_obj.is_face_currently_detected is False
# Test when face is not currently detected (event ended)
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.FACE] = "test_event_id"
mock_event.end = datetime.now()
with patch.object(camera_obj.api.bootstrap.events, "get", return_value=mock_event):
assert camera_obj.is_face_currently_detected is False
def test_camera_zone_color_serialization() -> None:
"""Test that CameraZone color is serialized as hex string, not dict with RGB values."""
zone = CameraZone.create_privacy_zone(0)
zone_dict = zone.unifi_dict()
# Verify color is serialized as string (hex format)
assert "color" in zone_dict
assert isinstance(zone_dict["color"], str)
assert zone_dict["color"] == "#85BCEC"
# Verify it's not a dict with RGB values (which would cause Pydantic warnings)
assert not isinstance(zone_dict["color"], dict)
def test_camera_zone_color_serialization_from_dict() -> None:
"""
Test that CameraZone.unifi_dict() converts color dict to hex string.
This simulates the case where Pydantic's serializer returns a dict
with RGB values instead of a hex string, which would cause warnings.
"""
zone = CameraZone.create_privacy_zone(0)
# Create a dict with color as a dict (simulating Pydantic serialization issue)
zone_dict_with_color_dict: dict[str, Any] = {
"id": 0,
"name": "pyufp_privacy_zone",
"color": {"r": 133, "g": 188, "b": 236, "a": 1.0}, # Color as dict
"points": [[0, 0], [1, 0], [1, 1], [0, 1]],
}
# Call unifi_dict with this data - it should convert color dict to hex string
result = zone.unifi_dict(data=zone_dict_with_color_dict)
# Verify the color was converted from dict to hex string
assert isinstance(result["color"], str)
assert result["color"] == "#85BCEC"
# PTZ Public API Tests
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("method", "args"),
[
("ptz_goto_preset_public", {"slot": 0}),
("ptz_patrol_start_public", {"slot": 0}),
("ptz_patrol_stop_public", {}),
],
)
@pytest.mark.asyncio()
async def test_camera_ptz_public_api_no_ptz(
camera_obj: Camera | None,
method: str,
args: dict,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request_raw.reset_mock()
camera_obj.feature_flags.is_ptz = False
with pytest.raises(BadRequest, match="Camera does not support PTZ features"):
await getattr(camera_obj, method)(**args)
assert not camera_obj.api.api_request_raw.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("active_patrol_slot", "expected"),
[
(None, False),
(0, True),
(1, True),
],
)
def test_camera_is_ptz_patrolling(
camera_obj: Camera | None,
active_patrol_slot: int | None,
expected: bool,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.active_patrol_slot = active_patrol_slot
assert camera_obj.is_ptz_patrolling is expected
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_get_ptz_patrols(ptz_camera: Camera | None):
if ptz_camera is None:
pytest.skip("No ptz_camera obj found")
ptz_camera.api.api_request = AsyncMock(
return_value=[
{
"id": "patrol1",
"name": "Patrol 1",
"slot": 0,
"presets": [0, 1],
"presetMovementSpeed": 100,
"presetDurationSeconds": 10,
"camera": ptz_camera.id,
"modelKey": "ptzPatrol",
},
]
)
patrols = await ptz_camera.get_ptz_patrols()
assert len(patrols) == 1
assert isinstance(patrols[0], PTZPatrol)
assert patrols[0].name == "Patrol 1"
assert patrols[0].slot == 0
assert patrols[0].presets == [0, 1]
ptz_camera.api.api_request.assert_called_with(
f"cameras/{ptz_camera.id}/ptz/patrol",
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_get_ptz_patrols_no_ptz(camera_obj: Camera | None):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_ptz = False
with pytest.raises(BadRequest, match="Camera does not support PTZ features"):
await camera_obj.get_ptz_patrols()
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("method", "args", "expected_path"),
[
("ptz_goto_preset_public", {"slot": 2}, "/ptz/goto/2"),
("ptz_patrol_start_public", {"slot": 1}, "/ptz/patrol/start/1"),
("ptz_patrol_stop_public", {}, "/ptz/patrol/stop"),
],
)
@pytest.mark.asyncio()
async def test_camera_ptz_public_api(
ptz_camera: Camera | None,
method: str,
args: dict,
expected_path: str,
):
if ptz_camera is None:
pytest.skip("No ptz_camera obj found")
ptz_camera.api.api_request_raw = AsyncMock(return_value=b"")
await getattr(ptz_camera, method)(**args)
ptz_camera.api.api_request_raw.assert_called_with(
url=f"/v1/cameras/{ptz_camera.id}{expected_path}",
method="post",
public_api=True,
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("smart_detect_types", "smart_detect_audio_types", "smart_type", "expected"),
[
([SmartDetectObjectType.PERSON], [], SmartDetectObjectType.PERSON, True),
([SmartDetectObjectType.PERSON], [], SmartDetectObjectType.VEHICLE, False),
([], [SmartDetectAudioType.CMONX], SmartDetectObjectType.CMONX, True),
([], [SmartDetectAudioType.SMOKE], SmartDetectObjectType.CMONX, False),
([], [SmartDetectAudioType.BURGLAR], SmartDetectObjectType.BURGLAR, True),
([], [SmartDetectAudioType.SMOKE], SmartDetectObjectType.BURGLAR, False),
([], [SmartDetectAudioType.SMOKE], SmartDetectObjectType.PERSON, False),
([SmartDetectObjectType.SMOKE], [], SmartDetectObjectType.SMOKE, False),
([], None, SmartDetectObjectType.SMOKE, False),
],
)
@pytest.mark.asyncio()
async def test_camera_can_detect(
camera_obj: Camera | None,
smart_detect_types: list[SmartDetectObjectType],
smart_detect_audio_types: list[SmartDetectAudioType] | None,
smart_type: SmartDetectObjectType,
expected: bool,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
camera_obj.feature_flags.smart_detect_types = smart_detect_types
camera_obj.feature_flags.smart_detect_audio_types = smart_detect_audio_types
assert camera_obj.can_detect(smart_type) is expected
uilibs-uiprotect-317a52c/tests/data/test_chime.py 0000664 0000000 0000000 00000023171 15246377057 0022141 0 ustar 00root root 0000000 0000000 # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr"
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from pydantic import ValidationError
from tests.conftest import TEST_CAMERA_EXISTS, TEST_CHIME_EXISTS
from uiprotect.data import RingSetting
from uiprotect.exceptions import BadRequest
if TYPE_CHECKING:
from uiprotect.data import Camera, Chime
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.parametrize("level", [-1, 0, 100, 200])
@pytest.mark.asyncio()
async def test_chime_set_volume(
chime_obj: Chime | None,
camera_obj: Camera | None,
level: int,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
chime_obj.api.api_request.reset_mock()
chime_obj.volume = 20
chime_obj.ring_settings = [
RingSetting(
camera_id=camera_obj.id,
repeat_times=1, # type: ignore[arg-type]
ringtone_id="test-ringtone-id",
volume=20,
),
]
if level in {-1, 200}:
with pytest.raises(ValidationError):
await chime_obj.set_volume(level)
assert not chime_obj.api.api_request.called
else:
await chime_obj.set_volume(level)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={
"volume": level,
"ringSettings": [
{
"camera": camera_obj.id,
"repeatTimes": 1,
"ringtoneId": "test-ringtone-id",
"trackNo": None,
"volume": level,
},
],
},
)
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_set_volume_with_existing_custom(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.camera_ids = [camera_obj.id]
chime_obj.volume = 100
chime_obj.ring_settings = [
RingSetting(
camera_id=camera_obj.id,
repeat_times=1, # type: ignore[arg-type]
ringtone_id="test-ringtone-id",
volume=20,
),
]
camera_obj.api.api_request.reset_mock()
await chime_obj.set_volume(50)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={"volume": 50},
)
@pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_chime_play(chime_obj: Chime | None):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
await chime_obj.play()
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}/play-speaker",
method="post",
json=None,
)
@pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_chime_play_with_options(chime_obj: Chime | None):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
chime_obj.volume = 100
chime_obj.repeat_times = 1
chime_obj.api.api_request.reset_mock()
await chime_obj.play(volume=50)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}/play-speaker",
method="post",
json={
"volume": 50,
"repeatTimes": 1,
},
)
@pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_chime_play_buzzer(chime_obj: Chime | None):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
await chime_obj.play_buzzer()
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}/play-buzzer",
method="post",
)
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_add_camera(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.api.api_request.reset_mock()
chime_obj.camera_ids = []
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_doorbell = True
await chime_obj.add_camera(camera_obj)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={"cameraIds": [camera_obj.id]},
)
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_add_camera_not_doorbell(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.api.api_request.reset_mock()
chime_obj.camera_ids = []
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_doorbell = False
with pytest.raises(BadRequest):
await chime_obj.add_camera(camera_obj)
assert not chime_obj.api.api_request.called
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_add_camera_exists(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.api.api_request.reset_mock()
chime_obj.camera_ids = [camera_obj.id]
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_doorbell = True
with pytest.raises(BadRequest):
await chime_obj.add_camera(camera_obj)
assert not chime_obj.api.api_request.called
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_remove_camera(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.api.api_request.reset_mock()
chime_obj.camera_ids = [camera_obj.id]
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_doorbell = True
await chime_obj.remove_camera(camera_obj)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={"cameraIds": []},
)
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_remove_camera_not_exists(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.api.api_request.reset_mock()
chime_obj.camera_ids = []
camera_obj.api.api_request.reset_mock()
camera_obj.feature_flags.is_doorbell = True
with pytest.raises(BadRequest):
await chime_obj.remove_camera(camera_obj)
assert not chime_obj.api.api_request.called
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_set_repeat_times(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.camera_ids = [camera_obj.id]
chime_obj.repeat_times = 1
chime_obj.ring_settings = [
RingSetting(
camera_id=camera_obj.id,
repeat_times=1, # type: ignore[arg-type]
ringtone_id="test-ringtone-id",
volume=100,
),
]
camera_obj.api.api_request.reset_mock()
await chime_obj.set_repeat_times(2)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={
"repeatTimes": 2,
"ringSettings": [
{
"camera": camera_obj.id,
"repeatTimes": 2,
"ringtoneId": "test-ringtone-id",
"trackNo": None,
"volume": 100,
},
],
},
)
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_set_repeat_times_with_existing_custom(
chime_obj: Chime | None,
camera_obj: Camera | None,
):
if chime_obj is None:
pytest.skip("No chime_obj obj found")
if camera_obj is None:
pytest.skip("No camera_obj obj found")
chime_obj.camera_ids = [camera_obj.id]
chime_obj.repeat_times = 1
chime_obj.ring_settings = [
RingSetting(
camera_id=camera_obj.id,
repeat_times=3, # type: ignore[arg-type]
ringtone_id="test-ringtone-id",
volume=100,
),
]
camera_obj.api.api_request.reset_mock()
await chime_obj.set_repeat_times(2)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={"repeatTimes": 2},
)
uilibs-uiprotect-317a52c/tests/data/test_common.py 0000664 0000000 0000000 00000204217 15246377057 0022346 0 ustar 00root root 0000000 0000000 """Tests for uiprotect.data"""
from __future__ import annotations
import asyncio
import base64
import logging
from copy import deepcopy
from datetime import timedelta
from ipaddress import IPv4Address
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, Mock, patch
import pytest
from tests.conftest import (
TEST_BRIDGE_EXISTS,
TEST_CAMERA_EXISTS,
TEST_LIGHT_EXISTS,
TEST_SENSOR_EXISTS,
TEST_VIEWPORT_EXISTS,
MockTalkback,
compare_objs,
)
from tests.sample_data.constants import CONSTANTS
from uiprotect.data import (
Bootstrap,
Camera,
DoorbellMessageType,
Event,
EventType,
FixSizeOrderedDict,
ModelType,
Permission,
RecordingMode,
SmartDetectAudioType,
SmartDetectObjectType,
StorageType,
User,
VideoMode,
WSPacket,
create_from_unifi_dict,
)
from uiprotect.data.bootstrap import MAX_EVENT_HISTORY_IN_STATE_MACHINE
from uiprotect.data.devices import LCDMessage
from uiprotect.data.nvr import EventMetadata
from uiprotect.data.types import RecordingType, ResolutionStorageType
from uiprotect.data.user import CloudAccount
from uiprotect.exceptions import BadRequest, NotAuthorized, StreamError
from uiprotect.utils import set_debug, set_no_debug, utc_now
from ..common import assert_equal_dump
if TYPE_CHECKING:
from pytest_benchmark.fixture import BenchmarkFixture
from uiprotect import ProtectApiClient
PACKET_B64 = "AQEBAAAAAHR4nB2MQQrCMBBFr1JmbSDNpJnRG4hrDzBNZqCgqUiriHh3SZb/Pd7/guRtWSucBtgfRTaFwwBV39c+zqUJskQW1DufUVwkJsfFxDGLyRFj0dSz+1r0dtFPa+rr2dDSD8YsyceUpskQxzjjHIIQMvz+hMoj/AIBAQAAAAA1eJyrViotKMnMTVWyUjA0MjawMLQ0MDDQUVDKSSwuCU5NzQOJmxkbACUszE0sLQ1rAVU/DPU="
PACKET_ACTION = {
"action": "update",
"newUpdateId": "7f67f2e0-0c3a-4787-8dfa-88afa934de6e",
"modelKey": "nvr",
"id": "1ca6046655f3314b3b22a738",
}
PACKET_DATA = {"uptime": 1230819000, "lastSeen": 1630081874991}
PACKET2_B64 = "AQEBAAAAAHZ4nB2MQQrDMAwEvxJ0rqGxFNnuD0rOfYBsyRBoklISSij9e3GOO8PsF6Rs07rArYP9pbIZXDpY7PM4x12bSNdMGhkdZZ8dBakusanLHHmoojwQt2xe1Z6jHa0pMttbGp3OD0JDid7YY/VYrPfWhxQEfn/qpCUVAgEBAAAAATl4nHWQzU7DMBCEXwX5jJD/1nE5grjBoeoTuMk2tTBOsB2gqvLuOA5NaRE369vZ8cweSUwmRXJ/cyTh6+GQcHqDWEkQWt3ekLRALkAJpfhKZvxpd7Ys1XvjPbr89oNzebIL+D6grw9n5Kx/3fSIzcu2j2ccbeuNWw/G2TSpgS5wkwL6Nu0zpWOmW5MShkP5scdQo0+mxbOVjY97E1rr28x2xkWcrBxiv8n1JiFpbKy7HLVO2JDJ88M22M3Fse5Ck5ezOKSMWG4JTIKirLRdBE++KWPBGVWgKg4X47L/vD450KyoKIMrhx/B7KE5V+XO9g2d6SP+l2ERXGTgigum/+yfMlRAtZJUU3rl8CuDkBqUVNNJYurCfNcjGSJO//A8ZCA4MF2qztcUtLrjq4prClBVQo7j+A3Be62W"
PACKET2_ACTION = {
"action": "update",
"newUpdateId": "90b4d863-4b2b-47af-96ed-b6865fad6546",
"modelKey": "camera",
"id": "43e3a82e623f23ce12e1797a",
}
PACKET2_DATA = {
"stats": {
"rxBytes": 53945386,
"txBytes": 2356366294,
"wifi": {
"channel": None,
"frequency": None,
"linkSpeedMbps": None,
"signalQuality": 50,
"signalStrength": 0,
},
"battery": {
"percentage": None,
"isCharging": False,
"sleepState": "disconnected",
},
"video": {
"recordingStart": 1629514560194,
"recordingEnd": 1632106567254,
"recordingStartLQ": 1629505677015,
"recordingEndLQ": 1632106582266,
"timelapseStart": 1629514560194,
"timelapseEnd": 1632106262318,
"timelapseStartLQ": 1627508640800,
"timelapseEndLQ": 1632103485646,
},
"storage": {"used": 285615325184, "rate": 307.297280557734},
},
}
def test_packet_decode():
packet_raw = base64.b64decode(PACKET_B64)
packet = WSPacket(packet_raw)
assert packet.raw == packet_raw
assert packet.raw_base64 == PACKET_B64
assert packet.action_frame.data == PACKET_ACTION
assert packet.data_frame.data == PACKET_DATA
def test_packet_raw_setter():
packet_raw = base64.b64decode(PACKET_B64)
packet2_raw = base64.b64decode(PACKET2_B64)
packet = WSPacket(packet_raw)
packet.raw = packet2_raw
assert packet.raw == packet2_raw
assert packet.raw_base64 == PACKET2_B64
assert packet.action_frame.data == PACKET2_ACTION
assert packet.data_frame.data == PACKET2_DATA
def compare_devices(data):
obj = create_from_unifi_dict(deepcopy(data))
obj_dict = obj.unifi_dict()
compare_objs(obj.model.value, data, obj_dict)
set_no_debug()
obj_construct = create_from_unifi_dict(deepcopy(data))
assert_equal_dump(obj, obj_construct)
set_debug()
@pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata")
def test_viewport(viewport):
compare_devices(viewport)
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
def test_light(light):
compare_devices(light)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_camera(camera):
compare_devices(camera)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
def test_sensor(sensor):
compare_devices(sensor)
@pytest.mark.skipif(not TEST_BRIDGE_EXISTS, reason="Missing testdata")
def test_bridge(bridge):
compare_devices(bridge)
@pytest.mark.timeout(CONSTANTS["event_count"] * 0.1)
def test_events(raw_events):
for event in raw_events:
compare_devices(event)
@pytest.fixture
def reset_smart_detect(camera_obj: Camera):
"""Reset smart detection state on camera and clear bootstrap events for test isolation."""
camera_obj.last_smart_detect_event_id = None
camera_obj.last_smart_detect = None
camera_obj.last_smart_detect_event_ids = {}
camera_obj.last_smart_detects = {}
camera_obj._active_smart_detect_events = {}
camera_obj.is_smart_detected = True
camera_obj.api.bootstrap.events.clear()
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_camera_smart_events(camera_obj: Camera, reset_smart_detect: None):
now = utc_now()
events = [
Event( # type: ignore[call-arg]
api=camera_obj.api,
id="test_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
),
Event( # type: ignore[call-arg]
api=camera_obj.api,
id="test_event_2",
camera_id=camera_obj.id,
start=now - timedelta(seconds=15),
end=now - timedelta(seconds=8),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PACKAGE],
smart_detect_event_ids=[],
),
Event( # type: ignore[call-arg]
api=camera_obj.api,
id="test_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
end=now - timedelta(seconds=7),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[
SmartDetectObjectType.PERSON,
SmartDetectObjectType.FACE,
SmartDetectObjectType.VEHICLE,
],
smart_detect_event_ids=[],
),
Event( # type: ignore[call-arg]
api=camera_obj.api,
id="test_event_3",
camera_id=camera_obj.id,
start=now - timedelta(seconds=5),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE],
smart_detect_event_ids=[],
),
]
for event in events:
camera_obj.api.bootstrap.process_event(event)
assert camera_obj.last_smart_detect == now - timedelta(seconds=5)
assert camera_obj.last_person_detect == now - timedelta(seconds=10)
assert camera_obj.last_face_detect == now - timedelta(seconds=10)
assert camera_obj.last_vehicle_detect == now - timedelta(seconds=10)
assert camera_obj.last_package_detect == now - timedelta(seconds=15)
assert camera_obj.last_license_plate_detect == now - timedelta(seconds=5)
assert camera_obj.last_smart_detect_event is not None
assert camera_obj.last_smart_detect_event.id == "test_event_3"
assert camera_obj.last_person_detect_event is not None
assert camera_obj.last_person_detect_event.id == "test_event_1"
assert camera_obj.last_face_detect_event is not None
assert camera_obj.last_face_detect_event.id == "test_event_1"
assert camera_obj.last_vehicle_detect_event is not None
assert camera_obj.last_vehicle_detect_event.id == "test_event_1"
assert camera_obj.last_package_detect_event is not None
assert camera_obj.last_package_detect_event.id == "test_event_2"
assert camera_obj.last_license_plate_detect_event is not None
assert camera_obj.last_license_plate_detect_event.id == "test_event_3"
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_concurrent_smart_detect_zone_and_line(
camera_obj: Camera, reset_smart_detect: None
):
"""Test sensor stays ON when zone event ends but line event is still active."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
# Both events start (active, end=None)
zone_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
line_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="line_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone_event)
bootstrap.process_event(line_event)
# Line event should be tracked (last processed active event)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "line_event_1"
)
# Both top-level and per-type sensor state should report ON.
assert camera_obj.is_smart_currently_detected is True
assert camera_obj.is_person_currently_detected is True
# Zone event ends
zone_event_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
end=now - timedelta(seconds=5),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone_event_ended)
# Line event is still active and tracked — sensor should stay ON
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "line_event_1"
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is None
# Sensor state must stay ON during the overlap — this is the HA regression.
assert camera_obj.is_smart_currently_detected is True
assert camera_obj.is_person_currently_detected is True
# Line event ends
line_event_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="line_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
end=now - timedelta(seconds=3),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(line_event_ended)
# No active event left — tracked event is now ended and sensor flips OFF.
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is not None
assert camera_obj.is_smart_currently_detected is False
assert camera_obj.is_person_currently_detected is False
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_singular_last_smart_detect_event_id_not_overwritten_by_ended(
camera_obj: Camera, reset_smart_detect: None
):
"""
Ended smart event must not overwrite the singular last_smart_detect_event_id.
Protect v7 emits a SMART_DETECT and SMART_DETECT_LINE simultaneously. When one
ends while the other is still active, the singular ``last_smart_detect_event_id``
(used by ``Camera.is_smart_currently_detected`` and ``last_smart_detect_event``)
must not be overwritten with the ended event's id — otherwise the sensor flips
OFF for the overlap window.
"""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
zone_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
line_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="line_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone_event)
bootstrap.process_event(line_event)
assert camera_obj.last_smart_detect_event_id == "line_event_1"
last = camera_obj.last_smart_detect_event
assert last is not None
assert last.end is None
# Zone event ends — line event is still active.
zone_event_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
end=now - timedelta(seconds=5),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone_event_ended)
# The singular field must still reference an active event, not the ended one.
assert camera_obj.last_smart_detect_event_id == "line_event_1"
last = camera_obj.last_smart_detect_event
assert last is not None
assert last.id == "line_event_1"
assert last.end is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_tracked_event_ends_replaced_by_active(
camera_obj: Camera, reset_smart_detect: None
):
"""Test that when the tracked event ends, it's replaced by another active event."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
# Zone event starts first and is tracked
zone_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone_event)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "zone_event_1"
)
# Line event starts — overwrites tracking
line_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="line_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(line_event)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "line_event_1"
)
# Line event (tracked) ends — should be replaced by zone_event_1
line_event_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="line_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
end=now - timedelta(seconds=5),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(line_event_ended)
# Should now track zone_event_1 (still active)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "zone_event_1"
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_concurrent_different_smart_types(camera_obj: Camera, reset_smart_detect: None):
"""Test that different smart types are tracked independently."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
person_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="person_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
vehicle_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="vehicle_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=8),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.VEHICLE],
smart_detect_event_ids=[],
)
bootstrap.process_event(person_event)
bootstrap.process_event(vehicle_event)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "person_event"
)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE]
== "vehicle_event"
)
# Person event ends — should not affect vehicle tracking
person_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="person_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
end=now - timedelta(seconds=5),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(person_ended)
# Person event ended, no replacement
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is not None
# Vehicle still active and tracked
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE]
== "vehicle_event"
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.VEHICLE)
assert event is not None
assert event.end is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_multiple_zones_same_smart_type(camera_obj: Camera, reset_smart_detect: None):
"""Test two smartDetectZone events for the same type (e.g. person in two zones)."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
zone1_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone1_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
zone2_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone2_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone1_event)
bootstrap.process_event(zone2_event)
# zone2 is tracked (last active event processed)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "zone2_event"
)
# zone2 (tracked) ends — should be replaced by zone1 (still active)
zone2_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone2_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
end=now - timedelta(seconds=5),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone2_ended)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "zone1_event"
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is None
# zone1 ends too — no replacement
zone1_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone1_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
end=now - timedelta(seconds=3),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone1_ended)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is not None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_ended_event_does_not_overwrite_active(
camera_obj: Camera, reset_smart_detect: None
):
"""Test that an ended event for a non-tracked ID does not overwrite an active one."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
# Active event is tracked
active_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="active_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(active_event)
# A different ended event arrives — should NOT overwrite the active tracking
ended_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="ended_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=15),
end=now - timedelta(seconds=12),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(ended_event)
# Active event should still be tracked
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "active_event"
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_no_prior_tracking_stores_ended_event_as_last_known(
camera_obj: Camera, reset_smart_detect: None
):
"""When no event was ever tracked and an ended event arrives first, store it as last known."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
# Verify truly no prior state
assert (
camera_obj.last_smart_detect_event_ids.get(SmartDetectObjectType.PERSON) is None
)
assert (
camera_obj._active_smart_detect_events.get(SmartDetectObjectType.PERSON) is None
)
ended_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="ended_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=15),
end=now - timedelta(seconds=12),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(ended_event)
# Ended event is stored as last known; _is_smart_detected returns False because end is set
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "ended_event"
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.end is not None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_no_prior_tracking_prefers_active_index(
camera_obj: Camera, reset_smart_detect: None
):
"""When ended event arrives and an active event exists in the per-camera index, prefer it."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
# Process active event first (populates the per-camera index)
active_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="active_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(active_event)
# Clear the tracking dict but keep the active index intact
del camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
del camera_obj.last_smart_detects[SmartDetectObjectType.PERSON]
# Ended event arrives with no prior tracking (current_id is None)
ended_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="ended_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=15),
end=now - timedelta(seconds=12),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(ended_event)
# Should recover the active event from the per-camera index
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "active_event"
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.id == "active_event"
assert event.end is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_replacement_prefers_first_processed_active_event(
camera_obj: Camera, reset_smart_detect: None
):
"""Replacement picks the first-processed still-active event (processing order, not timestamp)."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
# Intentionally set start times so chronological order does NOT match
# processing order: zone1 is processed first but has the newest start,
# zone2 is processed second but has the oldest start. This guarantees
# the assertion below truly validates processing-order precedence — an
# implementation that picked "oldest by start time" would fall back to
# zone2 instead of zone1 and fail the test.
zone1_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone1_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=8),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
zone2_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone2_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=11),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
zone3_event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone3_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone1_event)
bootstrap.process_event(zone2_event)
bootstrap.process_event(zone3_event)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "zone3_event"
)
zone3_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone3_event",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
end=now - timedelta(seconds=4),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone3_ended)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON]
== "zone1_event"
)
assert (
camera_obj.last_smart_detects[SmartDetectObjectType.PERSON] == zone1_event.start
)
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.id == "zone1_event"
assert event.end is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_active_event_survives_bootstrap_events_eviction(
camera_obj: Camera, reset_smart_detect: None
):
"""Tracked event must remain accessible even if evicted from bootstrap.events."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
# Create 3 parallel events on the same camera
zone1 = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
zone2 = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="zone2",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
line1 = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="line1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=8),
type=EventType.SMART_DETECT_LINE,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(zone1)
bootstrap.process_event(zone2)
bootstrap.process_event(line1)
# All 3 active, tracked is line1 (last received)
assert (
camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "line1"
)
assert camera_obj._is_smart_detected(SmartDetectObjectType.PERSON) is True
# Simulate eviction: remove tracked event from bootstrap.events
del bootstrap.events["line1"]
# Event must still be accessible via the per-camera active index
event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
assert event is not None
assert event.id == "line1"
assert event.end is None
assert camera_obj.last_smart_detect_event is not None
assert camera_obj.last_smart_detect_event.id == "line1"
assert camera_obj.is_smart_currently_detected is True
assert camera_obj._is_smart_detected(SmartDetectObjectType.PERSON) is True
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_find_active_cleans_stale_in_place_mutations(
camera_obj: Camera,
reset_smart_detect: None,
):
"""Stale events (end set via in-place mutation) are cleaned from the active index."""
now = utc_now()
bootstrap = camera_obj.api.bootstrap
event_a = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="event_a",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
event_b = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="event_b",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(event_a)
bootstrap.process_event(event_b)
# Both in active index
active = camera_obj._active_smart_detect_events[SmartDetectObjectType.PERSON]
assert len(active) == 2
# Simulate in-place mutation (as update_from_dict does via WS update)
event_a.end = now - timedelta(seconds=5)
# Trigger _find_active_smart_event via an ended event for event_b
event_b_ended = Event( # type: ignore[call-arg]
api=camera_obj.api,
id="event_b",
camera_id=camera_obj.id,
start=now - timedelta(seconds=9),
end=now - timedelta(seconds=3),
type=EventType.SMART_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
)
bootstrap.process_event(event_b_ended)
# event_a was stale (end set in-place) — should be cleaned from active index
assert SmartDetectObjectType.PERSON not in camera_obj._active_smart_detect_events
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_camera_smart_audio_events(camera_obj: Camera):
now = utc_now()
camera_obj.last_smart_audio_detect_event_id = None
camera_obj.last_smart_audio_detect = None
camera_obj.last_smart_audio_detect_event_ids = {}
camera_obj.last_smart_audio_detects = {}
events = [
Event( # type: ignore[call-arg]
api=camera_obj.api,
id="test_event_1",
camera_id=camera_obj.id,
start=now - timedelta(seconds=10),
type=EventType.SMART_AUDIO_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.SMOKE],
smart_detect_event_ids=[],
),
Event( # type: ignore[call-arg]
api=camera_obj.api,
id="test_event_2",
camera_id=camera_obj.id,
start=now - timedelta(seconds=5),
type=EventType.SMART_AUDIO_DETECT,
score=100,
smart_detect_types=[SmartDetectObjectType.CMONX],
smart_detect_event_ids=[],
),
]
for event in events:
camera_obj.api.bootstrap.process_event(event)
assert camera_obj.last_smart_audio_detect == now - timedelta(seconds=5)
assert camera_obj.last_smoke_detect == now - timedelta(seconds=10)
assert camera_obj.last_cmonx_detect == now - timedelta(seconds=5)
assert camera_obj.last_smart_audio_detect_event is not None
assert camera_obj.last_smart_audio_detect_event.id == "test_event_2"
assert camera_obj.last_smoke_detect_event is not None
assert camera_obj.last_smoke_detect_event.id == "test_event_1"
assert camera_obj.last_cmonx_detect_event is not None
assert camera_obj.last_cmonx_detect_event.id == "test_event_2"
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_bootstrap_events_are_bounded(camera_obj: Camera):
"""
Bootstrap.events must evict old entries so the in-memory cache is bounded.
Previously this dict was an unbounded ``FixSizeOrderedDict()`` — every
incoming event accumulated forever. The cap is
``MAX_EVENT_HISTORY_IN_STATE_MACHINE`` and is enforced by
``FixSizeOrderedDict.__setitem__``.
"""
bootstrap = camera_obj.api.bootstrap
bootstrap.events.clear()
now = utc_now()
# Push one more event than the cap — the oldest should be evicted.
overfill = MAX_EVENT_HISTORY_IN_STATE_MACHINE + 10
for i in range(overfill):
event = Event( # type: ignore[call-arg]
api=camera_obj.api,
id=f"bounded_event_{i}",
camera_id=camera_obj.id,
start=now - timedelta(seconds=overfill - i),
type=EventType.MOTION,
score=0,
smart_detect_types=[],
smart_detect_event_ids=[],
)
bootstrap.process_event(event)
assert len(bootstrap.events) == MAX_EVENT_HISTORY_IN_STATE_MACHINE
# Oldest insertions evicted, newest retained.
assert "bounded_event_0" not in bootstrap.events
assert f"bounded_event_{overfill - 1}" in bootstrap.events
def test_bootstrap(bootstrap: dict[str, Any]):
obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap))
set_no_debug()
obj_construct = Bootstrap.from_unifi_dict(**deepcopy(bootstrap))
set_debug()
obj_dict = obj.unifi_dict()
bootstrap.pop("schedules", None)
bootstrap.pop("agreements", None)
bootstrap.pop("deviceGroups", None)
# Remove additional keys from obj_dict
obj_dict.pop("keyrings", None)
obj_dict.pop("ulpUsers", None)
for model_type in ModelType.bootstrap_models:
key = model_type + "s"
expected_data = bootstrap.pop(key)
actual_data = obj_dict.pop(key)
assert len(expected_data) == len(actual_data)
for index, expected in enumerate(expected_data):
actual = actual_data[index]
compare_objs(expected["modelKey"], expected, actual)
compare_objs(ModelType.NVR.value, bootstrap.pop("nvr"), obj_dict.pop("nvr"))
assert bootstrap == obj_dict
assert_equal_dump(obj, obj_construct)
def test_bootstrap_aiports_missing(bootstrap: dict[str, Any]):
deepcopied_bootstrap_1 = deepcopy(bootstrap)
deepcopied_bootstrap_1.pop("aiports", None)
logger = logging.getLogger("uiprotect.data.bootstrap")
with patch.object(logger, "error") as mock_log_error:
obj = Bootstrap.from_unifi_dict(**deepcopied_bootstrap_1)
mock_log_error.assert_called_once_with(
"Missing key in bootstrap: %s. This may be fixed by updating Protect.",
"aiports",
)
assert obj.aiports == {}
def test_doorlock_modelkey_resolves_unknown():
"""An abandoned doorlock modelKey degrades to UNKNOWN instead of raising."""
assert ModelType.from_string("doorlock") is ModelType.UNKNOWN
assert "doorlock" not in ModelType.bootstrap_models_set
def test_unifi_dict_exclude(bootstrap: dict[str, Any]):
obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap))
obj_dict = obj.unifi_dict(exclude=set())
assert "authUserId" in obj_dict
obj_dict = obj.unifi_dict()
assert "authUserId" in obj_dict
obj_dict = obj.unifi_dict(exclude={"auth_user_id"})
assert "authUserId" not in obj_dict
obj_dict = obj.unifi_dict()
assert "authUserId" in obj_dict
obj_dict = obj.unifi_dict(exclude=set())
assert "authUserId" in obj_dict
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_bootstrap_device_not_adopted(bootstrap, protect_client: ProtectApiClient):
bootstrap["cameras"][0]["isAdopted"] = False
obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client)
set_no_debug()
obj_construct = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client)
set_debug()
expected_count = sum(1 if c["isAdopted"] else 0 for c in bootstrap["cameras"])
assert len(obj.cameras) == expected_count
assert_equal_dump(obj.cameras, obj_construct.cameras)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_bootstrap_device_not_adopted_no_api(bootstrap):
bootstrap["cameras"][0]["isAdopted"] = False
obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap))
set_no_debug()
obj_construct = cast("Bootstrap", Bootstrap.from_unifi_dict(**deepcopy(bootstrap)))
set_debug()
assert len(obj.cameras) == len(bootstrap["cameras"])
assert_equal_dump(obj.cameras, obj_construct.cameras)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_bootstrap_device_not_adopted_enabled(
bootstrap: dict[str, Any],
protect_client: ProtectApiClient,
):
bootstrap["cameras"][0]["isAdopted"] = False
protect_client.ignore_unadopted = False
obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client)
set_no_debug()
obj_construct = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client)
set_debug()
assert len(obj.cameras) == len(bootstrap["cameras"])
assert_equal_dump(obj.cameras, obj_construct.cameras)
@pytest.mark.benchmark(group="construct")
@pytest.mark.timeout(0)
def test_bootstrap_benchmark(bootstrap: dict[str, Any], benchmark: BenchmarkFixture):
def create():
Bootstrap.from_unifi_dict(**deepcopy(bootstrap))
benchmark.pedantic(create, rounds=50, iterations=5)
@pytest.mark.benchmark(group="construct")
@pytest.mark.timeout(0)
def test_bootstrap_benchmark_construct(
bootstrap: dict[str, Any],
benchmark: BenchmarkFixture,
):
set_no_debug()
def create():
Bootstrap.from_unifi_dict(**deepcopy(bootstrap))
benchmark.pedantic(create, rounds=50, iterations=5)
set_debug()
def test_fix_order_size_dict_no_max():
d = FixSizeOrderedDict()
d["test"] = 1
d["test2"] = 2
d["test3"] = 3
del d["test2"]
assert d == {"test": 1, "test3": 3}
def test_fix_order_size_dict_max():
d = FixSizeOrderedDict(max_size=1)
d["test"] = 1
d["test2"] = 2
d["test3"] = 3
with pytest.raises(KeyError):
del d["test2"]
assert d == {"test3": 3}
def test_fix_order_size_dict_negative_max():
d = FixSizeOrderedDict(max_size=-1)
d["test"] = 1
d["test2"] = 2
d["test3"] = 3
del d["test2"]
assert d == {"test": 1, "test3": 3}
def test_case_str_enum():
assert RecordingMode("always") == RecordingMode.ALWAYS
assert ResolutionStorageType("4K") == ResolutionStorageType.UHD
assert VideoMode("highFps") == VideoMode.HIGH_FPS
assert RecordingType("roTating") == RecordingType.CONTINUOUS
@pytest.mark.asyncio()
async def test_play_audio_no_speaker(camera_obj: Camera):
camera_obj.feature_flags.has_speaker = False
with pytest.raises(BadRequest):
await camera_obj.play_audio("test")
@pytest.mark.asyncio()
@pytest.mark.usefixtures("_disable_camera_validation")
async def test_play_audio_already_playing(camera_obj: Camera):
camera_obj.feature_flags.has_speaker = True
camera_obj.talkback_stream = Mock()
camera_obj.talkback_stream.is_running = True
with pytest.raises(BadRequest):
await camera_obj.play_audio("test")
@pytest.mark.asyncio()
@pytest.mark.usefixtures("_disable_camera_validation")
@patch("uiprotect.data.devices.TalkbackStream")
async def test_play_audio(mock_talkback, camera_obj: Camera):
camera_obj.feature_flags.has_speaker = True
mock_instance = MockTalkback()
mock_talkback.return_value = mock_instance
# Mock the API key and call for public talkback session
camera_obj._api._api_key = "test-api-key"
camera_obj._api.create_talkback_session_public = AsyncMock(
return_value=Mock(
url="rtp://192.168.1.100:7004",
codec="opus",
sampling_rate=24000,
host="192.168.1.100",
port=7004,
)
)
await camera_obj.play_audio("test")
assert mock_talkback.called
assert mock_instance.start.called
assert mock_instance.run_until_complete.called
@pytest.mark.asyncio()
@pytest.mark.usefixtures("_disable_camera_validation")
@patch("uiprotect.data.devices.TalkbackStream")
async def test_play_audio_ffmpeg_path_deprecated(
mock_talkback, camera_obj: Camera, caplog: pytest.LogCaptureFixture
):
"""Test that ffmpeg_path parameter triggers deprecation warning."""
camera_obj.feature_flags.has_speaker = True
mock_talkback.return_value = MockTalkback()
camera_obj._api._api_key = "test-api-key"
camera_obj._api.create_talkback_session_public = AsyncMock(return_value=Mock())
await camera_obj.play_audio("test", ffmpeg_path=Path("/usr/bin/ffmpeg"))
assert "ffmpeg_path is deprecated" in caplog.text
@pytest.mark.asyncio()
@pytest.mark.usefixtures("_disable_camera_validation")
@patch("uiprotect.data.devices.TalkbackStream")
async def test_play_audio_no_blocking(mock_talkback, camera_obj: Camera):
camera_obj.feature_flags.has_speaker = True
mock_instance = MockTalkback()
mock_talkback.return_value = mock_instance
# Mock the API key and call for public talkback session
camera_obj._api._api_key = "test-api-key"
camera_obj._api.create_talkback_session_public = AsyncMock(
return_value=Mock(
url="rtp://192.168.1.100:7004",
codec="opus",
sampling_rate=24000,
host="192.168.1.100",
port=7004,
)
)
await camera_obj.play_audio("test", blocking=False)
assert mock_talkback.called
assert mock_instance.start.called
assert not mock_instance.run_until_complete.called
await camera_obj.wait_until_audio_completes()
assert mock_instance.run_until_complete.called
@pytest.mark.asyncio()
@pytest.mark.usefixtures("_disable_camera_validation")
@patch("uiprotect.data.devices.TalkbackStream")
async def test_play_audio_stop(mock_talkback, camera_obj: Camera):
camera_obj.feature_flags.has_speaker = True
mock_instance = MockTalkback()
mock_talkback.return_value = mock_instance
# Mock the API key and call for public talkback session
camera_obj._api._api_key = "test-api-key"
camera_obj._api.create_talkback_session_public = AsyncMock(
return_value=Mock(
url="rtp://192.168.1.100:7004",
codec="opus",
sampling_rate=24000,
host="192.168.1.100",
port=7004,
)
)
await camera_obj.play_audio("test", blocking=False)
assert mock_talkback.called
assert mock_instance.start.called
assert not mock_instance.run_until_complete.called
await camera_obj.stop_audio()
assert mock_instance.stop.called
@pytest.mark.asyncio()
@pytest.mark.usefixtures("_disable_camera_validation")
@patch("uiprotect.data.devices.TalkbackStream")
async def test_play_audio_error(mock_talkback, camera_obj: Camera):
camera_obj.feature_flags.has_speaker = True
mock_instance = MockTalkback()
mock_instance.run_until_complete = AsyncMock(
side_effect=StreamError("Audio streaming failed")
)
mock_talkback.return_value = mock_instance
# Mock the API key and call for public talkback session
camera_obj._api._api_key = "test-api-key"
camera_obj._api.create_talkback_session_public = AsyncMock(
return_value=Mock(
url="rtp://192.168.1.100:7004",
codec="opus",
sampling_rate=24000,
host="192.168.1.100",
port=7004,
)
)
with pytest.raises(StreamError):
await camera_obj.play_audio("test")
assert mock_talkback.called
assert mock_instance.run_until_complete.called
@pytest.mark.asyncio()
async def test_get_smart_detect_track_bad_type(smart_dectect_obj: Event | None):
if smart_dectect_obj is None:
pytest.skip("No smart detection object found")
smart_dectect_obj.type = EventType.MOTION
with pytest.raises(BadRequest):
await smart_dectect_obj.get_smart_detect_track()
@pytest.mark.asyncio()
async def test_get_smart_detect_track(smart_dectect_obj: Event | None):
if smart_dectect_obj is None:
pytest.skip("No smart detection object found")
track = await smart_dectect_obj.get_smart_detect_track()
assert track.camera
@pytest.mark.asyncio()
async def test_get_smart_detect_zones(smart_dectect_obj: Event | None):
if smart_dectect_obj is None:
pytest.skip("No smart detection object found")
camera = smart_dectect_obj.camera
if camera is None:
pytest.skip("Camera not found for smart detection")
track = await smart_dectect_obj.get_smart_detect_track()
zone_ids: set[int] = set()
for item in track.payload:
zone_ids |= set(item.zone_ids)
zones = await smart_dectect_obj.get_smart_detect_zones()
for zone_id, zone in zones.items():
assert zone_id in zone_ids
assert zone_id == zone.id
assert zone in camera.smart_detect_zones
def test_doorbell_bad_state():
message = LCDMessage.from_unifi_dict(text="Test")
assert message.text == "Test"
assert message.type == DoorbellMessageType.CUSTOM_MESSAGE
def test_camera_ip_host(camera):
camera["host"] = "1.1.1.1"
camera["connectionHost"] = "1.1.1.1"
camera_obj = Camera.from_unifi_dict(**camera)
assert camera_obj.host == IPv4Address("1.1.1.1")
assert camera_obj.connection_host == IPv4Address("1.1.1.1")
def test_camera_dns_host(camera):
camera["host"] = "se-gw.local"
camera["connectionHost"] = "se-gw.local"
camera_obj = Camera.from_unifi_dict(**camera)
assert camera_obj.host == "se-gw.local"
assert camera_obj.connection_host == "se-gw.local"
def test_bootstrap_ip_host(bootstrap):
bootstrap["nvr"]["hosts"] = ["1.1.1.1"]
bootstrap_obj = Bootstrap.from_unifi_dict(**bootstrap)
assert bootstrap_obj.nvr.hosts == [IPv4Address("1.1.1.1")]
def test_bootstrap_dns_host(bootstrap):
bootstrap["nvr"]["hosts"] = ["se-gw.local"]
bootstrap_obj = Bootstrap.from_unifi_dict(**bootstrap)
assert bootstrap_obj.nvr.hosts == ["se-gw.local"]
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_save_device_no_changes(camera_obj: Camera):
camera_obj.api.api_request.reset_mock() # type: ignore[attr-defined]
data_before_changes = camera_obj.dict_with_excludes()
await camera_obj.save_device(data_before_changes)
assert not camera_obj.api.api_request.called # type: ignore[attr-defined]
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_device_reboot(camera_obj: Camera):
camera_obj.api.api_request.reset_mock() # type: ignore[attr-defined]
await camera_obj.reboot()
camera_obj.api.api_request.assert_called_with( # type: ignore[attr-defined]
f"cameras/{camera_obj.id}/reboot",
method="post",
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
(
"permissions",
"can_create",
"can_read",
"can_write",
"can_delete",
"can_read_media",
"can_delete_media",
),
[
(["camera:*:*"], True, True, True, True, True, True),
(
["camera:create,read,write,delete,readmedia,deletemedia:*"],
True,
True,
True,
True,
True,
True,
),
(
["camera:create,read,write,readmedia:*"],
True,
True,
True,
False,
True,
False,
),
(["camera:read,readmedia:*"], False, True, False, False, True, False),
(
["camera:read,readmedia:test_id_1,test_id_2"],
False,
True,
False,
False,
True,
False,
),
(
["camera:read,readmedia:test_id_2,test_id_1"],
False,
True,
False,
False,
True,
False,
),
(
["camera:delete:test_id_1", "camera:read,readmedia:*"],
False,
True,
False,
True,
True,
False,
),
(
["camera:delete:test_id_2", "camera:read,readmedia:*"],
False,
True,
False,
False,
True,
False,
),
(
["camera:read,readmedia:*", "camera:delete:test_id_1"],
False,
True,
False,
True,
True,
False,
),
(
["camera:read,readmedia:*", "camera:delete:test_id_2"],
False,
True,
False,
False,
True,
False,
),
],
)
@pytest.mark.asyncio()
async def test_permissions(
user_obj: User,
camera_obj: Camera,
permissions: list[str],
can_create: bool,
can_read: bool,
can_write: bool,
can_delete: bool,
can_read_media: bool,
can_delete_media: bool,
):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
api = user_obj.api
user_obj.all_permissions = [
Permission.from_unifi_dict(rawPermission=p, api=api) for p in permissions
]
camera_obj.id = "test_id_1"
api.bootstrap.cameras[camera_obj.id] = camera_obj
assert camera_obj.can_create(user_obj) is can_create
assert camera_obj.can_read(user_obj) is can_read
assert camera_obj.can_write(user_obj) is can_write
assert camera_obj.can_delete(user_obj) is can_delete
assert camera_obj.can_read_media(user_obj) is can_read_media
assert camera_obj.can_delete_media(user_obj) is can_delete_media
@pytest.mark.parametrize(
("permissions", "can_create", "can_read", "can_write", "can_delete"),
[
(["user:*:*"], True, True, True, True),
(["user:create,read,write,delete:*"], True, True, True, True),
(["user:create,read,write,delete:$"], True, True, True, True),
(
["user:read,write:$", "user:create,read,write,delete:*"],
True,
True,
True,
True,
),
(["user:read,write:*"], False, True, True, False),
(["user:read,write:$"], False, True, True, False),
(
["user:read,write:$", "user:create,read,write,delete:test_id_2"],
False,
True,
True,
False,
),
(["user:create,delete:$", "user:read,write:*"], True, True, True, True),
],
)
@pytest.mark.asyncio()
async def test_permissions_user(
user_obj: User,
permissions: list[str],
can_create: bool,
can_read: bool,
can_write: bool,
can_delete: bool,
):
api = user_obj.api
user1 = user_obj.model_copy()
user1.id = "test_id_1"
user1.all_permissions = [
Permission.from_unifi_dict(rawPermission=p, api=api) for p in permissions
]
api.bootstrap.auth_user_id = user1.id
api.bootstrap.users = {user1.id: user1}
assert user1.can_create(user1) is can_create
assert user1.can_read(user1) is can_read
assert user1.can_write(user1) is can_write
assert user1.can_delete(user1) is can_delete
@pytest.mark.parametrize(
("permissions", "can_create", "can_read", "can_write", "can_delete"),
[
(["user:*:*"], True, True, True, True),
(["user:create,read,write,delete:*"], True, True, True, True),
(["user:create,read,write,delete:$"], False, False, False, False),
(
["user:read,write:$", "user:create,read,write,delete:*"],
True,
True,
True,
True,
),
(["user:read,write:*"], False, True, True, False),
(["user:read,write:$"], False, False, False, False),
(
["user:read,write:$", "user:create,read,write,delete:test_id_2"],
True,
True,
True,
True,
),
(["user:create,delete:$", "user:read,write:*"], False, True, True, False),
],
)
@pytest.mark.asyncio()
async def test_permissions_self_with_other(
user_obj: User,
permissions: list[str],
can_create: bool,
can_read: bool,
can_write: bool,
can_delete: bool,
):
api = user_obj.api
user1 = user_obj.model_copy()
user1.id = "test_id_1"
user1.all_permissions = [
Permission.from_unifi_dict(rawPermission=p, api=api) for p in permissions
]
user2 = user_obj.model_copy()
user2.id = "test_id_2"
api.bootstrap.auth_user_id = user1.id
api.bootstrap.users = {user1.id: user1, user2.id: user2}
assert user2.can_create(user1) is can_create
assert user2.can_read(user1) is can_read
assert user2.can_write(user1) is can_write
assert user2.can_delete(user1) is can_delete
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_revert(user_obj: User, camera_obj: Camera):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
api = user_obj.api
camera_obj.id = "test_id_1"
camera_obj.add_privacy_zone()
camera_obj.recording_settings.mode = RecordingMode.NEVER
api.bootstrap.cameras[camera_obj.id] = camera_obj
user_obj.all_permissions = [
Permission.from_unifi_dict(rawPermission="camera:read:*", api=api),
]
camera_before = camera_obj.model_dump()
camera_obj.remove_privacy_zone()
camera_obj.recording_settings.mode = RecordingMode.ALWAYS
with pytest.raises(NotAuthorized):
await camera_obj.save_device(camera_before)
assert camera_before == camera_obj.model_dump()
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_multiple_updates(user_obj: User, camera_obj: Camera):
if camera_obj is None:
pytest.skip("No camera_obj obj found")
api = user_obj.api
camera_obj.id = "test_id_1"
camera_obj.recording_settings.enable_motion_detection = False
camera_obj.recording_settings.mode = RecordingMode.NEVER
camera_obj.smart_detect_settings.object_types = []
camera_obj.feature_flags.has_smart_detect = True
camera_obj.use_global = False
api.bootstrap.cameras[camera_obj.id] = camera_obj
await asyncio.gather(
camera_obj.set_motion_detection(True),
camera_obj.set_recording_mode(RecordingMode.ALWAYS),
camera_obj.set_smart_detect_types(
[
SmartDetectObjectType.FACE,
SmartDetectObjectType.PERSON,
SmartDetectObjectType.VEHICLE,
]
),
)
camera_obj.api.api_request.assert_called_with( # type: ignore[attr-defined]
f"cameras/{camera_obj.id}",
method="patch",
json={
"recordingSettings": {
"enableMotionDetection": True,
"mode": RecordingMode.ALWAYS.value,
},
"smartDetectSettings": {"objectTypes": ["face", "person", "vehicle"]},
},
)
@pytest.mark.asyncio()
async def test_user_becomes_cloud_account_and_then_removed(user_obj: User):
assert not user_obj.cloud_account
model = user_obj._get_protect_model()
assert "cloud_account" in model.objs
user_obj.update_from_dict(
{
"id": "test_id_1",
"name": "Test",
"cloud_account": {
"first_name": "Qpvfly",
"last_name": "Ikjzilt",
"email": "QhoFvCv@example.com",
"profile_img": None,
"user_id": "fe4c12ae2c1348edb7854e2f",
"id": "9efc4511-4539-4402-9581-51cee8b65cf5",
"cloud_id": "9efc4511-4539-4402-9581-51cee8b65cf5",
"name": "Qpvfly Ikjzilt",
"model_key": "cloudIdentity",
},
}
)
assert user_obj.name == "Test"
assert user_obj.cloud_account
assert isinstance(user_obj.cloud_account, CloudAccount)
assert user_obj.cloud_account.first_name == "Qpvfly"
user_obj.update_from_dict(
{
"id": "test_id_1",
"name": "Test",
"cloud_account": None,
}
)
assert user_obj.name == "Test"
assert user_obj.cloud_account is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_smart_detect_settings_becomes_none(camera_obj: Camera):
camera_obj.smart_detect_settings.update_from_dict(
{
"audio_types": None,
}
)
assert camera_obj.smart_detect_settings.audio_types is None
camera_obj.smart_detect_settings.update_from_dict(
{
"audio_types": ["alrmSmoke"],
}
)
assert camera_obj.smart_detect_settings.audio_types == [SmartDetectAudioType.SMOKE]
camera_obj.smart_detect_settings.update_from_dict(
{
"audio_types": None,
}
)
assert camera_obj.smart_detect_settings.audio_types is None
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_unknown_smart(
camera: dict[str, Any] | None,
bootstrap: dict[str, Any],
protect_client: ProtectApiClient,
):
if camera is None:
pytest.skip("No camera obj found")
camera["featureFlags"]["smartDetectTypes"] = ["alrmSmoke3"]
camera["smartDetectZones"][0]["objectTypes"] = ["alrmSmoke3"]
camera["smartDetectSettings"]["objectTypes"] = ["alrmSmoke3"]
bootstrap["cameras"] = [camera]
obj: Bootstrap = Bootstrap.from_unifi_dict(
**deepcopy(bootstrap),
api=protect_client,
)
camera_obj = next(iter(obj.cameras.values()))
assert camera_obj.feature_flags.smart_detect_types == []
assert camera_obj.smart_detect_zones[0].object_types == []
assert camera_obj.smart_detect_settings.object_types == []
set_no_debug()
obj: Bootstrap = Bootstrap.from_unifi_dict(
**deepcopy(bootstrap),
api=protect_client,
)
camera_obj = next(iter(obj.cameras.values()))
assert camera_obj.feature_flags.smart_detect_types == []
assert camera_obj.smart_detect_zones[0].object_types == []
assert camera_obj.smart_detect_settings.object_types == []
set_debug()
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_unknown_video(
camera: dict[str, Any] | None,
bootstrap: dict[str, Any],
protect_client: ProtectApiClient,
):
if camera is None:
pytest.skip("No camera obj found")
camera["featureFlags"]["videoModes"] = ["stuff"]
bootstrap["cameras"] = [camera]
obj: Bootstrap = Bootstrap.from_unifi_dict(
**deepcopy(bootstrap),
api=protect_client,
)
camera_obj = next(iter(obj.cameras.values()))
assert camera_obj.feature_flags.video_modes == []
set_no_debug()
obj: Bootstrap = Bootstrap.from_unifi_dict(
**deepcopy(bootstrap),
api=protect_client,
)
camera_obj = next(iter(obj.cameras.values()))
assert camera_obj.feature_flags.video_modes == []
set_debug()
def test_unknown_storage_type(
bootstrap: dict[str, Any],
protect_client: ProtectApiClient,
):
bootstrap["nvr"]["systemInfo"]["storage"]["type"] = "test"
obj: Bootstrap = Bootstrap.from_unifi_dict(
**deepcopy(bootstrap),
api=protect_client,
)
assert obj.nvr.system_info.storage.type == StorageType.UNKNOWN
set_no_debug()
obj: Bootstrap = Bootstrap.from_unifi_dict(
**deepcopy(bootstrap),
api=protect_client,
)
assert obj.nvr.system_info.storage.type == StorageType.UNKNOWN
set_debug()
def test_protect7_duplicate_snake_and_camel_case_keys():
"""
Protect 7.x firmware sends both camelCase and snake_case variants of the
same key in event metadata (e.g. ``hallwayMode`` *and* ``hallway_mode``).
``unifi_dict_to_dict`` iterates over a snapshot of the keys and pops each
one after converting it to snake_case. When two keys map to the same
snake_case result the second pop would raise a ``KeyError``.
Regression test for https://github.com/home-assistant/core/issues/164109
"""
# Real payload fragment from a Protect 7.x controller websocket message.
# Both "hallwayMode" (camelCase) and "hallway_mode" (already snake_case)
# are present in the same dict.
data: dict[str, Any] = {
"sensorId": "abc123",
"hallwayMode": None,
"hallway_mode": "disabled",
"type": "motion",
}
# Must not raise KeyError
result = EventMetadata.unifi_dict_to_dict(data)
assert result["sensor_id"] == "abc123"
assert result["type"] == "motion"
def test_event_metadata_collapse_keys_text_format():
"""Collapse keys with standard {"text": "value"} format are correctly unwrapped."""
data: dict[str, Any] = {
"sensorType": {"text": "temperature"},
"mountType": {"text": "door"},
"status": {"text": "safe"},
}
result = EventMetadata.unifi_dict_to_dict(data)
assert result["sensor_type"] == "temperature"
assert result["mount_type"] == "door"
assert result["status"] == "safe"
def test_event_metadata_collapse_keys_unexpected_format():
"""Collapse keys with unexpected dict format (no 'text' key) are dropped gracefully."""
data: dict[str, Any] = {
"sensorType": {"newFormat": "temperature", "extra": 123},
"sensorId": "abc123",
}
result = EventMetadata.unifi_dict_to_dict(data)
# Malformed key should be dropped
assert "sensor_type" not in result
# Other keys should still be processed
assert result["sensor_id"] == "abc123"
def test_event_metadata_collapse_keys_non_dict_passthrough():
"""Collapse keys that are plain strings (not dicts) pass through unchanged."""
data: dict[str, Any] = {
"sensorType": "temperature",
"type": "motion",
}
result = EventMetadata.unifi_dict_to_dict(data)
assert result["sensor_type"] == "temperature"
assert result["type"] == "motion"
def test_handle_ws_error_event_model(
protect_client: ProtectApiClient,
):
"""_handle_ws_error logs event errors without triggering a device refresh."""
bootstrap = protect_client.bootstrap
action = {"action": "update", "id": "event123", "modelKey": "event"}
err = ValueError("bad event data")
# Should not raise, should not create refresh tasks
bootstrap._handle_ws_error("update", ModelType.EVENT, action, err)
assert len(bootstrap._refresh_tasks) == 0
@pytest.mark.asyncio()
async def test_handle_ws_error_device_with_id(
protect_client: ProtectApiClient,
):
"""_handle_ws_error triggers a device refresh when device_id is present."""
bootstrap = protect_client.bootstrap
action = {"action": "update", "id": "some_device_id", "modelKey": "camera"}
err = ValueError("bad device data")
bootstrap._handle_ws_error("update", ModelType.CAMERA, action, err)
# A refresh task should have been created. The done-callback discards the
# task from the set once it completes, so check for at least one pending task
# rather than an exact count to avoid flakiness on fast event loops.
assert len(bootstrap._refresh_tasks) >= 1
assert any(not t.done() for t in bootstrap._refresh_tasks)
def test_handle_ws_error_no_device_id(
protect_client: ProtectApiClient,
):
"""_handle_ws_error handles missing device ID gracefully without crashing."""
bootstrap = protect_client.bootstrap
action = {"action": "update", "modelKey": "camera"} # no "id" key
err = KeyError("id")
# Must not raise — this was a bug before the fix
bootstrap._handle_ws_error("update", ModelType.CAMERA, action, err)
assert len(bootstrap._refresh_tasks) == 0
uilibs-uiprotect-317a52c/tests/data/test_convert.py 0000664 0000000 0000000 00000010067 15246377057 0022534 0 ustar 00root root 0000000 0000000 """Tests for uiprotect.data.convert - 100% coverage."""
from __future__ import annotations
from copy import deepcopy
from typing import TYPE_CHECKING, Any
import pytest
from tests.conftest import TEST_CAMERA_EXISTS
from uiprotect.data.convert import (
create_from_unifi_dict,
get_klass_from_dict,
list_from_unifi_list,
)
from uiprotect.data.devices import Camera
from uiprotect.data.types import ModelType
from uiprotect.exceptions import DataDecodeError
if TYPE_CHECKING:
from uiprotect import ProtectApiClient
_skip_no_camera = pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
class TestGetKlassFromDict:
"""Tests for get_klass_from_dict."""
def test_no_model_key_raises(self) -> None:
with pytest.raises(DataDecodeError, match="No modelKey"):
get_klass_from_dict({})
def test_unknown_model_key_raises(self) -> None:
with pytest.raises(DataDecodeError, match="Unknown modelKey"):
get_klass_from_dict({"modelKey": "totallyFakeModel"})
def test_known_model_key_returns_class(self) -> None:
assert get_klass_from_dict({"modelKey": "camera"}) is Camera
class TestCreateFromUnifiDict:
"""Tests for create_from_unifi_dict - covers the Protect 7 modelKey injection."""
def test_no_model_key_no_model_type_raises(self) -> None:
"""No modelKey in data and no model_type provided -> DataDecodeError."""
with pytest.raises(DataDecodeError, match="No modelKey"):
create_from_unifi_dict({"id": "test123"})
@_skip_no_camera
def test_model_type_with_missing_model_key(self, camera: dict[str, Any]) -> None:
"""Protect 7+: modelKey missing from data, model_type provided -> inject modelKey and succeed."""
data = deepcopy(camera)
data.pop("modelKey", None)
obj = create_from_unifi_dict(data, model_type=ModelType.CAMERA)
assert isinstance(obj, Camera)
assert data["modelKey"] == "camera"
@_skip_no_camera
def test_model_type_preserves_existing_model_key(
self, camera: dict[str, Any]
) -> None:
"""Protect 6: modelKey already present -> don't overwrite, resolve klass from model_type."""
data = deepcopy(camera)
obj = create_from_unifi_dict(data, model_type=ModelType.CAMERA)
assert isinstance(obj, Camera)
assert data["modelKey"] == "camera"
@_skip_no_camera
def test_explicit_klass_skips_resolution(self, camera: dict[str, Any]) -> None:
"""When klass is explicitly provided, model_type klass resolution is skipped."""
data = deepcopy(camera)
obj = create_from_unifi_dict(data, klass=Camera)
assert isinstance(obj, Camera)
@_skip_no_camera
def test_model_type_with_explicit_klass(self, camera: dict[str, Any]) -> None:
"""model_type + klass both provided -> injects modelKey and succeeds."""
data = deepcopy(camera)
data.pop("modelKey", None)
obj = create_from_unifi_dict(data, klass=Camera, model_type=ModelType.CAMERA)
assert isinstance(obj, Camera)
assert data["modelKey"] == "camera"
def test_unsupported_model_type_raises(self) -> None:
"""model_type not in MODEL_TO_CLASS -> DataDecodeError with 'Unknown modelKey'."""
with pytest.raises(DataDecodeError, match="Unknown modelKey"):
create_from_unifi_dict({"id": "test123"}, model_type=ModelType.SCHEDULE)
@_skip_no_camera
def test_fallback_to_get_klass_from_dict(self, camera: dict[str, Any]) -> None:
"""No model_type, no klass -> fall back to get_klass_from_dict."""
obj = create_from_unifi_dict(deepcopy(camera))
assert isinstance(obj, Camera)
class TestListFromUnifiList:
"""Tests for list_from_unifi_list."""
@_skip_no_camera
def test_converts_list(
self, protect_client: ProtectApiClient, camera: dict[str, Any]
) -> None:
result = list_from_unifi_list(
protect_client, [deepcopy(camera), deepcopy(camera)]
)
assert len(result) == 2
assert all(isinstance(obj, Camera) for obj in result)
uilibs-uiprotect-317a52c/tests/data/test_event_detected_thumbnail.py 0000664 0000000 0000000 00000020462 15246377057 0026101 0 ustar 00root root 0000000 0000000 # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, arg-type"
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uiprotect.data.nvr import (
Event,
EventDetectedThumbnail,
EventMetadata,
EventThumbnailGroup,
)
from uiprotect.data.types import SmartDetectObjectType
if TYPE_CHECKING:
from uiprotect import ProtectApiClient
def test_get_detected_thumbnail_with_clock_best_wall():
"""Test that get_detected_thumbnail returns the thumbnail with clockBestWall."""
event = Event.model_construct(
id="test_event",
type="smartDetect",
smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE],
metadata=EventMetadata.model_construct(
detected_thumbnails=[
EventDetectedThumbnail.model_construct(
type="vehicle",
cropped_id="thumb_1",
clock_best_wall=None,
group=EventThumbnailGroup.model_construct(
id="group_1",
matched_name="ABC123",
confidence=75,
),
),
EventDetectedThumbnail.model_construct(
type="vehicle",
cropped_id="thumb_2",
clock_best_wall=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC),
group=EventThumbnailGroup.model_construct(
id="group_2",
matched_name="XYZ789",
confidence=92,
),
),
EventDetectedThumbnail.model_construct(
type="vehicle",
cropped_id="thumb_3",
clock_best_wall=None,
group=EventThumbnailGroup.model_construct(
id="group_3",
matched_name="DEF456",
confidence=85,
),
),
]
),
)
thumbnail = event.get_detected_thumbnail()
assert thumbnail is not None
assert thumbnail.cropped_id == "thumb_2"
assert thumbnail.group is not None
assert thumbnail.group.matched_name == "XYZ789"
def test_get_detected_thumbnail_no_metadata():
"""Test that get_detected_thumbnail returns None when no metadata."""
event = Event.model_construct(
id="test_event",
type="smartDetect",
smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE],
metadata=None,
)
assert event.get_detected_thumbnail() is None
def test_get_detected_thumbnail_no_detected_thumbnails():
"""Test that get_detected_thumbnail returns None when no detected thumbnails."""
event = Event.model_construct(
id="test_event",
type="smartDetect",
smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE],
metadata=EventMetadata.model_construct(
detected_thumbnails=None,
),
)
assert event.get_detected_thumbnail() is None
def test_get_detected_thumbnail_empty_list():
"""Test that get_detected_thumbnail returns None when detected thumbnails is empty."""
event = Event.model_construct(
id="test_event",
type="smartDetect",
smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE],
metadata=EventMetadata.model_construct(
detected_thumbnails=[],
),
)
assert event.get_detected_thumbnail() is None
def test_get_detected_thumbnail_no_clock_best_wall():
"""Test that get_detected_thumbnail returns None when no thumbnail has clockBestWall."""
event = Event.model_construct(
id="test_event",
type="smartDetect",
smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE],
metadata=EventMetadata.model_construct(
detected_thumbnails=[
EventDetectedThumbnail.model_construct(
type="vehicle",
cropped_id="thumb_1",
clock_best_wall=None,
),
EventDetectedThumbnail.model_construct(
type="vehicle",
cropped_id="thumb_2",
clock_best_wall=None,
),
]
),
)
assert event.get_detected_thumbnail() is None
def test_get_detected_thumbnail_first_with_clock_best_wall():
"""Test that get_detected_thumbnail returns first thumbnail with clockBestWall."""
event = Event.model_construct(
id="test_event",
type="smartDetect",
smart_detect_types=[SmartDetectObjectType.FACE],
metadata=EventMetadata.model_construct(
detected_thumbnails=[
EventDetectedThumbnail.model_construct(
type="face",
cropped_id="thumb_1",
clock_best_wall=None,
),
EventDetectedThumbnail.model_construct(
type="face",
cropped_id="thumb_2",
clock_best_wall=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC),
group=EventThumbnailGroup.model_construct(
id="group_1",
matched_name="Alice",
confidence=90,
),
),
EventDetectedThumbnail.model_construct(
type="face",
cropped_id="thumb_3",
clock_best_wall=datetime(2024, 1, 1, 12, 1, 0, tzinfo=UTC),
group=EventThumbnailGroup.model_construct(
id="group_2",
matched_name="Bob",
confidence=95,
),
),
]
),
)
thumbnail = event.get_detected_thumbnail()
assert thumbnail is not None
assert thumbnail.cropped_id == "thumb_2"
assert thumbnail.group is not None
assert thumbnail.group.matched_name == "Alice"
def test_get_detected_thumbnail_from_real_data(
raw_events: list[dict], protect_client: ProtectApiClient
):
"""Test get_detected_thumbnail with real event data."""
events_with_thumbs = [
e for e in raw_events if e.get("metadata", {}).get("detectedThumbnails")
]
if not events_with_thumbs:
return # Skip if no events with thumbnails
# Parse first event with thumbnails
event = Event.from_unifi_dict(**events_with_thumbs[0], api=protect_client)
# Should return the thumbnail with clockBestWall
thumbnail = event.get_detected_thumbnail()
assert thumbnail is not None
assert thumbnail.clock_best_wall is not None
def test_event_thumbnail_attributes_get_value(
raw_events: list[dict], protect_client: ProtectApiClient
):
"""Test EventThumbnailAttributes.get_value() helper method."""
events_with_attrs = [
e
for e in raw_events
if any(
t.get("attributes")
for t in e.get("metadata", {}).get("detectedThumbnails", [])
)
]
if not events_with_attrs:
return # Skip if no events with attributes
event = Event.from_unifi_dict(**events_with_attrs[0], api=protect_client)
thumbnail = event.get_detected_thumbnail()
assert thumbnail is not None
assert thumbnail.attributes is not None
# Test get_value with EventThumbnailAttribute objects
# Check if color exists (common for LPR events)
if hasattr(thumbnail.attributes, "color"):
color = thumbnail.attributes.get_value("color")
assert color is not None
assert isinstance(color, str)
# Check if vehicleType exists (common for LPR events)
if hasattr(thumbnail.attributes, "vehicleType"):
vehicle_type = thumbnail.attributes.get_value("vehicleType")
assert vehicle_type is not None
assert isinstance(vehicle_type, str)
# Test get_value with non-EventThumbnailAttribute fields
# zone is list[int], not EventThumbnailAttribute
if hasattr(thumbnail.attributes, "zone"):
zone_value = thumbnail.attributes.get_value("zone")
assert (
zone_value is None
) # Should be None because it's not EventThumbnailAttribute
# Test get_value with non-existent field
nonexistent = thumbnail.attributes.get_value("nonexistent_field_12345")
assert nonexistent is None
uilibs-uiprotect-317a52c/tests/data/test_light.py 0000664 0000000 0000000 00000037074 15246377057 0022172 0 ustar 00root root 0000000 0000000 # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr"
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
import pytest
from pydantic import ValidationError
from tests.conftest import TEST_CAMERA_EXISTS, TEST_LIGHT_EXISTS
from uiprotect.data.public_devices import (
PublicLight,
PublicLightDeviceSettings,
PublicLightModeSettings,
)
from uiprotect.data.types import LightModeEnableType, LightModeType
from uiprotect.exceptions import BadRequest
from uiprotect.utils import to_ms
if TYPE_CHECKING:
from uiprotect.data import Camera, Light
def _public_light_response(
*,
is_light_force_enabled: bool = False,
light_device_settings: PublicLightDeviceSettings | None = None,
light_mode_settings: PublicLightModeSettings | None = None,
name: str | None = None,
) -> PublicLight:
"""Build a minimal ``PublicLight`` for mocking ``update_light_public`` returns."""
return PublicLight.model_construct(
is_light_force_enabled=is_light_force_enabled,
light_device_settings=light_device_settings,
light_mode_settings=light_mode_settings,
name=name,
)
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_light_set_paired_camera_none(light_obj: Light):
light_obj.api.api_request.reset_mock()
light_obj.camera_id = "bad_id"
await light_obj.set_paired_camera(None)
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json={"camera": None},
)
@pytest.mark.skipif(
not TEST_LIGHT_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_light_set_paired_camera(light_obj: Light, camera_obj: Camera):
light_obj.api.api_request.reset_mock()
light_obj.camera_id = None
await light_obj.set_paired_camera(camera_obj)
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json={"camera": camera_obj.id},
)
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_light_set_status_light(light_obj: Light, status: bool) -> None:
light_obj.api.api_request.reset_mock()
light_obj.light_device_settings.is_indicator_enabled = not status
await light_obj.set_status_light(status)
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json={"lightDeviceSettings": {"isIndicatorEnabled": status}},
)
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_light_set_name_public(light_obj: Light) -> None:
light_obj.api.update_light_public = AsyncMock(
return_value=_public_light_response(name="Renamed"),
)
await light_obj.set_name_public("Renamed")
light_obj.api.update_light_public.assert_called_once_with(
light_obj.id,
name="Renamed",
)
assert light_obj.name == "Renamed"
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_light_set_flood_light_public(light_obj: Light, status: bool) -> None:
light_obj.api.update_light_public = AsyncMock(
return_value=_public_light_response(is_light_force_enabled=status),
)
await light_obj.set_flood_light_public(status)
light_obj.api.update_light_public.assert_called_once_with(
light_obj.id,
is_light_force_enabled=status,
)
assert light_obj.light_on_settings.is_led_force_on is status
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_light_set_status_light_public(light_obj: Light, status: bool) -> None:
light_obj.api.update_light_public = AsyncMock(
return_value=_public_light_response(
light_device_settings=PublicLightDeviceSettings(
is_indicator_enabled=status
),
),
)
await light_obj.set_status_light_public(status)
call = light_obj.api.update_light_public.call_args
assert call.args == (light_obj.id,)
sent = call.kwargs["light_device_settings"]
assert sent.is_indicator_enabled is status
assert light_obj.light_device_settings.is_indicator_enabled is status
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [-1, 0, 1, 6, 7])
@pytest.mark.asyncio()
async def test_light_set_led_level_public(light_obj: Light, level: int) -> None:
light_obj.api.update_light_public = AsyncMock()
if level in {-1, 0, 7}:
with pytest.raises(BadRequest, match="led_level must be between 1 and 6"):
await light_obj.set_led_level_public(level)
assert not light_obj.api.update_light_public.called
return
light_obj.api.update_light_public.return_value = _public_light_response(
light_device_settings=PublicLightDeviceSettings(
is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled,
led_level=level,
),
)
await light_obj.set_led_level_public(level)
sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"]
assert sent.led_level == level
assert light_obj.light_device_settings.led_level == level
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_light_set_led_level_public_coerces_float(light_obj: Light) -> None:
light_obj.api.update_light_public = AsyncMock()
light_obj.api.update_light_public.return_value = _public_light_response(
light_device_settings=PublicLightDeviceSettings(
is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled,
led_level=3,
),
)
await light_obj.set_led_level_public(3.0)
sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"]
assert type(sent.led_level) is int
assert sent.led_level == 3
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [float("nan"), float("inf"), float("-inf")])
@pytest.mark.asyncio()
async def test_light_set_led_level_public_rejects_non_finite(
light_obj: Light, level: float
) -> None:
light_obj.api.update_light_public = AsyncMock()
with pytest.raises(BadRequest, match="led_level must be a finite number"):
await light_obj.set_led_level_public(level)
assert not light_obj.api.update_light_public.called
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_light_set_light_public_no_level(light_obj: Light, status: bool) -> None:
light_obj.api.update_light_public = AsyncMock(
return_value=_public_light_response(is_light_force_enabled=status),
)
await light_obj.set_light_public(status)
light_obj.api.update_light_public.assert_called_once_with(
light_obj.id,
is_light_force_enabled=status,
)
assert light_obj.light_on_settings.is_led_force_on is status
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_light_set_light_public_with_level(
light_obj: Light, status: bool
) -> None:
light_obj.api.update_light_public = AsyncMock(
return_value=_public_light_response(
is_light_force_enabled=status,
light_device_settings=PublicLightDeviceSettings(led_level=5),
),
)
await light_obj.set_light_public(status, 5)
call = light_obj.api.update_light_public.call_args
assert call.args == (light_obj.id,)
assert call.kwargs["is_light_force_enabled"] is status
assert call.kwargs["light_device_settings"].led_level == 5
assert light_obj.light_on_settings.is_led_force_on is status
assert light_obj.light_device_settings.led_level == 5
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("level", [-1, 0, 7])
@pytest.mark.asyncio()
async def test_light_set_light_public_bad_level(light_obj: Light, level: int) -> None:
light_obj.api.update_light_public = AsyncMock()
with pytest.raises(BadRequest, match="led_level must be between 1 and 6"):
await light_obj.set_light_public(True, level)
assert not light_obj.api.update_light_public.called
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_light_set_light_public_coerces_float(light_obj: Light) -> None:
light_obj.api.update_light_public = AsyncMock(
return_value=_public_light_response(
is_light_force_enabled=True,
light_device_settings=PublicLightDeviceSettings(led_level=3),
),
)
await light_obj.set_light_public(True, 3.0)
sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"]
assert type(sent.led_level) is int
assert sent.led_level == 3
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("sensitivity", [1, 100, -10])
@pytest.mark.asyncio()
async def test_light_set_sensitivity_public(light_obj: Light, sensitivity: int) -> None:
light_obj.api.update_light_public = AsyncMock()
if sensitivity == -10:
with pytest.raises(ValidationError):
await light_obj.set_sensitivity_public(sensitivity)
assert not light_obj.api.update_light_public.called
return
light_obj.api.update_light_public.return_value = _public_light_response(
light_device_settings=PublicLightDeviceSettings(
is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled,
pir_sensitivity=sensitivity,
),
)
await light_obj.set_sensitivity_public(sensitivity)
sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"]
assert sent.pir_sensitivity == sensitivity
assert light_obj.light_device_settings.pir_sensitivity == sensitivity
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
"duration",
[
timedelta(seconds=1),
timedelta(seconds=15),
timedelta(seconds=900),
timedelta(seconds=1000),
],
)
@pytest.mark.asyncio()
async def test_light_set_duration_public(light_obj: Light, duration: timedelta) -> None:
light_obj.api.update_light_public = AsyncMock()
if int(duration.total_seconds()) in {1, 1000}:
with pytest.raises(BadRequest):
await light_obj.set_duration_public(duration)
assert not light_obj.api.update_light_public.called
return
light_obj.api.update_light_public.return_value = _public_light_response(
light_device_settings=PublicLightDeviceSettings(
is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled,
pir_duration=to_ms(duration),
),
)
await light_obj.set_duration_public(duration)
sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"]
assert sent.pir_duration == duration
assert light_obj.light_device_settings.pir_duration == duration
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("mode", [LightModeType.MANUAL, LightModeType.WHEN_DARK])
@pytest.mark.parametrize("enable_at", [None, LightModeEnableType.ALWAYS])
@pytest.mark.asyncio()
async def test_light_set_light_mode_public(
light_obj: Light,
mode: LightModeType,
enable_at: LightModeEnableType | None,
) -> None:
response_enable_at = (
enable_at if enable_at is not None else light_obj.light_mode_settings.enable_at
)
light_obj.api.update_light_public = AsyncMock(
return_value=_public_light_response(
light_mode_settings=PublicLightModeSettings(
mode=mode, enable_at=response_enable_at
),
),
)
original_enable_at = light_obj.light_mode_settings.enable_at
await light_obj.set_light_mode_public(mode, enable_at=enable_at)
sent = light_obj.api.update_light_public.call_args.kwargs["light_mode_settings"]
assert sent.mode == mode
if enable_at is not None:
assert sent.enable_at == enable_at
else:
assert sent.enable_at == original_enable_at
assert light_obj.light_mode_settings.mode == mode
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
"duration",
[
None,
timedelta(seconds=1),
timedelta(seconds=15),
timedelta(seconds=60),
timedelta(seconds=900),
timedelta(seconds=1000),
],
)
@pytest.mark.parametrize("sensitivity", [None, 1, 100, -10])
@pytest.mark.parametrize("enable_at", [None, LightModeEnableType.ALWAYS])
@pytest.mark.asyncio()
async def test_light_set_light_settings_public(
light_obj: Light,
duration: timedelta | None,
sensitivity: int | None,
enable_at: LightModeEnableType | None,
) -> None:
light_obj.api.update_light_public = AsyncMock()
duration_invalid = duration is not None and int(duration.total_seconds()) in {
1,
1000,
}
if duration_invalid:
with pytest.raises(BadRequest):
await light_obj.set_light_settings_public(
LightModeType.MOTION,
enable_at=enable_at,
duration=duration,
sensitivity=sensitivity,
)
assert not light_obj.api.update_light_public.called
return
if sensitivity == -10:
with pytest.raises(ValidationError):
await light_obj.set_light_settings_public(
LightModeType.MOTION,
enable_at=enable_at,
duration=duration,
sensitivity=sensitivity,
)
assert not light_obj.api.update_light_public.called
return
response_enable_at = (
enable_at if enable_at is not None else light_obj.light_mode_settings.enable_at
)
updated_mode = PublicLightModeSettings(
mode=LightModeType.MOTION, enable_at=response_enable_at
)
has_device_update = duration is not None or sensitivity is not None
updated_device = (
PublicLightDeviceSettings(
is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled,
pir_duration=to_ms(duration) if duration is not None else None,
pir_sensitivity=sensitivity,
)
if has_device_update
else None
)
light_obj.api.update_light_public.return_value = _public_light_response(
light_mode_settings=updated_mode,
light_device_settings=updated_device,
)
await light_obj.set_light_settings_public(
LightModeType.MOTION,
enable_at=enable_at,
duration=duration,
sensitivity=sensitivity,
)
kwargs = light_obj.api.update_light_public.call_args.kwargs
assert kwargs["light_mode_settings"].mode == LightModeType.MOTION
if enable_at is not None:
assert kwargs["light_mode_settings"].enable_at == enable_at
if has_device_update:
assert kwargs["light_device_settings"] is not None
if duration is not None:
assert kwargs["light_device_settings"].pir_duration == duration
if sensitivity is not None:
assert kwargs["light_device_settings"].pir_sensitivity == sensitivity
else:
assert kwargs["light_device_settings"] is None
uilibs-uiprotect-317a52c/tests/data/test_nvr.py 0000664 0000000 0000000 00000024601 15246377057 0021660 0 ustar 00root root 0000000 0000000 # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, arg-type, list-item"
from __future__ import annotations
from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address
from unittest.mock import Mock
import pytest
from pydantic import ValidationError
from uiprotect.data import (
NVR,
AnalyticsOption,
DoorbellMessage,
DoorbellMessageType,
Event,
EventType,
RecordingMode,
)
from uiprotect.data.nvr import NVRSmartDetection, StorageDevice
from uiprotect.data.types import SmartDetectObjectType
from uiprotect.exceptions import BadRequest
from uiprotect.utils import to_ms
@pytest.mark.parametrize(
("data", "expected"),
[
({}, {"model": None, "size": None, "healthy": None}), # empty slot
(
{"model": "ST4000VN008", "size": 4000787030016, "healthy": True},
{"model": "ST4000VN008", "size": 4000787030016, "healthy": True},
),
({"healthy": "OK"}, {"healthy": "OK"}), # string healthy value
({"model": "TestModel"}, {"model": "TestModel", "size": None}), # partial
],
)
def test_storage_device(data: dict, expected: dict):
"""Test StorageDevice handles empty slots and various field types (Protect 6.x+)."""
device = StorageDevice.from_unifi_dict(**data)
for key, value in expected.items():
assert getattr(device, key) == value
def test_event_unknown_smart_detect_type_dropped() -> None:
"""An unknown ``smartDetectTypes`` entry is dropped rather than aborting parsing."""
event = Event.from_unifi_dict(
api=Mock(),
id="evt-1",
modelKey="event",
type=EventType.SMART_DETECT.value,
start=1735689600000,
score=0,
smartDetectTypes=["person", "linecrossing_basic"],
)
assert event.smart_detect_types == [SmartDetectObjectType.PERSON]
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_nvr_set_insights(nvr_obj: NVR, status: bool):
nvr_obj.api.api_request.reset_mock()
nvr_obj.is_insights_enabled = not status
await nvr_obj.set_insights(status)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"isInsightsEnabled": status},
)
@pytest.mark.asyncio()
async def test_nvr_set_anonymous_analytics(nvr_obj: NVR):
nvr_obj.api.api_request.reset_mock()
nvr_obj.analytics_data = AnalyticsOption.ANONYMOUS
await nvr_obj.set_anonymous_analytics(False)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"analyticsData": "none"},
)
@pytest.mark.asyncio()
async def test_nvr_set_default_reset_timeout(nvr_obj: NVR):
nvr_obj.api.api_request.reset_mock()
duration = timedelta(seconds=10)
await nvr_obj.set_default_reset_timeout(duration)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"doorbellSettings": {"defaultMessageResetTimeoutMs": to_ms(duration)}},
)
@pytest.mark.parametrize("message", ["Test", "fqthpqBgVMKXp9jXX2VeuGeXYfx2mMjB"])
@pytest.mark.asyncio()
async def test_nvr_set_default_doorbell_message(nvr_obj: NVR, message: str):
nvr_obj.api.api_request.reset_mock()
if len(message) > 30:
with pytest.raises(ValidationError):
await nvr_obj.set_default_doorbell_message(message)
assert not nvr_obj.api.api_request.called
else:
await nvr_obj.set_default_doorbell_message(message)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"doorbellSettings": {"defaultMessageText": message}},
)
@pytest.mark.parametrize(
"message",
["Welcome", "Test", "fqthpqBgVMKXp9jXX2VeuGeXYfx2mMjB"],
)
@pytest.mark.asyncio()
async def test_nvr_add_custom_doorbell_message(nvr_obj: NVR, message: str):
nvr_obj.api.api_request.reset_mock()
nvr_obj.doorbell_settings.custom_messages = ["Welcome"]
if message != "Test":
with pytest.raises(BadRequest):
await nvr_obj.add_custom_doorbell_message(message)
assert not nvr_obj.api.api_request.called
else:
await nvr_obj.add_custom_doorbell_message(message)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"doorbellSettings": {"customMessages": ["Welcome", "Test"]}},
)
assert nvr_obj.doorbell_settings.all_messages == [
DoorbellMessage(
type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR,
text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "),
),
DoorbellMessage(
type=DoorbellMessageType.DO_NOT_DISTURB,
text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "),
),
DoorbellMessage(
type=DoorbellMessageType.CUSTOM_MESSAGE,
text="Welcome",
),
DoorbellMessage(
type=DoorbellMessageType.CUSTOM_MESSAGE,
text="Test",
),
]
@pytest.mark.parametrize("message", ["Welcome", "Test"])
@pytest.mark.asyncio()
async def test_nvr_remove_custom_doorbell_message(nvr_obj: NVR, message: str):
nvr_obj.api.api_request.reset_mock()
nvr_obj.doorbell_settings.custom_messages = ["Welcome"]
if message == "Test":
with pytest.raises(BadRequest):
await nvr_obj.remove_custom_doorbell_message(message)
assert not nvr_obj.api.api_request.called
else:
await nvr_obj.remove_custom_doorbell_message(message)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"doorbellSettings": {"customMessages": []}},
)
assert nvr_obj.doorbell_settings.all_messages == [
DoorbellMessage(
type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR,
text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "),
),
DoorbellMessage(
type=DoorbellMessageType.DO_NOT_DISTURB,
text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "),
),
]
@pytest.mark.parametrize(
("ip", "expected"),
[
("192.168.1.1", IPv4Address("192.168.1.1")),
("fe80::1ff:fe23:4567:890a", IPv6Address("fe80::1ff:fe23:4567:890a")),
],
)
@pytest.mark.asyncio()
async def test_nvr_wan_ip(nvr_obj: NVR, ip: str, expected: IPv4Address | IPv6Address):
nvr_dict = nvr_obj.unifi_dict()
nvr_dict["wanIp"] = ip
nvr = NVR.from_unifi_dict(**nvr_dict)
assert nvr.wan_ip == expected
assert nvr.unifi_dict()["wanIp"] == ip
@pytest.mark.asyncio()
async def test_nvr_set_smart_detections(nvr_obj: NVR):
nvr_obj.smart_detection = NVRSmartDetection(
enable=False,
face_recognition=False,
license_plate_recognition=False,
)
nvr_obj.api.api_request.reset_mock()
await nvr_obj.set_smart_detections(True)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"smartDetection": {"enable": True}},
)
@pytest.mark.asyncio()
async def test_nvr_set_face_recognition(nvr_obj: NVR):
nvr_obj.smart_detection = NVRSmartDetection(
enable=True,
face_recognition=False,
license_plate_recognition=False,
)
nvr_obj.api.api_request.reset_mock()
await nvr_obj.set_face_recognition(True)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"smartDetection": {"faceRecognition": True}},
)
@pytest.mark.asyncio()
async def test_nvr_set_face_recognition_no_smart(nvr_obj: NVR):
nvr_obj.smart_detection = NVRSmartDetection(
enable=False,
face_recognition=False,
license_plate_recognition=False,
)
nvr_obj.api.api_request.reset_mock()
with pytest.raises(BadRequest):
await nvr_obj.set_face_recognition(True)
assert not nvr_obj.api.api_request.called
@pytest.mark.asyncio()
async def test_nvr_set_license_plate_recognition(nvr_obj: NVR):
nvr_obj.smart_detection = NVRSmartDetection(
enable=True,
face_recognition=False,
license_plate_recognition=False,
)
nvr_obj.api.api_request.reset_mock()
await nvr_obj.set_license_plate_recognition(True)
nvr_obj.api.api_request.assert_called_with(
"nvr",
method="patch",
json={"smartDetection": {"licensePlateRecognition": True}},
)
@pytest.mark.asyncio()
async def test_nvr_set_license_plate_recognition_no_smart(nvr_obj: NVR):
nvr_obj.smart_detection = NVRSmartDetection(
enable=False,
face_recognition=False,
license_plate_recognition=False,
)
nvr_obj.api.api_request.reset_mock()
with pytest.raises(BadRequest):
await nvr_obj.set_license_plate_recognition(True)
assert not nvr_obj.api.api_request.called
@pytest.mark.usefixtures("_disable_nvr_validation")
def test_nvr_is_global_face_detection_on(nvr_obj: NVR) -> None:
# Create mock global camera settings if they don't exist
if nvr_obj.global_camera_settings is None:
nvr_obj.global_camera_settings = Mock()
nvr_obj.global_camera_settings.recording_settings = Mock()
nvr_obj.global_camera_settings.smart_detect_settings = Mock()
# Test when global face detection is enabled
nvr_obj.global_camera_settings.recording_settings.mode = RecordingMode.ALWAYS
nvr_obj.global_camera_settings.smart_detect_settings.object_types = [
SmartDetectObjectType.FACE
]
assert nvr_obj.is_global_face_detection_on is True
# Test when global face detection is disabled
nvr_obj.global_camera_settings.smart_detect_settings.object_types = []
assert nvr_obj.is_global_face_detection_on is False
# Test when global recording is disabled
nvr_obj.global_camera_settings.recording_settings.mode = RecordingMode.NEVER
nvr_obj.global_camera_settings.smart_detect_settings.object_types = [
SmartDetectObjectType.FACE
]
assert nvr_obj.is_global_face_detection_on is False
# Test with mixed object types
nvr_obj.global_camera_settings.recording_settings.mode = RecordingMode.ALWAYS
nvr_obj.global_camera_settings.smart_detect_settings.object_types = [
SmartDetectObjectType.PERSON,
SmartDetectObjectType.FACE,
SmartDetectObjectType.VEHICLE,
]
assert nvr_obj.is_global_face_detection_on is True
uilibs-uiprotect-317a52c/tests/data/test_sensor.py 0000664 0000000 0000000 00000052121 15246377057 0022362 0 ustar 00root root 0000000 0000000 # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr"
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, Mock
import pytest
from tests.conftest import TEST_CAMERA_EXISTS, TEST_SENSOR_EXISTS
from uiprotect.data.types import MountType, SensorScheduleMode
from uiprotect.exceptions import BadRequest
if TYPE_CHECKING:
from uiprotect.data import Camera, Light, Sensor
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_status_light(sensor_obj: Sensor, status: bool):
sensor_obj.api.api_request.reset_mock()
sensor_obj.led_settings.is_enabled = not status
await sensor_obj.set_status_light(status)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"ledSettings": {"isEnabled": status}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("mount_type", [MountType.DOOR, MountType.NONE])
@pytest.mark.asyncio()
async def test_sensor_set_mount_type(sensor_obj: Sensor, mount_type: MountType):
sensor_obj.api.api_request.reset_mock()
sensor_obj.mount_type = MountType.LEAK
await sensor_obj.set_mount_type(mount_type)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"mountType": mount_type.value},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_motion_status(sensor_obj: Sensor, status: bool):
sensor_obj.api.api_request.reset_mock()
sensor_obj.motion_settings.is_enabled = not status
await sensor_obj.set_motion_status(status)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"motionSettings": {"isEnabled": status}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_temperature_status(sensor_obj: Sensor, status: bool):
sensor_obj.api.api_request.reset_mock()
sensor_obj.temperature_settings.is_enabled = not status
await sensor_obj.set_temperature_status(status)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"temperatureSettings": {"isEnabled": status}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_humidity_status(sensor_obj: Sensor, status: bool):
sensor_obj.api.api_request.reset_mock()
sensor_obj.humidity_settings.is_enabled = not status
await sensor_obj.set_humidity_status(status)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"humiditySettings": {"isEnabled": status}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_light_status(sensor_obj: Sensor, status: bool):
sensor_obj.api.api_request.reset_mock()
sensor_obj.light_settings.is_enabled = not status
await sensor_obj.set_light_status(status)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"lightSettings": {"isEnabled": status}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_alarm_status(sensor_obj: Sensor, status: bool):
sensor_obj.api.api_request.reset_mock()
sensor_obj.alarm_settings.is_enabled = not status
await sensor_obj.set_alarm_status(status)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"alarmSettings": {"isEnabled": status}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("low", [-1.0, 0.0, 25.0])
@pytest.mark.parametrize("high", [20.0, 45.0, 50.0])
@pytest.mark.asyncio()
async def test_sensor_set_temperature_safe_range(
sensor_obj: Sensor,
low: float,
high: float,
):
sensor_obj.api.api_request.reset_mock()
sensor_obj.temperature_settings.low_threshold = None
sensor_obj.temperature_settings.high_threshold = None
if low == -1.0 or high == 50.0 or low > high:
with pytest.raises(BadRequest):
await sensor_obj.set_temperature_safe_range(low, high)
assert not sensor_obj.api.api_request.called
else:
await sensor_obj.set_temperature_safe_range(low, high)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"temperatureSettings": {"lowThreshold": low, "highThreshold": high}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("low", [0.0, 1.0, 50.0])
@pytest.mark.parametrize("high", [45.0, 99.0, 100.0])
@pytest.mark.asyncio()
async def test_sensor_set_humidity_safe_range(
sensor_obj: Sensor,
low: float,
high: float,
):
sensor_obj.api.api_request.reset_mock()
sensor_obj.humidity_settings.low_threshold = None
sensor_obj.humidity_settings.high_threshold = None
if low == 0.0 or high == 100.0 or low > high:
with pytest.raises(BadRequest):
await sensor_obj.set_humidity_safe_range(low, high)
assert not sensor_obj.api.api_request.called
else:
await sensor_obj.set_humidity_safe_range(low, high)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"humiditySettings": {"lowThreshold": low, "highThreshold": high}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("low", [0.0, 1.0, 500.0])
@pytest.mark.parametrize("high", [400.0, 1000.0, 1001.0])
@pytest.mark.asyncio()
async def test_sensor_set_light_safe_range(sensor_obj: Sensor, low: float, high: float):
sensor_obj.api.api_request.reset_mock()
sensor_obj.light_settings.low_threshold = None
sensor_obj.light_settings.high_threshold = None
if low == 0.0 or high == 1001.0 or low > high:
with pytest.raises(BadRequest):
await sensor_obj.set_light_safe_range(low, high)
assert not sensor_obj.api.api_request.called
else:
await sensor_obj.set_light_safe_range(low, high)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"lightSettings": {"lowThreshold": low, "highThreshold": high}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_remove_temperature_safe_range(sensor_obj: Sensor):
sensor_obj.api.api_request.reset_mock()
sensor_obj.temperature_settings.low_threshold = 10
sensor_obj.temperature_settings.high_threshold = 20
await sensor_obj.remove_temperature_safe_range()
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"temperatureSettings": {"lowThreshold": None, "highThreshold": None}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_remove_humidity_safe_range(sensor_obj: Sensor):
sensor_obj.api.api_request.reset_mock()
sensor_obj.humidity_settings.low_threshold = 10
sensor_obj.humidity_settings.high_threshold = 20
await sensor_obj.remove_humidity_safe_range()
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"humiditySettings": {"lowThreshold": None, "highThreshold": None}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_remove_light_safe_range(sensor_obj: Sensor):
sensor_obj.api.api_request.reset_mock()
sensor_obj.light_settings.low_threshold = 10
sensor_obj.light_settings.high_threshold = 20
await sensor_obj.remove_light_safe_range()
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"lightSettings": {"lowThreshold": None, "highThreshold": None}},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_paired_camera_none(sensor_obj: Sensor):
sensor_obj.api.api_request.reset_mock()
sensor_obj.camera_id = "bad_id"
await sensor_obj.set_paired_camera(None)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"camera": None},
)
@pytest.mark.skipif(
not TEST_SENSOR_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_sensor_set_paired_camera(sensor_obj: Light, camera_obj: Camera):
sensor_obj.api.api_request.reset_mock()
sensor_obj.camera_id = None
await sensor_obj.set_paired_camera(camera_obj)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"camera": camera_obj.id},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_name_public(sensor_obj: Sensor):
updated = Mock()
updated.name = "Renamed"
sensor_obj.api.update_sensor_public = AsyncMock(return_value=updated)
await sensor_obj.set_name_public("Renamed")
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, name="Renamed"
)
assert sensor_obj.name == "Renamed"
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
"attr",
["temperature", "humidity", "light"],
)
@pytest.mark.asyncio()
async def test_sensor_set_threshold_settings_public(sensor_obj: Sensor, attr: str):
sensor_obj.api.update_sensor_public = AsyncMock()
setter = getattr(sensor_obj, f"set_{attr}_settings_public")
await setter(is_enabled=True, low_threshold=5, high_threshold=20, margin=2)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id,
**{
f"{attr}_settings": {
"isEnabled": True,
"lowThreshold": 5,
"highThreshold": 20,
"margin": 2,
}
},
)
local = getattr(sensor_obj, f"{attr}_settings")
assert local.is_enabled is True
assert local.low_threshold == 5
assert local.high_threshold == 20
assert local.margin == 2
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
"attr",
["temperature", "humidity", "light"],
)
@pytest.mark.asyncio()
async def test_sensor_set_threshold_settings_public_requires_arg(
sensor_obj: Sensor, attr: str
):
sensor_obj.api.update_sensor_public = AsyncMock()
setter = getattr(sensor_obj, f"set_{attr}_settings_public")
with pytest.raises(BadRequest):
await setter()
assert not sensor_obj.api.update_sensor_public.called
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_motion_settings_public(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_motion_settings_public(
is_enabled=True, sensitivity=70, sensitivity_when_armed=90
)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id,
motion_settings={
"isEnabled": True,
"sensitivity": 70,
"sensitivityWhenArmed": 90,
},
)
assert sensor_obj.motion_settings.is_enabled is True
assert sensor_obj.motion_settings.sensitivity == 70
assert sensor_obj.motion_settings.sensitivity_when_armed == 90
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_motion_settings_public_requires_arg(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
with pytest.raises(BadRequest):
await sensor_obj.set_motion_settings_public()
assert not sensor_obj.api.update_sensor_public.called
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_glass_break_settings_public(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_glass_break_settings_public(
is_enabled=True, sensitivity=55, sensitivity_when_armed=65
)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id,
glass_break_settings={
"isEnabled": True,
"sensitivity": 55,
"sensitivityWhenArmed": 65,
},
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_glass_break_settings_public_requires_arg(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
with pytest.raises(BadRequest):
await sensor_obj.set_glass_break_settings_public()
assert not sensor_obj.api.update_sensor_public.called
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("field", ["sensitivity", "sensitivity_when_armed"])
@pytest.mark.parametrize("value", [-1, 101, 150])
@pytest.mark.asyncio()
async def test_sensor_set_motion_settings_public_rejects_out_of_range(
sensor_obj: Sensor, field: str, value: int
):
sensor_obj.api.update_sensor_public = AsyncMock()
with pytest.raises(BadRequest):
await sensor_obj.set_motion_settings_public(**{field: value})
assert not sensor_obj.api.update_sensor_public.called
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_motion_settings_public_coerces_float(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_motion_settings_public(
sensitivity=70.0, sensitivity_when_armed=90.0
)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id,
motion_settings={"sensitivity": 70, "sensitivityWhenArmed": 90},
)
sent = sensor_obj.api.update_sensor_public.call_args.kwargs["motion_settings"]
assert isinstance(sent["sensitivity"], int)
assert isinstance(sent["sensitivityWhenArmed"], int)
assert sensor_obj.motion_settings.sensitivity == 70
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("value", [-1, 101])
@pytest.mark.asyncio()
async def test_sensor_set_glass_break_settings_public_rejects_out_of_range(
sensor_obj: Sensor, value: int
):
sensor_obj.api.update_sensor_public = AsyncMock()
with pytest.raises(BadRequest):
await sensor_obj.set_glass_break_settings_public(sensitivity=value)
assert not sensor_obj.api.update_sensor_public.called
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("attr", "low_bad", "low_ok"),
[
("temperature", -40, -39),
("temperature", 125, 124),
("humidity", 0, 1),
("humidity", 100, 99),
("light", 0, 1),
("light", 503193, 503192),
],
)
@pytest.mark.asyncio()
async def test_sensor_set_threshold_settings_public_rejects_out_of_range(
sensor_obj: Sensor,
attr: str,
low_bad: float,
low_ok: float,
):
sensor_obj.api.update_sensor_public = AsyncMock()
setter = getattr(sensor_obj, f"set_{attr}_settings_public")
with pytest.raises(BadRequest):
await setter(low_threshold=low_bad)
assert not sensor_obj.api.update_sensor_public.called
# A boundary low value passes; highThreshold is spec-unbounded, so even a
# value below the low minimum is accepted for it.
await setter(low_threshold=low_ok, high_threshold=low_bad)
assert sensor_obj.api.update_sensor_public.called
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("attr", ["humidity", "light"])
@pytest.mark.asyncio()
async def test_sensor_set_threshold_settings_public_passes_float_through(
sensor_obj: Sensor, attr: str
):
sensor_obj.api.update_sensor_public = AsyncMock()
setter = getattr(sensor_obj, f"set_{attr}_settings_public")
await setter(low_threshold=30.7, high_threshold=80.9)
sent = sensor_obj.api.update_sensor_public.call_args.kwargs[f"{attr}_settings"]
assert sent == {"lowThreshold": 30.7, "highThreshold": 80.9}
# Spec types these as ``number`` — the caller's float is sent verbatim, not
# silently truncated to int.
local = getattr(sensor_obj, f"{attr}_settings")
assert local.low_threshold == 30.7
assert local.high_threshold == 80.9
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("enabled", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_alarm_public(sensor_obj: Sensor, enabled: bool):
sensor_obj.api.update_sensor_public = AsyncMock()
sensor_obj.alarm_settings.is_enabled = not enabled
await sensor_obj.set_alarm_public(enabled)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, alarm_settings={"isEnabled": enabled}
)
assert sensor_obj.alarm_settings.is_enabled is enabled
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_schedule_mode_public(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_schedule_mode_public(SensorScheduleMode.WHEN_ARMED)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, schedule_mode=SensorScheduleMode.WHEN_ARMED
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_arm_profile_ids_public(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_arm_profile_ids_public(["a", "b"])
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, arm_profile_ids=["a", "b"]
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("enabled", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_custom_sensitivity_when_armed_public(
sensor_obj: Sensor, enabled: bool
):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_custom_sensitivity_when_armed_public(enabled)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, has_custom_sensitivity_when_armed=enabled
)
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("enabled", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_motion_status_public(sensor_obj: Sensor, enabled: bool):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_motion_status_public(enabled)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, motion_settings={"isEnabled": enabled}
)
assert sensor_obj.motion_settings.is_enabled is enabled
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_sensor_set_motion_sensitivity_public(sensor_obj: Sensor):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_motion_sensitivity_public(42)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, motion_settings={"sensitivity": 42}
)
assert sensor_obj.motion_settings.sensitivity == 42
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("attr", ["temperature", "humidity", "light"])
@pytest.mark.parametrize("enabled", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_threshold_status_public(
sensor_obj: Sensor, attr: str, enabled: bool
):
sensor_obj.api.update_sensor_public = AsyncMock()
await getattr(sensor_obj, f"set_{attr}_status_public")(enabled)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, **{f"{attr}_settings": {"isEnabled": enabled}}
)
assert getattr(sensor_obj, f"{attr}_settings").is_enabled is enabled
@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("enabled", [True, False])
@pytest.mark.asyncio()
async def test_sensor_set_glass_break_status_public(sensor_obj: Sensor, enabled: bool):
sensor_obj.api.update_sensor_public = AsyncMock()
await sensor_obj.set_glass_break_status_public(enabled)
sensor_obj.api.update_sensor_public.assert_called_once_with(
sensor_obj.id, glass_break_settings={"isEnabled": enabled}
)
uilibs-uiprotect-317a52c/tests/data/test_types.py 0000664 0000000 0000000 00000006175 15246377057 0022225 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typing
import pytest
from uiprotect.data.types import (
AudioStyle,
HDRMode,
IRLEDMode,
ModelType,
MountPosition,
MountType,
OsdOverlayLocation,
PermissionNode,
SensorStatusType,
SensorType,
StorageType,
VideoMode,
get_field_type,
)
@pytest.mark.asyncio()
async def test_model_type_from_string():
assert ModelType.from_string("camera") is ModelType.CAMERA
assert ModelType.from_string("invalid") is ModelType.UNKNOWN
@pytest.mark.parametrize(
("enum_cls", "known_value", "known_member"),
[
(AudioStyle, "nature", AudioStyle.NATURE),
(HDRMode, "normal", HDRMode.NORMAL),
(IRLEDMode, "auto", IRLEDMode.AUTO),
(IRLEDMode, "customFilterOnly", IRLEDMode.CUSTOM_FILTER_ONLY),
(MountType, "door", MountType.DOOR),
(SensorType, "temperature", SensorType.TEMPERATURE),
(SensorStatusType, "safe", SensorStatusType.SAFE),
(MountPosition, "ceiling", MountPosition.CEILING),
(StorageType, "hdd", StorageType.DISK),
(PermissionNode, "read", PermissionNode.READ),
(OsdOverlayLocation, "topLeft", OsdOverlayLocation.TOP_LEFT),
(OsdOverlayLocation, "bottomRight", OsdOverlayLocation.BOTTOM_RIGHT),
(VideoMode, "default", VideoMode.DEFAULT),
(VideoMode, "lprReflex", VideoMode.LPR_REFLEX),
],
)
def test_unknown_values_enum_known_value(enum_cls, known_value, known_member):
"""Known values resolve to their correct enum member."""
assert enum_cls(known_value) is known_member
@pytest.mark.parametrize(
"enum_cls",
[
AudioStyle,
HDRMode,
IRLEDMode,
MountType,
SensorType,
SensorStatusType,
MountPosition,
StorageType,
PermissionNode,
OsdOverlayLocation,
VideoMode,
],
)
def test_unknown_values_enum_falls_back_to_unknown(enum_cls):
"""Unknown values fall back to UNKNOWN instead of raising ValueError."""
result = enum_cls("completely_new_firmware_value")
assert result.name == "UNKNOWN"
@pytest.mark.parametrize(
("annotation", "origin", "type_"),
[
(bytearray, None, bytearray),
(typing.get_origin(dict[str, int]), None, dict),
(dict, None, dict),
# Extract value type from list, set, dict
(list[int], list, int),
(set[int], set, int),
(dict[str, int], dict, int),
# Extract type from Annotated
(typing.Annotated[int, "Hello World"], None, int),
# Remove '| None' from Union and extract remaining value type
(int | None, None, int),
(list[int] | None, list, int),
(typing.Annotated[int, "Hello World"] | None, None, int),
# Leave 'normal' unions as is
(int | str, None, int | str),
(int | str | bytes, None, int | str | bytes),
],
)
def test_get_field_type(annotation, origin, type_):
res = get_field_type(annotation)
assert origin == res[0]
assert type_ == res[1]
def test_get_field_type_error():
with pytest.raises(ValueError, match="Type annotation cannot be None"):
get_field_type(None)
uilibs-uiprotect-317a52c/tests/data/test_user.py 0000664 0000000 0000000 00000014604 15246377057 0022033 0 ustar 00root root 0000000 0000000 from datetime import datetime
from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers
def test_ulp_user_creation():
user = UlpUser(
id="1",
ulp_id="ulp1",
first_name="John",
last_name="Doe",
full_name="John Doe",
avatar="avatar_url",
status="active",
)
assert user.id == "1"
assert user.ulp_id == "ulp1"
assert user.first_name == "John"
assert user.last_name == "Doe"
assert user.full_name == "John Doe"
assert user.avatar == "avatar_url"
assert user.status == "active"
def test_ulp_users_add_and_remove():
users = UlpUsers()
user = UlpUser(
id="1",
ulp_id="ulp1",
first_name="John",
last_name="Doe",
full_name="John Doe",
avatar="avatar_url",
status="active",
)
users.add(user)
assert users.by_id("1") == user
assert users.by_ulp_id("ulp1") == user
users.remove(user)
assert users.by_id("1") is None
assert users.by_ulp_id("ulp1") is None
def test_keyring_creation():
keyring = Keyring(
id="1",
device_type="type1",
device_id="device1",
registry_type="reg_type1",
registry_id="reg_id1",
last_activity=datetime.now(),
ulp_user="ulp1",
)
assert keyring.id == "1"
assert keyring.device_type == "type1"
assert keyring.device_id == "device1"
assert keyring.registry_type == "reg_type1"
assert keyring.registry_id == "reg_id1"
assert keyring.ulp_user == "ulp1"
def test_keyrings_add_and_remove():
keyrings = Keyrings()
keyring = Keyring(
id="1",
device_type="type1",
device_id="device1",
registry_type="reg_type1",
registry_id="reg_id1",
last_activity=datetime.now(),
ulp_user="ulp1",
)
keyrings.add(keyring)
assert keyrings.by_id("1") == keyring
assert keyrings.by_registry_id("reg_id1") == keyring
assert keyrings.by_ulp_id("ulp1") == keyring
keyrings.remove(keyring)
assert keyrings.by_id("1") is None
assert keyrings.by_registry_id("reg_id1") is None
assert keyrings.by_ulp_id("ulp1") is None
def test_keyrings_equality():
keyrings1 = Keyrings()
keyring1 = Keyring(
id="1",
device_type="type1",
device_id="device1",
registry_type="reg_type1",
registry_id="reg_id1",
last_activity=datetime.now(),
ulp_user="ulp1",
)
keyring2 = Keyring(
id="2",
device_type="type2",
device_id="device2",
registry_type="reg_type2",
registry_id="reg_id2",
last_activity=datetime.now(),
ulp_user="ulp2",
)
keyrings1.add(keyring1)
keyrings1.add(keyring2)
keyrings2 = Keyrings()
keyrings2.add(keyring1)
keyrings2.add(keyring2)
assert keyrings1 == keyrings2
keyrings3 = Keyrings()
keyring3 = Keyring(
id="3",
device_type="type3",
device_id="device3",
registry_type="reg_type3",
registry_id="reg_id3",
last_activity=datetime.now(),
ulp_user="ulp3",
)
keyrings3.add(keyring3)
assert keyrings1 != keyrings3
def test_keyrings_eq_not_implemented():
keyrings = Keyrings()
assert keyrings.__eq__(object()) == NotImplemented
def test_ulp_users_eq_not_implemented():
users = UlpUsers()
assert users.__eq__(object()) == NotImplemented
def test_ulp_users_equality():
users1 = UlpUsers()
user1 = UlpUser(
id="1",
ulp_id="ulp1",
first_name="John",
last_name="Doe",
full_name="John Doe",
avatar="avatar_url",
status="active",
)
user2 = UlpUser(
id="2",
ulp_id="ulp2",
first_name="Jane",
last_name="Doe",
full_name="Jane Doe",
avatar="avatar_url",
status="inactive",
)
users1.add(user1)
users1.add(user2)
users2 = UlpUsers()
users2.add(user1)
users2.add(user2)
assert users1 == users2
users3 = UlpUsers()
user3 = UlpUser(
id="3",
ulp_id="ulp3",
first_name="Jim",
last_name="Beam",
full_name="Jim Beam",
avatar="avatar_url",
status="active",
)
users3.add(user3)
assert users1 != users3
def test_ulp_users_as_list():
users = UlpUsers()
user1 = UlpUser(
id="1",
ulp_id="ulp1",
first_name="John",
last_name="Doe",
full_name="John Doe",
avatar="avatar_url",
status="active",
)
user2 = UlpUser(
id="2",
ulp_id="ulp2",
first_name="Jane",
last_name="Doe",
full_name="Jane Doe",
avatar="avatar_url",
status="inactive",
)
users.add(user1)
users.add(user2)
user_list = users.as_list()
assert len(user_list) == 2
assert user1 in user_list
assert user2 in user_list
def test_keyrings_from_list():
keyring1 = Keyring(
id="1",
device_type="type1",
device_id="device1",
registry_type="reg_type1",
registry_id="reg_id1",
last_activity=datetime.now(),
ulp_user="ulp1",
)
keyring2 = Keyring(
id="2",
device_type="type2",
device_id="device2",
registry_type="reg_type2",
registry_id="reg_id2",
last_activity=datetime.now(),
ulp_user="ulp2",
)
keyrings_list = [keyring1, keyring2]
keyrings = Keyrings.from_list(keyrings_list)
assert keyrings.by_id("1") == keyring1
assert keyrings.by_id("2") == keyring2
assert keyrings.by_registry_id("reg_id1") == keyring1
assert keyrings.by_registry_id("reg_id2") == keyring2
assert keyrings.by_ulp_id("ulp1") == keyring1
assert keyrings.by_ulp_id("ulp2") == keyring2
def test_ulp_users_from_list():
user1 = UlpUser(
id="1",
ulp_id="ulp1",
first_name="John",
last_name="Doe",
full_name="John Doe",
avatar="avatar_url",
status="active",
)
user2 = UlpUser(
id="2",
ulp_id="ulp2",
first_name="Jane",
last_name="Doe",
full_name="Jane Doe",
avatar="avatar_url",
status="inactive",
)
users_list = [user1, user2]
users = UlpUsers.from_list(users_list)
assert users.by_id("1") == user1
assert users.by_id("2") == user2
assert users.by_ulp_id("ulp1") == user1
assert users.by_ulp_id("ulp2") == user2
uilibs-uiprotect-317a52c/tests/data/test_viewer.py 0000664 0000000 0000000 00000007254 15246377057 0022361 0 ustar 00root root 0000000 0000000 # mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr"
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, Mock
import pytest
from tests.conftest import TEST_VIEWPORT_EXISTS
from uiprotect.data.public_devices import PublicViewer
from uiprotect.data.websocket import WSAction, WSSubscriptionMessage
from uiprotect.exceptions import BadRequest
if TYPE_CHECKING:
from uiprotect.data import Liveview, Viewer
def _public_viewer_response(**overrides: object) -> PublicViewer:
raw: dict[str, object] = {
"id": "viewer-1",
"modelKey": "viewer",
"state": "CONNECTED",
"name": "Viewer 1",
"mac": "AABBCCDDEE01",
"liveview": "lv-1",
"streamLimit": 16,
}
raw.update(overrides)
return PublicViewer.from_unifi_dict(**raw)
@pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_viewer_set_liveview_invalid(viewer_obj: Viewer, liveview_obj: Liveview):
viewer_obj.api.api_request.reset_mock()
liveview = liveview_obj.update_from_dict({"id": "bad_id"})
with pytest.raises(BadRequest):
await viewer_obj.set_liveview(liveview)
assert not viewer_obj.api.api_request.called
@pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_viewer_set_liveview_valid(viewer_obj: Viewer, liveview_obj: Liveview):
viewer_obj.api.api_request.reset_mock()
viewer_obj.api.emit_message = Mock()
viewer_obj.liveview_id = "bad_id"
await viewer_obj.set_liveview(liveview_obj)
viewer_obj.api.api_request.assert_called_with(
f"viewers/{viewer_obj.id}",
method="patch",
json={"liveview": liveview_obj.id},
)
# old/new is actually the same here since the client
# generating the message is the one that changed it
viewer_obj.api.emit_message.assert_called_with(
WSSubscriptionMessage(
action=WSAction.UPDATE,
new_update_id=viewer_obj.api.bootstrap.last_update_id,
changed_data={"liveview_id": liveview_obj.id},
old_obj=viewer_obj,
new_obj=viewer_obj,
),
)
@pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_viewer_set_name_public(viewer_obj: Viewer) -> None:
viewer_obj.api.update_viewer_public = AsyncMock(
return_value=_public_viewer_response(name="Renamed"),
)
await viewer_obj.set_name_public("Renamed")
viewer_obj.api.update_viewer_public.assert_awaited_once_with(
viewer_obj.id,
name="Renamed",
)
assert viewer_obj.name == "Renamed"
@pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_viewer_set_liveview_public(viewer_obj: Viewer) -> None:
viewer_obj.api.update_viewer_public = AsyncMock(
return_value=_public_viewer_response(liveview="lv-9"),
)
await viewer_obj.set_liveview_public("lv-9")
viewer_obj.api.update_viewer_public.assert_awaited_once_with(
viewer_obj.id,
liveview="lv-9",
)
assert viewer_obj.liveview_id == "lv-9"
@pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_viewer_set_liveview_public_clear(viewer_obj: Viewer) -> None:
viewer_obj.api.update_viewer_public = AsyncMock(
return_value=_public_viewer_response(liveview=None),
)
await viewer_obj.set_liveview_public(None)
viewer_obj.api.update_viewer_public.assert_awaited_once_with(
viewer_obj.id,
liveview=None,
)
assert viewer_obj.liveview_id == ""
uilibs-uiprotect-317a52c/tests/sample_data/ 0000775 0000000 0000000 00000000000 15246377057 0021000 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/sample_data/__init__.py 0000664 0000000 0000000 00000000000 15246377057 0023077 0 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/sample_data/constants.py 0000664 0000000 0000000 00000001460 15246377057 0023367 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
UFP_SAMPLE_DIR = os.environ.get("UFP_SAMPLE_DIR")
if UFP_SAMPLE_DIR:
DATA_FILE = Path(UFP_SAMPLE_DIR) / "sample_constants.json"
else:
DATA_FILE = Path(__file__).parent / "sample_constants.json"
class ConstantData:
_data: dict[str, Any] | None = None
def __getitem__(self, key):
return self.data().__getitem__(key)
def __contains__(self, key):
return self.data().__contains__(key)
def get(self, key, default=None):
return self.data().get(key, default)
def data(self):
if self._data is None:
with DATA_FILE.open(encoding="utf-8") as f:
self._data = json.load(f)
return self._data
CONSTANTS = ConstantData()
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/ 0000775 0000000 0000000 00000000000 15246377057 0024353 5 ustar 00root root 0000000 0000000 uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/born_closed_update_bad_start.json 0000664 0000000 0000000 00000000332 15246377057 0033122 0 ustar 00root root 0000000 0000000 {
"type": "update",
"item": {
"id": "bad-event",
"modelKey": "event",
"type": "smartDetectZone",
"start": "not-a-timestamp",
"end": 1735690006000,
"device": "aabbccddeeff00112233aabb"
}
}
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/fingerprint_add_ulpid_null.json 0000664 0000000 0000000 00000000502 15246377057 0032631 0 ustar 00root root 0000000 0000000 {
"type": "add",
"item": {
"id": "11111111-1111-1111-1111-111111111113",
"modelKey": "event",
"type": "fingerprintIdentified",
"start": 1735689800000,
"end": 1735689800500,
"device": "aabbccddeeff00112233aabb",
"metadata": {
"fingerprint": {
"ulpId": null
}
}
}
}
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/fingerprint_add_with_end.json 0000664 0000000 0000000 00000000544 15246377057 0032271 0 ustar 00root root 0000000 0000000 {
"type": "add",
"item": {
"id": "11111111-1111-1111-1111-111111111112",
"modelKey": "event",
"type": "fingerprintIdentified",
"start": 1735689700000,
"end": 1735689700500,
"device": "aabbccddeeff00112233aabb",
"metadata": {
"fingerprint": {
"ulpId": "22222222-2222-2222-2222-222222222222"
}
}
}
}
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/light_motion_add.json 0000664 0000000 0000000 00000000320 15246377057 0030545 0 ustar 00root root 0000000 0000000 {
"type": "add",
"item": {
"id": "11111111-1111-1111-1111-111111111114",
"modelKey": "event",
"type": "lightMotion",
"start": 1735689900000,
"device": "aabbccddeeff00112233aabb"
}
}
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/nfc_add_with_end.json 0000664 0000000 0000000 00000000525 15246377057 0030507 0 ustar 00root root 0000000 0000000 {
"type": "add",
"item": {
"id": "11111111-1111-1111-1111-111111111111",
"modelKey": "event",
"type": "nfcCardScanned",
"start": 1735689600000,
"end": 1735689601000,
"device": "aabbccddeeff00112233aabb",
"metadata": {
"nfc": {
"ulpId": "22222222-2222-2222-2222-222222222222"
}
}
}
}
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/open_unknown_update.json 0000664 0000000 0000000 00000000343 15246377057 0031330 0 ustar 00root root 0000000 0000000 {
"type": "update",
"item": {
"id": "unseen-open",
"modelKey": "event",
"type": "smartDetectZone",
"start": 1735690000000,
"device": "aabbccddeeff00112233aabb",
"smartDetectTypes": ["package"]
}
}
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/package_born_closed_update.json 0000664 0000000 0000000 00000000426 15246377057 0032556 0 ustar 00root root 0000000 0000000 {
"type": "update",
"item": {
"id": "11111111-1111-1111-1111-111111111116",
"modelKey": "event",
"type": "smartDetectZone",
"start": 1735690000000,
"end": 1735690006000,
"device": "aabbccddeeff00112233aabb",
"smartDetectTypes": ["package"]
}
}
uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/smartdetect_lifecycle.json 0000664 0000000 0000000 00000001633 15246377057 0031607 0 ustar 00root root 0000000 0000000 [
{
"type": "add",
"item": {
"id": "11111111-1111-1111-1111-111111111115",
"modelKey": "event",
"type": "smartDetectZone",
"start": 1735690000000,
"device": "aabbccddeeff00112233aabb",
"smartDetectTypes": ["person"]
}
},
{
"type": "update",
"item": {
"id": "11111111-1111-1111-1111-111111111115",
"modelKey": "event",
"smartDetectTypes": ["person", "vehicle"]
}
},
{
"type": "update",
"item": {
"id": "11111111-1111-1111-1111-111111111115",
"modelKey": "event",
"end": 1735690010000
}
},
{
"type": "update",
"item": {
"id": "11111111-1111-1111-1111-111111111115",
"modelKey": "event",
"end": 1735690010000
}
},
{
"type": "update",
"item": {
"id": "11111111-1111-1111-1111-111111111115",
"modelKey": "event",
"end": 1735690010000
}
}
]
uilibs-uiprotect-317a52c/tests/sample_data/sample_aiport.json 0000664 0000000 0000000 00000041636 15246377057 0024544 0 ustar 00root root 0000000 0000000 {
"isDeleting": false,
"mac": "942A6EGCA31F",
"host": "192.168.7.217",
"connectionHost": "192.168.7.8",
"type": "UVC AI Port",
"sysid": "0xa5f1",
"name": "AI Port",
"upSince": 1734402814930,
"uptime": 66493,
"lastSeen": 1734469307930,
"connectedSince": 1734429720103,
"state": "CONNECTED",
"lastDisconnect": 1734429714493,
"hardwareRevision": "9",
"firmwareVersion": "4.73.8",
"latestFirmwareVersion": null,
"firmwareBuild": "bc916ee.241211.844",
"isUpdating": false,
"isDownloadingFW": false,
"fwUpdateState": "upToDate",
"isAdopting": false,
"isRestoring": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"uplinkDevice": null,
"guid": "f3d3739b-743d-46df-9209-a1d31595a893",
"anonymousDeviceId": "4a547994-8d51-4a57-9e69-880c5f699c04",
"lastMotion": null,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": false,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": null,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"apMgmtIp": null,
"elementInfo": null,
"chimeDuration": 0,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"eventStats": {
"motion": {
"today": 0,
"average": 0,
"lastDays": [],
"recentHours": []
},
"smart": {
"today": 0,
"average": 0,
"lastDays": []
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"activePatrolSlot": null,
"useGlobal": false,
"hubMac": null,
"isPoorNetwork": false,
"stopStreamLevel": null,
"downScaleMode": 0,
"isExtenderInstalledEver": false,
"isWaterproofCaseAttached": false,
"isMissingRecordingDetected": false,
"userConfiguredAp": false,
"hasRecordings": false,
"videoCodec": "h264",
"videoCodecState": 0,
"videoCodecSwitchingSince": null,
"enableNfc": false,
"isThirdPartyCamera": false,
"isPairedWithAiPort": false,
"streamingChannels": [],
"ptzControlEnabled": true,
"isAudioIncluded": true,
"isStreaming": true,
"wiredConnectionState": {
"phyRate": null
},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"txRate": null,
"signalQuality": null,
"ssid": null,
"bssid": null,
"apName": null,
"experience": null,
"signalStrength": null,
"connectivity": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "High",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 3840,
"height": 2160,
"fps": 24,
"bitrate": 8000000,
"minBitrate": 2000000,
"maxBitrate": 8000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24],
"idrInterval": 5,
"autoFps": true,
"autoBitrate": true
},
{
"id": 1,
"videoId": "video2",
"name": "Medium",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 750000,
"maxBitrate": 8000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24],
"idrInterval": 5,
"autoFps": true,
"autoBitrate": true
},
{
"id": 2,
"videoId": "video3",
"name": "Low",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 900000,
"minBitrate": 300000,
"maxBitrate": 8000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24],
"idrInterval": 5,
"autoFps": true,
"autoBitrate": true
}
],
"ispSettings": {
"aeMode": "none",
"irLedMode": "auto",
"irLedLevel": 256,
"wdr": 4,
"icrSensitivity": 4,
"icrSwitchMode": "none",
"icrCustomValue": 11,
"brightness": 101,
"contrast": 101,
"hue": 101,
"saturation": 101,
"sharpness": 101,
"denoise": 101,
"isColorNightVisionEnabled": true,
"spotlightDuration": 901,
"isFlippedVertical": true,
"isFlippedHorizontal": true,
"isAutoRotateEnabled": true,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": true,
"isAggressiveAntiFlickerEnabled": true,
"isPauseMotionEnabled": true,
"dZoomCenterX": 101,
"dZoomCenterY": 101,
"dZoomScale": 101,
"dZoomStreamId": 5,
"focusMode": "none",
"focusPosition": 101,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 101,
"mountPosition": "none",
"hdrMode": "none",
"sceneMode": "none"
},
"audioSettings": {
"style": ["nature"]
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": false,
"isDateEnabled": false,
"isLogoEnabled": true,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80,
"ringVolume": 80
},
"recordingSettings": {
"prePaddingSecs": 2,
"postPaddingSecs": 2,
"smartDetectPrePaddingSecs": 2,
"smartDetectPostPaddingSecs": 2,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"inScheduleMode": "always",
"outScheduleMode": "never",
"geofencing": "off",
"retentionDurationMs": null,
"retentionDurationLQMs": null,
"motionAlgorithm": "enhanced",
"enableMotionDetection": true,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle",
"animal",
"package",
"face",
"licensePlate"
],
"autoTrackingObjectTypes": [],
"autoTrackingWithZoom": true,
"audioTypes": [],
"detectionRange": {
"max": null,
"min": null
}
},
"recordingSchedulesV2": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[0.204, 0],
[0.546, 0],
[0.525, 0.631],
[0.625, 0.63],
[1, 0.704],
[1, 1],
[0.257, 1]
],
"sensitivity": 50,
"isTriggerLightEnabled": true
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[0.204, 0],
[0.533, 0],
[0.528, 0.626],
[0.696, 0.613],
[1, 0.711],
[1, 1],
[0.251, 1]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle",
"animal",
"face",
"licensePlate",
"package"
],
"isTriggerLightEnabled": true,
"source": "unifi-protect",
"triggerAccessTypes": [],
"enableAccessLPOnlyMode": false
}
],
"smartDetectLines": [
{
"id": 3,
"name": "New Crossing Line",
"color": "#46C7FD",
"points": [
[0.69, 0.609],
[0.53, 0.609]
],
"sensitivity": 50,
"objectTypes": ["person", "vehicle", "animal"],
"isTriggerLightEnabled": false,
"direction": "BA",
"isTargetCounting": false,
"plan": {
"autoReset": "none",
"time": null,
"weekDay": null,
"date": null
}
},
{
"id": 4,
"name": "New Crossing Line",
"color": "#61E066",
"points": [
[1, 0.685],
[0.69, 0.609]
],
"sensitivity": 50,
"objectTypes": ["person", "vehicle"],
"isTriggerLightEnabled": false,
"direction": "BA",
"isTargetCounting": false,
"plan": {
"autoReset": "none",
"time": null,
"weekDay": null,
"date": null
}
}
],
"smartDetectLoiterZones": [],
"stats": {
"rxBytes": 0,
"txBytes": 0,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"video": {
"recordingStart": null,
"recordingEnd": null,
"recordingStartLQ": null,
"recordingEndLQ": null,
"timelapseStart": null,
"timelapseEnd": null,
"timelapseStartLQ": null,
"timelapseEndLQ": null
},
"storage": {
"used": null,
"rate": null,
"channelStorage": {}
},
"sdCard": {
"state": "unmounted",
"health": null,
"mounts": [],
"serial": null,
"size": null,
"healthStatus": "insufficient_size",
"usedSize": 0
},
"edgeRecording": {
"recordStreamNumber": null,
"recordMode": "smartDetect"
},
"wifiQuality": 50,
"wifiStrength": 0,
"sdCardStorageCapacityMs": null
},
"featureFlags": {
"canAdjustIrLedLevel": false,
"maxScaleDownLevel": 0,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasVerticalFlip": true,
"hasAec": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasInfrared": true,
"hasLdc": false,
"hasLedIr": false,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": false,
"hasAutoICROnly": false,
"videoModes": ["default"],
"videoModeMaxFps": [30],
"hasMotionZones": true,
"hasLcdScreen": false,
"hasFingerprintSensor": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle",
"animal",
"face",
"licensePlate",
"package"
],
"smartDetectAudioTypes": [
"smoke_cmonx",
"alrmSmoke",
"alrmCmonx",
"alrmSiren",
"alrmBabyCry",
"alrmSpeak",
"alrmBurglar",
"alrmCarHorn",
"alrmBark",
"alrmGlassBreak"
],
"supportDoorAccessConfig": false,
"supportNfc": false,
"supportLpDetectionWithoutVehicle": false,
"lensType": null,
"lensModel": null,
"motionAlgorithms": ["enhanced"],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"audio": [],
"audioCodecs": ["aac"],
"videoCodecs": ["h264", "h265", "mjpg"],
"audioStyle": [],
"isDoorbell": false,
"isPtz": false,
"hasColorLcdScreen": false,
"hasLiveviewTracking": true,
"hasLineCrossing": true,
"hasLineCrossingCounting": true,
"hasFlash": false,
"flashRange": null,
"hasLuxCheck": false,
"presetTour": false,
"hasEdgeRecording": false,
"hasLprReflex": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 1,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hotplug": {
"audio": null,
"video": null,
"standaloneAdoption": false,
"extender": {
"isAttached": false,
"hasFlash": null,
"flashRange": null,
"hasIR": null,
"hasRadar": false,
"radarRangeMax": null,
"radarRangeMin": null
}
},
"hasSmartDetect": true
},
"tiltLimitsOfPrivacyZones": {
"side": "bottom",
"limit": 0
},
"lcdMessage": {},
"lenses": [],
"streamSharing": {
"enabled": false,
"token": null,
"shareLink": null,
"expires": null,
"sharedByUserId": null,
"sharedByUser": null,
"maxStreams": null
},
"homekitSettings": {
"talkbackSettingsActive": false,
"streamInProgress": false,
"microphoneMuted": false,
"speakerMuted": false
},
"shortcuts": [],
"alarms": {
"lensThermal": 0,
"tiltThermal": 0,
"panTiltMotorFaults": [],
"autoTrackingThermalThresholdReached": false,
"lensThermalThresholdReached": false,
"motorOverheated": false
},
"extendedAiFeatures": {
"smartDetectTypes": []
},
"thirdPartyCameraInfo": {
"port": null,
"rtspUrl": null,
"rtspUrlLQ": null,
"snapshotUrl": null
},
"fingerprintSettings": {
"enable": false,
"enablePrintLatency": false,
"mode": "identify",
"reportFingerTouch": false,
"reportCaptureComplete": false
},
"fingerprintState": {
"fingerprintId": null,
"status": null,
"progress": null,
"total": 0,
"free": 0
},
"nfcSettings": {
"enableNfc": false,
"supportThirdPartyCard": false
},
"nfcState": {
"lastSeen": null,
"mode": "disabled",
"cardId": null,
"isUACard": false
},
"pairedCameras": ["663d0aa400918803e4396735"],
"id": "6760947e02b4830659365930",
"nvrMac": "F9FC431B07EC",
"displayName": "AI Port",
"isConnected": true,
"platform": "all",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "AI Port",
"is4K": false,
"is2K": false,
"currentResolution": "HD",
"supportedScalingResolutions": ["HD"],
"modelKey": "aiport"
}
uilibs-uiprotect-317a52c/tests/sample_data/sample_bootstrap.json 0000664 0000000 0000000 00001037202 15246377057 0025256 0 ustar 00root root 0000000 0000000 {
"authUserId": "4c5f03a8c8bd48ad8e066285",
"accessKey": "8528571101220:340ff666bffb58bc404b859a:8f3f41a7b180b1ff7463fe4f7f13b528ac3d28668f25d0ecaa30c8e7888559e782b38d4335b40861030b75126eb7cea8385f3f9ab59dfa9a993e50757c277053",
"cameras": [
{
"isDeleting": false,
"mac": "7CD8B328641B",
"host": "192.168.164.242",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Dome",
"name": "Tfzotee Autbv",
"upSince": 1641665036921,
"uptime": null,
"lastSeen": 1641666397032,
"connectedSince": null,
"state": "DISCONNECTED",
"hardwareRevision": "10",
"firmwareVersion": "4.47.15",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "524d13e.211221.1032",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1641666350274,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": false,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 1000,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 0,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "355958af-1a08-4fb4-8619-d81fb88b5488",
"eventStats": {
"motion": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
],
"recentHours": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Jsqti Dnyvdi",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "BViVMrQWSv8mS5M8",
"width": 2688,
"height": 1512,
"fps": 24,
"bitrate": 10000000,
"minBitrate": 32000,
"maxBitrate": 10000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Xlddbfz Idwiu",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 32000,
"maxBitrate": 3000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Vpae Hsr",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 300000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": true,
"isFlippedHorizontal": true,
"isAutoRotateEnabled": true,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 50
},
"recordingSettings": {
"prePaddingSecs": 2,
"postPaddingSecs": 2,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"person"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 33888051,
"txBytes": 1445351903,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639849398628,
"recordingEnd": 1641666364031,
"recordingStartLQ": 1639849398580,
"recordingEndLQ": 1641666369049,
"timelapseStart": 1639849398590,
"timelapseEnd": 1641666309048,
"timelapseStartLQ": 1639849398590,
"timelapseEndLQ": 1641666294067
},
"storage": {},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": false,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": true,
"hasAec": true,
"hasBattery": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": true,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": true,
"hasWifi": false,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
48
],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "76227b20c37b2ff0abdc9d4d",
"isConnected": false,
"platform": "s5l",
"hasSpeaker": true,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Dome",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "4B8290F6D7A9",
"host": "10.10.0.12",
"connectionHost": null,
"type": "UA Intercom",
"name": "UI Intercom",
"upSince": 1737389973000,
"uptime": 135027140,
"lastSeen": 1737525000140,
"connectedSince": 1737390022000,
"state": "DISCONNECTED",
"lastDisconnect": 1737390740043,
"hardwareRevision": null,
"firmwareVersion": "1.5.30",
"latestFirmwareVersion": null,
"firmwareBuild": null,
"isUpdating": false,
"isDownloadingFW": false,
"fwUpdateState": "upToDate",
"isAdopting": false,
"isRestoring": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"uplinkDevice": null,
"guid": "a3cf3555-9a4d-487e-938e-0d2f2797909c",
"anonymousDeviceId": null,
"lastMotion": 1737373936750,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": false,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": null,
"hdrMode": false,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"apMgmtIp": null,
"elementInfo": null,
"chimeDuration": 0,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"eventStats": {
"motion": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
],
"recentHours": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"useGlobal": false,
"isPoorNetwork": false,
"stopStreamLevel": null,
"isWaterproofCaseAttached": false,
"userConfiguredAp": false,
"hasRecordings": false,
"isThirdPartyCamera": false,
"isPairedWithAiPort": false,
"isAdoptedByAccessApp": true,
"wiredConnectionState": {
"phyRate": null
},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"txRate": null,
"signalQuality": null,
"ssid": null,
"bssid": null,
"apName": null,
"experience": null,
"signalStrength": null,
"connectivity": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "High",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 1200,
"height": 1600,
"fps": 30,
"bitrate": 6000000,
"minBitrate": null,
"maxBitrate": null,
"minClientAdaptiveBitRate": null,
"minMotionAdaptiveBitRate": null,
"fpsValues": [
30
],
"idrInterval": 5,
"autoFps": false,
"autoBitrate": false
},
{
"id": 1,
"videoId": "video3",
"name": "Medium",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 960,
"height": 1280,
"fps": 30,
"bitrate": 1200000,
"minBitrate": null,
"maxBitrate": null,
"minClientAdaptiveBitRate": null,
"minMotionAdaptiveBitRate": null,
"fpsValues": [
30
],
"idrInterval": 5,
"autoFps": false,
"autoBitrate": false
},
{
"id": 2,
"videoId": "video2",
"name": "Low",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 360,
"height": 480,
"fps": 15,
"bitrate": 200000,
"minBitrate": null,
"maxBitrate": null,
"minClientAdaptiveBitRate": null,
"minMotionAdaptiveBitRate": null,
"fpsValues": [
15
],
"idrInterval": 5,
"autoFps": false,
"autoBitrate": false
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"icrSwitchMode": "sensitivity",
"icrCustomValue": 2,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isColorNightVisionEnabled": false,
"spotlightDuration": 15,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusPosition": 0,
"touchFocusX": null,
"touchFocusY": null,
"zoomPosition": 0,
"mountPosition": null,
"hdrMode": "normal"
},
"audioSettings": {
"style": [
"nature"
]
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "",
"bindAddr": "",
"bindPort": 8182,
"filterAddr": "",
"filterPort": 0,
"channels": 1,
"samplingRate": 48000,
"bitsPerSample": 16,
"quality": 0
},
"osdSettings": {
"isNameEnabled": false,
"isDateEnabled": false,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": true,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 5,
"postPaddingSecs": 5,
"smartDetectPrePaddingSecs": 2,
"smartDetectPostPaddingSecs": 2,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "never",
"inScheduleMode": "always",
"outScheduleMode": "never",
"geofencing": "off",
"retentionDurationMs": null,
"motionAlgorithm": "enhanced",
"enableMotionDetection": false,
"createAccessEvent": true,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [],
"autoTrackingObjectTypes": [],
"audioTypes": []
},
"recordingSchedulesV2": [],
"motionZones": [],
"privacyZones": [],
"smartDetectZones": [],
"smartDetectLines": [],
"stats": {
"rxBytes": 0,
"txBytes": 0,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"video": {
"recordingStart": null,
"recordingEnd": null,
"recordingStartLQ": null,
"recordingEndLQ": null,
"timelapseStart": null,
"timelapseEnd": null,
"timelapseStartLQ": null,
"timelapseEndLQ": null
},
"storage": {
"used": null
},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasVerticalFlip": true,
"hasAec": true,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasInfrared": true,
"hasLdc": false,
"hasLedIr": false,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": true,
"hasWifi": false,
"hasHdr": false,
"hasAutoICROnly": false,
"videoModes": [],
"videoModeMaxFps": [],
"hasMotionZones": true,
"hasLcdScreen": false,
"hasFingerprintSensor": false,
"mountPositions": [],
"smartDetectTypes": [],
"smartDetectAudioTypes": [],
"supportNfc": false,
"lensType": null,
"lensModel": null,
"motionAlgorithms": [],
"hasSquareEventThumbnail": false,
"hasPackageCamera": false,
"audio": [],
"audioCodecs": [
"aac",
"opus"
],
"audioStyle": [],
"isDoorbell": false,
"isPtz": false,
"hasColorLcdScreen": false,
"hasLiveviewTracking": false,
"hasLineCrossing": false,
"hasLineCrossingCounting": false,
"hasFlash": false,
"flashRange": null,
"privacyMaskCapability": {
"maxMasks": null,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 22,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hotplug": {
"audio": null,
"video": null,
"standaloneAdoption": false,
"extender": {
"isAttached": null,
"hasFlash": null,
"flashRange": null,
"hasIR": null,
"hasRadar": null
}
},
"hasSmartDetect": false
},
"lcdMessage": {},
"lenses": [],
"streamSharing": {
"enabled": false,
"token": null,
"shareLink": null,
"expires": null,
"sharedByUserId": null,
"sharedByUser": null,
"maxStreams": null
},
"homekitSettings": {
"talkbackSettingsActive": false,
"streamInProgress": false,
"microphoneMuted": false,
"speakerMuted": false
},
"id": "45235b20c37b2ff0abdc9d4d",
"nvrMac": "4B8290F6D7A3",
"isConnected": false,
"platform": null,
"hasSpeaker": true,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "UA Intercom",
"is4K": false,
"is2K": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "CE668E1CDADB",
"host": "192.168.11.58",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Doorbell",
"name": "Xfqf Fydhbh",
"upSince": 1642022726827,
"uptime": null,
"lastSeen": 1642023134515,
"connectedSince": null,
"state": "DISCONNECTED",
"hardwareRevision": "21",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1642023134518,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": false,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 135,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": 1642022756933,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "97323b0e-c723-4688-b382-177c83ab6d82",
"eventStats": {
"motion": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
],
"recentHours": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Oktg Brcmgt",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "d42VD60PnFGSWKAI",
"width": 1600,
"height": 1200,
"fps": 30,
"bitrate": 6000000,
"minBitrate": 32000,
"maxBitrate": 6000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Tgveb Xvipv",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "zJtlHLIysUUtrDFz",
"width": 960,
"height": 720,
"fps": 30,
"bitrate": 1200000,
"minBitrate": 32000,
"maxBitrate": 2000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Zqpexd Eryy",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 480,
"height": 360,
"fps": 30,
"bitrate": 200000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 200000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 0,
"icrSensitivity": 0,
"brightness": 51,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": "",
"filterPort": 0,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 100
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
0.639,
0
],
[
0.689,
0.554
],
[
0.854,
0.575
],
[
1,
0.601
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"person"
]
},
{
"id": 2,
"name": "Ihb Ses",
"color": "#FF0075",
"points": [
[
0,
0
],
[
0.389,
0
],
[
0.356,
0.533
],
[
0.648,
0.554
],
[
0.841,
0.578
],
[
1,
0.601
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 3607428,
"txBytes": 131235466,
"wifi": {
"channel": 153,
"frequency": 5765,
"linkSpeedMbps": null,
"signalQuality": 81,
"signalStrength": -71
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639183850698,
"recordingEnd": 1642023074549,
"recordingStartLQ": 1639183850689,
"recordingEndLQ": 1642023074607,
"timelapseStart": 1639183850646,
"timelapseEnd": 1642022576106,
"timelapseStartLQ": 1639183850646,
"timelapseEndLQ": 1642022576106
},
"storage": {},
"wifiQuality": 81,
"wifiStrength": -71
},
"featureFlags": {
"supportFullHdSnapshot": false,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasAec": true,
"hasBattery": false,
"hasBluetooth": true,
"hasChime": true,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": true,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": true,
"hasWifi": true,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
45
],
"hasMotionZones": true,
"hasLcdScreen": true,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {
"type": "CUSTOM_MESSAGE",
"text": "Welcome | 01:57 PM | 25\u00b0F",
"resetAt": null
},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "0777b5d342302079dc6b793d",
"isConnected": false,
"platform": "s5l",
"hasSpeaker": true,
"hasWifi": true,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Doorbell",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "A3ABB5ABE298",
"host": "192.168.23.22",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Bullet",
"name": "Ddxoxwb Ned",
"upSince": 1640292872771,
"uptime": null,
"lastSeen": 1640293400431,
"connectedSince": null,
"state": "DISCONNECTED",
"hardwareRevision": "5",
"firmwareVersion": "4.47.13",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "0a55423.211124.717",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1640290945306,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": false,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 1000,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "e12a9b6d-8a3b-411a-8ab8-34931517ed9d",
"eventStats": {
"motion": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
],
"recentHours": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Wrdvta Imi",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "GNYYX1qS1sEQMErd",
"width": 2688,
"height": 1512,
"fps": 24,
"bitrate": 10000000,
"minBitrate": 32000,
"maxBitrate": 10000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Ijje Uqsh",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "CGIMLNSphQQKZT4Z",
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 32000,
"maxBitrate": 3000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Tqc Jlwq",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 300000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 9642720,
"txBytes": 394685474,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639183865263,
"recordingEnd": 1640293360356,
"recordingStartLQ": 1639183865313,
"recordingEndLQ": 1640293375316,
"timelapseStart": 1639183865236,
"timelapseEnd": 1640293205314,
"timelapseStartLQ": 1639183865236,
"timelapseEndLQ": 1640290918216
},
"storage": {},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": false,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasAec": false,
"hasBattery": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": true,
"hasIcrSensitivity": true,
"hasLdc": true,
"hasLedIr": true,
"hasLedStatus": false,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
48
],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "56401f80d300adad7123c864",
"isConnected": false,
"platform": "s5l",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Bullet",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "331AFA68BE00",
"host": "192.168.10.22",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Doorbell Pro",
"name": "Mxxoxar Gyd",
"upSince": 1642431757906,
"uptime": 624002,
"lastSeen": 1643055759906,
"connectedSince": 1642431796280,
"state": "CONNECTED",
"hardwareRevision": "20",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": true,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643055545876,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 200,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": 1643055447318,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "0cc40649-3501-4755-a144-14cfd449efe5",
"eventStats": {
"motion": {
"today": 43,
"average": 69,
"lastDays": [
57,
59,
126,
59,
65,
64,
53
],
"recentHours": [
4,
2,
6,
5,
1,
0,
6,
4,
1,
5,
5,
0,
3
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": 27,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Evv Acjhsdh",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "OfJowIFFUBaYAtah",
"width": 1600,
"height": 1200,
"fps": 30,
"bitrate": 6000000,
"minBitrate": 32000,
"maxBitrate": 6000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Frjg Ciu",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 960,
"height": 720,
"fps": 30,
"bitrate": 1200000,
"minBitrate": 32000,
"maxBitrate": 2000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Huvqtqm Tbpibvh",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 480,
"height": 360,
"fps": 30,
"bitrate": 200000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 200000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 3,
"videoId": "video4",
"name": "Ldmm Hnqdcg",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "VWJGXSBiRS7WbQGL",
"width": 1600,
"height": 1200,
"fps": 2,
"bitrate": 1000000,
"minBitrate": 32000,
"maxBitrate": 2000000,
"minClientAdaptiveBitRate": null,
"minMotionAdaptiveBitRate": null,
"fpsValues": [
1,
2
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": "",
"filterPort": 0,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Plbxuwl Xlypwre",
"color": "#AB46BC",
"points": [
[
0.569,
0
],
[
0.604,
0.537
],
[
1,
0.565
],
[
1,
1
],
[
0,
1
],
[
0,
0
],
[
0.276,
0
]
],
"sensitivity": 50,
"objectTypes": [
"person"
]
},
{
"id": 2,
"name": "Oazunxs Ibxw",
"color": "#586CED",
"points": [
[
0,
0
],
[
0.369,
0
],
[
0.358,
0.543
],
[
1,
0.531
],
[
1,
1
],
[
0,
0.978
]
],
"sensitivity": 50,
"objectTypes": [
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 7916684198,
"txBytes": 270419791080,
"wifi": {
"channel": 153,
"frequency": 5765,
"linkSpeedMbps": null,
"signalQuality": 100,
"signalStrength": -52
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1641921049599,
"recordingEnd": 1643055770699,
"recordingStartLQ": 1641921049574,
"recordingEndLQ": 1643055785757,
"timelapseStart": 1641921049594,
"timelapseEnd": 1643055460715,
"timelapseStartLQ": 1641921049594,
"timelapseEndLQ": 1643055520725
},
"storage": {
"used": 427349245952,
"rate": 642.30288184191
},
"wifiQuality": 100,
"wifiStrength": -52
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasAec": true,
"hasBattery": false,
"hasBluetooth": true,
"hasChime": true,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": true,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": true,
"hasWifi": true,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default"
],
"videoModeMaxFps": [],
"hasMotionZones": true,
"hasLcdScreen": true,
"hasFingerprintSensor": true,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"supportNfc": true,
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": true,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {
"type": "CUSTOM_MESSAGE",
"text": "Welcome | 03:23 PM | 25\u00b0F",
"resetAt": null
},
"wifiConnectionState": {
"channel": 153,
"frequency": 5765,
"phyRate": 200,
"signalQuality": 100,
"signalStrength": -52,
"ssid": "Mortis Camera"
},
"lenses": [
{
"id": 2,
"video": {
"recordingStart": 1642357077629,
"recordingEnd": 1643055747396,
"recordingStartLQ": null,
"recordingEndLQ": null,
"timelapseStart": 1641921049317,
"timelapseEnd": 1643055438229,
"timelapseStartLQ": null,
"timelapseEndLQ": null
}
}
],
"id": "1c9a2db4df6efda47a3509be",
"isConnected": true,
"platform": "s5l",
"hasSpeaker": true,
"hasWifi": true,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Doorbell Pro",
"isPairedWithAiPort": true,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "C1E5752D2D9B",
"host": "192.168.16.186",
"connectionHost": "192.168.102.63",
"type": "UVC AI Bullet",
"name": "Dheril Fsx",
"upSince": 1641929381901,
"uptime": 1126378,
"lastSeen": 1643055759901,
"connectedSince": 1642896021779,
"state": "CONNECTED",
"hardwareRevision": "3",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1820",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643055575971,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 1000,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 0,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "dbb18c8b-a7f1-4759-905f-e5b52e9a614d",
"eventStats": {
"motion": {
"today": 22,
"average": 18,
"lastDays": [
36,
6,
0,
3,
3,
36,
44
],
"recentHours": [
3,
1,
12,
0,
1,
0,
5,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": 1000
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Wlixnbo Nykk",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "AZWxWsX2PSEMI6d2",
"width": 2688,
"height": 1512,
"fps": 30,
"bitrate": 10000000,
"minBitrate": 32000,
"maxBitrate": 10000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Jilbyey Dtdgebd",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 1280,
"height": 720,
"fps": 30,
"bitrate": 2000000,
"minBitrate": 32000,
"maxBitrate": 3000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Hevuxf Sre",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 30,
"bitrate": 300000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": true,
"isFlippedHorizontal": true,
"isAutoRotateEnabled": true,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": "wall"
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 21294840858,
"txBytes": 1002784215600,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1640288743861,
"recordingEnd": 1643055770802,
"recordingStartLQ": 1640288744338,
"recordingEndLQ": 1643055785778,
"timelapseStart": 1640288743858,
"timelapseEnd": 1643055480800,
"timelapseStartLQ": 1640288743858,
"timelapseEndLQ": 1643055095797
},
"storage": {
"used": 1987496116224,
"rate": 708.231920183599
},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": true,
"hasAec": false,
"hasBattery": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": true,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
30,
50
],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "586ab7c2bb6423c3fdd47e95",
"isConnected": true,
"platform": "cv2x",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "AI Bullet",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "49512F7958AE",
"host": "192.168.127.173",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Doorbell",
"name": "Ftjj Bjbx",
"upSince": 1642431757906,
"uptime": 624002,
"lastSeen": 1643055759906,
"connectedSince": 1642431800250,
"state": "CONNECTED",
"hardwareRevision": "22",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643055542532,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 43,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": 1641571914529,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "3e29e07a-13c2-4514-b610-8376df41c2ad",
"eventStats": {
"motion": {
"today": 166,
"average": 363,
"lastDays": [
272,
397,
479,
350,
326,
415,
307
],
"recentHours": [
13,
32,
14,
18,
15,
7,
27,
17,
6,
4,
6,
2,
3
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": 25.9,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Hvx Zle",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "CLsJTPVhWOq4OZQa",
"width": 1600,
"height": 1200,
"fps": 30,
"bitrate": 3000000,
"minBitrate": 32000,
"maxBitrate": 6000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Nckpktj Mvqte",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 960,
"height": 720,
"fps": 30,
"bitrate": 1200000,
"minBitrate": 32000,
"maxBitrate": 2000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Gubbdb Tvvy",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 480,
"height": 360,
"fps": 30,
"bitrate": 200000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 200000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": "",
"filterPort": 0,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0.575,
0.451
],
[
1,
0
],
[
1,
1
],
[
0,
1
],
[
0,
0.53
],
[
0.487,
0.535
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 4499323888,
"txBytes": 145588953564,
"wifi": {
"channel": 6,
"frequency": 2437,
"linkSpeedMbps": null,
"signalQuality": 100,
"signalStrength": -62
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1640308840567,
"recordingEnd": 1643055771020,
"recordingStartLQ": 1640308840563,
"recordingEndLQ": 1643055786085,
"timelapseStart": 1640308840533,
"timelapseEnd": 1643055521005,
"timelapseStartLQ": 1640308840533,
"timelapseEndLQ": 1643055146038
},
"storage": {
"used": 747324309504,
"rate": 342.122831820542
},
"wifiQuality": 100,
"wifiStrength": -62
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasAec": true,
"hasBattery": false,
"hasBluetooth": true,
"hasChime": true,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": true,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": true,
"hasWifi": true,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
45
],
"hasMotionZones": true,
"hasLcdScreen": true,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {
"type": "CUSTOM_MESSAGE",
"text": "Use Other Door | 25\u00b0F",
"resetAt": null
},
"wifiConnectionState": {
"channel": 6,
"frequency": 2437,
"phyRate": 43,
"signalQuality": 100,
"signalStrength": -62,
"ssid": "Mortis Camera"
},
"lenses": [],
"id": "e2ff0ade6be0f2a2beb61869",
"isConnected": true,
"platform": "s5l",
"hasSpeaker": true,
"hasWifi": true,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Doorbell",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "CF0C20B53D5E",
"host": "192.168.222.251",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Pro",
"name": "Yzlrc Duo",
"upSince": 1641929375904,
"uptime": 1126384,
"lastSeen": 1643055759904,
"connectedSince": 1643053361454,
"state": "CONNECTED",
"hardwareRevision": "7",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643038873769,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 1000,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "",
"eventStats": {
"motion": {
"today": 1,
"average": 6,
"lastDays": [
7,
5,
5,
4,
11,
2,
8
],
"recentHours": [
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": 1000
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Byrhmtr Oklylbv",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "NSRPJJY4ePQUVZfZ",
"width": 3840,
"height": 2160,
"fps": 24,
"bitrate": 16000000,
"minBitrate": 32000,
"maxBitrate": 16000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Abxb Nmhhzd",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "VEFUSSOvpL1KVRSj",
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 32000,
"maxBitrate": 4000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Mkoo Jbzf",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 300000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 2,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": false,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0.138
],
[
0.466,
0.07
],
[
0.601,
0.148
],
[
1,
0.456
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 18763321757,
"txBytes": 700072880391,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639183852977,
"recordingEnd": 1643055770002,
"recordingStartLQ": 1639183852992,
"recordingEndLQ": 1643055785014,
"timelapseStart": 1639183852941,
"timelapseEnd": 1643055599988,
"timelapseStartLQ": 1639183852941,
"timelapseEndLQ": 1643055105008
},
"storage": {
"used": 1603096543232,
"rate": 415.108794037914
},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": true,
"canMagicZoom": false,
"canOpticalZoom": true,
"canTouchFocus": true,
"hasAccelerometer": false,
"hasAec": false,
"hasBattery": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": false,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
50
],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "f0cd15b8bed9e38899286a8c",
"isConnected": true,
"platform": "s5l",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Pro",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "D3BA2C5D0032",
"host": "192.168.6.142",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Pro",
"name": "Nvs Bwd",
"upSince": 1641929376903,
"uptime": 1126383,
"lastSeen": 1643055759903,
"connectedSince": 1643053372503,
"state": "CONNECTED",
"hardwareRevision": "7",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1642803682789,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 1000,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "36ad9efc-8b8b-4f47-815e-14f65a46a6e3",
"eventStats": {
"motion": {
"today": 0,
"average": 1,
"lastDays": [
0,
0,
2,
0,
0,
0,
11
],
"recentHours": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": 1000
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Ncfv Xvxtzgd",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "lKaCXTMDTCJQQKum",
"width": 3840,
"height": 2160,
"fps": 24,
"bitrate": 16000000,
"minBitrate": 32000,
"maxBitrate": 16000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Jbsr Fbqsp",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "OpbJXC6TNvQNIXYN",
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 32000,
"maxBitrate": 4000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Ske Yea",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 300000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 2,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": false,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0.453
],
[
0.682,
0
],
[
0.844,
0
],
[
0.708,
0.431
],
[
0.468,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 17442683098,
"txBytes": 653254998457,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639183853280,
"recordingEnd": 1643055771104,
"recordingStartLQ": 1639183853258,
"recordingEndLQ": 1643055786019,
"timelapseStart": 1639183853279,
"timelapseEnd": 1643055611101,
"timelapseStartLQ": 1639183853279,
"timelapseEndLQ": 1643055116058
},
"storage": {
"used": 1471026298880,
"rate": 335.261879469184
},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": true,
"canMagicZoom": false,
"canOpticalZoom": true,
"canTouchFocus": true,
"hasAccelerometer": false,
"hasAec": false,
"hasBattery": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": false,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
50
],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "ab3e27f2d55fad817dac7bb9",
"isConnected": true,
"platform": "s5l",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Pro",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "25546150ED3E",
"host": "192.168.3.158",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Pro",
"name": "Zycyj Vxvp",
"upSince": 1641929377903,
"uptime": 1126382,
"lastSeen": 1643055759903,
"connectedSince": 1643053361126,
"state": "CONNECTED",
"hardwareRevision": "7",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": true,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643008086650,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 1000,
"hdrMode": false,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "c370d035-8003-4b70-85e9-ba5f460cac09",
"eventStats": {
"motion": {
"today": 1,
"average": 5,
"lastDays": [
0,
6,
2,
1,
1,
6,
20
],
"recentHours": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": 1000
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Txsfih Lvjpxxn",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "gXCHWI1VITVZ9PwY",
"width": 3840,
"height": 2160,
"fps": 24,
"bitrate": 16000000,
"minBitrate": 32000,
"maxBitrate": 16000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Rcidfx Iedpmai",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "PWK9O5JVX0VnVGBg",
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 32000,
"maxBitrate": 4000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Jtrh Mij",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 300000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 3,
"icrSensitivity": 1,
"brightness": 50,
"contrast": 54,
"hue": 52,
"saturation": 51,
"sharpness": 50,
"denoise": 54,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "touch",
"focusPosition": 0,
"touchFocusX": 865,
"touchFocusY": 213,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": false,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "adaptive",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0.201,
0
],
[
1,
0
],
[
1,
1
],
[
0.414,
1
],
[
0.293,
0.573
],
[
0.444,
0.478
],
[
0.432,
0.363
],
[
0.277,
0.456
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 27521967465,
"txBytes": 1497713862841,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639183852998,
"recordingEnd": 1643055750205,
"recordingStartLQ": 1639183853004,
"recordingEndLQ": 1643055780152,
"timelapseStart": 1639183852995,
"timelapseEnd": 1643055600214,
"timelapseStartLQ": 1639183852995,
"timelapseEndLQ": 1643055100356
},
"storage": {
"used": 3005403365376,
"rate": 832.407819806798
},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": true,
"canMagicZoom": false,
"canOpticalZoom": true,
"canTouchFocus": true,
"hasAccelerometer": false,
"hasAec": false,
"hasBattery": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": false,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
50
],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "4a333d993fe8e2e8472bc901",
"isConnected": true,
"platform": "s5l",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Pro",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "3F9EDAD1F6C4",
"host": "192.168.29.88",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Pro",
"name": "Fxdmvtc Gdo",
"upSince": 1641929376904,
"uptime": 1126383,
"lastSeen": 1643055759904,
"connectedSince": 1642537084909,
"state": "CONNECTED",
"hardwareRevision": "10",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643055447656,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 1000,
"hdrMode": false,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "242b87f0-f0e1-402e-a48a-2a0b5a16d850",
"eventStats": {
"motion": {
"today": 88,
"average": 160,
"lastDays": [
156,
105,
202,
171,
131,
173,
187
],
"recentHours": [
3,
10,
14,
6,
9,
0,
9,
6,
9,
9,
6,
2,
2
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": 1000
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Lwp Issyqxp",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "ZAOBJFOXCUNAPQEz",
"width": 3840,
"height": 2160,
"fps": 24,
"bitrate": 16000000,
"minBitrate": 32000,
"maxBitrate": 16000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Xedhihw Ryokoqa",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "honlCJQSGucYfRfX",
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 32000,
"maxBitrate": 4000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Ldgqgxy Ouduc",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 300000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "autoFilterOnly",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 52,
"sharpness": 51,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "touch",
"focusPosition": 0,
"touchFocusX": 437,
"touchFocusY": 0,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": false,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0.318
],
[
0.21,
0.158
],
[
0.435,
0.055
],
[
0.638,
0.07
],
[
1,
0.203
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 28446700467,
"txBytes": 1583875810916,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639183852906,
"recordingEnd": 1643055755100,
"recordingStartLQ": 1639183852911,
"recordingEndLQ": 1643055784968,
"timelapseStart": 1639183852888,
"timelapseEnd": 1643055575165,
"timelapseStartLQ": 1639183852888,
"timelapseEndLQ": 1643055285277
},
"storage": {
"used": 5012226834432,
"rate": 1753.16626544334
},
"wifiQuality": 50,
"wifiStrength": 0
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": true,
"canMagicZoom": false,
"canOpticalZoom": true,
"canTouchFocus": true,
"hasAccelerometer": false,
"hasAec": false,
"hasBattery": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": false,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default",
"highFps"
],
"videoModeMaxFps": [
24,
50
],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": null,
"signalStrength": null,
"ssid": null
},
"lenses": [],
"id": "c462c07dbd63ad805a7318c7",
"isConnected": true,
"platform": "s5l",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Pro",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
},
{
"isDeleting": false,
"mac": "2BE358A7CE14",
"host": "192.168.186.25",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Instant",
"name": "Eohx Mrkciz",
"upSince": 1642456952904,
"uptime": 598807,
"lastSeen": 1643055759904,
"connectedSince": 1642456986161,
"state": "CONNECTED",
"hardwareRevision": "11",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1757",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643055573501,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": false,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": 200,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 0,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "857fa0fd-4a74-4212-8b75-f9ce40589250",
"eventStats": {
"motion": {
"today": 43,
"average": 51,
"lastDays": [
75,
39,
36,
43,
43,
90,
35
],
"recentHours": [
9,
0,
8,
2,
8,
1,
6,
1,
0,
0,
1,
0,
0
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"isPoorNetwork": false,
"wiredConnectionState": {
"phyRate": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Ajkq Ygq",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "ybEZtAOEnPBFDHBN",
"width": 2688,
"height": 1512,
"fps": 30,
"bitrate": 10000000,
"minBitrate": 32000,
"maxBitrate": 10000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video2",
"name": "Etjo Bfa",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "66XxksASK5XGRKEM",
"width": 1280,
"height": 720,
"fps": 30,
"bitrate": 1500000,
"minBitrate": 32000,
"maxBitrate": 2000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video3",
"name": "Frmeln Grau",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 30,
"bitrate": 200000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 200000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": true,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": "wall"
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": "",
"filterPort": 0,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": false,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 100
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "detections",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": []
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50,
"objectTypes": []
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 8155758338,
"txBytes": 346703002345,
"wifi": {
"channel": 153,
"frequency": 5765,
"linkSpeedMbps": null,
"signalQuality": 100,
"signalStrength": -45
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1639219284079,
"recordingEnd": 1643055573528,
"recordingStartLQ": 1639219283987,
"recordingEndLQ": 1643055574508,
"timelapseStart": 1639219284030,
"timelapseEnd": 1643055513560,
"timelapseStartLQ": 1639219284030,
"timelapseEndLQ": 1643055533528
},
"storage": {
"used": 39728447488,
"rate": 13.3032332410829
},
"wifiQuality": 100,
"wifiStrength": -45
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": true,
"hasAec": true,
"hasBattery": false,
"hasBluetooth": true,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": false,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": true,
"hasWifi": true,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default"
],
"videoModeMaxFps": [],
"hasMotionZones": true,
"hasLcdScreen": false,
"mountPositions": [],
"smartDetectTypes": [],
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"privacyMaskCapability": {
"maxMasks": 4,
"rectangleOnly": true
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": false
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {},
"wifiConnectionState": {
"channel": 153,
"frequency": 5765,
"phyRate": 200,
"signalQuality": 100,
"signalStrength": -45,
"ssid": "Mortis Camera"
},
"lenses": [],
"id": "1ee3895eb4ef2170046f9f2c",
"isConnected": true,
"platform": "sav530q",
"hasSpeaker": true,
"hasWifi": true,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Instant",
"isPairedWithAiPort": false,
"modelKey": "camera",
"activePatrolSlot": null
}
],
"users": [
{
"permissions": [],
"lastLoginIp": null,
"lastLoginTime": null,
"isOwner": true,
"enableNotifications": false,
"settings": {
"flags": {}
},
"groups": [
"b061186823695fb901973177"
],
"alertRules": [],
"notificationsV2": {
"state": "custom",
"motionNotifications": {
"trigger": {
"when": "inherit",
"location": "away",
"schedules": []
},
"cameras": [
{
"inheritFromParent": true,
"motion": [],
"person": [],
"vehicle": [],
"camera": "61b3f5c7033ea703e7000424",
"trigger": {
"when": "always",
"location": "away",
"schedules": []
}
}
],
"doorbells": [],
"lights": [],
"doorlocks": [],
"sensors": []
},
"systemNotifications": {}
},
"featureFlags": {
"notificationsV2": true
},
"id": "fe4c12ae2c1348edb7854e2f",
"hasAcceptedInvite": true,
"allPermissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"nvr:write,delete:*",
"group:create,read,write,delete:*",
"user:create,read,write,delete:*",
"schedule:create,read,write,delete:*",
"legacyUFV:read,write,delete:*",
"bridge:create,read,write,delete:*",
"camera:create,read,write,delete,readmedia,readlive,deletemedia:*",
"light:create,read,write,delete:*",
"sensor:create,read,write,delete:*",
"doorlock:create,read,write,delete:*",
"viewer:create,read,write,delete:*",
"display:create,read,write,delete:*",
"chime:create,read,write,delete:*"
],
"cloudAccount": {
"firstName": "Qpvfly",
"lastName": "Ikjzilt",
"email": "QhoFvCv@example.com",
"profileImg": null,
"user": "fe4c12ae2c1348edb7854e2f",
"id": "9efc4511-4539-4402-9581-51cee8b65cf5",
"cloudId": "9efc4511-4539-4402-9581-51cee8b65cf5",
"name": "Qpvfly Ikjzilt",
"modelKey": "cloudIdentity"
},
"name": "Qpvfly Ikjzilt",
"firstName": "Qpvfly",
"lastName": "Ikjzilt",
"email": "QhoFvCv@example.com",
"localUsername": "QhoFvCv",
"modelKey": "user"
},
{
"permissions": [],
"lastLoginIp": null,
"lastLoginTime": null,
"isOwner": false,
"enableNotifications": false,
"settings": null,
"groups": [
"a7f3b2eb71b4c4e56f1f45ac",
"b061186823695fb901973177"
],
"alertRules": [],
"notificationsV2": {
"state": "auto",
"motionNotifications": {
"trigger": {
"when": "inherit",
"location": "away",
"schedules": []
},
"cameras": [],
"doorbells": [],
"lights": [],
"doorlocks": [],
"sensors": []
},
"systemNotifications": {}
},
"featureFlags": {
"notificationsV2": true
},
"id": "dcaef9cb8aed05c7db658a46",
"hasAcceptedInvite": false,
"allPermissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"bridge:read:*",
"camera:read,readmedia,readlive:*",
"doorlock:read:*",
"light:read:*",
"sensor:read:*",
"viewer:read:*",
"display:read:*",
"chime:read:*",
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"nvr:write,delete:*",
"group:create,read,write,delete:*",
"user:create,read,write,delete:*",
"schedule:create,read,write,delete:*",
"legacyUFV:read,write,delete:*",
"bridge:create,read,write,delete:*",
"camera:create,read,write,delete,readmedia,readlive,deletemedia:*",
"light:create,read,write,delete:*",
"sensor:create,read,write,delete:*",
"doorlock:create,read,write,delete:*",
"viewer:create,read,write,delete:*",
"display:create,read,write,delete:*",
"chime:create,read,write,delete:*"
],
"cloudAccount": null,
"name": "Uxqg Wcbz",
"firstName": "Uxqg",
"lastName": "Wcbz",
"email": "epHDEhE@example.com",
"localUsername": "epHDEhE",
"modelKey": "user"
},
{
"permissions": [
"liveview:*:d65bb41c14d6aa92bfa4a6d1",
"liveview:*:49bbb5005424a0d35152671a",
"liveview:*:b28c38f1220f6b43f3930dff",
"liveview:*:b9861b533a87ea639fa4d438"
],
"lastLoginIp": null,
"lastLoginTime": null,
"isOwner": false,
"enableNotifications": false,
"settings": {
"flags": {},
"web": {
"dewarp": {
"61ddb66b018e2703e7008c19": {
"dewarp": false,
"state": {
"pan": 0,
"tilt": -1.5707963267948966,
"zoom": 1.5707963267948966,
"panning": 0,
"tilting": 0
}
}
},
"liveview.includeGlobal": true,
"elements.events_viewmode": "grid",
"elements.viewmode": "list"
}
},
"groups": [
"b061186823695fb901973177"
],
"location": {
"isAway": true,
"latitude": null,
"longitude": null
},
"alertRules": [],
"notificationsV2": {
"state": "custom",
"motionNotifications": {
"trigger": {
"when": "inherit",
"location": "away",
"schedules": []
},
"cameras": [
{
"inheritFromParent": true,
"motion": [],
"camera": "61b3f5c703d2a703e7000427",
"trigger": {
"when": "always",
"location": "away",
"schedules": []
}
},
{
"inheritFromParent": true,
"motion": [],
"person": [],
"vehicle": [],
"camera": "61b3f5c7033ea703e7000424",
"trigger": {
"when": "always",
"location": "away",
"schedules": []
}
}
],
"doorbells": [],
"lights": [],
"doorlocks": [],
"sensors": []
},
"systemNotifications": {}
},
"featureFlags": {
"notificationsV2": true
},
"id": "4c5f03a8c8bd48ad8e066285",
"hasAcceptedInvite": false,
"allPermissions": [
"liveview:*:d65bb41c14d6aa92bfa4a6d1",
"liveview:*:49bbb5005424a0d35152671a",
"liveview:*:b28c38f1220f6b43f3930dff",
"liveview:*:b9861b533a87ea639fa4d438",
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"nvr:write,delete:*",
"group:create,read,write,delete:*",
"user:create,read,write,delete:*",
"schedule:create,read,write,delete:*",
"legacyUFV:read,write,delete:*",
"bridge:create,read,write,delete:*",
"camera:create,read,write,delete,readmedia,readlive,deletemedia:*",
"light:create,read,write,delete:*",
"sensor:create,read,write,delete:*",
"doorlock:create,read,write,delete:*",
"viewer:create,read,write,delete:*",
"display:create,read,write,delete:*",
"chime:create,read,write,delete:*"
],
"cloudAccount": null,
"name": "Ptcmsdo Tfiyoep",
"firstName": "Ptcmsdo",
"lastName": "Tfiyoep",
"email": "EQAoXL@example.com",
"localUsername": "EQAoXL",
"modelKey": "user"
},
{
"permissions": [],
"lastLoginIp": null,
"lastLoginTime": null,
"isOwner": false,
"enableNotifications": false,
"settings": {
"flags": {},
"web": {
"dewarp": {
"61c4d1db02c82a03e700429c": {
"dewarp": false,
"state": {
"pan": 0,
"tilt": 0,
"zoom": 1.5707963267948966,
"panning": 0,
"tilting": 0
}
}
},
"liveview.includeGlobal": true
}
},
"groups": [
"a7f3b2eb71b4c4e56f1f45ac"
],
"alertRules": [],
"notificationsV2": {
"state": "auto",
"motionNotifications": {
"trigger": {
"when": "inherit",
"location": "away",
"schedules": []
},
"cameras": [],
"doorbells": [],
"lights": [],
"doorlocks": [],
"sensors": []
},
"systemNotifications": {}
},
"featureFlags": {
"notificationsV2": true
},
"id": "bc3dd633553907952a6fe20d",
"hasAcceptedInvite": false,
"allPermissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"bridge:read:*",
"camera:read,readmedia:*",
"doorlock:read:*",
"light:read:*",
"sensor:read:*",
"viewer:read:*",
"display:read:*",
"chime:read:*"
],
"cloudAccount": null,
"name": "Evdxou Zgyv",
"firstName": "Evdxou",
"lastName": "Zgyv",
"email": "FMZuD@example.com",
"localUsername": "FMZuD",
"modelKey": "user"
},
{
"permissions": [],
"lastLoginIp": null,
"lastLoginTime": null,
"isOwner": false,
"enableNotifications": false,
"settings": null,
"groups": [
"a7f3b2eb71b4c4e56f1f45ac",
"b061186823695fb901973177"
],
"alertRules": [],
"notificationsV2": {
"state": "auto",
"motionNotifications": {
"trigger": {
"when": "inherit",
"location": "away",
"schedules": []
},
"cameras": [],
"doorbells": [],
"lights": [],
"doorlocks": [],
"sensors": []
},
"systemNotifications": {}
},
"featureFlags": {
"notificationsV2": true
},
"id": "adec5334b69f56f6a6c47520",
"hasAcceptedInvite": false,
"allPermissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"bridge:read:*",
"camera:read,readmedia:*",
"doorlock:read:*",
"light:read:*",
"sensor:read:*",
"viewer:read:*",
"display:read:*",
"chime:read:*",
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"nvr:write,delete:*",
"group:create,read,write,delete:*",
"user:create,read,write,delete:*",
"schedule:create,read,write,delete:*",
"legacyUFV:read,write,delete:*",
"bridge:create,read,write,delete:*",
"camera:create,read,write,delete,readmedia,deletemedia:*",
"light:create,read,write,delete:*",
"sensor:create,read,write,delete:*",
"doorlock:create,read,write,delete:*",
"viewer:create,read,write,delete:*",
"display:create,read,write,delete:*",
"chime:create,read,write,delete:*"
],
"cloudAccount": null,
"name": "Qpv Elqfgq",
"firstName": "Qpv",
"lastName": "Elqfgq",
"email": "xdr@example.com",
"localUsername": "xdr",
"modelKey": "user"
},
{
"permissions": [],
"lastLoginIp": null,
"lastLoginTime": null,
"isOwner": false,
"enableNotifications": false,
"settings": null,
"groups": [
"a7f3b2eb71b4c4e56f1f45ac",
"b061186823695fb901973177"
],
"alertRules": [],
"notificationsV2": {
"state": "auto",
"motionNotifications": {
"trigger": {
"when": "inherit",
"location": "away",
"schedules": []
},
"cameras": [],
"doorbells": [],
"lights": [],
"doorlocks": [],
"sensors": []
},
"systemNotifications": {}
},
"featureFlags": {
"notificationsV2": true
},
"id": "8593657a25b7826a4288b6af",
"hasAcceptedInvite": false,
"allPermissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"bridge:read:*",
"camera:read,readmedia:*",
"doorlock:read:*",
"light:read:*",
"sensor:read:*",
"viewer:read:*",
"display:read:*",
"chime:read:*",
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"nvr:write,delete:*",
"group:create,read,write,delete:*",
"user:create,read,write,delete:*",
"schedule:create,read,write,delete:*",
"legacyUFV:read,write,delete:*",
"bridge:create,read,write,delete:*",
"camera:create,read,write,delete,readmedia,deletemedia:*",
"light:create,read,write,delete:*",
"sensor:create,read,write,delete:*",
"doorlock:create,read,write,delete:*",
"viewer:create,read,write,delete:*",
"display:create,read,write,delete:*",
"chime:create,read,write,delete:*"
],
"cloudAccount": null,
"name": "Sgpy Ooevsme",
"firstName": "Sgpy",
"lastName": "Ooevsme",
"email": "WQJNT@example.com",
"localUsername": "WQJNT",
"modelKey": "user"
},
{
"permissions": [],
"isOwner": false,
"enableNotifications": false,
"groups": [
"a7f3b2eb71b4c4e56f1f45ac"
],
"alertRules": [],
"notificationsV2": {
"state": "off",
"motionNotifications": {
"trigger": {
"when": "inherit",
"location": "away",
"schedules": []
},
"cameras": [],
"doorbells": [],
"lights": [],
"doorlocks": [],
"sensors": []
},
"systemNotifications": {}
},
"featureFlags": {
"notificationsV2": true
},
"id": "abf647aed3650a781ceba13f",
"hasAcceptedInvite": false,
"allPermissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"bridge:read:*",
"camera:read,readmedia:*",
"doorlock:read:*",
"light:read:*",
"sensor:read:*",
"viewer:read:*",
"display:read:*",
"chime:read:*"
],
"cloudAccount": null,
"name": "Yiiyq Glx",
"firstName": "Yiiyq",
"lastName": "Glx",
"email": "fBjmm@example.com",
"localUsername": "fBjmm",
"modelKey": "user"
}
],
"groups": [
{
"name": "Kubw Xnbb",
"permissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"nvr:write,delete:*",
"group:create,read,write,delete:*",
"user:create,read,write,delete:*",
"schedule:create,read,write,delete:*",
"legacyUFV:read,write,delete:*",
"bridge:create,read,write,delete:*",
"camera:create,read,write,delete,readmedia,deletemedia:*",
"light:create,read,write,delete:*",
"sensor:create,read,write,delete:*",
"doorlock:create,read,write,delete:*",
"viewer:create,read,write,delete:*",
"display:create,read,write,delete:*",
"chime:create,read,write,delete:*"
],
"type": "preset",
"isDefault": true,
"id": "b061186823695fb901973177",
"modelKey": "group"
},
{
"name": "Pmbrvp Wyzqs",
"permissions": [
"nvr:read:*",
"liveview:create",
"user:read,write,delete:$",
"bridge:read:*",
"camera:read,readmedia:*",
"doorlock:read:*",
"light:read:*",
"sensor:read:*",
"viewer:read:*",
"display:read:*",
"chime:read:*"
],
"type": "preset",
"isDefault": false,
"id": "a7f3b2eb71b4c4e56f1f45ac",
"modelKey": "group"
}
],
"liveviews": [
{
"name": "Default",
"isDefault": true,
"isGlobal": true,
"layout": 12,
"slots": [
{
"cameras": [
"4a333d993fe8e2e8472bc901"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"1ee3895eb4ef2170046f9f2c"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"0777b5d342302079dc6b793d"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"c462c07dbd63ad805a7318c7"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"56401f80d300adad7123c864"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"76227b20c37b2ff0abdc9d4d"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"1c9a2db4df6efda47a3509be"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"586ab7c2bb6423c3fdd47e95"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"e2ff0ade6be0f2a2beb61869"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"ab3e27f2d55fad817dac7bb9"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"f0cd15b8bed9e38899286a8c"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [],
"cycleMode": "time",
"cycleInterval": 10
}
],
"owner": "fe4c12ae2c1348edb7854e2f",
"id": "bf41f6b5ba0ddd046eeb1c98",
"modelKey": "liveview"
},
{
"name": "Yisz Ovrhoka",
"isDefault": false,
"isGlobal": true,
"layout": 1,
"slots": [
{
"cameras": [
"1c9a2db4df6efda47a3509be",
"e2ff0ade6be0f2a2beb61869",
"c462c07dbd63ad805a7318c7",
"4a333d993fe8e2e8472bc901",
"ab3e27f2d55fad817dac7bb9",
"f0cd15b8bed9e38899286a8c"
],
"cycleMode": "motion",
"cycleInterval": 10
}
],
"owner": "4c5f03a8c8bd48ad8e066285",
"id": "d65bb41c14d6aa92bfa4a6d1",
"modelKey": "liveview"
},
{
"name": "Ozbz Qle",
"isDefault": false,
"isGlobal": true,
"layout": 1,
"slots": [
{
"cameras": [
"1c9a2db4df6efda47a3509be"
],
"cycleMode": "time",
"cycleInterval": 10
}
],
"owner": "4c5f03a8c8bd48ad8e066285",
"id": "b9861b533a87ea639fa4d438",
"modelKey": "liveview"
},
{
"name": "Axehzr Dhxebqg",
"isDefault": false,
"isGlobal": true,
"layout": 4,
"slots": [
{
"cameras": [
"1c9a2db4df6efda47a3509be"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"e2ff0ade6be0f2a2beb61869"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"c462c07dbd63ad805a7318c7"
],
"cycleMode": "time",
"cycleInterval": 10
},
{
"cameras": [
"586ab7c2bb6423c3fdd47e95"
],
"cycleMode": "time",
"cycleInterval": 10
}
],
"owner": "4c5f03a8c8bd48ad8e066285",
"id": "49bbb5005424a0d35152671a",
"modelKey": "liveview"
},
{
"name": "Jbrkb Urqq",
"isDefault": false,
"isGlobal": true,
"layout": 1,
"slots": [
{
"cameras": [
"1ee3895eb4ef2170046f9f2c"
],
"cycleMode": "time",
"cycleInterval": 10
}
],
"owner": "4c5f03a8c8bd48ad8e066285",
"id": "b28c38f1220f6b43f3930dff",
"modelKey": "liveview"
}
],
"schedules": [],
"nvr": {
"mac": "4B8290F6D7A3",
"host": "192.168.102.63",
"name": "Uiiji Ryoyo",
"canAutoUpdate": true,
"isStatsGatheringEnabled": true,
"timezone": "America/New_York",
"version": "1.21.0-beta.3",
"ucoreVersion": "2.3.26",
"firmwareVersion": "2.3.10",
"uiVersion": null,
"hardwarePlatform": "al324",
"ports": {
"ump": 7449,
"http": 7080,
"https": 7443,
"rtsp": 7447,
"rtsps": 7441,
"rtmp": 1935,
"devicesWss": 7442,
"cameraHttps": 7444,
"cameraTcp": 7877,
"liveWs": 7445,
"liveWss": 7446,
"tcpStreams": 7448,
"playback": 7450,
"emsCLI": 7440,
"emsLiveFLV": 7550,
"cameraEvents": 7551,
"tcpBridge": 7888,
"ucore": 11081,
"discoveryClient": 0
},
"uptime": 681778000,
"lastSeen": 1643055784651,
"isUpdating": false,
"lastUpdateAt": null,
"isStation": false,
"enableAutomaticBackups": true,
"enableStatsReporting": false,
"isSshEnabled": false,
"errorCode": null,
"releaseChannel": "beta",
"ssoChannel": null,
"hosts": [
"192.168.102.63"
],
"enableBridgeAutoAdoption": true,
"hardwareId": "4133b915-976c-4945-9da1-85a8297dc2e9",
"hardwareRevision": "113-03137-22",
"hostType": 59936,
"hostShortname": "UNVRPRO",
"isHardware": true,
"isWirelessUplinkEnabled": false,
"timeFormat": "24h",
"temperatureUnit": "C",
"recordingRetentionDurationMs": null,
"enableCrashReporting": true,
"disableAudio": false,
"analyticsData": "anonymous",
"anonymousDeviceId": "f7babe76-b29f-4786-ac5f-c4df3943287c",
"cameraUtilization": 30,
"isRecycling": false,
"avgMotions": [
11.14,
7.29,
6,
6.14,
5.71,
8.14,
17.29,
26.14,
19.86,
35,
33.43,
52.71,
46.71,
41.29,
28.14,
31.43,
33.71,
43.29,
58,
61.43,
39.29,
33.14,
16,
15
],
"disableAutoLink": false,
"skipFirmwareUpdate": false,
"wifiSettings": {
"useThirdPartyWifi": false,
"ssid": null,
"password": null
},
"locationSettings": {
"isAway": true,
"isGeofencingEnabled": false,
"latitude": 41.4519,
"longitude": -81.921,
"radius": 200
},
"featureFlags": {
"beta": false,
"dev": false,
"notificationsV2": true
},
"systemInfo": {
"cpu": {
"averageLoad": 6,
"temperature": 68
},
"memory": {
"available": 6388164,
"free": 102208,
"total": 8163024
},
"storage": {
"available": 13846957756416,
"isRecycling": false,
"size": 31855989432320,
"type": "raid",
"used": 16409797353472,
"devices": [
{
"model": "ST16000VE000-2L2103",
"size": 16000900661248,
"healthy": true
},
{
"model": "ST16000VE000-2L2103",
"size": 16000900661248,
"healthy": true
},
{
"model": "ST16000VE000-2L2103",
"size": 16000900661248,
"healthy": true
}
]
},
"tmpfs": {
"available": 960028,
"total": 1048576,
"used": 88548,
"path": "/var/opt/unifi-protect/tmp"
}
},
"doorbellSettings": {
"defaultMessageText": "Welcome",
"defaultMessageResetTimeoutMs": 60000,
"customMessages": [
"Come In!",
"Use Other Door"
],
"allMessages": [
{
"type": "LEAVE_PACKAGE_AT_DOOR",
"text": "LEAVE PACKAGE AT DOOR"
},
{
"type": "DO_NOT_DISTURB",
"text": "DO NOT DISTURB"
},
{
"type": "CUSTOM_MESSAGE",
"text": "Come In!"
},
{
"type": "CUSTOM_MESSAGE",
"text": "Use Other Door"
}
]
},
"smartDetectAgreement": {
"status": "agreed",
"lastUpdateAt": 1606964227734
},
"storageStats": {
"utilization": 51.623801166344975,
"capacity": 6304614230,
"remainingCapacity": 3049932715,
"recordingSpace": {
"total": 31787269955584,
"used": 16409797038080,
"available": 15377472917504
},
"storageDistribution": {
"recordingTypeDistributions": [
{
"recordingType": "rotating",
"size": 15332774950544,
"percentage": 93.45392305589237
},
{
"recordingType": "timelapse",
"size": 24696061952,
"percentage": 0.15052356020942406
},
{
"recordingType": "detections",
"size": 1049304058224,
"percentage": 6.395553383898204
}
],
"resolutionDistributions": [
{
"resolution": "4K",
"size": 11108932911104,
"percentage": 34.94774142801942
},
{
"resolution": "HD",
"size": 5297842159616,
"percentage": 16.666552890571023
},
{
"resolution": "free",
"size": 15380494884864,
"percentage": 48.385705681409554
}
]
}
},
"id": "2435ff5ab300d119d704bbe3",
"isAway": true,
"isSetup": true,
"network": "Ethernet",
"type": "UNVR-PRO",
"upSince": 1642374007037,
"isRecordingDisabled": false,
"isRecordingMotionOnly": false,
"maxCameraCapacity": {
"4K": 20,
"2K": 30,
"HD": 60
},
"modelKey": "nvr"
},
"lastUpdateId": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70",
"viewers": [
{
"mac": "85EEA7DF1601",
"host": "192.168.27.69",
"connectionHost": "192.168.102.63",
"type": "UP Viewport",
"name": "Aiyhwrk Fyoqval",
"upSince": 1642661973905,
"uptime": 393786,
"lastSeen": 1643055759905,
"connectedSince": 1642672730441,
"state": "CONNECTED",
"hardwareRevision": null,
"firmwareVersion": "1.2.54",
"latestFirmwareVersion": "1.2.54",
"firmwareBuild": "dcfb16f3.210907.625",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"streamLimit": 16,
"softwareVersion": "1.2.54",
"wiredConnectionState": {
"phyRate": 1000
},
"liveview": "bf41f6b5ba0ddd046eeb1c98",
"id": "081c58d13ad7e198d3dddffa",
"isConnected": true,
"marketName": "UP ViewPort",
"modelKey": "viewer"
}
],
"lights": [
{
"mac": "534D8B001B14",
"host": "192.168.234.163",
"connectionHost": "192.168.102.63",
"type": "UP FloodLight",
"name": "Icxei Irdq",
"upSince": 1638128967900,
"uptime": 4926792,
"lastSeen": 1643055759900,
"connectedSince": 1642902624923,
"state": "CONNECTED",
"hardwareRevision": null,
"firmwareVersion": "1.9.3",
"latestFirmwareVersion": "1.9.3",
"firmwareBuild": "g990c553.211105.251",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": true,
"canAdopt": false,
"isAttemptingToConnect": false,
"isPirMotionDetected": false,
"lastMotion": 1643055491157,
"isDark": false,
"isLightOn": false,
"isLocating": false,
"wiredConnectionState": {
"phyRate": 100
},
"lightDeviceSettings": {
"isIndicatorEnabled": false,
"ledLevel": 6,
"luxSensitivity": "medium",
"pirDuration": 120000,
"pirSensitivity": 46
},
"lightOnSettings": {
"isLedForceOn": false
},
"lightModeSettings": {
"mode": "off",
"enableAt": "fulltime"
},
"camera": "1c9a2db4df6efda47a3509be",
"id": "3ada785d6626c88d7d52446a",
"isConnected": true,
"isCameraPaired": true,
"marketName": "UP FloodLight",
"modelKey": "light"
}
],
"bridges": [
{
"mac": "A28D0DB15AE1",
"host": "192.168.231.68",
"connectionHost": "192.168.102.63",
"type": "UFP-UAP-B",
"name": "Sffde Gxcaqe",
"upSince": 1639807977891,
"uptime": 3247782,
"lastSeen": 1643055759891,
"connectedSince": 1642374159304,
"state": "CONNECTED",
"hardwareRevision": 19,
"firmwareVersion": "0.3.1",
"latestFirmwareVersion": null,
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"wiredConnectionState": {
"phyRate": null
},
"id": "1f5a055254fb9169d7536fb9",
"isConnected": true,
"platform": "mt7621",
"modelKey": "bridge"
},
{
"mac": "C65C557CCA95",
"host": "192.168.87.68",
"connectionHost": "192.168.102.63",
"type": "UFP-UAP-B",
"name": "Axiwj Bbd",
"upSince": 1641257260772,
"uptime": null,
"lastSeen": 1643052750862,
"connectedSince": 1643052754695,
"state": "CONNECTED",
"hardwareRevision": 19,
"firmwareVersion": "0.3.1",
"latestFirmwareVersion": null,
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"wiredConnectionState": {
"phyRate": null
},
"id": "e6901e3665a4c0eab0d9c1a5",
"isConnected": true,
"platform": "mt7621",
"modelKey": "bridge"
}
],
"sensors": [
{
"mac": "4191A8E35F39",
"host": null,
"connectionHost": "192.168.102.63",
"type": "UFP-SENSE",
"name": "Yeahe Anfa",
"upSince": 1642991171327,
"uptime": null,
"lastSeen": 1643054753862,
"connectedSince": 1643054778327,
"state": "CONNECTED",
"hardwareRevision": 6,
"firmwareVersion": "1.0.2",
"latestFirmwareVersion": "1.0.2",
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"isMotionDetected": false,
"mountType": "door",
"leakDetectedAt": null,
"tamperingDetectedAt": null,
"isOpened": false,
"openStatusChangedAt": 1643055549536,
"alarmTriggeredAt": null,
"motionDetectedAt": 1643055565002,
"wiredConnectionState": {
"phyRate": null
},
"stats": {
"light": {
"value": 20,
"status": "neutral"
},
"humidity": {
"value": 29,
"status": "neutral"
},
"temperature": {
"value": 16.95,
"status": "neutral"
}
},
"bluetoothConnectionState": {
"signalQuality": 37,
"signalStrength": -75
},
"batteryStatus": {
"percentage": 80,
"isLow": false
},
"alarmSettings": {
"isEnabled": false
},
"lightSettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 10
},
"motionSettings": {
"isEnabled": true,
"sensitivity": 100
},
"temperatureSettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 0.1
},
"humiditySettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 1
},
"ledSettings": {
"isEnabled": true
},
"bridge": "61b3f5c90054a703e700042b",
"camera": null,
"bridgeCandidates": [],
"id": "02ee9b99f17d69346e0c8c00",
"isConnected": true,
"marketName": "UP Sense",
"modelKey": "sensor"
},
{
"mac": "3CDB35674BB5",
"host": null,
"connectionHost": "192.168.102.63",
"type": "UFP-SENSE",
"name": "Xvub Rzo",
"upSince": 1641931605865,
"uptime": null,
"lastSeen": 1643052750836,
"connectedSince": 1643052765865,
"state": "CONNECTED",
"hardwareRevision": 6,
"firmwareVersion": "1.0.2",
"latestFirmwareVersion": "1.0.2",
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"isMotionDetected": false,
"mountType": "none",
"leakDetectedAt": null,
"tamperingDetectedAt": null,
"isOpened": false,
"openStatusChangedAt": null,
"alarmTriggeredAt": 1641931691625,
"motionDetectedAt": 1642978103125,
"wiredConnectionState": {
"phyRate": null
},
"stats": {
"light": {
"value": null,
"status": "unknown"
},
"humidity": {
"value": 14,
"status": "neutral"
},
"temperature": {
"value": 28.58,
"status": "neutral"
}
},
"bluetoothConnectionState": {
"signalQuality": 100,
"signalStrength": -42
},
"batteryStatus": {
"percentage": 73,
"isLow": false
},
"alarmSettings": {
"isEnabled": false
},
"lightSettings": {
"isEnabled": false,
"lowThreshold": null,
"highThreshold": null,
"margin": 10
},
"motionSettings": {
"isEnabled": true,
"sensitivity": 100
},
"temperatureSettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 0.1
},
"humiditySettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 1
},
"ledSettings": {
"isEnabled": true
},
"bridge": "61b3f5c90050a703e700042a",
"camera": null,
"bridgeCandidates": [],
"id": "d3cdb13d7b66b7d67763cd2e",
"isConnected": true,
"marketName": "UP Sense",
"modelKey": "sensor"
},
{
"mac": "CB9494FA353D",
"host": null,
"connectionHost": "192.168.102.63",
"type": "UFP-SENSE",
"name": "Vmhrp Zdebrsu",
"upSince": 1642635613872,
"uptime": null,
"lastSeen": 1643052750853,
"connectedSince": 1643052765872,
"state": "CONNECTED",
"hardwareRevision": 6,
"firmwareVersion": "1.0.2",
"latestFirmwareVersion": "1.0.2",
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"isMotionDetected": false,
"mountType": "window",
"leakDetectedAt": null,
"tamperingDetectedAt": null,
"isOpened": false,
"openStatusChangedAt": 1643055451596,
"alarmTriggeredAt": null,
"motionDetectedAt": null,
"wiredConnectionState": {
"phyRate": null
},
"stats": {
"light": {
"value": null,
"status": "unknown"
},
"humidity": {
"value": null,
"status": "unknown"
},
"temperature": {
"value": 15.3,
"status": "neutral"
}
},
"bluetoothConnectionState": {
"signalQuality": 47,
"signalStrength": -71
},
"batteryStatus": {
"percentage": 86,
"isLow": false
},
"alarmSettings": {
"isEnabled": false
},
"lightSettings": {
"isEnabled": false,
"lowThreshold": null,
"highThreshold": null,
"margin": 10
},
"motionSettings": {
"isEnabled": false,
"sensitivity": 100
},
"temperatureSettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 0.1
},
"humiditySettings": {
"isEnabled": false,
"lowThreshold": null,
"highThreshold": null,
"margin": 1
},
"ledSettings": {
"isEnabled": true
},
"bridge": "61b3f5c90050a703e700042a",
"camera": null,
"bridgeCandidates": [],
"id": "20b1e28b9a5fbdf1060bf7d8",
"isConnected": true,
"marketName": "UP Sense",
"modelKey": "sensor"
},
{
"mac": "0063ADA22061",
"host": null,
"connectionHost": "192.168.102.63",
"type": "UFP-SENSE",
"name": "Mhfu Txvn",
"upSince": 1642704127861,
"uptime": null,
"lastSeen": 1643052750856,
"connectedSince": 1643052765861,
"state": "CONNECTED",
"hardwareRevision": 6,
"firmwareVersion": "1.0.2",
"latestFirmwareVersion": "1.0.2",
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"isMotionDetected": false,
"mountType": "garage",
"leakDetectedAt": null,
"tamperingDetectedAt": null,
"isOpened": false,
"openStatusChangedAt": 1643055553059,
"alarmTriggeredAt": null,
"motionDetectedAt": 1643055561549,
"wiredConnectionState": {
"phyRate": null
},
"stats": {
"light": {
"value": 13,
"status": "neutral"
},
"humidity": {
"value": 60,
"status": "neutral"
},
"temperature": {
"value": -0.04,
"status": "neutral"
}
},
"bluetoothConnectionState": {
"signalQuality": 70,
"signalStrength": -62
},
"batteryStatus": {
"percentage": 74,
"isLow": false
},
"alarmSettings": {
"isEnabled": false
},
"lightSettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 10
},
"motionSettings": {
"isEnabled": true,
"sensitivity": 100
},
"temperatureSettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 0.1
},
"humiditySettings": {
"isEnabled": true,
"lowThreshold": null,
"highThreshold": null,
"margin": 1
},
"ledSettings": {
"isEnabled": true
},
"bridge": "61b3f5c90050a703e700042a",
"camera": "586ab7c2bb6423c3fdd47e95",
"bridgeCandidates": [],
"id": "5c1b116c29e4e55a70f39736",
"isConnected": true,
"marketName": "UP Sense",
"modelKey": "sensor"
}
],
"chimes": [
{
"mac": "BEEEE2FBE413",
"host": "192.168.144.146",
"connectionHost": "192.168.234.27",
"type": "UP Chime",
"name": "Xaorvu Tvsv",
"upSince": 1651882870009,
"uptime": 567870,
"lastSeen": 1652450740009,
"connectedSince": 1652448904587,
"state": "CONNECTED",
"hardwareRevision": null,
"firmwareVersion": "1.3.4",
"latestFirmwareVersion": "1.3.4",
"firmwareBuild": "58bd350.220401.1859",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": true,
"canAdopt": false,
"isAttemptingToConnect": false,
"volume": 100,
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"lastRing": 1652116059940,
"isWirelessUplinkEnabled": true,
"wiredConnectionState": {
"phyRate": null
},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": 100,
"signalStrength": -44,
"ssid": null
},
"cameraIds": [],
"id": "cf1a330397c08f919d02bd7c",
"isConnected": true,
"marketName": "UP Chime",
"modelKey": "chime"
}
],
"ringtones": [
{
"id": "66a14fa502d44203e40003eb",
"name": "Default",
"size": 208,
"isDefault": true,
"nvrMac": "4B8290F6D7A3",
"modelKey": "ringtone"
},
{
"id": "66a14fa502da4203e40003ec",
"name": "custometest",
"size": 180,
"isDefault": false,
"nvrMac": "4B8290F6D7A3",
"modelKey": "ringtone"
}
],
"aiports": [
{
"isDeleting": false,
"mac": "942A6FERF45E",
"host": "192.168.144.142",
"connectionHost": "192.168.144.141",
"type": "UVC AI Port",
"name": "AI Port",
"upSince": 1734358286292,
"uptime": 5711,
"lastSeen": 1734363997292,
"connectedSince": 1734358304453,
"state": "CONNECTED",
"lastDisconnect": 1734358270257,
"hardwareRevision": "9",
"firmwareVersion": "4.73.7",
"latestFirmwareVersion": null,
"firmwareBuild": "02c7590.241202.29",
"isUpdating": false,
"isDownloadingFW": false,
"fwUpdateState": "upToDate",
"isAdopting": false,
"isRestoring": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"uplinkDevice": null,
"guid": "d856a941-980d-4002-822c-6cc419266f9a",
"anonymousDeviceId": "08752e43-0d7f-4340-b2fe-9d2a5e89ba2b",
"lastMotion": null,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": false,
"isWirelessUplinkEnabled": true,
"isMotionDetected": false,
"isSmartDetected": false,
"phyRate": null,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"apMgmtIp": null,
"elementInfo": null,
"chimeDuration": 0,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": null,
"isLiveHeatmapEnabled": false,
"eventStats": {
"motion": {
"today": 2,
"average": 0,
"lastDays": [],
"recentHours": [
null
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": []
}
},
"videoReconfigurationInProgress": false,
"voltage": null,
"useGlobal": false,
"isPoorNetwork": false,
"stopStreamLevel": null,
"isWaterproofCaseAttached": false,
"userConfiguredAp": false,
"hasRecordings": false,
"isThirdPartyCamera": false,
"isPairedWithAiPort": false,
"wiredConnectionState": {
"phyRate": null
},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"txRate": null,
"signalQuality": null,
"ssid": null,
"bssid": null,
"apName": null,
"experience": null,
"signalStrength": null,
"connectivity": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "High",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 3840,
"height": 2160,
"fps": 24,
"bitrate": 8000000,
"minBitrate": 2000000,
"maxBitrate": 8000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 2000000,
"fpsValues": [
1,
3,
5,
8,
10,
12,
15,
16,
18,
20,
22,
24
],
"idrInterval": 5,
"autoFps": true,
"autoBitrate": true
},
{
"id": 1,
"videoId": "video2",
"name": "Medium",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 1280,
"height": 720,
"fps": 24,
"bitrate": 2000000,
"minBitrate": 750000,
"maxBitrate": 8000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
3,
5,
8,
10,
12,
15,
16,
18,
20,
22,
24
],
"idrInterval": 5,
"autoFps": true,
"autoBitrate": true
},
{
"id": 2,
"videoId": "video3",
"name": "Low",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 640,
"height": 360,
"fps": 24,
"bitrate": 900000,
"minBitrate": 300000,
"maxBitrate": 8000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 300000,
"fpsValues": [
1,
3,
5,
8,
10,
12,
15,
16,
18,
20,
22,
24
],
"idrInterval": 5,
"autoFps": true,
"autoBitrate": true
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"icrSwitchMode": "sensitivity",
"icrCustomValue": 2,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isColorNightVisionEnabled": false,
"spotlightDuration": 15,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null,
"hdrMode": "normal"
},
"audioSettings": {
"style": [
"nature"
]
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": null,
"filterPort": null,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": false,
"isDateEnabled": false,
"isLogoEnabled": true,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 2,
"postPaddingSecs": 2,
"smartDetectPrePaddingSecs": 2,
"smartDetectPostPaddingSecs": 2,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"inScheduleMode": "always",
"outScheduleMode": "never",
"geofencing": "off",
"retentionDurationMs": null,
"motionAlgorithm": "enhanced",
"enableMotionDetection": true,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle",
"package",
"animal",
"face",
"licensePlate"
],
"autoTrackingObjectTypes": [],
"audioTypes": [
"alrmSmoke",
"alrmCmonx",
"alrmSiren",
"alrmBabyCry",
"alrmSpeak",
"alrmBurglar",
"alrmCarHorn",
"alrmBark",
"alrmGlassBreak"
]
},
"recordingSchedulesV2": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0.249,
0
],
[
0.517,
0
],
[
0.525,
0.62
],
[
0.7,
0.622
],
[
1,
0.689
],
[
1,
1
],
[
0,
1
],
[
0.205,
0.611
]
],
"sensitivity": 50,
"isTriggerLightEnabled": true
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 2,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0.229,
0
],
[
0.524,
0
],
[
0.526,
0.602
],
[
0.692,
0.6
],
[
1,
0.667
],
[
1,
1
],
[
0,
1
],
[
0.201,
0.62
]
],
"sensitivity": 50,
"objectTypes": [
"animal",
"face",
"vehicle",
"person"
],
"isTriggerLightEnabled": true,
"source": "unifi-protect",
"triggerAccessTypes": [],
"enableAccessLPOnlyMode": false
}
],
"smartDetectLines": [
{
"id": 3,
"name": "New Crossing Line",
"color": "#46C7FD",
"points": [
[
0.69,
0.609
],
[
0.53,
0.609
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle",
"animal"
],
"isTriggerLightEnabled": false,
"direction": "BA",
"isTargetCounting": false,
"plan": {
"autoReset": "none",
"time": null,
"weekDay": null,
"date": null
}
},
{
"id": 4,
"name": "New Crossing Line",
"color": "#61E066",
"points": [
[
1,
0.685
],
[
0.69,
0.609
]
],
"sensitivity": 50,
"objectTypes": [
"person",
"vehicle"
],
"isTriggerLightEnabled": false,
"direction": "BA",
"isTargetCounting": false,
"plan": {
"autoReset": "none",
"time": null,
"weekDay": null,
"date": null
}
}
],
"stats": {
"rxBytes": 0,
"txBytes": 0,
"wifi": {
"channel": null,
"frequency": null,
"linkSpeedMbps": null,
"signalQuality": 50,
"signalStrength": 0
},
"video": {
"recordingStart": null,
"recordingEnd": null,
"recordingStartLQ": null,
"recordingEndLQ": null,
"timelapseStart": null,
"timelapseEnd": null,
"timelapseStartLQ": null,
"timelapseEndLQ": null
},
"wifiQuality": 50,
"wifiStrength": 0,
"storage": {}
},
"featureFlags": {
"supportFullHdSnapshot": true,
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasVerticalFlip": true,
"hasAec": false,
"hasBluetooth": false,
"hasChime": false,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasInfrared": true,
"hasLdc": false,
"hasLedIr": false,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": false,
"hasWifi": false,
"hasHdr": false,
"hasAutoICROnly": false,
"videoModes": [
"default"
],
"videoModeMaxFps": [
30
],
"hasMotionZones": true,
"hasLcdScreen": false,
"hasFingerprintSensor": false,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle",
"animal",
"face",
"licensePlate",
"package"
],
"smartDetectAudioTypes": [
"smoke_cmonx",
"alrmSmoke",
"alrmCmonx",
"alrmSiren",
"alrmBabyCry",
"alrmSpeak",
"alrmBurglar",
"alrmCarHorn",
"alrmBark",
"alrmGlassBreak"
],
"supportNfc": false,
"lensType": null,
"lensModel": null,
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": false,
"audio": [],
"audioCodecs": [
"aac"
],
"audioStyle": [],
"isDoorbell": false,
"isPtz": false,
"hasColorLcdScreen": false,
"hasLiveviewTracking": true,
"hasLineCrossing": true,
"hasLineCrossingCounting": true,
"hasFlash": false,
"flashRange": null,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 1,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hotplug": {
"audio": null,
"video": null,
"standaloneAdoption": false,
"extender": {
"isAttached": false,
"hasFlash": null,
"flashRange": null,
"hasIR": null,
"hasRadar": false
}
},
"hasSmartDetect": true
},
"lcdMessage": {},
"lenses": [],
"streamSharing": {
"enabled": false,
"token": null,
"shareLink": null,
"expires": null,
"sharedByUserId": null,
"sharedByUser": null,
"maxStreams": null
},
"homekitSettings": {
"talkbackSettingsActive": false,
"streamInProgress": false,
"microphoneMuted": false,
"speakerMuted": false
},
"pairedCameras": [
"0777b5d342302079dc6b793d"
],
"id": "675erf3d000c9787e7415re2",
"nvrMac": "4B8290F6D7A3",
"isConnected": true,
"platform": "all",
"hasSpeaker": false,
"hasWifi": false,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "AI Port",
"is4K": false,
"is2K": false,
"modelKey": "aiport",
"activePatrolSlot": null
}
]
}
uilibs-uiprotect-317a52c/tests/sample_data/sample_bridge.json 0000664 0000000 0000000 00000001505 15246377057 0024471 0 ustar 00root root 0000000 0000000 {
"mac": "A28D0DB15AE1",
"host": "192.168.231.68",
"connectionHost": "192.168.102.63",
"type": "UFP-UAP-B",
"name": "Vdr Fzr",
"upSince": 1639807977891,
"uptime": 3247782,
"lastSeen": 1643055759891,
"connectedSince": 1642374159304,
"state": "CONNECTED",
"hardwareRevision": 19,
"firmwareVersion": "0.3.1",
"latestFirmwareVersion": null,
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"wiredConnectionState": {
"phyRate": null
},
"id": "1f5a055254fb9169d7536fb9",
"isConnected": true,
"platform": "mt7621",
"modelKey": "bridge"
}
uilibs-uiprotect-317a52c/tests/sample_data/sample_camera.json 0000664 0000000 0000000 00000034612 15246377057 0024472 0 ustar 00root root 0000000 0000000 {
"isDeleting": false,
"mac": "331AFA68BE00",
"host": "192.168.10.22",
"connectionHost": "192.168.102.63",
"type": "UVC G4 Doorbell Pro",
"name": "Oxnjk Ajfq",
"upSince": 1642431757906,
"uptime": 624002,
"lastSeen": 1643055759906,
"connectedSince": 1642431796280,
"state": "CONNECTED",
"hardwareRevision": "20",
"firmwareVersion": "4.48.16",
"latestFirmwareVersion": "4.48.16",
"firmwareBuild": "dd71d61.211230.1810",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": true,
"isRebooting": false,
"isSshEnabled": true,
"canAdopt": false,
"isAttemptingToConnect": false,
"lastMotion": 1643055816121,
"micVolume": 100,
"isMicEnabled": true,
"isRecording": true,
"isWirelessUplinkEnabled": true,
"isMotionDetected": true,
"isSmartDetected": true,
"phyRate": 200,
"hdrMode": true,
"videoMode": "default",
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"chimeDuration": 300,
"isDark": false,
"lastPrivacyZonePositionId": null,
"lastRing": 1643055803577,
"isLiveHeatmapEnabled": false,
"useGlobal": false,
"anonymousDeviceId": "783670a8-fd5d-45f7-9e4a-5af42ba89c25",
"lastDisconnect": 1669917368811,
"eventStats": {
"motion": {
"today": 44,
"average": 69,
"lastDays": [
57,
59,
126,
59,
65,
64,
53
],
"recentHours": [
5,
2,
6,
5,
1,
0,
6,
4,
1,
5,
5,
0,
3
]
},
"smart": {
"today": 0,
"average": 0,
"lastDays": [
0,
0,
0,
0,
0,
0,
0
]
}
},
"videoReconfigurationInProgress": false,
"voltage": 27.3,
"isPoorNetwork": false,
"isThirdPartyCamera": false,
"wiredConnectionState": {
"phyRate": null
},
"channels": [
{
"id": 0,
"videoId": "video1",
"name": "Ppkqf Wamffz",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "bVFsZBWTKrCRgZKO",
"width": 1600,
"height": 1200,
"fps": 30,
"bitrate": 6000000,
"minBitrate": 32000,
"maxBitrate": 6000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 1,
"videoId": "video3",
"name": "Woi Uoyb",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 960,
"height": 720,
"fps": 30,
"bitrate": 1200000,
"minBitrate": 32000,
"maxBitrate": 2000000,
"minClientAdaptiveBitRate": 150000,
"minMotionAdaptiveBitRate": 750000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 2,
"videoId": "video2",
"name": "Rtogc Kztj",
"enabled": true,
"isRtspEnabled": false,
"rtspAlias": null,
"width": 480,
"height": 360,
"fps": 30,
"bitrate": 200000,
"minBitrate": 32000,
"maxBitrate": 1000000,
"minClientAdaptiveBitRate": 0,
"minMotionAdaptiveBitRate": 200000,
"fpsValues": [
1,
2,
3,
4,
5,
6,
8,
9,
10,
12,
15,
16,
18,
20,
24,
25,
30
],
"idrInterval": 5
},
{
"id": 3,
"videoId": "video4",
"name": "Ohxs Zvtmc",
"enabled": true,
"isRtspEnabled": true,
"rtspAlias": "d8KCmJNaI1NTIpGJ",
"width": 1600,
"height": 1200,
"fps": 2,
"bitrate": 1000000,
"minBitrate": 32000,
"maxBitrate": 2000000,
"minClientAdaptiveBitRate": null,
"minMotionAdaptiveBitRate": null,
"fpsValues": [
1,
2
],
"idrInterval": 5
}
],
"ispSettings": {
"aeMode": "auto",
"irLedMode": "auto",
"irLedLevel": 255,
"wdr": 1,
"icrSensitivity": 0,
"brightness": 50,
"contrast": 50,
"hue": 50,
"saturation": 50,
"sharpness": 50,
"denoise": 50,
"isFlippedVertical": false,
"isFlippedHorizontal": false,
"isAutoRotateEnabled": false,
"isLdcEnabled": true,
"is3dnrEnabled": true,
"isExternalIrEnabled": false,
"isAggressiveAntiFlickerEnabled": false,
"isPauseMotionEnabled": false,
"dZoomCenterX": 50,
"dZoomCenterY": 50,
"dZoomScale": 0,
"dZoomStreamId": 4,
"focusMode": "ztrig",
"focusPosition": 0,
"touchFocusX": 1001,
"touchFocusY": 1001,
"zoomPosition": 0,
"mountPosition": null
},
"talkbackSettings": {
"typeFmt": "aac",
"typeIn": "serverudp",
"bindAddr": "0.0.0.0",
"bindPort": 7004,
"filterAddr": "",
"filterPort": 0,
"channels": 1,
"samplingRate": 22050,
"bitsPerSample": 16,
"quality": 100
},
"osdSettings": {
"isNameEnabled": true,
"isDateEnabled": true,
"isLogoEnabled": false,
"isDebugEnabled": false
},
"ledSettings": {
"isEnabled": true,
"blinkRate": 0
},
"speakerSettings": {
"isEnabled": true,
"areSystemSoundsEnabled": false,
"volume": 80
},
"recordingSettings": {
"prePaddingSecs": 10,
"postPaddingSecs": 10,
"minMotionEventTrigger": 1000,
"endMotionEventDelay": 3000,
"suppressIlluminationSurge": false,
"mode": "always",
"geofencing": "off",
"motionAlgorithm": "enhanced",
"enablePirTimelapse": false,
"useNewMotionAlgorithm": true
},
"smartDetectSettings": {
"objectTypes": [
"person",
"vehicle"
]
},
"recordingSchedules": [],
"motionZones": [
{
"id": 1,
"name": "Default",
"color": "#AB46BC",
"points": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
]
],
"sensitivity": 50
}
],
"privacyZones": [],
"smartDetectZones": [
{
"id": 1,
"name": "Txeb Hlswjhf",
"color": "#AB46BC",
"points": [
[
0.569,
0
],
[
0.604,
0.537
],
[
1,
0.565
],
[
1,
1
],
[
0,
1
],
[
0,
0
],
[
0.276,
0
]
],
"sensitivity": 50,
"objectTypes": [
"person"
]
},
{
"id": 2,
"name": "Krc Zhcvlco",
"color": "#586CED",
"points": [
[
0,
0
],
[
0.369,
0
],
[
0.358,
0.543
],
[
1,
0.531
],
[
1,
1
],
[
0,
0.978
]
],
"sensitivity": 50,
"objectTypes": [
"vehicle"
]
}
],
"smartDetectLines": [],
"stats": {
"rxBytes": 7917059346,
"txBytes": 270432644401,
"wifi": {
"channel": 153,
"frequency": 5765,
"linkSpeedMbps": null,
"signalQuality": 100,
"signalStrength": -52
},
"battery": {
"percentage": null,
"isCharging": false,
"sleepState": "disconnected"
},
"video": {
"recordingStart": 1641921049599,
"recordingEnd": 1643055815739,
"recordingStartLQ": 1641921049574,
"recordingEndLQ": 1643055815722,
"timelapseStart": 1641921049594,
"timelapseEnd": 1643055730717,
"timelapseStartLQ": 1641921049594,
"timelapseEndLQ": 1643055520725
},
"storage": {
"used": 427349245952,
"rate": 642.30288184191
},
"wifiQuality": 100,
"wifiStrength": -52
},
"featureFlags": {
"canAdjustIrLedLevel": false,
"canMagicZoom": false,
"canOpticalZoom": false,
"canTouchFocus": false,
"hasAccelerometer": false,
"hasAec": true,
"hasBattery": false,
"hasBluetooth": true,
"hasChime": true,
"hasExternalIr": false,
"hasIcrSensitivity": true,
"hasLdc": true,
"hasLedIr": true,
"hasLedStatus": true,
"hasLineIn": false,
"hasMic": true,
"hasPrivacyMask": true,
"hasRtc": false,
"hasSdCard": false,
"hasSpeaker": true,
"hasWifi": true,
"hasHdr": true,
"hasAutoICROnly": true,
"videoModes": [
"default"
],
"videoModeMaxFps": [],
"hasMotionZones": true,
"hasLcdScreen": true,
"hasFingerprintSensor": true,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"supportNfc": true,
"motionAlgorithms": [
"enhanced"
],
"hasSquareEventThumbnail": true,
"hasPackageCamera": true,
"privacyMaskCapability": {
"maxMasks": 16,
"rectangleOnly": false
},
"focus": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"pan": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"tilt": {
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"zoom": {
"ratio": 3,
"steps": {
"max": null,
"min": null,
"step": null
},
"degrees": {
"max": null,
"min": null,
"step": null
}
},
"hasSmartDetect": true,
"supportFullHdSnapshot": false
},
"pirSettings": {
"pirSensitivity": 100,
"pirMotionClipLength": 15,
"timelapseFrameInterval": 15,
"timelapseTransferInterval": 600
},
"lcdMessage": {
"type": "CUSTOM_MESSAGE",
"text": "Welcome | 03:23 PM | 25\u00b0F",
"resetAt": null
},
"wifiConnectionState": {
"channel": 153,
"frequency": 5765,
"phyRate": 200,
"signalQuality": 100,
"signalStrength": -52,
"ssid": "Mortis Camera"
},
"lenses": [
{
"id": 2,
"video": {
"recordingStart": 1642357077629,
"recordingEnd": 1643055806877,
"recordingStartLQ": null,
"recordingEndLQ": null,
"timelapseStart": 1641921049317,
"timelapseEnd": 1643055667971,
"timelapseStartLQ": null,
"timelapseEndLQ": null
}
}
],
"id": "1c9a2db4df6efda47a3509be",
"isConnected": true,
"platform": "s5l",
"hasSpeaker": true,
"hasWifi": true,
"audioBitrate": 64000,
"canManage": false,
"isManaged": true,
"marketName": "G4 Doorbell Pro",
"modelKey": "camera",
"isPairedWithAiPort": false,
"guid": "00000000-0000-00 0- 000-000000000000",
"activePatrolSlot": null
}
uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_heatmap.png 0000664 0000000 0000000 00000002711 15246377057 0025777 0 ustar 00root root 0000000 0000000 ‰PNG
IHDR € h HPÝ �IDATxœíÕ1 À0@ù¤ã‚$
úuÏÌ Þ:u üÈ€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 �MaPk¸�y IEND®B`‚ uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_snapshot.png 0000664 0000000 0000000 00000020446 15246377057 0026224 0 ustar 00root root 0000000 0000000 ‰PNG
IHDR @ ° ,cÀ íIDATxœíØ1 À0@ù¤ã€—‰‚ÞÝ3³ êü € €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €´Œ¹
àt·¨ IEND®B`‚ uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_thumbnail.png 0000664 0000000 0000000 00000002711 15246377057 0026343 0 ustar 00root root 0000000 0000000 ‰PNG
IHDR € h HPÝ �IDATxœíÕ1 À0@ù¤ã‚$
úuÏÌ Þ:u üÈ€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 €€@À€ `À 0` 0 �MaPk¸�y IEND®B`‚ uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_video.mp4 0000664 0000000 0000000 00000143110 15246377057 0025401 0 ustar 00root root 0000000 0000000 ftypisom isomiso2avc1mp41 free u‹mdat ¯ÿÿ«ÜE齿ÙH·–,Ø Ù#îïx264 - core 160 r3011 cde9a93 - H.264/MPEG-4 AVC codec - Copyleft 2003-2020 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=22 lookahead_threads=3 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00 € Óeˆ„ ;ÿþ÷N¿›TÂ*’W
öʤ&Y¹Rkò¦
[j(…iôɽ4 u U &