pax_global_header 0000666 0000000 0000000 00000000064 14673102202 0014507 g ustar 00root root 0000000 0000000 52 comment=6e2b47bf346481f4dd7eb5761c2d06bc68bebb26
uiprotect-6.1.0/ 0000775 0000000 0000000 00000000000 14673102202 0013531 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.all-contributorsrc 0000664 0000000 0000000 00000000445 14673102202 0017365 0 ustar 00root root 0000000 0000000 {
"projectName": "uiprotect",
"projectOwner": "uilibs",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 80,
"commit": true,
"commitConvention": "angular",
"contributors": [],
"contributorsPerLine": 7,
"skipCi": true
}
uiprotect-6.1.0/.bin/ 0000775 0000000 0000000 00000000000 14673102202 0014357 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.bin/lib/ 0000775 0000000 0000000 00000000000 14673102202 0015125 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.bin/lib/common.sh 0000664 0000000 0000000 00000001210 14673102202 0016743 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
}
uiprotect-6.1.0/.bin/run-mypy 0000775 0000000 0000000 00000000051 14673102202 0016101 0 ustar 00root root 0000000 0000000 #!/bin/sh
poetry run mypy src/uiprotect
uiprotect-6.1.0/.bin/test-code 0000775 0000000 0000000 00000001177 14673102202 0016202 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
uiprotect-6.1.0/.bin/update-release-cache 0000775 0000000 0000000 00000000161 14673102202 0020244 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
poetry install
poetry run uiprotect release-versions
uiprotect-6.1.0/.codespellrc 0000664 0000000 0000000 00000000057 14673102202 0016033 0 ustar 00root root 0000000 0000000 [codespell]
ignore-words-list = socio-economic
uiprotect-6.1.0/.copier-answers.yml 0000664 0000000 0000000 00000001044 14673102202 0017272 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
uiprotect-6.1.0/.coveragerc 0000664 0000000 0000000 00000001417 14673102202 0015655 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):
uiprotect-6.1.0/.devcontainer/ 0000775 0000000 0000000 00000000000 14673102202 0016270 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.devcontainer/devcontainer.json 0000664 0000000 0000000 00000011675 14673102202 0021656 0 ustar 00root root 0000000 0000000 {
"name": "uiprotect",
"build": {
"dockerfile": "../Dockerfile",
"context": "..",
"target": "dev"
},
"updateRemoteUserUID": true,
"containerUser": "app",
"mounts": [
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.cache,target=/home/app/.cache,type=bind,consistency=cached"
],
"initializeCommand": {
"mkdir-posix": "mkdir -p $HOME/.cache $HOME/.cache $HOME/.cache || true"
},
"onCreateCommand": [
"/bin/bash",
"-c",
"sudo -E HOME=/root uv pip install -e '.[dev]' && /usr/local/bin/uiprotect --install-completion bash && docker-fix"
],
"updateContentCommand": [
"/bin/bash",
"-c",
"sudo -E HOME=/root uv pip sync requirements.txt dev-requirements.txt"
],
"postAttachCommand": [
"/bin/bash",
"-c",
"test -f /.codespaces && echo -e '\\e[1;31mYou will not be able to connect to a UniFi Protect instance inside of Codespaces.' || true"
],
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
},
"hostRequirements": {
"cpus": 2,
"memory": "8gb",
"storage": "32gb"
},
"customizations": {
"vscode": {
"extensions": [
"eamodio.gitlens",
"github.codespaces",
"github.vscode-github-actions",
"gitHub.vscode-pull-request-github",
"jasonnutter.vscode-codeowners",
"mikestead.dotenv",
"ms-azuretools.vscode-docker",
"ms-vsliveshare.vsliveshare",
"streetsidesoftware.code-spell-checker",
"tamasfe.even-better-toml",
"ryanluker.vscode-coverage-gutters",
"charliermarsh.ruff",
"ms-python.black-formatter",
"ms-python.isort",
"ms-python.mypy-type-checker",
"ms-python.python",
"ms-python.debugpy",
"ms-python.vscode-pylance",
"njpwerner.autodocstring",
"samuelcolvin.jinjahtml"
],
"settings": {
"editor.formatOnSave": true,
"editor.formatOnSaveTimeout": 3000,
"editor.insertSpaces": true,
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"[toml]": {
"editor.formatOnSave": false
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.codeActionsOnSave": {
"source.organizeImports": true,
"source.fixAll": true
}
},
"python.envFile": "${workspaceFolder}/.env",
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.pythonPath": "/usr/local/bin/python",
"python.formatting.provider": "none",
"python.linting.enabled": true,
"black.args": [
"--config=/workspaces/uiprotect/pyproject.toml"
],
"isort.args": [
"--settings-path=/workspaces/uiprotect/pyproject.toml"
],
"mypy-type-checker.interpreter": [
"/usr/local/bin/python"
],
"mypy-type-checker.args": [
"--config-file=/workspaces/uiprotect/pyproject.toml"
],
"mypy-type-checker.severity": {
"error": "Error",
"note": "Warning"
},
"ruff.lint.args": [
"--preview",
"--config=/workspaces/uiprotect/pyproject.toml"
],
"ruff.fixAll": true,
"ruff.organizeImports": false,
"coverage-gutters.xmlname": "coverage.xml",
"coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true,
"coverage-gutters.showLineCoverage": true,
"python.testing.promptToConfigure": false,
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.nosetestsEnabled": false,
"python.testing.pytestArgs": [
"--cov-report=xml",
"--color=no"
],
"cSpell.allowCompoundWords": true,
"cSpell.dictionaries": [
"en_US",
"en-gb",
"companies",
"softwareTerms",
"misc",
"python",
"html",
"bash",
"fonts",
"filetypes"
]
}
}
}
}
uiprotect-6.1.0/.docker/ 0000775 0000000 0000000 00000000000 14673102202 0015056 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.docker/bashrc 0000664 0000000 0000000 00000001330 14673102202 0016240 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
uiprotect-6.1.0/.docker/docker-fix.sh 0000664 0000000 0000000 00000001454 14673102202 0017451 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
uiprotect-6.1.0/.docker/entrypoint.sh 0000664 0000000 0000000 00000000132 14673102202 0017621 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
/usr/local/bin/uiprotect "$@"
uiprotect-6.1.0/.dockerignore 0000664 0000000 0000000 00000000165 14673102202 0016207 0 ustar 00root root 0000000 0000000 .vscode
test-data
ufp-data
*.mp3
*.mp4
.*
*.xml
Dockerfile
LICENSE
*.md
!README.md
tests/**
*.egg-info
*.js
!.docker
uiprotect-6.1.0/.editorconfig 0000664 0000000 0000000 00000000444 14673102202 0016210 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
uiprotect-6.1.0/.github/ 0000775 0000000 0000000 00000000000 14673102202 0015071 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.github/CODE_OF_CONDUCT.md 0000664 0000000 0000000 00000012056 14673102202 0017674 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.
uiprotect-6.1.0/.github/FUNDING.yml 0000664 0000000 0000000 00000000023 14673102202 0016701 0 ustar 00root root 0000000 0000000 github: ["uilibs"]
uiprotect-6.1.0/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 14673102202 0017254 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.github/ISSUE_TEMPLATE/1-bug_report.yml 0000664 0000000 0000000 00000003531 14673102202 0022307 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!
uiprotect-6.1.0/.github/ISSUE_TEMPLATE/2-feature-request.yml 0000664 0000000 0000000 00000003610 14673102202 0023257 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!
uiprotect-6.1.0/.github/ISSUE_TEMPLATE/config.yml 0000664 0000000 0000000 00000000270 14673102202 0021243 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.
uiprotect-6.1.0/.github/PULL_REQUEST_TEMPLATE.md 0000664 0000000 0000000 00000003460 14673102202 0020675 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 new commits follow conventions outlined in the [conventional commit spec](https://www.conventionalcommits.org/en/v1.0.0/), such as "fix(api): prevent racing of requests".
> - 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.
uiprotect-6.1.0/.github/dependabot.yml 0000664 0000000 0000000 00000001015 14673102202 0017716 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: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
uiprotect-6.1.0/.github/labels.toml 0000664 0000000 0000000 00000003515 14673102202 0017234 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"
uiprotect-6.1.0/.github/workflows/ 0000775 0000000 0000000 00000000000 14673102202 0017126 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.github/workflows/ci.yml 0000664 0000000 0000000 00000006367 14673102202 0020260 0 ustar 00root root 0000000 0000000 name: CI
on:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v5
with:
python-version: 3.x
cache: "poetry"
- name: Install Dependencies
run: |
poetry install
- uses: pre-commit/action@v3.0.1
# Make sure commit messages follow the conventional commits convention:
# https://www.conventionalcommits.org
commitlint:
name: Lint Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6.0.1
test:
strategy:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
os:
- ubuntu-latest
pydantic:
- "1"
- "2"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
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: |
sudo apt update
sudo apt install -y ffmpeg
poetry install
- name: Downgrade to Pydantic 1.x
if: matrix.pydantic == '1'
run: |
poetry add 'pydantic>=1.0,<2.0,!=1.10.16'
- name: Test with Pytest
run: ./.bin/test-code
shell: bash
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
release:
needs:
- test
- lint
- commitlint
runs-on: ubuntu-latest
environment: release
concurrency: release
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
# Do a dry run of PSR
- name: Test release
uses: python-semantic-release/python-semantic-release@v9.8.8
if: github.ref_name != 'main'
with:
root_options: --noop
# On main branch: actual PSR + upload to PyPI & GitHub
- name: Release
uses: python-semantic-release/python-semantic-release@v9.8.8
id: release
if: github.ref_name == 'main'
with:
github_token: ${{ secrets.GITHUB_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/upload-to-gh-release@main
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
uiprotect-6.1.0/.github/workflows/docker.yml 0000664 0000000 0000000 00000005361 14673102202 0021125 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@v4
with:
fetch-depth: 0
- name: Install poetry
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
cache: "poetry"
- name: Install dependencies
run: |
poetry install
- 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$(python -c 'from importlib.metadata import version; print(version("uiprotect"))')
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@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v3
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 }}
uiprotect-6.1.0/.github/workflows/issue-manager.yml 0000664 0000000 0000000 00000001340 14673102202 0022407 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.5.0
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."
}
}
uiprotect-6.1.0/.github/workflows/poetry-upgrade.yml 0000664 0000000 0000000 00000000340 14673102202 0022615 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 }}
uiprotect-6.1.0/.gitignore 0000664 0000000 0000000 00000005471 14673102202 0015530 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
# Sphinx documentation
docs/_build/
# 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/
uiprotect-6.1.0/.gitpod.yml 0000664 0000000 0000000 00000000306 14673102202 0015617 0 ustar 00root root 0000000 0000000 tasks:
- command: |
pip install poetry
PIP_USER=false poetry install
- command: |
pip install pre-commit
pre-commit install
PIP_USER=false pre-commit install-hooks
uiprotect-6.1.0/.pre-commit-config.yaml 0000664 0000000 0000000 00000002713 14673102202 0020015 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|.all-contributorsrc"
default_stages: [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/commitizen-tools/commitizen
rev: v3.29.0
hooks:
- id: commitizen
stages: [commit-msg]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.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: 1.8.0
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/astral-sh/ruff-pre-commit
rev: v0.6.5
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)$
uiprotect-6.1.0/.readthedocs.yml 0000664 0000000 0000000 00000001111 14673102202 0016611 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 --with docs
# Build documentation in the docs directory with mkdocs
mkdocs:
configuration: mkdocs.yml
uiprotect-6.1.0/.vscode/ 0000775 0000000 0000000 00000000000 14673102202 0015072 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/.vscode/launch.json 0000664 0000000 0000000 00000002154 14673102202 0017241 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": "Python: Debug Tests",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"purpose": [
"debug-test"
],
"console": "integratedTerminal",
"justMyCode": false,
"env": {
"PYTEST_ADDOPTS": "-n=0 --no-cov -vv"
}
}
]
}
uiprotect-6.1.0/.vscode/tasks.json 0000664 0000000 0000000 00000004107 14673102202 0017114 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",
],
},
]
}
uiprotect-6.1.0/CHANGELOG.md 0000664 0000000 0000000 00000062260 14673102202 0015350 0 ustar 00root root 0000000 0000000 # Changelog
## v6.1.0 (2024-09-19)
### Fix
- Add additional types to device_events (#213) ([`072bc7c`](https://github.com/uilibs/uiprotect/commit/072bc7cbc6a8af634f4638ac79658715cb31379a))
- Bump psr to 9.8.8 to fix release process (#221) ([`b109433`](https://github.com/uilibs/uiprotect/commit/b1094333c8767dd7588fe0d0f97f4c711b7e2595))
### Feature
- Speed up url joins (#220) ([`a10fc5a`](https://github.com/uilibs/uiprotect/commit/a10fc5adc88a1cf78199f5ca2e4a995032f58743))
## v6.0.2 (2024-08-13)
### Fix
- Bump aiofiles requirement to >=24 (#182) ([`1eb9ea7`](https://github.com/uilibs/uiprotect/commit/1eb9ea7c5fb2036ad0af42eb607604652d1b0210))
## v6.0.1 (2024-08-09)
### Fix
- Simplify ssl verify flag in websocket class (#175) ([`c36e19a`](https://github.com/uilibs/uiprotect/commit/c36e19a549c78f4fd123b89f562669fdaa5f78a5))
## v6.0.0 (2024-08-08)
### Breaking
- Remove default websocket receive timeout (#173) ([`8b0b303`](https://github.com/uilibs/uiprotect/commit/8b0b3033880532ddbf00cb59df881100db273dcb))
## v5.4.0 (2024-07-20)
### Feature
- Improve performance of convert_unifi_data (#153) ([`45f66b4`](https://github.com/uilibs/uiprotect/commit/45f66b4d6f35cbd02abae21f0905089b0e329d59))
## v5.3.0 (2024-07-16)
### Feature
- Speed up camera snapshots (#152) ([`d333865`](https://github.com/uilibs/uiprotect/commit/d3338658c2fa714e993c3d668945b44a1e7ebd27))
## v5.2.2 (2024-07-04)
### Fix
- Reflection of chime duration seconds (#142) ([`0266b8e`](https://github.com/uilibs/uiprotect/commit/0266b8e2470084df63422d4971c04354710b1ae8))
## v5.2.1 (2024-07-04)
### Fix
- Avoid reflecting back smoke_cmonx when changing smart audio (#141) ([`7270a5c`](https://github.com/uilibs/uiprotect/commit/7270a5cb40ed9c83db353677abc0496dc7b59f9e))
## v5.2.0 (2024-07-03)
### Feature
- Remove deepcopy before calling update_from_dict (#140) ([`23bc68f`](https://github.com/uilibs/uiprotect/commit/23bc68f2ca31c06e224cb5f5600ce87e1c842ec6))
## v5.1.0 (2024-07-03)
### Feature
- Small cleanups to smart detect lookups (#139) ([`ef21763`](https://github.com/uilibs/uiprotect/commit/ef217638129bc48fb67d9e60fe828f78daf2a017))
## v5.0.0 (2024-07-02)
### Breaking
- Do not auto convert enums to values for fetching attrs (#138) ([`f6d7ead`](https://github.com/uilibs/uiprotect/commit/f6d7eade0e2b1dc4073b5e45f7f2a75909180a30))
## v4.2.0 (2024-06-27)
### Feature
- Replace manual dict deletes with convertertools (#131) ([`22f7df8`](https://github.com/uilibs/uiprotect/commit/22f7df8852d5dcb252337a3f4620932619b6c5be))
## v4.1.0 (2024-06-27)
### Feature
- Avoid the need to deepcopy in the ws stats (#130) ([`5318b02`](https://github.com/uilibs/uiprotect/commit/5318b0219c89a1183218c94525fe08319208bc30))
## v4.0.0 (2024-06-26)
### Breaking
- Remove is_ringing property and ring ping back from camera (#125) ([`b400435`](https://github.com/uilibs/uiprotect/commit/b400435366c859d0350a9095ae6e9136afb2b08a))
## v3.8.0 (2024-06-26)
### Fix
- Use id checks for type compares (#126) ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3))
- Do not swallow asyncio.cancellederror (#129) ([`09bc38b`](https://github.com/uilibs/uiprotect/commit/09bc38b419b26c00363b47c5ae8ce0e6a7280133))
### Feature
- Improve websocket error handling (#128) ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe))
## v3.7.0 (2024-06-25)
### Feature
- Small cleanups to packet packing/unpacking (#122) ([`00cb125`](https://github.com/uilibs/uiprotect/commit/00cb125e89f5f43f7c759719d5fc581fb631af3c))
- Small cleanups to devices (#124) ([`1b64a8e`](https://github.com/uilibs/uiprotect/commit/1b64a8e89259e9d791a9c9703ced088e4fc7622c))
- Cleanup some additional dupe attr lookups (#123) ([`24849d8`](https://github.com/uilibs/uiprotect/commit/24849d819cfbba582a0f21c975de895d3754ef3b))
## v3.6.0 (2024-06-25)
### Feature
- Reduce some duplicate attr lookups in devices (#121) ([`8ea72ea`](https://github.com/uilibs/uiprotect/commit/8ea72eae1c8c0e37206a1268937287b0b1f29b28))
## v3.5.0 (2024-06-25)
### Feature
- Use more list/dict comps where possible (#120) ([`9c1ef3f`](https://github.com/uilibs/uiprotect/commit/9c1ef3f30b8e1c01edb5a6d44b0126edd9e3610d))
## v3.4.0 (2024-06-25)
### Feature
- Reduce duplicate code to do unifi_dict_to_dict conversions (#119) ([`f616c52`](https://github.com/uilibs/uiprotect/commit/f616c528cc94a313dd2ac0ba7e302bfcfca4afde))
## v3.3.1 (2024-06-24)
### Fix
- License classifier (#116) ([`ac048d7`](https://github.com/uilibs/uiprotect/commit/ac048d7325529823ab7d2840dc63aaa822008b32))
## v3.3.0 (2024-06-24)
### Feature
- Skip empty models in unifi_dict (#115) ([`d42023f`](https://github.com/uilibs/uiprotect/commit/d42023f9f07d3bdf097669637e1ad754a70ea0b7))
## v3.2.0 (2024-06-24)
### Feature
- Refactor internal object tracking (#114) ([`ad1b2b4`](https://github.com/uilibs/uiprotect/commit/ad1b2b45f3d72243ca8cb24c326b4f0fcd0bd71f))
## v3.1.9 (2024-06-24)
### Fix
- Remove event is in range check (#92) ([`2847f40`](https://github.com/uilibs/uiprotect/commit/2847f402a19655e9dee1d596b331e70b25bf3da3))
## v3.1.8 (2024-06-23)
### Fix
- Small tweaks to compact code (#113) ([`aa136ba`](https://github.com/uilibs/uiprotect/commit/aa136badd8ff7dbad6b74fcd1418de5f8ca04d73))
## v3.1.7 (2024-06-23)
### Fix
- Remove unreachable code in the websocket decoder (#112) ([`235cdef`](https://github.com/uilibs/uiprotect/commit/235cdef8bf930fc7b86084fc44cccea96fb316ef))
## v3.1.6 (2024-06-23)
### Fix
- Remove unreachable api in data checks (#110) ([`c7772a9`](https://github.com/uilibs/uiprotect/commit/c7772a9ecdf8d29290d0ba84e31a6f104fcb1dd1))
- Make creation of update sync primitives lazy (#111) ([`b05af57`](https://github.com/uilibs/uiprotect/commit/b05af578a1ed9b30a1c986a13d006fbaf89b760f))
## v3.1.5 (2024-06-23)
### Fix
- Exclude_fields would mutate the classvar (#109) ([`1c461e1`](https://github.com/uilibs/uiprotect/commit/1c461e1a481eb1c022c1dc5aa09529fc1abfec0e))
## v3.1.4 (2024-06-23)
### Fix
- Ensure test harness does not delete coveragerc (#108) ([`02bd064`](https://github.com/uilibs/uiprotect/commit/02bd0640fc6ce917db180a410ab0d102b6c8c73a))
## v3.1.3 (2024-06-23)
### Fix
- Add test coverage for updating to none (#107) ([`b2adeac`](https://github.com/uilibs/uiprotect/commit/b2adeac94fcef09bac8fe06c9795c8a41694ff95))
## v3.1.2 (2024-06-23)
### Fix
- Coveragerc fails to omit cli and tests (#106) ([`d1a4052`](https://github.com/uilibs/uiprotect/commit/d1a4052984e8545b5ac876337909ae235813db7f))
## v3.1.1 (2024-06-22)
### Fix
- _raise_for_status when raise_exception is not set (#105) ([`0a6ff9e`](https://github.com/uilibs/uiprotect/commit/0a6ff9e358e66058f2f7ca3bff12925f3b1d4e90))
## v3.1.0 (2024-06-22)
### Feature
- Add websocket state subscription (#104) ([`d7083ab`](https://github.com/uilibs/uiprotect/commit/d7083ab8ced2dc3cc65dcaf6ea2dd8c869e70a96))
## v3.0.0 (2024-06-22)
### Breaking
- Remove the force flag from update (#103) ([`0bee3e6`](https://github.com/uilibs/uiprotect/commit/0bee3e64d8f1a540e6bfde7b3ab282bc26e6f150))
## v2.3.0 (2024-06-22)
### Feature
- Handle websocket auth errors on restart (#102) ([`7026491`](https://github.com/uilibs/uiprotect/commit/7026491ac909cb2ed2bf3d9457cf86a1a44de025))
## v2.2.0 (2024-06-22)
### Feature
- Decrease websocket logging for known errors (#101) ([`05df499`](https://github.com/uilibs/uiprotect/commit/05df499863006b8d66d2ca0e3c76c639730e30de))
## v2.1.0 (2024-06-22)
### Feature
- Improve websocket error handling (#100) ([`813ac9c`](https://github.com/uilibs/uiprotect/commit/813ac9ca2eaefa2623b15f43d9cdf4f3fab31bcb))
## v2.0.0 (2024-06-22)
### Breaking
- Rework websocket (#96) ([`574a846`](https://github.com/uilibs/uiprotect/commit/574a846ff4e34737169b49ec418b4a112fa12f3e))
## v1.20.0 (2024-06-21)
### Feature
- Include getter builder utils for fetching ufp object values (#95) ([`9056edf`](https://github.com/uilibs/uiprotect/commit/9056edf85ecf8cd59d053411ae18f1d05093d9e5))
## v1.19.3 (2024-06-21)
### Fix
- Pin and drop pydantic compat imports now that pydantic is fixed (#94) ([`00adc2c`](https://github.com/uilibs/uiprotect/commit/00adc2cc39cf004e93952a8ef489ef1051c1fb83))
## v1.19.2 (2024-06-20)
### Fix
- Ensure update_from_dict creates the object is it was previously none (#93) ([`f268c01`](https://github.com/uilibs/uiprotect/commit/f268c01bac2b9969f10de70dae2295ce87a6f70b))
## v1.19.1 (2024-06-19)
### Fix
- Update broken documentation readme link (#90) ([`1580c04`](https://github.com/uilibs/uiprotect/commit/1580c042d04d989e1ebe4b919df3d232ae4e8ae9))
## v1.19.0 (2024-06-17)
### Feature
- Simplify websocket stats logic (#88) ([`5b01f34`](https://github.com/uilibs/uiprotect/commit/5b01f34b9c5cc8bcb3cae9f274acd687870a4091))
### Fix
- Refactoring error in 83 (#89) ([`ed477c2`](https://github.com/uilibs/uiprotect/commit/ed477c288047fd1fba39f51d6e695adb6a72ba08))
## v1.18.1 (2024-06-17)
### Fix
- Ensure camera and chime keys are not included in the base ignored set (#86) ([`02ab5f6`](https://github.com/uilibs/uiprotect/commit/02ab5f696db9497610ec6b34739452abdfe6ca68))
- Ignore cameraids for chime updates (#85) ([`3a7e48d`](https://github.com/uilibs/uiprotect/commit/3a7e48dea4111eb6b0a6012ffe08cafcd66cf4d6))
## v1.18.0 (2024-06-17)
### Feature
- Add repr for websocket packets (#84) ([`60dd356`](https://github.com/uilibs/uiprotect/commit/60dd356a233ab183c31375417ded3f6e53427e5d))
### Refactor
- Avoid writing out some more key converts (#83) ([`851c798`](https://github.com/uilibs/uiprotect/commit/851c7987b772a185fd4c448dddd9e180fd4f16da))
## v1.17.0 (2024-06-17)
### Feature
- Improve performance of websocket packet processing (#82) ([`58df1c3`](https://github.com/uilibs/uiprotect/commit/58df1c3ac1c050c418d6ea6255ce18ad64422168))
### Refactor
- Remove and consolidate unused code in base (#81) ([`523d931`](https://github.com/uilibs/uiprotect/commit/523d931f6a06b7c66fc7af7cdfac2abf8ebaa737))
- Use tuples for all the delete iterators (#80) ([`9ec88ce`](https://github.com/uilibs/uiprotect/commit/9ec88ce68ab5c0d9f6cb30175eb4ffd9b4a47d43))
- Cleanup debug (#79) ([`7883c24`](https://github.com/uilibs/uiprotect/commit/7883c24c9b9a08e41ec044e943e6fab3b66a56f1))
- Reduce code to remove keys (#78) ([`7b496cb`](https://github.com/uilibs/uiprotect/commit/7b496cb72b3b5efffad18bb86f58355e910122e7))
## v1.16.0 (2024-06-17)
### Feature
- Refactor protect obj methods to use comprehensions (#77) ([`ae4cdb9`](https://github.com/uilibs/uiprotect/commit/ae4cdb914b162c756f8384c0c25f256fbaa634d7))
## v1.15.0 (2024-06-17)
### Feature
- Small cleanup to get device functions (#76) ([`86f18d8`](https://github.com/uilibs/uiprotect/commit/86f18d8901d8fd9b6e2ebfa9c3926ed1d1d0e45c))
## v1.14.0 (2024-06-17)
### Feature
- Optimize update_from_dict (#75) ([`1b8ed6d`](https://github.com/uilibs/uiprotect/commit/1b8ed6dc146c0351927eeb15c47373481b3ad40e))
## v1.13.0 (2024-06-16)
### Feature
- Improve performance of processing websocket messages (#74) ([`84277cb`](https://github.com/uilibs/uiprotect/commit/84277cb3ac8b47e8d6b483ace8e31c0d9b07baad))
## v1.12.1 (2024-06-16)
### Fix
- Ensure ping back messages are called back and empty updates excluded (#62) ([`b319dba`](https://github.com/uilibs/uiprotect/commit/b319dba4b88e0a7d7b237ec57f2e89ca46c1cc6c))
## v1.12.0 (2024-06-16)
### Fix
- Add missing eventstats key to stats_keys (#73) ([`6c8be31`](https://github.com/uilibs/uiprotect/commit/6c8be3129c763d6ade16c57df01cc79d57190fef))
### Feature
- Small cleanups to bootstrap code (#72) ([`78e6dbb`](https://github.com/uilibs/uiprotect/commit/78e6dbb8165b97522b7f42d8f9e885f0e23cd1eb))
## v1.11.1 (2024-06-16)
### Fix
- Revert to using protected attrs for property cache (#71) ([`f0b259c`](https://github.com/uilibs/uiprotect/commit/f0b259caaf7c990de68f1a51a0bd166f94eb3bf7))
## v1.11.0 (2024-06-16)
### Feature
- Speed up bootstrap by adding cached_property (#68) ([`c6b746d`](https://github.com/uilibs/uiprotect/commit/c6b746d8e4d961c0fc1f98d693357e9becd26baa))
## v1.10.0 (2024-06-16)
### Feature
- Make websocket dataclasses sloted (#67) ([`58e42f6`](https://github.com/uilibs/uiprotect/commit/58e42f69b7603ab77ffe170d091051febe22e48f))
## v1.9.0 (2024-06-15)
### Feature
- Improve performance of websocket message processing (#66) ([`d6a6472`](https://github.com/uilibs/uiprotect/commit/d6a6472d3516e27dcfdd2ed3b5d8ca68428e273f))
## v1.8.0 (2024-06-15)
### Feature
- Replace some attrs with cached methods (#65) ([`fc0fc57`](https://github.com/uilibs/uiprotect/commit/fc0fc5717a171eb705dce4f88dca79509bd889b4))
### Refactor
- Delete unused bootstrap constants (#64) ([`0283c45`](https://github.com/uilibs/uiprotect/commit/0283c4564c905bee1b1f82cc4c0280a02e07ec5d))
- Small cleanups to _process_add_packet (#63) ([`8fd8280`](https://github.com/uilibs/uiprotect/commit/8fd82800b63c7cb8c70da164dcc3e1853fc170a6))
## v1.7.2 (2024-06-14)
### Fix
- Pingback did not hold a strong reference to the task (#61) ([`7b11ce9`](https://github.com/uilibs/uiprotect/commit/7b11ce952a9e2f66fc5ac9ceccd1a21e74c218b9))
## v1.7.1 (2024-06-14)
### Fix
- Refactoring error in _process_add_packet (#60) ([`e21516b`](https://github.com/uilibs/uiprotect/commit/e21516b212762955a49d6da66f2f823a1b252ca2))
## v1.7.0 (2024-06-14)
### Feature
- Add debug logging when saving device changes (#59) ([`1c57d00`](https://github.com/uilibs/uiprotect/commit/1c57d005f8f97c148b70401256929c262ba5a8a1))
### Refactor
- Cleanup duplicate doorbell text code (#58) ([`5e3fac8`](https://github.com/uilibs/uiprotect/commit/5e3fac8b862dfe7df83fe7b5b565578f494b8bf1))
## v1.6.0 (2024-06-14)
### Feature
- Simplify object conversions (#55) ([`feb8236`](https://github.com/uilibs/uiprotect/commit/feb8236d7e1817a604186a493d57511fff455e47))
## v1.5.0 (2024-06-14)
### Feature
- Make audio_type a cached_property (#54) ([`50d22de`](https://github.com/uilibs/uiprotect/commit/50d22de5bbf03328c307c7710015e6ec62ab6826))
## v1.4.1 (2024-06-14)
### Fix
- Use none instead of ... for privateattr (#53) ([`fc06f42`](https://github.com/uilibs/uiprotect/commit/fc06f420b6c4531dd59bfa3db8b53a965409cac0))
## v1.4.0 (2024-06-14)
### Feature
- Only process incoming websocket packet model type once (#52) ([`57d7c10`](https://github.com/uilibs/uiprotect/commit/57d7c10d3915fbf45dd81a855298530a36b9e3c7))
## v1.3.0 (2024-06-13)
### Feature
- Cleanup duplicate object lookups in event processing (#51) ([`ec00121`](https://github.com/uilibs/uiprotect/commit/ec001218a39f7ec10bcc18005e59a1130f16f8aa))
## v1.2.2 (2024-06-13)
### Fix
- Restore some unreachable code in _process_device_update (#50) ([`c638cd3`](https://github.com/uilibs/uiprotect/commit/c638cd3b087d63279bd8f798bd8831fc2e11a916))
## v1.2.1 (2024-06-13)
### Fix
- Blocking i/o in the event loop (#49) ([`36a4355`](https://github.com/uilibs/uiprotect/commit/36a4355170566b9d7cfb1632d9c35c28b693d9ce))
## v1.2.0 (2024-06-13)
### Feature
- Avoid fetching and iterating convert keys when empty (#48) ([`7c9ae89`](https://github.com/uilibs/uiprotect/commit/7c9ae89ed667bbe3e9ca2f5561489d4b8335180e))
### Style
- Remove ide workspace files and add the directories for them to the gitignore (#47) ([`486e3f9`](https://github.com/uilibs/uiprotect/commit/486e3f92f4d12ab195f0433e599c9eac0f008aef))
## v1.1.0 (2024-06-12)
### Feature
- Remove _get_frame_data helper (#45) ([`21d6768`](https://github.com/uilibs/uiprotect/commit/21d6768132d553cc9f59e73cc7adbfde02a42915))
### Refactor
- Consolidate logic to remove keys (#44) ([`9da56d2`](https://github.com/uilibs/uiprotect/commit/9da56d2c0f094d31b0cf8cba07c4c07fd96c64ea))
- Use new _event_is_in_range helper in _process_camera_event (#43) ([`49e0a67`](https://github.com/uilibs/uiprotect/commit/49e0a67c5f2473ae1a6bfbe3db513a77786a68df))
- Reduce duplicate code to process sensor events (#41) ([`78c291b`](https://github.com/uilibs/uiprotect/commit/78c291b76a0cbce1f891f91c9c01236d71edbf81))
## v1.0.1 (2024-06-11)
### Fix
- New cookie flag preventing auth cookie from being stored (#36) ([`b6eb7fc`](https://github.com/uilibs/uiprotect/commit/b6eb7fcef23885d734ba0f9031bf15bdbba91bc5))
## v1.0.0 (2024-06-11)
### Breaking
- Remove unused is_ready property from the api client (#33) ([`c36ee42`](https://github.com/uilibs/uiprotect/commit/c36ee422ddd04f811019d2e99cbb1d6b398eae01))
### Refactor
- Use internal self._api inside the object (#34) ([`c20e7a9`](https://github.com/uilibs/uiprotect/commit/c20e7a9690a15f42ff0f17105141f21b2e6e4020))
## v0.15.1 (2024-06-11)
### Fix
- Missing url param in websocket disconnected error log message (#32) ([`60e6511`](https://github.com/uilibs/uiprotect/commit/60e651110ed935bb0c35b09aedbc2253a73c35a4))
## v0.15.0 (2024-06-11)
### Feature
- Cache bootstrap on the protectapiclient once it has been initialized (#31) ([`185e47f`](https://github.com/uilibs/uiprotect/commit/185e47fed693c5a6f8383cece10c5267dbb7e046))
## v0.14.0 (2024-06-11)
### Feature
- Cache parsing of datetimes (#29) ([`8b6747a`](https://github.com/uilibs/uiprotect/commit/8b6747ae41d483da7395f49e402e29f68112fe83))
### Refactor
- Use f-strings in more places (#28) ([`22706c8`](https://github.com/uilibs/uiprotect/commit/22706c896121eac3b6847a951ef516f350119072))
## v0.13.0 (2024-06-11)
### Feature
- Cleanup processing camera events (#27) ([`2c1a266`](https://github.com/uilibs/uiprotect/commit/2c1a266a3f7c290e4ae9724642eb427ca41cabf1))
## v0.12.0 (2024-06-11)
### Feature
- Cleanup websocket add/remove packet processing (#25) ([`fdf0f6e`](https://github.com/uilibs/uiprotect/commit/fdf0f6eef96c17c0d2afe008444c24ce8fad72ee))
- Use a single function to normalize mac addresses (#26) ([`7ce8654`](https://github.com/uilibs/uiprotect/commit/7ce86543d4ec1efa9143839b1b7be1c6dd977ca1))
## v0.11.0 (2024-06-11)
### Feature
- Cleanup processing of websocket packets (#24) ([`b59e19c`](https://github.com/uilibs/uiprotect/commit/b59e19c13ea48e5ab235090c1b02d8d73c3aac24))
## v0.10.1 (2024-06-11)
### Fix
- Remove useless time check (#23) ([`749cfef`](https://github.com/uilibs/uiprotect/commit/749cfef9b44f87397153977c673c577659450a48))
## v0.10.0 (2024-06-11)
### Feature
- Improve performance of process websocket packets (#22) ([`7b59c98`](https://github.com/uilibs/uiprotect/commit/7b59c98d02d2f874375b168979a1db253da58914))
## v0.9.0 (2024-06-10)
### Feature
- Avoid linear searches to process websocket packets (#21) ([`86d5f19`](https://github.com/uilibs/uiprotect/commit/86d5f198071b0478b480804d055ed80c88341ee1))
## v0.8.0 (2024-06-10)
### Feature
- Guard debug logging that reformats data in the arguments (#20) ([`0cfdea8`](https://github.com/uilibs/uiprotect/commit/0cfdea8d27c0a35d71cd98d65120288218f4ca4c))
### Refactor
- Remove useless .keys() calls (#19) ([`ec1fd12`](https://github.com/uilibs/uiprotect/commit/ec1fd129deb06b5d2334d49ccd0b238033c5b904))
## v0.7.0 (2024-06-10)
### Feature
- Refactor protect object subtype bucketing (#18) ([`e4123ac`](https://github.com/uilibs/uiprotect/commit/e4123ac13015c186f141c1bfec3a7c064bb2d732))
## v0.6.0 (2024-06-10)
### Feature
- Small code cleanups (#17) ([`f1668ae`](https://github.com/uilibs/uiprotect/commit/f1668ae2c9c9f49f6e703a387159d305c2cba847))
## v0.5.0 (2024-06-10)
### Feature
- Memoize enum type check to speed up data conversion (#15) ([`73b0c4a`](https://github.com/uilibs/uiprotect/commit/73b0c4a813e99d3f353a8fbf3d8a997158cedf3a))
## v0.4.1 (2024-06-10)
### Fix
- Handle unifi os 4 token change (#14) ([`a6aab8f`](https://github.com/uilibs/uiprotect/commit/a6aab8f1eefd631119288f6d29d643f3984c5b0d))
## v0.4.0 (2024-06-10)
### Feature
- Avoid parsing last_update_id (#12) ([`ac86b13`](https://github.com/uilibs/uiprotect/commit/ac86b13b3efc8fc619471536ea993f3741882264))
## v0.3.10 (2024-06-10)
### Fix
- Add missing doorbellmessagetype image (#11) ([`eaed04b`](https://github.com/uilibs/uiprotect/commit/eaed04bbc1697553895a64edc573d1acc9112a1a))
## v0.3.9 (2024-06-09)
### Fix
- Revert global flags check (#9) ([`8dc437f`](https://github.com/uilibs/uiprotect/commit/8dc437f38dc4f6f6081d9a8a80f9f295b31bf579))
## v0.3.8 (2024-06-09)
### Fix
- Improve readme and testdata docs (#8) ([`90ae6a8`](https://github.com/uilibs/uiprotect/commit/90ae6a8cec7a10c1631b301a5d64c94bffdee16d))
## v0.3.7 (2024-06-09)
### Fix
- Revert pydantic changes for ha compat (#7) ([`c7770c1`](https://github.com/uilibs/uiprotect/commit/c7770c135deaa52da078794c67d5e3f5dbe3455d))
## v0.3.6 (2024-06-09)
### Fix
- 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)
### Fix
- Add missing docs deps ([`399de45`](https://github.com/uilibs/uiprotect/commit/399de45721cb72c1cd6c945ad9aa0d73d82dea8f))
## v0.3.4 (2024-06-09)
### Fix
- Small fixes for readme.md (#6) ([`7a0acf4`](https://github.com/uilibs/uiprotect/commit/7a0acf4da9cfcc1cbf6111cc9d2083be68aa9d93))
## v0.3.3 (2024-06-09)
### Fix
- Ensure uv is installed for docker image ([`d286198`](https://github.com/uilibs/uiprotect/commit/d286198ce4d26ff5151c9b937058b4c223aa95f2))
## v0.3.2 (2024-06-09)
### Fix
- 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)
### Fix
- Dockerfile ([`b25d8a1`](https://github.com/uilibs/uiprotect/commit/b25d8a1218158368ec50d1a2b20280b94696ccee))
- Docker ci (#5) ([`3d8e9fe`](https://github.com/uilibs/uiprotect/commit/3d8e9fe294c7c75a7efc2d2653a51fdb052fbf29))
## v0.3.0 (2024-06-09)
### Feature
- Migrate docs (#4) ([`1e62ec2`](https://github.com/uilibs/uiprotect/commit/1e62ec204c6d1b26f95486a8c27a61bb40a8219b))
## v0.2.2 (2024-06-09)
### Fix
- Readme updates (#3) ([`8cf5d24`](https://github.com/uilibs/uiprotect/commit/8cf5d24915e9aed2ffbdce4390dd061c9c40d4a1))
## v0.2.1 (2024-06-09)
### Fix
- Adjust jinja check for changelog template ([`e5f55c1`](https://github.com/uilibs/uiprotect/commit/e5f55c1f1af84d3f9053bf9b36c3662dab706882))
- Changelog generation (#2) ([`2b770e9`](https://github.com/uilibs/uiprotect/commit/2b770e9a4a6ccfa352fd0fc2b30099ef07b59db8))
## v0.2.0 (2024-06-09)
### Feature
- Update classifiers (#1) ([`0d4eaf6`](https://github.com/uilibs/uiprotect/commit/0d4eaf6e5fe30c83c52d30d388d65ebe33ee7c3f))
### Unknown
### Fix
- Re-enable changelog ([`68620b0`](https://github.com/uilibs/uiprotect/commit/68620b09b65ee553982c2c54bfc1e0a3c6ba4380))
## v0.1.0 (2024-06-09)
### Fix
- 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
### Fix
- Actually set chime_duration ([`e7edd26`](https://github.com/uilibs/uiprotect/commit/e7edd26823505f73e97b1a46e70f397a95126a3f))
### Feature
- Make chime duration adjustable ([`b4d13c1`](https://github.com/uilibs/uiprotect/commit/b4d13c146f292eae216109f747d3bee6608b0f28))
uiprotect-6.1.0/CONTRIBUTING.md 0000664 0000000 0000000 00000007407 14673102202 0015772 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
```
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: the commit message should follow [the conventional commits](https://www.conventionalcommits.org). We run [`commitlint` on CI](https://github.com/marketplace/actions/commit-linter) to validate it, and if you've installed pre-commit hooks at the previous step, the message will be checked at commit time.
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
uiprotect-6.1.0/Dockerfile 0000664 0000000 0000000 00000004631 14673102202 0015527 0 ustar 00root root 0000000 0000000 FROM python:3.12-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
RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \
apt-get update -qq \
&& apt-get install -yqq ffmpeg
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
FROM base as prod
ARG UIPROTECT_VERSION
COPY --from=builder /usr/local/bin/ /usr/local/bin/
COPY --from=builder /usr/local/lib/python3.12/ /usr/local/lib/python3.12/
RUN --mount=source=.,target=/tmp/uiprotect,type=bind,readwrite \
--mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
SETUPTOOLS_SCM_PRETEND_VERSION=${UIPROTECT_VERSION} uv pip install -U "/tmp/uiprotect[tz]" \
&& cp /tmp/uiprotect/.docker/entrypoint.sh /usr/local/bin/entrypoint \
&& 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
RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \
poetry install
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.12/ /usr/local/lib/python3.12/
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 curl 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/
uiprotect-6.1.0/LICENSE 0000664 0000000 0000000 00000002127 14673102202 0014540 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.
uiprotect-6.1.0/LIVE_DATA_CI.md 0000664 0000000 0000000 00000012053 14673102202 0015757 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.
uiprotect-6.1.0/README.md 0000664 0000000 0000000 00000022310 14673102202 0015006 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
---
Python API for UniFi Protect (Unofficial)
## Looking for maintainers
This project is looking for maintainers.
## Installation
Install this via pip (or your favorite package manager):
`pip install uiprotect`
## 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
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
`uiprotect` is an unofficial API for UniFi Protect. There is no affiliation with Ubiquiti.
This module communicates with UniFi Protect surveillance software installed on a UniFi OS Console such as a Ubiquiti CloudKey+ or UniFi Dream Machine Pro.
The API is not documented by Ubiquiti, so there might be misses and/or frequent changes in this module, as Ubiquiti evolves the software.
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.
## Smart Detections now Require Remote Access to enable
Smart Detections (person, vehicle, animal, face), a feature that previously could be used with local only console, [now requires you to enable remote access to enable](https://community.ui.com/questions/Cannot-enable-Smart-Detections/e3d50641-5c00-4607-9723-453cda557e35#answer/1d146426-89aa-4022-a0ae-fd5000846028).
Enabling Remote Access may grant other users access to your console [due to the fact Ubiquiti can reconfigure access controls at any time](https://community.ui.com/questions/Bug-Fix-Cloud-Access-Misconfiguration/fe8d4479-e187-4471-bf95-b2799183ceb7).
If you are not okay with the feature being locked behind Remote Access, [let Ubiquiti know](https://community.ui.com/questions/Cannot-enable-Smart-Detections/e3d50641-5c00-4607-9723-453cda557e35).
## Documentation
[Full documentation for the project](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 1.20+
- Latest version of library is generally only tested against the two latest minor version. This is either two latest stable versions (such as 1.21.x and 2.0.x) or the latest EA version and stable version (such as 2.2.x EA and 2.1.x).
- [Python](https://www.python.org/) 3.10+
- POSIX compatible system
- Library is only tested on Linux, specifically the latest Debian version available for the official Python Docker images, but there is no reason the library should not work on any Linux distro or macOS.
- [ffmpeg](https://ffmpeg.org/)
- ffmpeg is primarily only for streaming audio to Protect cameras, this can be considered a soft requirement
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).
## Install
### From PyPi
`uiprotect` is available on PyPi:
```bash
pip install uiprotect
```
### From GitHub
```bash
pip install git+https://github.com/uilibs/uiprotect.git#egg=uiprotect
```
### 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=True \
-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.
```bash
export UFP_USERNAME=YOUR_USERNAME_HERE
export UFP_PASSWORD=YOUR_PASSWORD_HERE
export UFP_ADDRESS=YOUR_IP_ADDRESS
export UFP_PORT=443
# change to false if you do not have a valid HTTPS certificate for your instance
export UFP_SSL_VERIFY=True
uiprotect --help
uiprotect nvr
```
### 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
protect = ProtectApiClient(host, port, username, password, 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()
```
## TODO / Planned / Not Implemented
Generally any feature missing from the library is planned to be done eventually / nice to have with the following exceptions
### UniFi OS Features
Anything that is strictly a UniFi OS feature. If it is ever done, it will be in a separate library that interacts with this one. Examples include:
- Managing RAID and disks
- Creating and managing users
### Remote Access / Ubiquiti Cloud Features
Some features that require an Ubiquiti Account or "Remote Access" to be enabled are currently not implemented. Examples include:
- Stream sharing
- Face detection
uiprotect-6.1.0/TESTDATA.md 0000664 0000000 0000000 00000003171 14673102202 0015266 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.10+ 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.
uiprotect-6.1.0/commitlint.config.mjs 0000664 0000000 0000000 00000000362 14673102202 0017670 0 ustar 00root root 0000000 0000000 export default {
extends: ["@commitlint/config-conventional"],
rules: {
"header-max-length": [0, "always", Infinity],
"body-max-line-length": [0, "always", Infinity],
"footer-max-line-length": [0, "always", Infinity],
},
};
uiprotect-6.1.0/docs/ 0000775 0000000 0000000 00000000000 14673102202 0014461 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/docs/Makefile 0000664 0000000 0000000 00000001372 14673102202 0016124 0 ustar 00root root 0000000 0000000 # Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
.PHONY: help livehtml Makefile
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
# Build, watch and serve docs with live reload
livehtml:
sphinx-autobuild -b html -c . $(SOURCEDIR) $(BUILDDIR)/html
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
uiprotect-6.1.0/docs/_static/ 0000775 0000000 0000000 00000000000 14673102202 0016107 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/docs/_static/.gitkeep 0000664 0000000 0000000 00000000000 14673102202 0017526 0 ustar 00root root 0000000 0000000 uiprotect-6.1.0/docs/api.md 0000664 0000000 0000000 00000002005 14673102202 0015551 0 ustar 00root root 0000000 0000000 ---
hide:
- navigation
toc_depth: 3
---
# API Reference
## API Client (`uiprotect.api`)
::: uiprotect.api
options:
show_root_toc_entry: false
show_signature_annotations: true
show_source: false
heading_level: 3
## Data Models (`uiprotect.data`)
::: uiprotect.data
options:
show_root_toc_entry: false
show_signature_annotations: true
show_source: false
heading_level: 3
## Exceptions (`uiprotect.exception`)
::: uiprotect.exceptions
options:
show_root_toc_entry: false
show_signature_annotations: true
show_source: false
heading_level: 3
## Stream (`uiprotect.stream`)
::: uiprotect.stream
options:
show_root_toc_entry: false
show_signature_annotations: true
show_source: false
heading_level: 3
## Utils (`uiprotect.utils`)
::: uiprotect.utils
options:
show_root_toc_entry: false
show_signature_annotations: true
show_source: false
heading_level: 3
## Websocket (`uiprotect.websocket`)
::: uiprotect.websocket
options:
show_root_toc_entry: false
show_signature_annotations: true
show_source: false
heading_level: 3
uiprotect-6.1.0/docs/changelog.md 0000664 0000000 0000000 00000000060 14673102202 0016726 0 ustar 00root root 0000000 0000000 (changelog)=
```{include} ../CHANGELOG.md
```
uiprotect-6.1.0/docs/cli.md 0000664 0000000 0000000 00000057505 14673102202 0015566 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
# change to false if you do not have a valid HTTPS Certificate for your instance
export UFP_SSL_VERIFY=True
uiprotect nvr
```
### CLI Args
```bash
uiprotect -U YOUR_USERNAME_HERE -P YOUR_PASSWORD_HERE -a YOUR_IP_ADDRESS -p 443 --no-verify 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 |
| | `--no-verify` | | `UFP_SSL_VERIFY` | boolean | `True` | Verify SSL |
| | `--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`
| Command | Description |
| ---------------------- | ---------------------------------------------------------------- |
| `backup` | [Backup CLI](#backup-cli). |
| `cameras` | Camera device CLI. |
| `chimes` | Chime device CLI. |
| `decode-ws-msg` | Decodes a base64 encoded UniFi Protect Websocket binary message. |
| `doorlocks` | Doorlock device CLI. |
| `events` | Events CLI. |
| `generate-sample-data` | Generates sample data for UniFi Protect instance. |
| `lights` | Lights device CLI. |
| `liveviews` | Liveviews CLI. |
| `nvr` | NVR device CLI. |
| `profile-ws` | Profiles Websocket messages for UniFi Protect instance. |
| `sensors` | Sensors device CLI. |
| `shell` | Opens iPython shell with Protect client initialized. |
| `viewers` | Viewers device CLI. |
#### Multiple Item CLI Commands
All adoptable device CLIs, event and liveview CLI work on the idea you have multiple cameras, multiple lights, multiple events or multiple liveviews. As such, they have four variations:
```bash
# list all devices (or events/liveviews)
uiprotect cameras
# list short list of all devices (or events/liveviews)
uiprotect cameras list-ids
# list a specific device (or event/liveview)
uiprotect cameras DEVICE_ID
# run a command against a specific device (or event/liveview)
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 Done"
],
...
]
```
###### Check if a Light is Online
```bash
$ uiprotect cameras 61ddb66b018e2703e7008c19 | jq .isConnected
true
```
###### Take Snapshot of Camera
```bash
$ uiprotectcameras 61ddb66b018e2703e7008c19 save-snapshot output.jpg
```
#### Adoptable Devices CLI Commands
Adoptable devices (Cameras, Chimes, Doorlocks, 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. |
#### 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](#timezone) datetime of start of export. Uses `sep` between parts. |
| `date_local` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted [Local](#timezone) 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](#timezone) for determined 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 lights 61b3f5c801f8a703e7000428 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
```
uiprotect-6.1.0/docs/conf.py 0000664 0000000 0000000 00000001235 14673102202 0015761 0 ustar 00root root 0000000 0000000 # Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# Project information
project = "uiprotect"
copyright = "2024, UI Protect Maintainers"
author = "UI Protect Maintainers"
release = "6.1.0"
# General configuration
extensions = [
"myst_parser",
]
# The suffix of source filenames.
source_suffix = [
".rst",
".md",
]
templates_path = [
"_templates",
]
exclude_patterns = [
"_build",
"Thumbs.db",
".DS_Store",
]
# Options for HTML output
html_theme = "furo"
html_static_path = ["_static"]
uiprotect-6.1.0/docs/contributing.md 0000664 0000000 0000000 00000000066 14673102202 0017514 0 ustar 00root root 0000000 0000000 (contributing)=
```{include} ../CONTRIBUTING.md
```
uiprotect-6.1.0/docs/dev.md 0000664 0000000 0000000 00000010457 14673102202 0015570 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 /home/cbailey/dev/uiprotect:/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
# change to false if you do not have a valid HTTPS Certificate for your instance
UFP_SSL_VERIFY=True
```
## 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.
## Updating Requirements
To generate an updated pinned requirements file to be used for testing and CI using the `.bin/update-requirements` 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
```
uiprotect-6.1.0/docs/index.md 0000664 0000000 0000000 00000000104 14673102202 0016105 0 ustar 00root root 0000000 0000000 ---
hide:
- navigation
---
{% include-markdown "../README.md" %}
uiprotect-6.1.0/docs/installation.md 0000664 0000000 0000000 00000000411 14673102202 0017500 0 ustar 00root root 0000000 0000000 (installation)=
# 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
```
Next, see the {ref}`section about usage ` to see how to use it.
uiprotect-6.1.0/docs/make.bat 0000664 0000000 0000000 00000001375 14673102202 0016074 0 ustar 00root root 0000000 0000000 @ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd
uiprotect-6.1.0/docs/overrides/ 0000775 0000000 0000000 00000000000 14673102202 0016463 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/docs/overrides/main.html 0000664 0000000 0000000 00000000273 14673102202 0020277 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 %}
uiprotect-6.1.0/docs/overrides/partials/ 0000775 0000000 0000000 00000000000 14673102202 0020302 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/docs/overrides/partials/toc-item.html 0000664 0000000 0000000 00000000702 14673102202 0022710 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 %}
uiprotect-6.1.0/docs/usage.md 0000664 0000000 0000000 00000000334 14673102202 0016107 0 ustar 00root root 0000000 0000000 (usage)=
# Usage
Assuming that you've followed the {ref}`installations steps `, you're now ready to use this package.
Call the command line interface:
```bash
uiprotect --help
```
TODO: Document usage
uiprotect-6.1.0/mkdocs.yml 0000664 0000000 0000000 00000003675 14673102202 0015547 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"
- Development: "dev.md"
- Command Line: "cli.md"
- API Reference: "api.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]
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
uiprotect-6.1.0/poetry.lock 0000664 0000000 0000000 00000731355 14673102202 0015743 0 ustar 00root root 0000000 0000000 # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aiofiles"
version = "24.1.0"
description = "File support for asyncio."
optional = false
python-versions = ">=3.8"
files = [
{file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"},
{file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"},
]
[[package]]
name = "aiohappyeyeballs"
version = "2.3.5"
description = "Happy Eyeballs for asyncio"
optional = false
python-versions = ">=3.8"
files = [
{file = "aiohappyeyeballs-2.3.5-py3-none-any.whl", hash = "sha256:4d6dea59215537dbc746e93e779caea8178c866856a721c9c660d7a5a7b8be03"},
{file = "aiohappyeyeballs-2.3.5.tar.gz", hash = "sha256:6fa48b9f1317254f122a07a131a86b71ca6946ca989ce6326fff54a99a920105"},
]
[[package]]
name = "aiohttp"
version = "3.10.5"
description = "Async http client/server framework (asyncio)"
optional = false
python-versions = ">=3.8"
files = [
{file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"},
{file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"},
{file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"},
{file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"},
{file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"},
{file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"},
{file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"},
{file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"},
{file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"},
{file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"},
{file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"},
{file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"},
{file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"},
{file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"},
{file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"},
{file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"},
{file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"},
{file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"},
{file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"},
{file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"},
{file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"},
{file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"},
{file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"},
{file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"},
{file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"},
{file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"},
{file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"},
{file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"},
{file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"},
{file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"},
{file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"},
{file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"},
{file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"},
{file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"},
{file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"},
{file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"},
{file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"},
{file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"},
{file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"},
{file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"},
{file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"},
{file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"},
{file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"},
{file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"},
{file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"},
{file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"},
{file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"},
{file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"},
{file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"},
{file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"},
{file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"},
{file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"},
{file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"},
{file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"},
{file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"},
{file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"},
{file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"},
{file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"},
{file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"},
{file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"},
{file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"},
{file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"},
{file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"},
{file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"},
{file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"},
{file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"},
{file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"},
{file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"},
{file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"},
{file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"},
{file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"},
{file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"},
{file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"},
{file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"},
{file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"},
{file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"},
{file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"},
{file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"},
{file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"},
{file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"},
{file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"},
{file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"},
{file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"},
{file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"},
{file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"},
{file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"},
{file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"},
{file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"},
{file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"},
{file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"},
{file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"},
]
[package.dependencies]
aiohappyeyeballs = ">=2.3.0"
aiosignal = ">=1.1.2"
async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""}
attrs = ">=17.3.0"
frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0"
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
[[package]]
name = "aioshutil"
version = "1.5"
description = "Asynchronous shutil module."
optional = false
python-versions = ">=3.8"
files = [
{file = "aioshutil-1.5-py3-none-any.whl", hash = "sha256:bc2a6cdcf1a8615b62f856154fd81131031d03f2834912ebb06d8a2391253652"},
{file = "aioshutil-1.5.tar.gz", hash = "sha256:2756d6cd3bb03405dc7348ac11a0b60eb949ebd63cdd15f56e922410231c1201"},
]
[[package]]
name = "aiosignal"
version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks"
optional = false
python-versions = ">=3.7"
files = [
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
]
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "aiosqlite"
version = "0.20.0"
description = "asyncio bridge to the standard sqlite3 module"
optional = false
python-versions = ">=3.8"
files = [
{file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"},
{file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"},
]
[package.dependencies]
typing_extensions = ">=4.0"
[package.extras]
dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"]
docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"]
[[package]]
name = "alabaster"
version = "0.7.16"
description = "A light, configurable Sphinx theme"
optional = false
python-versions = ">=3.9"
files = [
{file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"},
{file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"},
]
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
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 = "anyio"
version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]]
name = "asttokens"
version = "2.4.1"
description = "Annotate AST trees with source code positions"
optional = false
python-versions = "*"
files = [
{file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"},
{file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"},
]
[package.dependencies]
six = ">=1.12.0"
[package.extras]
astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"]
test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"]
[[package]]
name = "async-timeout"
version = "4.0.3"
description = "Timeout context manager for asyncio programs"
optional = false
python-versions = ">=3.7"
files = [
{file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"},
{file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
]
[[package]]
name = "attrs"
version = "23.2.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.7"
files = [
{file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
{file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
]
[package.extras]
cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
dev = ["attrs[tests]", "pre-commit"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
tests = ["attrs[tests-no-zope]", "zope-interface"]
tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
[[package]]
name = "babel"
version = "2.15.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
files = [
{file = "Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb"},
{file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"},
]
[package.extras]
dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
[[package]]
name = "beautifulsoup4"
version = "4.12.3"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.6.0"
files = [
{file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
{file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
]
[package.dependencies]
soupsieve = ">1.2"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "bracex"
version = "2.4"
description = "Bash style brace expander."
optional = false
python-versions = ">=3.8"
files = [
{file = "bracex-2.4-py3-none-any.whl", hash = "sha256:efdc71eff95eaff5e0f8cfebe7d01adf2c8637c8c92edaf63ef348c241a82418"},
{file = "bracex-2.4.tar.gz", hash = "sha256:a27eaf1df42cf561fed58b7a8f3fdf129d1ea16a81e1fadd1d17989bc6384beb"},
]
[[package]]
name = "certifi"
version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
]
[[package]]
name = "charset-normalizer"
version = "3.3.2"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7.0"
files = [
{file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
{file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
{file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
{file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
{file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
{file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
{file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
{file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
{file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
{file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
{file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
{file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
{file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
{file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
{file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
{file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
{file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
{file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
{file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
{file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
{file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
{file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
{file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
{file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
{file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
{file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
{file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
{file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
{file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
{file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
{file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
{file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
{file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
{file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
{file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
{file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
{file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
{file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
{file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
{file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
{file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
{file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
{file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
{file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
{file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
{file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
{file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
{file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
{file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
{file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
{file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
{file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
{file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
{file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
{file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
{file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
{file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
{file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
{file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
{file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
{file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
{file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
{file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
{file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
{file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
{file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
{file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
{file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
{file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
{file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
{file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
{file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
{file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
{file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
{file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
{file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
{file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
{file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[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"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "convertertools"
version = "0.5.0"
description = "Tools for converting python data types"
optional = false
python-versions = ">=3.10"
files = [
{file = "convertertools-0.5.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:b723a3486f6250d5d738a31591776cbb20378177030a29f0e4fda56d7b99e795"},
{file = "convertertools-0.5.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:fbf4a533608c525dfbbd9b931e290dd679a74a2d6630f0f2e04d1fa44b33f3b0"},
{file = "convertertools-0.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8069e4c6bc908b370bfdb9129af5011d40c0704bda265a8f56345001d0478e6b"},
{file = "convertertools-0.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cfea4d24a8e988db79b2213124dafdd3d12dc3209c7d1d4d7205a21631bb8888"},
{file = "convertertools-0.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e89fbb97f5f2945e11da457c5888f50ca006f9930a3d8d3a932091403cbea36b"},
{file = "convertertools-0.5.0-cp311-cp311-win32.whl", hash = "sha256:03e2816ea8bd481e0816d27b55a37ba90c01ae9df105d654a1a0e213c6fe425a"},
{file = "convertertools-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:1b7e7dfd3daae6c44e9376297adc9920bd75791ad8de32ebb2f70dcee263e460"},
{file = "convertertools-0.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4a8630056eb0d1250177fec1176b1bf9cf6e92eec7973cb78f238b802cc2003c"},
{file = "convertertools-0.5.0-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:e259aca6b4b9b7a30df25be2d2dafd7985c98fd6ce5f8eb5a43874e5ee1b6016"},
{file = "convertertools-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24fd3d2c71beb77502ceaab9bb99c3d28b18f071e3856e7c39a62c631173fc7e"},
{file = "convertertools-0.5.0-cp312-cp312-manylinux_2_36_x86_64.whl", hash = "sha256:021ff984d888b110040ea86a12ff8d02aa29a6f1b603bf88cd42b990f16e5f4e"},
{file = "convertertools-0.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:723e34b5859c2f7bb1892fe6be71e4d98a89cedd3f9e219b4cb70eb0d94a869a"},
{file = "convertertools-0.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:714dd41ef8bdcba5a9a20c3dc55d604b83b80b603da9ed9ca379893634792eca"},
{file = "convertertools-0.5.0-cp312-cp312-win32.whl", hash = "sha256:cefe9f4be7c600f1e27c1354e8dfd192bcd59f058f99ab93ca1c49abffbae687"},
{file = "convertertools-0.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:4ac2ae852cc6c3e0e3d75fe80276fdecd1889155141364a5c75128c9ff249c29"},
{file = "convertertools-0.5.0.tar.gz", hash = "sha256:477812a307adf368805da5ee2cbc2fe984ed305d76164fe3fbc2a9f9baf4c3f0"},
]
[[package]]
name = "coverage"
version = "7.5.4"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"},
{file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"},
{file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"},
{file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"},
{file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"},
{file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"},
{file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"},
{file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"},
{file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"},
{file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"},
{file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"},
{file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"},
{file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"},
{file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"},
{file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"},
{file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"},
{file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"},
{file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"},
{file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"},
{file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"},
{file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"},
{file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"},
{file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"},
{file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"},
{file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"},
{file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"},
{file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"},
{file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"},
{file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"},
{file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"},
{file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"},
{file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"},
{file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"},
{file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"},
{file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"},
{file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"},
{file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"},
{file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"},
{file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"},
{file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"},
{file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"},
{file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"},
{file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"},
{file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"},
{file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"},
{file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"},
{file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"},
{file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"},
{file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"},
{file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"},
{file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"},
{file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "dateparser"
version = "1.2.0"
description = "Date parsing library designed to parse dates from HTML pages"
optional = false
python-versions = ">=3.7"
files = [
{file = "dateparser-1.2.0-py2.py3-none-any.whl", hash = "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830"},
{file = "dateparser-1.2.0.tar.gz", hash = "sha256:7975b43a4222283e0ae15be7b4999d08c9a70e2d378ac87385b1ccf2cffbbb30"},
]
[package.dependencies]
python-dateutil = "*"
pytz = "*"
regex = "<2019.02.19 || >2019.02.19,<2021.8.27 || >2021.8.27"
tzlocal = "*"
[package.extras]
calendars = ["convertdate", "hijri-converter"]
fasttext = ["fasttext"]
langdetect = ["langdetect"]
[[package]]
name = "docutils"
version = "0.21.2"
description = "Docutils -- Python Documentation Utilities"
optional = false
python-versions = ">=3.9"
files = [
{file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
{file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
]
[[package]]
name = "exceptiongroup"
version = "1.2.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "execnet"
version = "2.1.1"
description = "execnet: rapid multi-Python deployment"
optional = false
python-versions = ">=3.8"
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 = "frozenlist"
version = "1.4.1"
description = "A list-like structure which implements collections.abc.MutableSequence"
optional = false
python-versions = ">=3.8"
files = [
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"},
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"},
{file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"},
{file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"},
{file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"},
{file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"},
{file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"},
{file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"},
{file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"},
{file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"},
{file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"},
{file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"},
{file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"},
{file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"},
{file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
]
[[package]]
name = "furo"
version = "2024.8.6"
description = "A clean customisable Sphinx documentation theme."
optional = false
python-versions = ">=3.8"
files = [
{file = "furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c"},
{file = "furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01"},
]
[package.dependencies]
beautifulsoup4 = "*"
pygments = ">=2.7"
sphinx = ">=6.0,<9.0"
sphinx-basic-ng = ">=1.0.0.beta2"
[[package]]
name = "ghp-import"
version = "2.1.0"
description = "Copy your docs directly to the gh-pages branch."
optional = false
python-versions = "*"
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.11"
description = "Git Object Database"
optional = false
python-versions = ">=3.7"
files = [
{file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"},
{file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"},
]
[package.dependencies]
smmap = ">=3.0.1,<6"
[[package]]
name = "gitpython"
version = "3.1.43"
description = "GitPython is a Python library used to interact with Git repositories"
optional = false
python-versions = ">=3.7"
files = [
{file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"},
{file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"},
]
[package.dependencies]
gitdb = ">=4.0.1,<5"
[package.extras]
doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"]
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"]
[[package]]
name = "griffe"
version = "1.1.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.8"
files = [
{file = "griffe-1.1.0-py3-none-any.whl", hash = "sha256:38ccc5721571c95ae427123074cf0dc0d36bce7c9701ab2ada9fe0566ff50c10"},
{file = "griffe-1.1.0.tar.gz", hash = "sha256:c6328cbdec0d449549c1cc332f59227cd5603f903479d73e4425d828b782ffc3"},
]
[package.dependencies]
colorama = ">=0.4"
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "idna"
version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]]
name = "imagesize"
version = "1.4.1"
description = "Getting image size from png/jpeg/jpeg2000/gif file"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
{file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
]
[[package]]
name = "importlib-metadata"
version = "8.0.0"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "importlib_metadata-8.0.0-py3-none-any.whl", hash = "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f"},
{file = "importlib_metadata-8.0.0.tar.gz", hash = "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812"},
]
[package.dependencies]
zipp = ">=0.5"
[package.extras]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
perf = ["ipython"]
test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"]
[[package]]
name = "importlib-resources"
version = "6.4.0"
description = "Read resources from Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "importlib_resources-6.4.0-py3-none-any.whl", hash = "sha256:50d10f043df931902d4194ea07ec57960f66a80449ff867bfe782b4c486ba78c"},
{file = "importlib_resources-6.4.0.tar.gz", hash = "sha256:cdb2b453b8046ca4e3798eb1d84f3cce1446a0e8e7b5ef4efb600f19fc398145"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["jaraco.test (>=5.4)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.7"
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "jinja2"
version = "3.1.4"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
files = [
{file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
{file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markdown"
version = "3.6"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.8"
files = [
{file = "Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f"},
{file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"},
]
[package.extras]
docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "markupsafe"
version = "2.1.5"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.7"
files = [
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
{file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
{file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
{file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
{file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
{file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
{file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
{file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
{file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
{file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
{file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
{file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
{file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
{file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
{file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
{file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
{file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
{file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
{file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
{file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
]
[[package]]
name = "mdit-py-plugins"
version = "0.4.1"
description = "Collection of plugins for markdown-it-py"
optional = false
python-versions = ">=3.8"
files = [
{file = "mdit_py_plugins-0.4.1-py3-none-any.whl", hash = "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a"},
{file = "mdit_py_plugins-0.4.1.tar.gz", hash = "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c"},
]
[package.dependencies]
markdown-it-py = ">=1.0.0,<4.0.0"
[package.extras]
code-style = ["pre-commit"]
rtd = ["myst-parser", "sphinx-book-theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "mergedeep"
version = "1.3.4"
description = "A deep merge function for ðŸ."
optional = false
python-versions = ">=3.6"
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.1.3"
description = "Manage multiple versions of your MkDocs-powered documentation"
optional = false
python-versions = "*"
files = [
{file = "mike-2.1.3-py3-none-any.whl", hash = "sha256:d90c64077e84f06272437b464735130d380703a76a5738b152932884c60c062a"},
{file = "mike-2.1.3.tar.gz", hash = "sha256:abd79b8ea483fb0275b7972825d3082e5ae67a41820f8d8a0dc7a3f49944e810"},
]
[package.dependencies]
importlib-metadata = "*"
importlib-resources = "*"
jinja2 = ">=2.7"
mkdocs = ">=1.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.0"
description = "Project documentation with Markdown."
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocs-1.6.0-py3-none-any.whl", hash = "sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7"},
{file = "mkdocs-1.6.0.tar.gz", hash = "sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512"},
]
[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)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "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.2.0"
description = "Automatically link across pages in MkDocs."
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f"},
{file = "mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f"},
]
[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"
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.2.8"
description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file."
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocs_git_revision_date_localized_plugin-1.2.8-py3-none-any.whl", hash = "sha256:c7ec3b1481ca23134269e84927bd8a5dc1aa359c0e515b832dbd5d25019b5748"},
{file = "mkdocs_git_revision_date_localized_plugin-1.2.8.tar.gz", hash = "sha256:6e09c308bb27bcf36b211d17b74152ecc2837cdfc351237f70cffc723ef0fd99"},
]
[package.dependencies]
babel = ">=2.7.0"
GitPython = "*"
mkdocs = ">=1.0"
pytz = "*"
[package.extras]
all = ["GitPython", "babel (>=2.7.0)", "click", "codecov", "mkdocs (>=1.0)", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkdocs-material", "mkdocs-static-i18n", "pytest", "pytest-cov", "pytz"]
base = ["GitPython", "babel (>=2.7.0)", "mkdocs (>=1.0)", "pytz"]
dev = ["click", "codecov", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkdocs-material", "mkdocs-static-i18n", "pytest", "pytest-cov"]
[[package]]
name = "mkdocs-include-markdown-plugin"
version = "6.2.2"
description = "Mkdocs Markdown includer plugin."
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocs_include_markdown_plugin-6.2.2-py3-none-any.whl", hash = "sha256:d293950f6499d2944291ca7b9bc4a60e652bbfd3e3a42b564f6cceee268694e7"},
{file = "mkdocs_include_markdown_plugin-6.2.2.tar.gz", hash = "sha256:f2bd5026650492a581d2fd44be6c22f90391910d76582b96a34c264f2d17875d"},
]
[package.dependencies]
mkdocs = ">=1.4"
wcmatch = "*"
[package.extras]
cache = ["platformdirs"]
[[package]]
name = "mkdocs-material"
version = "9.5.34"
description = "Documentation that simply works"
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocs_material-9.5.34-py3-none-any.whl", hash = "sha256:54caa8be708de2b75167fd4d3b9f3d949579294f49cb242515d4653dbee9227e"},
{file = "mkdocs_material-9.5.34.tar.gz", hash = "sha256:1e60ddf716cfb5679dfd65900b8a25d277064ed82d9a53cd5190e3f894df7840"},
]
[package.dependencies]
babel = ">=2.10,<3.0"
colorama = ">=0.4,<1.0"
jinja2 = ">=3.0,<4.0"
markdown = ">=3.2,<4.0"
mkdocs = ">=1.6,<2.0"
mkdocs-material-extensions = ">=1.3,<2.0"
paginate = ">=0.5,<1.0"
pygments = ">=2.16,<3.0"
pymdown-extensions = ">=10.2,<11.0"
regex = ">=2022.4"
requests = ">=2.26,<3.0"
[package.extras]
git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"]
imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"]
recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"]
[[package]]
name = "mkdocs-material-extensions"
version = "1.3.1"
description = "Extension pack for Python Markdown and MkDocs Material."
optional = false
python-versions = ">=3.8"
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 = "0.26.0"
description = "Automatic documentation from sources, for MkDocs."
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocstrings-0.26.0-py3-none-any.whl", hash = "sha256:1aa227fe94f88e80737d37514523aacd473fc4b50a7f6852ce41447ab23f2654"},
{file = "mkdocstrings-0.26.0.tar.gz", hash = "sha256:ff9d0de28c8fa877ed9b29a42fe407cfe6736d70a1c48177aa84fcc3dc8518cd"},
]
[package.dependencies]
click = ">=7.0"
Jinja2 = ">=2.11.1"
Markdown = ">=3.6"
MarkupSafe = ">=1.1"
mkdocs = ">=1.4"
mkdocs-autorefs = ">=1.2"
platformdirs = ">=2.2"
pymdown-extensions = ">=6.3"
[package.extras]
crystal = ["mkdocstrings-crystal (>=0.3.4)"]
python = ["mkdocstrings-python (>=0.5.2)"]
python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]]
name = "mkdocstrings-python"
version = "1.11.1"
description = "A Python handler for mkdocstrings."
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af"},
{file = "mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322"},
]
[package.dependencies]
griffe = ">=0.49"
mkdocs-autorefs = ">=1.2"
mkdocstrings = ">=0.26"
[[package]]
name = "multidict"
version = "6.0.5"
description = "multidict implementation"
optional = false
python-versions = ">=3.7"
files = [
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"},
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"},
{file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"},
{file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"},
{file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"},
{file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"},
{file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"},
{file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"},
{file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"},
{file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"},
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"},
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"},
{file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"},
{file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"},
{file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"},
{file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"},
{file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"},
{file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"},
{file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"},
{file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"},
{file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"},
{file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"},
{file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"},
{file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"},
{file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"},
{file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"},
{file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"},
{file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"},
{file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"},
{file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"},
]
[[package]]
name = "mypy"
version = "1.11.2"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"},
{file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"},
{file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"},
{file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"},
{file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"},
{file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"},
{file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"},
{file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"},
{file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"},
{file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"},
{file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"},
{file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"},
{file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"},
{file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"},
{file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"},
{file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"},
{file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"},
{file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"},
{file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"},
{file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"},
{file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"},
{file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"},
{file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"},
{file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"},
{file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"},
{file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"},
{file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=4.6.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "myst-parser"
version = "4.0.0"
description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser,"
optional = false
python-versions = ">=3.10"
files = [
{file = "myst_parser-4.0.0-py3-none-any.whl", hash = "sha256:b9317997552424448c6096c2558872fdb6f81d3ecb3a40ce84a7518798f3f28d"},
{file = "myst_parser-4.0.0.tar.gz", hash = "sha256:851c9dfb44e36e56d15d05e72f02b80da21a9e0d07cba96baf5e2d476bb91531"},
]
[package.dependencies]
docutils = ">=0.19,<0.22"
jinja2 = "*"
markdown-it-py = ">=3.0,<4.0"
mdit-py-plugins = ">=0.4.1,<1.0"
pyyaml = "*"
sphinx = ">=7,<9"
[package.extras]
code-style = ["pre-commit (>=3.0,<4.0)"]
linkify = ["linkify-it-py (>=2.0,<3.0)"]
rtd = ["ipython", "sphinx (>=7)", "sphinx-autodoc2 (>=0.5.0,<0.6.0)", "sphinx-book-theme (>=1.1,<2.0)", "sphinx-copybutton", "sphinx-design", "sphinx-pyscript", "sphinx-tippy (>=0.4.3)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.9.0,<0.10.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"]
testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pytest (>=8,<9)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest"]
testing-docutils = ["pygments", "pytest (>=8,<9)", "pytest-param-files (>=0.6.0,<0.7.0)"]
[[package]]
name = "orjson"
version = "3.10.7"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.8"
files = [
{file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"},
{file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"},
{file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"},
{file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"},
{file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"},
{file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"},
{file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"},
{file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"},
{file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"},
{file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"},
{file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"},
{file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"},
{file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"},
{file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"},
{file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"},
{file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"},
{file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"},
{file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"},
{file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"},
{file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"},
{file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"},
{file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"},
{file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"},
{file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"},
{file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"},
{file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"},
{file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"},
{file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"},
{file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"},
{file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"},
{file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"},
{file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"},
{file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"},
{file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"},
{file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"},
{file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"},
{file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"},
{file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"},
{file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"},
{file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"},
{file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"},
{file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"},
{file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"},
{file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"},
{file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"},
{file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"},
{file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"},
{file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"},
{file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"},
{file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"},
{file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"},
{file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"},
{file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"},
{file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"},
{file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"},
{file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"},
{file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"},
]
[[package]]
name = "packaging"
version = "24.1"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
]
[[package]]
name = "paginate"
version = "0.5.6"
description = "Divides large result sets into pages for easier browsing"
optional = false
python-versions = "*"
files = [
{file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.8"
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "pillow"
version = "10.4.0"
description = "Python Imaging Library (Fork)"
optional = false
python-versions = ">=3.8"
files = [
{file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"},
{file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"},
{file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"},
{file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"},
{file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"},
{file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"},
{file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"},
{file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"},
{file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"},
{file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"},
{file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"},
{file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"},
{file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"},
{file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"},
{file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"},
{file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"},
{file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"},
{file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"},
{file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"},
{file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"},
{file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"},
{file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"},
{file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"},
{file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"},
{file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"},
{file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"},
{file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"},
{file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"},
{file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"},
{file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"},
{file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"},
{file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"},
{file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"},
{file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"},
{file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"},
{file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"},
{file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"},
{file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"},
{file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"},
{file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"},
{file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"},
{file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
typing = ["typing-extensions"]
xmp = ["defusedxml"]
[[package]]
name = "platformdirs"
version = "4.3.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.3.2-py3-none-any.whl", hash = "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617"},
{file = "platformdirs-4.3.2.tar.gz", hash = "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c"},
]
[package.extras]
docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.11.2)"]
[[package]]
name = "pluggy"
version = "1.5.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "py-cpuinfo"
version = "9.0.0"
description = "Get CPU info with pure Python"
optional = false
python-versions = "*"
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.9.1"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612"},
{file = "pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
pydantic-core = "2.23.3"
typing-extensions = [
{version = ">=4.12.2", markers = "python_version >= \"3.13\""},
{version = ">=4.6.1", markers = "python_version < \"3.13\""},
]
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"]
[[package]]
name = "pydantic-core"
version = "2.23.3"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6"},
{file = "pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba"},
{file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee"},
{file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe"},
{file = "pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b"},
{file = "pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83"},
{file = "pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27"},
{file = "pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8"},
{file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48"},
{file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5"},
{file = "pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1"},
{file = "pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa"},
{file = "pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305"},
{file = "pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c"},
{file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c"},
{file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab"},
{file = "pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c"},
{file = "pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b"},
{file = "pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f"},
{file = "pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855"},
{file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4"},
{file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d"},
{file = "pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8"},
{file = "pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1"},
{file = "pydantic_core-2.23.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d063c6b9fed7d992bcbebfc9133f4c24b7a7f215d6b102f3e082b1117cddb72c"},
{file = "pydantic_core-2.23.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6cb968da9a0746a0cf521b2b5ef25fc5a0bee9b9a1a8214e0a1cfaea5be7e8a4"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edbefe079a520c5984e30e1f1f29325054b59534729c25b874a16a5048028d16"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbaaf2ef20d282659093913da9d402108203f7cb5955020bd8d1ae5a2325d1c4"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb539d7e5dc4aac345846f290cf504d2fd3c1be26ac4e8b5e4c2b688069ff4cf"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e6f33503c5495059148cc486867e1d24ca35df5fc064686e631e314d959ad5b"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04b07490bc2f6f2717b10c3969e1b830f5720b632f8ae2f3b8b1542394c47a8e"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03795b9e8a5d7fda05f3873efc3f59105e2dcff14231680296b87b80bb327295"},
{file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c483dab0f14b8d3f0df0c6c18d70b21b086f74c87ab03c59250dbf6d3c89baba"},
{file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b2682038e255e94baf2c473dca914a7460069171ff5cdd4080be18ab8a7fd6e"},
{file = "pydantic_core-2.23.3-cp38-none-win32.whl", hash = "sha256:f4a57db8966b3a1d1a350012839c6a0099f0898c56512dfade8a1fe5fb278710"},
{file = "pydantic_core-2.23.3-cp38-none-win_amd64.whl", hash = "sha256:13dd45ba2561603681a2676ca56006d6dee94493f03d5cadc055d2055615c3ea"},
{file = "pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8"},
{file = "pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd"},
{file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835"},
{file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70"},
{file = "pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7"},
{file = "pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab"},
{file = "pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pygments"
version = "2.18.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
files = [
{file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
{file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyjwt"
version = "2.9.0"
description = "JSON Web Token implementation in Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"},
{file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"},
]
[package.extras]
crypto = ["cryptography (>=3.4.0)"]
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pymdown-extensions"
version = "10.9"
description = "Extension pack for Python Markdown."
optional = false
python-versions = ">=3.8"
files = [
{file = "pymdown_extensions-10.9-py3-none-any.whl", hash = "sha256:d323f7e90d83c86113ee78f3fe62fc9dee5f56b54d912660703ea1816fed5626"},
{file = "pymdown_extensions-10.9.tar.gz", hash = "sha256:6ff740bcd99ec4172a938970d42b96128bdc9d4b9bcad72494f29921dc69b753"},
]
[package.dependencies]
markdown = ">=3.6"
pyyaml = "*"
[package.extras]
extra = ["pygments (>=2.12)"]
[[package]]
name = "pyparsing"
version = "3.1.2"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
optional = false
python-versions = ">=3.6.8"
files = [
{file = "pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742"},
{file = "pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pytest"
version = "8.3.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=1.5,<2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "0.24.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"},
{file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"},
]
[package.dependencies]
pytest = ">=8.2,<9"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-benchmark"
version = "4.0.0"
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.7"
files = [
{file = "pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1"},
{file = "pytest_benchmark-4.0.0-py3-none-any.whl", hash = "sha256:fdb7db64e31c8b277dff9850d2a2556d8b60bcb0ea6524e36e28ffd7c87f71d6"},
]
[package.dependencies]
py-cpuinfo = "*"
pytest = ">=3.8"
[package.extras]
aspect = ["aspectlib"]
elasticsearch = ["elasticsearch"]
histogram = ["pygal", "pygaljs"]
[[package]]
name = "pytest-cov"
version = "5.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
{file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
]
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-sugar"
version = "1.0.0"
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 = "*"
files = [
{file = "pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a"},
{file = "pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd"},
]
[package.dependencies]
packaging = ">=21.3"
pytest = ">=6.2.0"
termcolor = ">=2.1.0"
[package.extras]
dev = ["black", "flake8", "pre-commit"]
[[package]]
name = "pytest-timeout"
version = "2.3.1"
description = "pytest plugin to abort hanging tests"
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"},
{file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"},
]
[package.dependencies]
pytest = ">=7.0.0"
[[package]]
name = "pytest-xdist"
version = "3.6.1"
description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"},
{file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"},
]
[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"
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"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pytz"
version = "2024.1"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
files = [
{file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
{file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
]
[[package]]
name = "pyyaml"
version = "6.0.1"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
{file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
{file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
{file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
{file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
{file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
{file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
]
[[package]]
name = "pyyaml-env-tag"
version = "0.1"
description = "A custom YAML tag for referencing environment variables in YAML files. "
optional = false
python-versions = ">=3.6"
files = [
{file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"},
{file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
]
[package.dependencies]
pyyaml = "*"
[[package]]
name = "regex"
version = "2024.5.15"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.8"
files = [
{file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"},
{file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"},
{file = "regex-2024.5.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0bd000c6e266927cb7a1bc39d55be95c4b4f65c5be53e659537537e019232b1"},
{file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eaa7ddaf517aa095fa8da0b5015c44d03da83f5bd49c87961e3c997daed0de7"},
{file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba68168daedb2c0bab7fd7e00ced5ba90aebf91024dea3c88ad5063c2a562cca"},
{file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e8d717bca3a6e2064fc3a08df5cbe366369f4b052dcd21b7416e6d71620dca1"},
{file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1337b7dbef9b2f71121cdbf1e97e40de33ff114801263b275aafd75303bd62b5"},
{file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9ebd0a36102fcad2f03696e8af4ae682793a5d30b46c647eaf280d6cfb32796"},
{file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9efa1a32ad3a3ea112224897cdaeb6aa00381627f567179c0314f7b65d354c62"},
{file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1595f2d10dff3d805e054ebdc41c124753631b6a471b976963c7b28543cf13b0"},
{file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b802512f3e1f480f41ab5f2cfc0e2f761f08a1f41092d6718868082fc0d27143"},
{file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a0981022dccabca811e8171f913de05720590c915b033b7e601f35ce4ea7019f"},
{file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:19068a6a79cf99a19ccefa44610491e9ca02c2be3305c7760d3831d38a467a6f"},
{file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b5269484f6126eee5e687785e83c6b60aad7663dafe842b34691157e5083e53"},
{file = "regex-2024.5.15-cp310-cp310-win32.whl", hash = "sha256:ada150c5adfa8fbcbf321c30c751dc67d2f12f15bd183ffe4ec7cde351d945b3"},
{file = "regex-2024.5.15-cp310-cp310-win_amd64.whl", hash = "sha256:ac394ff680fc46b97487941f5e6ae49a9f30ea41c6c6804832063f14b2a5a145"},
{file = "regex-2024.5.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f5b1dff3ad008dccf18e652283f5e5339d70bf8ba7c98bf848ac33db10f7bc7a"},
{file = "regex-2024.5.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6a2b494a76983df8e3d3feea9b9ffdd558b247e60b92f877f93a1ff43d26656"},
{file = "regex-2024.5.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a32b96f15c8ab2e7d27655969a23895eb799de3665fa94349f3b2fbfd547236f"},
{file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10002e86e6068d9e1c91eae8295ef690f02f913c57db120b58fdd35a6bb1af35"},
{file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec54d5afa89c19c6dd8541a133be51ee1017a38b412b1321ccb8d6ddbeb4cf7d"},
{file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10e4ce0dca9ae7a66e6089bb29355d4432caed736acae36fef0fdd7879f0b0cb"},
{file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e507ff1e74373c4d3038195fdd2af30d297b4f0950eeda6f515ae3d84a1770f"},
{file = "regex-2024.5.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f059a4d795e646e1c37665b9d06062c62d0e8cc3c511fe01315973a6542e40"},
{file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0721931ad5fe0dda45d07f9820b90b2148ccdd8e45bb9e9b42a146cb4f695649"},
{file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:833616ddc75ad595dee848ad984d067f2f31be645d603e4d158bba656bbf516c"},
{file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:287eb7f54fc81546346207c533ad3c2c51a8d61075127d7f6d79aaf96cdee890"},
{file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:19dfb1c504781a136a80ecd1fff9f16dddf5bb43cec6871778c8a907a085bb3d"},
{file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:119af6e56dce35e8dfb5222573b50c89e5508d94d55713c75126b753f834de68"},
{file = "regex-2024.5.15-cp311-cp311-win32.whl", hash = "sha256:1c1c174d6ec38d6c8a7504087358ce9213d4332f6293a94fbf5249992ba54efa"},
{file = "regex-2024.5.15-cp311-cp311-win_amd64.whl", hash = "sha256:9e717956dcfd656f5055cc70996ee2cc82ac5149517fc8e1b60261b907740201"},
{file = "regex-2024.5.15-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:632b01153e5248c134007209b5c6348a544ce96c46005d8456de1d552455b014"},
{file = "regex-2024.5.15-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e64198f6b856d48192bf921421fdd8ad8eb35e179086e99e99f711957ffedd6e"},
{file = "regex-2024.5.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68811ab14087b2f6e0fc0c2bae9ad689ea3584cad6917fc57be6a48bbd012c49"},
{file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ec0c2fea1e886a19c3bee0cd19d862b3aa75dcdfb42ebe8ed30708df64687a"},
{file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0c0c0003c10f54a591d220997dd27d953cd9ccc1a7294b40a4be5312be8797b"},
{file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2431b9e263af1953c55abbd3e2efca67ca80a3de8a0437cb58e2421f8184717a"},
{file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a605586358893b483976cffc1723fb0f83e526e8f14c6e6614e75919d9862cf"},
{file = "regex-2024.5.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391d7f7f1e409d192dba8bcd42d3e4cf9e598f3979cdaed6ab11288da88cb9f2"},
{file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9ff11639a8d98969c863d4617595eb5425fd12f7c5ef6621a4b74b71ed8726d5"},
{file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4eee78a04e6c67e8391edd4dad3279828dd66ac4b79570ec998e2155d2e59fd5"},
{file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8fe45aa3f4aa57faabbc9cb46a93363edd6197cbc43523daea044e9ff2fea83e"},
{file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0a3d8d6acf0c78a1fff0e210d224b821081330b8524e3e2bc5a68ef6ab5803d"},
{file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c486b4106066d502495b3025a0a7251bf37ea9540433940a23419461ab9f2a80"},
{file = "regex-2024.5.15-cp312-cp312-win32.whl", hash = "sha256:c49e15eac7c149f3670b3e27f1f28a2c1ddeccd3a2812cba953e01be2ab9b5fe"},
{file = "regex-2024.5.15-cp312-cp312-win_amd64.whl", hash = "sha256:673b5a6da4557b975c6c90198588181029c60793835ce02f497ea817ff647cb2"},
{file = "regex-2024.5.15-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:87e2a9c29e672fc65523fb47a90d429b70ef72b901b4e4b1bd42387caf0d6835"},
{file = "regex-2024.5.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3bea0ba8b73b71b37ac833a7f3fd53825924165da6a924aec78c13032f20850"},
{file = "regex-2024.5.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bfc4f82cabe54f1e7f206fd3d30fda143f84a63fe7d64a81558d6e5f2e5aaba9"},
{file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5bb9425fe881d578aeca0b2b4b3d314ec88738706f66f219c194d67179337cb"},
{file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64c65783e96e563103d641760664125e91bd85d8e49566ee560ded4da0d3e704"},
{file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf2430df4148b08fb4324b848672514b1385ae3807651f3567871f130a728cc3"},
{file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5397de3219a8b08ae9540c48f602996aa6b0b65d5a61683e233af8605c42b0f2"},
{file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:455705d34b4154a80ead722f4f185b04c4237e8e8e33f265cd0798d0e44825fa"},
{file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2b6f1b3bb6f640c1a92be3bbfbcb18657b125b99ecf141fb3310b5282c7d4ed"},
{file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3ad070b823ca5890cab606c940522d05d3d22395d432f4aaaf9d5b1653e47ced"},
{file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5b5467acbfc153847d5adb21e21e29847bcb5870e65c94c9206d20eb4e99a384"},
{file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e6662686aeb633ad65be2a42b4cb00178b3fbf7b91878f9446075c404ada552f"},
{file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:2b4c884767504c0e2401babe8b5b7aea9148680d2e157fa28f01529d1f7fcf67"},
{file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3cd7874d57f13bf70078f1ff02b8b0aa48d5b9ed25fc48547516c6aba36f5741"},
{file = "regex-2024.5.15-cp38-cp38-win32.whl", hash = "sha256:e4682f5ba31f475d58884045c1a97a860a007d44938c4c0895f41d64481edbc9"},
{file = "regex-2024.5.15-cp38-cp38-win_amd64.whl", hash = "sha256:d99ceffa25ac45d150e30bd9ed14ec6039f2aad0ffa6bb87a5936f5782fc1569"},
{file = "regex-2024.5.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13cdaf31bed30a1e1c2453ef6015aa0983e1366fad2667657dbcac7b02f67133"},
{file = "regex-2024.5.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cac27dcaa821ca271855a32188aa61d12decb6fe45ffe3e722401fe61e323cd1"},
{file = "regex-2024.5.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7dbe2467273b875ea2de38ded4eba86cbcbc9a1a6d0aa11dcf7bd2e67859c435"},
{file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f18a9a3513a99c4bef0e3efd4c4a5b11228b48aa80743be822b71e132ae4f5"},
{file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d347a741ea871c2e278fde6c48f85136c96b8659b632fb57a7d1ce1872547600"},
{file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1878b8301ed011704aea4c806a3cadbd76f84dece1ec09cc9e4dc934cfa5d4da"},
{file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4babf07ad476aaf7830d77000874d7611704a7fcf68c9c2ad151f5d94ae4bfc4"},
{file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35cb514e137cb3488bce23352af3e12fb0dbedd1ee6e60da053c69fb1b29cc6c"},
{file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cdd09d47c0b2efee9378679f8510ee6955d329424c659ab3c5e3a6edea696294"},
{file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:72d7a99cd6b8f958e85fc6ca5b37c4303294954eac1376535b03c2a43eb72629"},
{file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a094801d379ab20c2135529948cb84d417a2169b9bdceda2a36f5f10977ebc16"},
{file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c18345010870e58238790a6779a1219b4d97bd2e77e1140e8ee5d14df071aa"},
{file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:16093f563098448ff6b1fa68170e4acbef94e6b6a4e25e10eae8598bb1694b5d"},
{file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e38a7d4e8f633a33b4c7350fbd8bad3b70bf81439ac67ac38916c4a86b465456"},
{file = "regex-2024.5.15-cp39-cp39-win32.whl", hash = "sha256:71a455a3c584a88f654b64feccc1e25876066c4f5ef26cd6dd711308aa538694"},
{file = "regex-2024.5.15-cp39-cp39-win_amd64.whl", hash = "sha256:cab12877a9bdafde5500206d1020a584355a97884dfd388af3699e9137bf7388"},
{file = "regex-2024.5.15.tar.gz", hash = "sha256:d3ee02d9e5f482cc8309134a91eeaacbdd2261ba111b0fef3748eeb4913e6a2c"},
]
[[package]]
name = "requests"
version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
version = "13.8.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"},
{file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"},
]
[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 = false
python-versions = ">=3.7"
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.16.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]]
name = "smmap"
version = "5.0.1"
description = "A pure Python implementation of a sliding window memory map manager"
optional = false
python-versions = ">=3.7"
files = [
{file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"},
{file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"},
]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "snowballstemmer"
version = "2.2.0"
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
optional = false
python-versions = "*"
files = [
{file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
{file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
]
[[package]]
name = "soupsieve"
version = "2.5"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.8"
files = [
{file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"},
{file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"},
]
[[package]]
name = "sphinx"
version = "8.0.2"
description = "Python documentation generator"
optional = false
python-versions = ">=3.10"
files = [
{file = "sphinx-8.0.2-py3-none-any.whl", hash = "sha256:56173572ae6c1b9a38911786e206a110c9749116745873feae4f9ce88e59391d"},
{file = "sphinx-8.0.2.tar.gz", hash = "sha256:0cce1ddcc4fd3532cf1dd283bc7d886758362c5c1de6598696579ce96d8ffa5b"},
]
[package.dependencies]
alabaster = ">=0.7.14"
babel = ">=2.13"
colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""}
docutils = ">=0.20,<0.22"
imagesize = ">=1.3"
Jinja2 = ">=3.1"
packaging = ">=23.0"
Pygments = ">=2.17"
requests = ">=2.30.0"
snowballstemmer = ">=2.2"
sphinxcontrib-applehelp = "*"
sphinxcontrib-devhelp = "*"
sphinxcontrib-htmlhelp = ">=2.0.0"
sphinxcontrib-jsmath = "*"
sphinxcontrib-qthelp = "*"
sphinxcontrib-serializinghtml = ">=1.1.9"
[package.extras]
docs = ["sphinxcontrib-websupport"]
lint = ["flake8 (>=6.0)", "mypy (==1.11.0)", "pytest (>=6.0)", "ruff (==0.5.5)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240520)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20240724)", "types-requests (>=2.30.0)"]
test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"]
[[package]]
name = "sphinx-autobuild"
version = "2024.9.3"
description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser."
optional = false
python-versions = ">=3.9"
files = [
{file = "sphinx_autobuild-2024.9.3-py3-none-any.whl", hash = "sha256:55fe9bcc05dab659650d79bed0e6beb8b6032234edbf23f028f2cac3471f0c2d"},
{file = "sphinx_autobuild-2024.9.3.tar.gz", hash = "sha256:75929a5a92b932da8d29837406d6d973a927c456f30986a27f1f20b067897892"},
]
[package.dependencies]
colorama = ">=0.4.6"
sphinx = "*"
starlette = ">=0.35"
uvicorn = ">=0.25"
watchfiles = ">=0.20"
websockets = ">=11"
[package.extras]
test = ["httpx", "pytest (>=6)"]
[[package]]
name = "sphinx-basic-ng"
version = "1.0.0b2"
description = "A modern skeleton for Sphinx themes."
optional = false
python-versions = ">=3.7"
files = [
{file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
{file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
]
[package.dependencies]
sphinx = ">=4.0"
[package.extras]
docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"]
[[package]]
name = "sphinxcontrib-applehelp"
version = "1.0.8"
description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
optional = false
python-versions = ">=3.9"
files = [
{file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"},
{file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"},
]
[package.extras]
lint = ["docutils-stubs", "flake8", "mypy"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-devhelp"
version = "1.0.6"
description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
optional = false
python-versions = ">=3.9"
files = [
{file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"},
{file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"},
]
[package.extras]
lint = ["docutils-stubs", "flake8", "mypy"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.0.5"
description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
optional = false
python-versions = ">=3.9"
files = [
{file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"},
{file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"},
]
[package.extras]
lint = ["docutils-stubs", "flake8", "mypy"]
standalone = ["Sphinx (>=5)"]
test = ["html5lib", "pytest"]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
description = "A sphinx extension which renders display math in HTML via JavaScript"
optional = false
python-versions = ">=3.5"
files = [
{file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
{file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
]
[package.extras]
test = ["flake8", "mypy", "pytest"]
[[package]]
name = "sphinxcontrib-qthelp"
version = "1.0.7"
description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
optional = false
python-versions = ">=3.9"
files = [
{file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"},
{file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"},
]
[package.extras]
lint = ["docutils-stubs", "flake8", "mypy"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "1.1.10"
description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
optional = false
python-versions = ">=3.9"
files = [
{file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"},
{file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"},
]
[package.extras]
lint = ["docutils-stubs", "flake8", "mypy"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "starlette"
version = "0.37.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
files = [
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
]
[package.dependencies]
anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
[[package]]
name = "termcolor"
version = "2.4.0"
description = "ANSI color formatting for output in terminal"
optional = false
python-versions = ">=3.8"
files = [
{file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"},
{file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"},
]
[package.extras]
tests = ["pytest", "pytest-cov"]
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.7"
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "typer"
version = "0.12.5"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false
python-versions = ">=3.7"
files = [
{file = "typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b"},
{file = "typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722"},
]
[package.dependencies]
click = ">=8.0.0"
rich = ">=10.11.0"
shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3"
[[package]]
name = "types-aiofiles"
version = "24.1.0.20240626"
description = "Typing stubs for aiofiles"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-aiofiles-24.1.0.20240626.tar.gz", hash = "sha256:48604663e24bc2d5038eac05ccc33e75799b0779e93e13d6a8f711ddc306ac08"},
{file = "types_aiofiles-24.1.0.20240626-py3-none-any.whl", hash = "sha256:7939eca4a8b4f9c6491b6e8ef160caee9a21d32e18534a57d5ed90aee47c66b4"},
]
[[package]]
name = "types-dateparser"
version = "1.2.0.20240420"
description = "Typing stubs for dateparser"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-dateparser-1.2.0.20240420.tar.gz", hash = "sha256:8f813ddf5ef41b32cabe6167138ae833ada10c22811e42220a1e38a0be7adbdc"},
{file = "types_dateparser-1.2.0.20240420-py3-none-any.whl", hash = "sha256:bf3695ddfbadfdfc875064895a51d926fd80b04da1a44364c6c1a9703db7b194"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "tzdata"
version = "2024.1"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
files = [
{file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"},
{file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
]
[[package]]
name = "tzlocal"
version = "5.2"
description = "tzinfo object for the local timezone"
optional = false
python-versions = ">=3.8"
files = [
{file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"},
{file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"},
]
[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.2.2"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.8"
files = [
{file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"},
{file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.30.1"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
files = [
{file = "uvicorn-0.30.1-py3-none-any.whl", hash = "sha256:cd17daa7f3b9d7a24de3617820e634d0933b69eed8e33a516071174427238c81"},
{file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "verspec"
version = "0.1.0"
description = "Flexible version handling"
optional = false
python-versions = "*"
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 = "watchdog"
version = "4.0.1"
description = "Filesystem events monitoring"
optional = false
python-versions = ">=3.8"
files = [
{file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:da2dfdaa8006eb6a71051795856bedd97e5b03e57da96f98e375682c48850645"},
{file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e93f451f2dfa433d97765ca2634628b789b49ba8b504fdde5837cdcf25fdb53b"},
{file = "watchdog-4.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ef0107bbb6a55f5be727cfc2ef945d5676b97bffb8425650dadbb184be9f9a2b"},
{file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17e32f147d8bf9657e0922c0940bcde863b894cd871dbb694beb6704cfbd2fb5"},
{file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03e70d2df2258fb6cb0e95bbdbe06c16e608af94a3ffbd2b90c3f1e83eb10767"},
{file = "watchdog-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123587af84260c991dc5f62a6e7ef3d1c57dfddc99faacee508c71d287248459"},
{file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:093b23e6906a8b97051191a4a0c73a77ecc958121d42346274c6af6520dec175"},
{file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:611be3904f9843f0529c35a3ff3fd617449463cb4b73b1633950b3d97fa4bfb7"},
{file = "watchdog-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:62c613ad689ddcb11707f030e722fa929f322ef7e4f18f5335d2b73c61a85c28"},
{file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d4925e4bf7b9bddd1c3de13c9b8a2cdb89a468f640e66fbfabaf735bd85b3e35"},
{file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cad0bbd66cd59fc474b4a4376bc5ac3fc698723510cbb64091c2a793b18654db"},
{file = "watchdog-4.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a3c2c317a8fb53e5b3d25790553796105501a235343f5d2bf23bb8649c2c8709"},
{file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9904904b6564d4ee8a1ed820db76185a3c96e05560c776c79a6ce5ab71888ba"},
{file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:667f3c579e813fcbad1b784db7a1aaa96524bed53437e119f6a2f5de4db04235"},
{file = "watchdog-4.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d10a681c9a1d5a77e75c48a3b8e1a9f2ae2928eda463e8d33660437705659682"},
{file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0144c0ea9997b92615af1d94afc0c217e07ce2c14912c7b1a5731776329fcfc7"},
{file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:998d2be6976a0ee3a81fb8e2777900c28641fb5bfbd0c84717d89bca0addcdc5"},
{file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e7921319fe4430b11278d924ef66d4daa469fafb1da679a2e48c935fa27af193"},
{file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f0de0f284248ab40188f23380b03b59126d1479cd59940f2a34f8852db710625"},
{file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bca36be5707e81b9e6ce3208d92d95540d4ca244c006b61511753583c81c70dd"},
{file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab998f567ebdf6b1da7dc1e5accfaa7c6992244629c0fdaef062f43249bd8dee"},
{file = "watchdog-4.0.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dddba7ca1c807045323b6af4ff80f5ddc4d654c8bce8317dde1bd96b128ed253"},
{file = "watchdog-4.0.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:4513ec234c68b14d4161440e07f995f231be21a09329051e67a2118a7a612d2d"},
{file = "watchdog-4.0.1-py3-none-manylinux2014_i686.whl", hash = "sha256:4107ac5ab936a63952dea2a46a734a23230aa2f6f9db1291bf171dac3ebd53c6"},
{file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:6e8c70d2cd745daec2a08734d9f63092b793ad97612470a0ee4cbb8f5f705c57"},
{file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f27279d060e2ab24c0aa98363ff906d2386aa6c4dc2f1a374655d4e02a6c5e5e"},
{file = "watchdog-4.0.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:f8affdf3c0f0466e69f5b3917cdd042f89c8c63aebdb9f7c078996f607cdb0f5"},
{file = "watchdog-4.0.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ac7041b385f04c047fcc2951dc001671dee1b7e0615cde772e84b01fbf68ee84"},
{file = "watchdog-4.0.1-py3-none-win32.whl", hash = "sha256:206afc3d964f9a233e6ad34618ec60b9837d0582b500b63687e34011e15bb429"},
{file = "watchdog-4.0.1-py3-none-win_amd64.whl", hash = "sha256:7577b3c43e5909623149f76b099ac49a1a01ca4e167d1785c76eb52fa585745a"},
{file = "watchdog-4.0.1-py3-none-win_ia64.whl", hash = "sha256:d7b9f5f3299e8dd230880b6c55504a1f69cf1e4316275d1b215ebdd8187ec88d"},
{file = "watchdog-4.0.1.tar.gz", hash = "sha256:eebaacf674fa25511e8867028d281e602ee6500045b57f43b08778082f7f8b44"},
]
[package.extras]
watchmedo = ["PyYAML (>=3.10)"]
[[package]]
name = "watchfiles"
version = "0.22.0"
description = "Simple, modern and high performance file watching and code reload in python."
optional = false
python-versions = ">=3.8"
files = [
{file = "watchfiles-0.22.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:da1e0a8caebf17976e2ffd00fa15f258e14749db5e014660f53114b676e68538"},
{file = "watchfiles-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61af9efa0733dc4ca462347becb82e8ef4945aba5135b1638bfc20fad64d4f0e"},
{file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9188979a58a096b6f8090e816ccc3f255f137a009dd4bbec628e27696d67c1"},
{file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2bdadf6b90c099ca079d468f976fd50062905d61fae183f769637cb0f68ba59a"},
{file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:067dea90c43bf837d41e72e546196e674f68c23702d3ef80e4e816937b0a3ffd"},
{file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbf8a20266136507abf88b0df2328e6a9a7c7309e8daff124dda3803306a9fdb"},
{file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1235c11510ea557fe21be5d0e354bae2c655a8ee6519c94617fe63e05bca4171"},
{file = "watchfiles-0.22.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2444dc7cb9d8cc5ab88ebe792a8d75709d96eeef47f4c8fccb6df7c7bc5be71"},
{file = "watchfiles-0.22.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c5af2347d17ab0bd59366db8752d9e037982e259cacb2ba06f2c41c08af02c39"},
{file = "watchfiles-0.22.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9624a68b96c878c10437199d9a8b7d7e542feddda8d5ecff58fdc8e67b460848"},
{file = "watchfiles-0.22.0-cp310-none-win32.whl", hash = "sha256:4b9f2a128a32a2c273d63eb1fdbf49ad64852fc38d15b34eaa3f7ca2f0d2b797"},
{file = "watchfiles-0.22.0-cp310-none-win_amd64.whl", hash = "sha256:2627a91e8110b8de2406d8b2474427c86f5a62bf7d9ab3654f541f319ef22bcb"},
{file = "watchfiles-0.22.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8c39987a1397a877217be1ac0fb1d8b9f662c6077b90ff3de2c05f235e6a8f96"},
{file = "watchfiles-0.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a927b3034d0672f62fb2ef7ea3c9fc76d063c4b15ea852d1db2dc75fe2c09696"},
{file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052d668a167e9fc345c24203b104c313c86654dd6c0feb4b8a6dfc2462239249"},
{file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e45fb0d70dda1623a7045bd00c9e036e6f1f6a85e4ef2c8ae602b1dfadf7550"},
{file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c49b76a78c156979759d759339fb62eb0549515acfe4fd18bb151cc07366629c"},
{file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a65474fd2b4c63e2c18ac67a0c6c66b82f4e73e2e4d940f837ed3d2fd9d4da"},
{file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc0cba54f47c660d9fa3218158b8963c517ed23bd9f45fe463f08262a4adae1"},
{file = "watchfiles-0.22.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ebe84a035993bb7668f58a0ebf998174fb723a39e4ef9fce95baabb42b787f"},
{file = "watchfiles-0.22.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0f0a874231e2839abbf473256efffe577d6ee2e3bfa5b540479e892e47c172d"},
{file = "watchfiles-0.22.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:213792c2cd3150b903e6e7884d40660e0bcec4465e00563a5fc03f30ea9c166c"},
{file = "watchfiles-0.22.0-cp311-none-win32.whl", hash = "sha256:b44b70850f0073b5fcc0b31ede8b4e736860d70e2dbf55701e05d3227a154a67"},
{file = "watchfiles-0.22.0-cp311-none-win_amd64.whl", hash = "sha256:00f39592cdd124b4ec5ed0b1edfae091567c72c7da1487ae645426d1b0ffcad1"},
{file = "watchfiles-0.22.0-cp311-none-win_arm64.whl", hash = "sha256:3218a6f908f6a276941422b035b511b6d0d8328edd89a53ae8c65be139073f84"},
{file = "watchfiles-0.22.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c7b978c384e29d6c7372209cbf421d82286a807bbcdeb315427687f8371c340a"},
{file = "watchfiles-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd4c06100bce70a20c4b81e599e5886cf504c9532951df65ad1133e508bf20be"},
{file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:425440e55cd735386ec7925f64d5dde392e69979d4c8459f6bb4e920210407f2"},
{file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68fe0c4d22332d7ce53ad094622b27e67440dacefbaedd29e0794d26e247280c"},
{file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8a31bfd98f846c3c284ba694c6365620b637debdd36e46e1859c897123aa232"},
{file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc2e8fe41f3cac0660197d95216c42910c2b7e9c70d48e6d84e22f577d106fc1"},
{file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b7cc10261c2786c41d9207193a85c1db1b725cf87936df40972aab466179b6"},
{file = "watchfiles-0.22.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28585744c931576e535860eaf3f2c0ec7deb68e3b9c5a85ca566d69d36d8dd27"},
{file = "watchfiles-0.22.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00095dd368f73f8f1c3a7982a9801190cc88a2f3582dd395b289294f8975172b"},
{file = "watchfiles-0.22.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:52fc9b0dbf54d43301a19b236b4a4614e610605f95e8c3f0f65c3a456ffd7d35"},
{file = "watchfiles-0.22.0-cp312-none-win32.whl", hash = "sha256:581f0a051ba7bafd03e17127735d92f4d286af941dacf94bcf823b101366249e"},
{file = "watchfiles-0.22.0-cp312-none-win_amd64.whl", hash = "sha256:aec83c3ba24c723eac14225194b862af176d52292d271c98820199110e31141e"},
{file = "watchfiles-0.22.0-cp312-none-win_arm64.whl", hash = "sha256:c668228833c5619f6618699a2c12be057711b0ea6396aeaece4ded94184304ea"},
{file = "watchfiles-0.22.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d47e9ef1a94cc7a536039e46738e17cce058ac1593b2eccdede8bf72e45f372a"},
{file = "watchfiles-0.22.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:28f393c1194b6eaadcdd8f941307fc9bbd7eb567995232c830f6aef38e8a6e88"},
{file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd64f3a4db121bc161644c9e10a9acdb836853155a108c2446db2f5ae1778c3d"},
{file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2abeb79209630da981f8ebca30a2c84b4c3516a214451bfc5f106723c5f45843"},
{file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cc382083afba7918e32d5ef12321421ef43d685b9a67cc452a6e6e18920890e"},
{file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d048ad5d25b363ba1d19f92dcf29023988524bee6f9d952130b316c5802069cb"},
{file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:103622865599f8082f03af4214eaff90e2426edff5e8522c8f9e93dc17caee13"},
{file = "watchfiles-0.22.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3e1f3cf81f1f823e7874ae563457828e940d75573c8fbf0ee66818c8b6a9099"},
{file = "watchfiles-0.22.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8597b6f9dc410bdafc8bb362dac1cbc9b4684a8310e16b1ff5eee8725d13dcd6"},
{file = "watchfiles-0.22.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0b04a2cbc30e110303baa6d3ddce8ca3664bc3403be0f0ad513d1843a41c97d1"},
{file = "watchfiles-0.22.0-cp38-none-win32.whl", hash = "sha256:b610fb5e27825b570554d01cec427b6620ce9bd21ff8ab775fc3a32f28bba63e"},
{file = "watchfiles-0.22.0-cp38-none-win_amd64.whl", hash = "sha256:fe82d13461418ca5e5a808a9e40f79c1879351fcaeddbede094028e74d836e86"},
{file = "watchfiles-0.22.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3973145235a38f73c61474d56ad6199124e7488822f3a4fc97c72009751ae3b0"},
{file = "watchfiles-0.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:280a4afbc607cdfc9571b9904b03a478fc9f08bbeec382d648181c695648202f"},
{file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a0d883351a34c01bd53cfa75cd0292e3f7e268bacf2f9e33af4ecede7e21d1d"},
{file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9165bcab15f2b6d90eedc5c20a7f8a03156b3773e5fb06a790b54ccecdb73385"},
{file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc1b9b56f051209be458b87edb6856a449ad3f803315d87b2da4c93b43a6fe72"},
{file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc1fc25a1dedf2dd952909c8e5cb210791e5f2d9bc5e0e8ebc28dd42fed7562"},
{file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc92d2d2706d2b862ce0568b24987eba51e17e14b79a1abcd2edc39e48e743c8"},
{file = "watchfiles-0.22.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97b94e14b88409c58cdf4a8eaf0e67dfd3ece7e9ce7140ea6ff48b0407a593ec"},
{file = "watchfiles-0.22.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96eec15e5ea7c0b6eb5bfffe990fc7c6bd833acf7e26704eb18387fb2f5fd087"},
{file = "watchfiles-0.22.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:28324d6b28bcb8d7c1041648d7b63be07a16db5510bea923fc80b91a2a6cbed6"},
{file = "watchfiles-0.22.0-cp39-none-win32.whl", hash = "sha256:8c3e3675e6e39dc59b8fe5c914a19d30029e36e9f99468dddffd432d8a7b1c93"},
{file = "watchfiles-0.22.0-cp39-none-win_amd64.whl", hash = "sha256:25c817ff2a86bc3de3ed2df1703e3d24ce03479b27bb4527c57e722f8554d971"},
{file = "watchfiles-0.22.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b810a2c7878cbdecca12feae2c2ae8af59bea016a78bc353c184fa1e09f76b68"},
{file = "watchfiles-0.22.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7e1f9c5d1160d03b93fc4b68a0aeb82fe25563e12fbcdc8507f8434ab6f823c"},
{file = "watchfiles-0.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:030bc4e68d14bcad2294ff68c1ed87215fbd9a10d9dea74e7cfe8a17869785ab"},
{file = "watchfiles-0.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace7d060432acde5532e26863e897ee684780337afb775107c0a90ae8dbccfd2"},
{file = "watchfiles-0.22.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5834e1f8b71476a26df97d121c0c0ed3549d869124ed2433e02491553cb468c2"},
{file = "watchfiles-0.22.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0bc3b2f93a140df6806c8467c7f51ed5e55a931b031b5c2d7ff6132292e803d6"},
{file = "watchfiles-0.22.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fdebb655bb1ba0122402352b0a4254812717a017d2dc49372a1d47e24073795"},
{file = "watchfiles-0.22.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c8e0aa0e8cc2a43561e0184c0513e291ca891db13a269d8d47cb9841ced7c71"},
{file = "watchfiles-0.22.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2f350cbaa4bb812314af5dab0eb8d538481e2e2279472890864547f3fe2281ed"},
{file = "watchfiles-0.22.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7a74436c415843af2a769b36bf043b6ccbc0f8d784814ba3d42fc961cdb0a9dc"},
{file = "watchfiles-0.22.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00ad0bcd399503a84cc688590cdffbe7a991691314dde5b57b3ed50a41319a31"},
{file = "watchfiles-0.22.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72a44e9481afc7a5ee3291b09c419abab93b7e9c306c9ef9108cb76728ca58d2"},
{file = "watchfiles-0.22.0.tar.gz", hash = "sha256:988e981aaab4f3955209e7e28c7794acdb690be1efa7f16f8ea5aba7ffdadacb"},
]
[package.dependencies]
anyio = ">=3.0.0"
[[package]]
name = "wcmatch"
version = "8.5.2"
description = "Wildcard/glob file name matcher."
optional = false
python-versions = ">=3.8"
files = [
{file = "wcmatch-8.5.2-py3-none-any.whl", hash = "sha256:17d3ad3758f9d0b5b4dedc770b65420d4dac62e680229c287bf24c9db856a478"},
{file = "wcmatch-8.5.2.tar.gz", hash = "sha256:a70222b86dea82fb382dd87b73278c10756c138bd6f8f714e2183128887b9eb2"},
]
[package.dependencies]
bracex = ">=2.1.1"
[[package]]
name = "websockets"
version = "12.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
python-versions = ">=3.8"
files = [
{file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"},
{file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"},
{file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"},
{file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"},
{file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"},
{file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"},
{file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"},
{file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"},
{file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"},
{file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"},
{file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"},
{file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"},
{file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"},
{file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"},
{file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"},
{file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"},
{file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"},
{file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"},
{file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"},
{file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"},
{file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"},
{file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"},
{file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"},
{file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"},
{file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"},
{file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"},
{file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"},
{file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"},
{file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"},
{file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"},
{file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"},
{file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"},
{file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"},
{file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"},
{file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"},
{file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"},
{file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"},
{file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"},
{file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"},
{file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"},
{file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"},
{file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"},
{file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"},
{file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"},
{file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"},
{file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"},
{file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"},
{file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"},
{file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"},
{file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"},
{file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"},
{file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"},
{file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"},
{file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"},
{file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"},
{file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"},
{file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"},
{file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"},
{file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"},
{file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"},
{file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"},
{file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"},
{file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"},
{file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"},
{file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"},
{file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"},
{file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"},
{file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"},
{file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"},
{file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"},
{file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"},
{file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"},
]
[[package]]
name = "yarl"
version = "1.11.1"
description = "Yet another URL library"
optional = false
python-versions = ">=3.8"
files = [
{file = "yarl-1.11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00"},
{file = "yarl-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d"},
{file = "yarl-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2164cd9725092761fed26f299e3f276bb4b537ca58e6ff6b252eae9631b5c96e"},
{file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08ea567c16f140af8ddc7cb58e27e9138a1386e3e6e53982abaa6f2377b38cc"},
{file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:768ecc550096b028754ea28bf90fde071c379c62c43afa574edc6f33ee5daaec"},
{file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2909fa3a7d249ef64eeb2faa04b7957e34fefb6ec9966506312349ed8a7e77bf"},
{file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a8697ec24f17c349c4f655763c4db70eebc56a5f82995e5e26e837c6eb0e49"},
{file = "yarl-1.11.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e286580b6511aac7c3268a78cdb861ec739d3e5a2a53b4809faef6b49778eaff"},
{file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4179522dc0305c3fc9782549175c8e8849252fefeb077c92a73889ccbcd508ad"},
{file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27fcb271a41b746bd0e2a92182df507e1c204759f460ff784ca614e12dd85145"},
{file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f61db3b7e870914dbd9434b560075e0366771eecbe6d2b5561f5bc7485f39efd"},
{file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c92261eb2ad367629dc437536463dc934030c9e7caca861cc51990fe6c565f26"},
{file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d95b52fbef190ca87d8c42f49e314eace4fc52070f3dfa5f87a6594b0c1c6e46"},
{file = "yarl-1.11.1-cp310-cp310-win32.whl", hash = "sha256:489fa8bde4f1244ad6c5f6d11bb33e09cf0d1d0367edb197619c3e3fc06f3d91"},
{file = "yarl-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:476e20c433b356e16e9a141449f25161e6b69984fb4cdbd7cd4bd54c17844998"},
{file = "yarl-1.11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:946eedc12895873891aaceb39bceb484b4977f70373e0122da483f6c38faaa68"},
{file = "yarl-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21a7c12321436b066c11ec19c7e3cb9aec18884fe0d5b25d03d756a9e654edfe"},
{file = "yarl-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c35f493b867912f6fda721a59cc7c4766d382040bdf1ddaeeaa7fa4d072f4675"},
{file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25861303e0be76b60fddc1250ec5986c42f0a5c0c50ff57cc30b1be199c00e63"},
{file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4b53f73077e839b3f89c992223f15b1d2ab314bdbdf502afdc7bb18e95eae27"},
{file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:327c724b01b8641a1bf1ab3b232fb638706e50f76c0b5bf16051ab65c868fac5"},
{file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4307d9a3417eea87715c9736d050c83e8c1904e9b7aada6ce61b46361b733d92"},
{file = "yarl-1.11.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a28bed68ab8fb7e380775f0029a079f08a17799cb3387a65d14ace16c12e2b"},
{file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:067b961853c8e62725ff2893226fef3d0da060656a9827f3f520fb1d19b2b68a"},
{file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8215f6f21394d1f46e222abeb06316e77ef328d628f593502d8fc2a9117bde83"},
{file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:498442e3af2a860a663baa14fbf23fb04b0dd758039c0e7c8f91cb9279799bff"},
{file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:69721b8effdb588cb055cc22f7c5105ca6fdaa5aeb3ea09021d517882c4a904c"},
{file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e969fa4c1e0b1a391f3fcbcb9ec31e84440253325b534519be0d28f4b6b533e"},
{file = "yarl-1.11.1-cp311-cp311-win32.whl", hash = "sha256:7d51324a04fc4b0e097ff8a153e9276c2593106a811704025bbc1d6916f45ca6"},
{file = "yarl-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:15061ce6584ece023457fb8b7a7a69ec40bf7114d781a8c4f5dcd68e28b5c53b"},
{file = "yarl-1.11.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a4264515f9117be204935cd230fb2a052dd3792789cc94c101c535d349b3dab0"},
{file = "yarl-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f41fa79114a1d2eddb5eea7b912d6160508f57440bd302ce96eaa384914cd265"},
{file = "yarl-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02da8759b47d964f9173c8675710720b468aa1c1693be0c9c64abb9d8d9a4867"},
{file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9361628f28f48dcf8b2f528420d4d68102f593f9c2e592bfc842f5fb337e44fd"},
{file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b91044952da03b6f95fdba398d7993dd983b64d3c31c358a4c89e3c19b6f7aef"},
{file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74db2ef03b442276d25951749a803ddb6e270d02dda1d1c556f6ae595a0d76a8"},
{file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e975a2211952a8a083d1b9d9ba26472981ae338e720b419eb50535de3c02870"},
{file = "yarl-1.11.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aef97ba1dd2138112890ef848e17d8526fe80b21f743b4ee65947ea184f07a2"},
{file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7915ea49b0c113641dc4d9338efa9bd66b6a9a485ffe75b9907e8573ca94b84"},
{file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:504cf0d4c5e4579a51261d6091267f9fd997ef58558c4ffa7a3e1460bd2336fa"},
{file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3de5292f9f0ee285e6bd168b2a77b2a00d74cbcfa420ed078456d3023d2f6dff"},
{file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a34e1e30f1774fa35d37202bbeae62423e9a79d78d0874e5556a593479fdf239"},
{file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66b63c504d2ca43bf7221a1f72fbe981ff56ecb39004c70a94485d13e37ebf45"},
{file = "yarl-1.11.1-cp312-cp312-win32.whl", hash = "sha256:a28b70c9e2213de425d9cba5ab2e7f7a1c8ca23a99c4b5159bf77b9c31251447"},
{file = "yarl-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:17b5a386d0d36fb828e2fb3ef08c8829c1ebf977eef88e5367d1c8c94b454639"},
{file = "yarl-1.11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1fa2e7a406fbd45b61b4433e3aa254a2c3e14c4b3186f6e952d08a730807fa0c"},
{file = "yarl-1.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:750f656832d7d3cb0c76be137ee79405cc17e792f31e0a01eee390e383b2936e"},
{file = "yarl-1.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b8486f322d8f6a38539136a22c55f94d269addb24db5cb6f61adc61eabc9d93"},
{file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fce4da3703ee6048ad4138fe74619c50874afe98b1ad87b2698ef95bf92c96d"},
{file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed653638ef669e0efc6fe2acb792275cb419bf9cb5c5049399f3556995f23c7"},
{file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18ac56c9dd70941ecad42b5a906820824ca72ff84ad6fa18db33c2537ae2e089"},
{file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:688654f8507464745ab563b041d1fb7dab5d9912ca6b06e61d1c4708366832f5"},
{file = "yarl-1.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4973eac1e2ff63cf187073cd4e1f1148dcd119314ab79b88e1b3fad74a18c9d5"},
{file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:964a428132227edff96d6f3cf261573cb0f1a60c9a764ce28cda9525f18f7786"},
{file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d23754b9939cbab02c63434776df1170e43b09c6a517585c7ce2b3d449b7318"},
{file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c2dc4250fe94d8cd864d66018f8344d4af50e3758e9d725e94fecfa27588ff82"},
{file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09696438cb43ea6f9492ef237761b043f9179f455f405279e609f2bc9100212a"},
{file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:999bfee0a5b7385a0af5ffb606393509cfde70ecca4f01c36985be6d33e336da"},
{file = "yarl-1.11.1-cp313-cp313-win32.whl", hash = "sha256:ce928c9c6409c79e10f39604a7e214b3cb69552952fbda8d836c052832e6a979"},
{file = "yarl-1.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:501c503eed2bb306638ccb60c174f856cc3246c861829ff40eaa80e2f0330367"},
{file = "yarl-1.11.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dae7bd0daeb33aa3e79e72877d3d51052e8b19c9025ecf0374f542ea8ec120e4"},
{file = "yarl-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3ff6b1617aa39279fe18a76c8d165469c48b159931d9b48239065767ee455b2b"},
{file = "yarl-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3257978c870728a52dcce8c2902bf01f6c53b65094b457bf87b2644ee6238ddc"},
{file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f351fa31234699d6084ff98283cb1e852270fe9e250a3b3bf7804eb493bd937"},
{file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8aef1b64da41d18026632d99a06b3fefe1d08e85dd81d849fa7c96301ed22f1b"},
{file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7175a87ab8f7fbde37160a15e58e138ba3b2b0e05492d7351314a250d61b1591"},
{file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba444bdd4caa2a94456ef67a2f383710928820dd0117aae6650a4d17029fa25e"},
{file = "yarl-1.11.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ea9682124fc062e3d931c6911934a678cb28453f957ddccf51f568c2f2b5e05"},
{file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8418c053aeb236b20b0ab8fa6bacfc2feaaf7d4683dd96528610989c99723d5f"},
{file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:61a5f2c14d0a1adfdd82258f756b23a550c13ba4c86c84106be4c111a3a4e413"},
{file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f3a6d90cab0bdf07df8f176eae3a07127daafcf7457b997b2bf46776da2c7eb7"},
{file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:077da604852be488c9a05a524068cdae1e972b7dc02438161c32420fb4ec5e14"},
{file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:15439f3c5c72686b6c3ff235279630d08936ace67d0fe5c8d5bbc3ef06f5a420"},
{file = "yarl-1.11.1-cp38-cp38-win32.whl", hash = "sha256:238a21849dd7554cb4d25a14ffbfa0ef380bb7ba201f45b144a14454a72ffa5a"},
{file = "yarl-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:67459cf8cf31da0e2cbdb4b040507e535d25cfbb1604ca76396a3a66b8ba37a6"},
{file = "yarl-1.11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:884eab2ce97cbaf89f264372eae58388862c33c4f551c15680dd80f53c89a269"},
{file = "yarl-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a336eaa7ee7e87cdece3cedb395c9657d227bfceb6781295cf56abcd3386a26"},
{file = "yarl-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87f020d010ba80a247c4abc335fc13421037800ca20b42af5ae40e5fd75e7909"},
{file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637c7ddb585a62d4469f843dac221f23eec3cbad31693b23abbc2c366ad41ff4"},
{file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48dfd117ab93f0129084577a07287376cc69c08138694396f305636e229caa1a"},
{file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e0ae31fb5ccab6eda09ba1494e87eb226dcbd2372dae96b87800e1dcc98804"},
{file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f46f81501160c28d0c0b7333b4f7be8983dbbc161983b6fb814024d1b4952f79"},
{file = "yarl-1.11.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04293941646647b3bfb1719d1d11ff1028e9c30199509a844da3c0f5919dc520"},
{file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:250e888fa62d73e721f3041e3a9abf427788a1934b426b45e1b92f62c1f68366"},
{file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e8f63904df26d1a66aabc141bfd258bf738b9bc7bc6bdef22713b4f5ef789a4c"},
{file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aac44097d838dda26526cffb63bdd8737a2dbdf5f2c68efb72ad83aec6673c7e"},
{file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:267b24f891e74eccbdff42241c5fb4f974de2d6271dcc7d7e0c9ae1079a560d9"},
{file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6907daa4b9d7a688063ed098c472f96e8181733c525e03e866fb5db480a424df"},
{file = "yarl-1.11.1-cp39-cp39-win32.whl", hash = "sha256:14438dfc5015661f75f85bc5adad0743678eefee266ff0c9a8e32969d5d69f74"},
{file = "yarl-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:94d0caaa912bfcdc702a4204cd5e2bb01eb917fc4f5ea2315aa23962549561b0"},
{file = "yarl-1.11.1-py3-none-any.whl", hash = "sha256:72bf26f66456baa0584eff63e44545c9f0eaed9b73cb6601b647c91f14c11f38"},
{file = "yarl-1.11.1.tar.gz", hash = "sha256:1bb2d9e212fb7449b8fb73bc461b51eaa17cc8430b4a87d87be7b25052d92f53"},
]
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
[[package]]
name = "zipp"
version = "3.19.2"
description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false
python-versions = ">=3.8"
files = [
{file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"},
{file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"},
]
[package.extras]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10"
content-hash = "4f3a5906119cb6fb374d14db3d452001d57fe579b077b418bf33c3be7226f5e3"
uiprotect-6.1.0/pyproject.toml 0000664 0000000 0000000 00000013071 14673102202 0016447 0 ustar 00root root 0000000 0000000 [tool.poetry]
name = "uiprotect"
version = "6.1.0"
description = "Python API for Unifi Protect (Unofficial)"
authors = ["UI Protect Maintainers "]
readme = "README.md"
repository = "https://github.com/uilibs/uiprotect"
documentation = "https://uiprotect.readthedocs.io"
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",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: MIT License"
]
packages = [
{ include = "uiprotect", from = "src" },
]
[tool.poetry.urls]
"Bug Tracker" = "https://github.com/uilibs/uiprotect/issues"
"Changelog" = "https://github.com/uilibs/uiprotect/blob/main/CHANGELOG.md"
[tool.poetry.scripts]
uiprotect = "uiprotect.cli:app"
[tool.poetry.dependencies]
python = ">=3.10"
rich = ">=10"
async-timeout = ">=3.0.1"
aiofiles = ">=24"
aiohttp = ">=3.10.0"
aioshutil = ">=1.3"
dateparser = ">=1.1.0"
orjson = ">=3.9.15"
packaging = ">=23"
pillow = ">=10"
platformdirs = ">=4"
pydantic = "!=1.9.1,>=1.10.17"
pyjwt = ">=2.6"
yarl = ">=1.9"
typer = ">=0.12.3"
convertertools = ">=0.5.0"
[tool.poetry.group.dev.dependencies]
pytest = ">=7,<9"
pytest-cov = ">=3,<6"
aiosqlite = ">=0.20.0"
asttokens = "^2.4.1"
pytest-asyncio = ">=0.23.7,<0.25.0"
pytest-benchmark = "^4.0.0"
pytest-sugar = "^1.0.0"
pytest-timeout = "^2.3.1"
pytest-xdist = "^3.6.1"
types-aiofiles = ">=23.2.0.20240403,<25.0.0.0"
types-dateparser = "^1.2.0.20240420"
mypy = "^1.10.0"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
myst-parser = { version = ">=0.16", python = ">=3.11"}
sphinx = { version = ">=4.0", python = ">=3.11"}
furo = { version = ">=2023.5.20", python = ">=3.11"}
sphinx-autobuild = { version = ">=2024.0.0", python = ">=3.11"}
mike = "^2.1.1"
mkdocs-material = "^9.5.26"
mkdocs-material-extensions = "^1.3.1"
pymdown-extensions = "^10.8.1"
mkdocs-git-revision-date-localized-plugin = "^1.2.6"
mkdocs-include-markdown-plugin = "^6.1.1"
mkdocstrings = ">=0.25.1,<0.27.0"
mkdocstrings-python = "^1.10.3"
[tool.semantic_release]
version_toml = ["pyproject.toml:tool.poetry.version"]
version_variables = [
"src/uiprotect/__init__.py:__version__",
"docs/conf.py:release",
]
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.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 = "py310"
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
"UP038", # Use `X | Y` in `isinstance` is slower
"S603", # check for execution of untrusted input
]
select = [
"B", # flake8-bugbear
"D", # flake8-docstrings
"C4", # flake8-comprehensions
"S", # flake8-bandit
"F", # pyflake
"E", # pycodestyle
"W", # pycodestyle
"UP", # pyupgrade
"I", # isort
"RUF", # ruff specific
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*" = [
"D100",
"D101",
"D102",
"D103",
"D104",
"S101",
]
"setup.py" = ["D100"]
"conftest.py" = ["D100"]
"docs/conf.py" = ["D100"]
[tool.ruff.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>=1.0.0"]
build-backend = "poetry.core.masonry.api"
uiprotect-6.1.0/renovate.json 0000664 0000000 0000000 00000000101 14673102202 0016237 0 ustar 00root root 0000000 0000000 {
"extends": ["github>browniebroke/renovate-configs:python"]
}
uiprotect-6.1.0/setup.py 0000664 0000000 0000000 00000000357 14673102202 0015250 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")
uiprotect-6.1.0/src/ 0000775 0000000 0000000 00000000000 14673102202 0014320 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/src/uiprotect/ 0000775 0000000 0000000 00000000000 14673102202 0016336 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/src/uiprotect/__init__.py 0000664 0000000 0000000 00000001174 14673102202 0020452 0 ustar 00root root 0000000 0000000 """Unofficial UniFi Protect Python API and Command Line Interface."""
from __future__ import annotations
from .api import ProtectApiClient
from .exceptions import Invalid, NotAuthorized, NvrError
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__ = [
"Invalid",
"NotAuthorized",
"NvrError",
"ProtectApiClient",
"get_nested_attr",
"get_nested_attr_as_bool",
"get_top_level_attr_as_bool",
"make_value_getter",
"make_enabled_getter",
"make_required_getter",
]
uiprotect-6.1.0/src/uiprotect/__main__.py 0000664 0000000 0000000 00000000716 14673102202 0020434 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import os
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 = os.path.join(os.getcwd(), ".env")
if os.path.exists(env_file):
load_dotenv(dotenv_path=env_file)
else:
load_dotenv()
app()
if __name__ == "__main__":
start()
uiprotect-6.1.0/src/uiprotect/api.py 0000664 0000000 0000000 00000204536 14673102202 0017473 0 ustar 00root root 0000000 0000000 """UniFi Protect Server Wrapper."""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import logging
import re
import sys
import time
from collections.abc import Callable
from datetime import datetime, timedelta
from functools import cached_property, partial
from http import HTTPStatus
from http.cookies import Morsel, SimpleCookie
from ipaddress import IPv4Address, IPv6Address
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import SplitResult
import aiofiles
import aiohttp
import orjson
from aiofiles import os as aos
from aiohttp import CookieJar, client_exceptions
from platformdirs import user_cache_dir, user_config_dir
from yarl import URL
from .data import (
NVR,
Bootstrap,
Bridge,
Camera,
Doorlock,
Event,
EventCategories,
EventType,
Light,
Liveview,
ModelType,
ProtectAdoptableDeviceModel,
ProtectModel,
PTZPosition,
PTZPreset,
Sensor,
SmartDetectObjectType,
SmartDetectTrack,
Version,
Viewer,
WSPacket,
WSSubscriptionMessage,
create_from_unifi_dict,
)
from .data.base import ProtectModelWithId
from .data.devices import Chime
from .data.types import IteratorCallback, ProgressCallback
from .exceptions import BadRequest, NotAuthorized, NvrError
from .utils import (
decode_token_cookie,
get_response_reason,
ip_from_host,
set_debug,
to_js_time,
utc_now,
)
from .websocket import Websocket, WebsocketState
if sys.version_info[:2] < (3, 13):
from http import cookies
# 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]
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
PROTECT_APT_URLS = [
"https://apt.artifacts.ui.com/dists/stretch/release/binary-arm64/Packages",
"https://apt.artifacts.ui.com/dists/bullseye/release/binary-arm64/Packages",
]
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)
# TODO: Urls to still support
# Backups
# * GET /backups - list backends
# * POST /backups/import - import backup
# * POST /backups - create backup
# * GET /backups/{id} - download backup
# * POST /backups/{id}/restore - restore backup
# * DELETE /backups/{id} - delete backup
#
# Cameras
# * POST /cameras/{id}/reset - factory reset camera
# * POST /cameras/{id}/reset-isp - reset ISP settings
# * POST /cameras/{id}/reset-isp - reset ISP settings
# * POST /cameras/{id}/wake - battery powered cameras
# * POST /cameras/{id}/sleep
# * POST /cameras/{id}/homekit-talkback-speaker-muted
# * GET /cameras/{id}/live-heatmap - add live heatmap to WebRTC stream
# * GET /cameras/{id}/enable-control - PTZ controls
# * GET /cameras/{id}/disable-control
# * POST /cameras/{id}/move
# * POST /cameras/{id}/ptz/position
# * GET|POST /cameras/{id}/ptz/preset
# * GET /cameras/{id}/ptz/snapshot
# * POST /cameras/{id}/ptz/goto
# * GET /cameras/{id}/analytics-heatmap - analytics
# * GET /cameras/{id}/analytics-detections
# * GET /cameras/{id}/wifi-list - WiFi scan
# * POST /cameras/{id}/wifi-setup - Change WiFi settings
# * GET /cameras/{id}/playback-history
# * GET|POST|DELETE /cameras/{id}/sharedStream - stream sharing, unfinished?
#
# Device Groups
# * GET|POST|PUT|DELETE /device-groups
# * GET|PATCH|DELETE /device-groups/{id}
# * PATCH /device-groups/{id}/items
#
# Events
# POST /events/{id}/animated-thumbnail
#
# Lights
# POST /lights/{id}/locate
#
# NVR
# GET|PATCH /nvr/device-password
#
# Schedules
# GET|POST /recordingSchedules
# PATCH|DELETE /recordingSchedules/{id}
#
# Sensors
# POST /sensors/{id}/locate
#
# Timeline
# GET /timeline
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
_password: str
_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
_loaded_session: bool = False
_cookiename = "TOKEN"
headers: dict[str, str] | None = None
_websocket: Websocket | None = None
api_path: str = "/proxy/protect/api/"
ws_path: str = "/proxy/protect/ws/updates"
cache_dir: Path
config_dir: Path
store_sessions: bool
def __init__(
self,
host: str,
port: int,
username: str,
password: str,
verify_ssl: bool = True,
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,
) -> None:
self._auth_lock = asyncio.Lock()
self._host = host
self._port = port
self._username = username
self._password = password
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
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
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."""
if self._port != 443:
self._url = URL(f"https://{self._host}:{self._port}")
self._ws_url = URL(f"wss://{self._host}:{self._port}{self.ws_path}")
else:
self._url = URL(f"https://{self._host}")
self._ws_url = URL(f"wss://{self._host}{self.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 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 _auth_websocket(self, force: bool) -> dict[str, str] | None:
"""Authenticate for Websocket."""
if force:
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()
return self.headers
def _get_websocket(self) -> Websocket:
"""Gets or creates current Websocket."""
if self._websocket is None:
self._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,
)
return self._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 client session"""
await self._cancel_update_task()
if self._session is not None:
await self._session.close()
self._session = None
self._loaded_session = False
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
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 request(
self,
method: str,
url: str,
require_auth: bool = False,
auto_close: bool = True,
**kwargs: Any,
) -> aiohttp.ClientResponse:
"""Make a request to UniFi Protect"""
if require_auth:
await self.ensure_authenticated()
request_url = self._url.join(
URL(SplitResult("", "", url, "", ""), encoded=True)
)
headers = kwargs.get("headers") or self.headers
_LOGGER.debug("Request url: %s", request_url)
if not self._verify_ssl:
kwargs["ssl"] = False
session = await self.get_session()
for attempt in range(2):
try:
req_context = session.request(
method,
request_url,
headers=headers,
**kwargs,
)
response = await req_context.__aenter__()
await self._update_last_token_cookie(response)
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
except aiohttp.ServerDisconnectedError as err:
# If the server disconnected, try again
# since HTTP/1.1 allows the server to disconnect
# at any time
if attempt == 0:
continue
raise NvrError(
f"Error requesting data from {self._host}: {err}",
) from err
except client_exceptions.ClientError as err:
raise NvrError(
f"Error requesting data from {self._host}: {err}",
) from err
# should never happen
raise NvrError(f"Error requesting data from {self._host}")
async def api_request_raw(
self,
url: str,
method: str = "get",
require_auth: bool = True,
raise_exception: bool = True,
**kwargs: Any,
) -> bytes | None:
"""Make a request to UniFi Protect API"""
response = await self.request(
method,
f"{self.api_path}{url}",
require_auth=require_auth,
auto_close=False,
**kwargs,
)
try:
if response.status != 200:
await self._raise_for_status(response, raise_exception)
return None
data: bytes | None = await response.read()
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
if raise_exception:
if status in {
HTTPStatus.UNAUTHORIZED.value,
HTTPStatus.FORBIDDEN.value,
}:
raise NotAuthorized(msg % (url, status, reason))
elif status == HTTPStatus.TOO_MANY_REQUESTS.value:
_LOGGER.debug("Too many requests - Login is rate limited: %s", response)
raise NvrError(msg % (url, status, reason))
elif (
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,
**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,
**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,
**kwargs: Any,
) -> dict[str, Any]:
data = await self.api_request(
url=url,
method=method,
require_auth=require_auth,
raise_exception=raise_exception,
**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,
**kwargs: Any,
) -> list[Any]:
data = await self.api_request(
url=url,
method=method,
require_auth=require_auth,
raise_exception=raise_exception,
**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."""
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._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)
self.set_header("cookie", response.headers.get("set-cookie", ""))
self._is_authenticated = True
_LOGGER.debug("Authenticated successfully!")
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)
await self._update_last_token_cookie(response)
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."""
if self._last_token_cookie is None:
return
await aos.makedirs(self.config_dir, exist_ok=True)
config: dict[str, Any] = {}
session_hash = get_user_hash(str(self._url), self._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._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."""
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)
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
self._is_authenticated = True
self.set_header("cookie", cookie_value)
if session.get("csrf"):
self.set_header("x-csrf-token", session["csrf"])
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
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._websocket:
websocket = self._get_websocket()
websocket.stop()
await websocket.wait_closed()
self._websocket = None
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
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)
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 aplication on top of.
All objects inside of `.bootstrap` have a refernce 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
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 RTSP stream 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]
_ignore_stats: bool
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
_bootstrap: Bootstrap | None = None
_last_update_dt: datetime | None = None
_connection_host: IPv4Address | IPv6Address | str | None = None
ignore_unadopted: bool
def __init__(
self,
host: str,
port: int,
username: str,
password: str,
verify_ssl: bool = True,
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,
ignore_stats: bool = False,
ignore_unadopted: bool = True,
debug: bool = False,
ws_receive_timeout: int | None = None,
) -> None:
super().__init__(
host=host,
port=port,
username=username,
password=password,
verify_ssl=verify_ssl,
session=session,
ws_timeout=ws_timeout,
ws_receive_timeout=ws_receive_timeout,
cache_dir=cache_dir,
config_dir=config_dir,
store_sessions=store_sessions,
)
self._minimum_score = minimum_score
self._subscribed_models = subscribed_models or set()
self._ignore_stats = ignore_stats
self._ws_subscriptions = []
self._ws_state_subscriptions = []
self.ignore_unadopted = ignore_unadopted
self._update_lock = asyncio.Lock()
if override_connection_host:
self._connection_host = ip_from_host(self._host)
if debug:
set_debug()
@cached_property
def bootstrap(self) -> Bootstrap:
if self._bootstrap is None:
raise BadRequest("Client not initialized, run `update` first")
return self._bootstrap
@property
def connection_host(self) -> IPv4Address | IPv6Address | str:
"""Connection host to use for generating RTSP URLs"""
if self._connection_host is None:
# fallback if cannot find user supplied host
index = 0
try:
# check if user supplied host is avaiable
index = self.bootstrap.nvr.hosts.index(self._host)
except ValueError:
# check if IP of user supplied host is avaiable
host = ip_from_host(self._host)
with contextlib.suppress(ValueError):
index = self.bootstrap.nvr.hosts.index(host)
self._connection_host = self.bootstrap.nvr.hosts[index]
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
"""
async with self._update_lock:
bootstrap = await self.get_bootstrap()
self.__dict__.pop("bootstrap", None)
self._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 _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)
async def _get_event_paginate(
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(
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 decending, 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 decending, 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 _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 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 _unsubscribe_websocket_state(
self,
ws_callback: Callable[[WebsocketState], None],
) -> None:
"""Unsubscribe to websocket state changes."""
self._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")
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
"""
data = await self.api_request_obj("bootstrap")
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`
"""
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_doorlocks(self) -> list[Doorlock]:
"""
Gets the list of doorlocks straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.doorlocks`
"""
return cast(
list[Doorlock],
await self.get_devices(ModelType.DOORLOCK, Doorlock),
)
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_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")
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]`
"""
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_doorlock(self, device_id: str) -> Doorlock:
"""
Gets a doorlock straight from the NVR.
The websocket is connected and running, you likely just want to use `self.bootstrap.doorlocks[device_id]`
"""
return cast(
Doorlock,
await self.get_device(ModelType.DOORLOCK, device_id, Doorlock),
)
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_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_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 wtih 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,
)
r = await self.request(
"get",
f"{self.api_path}{path}",
auto_close=False,
timeout=0,
params=params,
)
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.replace("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.replace("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.replace("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 close_lock(self, device_id: str) -> None:
"""Close doorlock (lock)"""
await self.api_request(f"doorlocks/{device_id}/close", method="post")
async def open_lock(self, device_id: str) -> None:
"""Open doorlock (unlock)"""
await self.api_request(f"doorlocks/{device_id}/open", method="post")
async def calibrate_lock(self, device_id: str) -> None:
"""
Calibrate the doorlock.
Door must be open and lock unlocked.
"""
await self.api_request(
f"doorlocks/{device_id}/calibrate",
method="post",
json={"auto": True},
)
async def play_speaker(
self,
device_id: str,
*,
volume: int | None = None,
repeat_times: int | None = None,
) -> None:
"""Plays chime tones on a chime"""
data: dict[str, Any] | None = None
if volume or repeat_times:
chime = self.bootstrap.chimes.get(device_id)
if chime is None:
raise BadRequest("Invalid chime ID %s", device_id)
data = {
"volume": volume or chime.volume,
"repeatTimes": repeat_times or chime.repeat_times,
"trackNo": chime.track_no,
}
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 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,
asyncio.TimeoutError,
aiohttp.ServerDisconnectedError,
client_exceptions.ClientError,
) as err:
raise NvrError(f"Error packages from {url}: {err}") from err
return versions
async def get_release_versions(self) -> set[Version]:
"""Get all release versions for UniFi Protect"""
versions: set[Version] = set()
for url in PROTECT_APT_URLS:
try:
versions |= await self._get_versions_from_api(url)
except NvrError:
_LOGGER.warning("Failed to retrieve release versions from online.")
return versions
async def relative_move_ptz_camera(
self,
device_id: str,
*,
pan: float,
tilt: float,
pan_speed: int = 10,
tilt_speed: int = 10,
scale: int = 0,
) -> None:
"""
Move PTZ Camera relatively.
Pan/tilt values vary from camera to camera, but for G4 PTZ:
* Pan values range from 1 (0°) to 35200 (360°/0°).
* Tilt values range from 1 (-20°) to 9777 (90°).
Relative positions cannot move more then 4095 units in either direction at a time.
Camera objects have ptz values in feature flags and the methods on them provide better
control.
"""
data = {
"type": "relative",
"payload": {
"panPos": pan,
"tiltPos": tilt,
"panSpeed": pan_speed,
"tiltSpeed": tilt_speed,
"scale": scale,
},
}
await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
async def center_ptz_camera(
self,
device_id: str,
*,
x: int,
y: int,
z: int,
) -> None:
"""
Center PTZ Camera on point in viewport.
x, y, z values range from 0 to 1000.
x, y are relative coords for the current viewport:
* (0, 0) is top left
* (500, 500) is the center
* (1000, 1000) is the bottom right
z value is zoom, but since it is capped at 1000, probably better to use `ptz_zoom_camera`.
"""
data = {
"type": "center",
"payload": {
"x": x,
"y": y,
"z": z,
},
}
await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
async def zoom_ptz_camera(
self,
device_id: str,
*,
zoom: float,
speed: int = 10,
) -> None:
"""
Zoom PTZ Camera.
Zoom levels vary from camera to camera, but for G4 PTZ it goes from 0 (1x) to 2010 (22x).
Zoom speed does not seem to do much, if anything.
Camera objects have ptz values in feature flags and the methods on them provide better
control.
"""
data = {
"type": "zoom",
"payload": {
"zoomPos": zoom,
"zoomSpeed": speed,
},
}
await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
async def get_position_ptz_camera(self, device_id: str) -> PTZPosition:
"""Get current PTZ camera position."""
pos = await self.api_request_obj(f"cameras/{device_id}/ptz/position")
return PTZPosition(**pos)
async def goto_ptz_camera(self, device_id: str, *, slot: int = -1) -> None:
"""
Goto PTZ slot position.
-1 is Home slot.
"""
await self.api_request(f"cameras/{device_id}/ptz/goto/{slot}", method="post")
async def create_preset_ptz_camera(self, device_id: str, *, name: str) -> PTZPreset:
"""Create PTZ Preset for camera based on current camera settings."""
preset = await self.api_request_obj(
f"cameras/{device_id}/ptz/preset",
method="post",
json={"name": name},
)
return PTZPreset(**preset)
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 delete_preset_ptz_camera(self, device_id: str, *, slot: int) -> None:
"""Delete PTZ preset for camera."""
await self.api_request(
f"cameras/{device_id}/ptz/preset/{slot}",
method="delete",
)
async def get_home_ptz_camera(self, device_id: str) -> PTZPreset:
"""Get PTZ home preset (-1)."""
preset = await self.api_request_obj(f"cameras/{device_id}/ptz/home")
return PTZPreset(**preset)
async def set_home_ptz_camera(self, device_id: str) -> PTZPreset:
"""Set PTZ home preset (-1) to current position."""
preset = await self.api_request_obj(
f"cameras/{device_id}/ptz/home",
method="post",
)
return PTZPreset(**preset)
uiprotect-6.1.0/src/uiprotect/cli/ 0000775 0000000 0000000 00000000000 14673102202 0017105 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/src/uiprotect/cli/__init__.py 0000664 0000000 0000000 00000021244 14673102202 0021221 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import base64
import logging
import sys
from pathlib import Path
from typing import Optional, cast
import orjson
import typer
from rich.progress import track
from uiprotect.api import ProtectApiClient
from ..data import Version, WSPacket
from ..test_util import SampleDataGenerator
from ..utils import RELEASE_CACHE, get_local_timezone, run_async
from ..utils import profile_ws as profile_ws_job
from .base import CliContext, OutputFormatEnum
from .cameras import app as camera_app
from .chimes import app as chime_app
from .doorlocks import app as doorlock_app
from .events import app as event_app
from .lights import app as light_app
from .liveviews import app as liveview_app
from .nvr import app as nvr_app
from .sensors import app as sensor_app
from .viewers import app as viewer_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]
OPTION_USERNAME = typer.Option(
...,
"--username",
"-U",
help="UniFi Protect username",
prompt=True,
envvar="UFP_USERNAME",
)
OPTION_PASSWORD = typer.Option(
...,
"--password",
"-P",
help="UniFi Protect password",
prompt=True,
hide_input=True,
envvar="UFP_PASSWORD",
)
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 = typer.Option(
True,
"--no-verify",
help="Verify SSL",
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(doorlock_app, name="doorlocks")
app.add_typer(light_app, name="lights")
app.add_typer(sensor_app, name="sensors")
app.add_typer(viewer_app, name="viewers")
if backup_app is not None:
app.add_typer(backup_app, name="backup")
@app.callback()
def main(
ctx: typer.Context,
username: str = OPTION_USERNAME,
password: str = OPTION_PASSWORD,
address: str = OPTION_ADDRESS,
port: int = OPTION_PORT,
verify: bool = OPTION_VERIFY,
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()
protect = ProtectApiClient(
address,
port,
username,
password,
verify_ssl=verify,
ignore_unadopted=not include_unadopted,
)
async def update() -> None:
protect._bootstrap = await protect.get_bootstrap()
await protect.close_session()
run_async(update())
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: Optional[Path] = 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: Optional[Path] = 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()
_setup_logger()
run_async(callback())
@app.command()
def decode_ws_msg(
ws_file: typer.FileBinaryRead = OPTION_WS_FILE,
ws_data: Optional[str] = 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 release_versions(ctx: typer.Context) -> None:
"""Updates the release version cache on disk."""
protect = cast(ProtectApiClient, ctx.obj.protect)
async def callback() -> set[Version]:
versions = await protect.get_release_versions()
await protect.close_session()
return versions
_setup_logger()
versions = run_async(callback())
output = orjson.dumps(sorted([str(v) for v in versions]))
Path(RELEASE_CACHE).write_bytes(output)
typer.echo(output.decode("utf-8"))
uiprotect-6.1.0/src/uiprotect/cli/backup.py 0000664 0000000 0000000 00000107544 14673102202 0020737 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import logging
import math
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, 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__)
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
seperator: 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(str, Enum):
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=timezone.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=timezone.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:
camera_slug = (
camera.display_name.lower().replace(" ", ctx.seperator)
+ ctx.seperator
)
display_name = camera.display_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.seperator,
),
"date": self.start.strftime("%Y-%m-%d").replace("-", ctx.seperator),
"time": self.start.strftime("%H-%M-%S%z").replace("-", ctx.seperator),
"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.seperator,
),
"date_local": start_local.strftime("%Y-%m-%d").replace(
"-",
ctx.seperator,
),
"time_local": start_local.strftime("%H-%M-%S%z").replace(
"-",
ctx.seperator,
),
"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.seperator,
}
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 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)
paths = list(ctx.output.glob(file_path))
if paths:
return paths[0]
return None
def get_gif_path(self, ctx: BackupContext) -> Path:
context = self.get_file_context(ctx)
file_path = ctx.gif_format.format(**context)
return 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)
paths = list(ctx.output.glob(file_path))
if paths:
return paths[0]
return None
def get_event_path(self, ctx: BackupContext) -> Path:
context = self.get_file_context(ctx)
file_path = ctx.event_format.format(**context)
return 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)
paths = list(ctx.output.glob(file_path))
if paths:
return paths[0]
return None
@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_SPERATOR = 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: Optional[str] = OPTION_START,
end: Optional[str] = OPTION_END,
output_folder: Optional[Path] = 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,
seperator: str = OPTION_SPERATOR,
) -> 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(os.getcwd())
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),
seperator=seperator,
)
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():
os.remove(ctx.db_file)
for path in track(ctx.output.glob("**/*.jpg"), description="Deleting Thumbnails"):
os.remove(path)
for path in track(ctx.output.glob("**/*.mp4"), description="Deleting Clips"):
os.remove(path)
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"):
thumb_path = event.get_thumbnail_path(ctx)
if thumb_path.exists():
_LOGGER.debug("Delete file %s", thumb_path)
await aos.remove(thumb_path)
event_path = event.get_event_path(ctx)
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]
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:
os.remove(path)
output_path.rename(path)
elif output_path.exists():
os.remove(output_path)
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
# TODO
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.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."""
# surpress 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),
)
uiprotect-6.1.0/src/uiprotect/cli/base.py 0000664 0000000 0000000 00000016647 14673102202 0020407 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from collections.abc import Callable, Coroutine, Mapping, Sequence
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional, TypeVar
import orjson
import typer
from pydantic.v1 import ValidationError
from ..api import ProtectApiClient
from ..data import NVR, ProtectAdoptableDeviceModel, ProtectBaseObject
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(str, Enum):
JSON = "json"
PLAIN = "plain"
@dataclass
class CliContext:
protect: ProtectApiClient
output_format: OutputFormatEnum
def run(ctx: typer.Context, func: Coroutine[Any, Any, T]) -> T:
"""Helper method to call async function and clean up API client"""
async def callback() -> T:
return_value = await func
await ctx.obj.protect.close_session()
return return_value
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]) -> None:
"""Helper method to print a list of protect objects"""
data = [o.unifi_dict() for o in objs]
json_output(data)
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: Optional[str] = typer.Argument(None)) -> None:
"""Sets name for the device"""
require_device_id(ctx)
obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device
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: Optional[str] = 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["protect-url"] = app.command()(protect_url)
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
uiprotect-6.1.0/src/uiprotect/cli/cameras.py 0000664 0000000 0000000 00000041115 14673102202 0021074 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, 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: Optional[str] = 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: Optional[bool] = typer.Argument(None),
) -> None:
"""
Returns/sets library managed privacy mode.
Does not change the microphone sensitivity or recording mode.
It must be changed seperately.
"""
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: Optional[d.ChimeType] = 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: Optional[int] = typer.Option(None, "-w", "--width"),
height: Optional[int] = typer.Option(None, "-h", "--height"),
dt: Optional[datetime] = 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(timezone.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: Optional[int] = 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(timezone.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="ffmpeg playable URL"),
ffmpeg_path: Optional[Path] = typer.Option(
None,
"--ffmpeg-path",
help="Path to ffmpeg executable",
envvar="FFMPEG_PATH",
),
) -> 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, ffmpeg_path=ffmpeg_path))
@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 mutally 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))
data_before_changes = obj.dict_with_excludes()
obj.smart_detect_settings.object_types = values
base.run(ctx, obj.save_device(data_before_changes))
@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))
data_before_changes = obj.dict_with_excludes()
obj.smart_detect_settings.audio_types = values
base.run(ctx, obj.save_device(data_before_changes))
@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(enabled))
@app.command()
def set_hdr(ctx: typer.Context, enabled: bool) -> None:
"""Sets HDR (High Dynamic Range) on camera"""
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_hdr(enabled))
@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(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(level))
@app.command()
def set_speaker_volume(
ctx: typer.Context,
level: int = typer.Argument(..., min=0, max=100),
) -> None:
"""Sets the speaker sensitivity level 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_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(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(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(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_bitrate(enabled))
@app.command()
def set_lcd_text(
ctx: typer.Context,
text_type: Optional[d.DoorbellMessageType] = typer.Argument(
None,
help="No value sets it back to the global default doorbell message.",
),
text: Optional[str] = typer.Argument(
None,
help="Only for CUSTOM_MESSAGE text type",
),
reset_at: Optional[datetime] = 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(timezone.utc).astimezone().tzinfo
reset_at = reset_at.replace(tzinfo=local_tz)
base.require_device_id(ctx)
obj: d.Camera = ctx.obj.device
base.run(ctx, obj.set_lcd_text(text_type, text, reset_at))
uiprotect-6.1.0/src/uiprotect/cli/chimes.py 0000664 0000000 0000000 00000012316 14673102202 0020732 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
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: Optional[str] = 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 mutally 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))
data_before_changes = obj.dict_with_excludes()
obj.camera_ids = camera_ids
base.run(ctx, obj.save_device(data_before_changes))
@app.command()
def set_volume(
ctx: typer.Context,
value: int = ARG_VOLUME,
camera_id: Optional[str] = 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
if camera_id is None:
base.run(ctx, obj.set_volume(value))
else:
protect: ProtectApiClient = ctx.obj.protect
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(camera, value))
@app.command()
def play(
ctx: typer.Context,
volume: Optional[int] = typer.Option(None, "-v", "--volume", min=1, max=100),
repeat_times: Optional[int] = 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: Optional[str] = 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
if camera_id is None:
base.run(ctx, obj.set_repeat_times(value))
else:
protect: ProtectApiClient = ctx.obj.protect
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(camera, value))
uiprotect-6.1.0/src/uiprotect/cli/doorlocks.py 0000664 0000000 0000000 00000006703 14673102202 0021464 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Optional
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import Doorlock
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(None, help="ID of doorlock to select for subcommands")
@dataclass
class DoorlockContext(base.CliContext):
devices: dict[str, Doorlock]
device: Doorlock | None = None
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
"""
Doorlock device CLI.
Returns full list of Doorlocks without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = DoorlockContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.doorlocks,
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.doorlocks.get(device_id)) is None:
typer.secho("Invalid doorlock 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: Optional[str] = typer.Argument(None)) -> None:
"""Returns or sets tha paired camera for a doorlock."""
base.require_device_id(ctx)
obj: Doorlock = 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 the lock."""
base.require_device_id(ctx)
obj: Doorlock = ctx.obj.device
base.run(ctx, obj.set_status_light(enabled))
@app.command()
def set_auto_close_time(
ctx: typer.Context,
duration: int = typer.Argument(..., min=0, max=3600),
) -> None:
"""Sets auto-close time for the lock (in seconds). 0 = disabled."""
base.require_device_id(ctx)
obj: Doorlock = ctx.obj.device
base.run(ctx, obj.set_auto_close_time(timedelta(seconds=duration)))
@app.command()
def unlock(ctx: typer.Context) -> None:
"""Unlocks the lock."""
base.require_device_id(ctx)
obj: Doorlock = ctx.obj.device
base.run(ctx, obj.open_lock())
@app.command()
def lock(ctx: typer.Context) -> None:
"""Locks the lock."""
base.require_device_id(ctx)
obj: Doorlock = ctx.obj.device
base.run(ctx, obj.close_lock())
@app.command()
def calibrate(ctx: typer.Context, force: bool = base.OPTION_FORCE) -> None:
"""
Calibrate the doorlock.
Door must be open and lock unlocked.
"""
base.require_device_id(ctx)
obj: Doorlock = ctx.obj.device
if force or typer.confirm("Is the door open and unlocked?"):
base.run(ctx, obj.calibrate())
uiprotect-6.1.0/src/uiprotect/cli/events.py 0000664 0000000 0000000 00000016022 14673102202 0020764 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
from typing import Optional
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: Optional[str] = ARG_EVENT_ID,
start: Optional[datetime] = OPTION_START,
end: Optional[datetime] = OPTION_END,
limit: Optional[int] = OPTION_LIMIT,
offset: Optional[int] = OPTION_OFFSET,
types: Optional[list[d.EventType]] = OPTION_TYPES,
smart_types: Optional[list[d.SmartDetectObjectType]] = 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 dispalyed 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,
),
)
uiprotect-6.1.0/src/uiprotect/cli/lights.py 0000664 0000000 0000000 00000006362 14673102202 0020760 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Optional
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: Optional[str] = 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: Optional[str] = typer.Argument(None)) -> None:
"""Returns or sets tha 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(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(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(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(timedelta(seconds=duration)))
uiprotect-6.1.0/src/uiprotect/cli/liveviews.py 0000664 0000000 0000000 00000003507 14673102202 0021501 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import Liveview
app = typer.Typer(rich_markup_mode="rich")
ARG_DEVICE_ID = typer.Argument(None, help="ID of liveview to select for subcommands")
ALL_COMMANDS = {"list-ids": app.command(name="list-ids")(base.list_ids)}
app.command(name="protect-url")(base.protect_url)
@dataclass
class LiveviewContext(base.CliContext):
devices: dict[str, Liveview]
device: Liveview | None = None
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
"""
Liveviews CLI.
Returns full list of Liveviews without any arguments passed.
"""
protect: ProtectApiClient = ctx.obj.protect
context = LiveviewContext(
protect=ctx.obj.protect,
device=None,
devices=protect.bootstrap.liveviews,
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.liveviews.get(device_id)) is None:
typer.secho("Invalid liveview 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 owner(ctx: typer.Context) -> None:
"""Gets the owner for the liveview."""
base.require_device_id(ctx)
obj: Liveview = ctx.obj.device
base.print_unifi_obj(obj.owner, ctx.obj.output_format)
uiprotect-6.1.0/src/uiprotect/cli/nvr.py 0000664 0000000 0000000 00000010244 14673102202 0020265 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())
uiprotect-6.1.0/src/uiprotect/cli/sensors.py 0000664 0000000 0000000 00000017731 14673102202 0021164 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import typer
from ..api import ProtectApiClient
from ..cli import base
from ..data import MountType, Sensor
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: Optional[str] = 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: Optional[str] = typer.Argument(None)) -> None:
"""Returns or sets tha 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(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(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(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(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_status(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(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
base.run(ctx, obj.set_temperature_safe_range(low, 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
base.run(ctx, obj.set_humidity_safe_range(low, 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
base.run(ctx, obj.set_light_safe_range(low, 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())
uiprotect-6.1.0/src/uiprotect/cli/viewers.py 0000664 0000000 0000000 00000004173 14673102202 0021150 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
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: Optional[str] = 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: Optional[str] = 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_obj := protect.bootstrap.liveviews.get(liveview_id)) is None:
typer.secho("Invalid liveview ID")
raise typer.Exit(1)
base.run(ctx, obj.set_liveview(liveview_obj))
uiprotect-6.1.0/src/uiprotect/data/ 0000775 0000000 0000000 00000000000 14673102202 0017247 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/src/uiprotect/data/__init__.py 0000664 0000000 0000000 00000005572 14673102202 0021371 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from .base import (
ProtectAdoptableDeviceModel,
ProtectBaseObject,
ProtectDeviceModel,
ProtectModel,
ProtectModelWithId,
)
from .bootstrap import Bootstrap
from .convert import create_from_unifi_dict
from .devices import (
Bridge,
Camera,
CameraChannel,
Chime,
Doorlock,
LCDMessage,
Light,
RingSetting,
Sensor,
Viewer,
)
from .nvr import (
NVR,
DoorbellMessage,
Event,
Liveview,
NVRLocation,
SmartDetectItem,
SmartDetectTrack,
)
from .types import (
DEFAULT,
DEFAULT_TYPE,
AnalyticsOption,
AudioStyle,
ChimeType,
Color,
CoordType,
DoorbellMessageType,
DoorbellText,
EventCategories,
EventType,
FixSizeOrderedDict,
HDRMode,
ICRCustomValue,
ICRLuxValue,
IRLEDMode,
LensType,
LightModeEnableType,
LightModeType,
LockStatusType,
ModelType,
MountType,
Percent,
PermissionNode,
ProtectWSPayloadFormat,
PTZPosition,
PTZPreset,
RecordingMode,
SensorStatusType,
SensorType,
SmartDetectAudioType,
SmartDetectObjectType,
StateType,
StorageType,
Version,
VideoMode,
WDRLevel,
)
from .user import CloudAccount, Group, Permission, User, UserLocation
from .websocket import (
WS_HEADER_SIZE,
WSAction,
WSJSONPacketFrame,
WSPacket,
WSPacketFrameHeader,
WSRawPacketFrame,
WSSubscriptionMessage,
)
__all__ = [
"DEFAULT",
"DEFAULT_TYPE",
"NVR",
"WS_HEADER_SIZE",
"AnalyticsOption",
"AudioStyle",
"Bootstrap",
"Bridge",
"Camera",
"CameraChannel",
"Chime",
"ChimeType",
"CloudAccount",
"Color",
"CoordType",
"DoorbellMessage",
"DoorbellMessageType",
"DoorbellText",
"Doorlock",
"Event",
"EventCategories",
"EventType",
"FixSizeOrderedDict",
"Group",
"HDRMode",
"ICRCustomValue",
"ICRLuxValue",
"IRLEDMode",
"LCDMessage",
"LensType",
"Light",
"LightModeEnableType",
"LightModeType",
"Liveview",
"LockStatusType",
"ModelType",
"MountType",
"NVRLocation",
"PTZPosition",
"PTZPreset",
"Percent",
"Permission",
"PermissionNode",
"ProtectAdoptableDeviceModel",
"ProtectBaseObject",
"ProtectDeviceModel",
"ProtectModel",
"ProtectModelWithId",
"ProtectWSPayloadFormat",
"RecordingMode",
"RingSetting",
"Sensor",
"SensorStatusType",
"SensorType",
"SmartDetectAudioType",
"SmartDetectItem",
"SmartDetectObjectType",
"SmartDetectTrack",
"StateType",
"StorageType",
"User",
"UserLocation",
"Version",
"VideoMode",
"Viewer",
"WDRLevel",
"WSAction",
"WSJSONPacketFrame",
"WSPacket",
"WSPacketFrameHeader",
"WSRawPacketFrame",
"WSSubscriptionMessage",
"create_from_unifi_dict",
]
uiprotect-6.1.0/src/uiprotect/data/base.py 0000664 0000000 0000000 00000104331 14673102202 0020535 0 ustar 00root root 0000000 0000000 """UniFi Protect Data."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from datetime import datetime, timedelta
from functools import cache, cached_property
from ipaddress import IPv4Address
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
from uuid import UUID
from convertertools import pop_dict_set_if_none, pop_dict_tuple
from pydantic.v1 import BaseModel
from pydantic.v1.fields import SHAPE_DICT, SHAPE_LIST, PrivateAttr
from ..exceptions import BadRequest, ClientError, NotAuthorized
from ..utils import (
asyncio_timeout,
convert_to_datetime,
convert_unifi_data,
dict_diff,
is_debug,
serialize_unifi_obj,
to_snake_case,
)
from .types import (
ModelType,
PercentFloat,
PermissionNode,
ProtectWSPayloadFormat,
StateType,
)
from .websocket import (
WSJSONPacketFrame,
WSPacket,
WSPacketFrameHeader,
)
if TYPE_CHECKING:
from asyncio.events import TimerHandle
from typing_extensions 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
ProtectObject = TypeVar("ProtectObject", bound="ProtectBaseObject")
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) -> 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 Pyhonic name and they will automatically be populated from the UFP JSON if passed in to the constructer
* Provides `.unifi_dict` to convert object back into UFP JSON
"""
_api: ProtectApiClient = PrivateAttr(None)
class Config:
arbitrary_types_allowed = True
validate_assignment = True
copy_on_model_validation = "shallow"
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.construct(**data)
@classmethod
def 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].construct(**value)
elif has_unifi_lists and key in unifi_lists and isinstance(value, list):
values[key] = [
unifi_lists[key].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].construct(**v) if isinstance(v, dict) else v
for k, v in value.items()
}
obj = super().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.__fields__.items():
try:
if _is_protect_base_object(field.type_):
if field.shape == SHAPE_LIST:
lists[name] = field.type_
elif field.shape == SHAPE_DICT:
dicts[name] = field.type_
else:
objs[name] = field.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.__fields__
for key in list(data):
if key in remaps:
# remap keys that will not be converted correctly by snake_case convert
remapped_key = remaps[key]
data[remapped_key] = data.pop(key)
key = remapped_key
new_key = to_snake_case(key)
data[new_key] = data.pop(key)
key = new_key
if key == "api":
continue
if key not in _fields:
del data[key]
continue
data[key] = convert_unifi_data(data[key], _fields[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.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.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.dict(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(cls: ProtectObject, data: dict[str, Any]) -> ProtectObject:
"""
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,
) = cls._get_protect_model()
api = cls._api
_fields = cls.__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(cls, 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(cls, key, value)
return cls
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.dict(exclude=excludes)
def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
return dict_diff(data_before_changes, self.dict())
@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
@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)
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 construct(cls, _fields_set: set[str] | None = None, **values: Any) -> Self:
update_sync = values.pop("update_sync", None)
obj = super().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, asyncio.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)
class ProtectDeviceModel(ProtectModelWithId):
name: str | None
type: str
mac: str
host: IPv4Address | str | None
up_since: datetime | None
uptime: timedelta | None
last_seen: datetime | None
hardware_revision: str | None
firmware_version: str | 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_event_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
class WirelessConnectionState(ProtectBaseObject):
signal_quality: int | None
signal_strength: int | None
class BluetoothConnectionState(WirelessConnectionState):
experience_score: PercentFloat | None = None
class WifiConnectionState(WirelessConnectionState):
phy_rate: float | None
channel: int | None
frequency: int | None
ssid: str | 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 | str | None
connected_since: datetime | None
latest_firmware_version: str | None
firmware_build: str | 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
# 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
is_downloading_firmware: bool | None
# TODO:
# bridgeCandidates
@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
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
uiprotect-6.1.0/src/uiprotect/data/bootstrap.py 0000664 0000000 0000000 00000047746 14673102202 0021660 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
from aiohttp.client_exceptions import ServerDisconnectedError
from convertertools import pop_dict_set, pop_dict_tuple
from pydantic.v1 import PrivateAttr, ValidationError
from ..exceptions import ClientError
from ..utils import normalize_mac, utc_now
from .base import (
RECENT_EVENT_MAX,
ProtectBaseObject,
ProtectDeviceModel,
ProtectModel,
ProtectModelWithId,
)
from .convert import create_from_unifi_dict
from .devices import (
Bridge,
Camera,
Chime,
Doorlock,
Light,
ProtectAdoptableDeviceModel,
Sensor,
Viewer,
)
from .nvr import NVR, Event, Liveview
from .types import EventType, FixSizeOrderedDict, ModelType
from .user import Group, User
from .websocket import (
WSAction,
WSPacket,
WSSubscriptionMessage,
)
if TYPE_CHECKING:
from ..api import ProtectApiClient
_LOGGER = logging.getLogger(__name__)
MAX_SUPPORTED_CAMERAS = 256
MAX_EVENT_HISTORY_IN_STATE_MACHINE = MAX_SUPPORTED_CAMERAS * 2
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"),
}
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 _process_camera_event(event: Event, camera: Camera) -> None:
event_type = event.type
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 in _CAMERA_SMART_AND_LINE_EVENTS:
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
elif 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]
doorlocks: dict[str, Doorlock]
chimes: dict[str, Chime]
last_update_id: str
# TODO:
# schedules
# agreements
# not directly from UniFi
events: dict[str, Event] = FixSizeOrderedDict()
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
for model_type in ModelType.bootstrap_models_types_set:
key = model_type.devices_key # type: ignore[attr-defined]
items: dict[str, ProtectModel] = {}
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 earilest 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:
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.dict(),
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_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.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.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(
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"]
if action_action == "remove":
return self._process_remove_packet(model_type, action)
if not data and not is_ping_back:
return None
try:
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 (ValidationError, ValueError) 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 = action["id"]
if model_type is ModelType.EVENT:
msg = f"Validation error processing event: {device_id}. Ignoring event."
else:
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}"
)
_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 (
ValidationError,
TimeoutError,
asyncio.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:
"""Get if current version of Protect is a prerelease version."""
return await self.nvr.get_is_prerelease()
uiprotect-6.1.0/src/uiprotect/data/convert.py 0000664 0000000 0000000 00000004211 14673102202 0021277 0 ustar 00root root 0000000 0000000 """UniFi Protect Data Conversion."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..exceptions import DataDecodeError
from .devices import (
Bridge,
Camera,
Chime,
Doorlock,
Light,
Sensor,
Viewer,
)
from .nvr import NVR, Event, Liveview
from .types import ModelType
from .user import CloudAccount, Group, User, UserLocation
if TYPE_CHECKING:
from ..api import ProtectApiClient
from ..data.base import ProtectModel
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.DOORLOCK: Doorlock,
ModelType.CHIME: Chime,
}
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:
"""
Helper method to read the `modelKey` from a UFP JSON dict and convert to currect Python class.
Will raise `DataDecodeError` if the `modelKey` is for an unknown object.
"""
if "modelKey" not in data:
raise DataDecodeError("No modelKey")
if model_type is not None and klass is None:
klass = MODEL_TO_CLASS.get(model_type)
if klass is None:
klass = get_klass_from_dict(data)
return klass.from_unifi_dict(**data, api=api)
uiprotect-6.1.0/src/uiprotect/data/devices.py 0000664 0000000 0000000 00000330440 14673102202 0021247 0 ustar 00root root 0000000 0000000 """UniFi Protect Data."""
from __future__ import annotations
import asyncio
import logging
import warnings
from collections.abc import Callable
from datetime import datetime, timedelta
from functools import cache, lru_cache
from ipaddress import IPv4Address
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from convertertools import pop_dict_set_if_none, pop_dict_tuple
from pydantic.v1.fields import PrivateAttr
from ..exceptions import BadRequest, NotAuthorized, StreamError
from ..stream import TalkbackStream
from ..utils import (
clamp_value,
convert_smart_audio_types,
convert_smart_types,
convert_to_datetime,
convert_video_modes,
from_js_time,
serialize_point,
timedelta_total_seconds,
to_js_time,
utc_now,
)
from .base import (
EVENT_PING_INTERVAL,
ProtectAdoptableDeviceModel,
ProtectBaseObject,
ProtectMotionDeviceModel,
)
from .types import (
DEFAULT,
DEFAULT_TYPE,
AudioCodecs,
AudioStyle,
AutoExposureMode,
ChimeType,
Color,
DoorbellMessageType,
FocusMode,
GeofencingSetting,
HDRMode,
ICRCustomValue,
ICRLuxValue,
ICRSensitivity,
IRLEDMode,
IteratorCallback,
LEDLevel,
LensType,
LightModeEnableType,
LightModeType,
LockStatusType,
LowMedHigh,
ModelType,
MotionAlgorithm,
MountPosition,
MountType,
Percent,
PercentInt,
PermissionNode,
ProgressCallback,
PTZPosition,
PTZPreset,
RecordingMode,
RepeatTimes,
SensorStatusType,
SmartDetectAudioType,
SmartDetectObjectType,
TwoByteInt,
VideoMode,
WDRLevel,
)
from .user import User
if TYPE_CHECKING:
from .nvr import Event, Liveview
PRIVACY_ZONE_NAME = "pyufp_privacy_zone"
LUX_MAPPING_VALUES = [
30,
25,
20,
15,
12,
10,
7,
5,
3,
1,
]
_LOGGER = logging.getLogger(__name__)
class LightDeviceSettings(ProtectBaseObject):
# Status LED
is_indicator_enabled: bool
# Brightness
led_level: LEDLevel
lux_sensitivity: LowMedHigh
pir_duration: timedelta
pir_sensitivity: PercentInt
@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
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_led_level(self, led_level: int) -> None:
"""Sets the LED level for the light"""
def callback() -> None:
self.light_device_settings.led_level = LEDLevel(led_level)
await self.queue_update(callback)
async def set_light(self, enabled: bool, led_level: int | None = None) -> None:
"""Force turns on/off the light"""
def callback() -> None:
self.light_on_settings.is_led_force_on = enabled
if led_level is not None:
self.light_device_settings.led_level = LEDLevel(led_level)
await self.queue_update(callback)
async def set_sensitivity(self, sensitivity: int) -> None:
"""Sets motion sensitivity"""
def callback() -> None:
self.light_device_settings.pir_sensitivity = PercentInt(sensitivity)
await self.queue_update(callback)
async def set_duration(self, duration: timedelta) -> None:
"""Sets motion sensitivity"""
if duration.total_seconds() < 15 or duration.total_seconds() > 900:
raise BadRequest("Duration outside of 15s to 900s range")
def callback() -> None:
self.light_device_settings.pir_duration = duration
await self.queue_update(callback)
async def set_light_settings(
self,
mode: LightModeType,
enable_at: LightModeEnableType | None = None,
duration: timedelta | None = None,
sensitivity: int | None = None,
) -> None:
"""
Updates various Light settings.
Args:
----
mode: Light trigger mode
enable_at: Then the light automatically turns on by itself
duration: How long the light should remain on after motion, must be timedelta between 15s and 900s
sensitivity: PIR Motion sensitivity
"""
if duration is not None and (
duration.total_seconds() < 15 or duration.total_seconds() > 900
):
raise BadRequest("Duration outside of 15s to 900s range")
def callback() -> None:
self.light_mode_settings.mode = mode
if enable_at is not None:
self.light_mode_settings.enable_at = enable_at
if duration is not None:
self.light_device_settings.pir_duration = duration
if sensitivity is not None:
self.light_device_settings.pir_sensitivity = PercentInt(sensitivity)
await self.queue_update(callback)
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 # read only
width: int
height: int
fps: int
bitrate: int
min_bitrate: int # read only
max_bitrate: int # read only
min_client_adaptive_bit_rate: int | None # read only
min_motion_adaptive_bit_rate: int | 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
_rtsp_url: str | None = PrivateAttr(None)
_rtsps_url: str | None = PrivateAttr(None)
@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
self._rtsp_url = f"rtsp://{self._api.connection_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
self._rtsps_url = f"rtsps://{self._api.connection_host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}?enableSrtp"
return self._rtsps_url
@property
def is_package(self) -> bool:
return self.fps <= 2
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
touch_focus_y: int | 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
class LEDSettings(ProtectBaseObject):
# Status Light
is_enabled: bool
blink_rate: int # in milliseconds betweeen blinks, 0 = solid
class SpeakerSettings(ProtectBaseObject):
is_enabled: bool
# Status Sounds
are_system_sounds_enabled: bool
volume: PercentInt
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
@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
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
bind_port: int
filter_addr: str | None # can be used to restrict sender address
filter_port: int | 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
frequency: int | None
link_speed_mbps: str | None
signal_quality: PercentInt
signal_strength: int
class VideoStats(ProtectBaseObject):
recording_start: datetime | None
recording_end: datetime | None
recording_start_lq: datetime | None
recording_end_lq: datetime | None
timelapse_start: datetime | None
timelapse_end: datetime | None
timelapse_start_lq: datetime | None
timelapse_end_lq: datetime | 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 {
key: convert_to_datetime
for key in (
"recordingStart",
"recordingEnd",
"recordingStartLQ",
"recordingEndLQ",
"timelapseStart",
"timelapseEnd",
"timelapseStartLQ",
"timelapseEndLQ",
)
} | super().unifi_dict_conversions()
class StorageStats(ProtectBaseObject):
used: int | None # bytes
rate: float | 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
tx_bytes: int
wifi: WifiStats
video: VideoStats
storage: StorageStats | 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"]]
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
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
min: float | None
step: float | 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: float
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
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
hdr_mode: bool
# Recording Quality -> High Frame
video_mode: VideoMode
is_probing_for_wifi: bool
chime_duration: timedelta
last_ring: datetime | 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
lenses: list[CameraLenses]
platform: str
has_speaker: bool
has_wifi: bool
audio_bitrate: int
can_manage: bool
is_managed: bool
voltage: float | None
# requires 1.21+
is_poor_network: bool | None
is_wireless_uplink_enabled: bool | None
# requires 2.6.13+
homekit_settings: CameraHomekitSettings | None = None
# requires 2.6.17+
ap_mgmt_ip: IPv4Address | 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
# requires 2.11.13+
audio_settings: CameraAudioSettings | None = None
# TODO: used for adopting
# apMac read only
# apRssi read only
# elementInfo read only
# TODO:
# lastPrivacyZonePositionId
# smartDetectLines
# streamSharing read only
# stopStreamLevel
# uplinkDevice
# recordingSchedulesV2
# not directly from UniFi
last_ring_event_id: str | 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
@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_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()
@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,
(
"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.dict()
# 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:
if 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
return self._api.bootstrap.events.get(last_smart_detect_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."""
if event_id := self.last_smart_detect_event_ids.get(smart_type):
return self._api.bootstrap.events.get(event_id)
return None
@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_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)
# 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)
async def set_person_detection(self, enabled: bool) -> None:
"""Toggles person smart detection. Requires camera to have smart detection"""
return await self._set_object_detect(SmartDetectObjectType.PERSON, enabled)
@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)
async def set_vehicle_detection(self, enabled: bool) -> None:
"""Toggles vehicle smart detection. Requires camera to have smart detection"""
return await self._set_object_detect(SmartDetectObjectType.VEHICLE, enabled)
# 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)
async def set_license_plate_detection(self, enabled: bool) -> None:
"""Toggles license plate smart detection. Requires camera to have smart detection"""
return await self._set_object_detect(
SmartDetectObjectType.LICENSE_PLATE,
enabled,
)
# 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)
async def set_package_detection(self, enabled: bool) -> None:
"""Toggles package smart detection. Requires camera to have smart detection"""
return await self._set_object_detect(SmartDetectObjectType.PACKAGE, enabled)
# 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)
async def set_animal_detection(self, enabled: bool) -> None:
"""Toggles animal smart detection. Requires camera to have smart detection"""
return await self._set_object_detect(SmartDetectObjectType.ANIMAL, enabled)
# 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)
async def set_smoke_detection(self, enabled: bool) -> None:
"""Toggles smoke smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.SMOKE, enabled)
# 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)
async def set_siren_detection(self, enabled: bool) -> None:
"""Toggles siren smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.SIREN, enabled)
# 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)
async def set_baby_cry_detection(self, enabled: bool) -> None:
"""Toggles baby_cry smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.BABY_CRY, enabled)
# 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)
async def set_speaking_detection(self, enabled: bool) -> None:
"""Toggles speaking smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.SPEAK, enabled)
# 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)
async def set_bark_detection(self, enabled: bool) -> None:
"""Toggles bark smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.BARK, enabled)
# 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)
async def set_car_horn_detection(self, enabled: bool) -> None:
"""Toggles car_horn smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.CAR_HORN, enabled)
# 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)
async def set_glass_break_detection(self, enabled: bool) -> None:
"""Toggles glass_break smart detection. Requires camera to have smart detection"""
return await self._set_audio_detect(SmartDetectAudioType.GLASS_BREAK, enabled)
# 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.
"""
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}",
)
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_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")
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}",
)
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 wtih 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 == IRLEDMode.CUSTOM
)
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
self.led_settings.blink_rate = 0
await self.queue_update(callback)
async def set_hdr(self, enabled: bool) -> None:
"""Sets HDR (High Dynamic Range) on camera"""
warnings.warn(
"set_hdr is deprecated and replaced with set_hdr_mode for versions of UniFi Protect v3.0+",
DeprecationWarning,
stacklevel=2,
)
if not self.feature_flags.has_hdr:
raise BadRequest("Camera does not have HDR")
def callback() -> None:
self.hdr_mode = enabled
await self.queue_update(callback)
async def set_hdr_mode(self, mode: Literal["auto", "off", "always"]) -> None:
"""Sets HDR mode similar to how Protect interface works."""
if not self.feature_flags.has_hdr:
raise BadRequest("Camera does not have HDR")
def callback() -> None:
if mode == "off":
self.hdr_mode = False
if self.isp_settings.hdr_mode is not None:
self.isp_settings.hdr_mode = HDRMode.NORMAL
else:
self.hdr_mode = True
if self.isp_settings.hdr_mode is not None:
self.isp_settings.hdr_mode = (
HDRMode.NORMAL if mode == "auto" else HDRMode.ALWAYS_ON
)
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_video_mode(self, mode: VideoMode) -> None:
"""Sets video mode on camera"""
if mode not in self.feature_flags.video_modes:
raise BadRequest(f"Camera does not have {mode}")
def callback() -> None:
self.video_mode = mode
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_mic_volume(self, level: int) -> None:
"""Sets the mic sensitivity level on camera"""
if not self.feature_flags.has_mic:
raise BadRequest("Camera does not have mic")
def callback() -> None:
self.mic_volume = PercentInt(level)
await self.queue_update(callback)
async def set_speaker_volume(self, level: int) -> None:
"""Sets the speaker sensitivity 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_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_name(self, enabled: bool) -> None:
"""Sets whether camera name is in the On Screen Display"""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.osd_settings.is_name_enabled = enabled
await self.queue_update(callback)
async def set_osd_date(self, enabled: bool) -> None:
"""Sets whether current date is in the On Screen Display"""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.osd_settings.is_date_enabled = enabled
await self.queue_update(callback)
async def set_osd_logo(self, enabled: bool) -> None:
"""Sets whether the UniFi logo is in the On Screen Display"""
if self.use_global:
raise BadRequest("Camera is using global recording settings.")
def callback() -> None:
self.osd_settings.is_logo_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_object_detect(
self,
obj_to_mod: SmartDetectObjectType,
enabled: bool,
) -> None:
if obj_to_mod not in self.feature_flags.smart_detect_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.object_types
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.object_types = objects
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: None | datetime | DEFAULT_TYPE = 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)
def create_talkback_stream(
self,
content_url: str,
ffmpeg_path: Path | None = None,
) -> TalkbackStream:
"""
Creates a subprocess to play audio to a camera through its speaker.
Requires ffmpeg to use.
Args:
----
content_url: Either a URL accessible by python or a path to a file (ffmepg's `-i` parameter)
ffmpeg_path: Optional path to ffmpeg binary
Use either `await stream.run_until_complete()` or `await stream.start()` to start subprocess command
after getting the stream.
`.play_audio()` is a helper that wraps this method and automatically runs the subprocess as well
"""
if self.talkback_stream is not None and self.talkback_stream.is_running:
raise BadRequest("Camera is already playing audio")
self.talkback_stream = TalkbackStream(self, content_url, ffmpeg_path)
return self.talkback_stream
async def play_audio(
self,
content_url: str,
ffmpeg_path: Path | None = None,
blocking: bool = True,
) -> None:
"""
Plays audio to a camera through its speaker.
Requires ffmpeg to use.
Args:
----
content_url: Either a URL accessible by python or a path to a file (ffmepg's `-i` parameter)
ffmpeg_path: Optional path to ffmpeg binary
blocking: Awaits stream completion and logs stdout/stderr
"""
stream = self.create_talkback_stream(content_url, ffmpeg_path)
await stream.start()
if blocking:
await self.wait_until_audio_completes()
async def wait_until_audio_completes(self) -> None:
"""Awaits stream completion of audio and logs stdout/stderr."""
stream = self.talkback_stream
if stream is None:
raise StreamError("No audio playing to wait for")
await stream.run_until_complete()
_LOGGER.debug("ffmpeg stdout:\n%s", "\n".join(stream.stdout))
_LOGGER.debug("ffmpeg stderr:\n%s", "\n".join(stream.stderr))
if stream.is_error:
error = "\n".join(stream.stderr)
raise StreamError("Error while playing audio (ffmpeg): \n" + error)
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 ptz_relative_move(
self,
*,
pan: float,
tilt: float,
pan_speed: int = 10,
tilt_speed: int = 10,
scale: int = 0,
use_native: bool = False,
) -> None:
"""
Move PTZ relative to current position.
Pan/tilt values vary from camera to camera, but for G4 PTZ:
* Pan values range from 0° and go to 360°/0°
* Tilt values range from -20° and go to 90°
Relative positions cannot move more then 4095 steps at a time in any direction.
For the G4 PTZ, 4095 steps is ~41° for pan and ~45° for tilt.
`use_native` lets you use the native step values instead of degrees.
"""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
if not use_native:
pan = self.feature_flags.pan.to_native_value(pan, is_relative=True)
tilt = self.feature_flags.tilt.to_native_value(tilt, is_relative=True)
await self._api.relative_move_ptz_camera(
self.id,
pan=pan,
tilt=tilt,
pan_speed=pan_speed,
tilt_speed=tilt_speed,
scale=scale,
)
async def ptz_center(self, *, x: int, y: int, z: int) -> None:
"""
Center PTZ Camera on point in viewport.
x, y, z values range from 0 to 1000.
x, y are relative coords for the current viewport:
* (0, 0) is top left
* (500, 500) is the center
* (1000, 1000) is the bottom right
z value is zoom, but since it is capped at 1000, probably better to use `ptz_zoom`.
"""
await self._api.center_ptz_camera(self.id, x=x, y=y, z=z)
async def ptz_zoom(
self,
*,
zoom: float,
speed: int = 100,
use_native: bool = False,
) -> None:
"""
Zoom PTZ Camera.
Zoom levels vary from camera to camera, but for G4 PTZ it goes from 1x to 22x.
Zoom speed seems to range from 0 to 100. Any value over 100 results in a speed of 0.
"""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
if not use_native:
zoom = self.feature_flags.zoom.to_native_value(zoom)
await self._api.zoom_ptz_camera(self.id, zoom=zoom, speed=speed)
async def get_ptz_position(self) -> PTZPosition:
"""Get current PTZ Position."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
return await self._api.get_position_ptz_camera(self.id)
async def goto_ptz_slot(self, *, slot: int) -> None:
"""
Goto PTZ slot position.
-1 is Home slot.
"""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
await self._api.goto_ptz_camera(self.id, slot=slot)
async def create_ptz_preset(self, *, name: str) -> PTZPreset:
"""Create PTZ Preset for camera based on current camera settings."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
return await self._api.create_preset_ptz_camera(self.id, name=name)
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 delete_ptz_preset(self, *, slot: int) -> None:
"""Delete PTZ preset for camera."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
await self._api.delete_preset_ptz_camera(self.id, slot=slot)
async def get_ptz_home(self) -> PTZPreset:
"""Get PTZ home preset (-1)."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
return await self._api.get_home_ptz_camera(self.id)
async def set_ptz_home(self) -> PTZPreset:
"""Get PTZ home preset (-1) to current position."""
if not self.feature_flags.is_ptz:
raise BadRequest("Camera does not support PTZ features.")
return await self._api.set_home_ptz_camera(self.id)
# 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:
"""
Sets the liveview current set for the viewer
Args:
----
liveview: The liveview you want to set
"""
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)
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
high_threshold: float | None
class SensorSensitivitySettings(SensorSettingsBase):
sensitivity: PercentInt
class SensorBatteryStatus(ProtectBaseObject):
percentage: PercentInt | None
is_low: bool
class SensorStat(ProtectBaseObject):
value: float | None
status: SensorStatusType
class SensorStats(ProtectBaseObject):
light: SensorStat
humidity: SensorStat
temperature: SensorStat
class Sensor(ProtectAdoptableDeviceModel):
alarm_settings: SensorSettingsBase
alarm_triggered_at: datetime | None
battery_status: SensorBatteryStatus
camera_id: str | None
humidity_settings: SensorThresholdSettings
is_motion_detected: bool
is_opened: bool
leak_detected_at: datetime | None
led_settings: SensorSettingsBase
light_settings: SensorThresholdSettings
motion_detected_at: datetime | None
motion_settings: SensorSensitivitySettings
open_status_changed_at: datetime | None
stats: SensorStats
tampering_detected_at: datetime | 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_motion_sensitivity(self, sensitivity: int) -> None:
"""Sets the motion sensitivity for the sensor"""
def callback() -> None:
self.motion_settings.sensitivity = PercentInt(sensitivity)
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)
class Doorlock(ProtectAdoptableDeviceModel):
credentials: str | None
lock_status: LockStatusType
enable_homekit: bool
auto_close_time: timedelta
led_settings: SensorSettingsBase
battery_status: SensorBatteryStatus
camera_id: str | None
has_homekit: bool
private_token: str
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"camera": "cameraId",
"autoCloseTimeMs": "autoCloseTime",
}
@classmethod
@cache
def _get_read_only_fields(cls) -> set[str]:
return super()._get_read_only_fields() | {
"credentials",
"lockStatus",
"batteryStatus",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
"autoCloseTimeMs": lambda x: timedelta(milliseconds=x)
} | super().unifi_dict_conversions()
@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 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 set_status_light(self, enabled: bool) -> None:
"""Sets the status indicator light for the doorlock"""
def callback() -> None:
self.led_settings.is_enabled = enabled
await self.queue_update(callback)
async def set_auto_close_time(self, duration: timedelta) -> None:
"""Sets the auto-close time for doorlock. 0 seconds = disabled."""
if duration > timedelta(hours=1):
raise BadRequest("Max duration is 1 hour")
def callback() -> None:
self.auto_close_time = duration
await self.queue_update(callback)
async def close_lock(self) -> None:
"""Close doorlock (lock)"""
if self.lock_status != LockStatusType.OPEN:
raise BadRequest("Lock is not open")
await self._api.close_lock(self.id)
async def open_lock(self) -> None:
"""Open doorlock (unlock)"""
if self.lock_status != LockStatusType.CLOSED:
raise BadRequest("Lock is not closed")
await self._api.open_lock(self.id)
async def calibrate(self) -> None:
"""
Calibrate the doorlock.
Door must be open and lock unlocked.
"""
await self._api.calibrate_lock(self.id)
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):
camera_id: str
repeat_times: RepeatTimes
track_no: int
volume: int
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {**super()._get_unifi_remaps(), "camera": "cameraId"}
@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
is_wireless_uplink_enabled: bool
camera_ids: list[str]
# requires 2.6.17+
ap_mgmt_ip: IPv4Address | 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
track_no: int | None = None
ring_settings: list[RingSetting] = []
speaker_track_list: list[ChimeTrack] = []
# TODO: used for adoption
# apMac read only
# apRssi read only
# elementInfo read only
@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 volume on chime."""
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 set_volume_for_camera(self, camera: Camera, level: int) -> None:
"""Set the volume on chime for specific camera."""
def callback() -> None:
handled = False
for setting in self.ring_settings:
if setting.camera_id == camera.id:
setting.volume = cast(PercentInt, level)
handled = True
break
if not handled:
raise BadRequest("Camera %s is not paired with chime", camera.id)
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,
) -> None:
"""Plays chime tone"""
await self._api.play_speaker(self.id, volume=volume, repeat_times=repeat_times)
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."""
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_repeat_times_for_camera(
self,
camera: Camera,
value: int,
) -> None:
"""Set repeat times on chime for specific camera."""
def callback() -> None:
handled = False
for setting in self.ring_settings:
if setting.camera_id == camera.id:
setting.repeat_times = cast(RepeatTimes, value)
handled = True
break
if not handled:
raise BadRequest("Camera %s is not paired with chime", camera.id)
await self.queue_update(callback)
uiprotect-6.1.0/src/uiprotect/data/nvr.py 0000664 0000000 0000000 00000133006 14673102202 0020431 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 TYPE_CHECKING, Any, ClassVar, Literal
from uuid import UUID
import aiofiles
import orjson
from aiofiles import os as aos
from convertertools import pop_dict_set_if_none, pop_dict_tuple
from pydantic.v1.fields import PrivateAttr
from ..exceptions import BadRequest, NotAuthorized
from ..utils import RELEASE_CACHE, 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,
PercentInt,
PermissionNode,
ProgressCallback,
RecordingMode,
RecordingType,
ResolutionStorageType,
SensorStatusType,
SensorType,
SmartDetectObjectType,
StorageType,
Version,
)
from .user import User, UserLocation
if TYPE_CHECKING:
from pydantic.v1.typing import SetStr
_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"}
class NVRLocation(UserLocation):
is_geofencing_enabled: bool
radius: int
model: ModelType | None = None
class SmartDetectItem(ProtectBaseObject):
id: str
timestamp: datetime
level: PercentInt
coord: tuple[int, int, int, int]
object_type: SmartDetectObjectType
zone_ids: list[int]
duration: timedelta
@classmethod
@cache
def _get_unifi_remaps(cls) -> dict[str, str]:
return {
**super()._get_unifi_remaps(),
"zones": "zoneIds",
}
@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 LicensePlateMetadata(ProtectBaseObject):
name: str
confidence_level: int
class EventThumbnailAttribute(ProtectBaseObject):
confidence: int
val: str
class EventThumbnailAttributes(ProtectBaseObject):
color: EventThumbnailAttribute | None = None
vehicle_type: EventThumbnailAttribute | 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, DELETE_KEYS_THUMB)
return data
class EventDetectedThumbnail(ProtectBaseObject):
clock_best_wall: datetime | None = None
type: str
cropped_id: str
attributes: EventThumbnailAttributes | None = None
name: str | 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"})
return data
class EventMetadata(ProtectBaseObject):
client_platform: str | None
reason: str | None
app_update: str | None
light_id: str | None
light_name: str | None
type: str | None
sensor_id: str | None
sensor_name: str | None
sensor_type: SensorType | None
doorlock_id: str | None
doorlock_name: str | None
from_value: str | None
to_value: str | None
mount_type: MountType | None
status: SensorStatusType | None
alarm_type: str | None
device_id: str | None
mac: str | None
# require 2.7.5+
license_plate: LicensePlateMetadata | None = None
# requires 2.11.13+
detected_thumbnails: list[EventDetectedThumbnail] | None = None
_collapse_keys: ClassVar[SetStr] = {
"lightId",
"lightName",
"type",
"sensorId",
"sensorName",
"sensorType",
"doorlockId",
"doorlockName",
"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):
data[key] = data[key]["text"]
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
score: int
heatmap_id: str | None
camera_id: str | None
smart_detect_types: list[SmartDetectObjectType]
smart_detect_event_ids: list[str]
thumbnail_id: str | None
user_id: str | None
timestamp: datetime | None
metadata: EventMetadata | 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
# TODO:
# partition
# description
_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",
}
@classmethod
@cache
def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
return {
key: convert_to_datetime
for key in ("start", "end", "timestamp", "deletedAt")
} | 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
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
free: int | None
total: int | None
class StorageDevice(ProtectBaseObject):
model: str
size: int
healthy: bool | str
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
# TODO:
# reasons
@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]
# TODO:
# sdcards
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
remaining_capacity: timedelta | 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
# TODO:
# recordingSchedulesV2
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
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
time_format: Literal["12h", "24h"]
temperature_unit: Literal["C", "F"]
recording_retention_duration: timedelta | None
enable_crash_reporting: bool
disable_audio: bool
analytics_data: AnalyticsOption
anonymous_device_id: UUID | 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 | 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
# TODO:
# errorCode read only
# wifiSettings
# smartDetectAgreement
# dbRecoveryOptions
# portStatus
# cameraCapacity
# deviceFirmwareSettings
@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 analystics 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:
"""Get if current version of Protect is a prerelease version."""
# only EA versions have `-beta` in versions
if self.version.is_prerelease:
return True
# 2.6.14 is an EA version that looks like a release version
cache_file_path = self._api.cache_dir / "release_cache.json"
versions = await self._read_cache_file(
cache_file_path,
) or await self._read_cache_file(RELEASE_CACHE)
if versions is None or self.version not in versions:
versions = await self._api.get_release_versions()
try:
_LOGGER.debug("Fetching releases from APT repos...")
tmp = self._api.cache_dir / "release_cache.tmp.json"
await aos.makedirs(self._api.cache_dir, exist_ok=True)
async with aiofiles.open(tmp, "wb") as cache_file:
await cache_file.write(orjson.dumps([str(v) for v in versions]))
await aos.rename(tmp, cache_file_path)
except Exception:
_LOGGER.warning("Failed write cache file.")
return self.version not in versions
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_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}"
uiprotect-6.1.0/src/uiprotect/data/types.py 0000664 0000000 0000000 00000043605 14673102202 0020775 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import enum
from collections.abc import Callable, Coroutine
from functools import cache, cached_property
from typing import Any, Literal, Optional, TypeVar, Union
from packaging.version import Version as BaseVersion
from pydantic.v1 import BaseModel, ConstrainedInt
from pydantic.v1.color import Color as BaseColor
from pydantic.v1.types import ConstrainedFloat, ConstrainedStr
KT = TypeVar("KT")
VT = TypeVar("VT")
DEFAULT = "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, Optional[bytes]], 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:
# value always set in superclass _missing
return super()._missing_(value) or cls._values_normalized.get("unknown") # type: ignore[union-attr]
@enum.unique
class ModelType(str, UnknownValuesEnumMixin, enum.Enum):
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"
DOORLOCK = "doorlock"
SCHEDULE = "schedule"
CHIME = "chime"
DEVICE_GROUP = "deviceGroup"
RECORDING_SCHEDULE = "recordingSchedule"
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."""
# TODO:
# legacyUFV
# display
return (
ModelType.CAMERA,
ModelType.USER,
ModelType.GROUP,
ModelType.LIVEVIEW,
ModelType.VIEWPORT,
ModelType.LIGHT,
ModelType.BRIDGE,
ModelType.SENSOR,
ModelType.DOORLOCK,
ModelType.CHIME,
)
@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(str, ValuesEnumMixin, enum.Enum):
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"
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_OPENED = "sensorOpened"
SENSOR_CLOSED = "sensorClosed"
SENSOR_ALARM = "sensorAlarm"
SENSOR_EXTREME_VALUE = "sensorExtremeValues"
SENSOR_WATER_LEAK = "sensorWaterLeak"
SENSOR_BATTERY_LOW = "sensorBatteryLow"
# ---
MOTION_LIGHT = "lightMotion"
# ---
DOORLOCK_OPEN = "doorlockOpened"
DOORLOCK_CLOSE = "doorlockClosed"
DOORLOCK_BATTERY_LOW = "doorlockBatteryLow"
# ---
DISRUPTED_CONDITIONS = "ringDisruptedConditions"
# ---
RECORDING_OFF = "recordingOff"
@staticmethod
@cache
def device_events() -> list[str]:
return [
EventType.MOTION.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(str, ValuesEnumMixin, enum.Enum):
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(str, ValuesEnumMixin, enum.Enum):
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(str, ValuesEnumMixin, enum.Enum):
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(str, ValuesEnumMixin, enum.Enum):
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(str, ValuesEnumMixin, enum.Enum):
LEAVE_PACKAGE_AT_DOOR = "LEAVE_PACKAGE_AT_DOOR"
DO_NOT_DISTURB = "DO_NOT_DISTURB"
CUSTOM_MESSAGE = "CUSTOM_MESSAGE"
IMAGE = "IMAGE"
@enum.unique
class LightModeEnableType(str, ValuesEnumMixin, enum.Enum):
DARK = "dark"
ALWAYS = "fulltime"
NIGHT = "night"
@enum.unique
class LightModeType(str, ValuesEnumMixin, enum.Enum):
MOTION = "motion"
WHEN_DARK = "always"
MANUAL = "off"
SCHEDULE = "schedule"
@enum.unique
class VideoMode(str, ValuesEnumMixin, enum.Enum):
DEFAULT = "default"
HIGH_FPS = "highFps"
HOMEKIT = "homekit"
SPORT = "sport"
SLOW_SHUTTER = "slowShutter"
# should only be for unadopted devices
UNKNOWN = "unknown"
@enum.unique
class AudioStyle(str, UnknownValuesEnumMixin, enum.Enum):
NATURE = "nature"
NOISE_REDUCED = "noiseReduced"
@enum.unique
class RecordingMode(str, ValuesEnumMixin, enum.Enum):
ALWAYS = "always"
NEVER = "never"
SCHEDULE = "schedule"
DETECTIONS = "detections"
@enum.unique
class AnalyticsOption(str, ValuesEnumMixin, enum.Enum):
NONE = "none"
ANONYMOUS = "anonymous"
FULL = "full"
@enum.unique
class RecordingType(str, ValuesEnumMixin, enum.Enum):
TIMELAPSE = "timelapse"
CONTINUOUS = "rotating"
DETECTIONS = "detections"
@enum.unique
class ResolutionStorageType(str, ValuesEnumMixin, enum.Enum):
UHD = "4K"
HD = "HD"
FREE = "free"
@enum.unique
class IRLEDMode(str, UnknownValuesEnumMixin, enum.Enum):
AUTO = "auto"
ON = "on"
AUTO_NO_LED = "autoFilterOnly"
OFF = "off"
MANUAL = "manual"
CUSTOM = "custom"
UNKNOWN = "unknown"
@enum.unique
class MountType(str, ValuesEnumMixin, enum.Enum):
NONE = "none"
LEAK = "leak"
DOOR = "door"
WINDOW = "window"
GARAGE = "garage"
@enum.unique
class SensorType(str, ValuesEnumMixin, enum.Enum):
TEMPERATURE = "temperature"
LIGHT = "light"
HUMIDITY = "humidity"
@enum.unique
class SensorStatusType(str, UnknownValuesEnumMixin, enum.Enum):
OFFLINE = "offline"
UNKNOWN = "unknown"
SAFE = "safe"
NEUTRAL = "neutral"
LOW = "low"
HIGH = "high"
@enum.unique
class SleepStateType(str, ValuesEnumMixin, enum.Enum):
DISCONNECTED = "disconnected"
AWAKE = "awake"
START_SLEEP = "goingToSleep"
ASLEEP = "asleep"
WAKING = "waking"
@enum.unique
class AutoExposureMode(str, ValuesEnumMixin, enum.Enum):
MANUAL = "manual"
AUTO = "auto"
SHUTTER = "shutter"
FLICK50 = "flick50"
FLICK60 = "flick60"
@enum.unique
class FocusMode(str, ValuesEnumMixin, enum.Enum):
MANUAL = "manual"
AUTO = "auto"
ZTRIG = "ztrig"
TOUCH = "touch"
@enum.unique
class MountPosition(str, UnknownValuesEnumMixin, enum.Enum):
CEILING = "ceiling"
WALL = "wall"
DESK = "desk"
NONE = "none"
UNKNOWN = "unknown"
@enum.unique
class GeofencingSetting(str, ValuesEnumMixin, enum.Enum):
OFF = "off"
ALL_AWAY = "allAway"
@enum.unique
class MotionAlgorithm(str, ValuesEnumMixin, enum.Enum):
STABLE = "stable"
ENHANCED = "enhanced"
@enum.unique
class AudioCodecs(str, ValuesEnumMixin, enum.Enum):
AAC = "aac"
VORBIS = "vorbis"
OPUS = "opus"
@enum.unique
class LowMedHigh(str, ValuesEnumMixin, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
@enum.unique
class StorageType(str, UnknownValuesEnumMixin, enum.Enum):
DISK = "hdd"
RAID = "raid"
SD_CARD = "sdcard"
INTERNAL_SSD = "internalSSD"
UNKNOWN = "UNKNOWN"
@enum.unique
class FirmwareReleaseChannel(str, ValuesEnumMixin, enum.Enum):
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 LockStatusType(str, ValuesEnumMixin, enum.Enum):
OPEN = "OPEN"
OPENING = "OPENING"
CLOSED = "CLOSED"
CLOSING = "CLOSING"
JAMMED_WHILE_CLOSING = "JAMMED_WHILE_CLOSING"
JAMMED_WHILE_OPENING = "JAMMED_WHILE_OPENING"
FAILED_WHILE_CLOSING = "FAILED_WHILE_CLOSING"
FAILED_WHILE_OPENING = "FAILED_WHILE_OPENING"
NOT_CALIBRATED = "NOT_CALIBRATED"
AUTO_CALIBRATION_IN_PROGRESS = "AUTO_CALIBRATION_IN_PROGRESS"
CALIBRATION_WAITING_OPEN = "CALIBRATION_WAITING_OPEN"
CALIBRATION_WAITING_CLOSE = "CALIBRATION_WAITING_CLOSE"
@enum.unique
class PermissionNode(str, UnknownValuesEnumMixin, enum.Enum):
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(str, UnknownValuesEnumMixin, enum.Enum):
NORMAL = "normal"
ALWAYS_ON = "superHdr"
@enum.unique
class LensType(str, enum.Enum):
NONE = "none"
FULL_360 = "360"
WIDE = "wide"
TELESCOPIC = "tele"
DLSR_17 = "m43"
class DoorbellText(ConstrainedStr):
max_length = 30
class ICRCustomValue(ConstrainedInt):
ge = 0
le = 10
class ICRLuxValue(ConstrainedInt):
ge = 1
le = 30
class LEDLevel(ConstrainedInt):
ge = 0
le = 6
class PercentInt(ConstrainedInt):
ge = 0
le = 100
class TwoByteInt(ConstrainedInt):
ge = 1
le = 255
class PercentFloat(ConstrainedFloat):
ge = 0
le = 100
class WDRLevel(ConstrainedInt):
ge = 0
le = 3
class ICRSensitivity(ConstrainedInt):
ge = 0
le = 3
class Percent(ConstrainedFloat):
ge = 0
le = 1
class RepeatTimes(ConstrainedInt):
ge = 1
le = 6
class PTZPositionDegree(BaseModel):
pan: float
tilt: float
zoom: int
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
class PTZPreset(BaseModel):
id: str
name: str
slot: int
ptz: PTZPresetPosition
CoordType = Union[Percent, int, float]
# TODO: fix when upgrading to pydantic v2
class Color(BaseColor):
def __eq__(self, o: object) -> bool:
if isinstance(o, Color):
return self.as_hex() == o.as_hex()
return super().__eq__(o)
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
uiprotect-6.1.0/src/uiprotect/data/user.py 0000664 0000000 0000000 00000015532 14673102202 0020605 0 ustar 00root root 0000000 0000000 """UniFi Protect User models."""
from __future__ import annotations
from datetime import datetime
from functools import cache
from typing import Any
from pydantic.v1.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
@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
longitude: float | None
class CloudAccount(ProtectModelWithId):
first_name: str
last_name: str
email: str
user_id: str
name: str
location: UserLocation | 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
last_login_time: datetime | None
is_owner: bool
enable_notifications: bool
has_accepted_invite: bool
all_permissions: list[Permission]
scopes: list[str] | None = None
location: UserLocation | None
name: str
first_name: str
last_name: str
email: str | None
local_username: str
group_ids: list[str]
cloud_account: CloudAccount | None
feature_flags: UserFeatureFlags
# TODO:
# settings
# alertRules
# notificationsV2
# notifications
# cloudProviders
_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
uiprotect-6.1.0/src/uiprotect/data/websocket.py 0000664 0000000 0000000 00000015162 14673102202 0021614 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, cached_property
from typing import TYPE_CHECKING, Any
import orjson
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(str, enum.Enum):
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
uiprotect-6.1.0/src/uiprotect/exceptions.py 0000664 0000000 0000000 00000001705 14673102202 0021074 0 ustar 00root root 0000000 0000000 from __future__ import annotations
class UnifiProtectError(Exception):
"""Base class for all other UniFi Protect errors"""
class StreamError(UnifiProtectError):
"""Expcetion 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 Invalid(ClientError):
"""Invalid return from Authorization Request."""
class NotAuthorized(PermissionError, BadRequest):
"""Wrong username, password or permission error."""
class NvrError(ClientError):
"""Other error."""
uiprotect-6.1.0/src/uiprotect/py.typed 0000664 0000000 0000000 00000000000 14673102202 0020023 0 ustar 00root root 0000000 0000000 uiprotect-6.1.0/src/uiprotect/release_cache.json 0000664 0000000 0000000 00000000602 14673102202 0021772 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"]
uiprotect-6.1.0/src/uiprotect/stream.py 0000664 0000000 0000000 00000011474 14673102202 0020212 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import logging
from asyncio.streams import StreamReader
from asyncio.subprocess import PIPE, Process, create_subprocess_exec
from pathlib import Path
from shlex import split
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from aioshutil import which
from .exceptions import BadRequest, StreamError
if TYPE_CHECKING:
from .data import Camera
_LOGGER = logging.getLogger(__name__)
class FfmpegCommand:
ffmpeg_path: Path | None
args: list[str]
process: Process | None = None
stdout: list[str] = []
stderr: list[str] = []
def __init__(self, cmd: str, ffmpeg_path: Path | None = None) -> None:
self.args = split(cmd)
if "ffmpeg" in self.args[0] and ffmpeg_path is None:
self.ffmpeg_path = Path(self.args.pop(0))
else:
self.ffmpeg_path = ffmpeg_path
@property
def is_started(self) -> bool:
return self.process is not None
@property
def is_running(self) -> bool:
if self.process is None:
return False
return self.process.returncode is None
@property
def is_error(self) -> bool:
if self.process is None:
raise StreamError("ffmpeg has not started")
if self.is_running:
return False
return self.process.returncode != 0
async def start(self) -> None:
if self.is_started:
raise StreamError("ffmpeg command already started")
if self.ffmpeg_path is None:
system_ffmpeg = await which("ffmpeg")
if system_ffmpeg is None:
raise StreamError("Could not find ffmpeg")
self.ffmpeg_path = Path(system_ffmpeg)
if not self.ffmpeg_path.exists():
raise StreamError("Could not find ffmpeg")
_LOGGER.debug("ffmpeg: %s %s", self.ffmpeg_path, " ".join(self.args))
self.process = await create_subprocess_exec(
self.ffmpeg_path,
*self.args,
stdout=PIPE,
stderr=PIPE,
)
async def stop(self) -> None:
if self.process is None:
raise StreamError("ffmpeg has not started")
self.process.kill()
await self.process.wait()
async def _read_stream(self, stream: StreamReader | None, attr: str) -> None:
if stream is None:
return
while True:
line = await stream.readline()
if line:
getattr(self, attr).append(line.decode("utf8").rstrip())
else:
break
async def run_until_complete(self) -> None:
if not self.is_started:
await self.start()
if self.process is None:
raise StreamError("Could not start stream")
await asyncio.wait(
[
asyncio.create_task(self._read_stream(self.process.stdout, "stdout")),
asyncio.create_task(self._read_stream(self.process.stderr, "stderr")),
],
)
await self.process.wait()
class TalkbackStream(FfmpegCommand):
camera: Camera
content_url: str
def __init__(
self,
camera: Camera,
content_url: str,
ffmpeg_path: Path | None = None,
):
if not camera.feature_flags.has_speaker:
raise BadRequest("Camera does not have a speaker for talkback")
content_url = self.clean_url(content_url)
input_args = self.get_args_from_url(content_url)
if len(input_args) > 0:
input_args += " "
bitrate = camera.talkback_settings.bits_per_sample * 1000
# 8000 seems to result in best quality without overloading the camera
udp_bitrate = bitrate + 8000
# vn = no video
# acodec = audio codec to encode output in (aac)
# ac = number of output channels (1)
# ar = output sampling rate (22050)
# b:a = set bit rate of output audio
cmd = (
"-loglevel info -hide_banner "
f'{input_args}-i "{content_url}" -vn '
f"-acodec {camera.talkback_settings.type_fmt.value} -ac {camera.talkback_settings.channels} "
f"-ar {camera.talkback_settings.sampling_rate} -b:a {bitrate} -map 0:a "
f'-f adts "udp://{camera.host}:{camera.talkback_settings.bind_port}?bitrate={udp_bitrate}"'
)
super().__init__(cmd, ffmpeg_path)
@classmethod
def clean_url(cls, content_url: str) -> str:
parsed = urlparse(content_url)
if parsed.scheme in {"file", ""}:
path = Path(parsed.netloc + parsed.path)
if not path.exists():
raise BadRequest(f"File {path} does not exist")
content_url = str(path.absolute())
return content_url
@classmethod
def get_args_from_url(cls, content_url: str) -> str:
# TODO:
return ""
uiprotect-6.1.0/src/uiprotect/test_util/ 0000775 0000000 0000000 00000000000 14673102202 0020352 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/src/uiprotect/test_util/__init__.py 0000664 0000000 0000000 00000044420 14673102202 0022467 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 datetime, timezone
from pathlib import Path
from shlex import split
from subprocess import run
from typing import Any, overload
import aiohttp
from PIL import Image
from ..api import ProtectApiClient
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
BLANK_VIDEO_CMD = "ffmpeg -y -hide_banner -loglevel error -f lavfi -i color=size=1280x720:rate=25:color=black -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -t {length} {filename}"
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]] = {}
_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["lights"]),
"bridge": len(bootstrap["bridges"]),
"sensor": len(bootstrap["sensors"]),
"doorlock": len(bootstrap["doorlocks"]),
"chime": len(bootstrap["chimes"]),
}
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.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.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()
await self.write_json_file(
"sample_ws_messages",
self._record_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=timezone.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_lock_data(),
self.generate_chime_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
if not camera_is_online:
self.log(
"Camera is not online, skipping snapshot, thumbnail and heatmap generation",
)
# 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)
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:
run(
split(
BLANK_VIDEO_CMD.format(
length=length,
filename=self.output_folder / f"{filename}.mp4",
),
),
check=True,
)
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_lock_data(self) -> None:
objs = await self.client.api_request_list("doorlocks")
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 doorlock found. Skipping doorlock endpoints...")
return
obj = await self.client.api_request_obj(f"doorlocks/{device_id}")
await self.write_json_file("sample_doorlock", 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_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}")
uiprotect-6.1.0/src/uiprotect/test_util/anonymize.py 0000664 0000000 0000000 00000020436 14673102202 0022742 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import secrets
import string
import uuid
from typing import Any
from urllib.parse import urlparse
import typer
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:
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]:
obj_type = None
if "modelKey" in obj:
if obj["modelKey"] in [m.value for m in ModelType]:
obj_type = ModelType(obj["modelKey"])
else:
typer.secho(f"Unknown modelKey: {obj['modelKey']}", fg="yellow")
if obj_type == ModelType.USER:
return anonymize_user(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",
}:
handled = True
if name == "hosts":
items[index] = anonymize_ip(items[index])
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())
uiprotect-6.1.0/src/uiprotect/utils.py 0000664 0000000 0000000 00000047566 14673102202 0020072 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 sys
import time
import zoneinfo
from collections import Counter
from collections.abc import Callable, Coroutine, Iterable
from copy import deepcopy
from datetime import datetime, timedelta, timezone, tzinfo
from decimal import Decimal
from enum import Enum
from functools import cache, lru_cache, partial
from hashlib import sha224
from http.cookies import Morsel
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, overload
from uuid import UUID
import jwt
from aiohttp import ClientResponse
from pydantic.v1.fields import SHAPE_DICT, SHAPE_LIST, SHAPE_SET, ModelField
from pydantic.v1.utils import to_camel
from .data.types import (
Color,
SmartDetectAudioType,
SmartDetectObjectType,
Version,
VideoMode,
)
from .exceptions import NvrError
if TYPE_CHECKING:
from uiprotect.api import ProtectApiClient
from uiprotect.data import CoordType, Event
from uiprotect.data.bootstrap import WSStat
if sys.version_info[:2] < (3, 11):
from async_timeout import timeout as asyncio_timeout
else:
from asyncio import timeout as asyncio_timeout # noqa: F401
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__)
RELEASE_CACHE = Path(__file__).parent / "release_cache.json"
_CREATE_TYPES = {IPv6Address, IPv4Address, UUID, Color, Decimal, Path, Version}
_BAD_UUID = "00000000-0000-00 0- 000-000000000000"
IP_TYPES = {
Union[IPv4Address, str, None],
Union[IPv4Address, str],
Union[IPv6Address, str, None],
Union[IPv6Address, str],
Union[IPv6Address, IPv4Address, str, None],
Union[IPv6Address, IPv4Address, str],
Union[IPv6Address, IPv4Address],
Union[IPv6Address, IPv4Address, None],
}
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(timezone.utc).timestamp() * 1000)
def to_ms(duration: timedelta | None) -> int | None:
"""Converts python timedelta to Milliseconds"""
if duration is None:
return None
return int(round(duration.total_seconds() * 1000))
def utc_now() -> datetime:
return datetime.now(tz=timezone.utc)
def from_js_time(num: float | str | datetime) -> datetime:
"""Converts Javascript timestamp to Python datetime"""
if isinstance(num, datetime):
return num
return datetime.fromtimestamp(int(num) / 1000, tz=timezone.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 consisent 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)
_SHAPE_TYPES = {SHAPE_DICT, SHAPE_LIST, SHAPE_SET}
def convert_unifi_data(value: Any, field: ModelField) -> Any:
"""Converts value from UFP data into pydantic field class"""
type_ = field.type_
if type_ is Any:
return value
if (shape := field.shape) in _SHAPE_TYPES:
if shape == SHAPE_LIST and isinstance(value, list):
return [convert_unifi_data(v, field) for v in value]
if shape == SHAPE_SET and isinstance(value, list):
return {convert_unifi_data(v, field) for v in value}
if shape == SHAPE_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:
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
# handle edge case for improperly formatted UUIDs
# 00000000-0000-00 0- 000-000000000000
if type_ is UUID and 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:
"""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"""
from uiprotect.data import Percent
if not isinstance(coord, Percent):
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:
try:
types.append(VideoMode(video_mode))
except ValueError:
_LOGGER.warning("Unknown video mode: %s", video_mode)
return types
def ip_from_host(host: str) -> IPv4Address | IPv6Address:
try:
return ip_address(host)
except ValueError:
pass
return ip_address(socket.gethostbyname(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 open(output_path, "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:
# typer<0.4.1 is incompatible with click>=8.1.0
# allows only the CLI interface to break if both are installed
import typer
if output is None:
output = typer.echo if typer is not None else 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
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 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()
for root, _, filenames in os.walk(Path("/usr/share/zoneinfo/")):
for filename in filenames:
fullname = os.path.join(root, filename)
digest = sha224(Path(fullname).read_bytes()).hexdigest()
if digest == tzfile_digest:
timezone_name = "/".join((fullname.split("/"))[-2:])
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=timezone.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
_LOGGER.debug("event WS msg: %s", event.dict())
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."""
if sys.version_info >= (3, 11):
return asyncio.run(callback)
loop = asyncio.get_event_loop() # type: ignore[unreachable]
return loop.run_until_complete(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()
uiprotect-6.1.0/src/uiprotect/websocket.py 0000664 0000000 0000000 00000017635 14673102202 0020712 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 Any, Optional
from aiohttp import (
ClientError,
ClientSession,
ClientWebSocketResponse,
WSMessage,
WSMsgType,
WSServerHandshakeError,
)
from yarl import URL
from .exceptions import NotAuthorized, NvrError
_LOGGER = logging.getLogger(__name__)
AuthCallbackType = Callable[..., Coroutine[Any, Any, Optional[dict[str, str]]]]
GetSessionCallbackType = Callable[[], Awaitable[ClientSession]]
UpdateBootstrapCallbackType = Callable[[], None]
_CLOSE_MESSAGE_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED}
class WebsocketState(Enum):
CONNECTED = True
DISCONNECTED = False
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,
verify: bool = True,
receive_timeout: float | None = None,
) -> None:
"""Init Websocket."""
self.get_url = get_url
self.timeout = timeout
self.receive_timeout = receive_timeout
self.backoff = 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
@property
def is_connected(self) -> bool:
"""Return if the websocket is connected and has received a valid message."""
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()
backoff = self.backoff
while True:
url = self.get_url()
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)
else:
_LOGGER.log(level, "Websocket handshake error: %s: %s", url, ex)
else:
_LOGGER.log(level, "Websocket disconnect error: %s: %s", url, ex)
except asyncio.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._state_changed(WebsocketState.DISCONNECTED)
if self._running is False:
break
_LOGGER.debug("Reconnecting websocket in %s seconds", backoff)
await asyncio.sleep(self.backoff)
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=self.timeout
)
while True:
msg = await self._ws_connection.receive(self.receive_timeout)
msg_type = msg.type
if msg_type is WSMsgType.ERROR:
_LOGGER.exception("Error from Websocket: %s", msg.data)
break
elif msg_type in _CLOSE_MESSAGE_TYPES:
_LOGGER.debug("Websocket closed: %s", msg)
break
if not self._seen_non_close_message:
self._seen_non_close_message = True
self._state_changed(WebsocketState.CONNECTED)
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)
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
uiprotect-6.1.0/templates/ 0000775 0000000 0000000 00000000000 14673102202 0015527 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/templates/CHANGELOG.md.j2 0000664 0000000 0000000 00000001235 14673102202 0017653 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 #}
uiprotect-6.1.0/tests/ 0000775 0000000 0000000 00000000000 14673102202 0014673 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/tests/__init__.py 0000664 0000000 0000000 00000000000 14673102202 0016772 0 ustar 00root root 0000000 0000000 uiprotect-6.1.0/tests/conftest.py 0000664 0000000 0000000 00000074036 14673102202 0017104 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import base64
import json
import math
import os
from contextlib import suppress
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from shlex import split
from subprocess import run
from tempfile import NamedTemporaryFile
from typing import Any
from unittest.mock import AsyncMock, Mock
import aiohttp
import pytest
import pytest_asyncio
from tests.sample_data.constants import CONSTANTS
from uiprotect import ProtectApiClient
from uiprotect.data import 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
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"
CHECK_CMD = "ffprobe -v error -select_streams v:0 -show_entries stream=codec_type -of csv=p=0 {filename}"
LENGTH_CMD = "ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {filename}"
TEST_CAMERA_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera.json").exists()
TEST_SNAPSHOT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_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_DOORLOCK_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_doorlock.json").exists()
TEST_CHIME_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_chime.json").exists()
ANY_NONE = [[None], None, []]
def read_binary_file(name: str, ext: str = "png"):
with (SAMPLE_DATA_DIRECTORY / f"{name}.{ext}").open("rb") as f:
return f.read()
def read_json_file(name: str):
with (SAMPLE_DATA_DIRECTORY / f"{name}.json").open(encoding="utf8") as f:
return json.load(f)
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 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):
output = run(
split(CHECK_CMD.format(filename=filepath)),
check=True,
capture_output=True,
)
assert output.stdout.decode("utf8").strip() == "video"
output = run(
split(LENGTH_CMD.format(filename=filepath)),
check=True,
capture_output=True,
)
# it looks like UFP does not always generate a video of exact length
assert length - 10 < int(float(output.stdout.decode("utf8").strip())) < 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("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 == "doorlocks":
return [read_json_file("sample_doorlock")]
if url == "chimes":
return [read_json_file("sample_chime")]
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("doorlocks"):
return read_json_file("sample_doorlock")
if url.startswith("chimes"):
return read_json_file("sample_chime")
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()
MockDatetime.utcnow.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()
@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="doorlock_obj")
async def doorlock_obj_fixture(protect_client: ProtectApiClient):
if not TEST_DOORLOCK_EXISTS:
return None
return next(iter(protect_client.bootstrap.doorlocks.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
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 sensor():
if not TEST_SENSOR_EXISTS:
return None
return read_json_file("sample_sensor")
@pytest.fixture()
def doorlock():
if not TEST_DOORLOCK_EXISTS:
return None
return read_json_file("sample_doorlock")
@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 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 doorlocks():
if not TEST_DOORLOCK_EXISTS:
return []
return [read_json_file("sample_doorlock")]
@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=timezone.utc)
@pytest.fixture()
def tmp_binary_file():
tmp_file = NamedTemporaryFile(mode="wb", delete=False)
yield tmp_file
with suppress(Exception):
tmp_file.close()
os.remove(tmp_file.name)
# 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",
"trackNo",
"hasHttpsClientOTA",
"isUCoreStacked",
}
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",
}
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",
}
def compare_objs(obj_type, expected, actual):
expected = deepcopy(expected)
actual = deepcopy(actual)
# TODO: fields not supported yet
if obj_type == ModelType.CAMERA.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)
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"]
for index, channel in enumerate(expected["channels"]):
if "bitrate" not in channel:
actual["channels"][index].pop("bitrate", 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 in {ModelType.SENSOR.value, ModelType.DOORLOCK.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:
# TODO: fields that still need implemented
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",
)
# TODO:
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:
if 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:
if 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)
# TODO field
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]
assert expected == actual
@pytest.fixture()
def _disable_camera_validation():
Camera.__config__.validate_assignment = False
yield
Camera.__config__.validate_assignment = True
class MockTalkback:
is_error: bool = False
stdout: list[str] = []
stderr: list[str] = []
def __init__(self) -> None:
self.start = AsyncMock()
self.stop = AsyncMock()
self.run_until_complete = AsyncMock()
uiprotect-6.1.0/tests/data/ 0000775 0000000 0000000 00000000000 14673102202 0015604 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/tests/data/__init__.py 0000664 0000000 0000000 00000000000 14673102202 0017703 0 ustar 00root root 0000000 0000000 uiprotect-6.1.0/tests/data/test_camera.py 0000664 0000000 0000000 00000115430 14673102202 0020451 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 datetime, timedelta, timezone
from unittest.mock import Mock, patch
import pytest
from pydantic.v1 import ValidationError
from tests.conftest import TEST_CAMERA_EXISTS
from uiprotect.data import (
Camera,
ChimeType,
DoorbellMessageType,
HDRMode,
IRLEDMode,
LCDMessage,
PTZPreset,
RecordingMode,
SmartDetectAudioType,
VideoMode,
)
from uiprotect.data.devices import CameraZone, Hotplug, HotplugExtender
from uiprotect.data.types import DEFAULT, SmartDetectObjectType
from uiprotect.data.websocket import WSAction, WSSubscriptionMessage
from uiprotect.exceptions import BadRequest
from uiprotect.utils import to_js_time
@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.asyncio()
async def test_camera_set_hdr_no_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 = False
with pytest.raises(BadRequest):
await camera_obj.set_hdr_mode("off")
assert not camera_obj.api.api_request.called
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("status", "state"),
[
("auto", (True, HDRMode.NORMAL)),
("off", (False, HDRMode.NORMAL)),
("always", (True, HDRMode.ALWAYS_ON)),
],
)
@pytest.mark.asyncio()
async def test_camera_set_hdr(
camera_obj: Camera | None,
status: str,
state: tuple[bool, HDRMode],
):
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
camera_obj.hdr_mode = not state[0]
camera_obj.isp_settings.hdr_mode = (
HDRMode.NORMAL if state[1] == HDRMode.ALWAYS_ON else HDRMode.ALWAYS_ON
)
await camera_obj.set_hdr_mode(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"hdrMode": state[0], "ispSettings": {"hdrMode": state[1]}},
)
assert camera_obj.hdr_mode_display == 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_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_video_mode_no_highfps(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.video_modes = [VideoMode.DEFAULT]
camera_obj.video_mode = VideoMode.DEFAULT
with pytest.raises(BadRequest):
await camera_obj.set_video_mode(VideoMode.HIGH_FPS)
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_video_mode(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.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS]
camera_obj.video_mode = VideoMode.DEFAULT
await camera_obj.set_video_mode(VideoMode.HIGH_FPS)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"videoMode": VideoMode.HIGH_FPS.value},
)
@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, 4])
@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, 4}:
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_mic_volume_no_mic(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_mic = False
with pytest.raises(BadRequest):
await camera_obj.set_mic_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_mic_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_mic = True
camera_obj.mic_volume = 10
if level in {-1, 200}:
with pytest.raises(ValidationError):
await camera_obj.set_mic_volume(level)
assert not camera_obj.api.api_request.called
else:
await camera_obj.set_mic_volume(level)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"micVolume": level},
)
@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.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": {"volume": 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_name(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_name_enabled = not status
await camera_obj.set_osd_name(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"osdSettings": {"isNameEnabled": 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_date(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_date_enabled = not status
await camera_obj.set_osd_date(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"osdSettings": {"isDateEnabled": 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_logo(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_logo_enabled = not status
await camera_obj.set_osd_logo(status)
camera_obj.api.api_request.assert_called_with(
f"cameras/{camera_obj.id}",
method="patch",
json={"osdSettings": {"isLogoEnabled": 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
@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=timezone.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=timezone.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"),
[
(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
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.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("pan", "tilt", "pan_native", "tilt_native"),
[
(0, 0, 0, 0),
(1, 1, 97, 88),
(5, 5, 488, 444),
(20, 20, 1955, 1777),
(40, 40, 3911, 3554),
(-1, -1, -97, -88),
(-5, -5, -488, -444),
(-20, -20, -1955, -1777),
(-40, -40, -3911, -3554),
],
)
@pytest.mark.asyncio()
async def test_camera_ptz_relative_move(
ptz_camera: Camera | None,
pan: float,
tilt: float,
pan_native: float,
tilt_native: float,
):
if ptz_camera is None:
pytest.skip("No camera_obj obj found")
ptz_camera.api.api_request.reset_mock()
await ptz_camera.ptz_relative_move(pan=pan, tilt=tilt)
ptz_camera.api.api_request.assert_called_with(
f"cameras/{ptz_camera.id}/move",
method="post",
json=(
{
"type": "relative",
"payload": {
"panPos": pan_native,
"tiltPos": tilt_native,
"panSpeed": 10,
"tiltSpeed": 10,
"scale": 0,
},
}
),
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_ptz_center(ptz_camera: Camera | None):
if ptz_camera is None:
pytest.skip("No camera_obj obj found")
ptz_camera.api.api_request.reset_mock()
await ptz_camera.ptz_center(x=500, y=500, z=0)
ptz_camera.api.api_request.assert_called_with(
f"cameras/{ptz_camera.id}/move",
method="post",
json=(
{
"type": "center",
"payload": {
"x": 500,
"y": 500,
"z": 0,
},
}
),
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
("zoom", "zoom_native"),
[
(1, 0),
(5, 382),
(20, 1818),
(22, 2009),
],
)
@pytest.mark.asyncio()
async def test_camera_ptz_zoom(
ptz_camera: Camera | None,
zoom: float,
zoom_native: float,
):
if ptz_camera is None:
pytest.skip("No camera_obj obj found")
ptz_camera.api.api_request.reset_mock()
await ptz_camera.ptz_zoom(zoom=zoom)
ptz_camera.api.api_request.assert_called_with(
f"cameras/{ptz_camera.id}/move",
method="post",
json=(
{
"type": "zoom",
"payload": {
"zoomPos": zoom_native,
"zoomSpeed": 100,
},
}
),
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_goto_ptz_slot(ptz_camera: Camera | None):
if ptz_camera is None:
pytest.skip("No camera_obj obj found")
ptz_camera.api.api_request.reset_mock()
await ptz_camera.goto_ptz_slot(slot=-1)
ptz_camera.api.api_request.assert_called_with(
f"cameras/{ptz_camera.id}/ptz/goto/-1",
method="post",
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_create_ptz_preset(ptz_camera: Camera | None):
if ptz_camera is None:
pytest.skip("No camera_obj obj found")
ptz_camera.api.api_request.reset_mock()
preset = await ptz_camera.create_ptz_preset(name="Test")
assert preset == PTZPreset(
id="test-id",
name="Test",
slot=0,
ptz={
"pan": 100,
"tilt": 100,
"zoom": 0,
},
)
ptz_camera.api.api_request.assert_called_with(
url=f"cameras/{ptz_camera.id}/ptz/preset",
method="post",
require_auth=True,
raise_exception=True,
json={"name": "Test"},
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_delete_ptz_preset(ptz_camera: Camera | None):
if ptz_camera is None:
pytest.skip("No camera_obj obj found")
ptz_camera.api.api_request.reset_mock()
await ptz_camera.delete_ptz_preset(slot=0)
ptz_camera.api.api_request.assert_called_with(
f"cameras/{ptz_camera.id}/ptz/preset/0",
method="delete",
)
@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_camera_set_ptz_home(ptz_camera: Camera | None):
if ptz_camera is None:
pytest.skip("No camera_obj obj found")
ptz_camera.api.api_request.reset_mock()
preset = await ptz_camera.set_ptz_home()
assert preset == PTZPreset(
id="test-id",
name="Home",
slot=-1,
ptz={
"pan": 100,
"tilt": 100,
"zoom": 0,
},
)
ptz_camera.api.api_request.assert_called_with(
url=f"cameras/{ptz_camera.id}/ptz/home",
method="post",
require_auth=True,
raise_exception=True,
)
uiprotect-6.1.0/tests/data/test_chime.py 0000664 0000000 0000000 00000032671 14673102202 0020313 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.v1 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]
track_no=1,
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,
"trackNo": 1,
"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]
track_no=1,
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 or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_set_volume_for_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.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]
track_no=1,
volume=100,
),
]
camera_obj.api.api_request.reset_mock()
await chime_obj.set_volume_for_camera(camera_obj, 50)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={
"ringSettings": [
{
"camera": camera_obj.id,
"repeatTimes": 1,
"trackNo": 1,
"volume": 50,
},
],
},
)
@pytest.mark.skipif(
not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_chime_set_volume_for_camera_not_exist(
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="other-id",
repeat_times=1, # type: ignore[arg-type]
track_no=1,
volume=100,
),
]
camera_obj.api.api_request.reset_mock()
with pytest.raises(BadRequest):
await chime_obj.set_volume_for_camera(camera_obj, 2)
assert not chime_obj.api.api_request.called
@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.track_no = 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,
"trackNo": 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]
track_no=1,
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,
"trackNo": 1,
"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]
track_no=1,
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},
)
@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_for_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.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]
track_no=1,
volume=100,
),
]
camera_obj.api.api_request.reset_mock()
await chime_obj.set_repeat_times_for_camera(camera_obj, 2)
chime_obj.api.api_request.assert_called_with(
f"chimes/{chime_obj.id}",
method="patch",
json={
"ringSettings": [
{
"camera": camera_obj.id,
"repeatTimes": 2,
"trackNo": 1,
"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_for_camera_not_exist(
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="other-id",
repeat_times=1, # type: ignore[arg-type]
track_no=1,
volume=100,
),
]
camera_obj.api.api_request.reset_mock()
with pytest.raises(BadRequest):
await chime_obj.set_repeat_times_for_camera(camera_obj, 2)
assert not chime_obj.api.api_request.called
uiprotect-6.1.0/tests/data/test_common.py 0000664 0000000 0000000 00000101360 14673102202 0020506 0 ustar 00root root 0000000 0000000 """Tests for uiprotect.data"""
from __future__ import annotations
import asyncio
import base64
from copy import deepcopy
from datetime import timedelta
from ipaddress import IPv4Address
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import Mock, patch
import pytest
from tests.conftest import (
TEST_BRIDGE_EXISTS,
TEST_CAMERA_EXISTS,
TEST_DOORLOCK_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.devices import LCDMessage
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
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 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_DOORLOCK_EXISTS, reason="Missing testdata")
def test_doorlock(doorlock):
compare_devices(doorlock)
@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.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
def test_camera_smart_events(camera_obj: Camera):
now = utc_now()
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 = {}
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.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_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_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_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"
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()
# TODO: fields that still need implemented
if "deviceGroups" in bootstrap: # added in 2.0-beta
del bootstrap["deviceGroups"]
bootstrap.pop("schedules", None)
bootstrap.pop("agreements", None)
if "deviceGroups" in bootstrap:
del bootstrap["deviceGroups"]
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 obj == obj_construct
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 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 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 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
await camera_obj.play_audio("test")
mock_talkback.assert_called_with(camera_obj, "test", None)
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_no_blocking(mock_talkback, camera_obj: Camera):
camera_obj.feature_flags.has_speaker = True
mock_instance = MockTalkback()
mock_talkback.return_value = mock_instance
await camera_obj.play_audio("test", blocking=False)
mock_talkback.assert_called_with(camera_obj, "test", None)
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
await camera_obj.play_audio("test", blocking=False)
mock_talkback.assert_called_with(camera_obj, "test", None)
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.is_error = True
mock_talkback.return_value = mock_instance
with pytest.raises(StreamError):
await camera_obj.play_audio("test")
mock_talkback.assert_called_with(camera_obj, "test", None)
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.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.copy()
user1.id = "test_id_1"
user1.all_permissions = [
Permission.from_unifi_dict(rawPermission=p, api=api) for p in permissions
]
user2 = user_obj.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.dict()
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.dict()
@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.smart_detect_settings.object_types = []
api.bootstrap.cameras[camera_obj.id] = camera_obj
await asyncio.gather(
camera_obj.set_motion_detection(True),
camera_obj.set_person_detection(True),
camera_obj.set_vehicle_detection(True),
)
camera_obj.api.api_request.assert_called_with( # type: ignore[attr-defined]
f"cameras/{camera_obj.id}",
method="patch",
json={
"recordingSettings": {"enableMotionDetection": True},
"smartDetectSettings": {"objectTypes": ["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()
uiprotect-6.1.0/tests/data/test_doorlock.py 0000664 0000000 0000000 00000011050 14673102202 0021026 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
import pytest
from tests.conftest import TEST_CAMERA_EXISTS, TEST_DOORLOCK_EXISTS
from uiprotect.data.types import LockStatusType
from uiprotect.exceptions import BadRequest
from uiprotect.utils import to_ms
if TYPE_CHECKING:
from uiprotect.data import Camera, Doorlock, Light
@pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_doorlock_set_paired_camera_none(doorlock_obj: Doorlock):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.camera_id = "bad_id"
await doorlock_obj.set_paired_camera(None)
doorlock_obj.api.api_request.assert_called_with(
f"doorlocks/{doorlock_obj.id}",
method="patch",
json={"camera": None},
)
@pytest.mark.skipif(
not TEST_DOORLOCK_EXISTS or not TEST_CAMERA_EXISTS,
reason="Missing testdata",
)
@pytest.mark.asyncio()
async def test_doorlock_set_paired_camera(doorlock_obj: Light, camera_obj: Camera):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.camera_id = None
await doorlock_obj.set_paired_camera(camera_obj)
doorlock_obj.api.api_request.assert_called_with(
f"doorlocks/{doorlock_obj.id}",
method="patch",
json={"camera": camera_obj.id},
)
@pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.asyncio()
async def test_doorlock_set_status_light(doorlock_obj: Doorlock, status: bool):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.led_settings.is_enabled = not status
await doorlock_obj.set_status_light(status)
doorlock_obj.api.api_request.assert_called_with(
f"doorlocks/{doorlock_obj.id}",
method="patch",
json={"ledSettings": {"isEnabled": status}},
)
@pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize(
"duration",
[
timedelta(seconds=0),
timedelta(seconds=15),
timedelta(seconds=3600),
timedelta(seconds=3601),
],
)
@pytest.mark.asyncio()
async def test_doorlock_set_auto_close_time(
doorlock_obj: Doorlock,
duration: timedelta,
):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.auto_close_time = timedelta(seconds=30)
duration_invalid = duration is not None and int(duration.total_seconds()) == 3601
if duration_invalid:
with pytest.raises(BadRequest):
await doorlock_obj.set_auto_close_time(duration)
assert not doorlock_obj.api.api_request.called
else:
await doorlock_obj.set_auto_close_time(duration)
expected = {"autoCloseTimeMs": to_ms(duration)}
doorlock_obj.api.api_request.assert_called_with(
f"doorlocks/{doorlock_obj.id}",
method="patch",
json=expected,
)
@pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_doorlock_close(doorlock_obj: Doorlock):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.lock_status = LockStatusType.OPEN
await doorlock_obj.close_lock()
doorlock_obj.api.api_request.assert_called_with(
f"doorlocks/{doorlock_obj.id}/close",
method="post",
)
@pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_doorlock_close_invalid(doorlock_obj: Doorlock):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.lock_status = LockStatusType.CLOSED
with pytest.raises(BadRequest):
await doorlock_obj.close_lock()
assert not doorlock_obj.api.api_request.called
@pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_doorlock_open(doorlock_obj: Doorlock):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.lock_status = LockStatusType.CLOSED
await doorlock_obj.open_lock()
doorlock_obj.api.api_request.assert_called_with(
f"doorlocks/{doorlock_obj.id}/open",
method="post",
)
@pytest.mark.skipif(not TEST_DOORLOCK_EXISTS, reason="Missing testdata")
@pytest.mark.asyncio()
async def test_doorlock_open_invalid(doorlock_obj: Doorlock):
doorlock_obj.api.api_request.reset_mock()
doorlock_obj.lock_status = LockStatusType.OPEN
with pytest.raises(BadRequest):
await doorlock_obj.open_lock()
assert not doorlock_obj.api.api_request.called
uiprotect-6.1.0/tests/data/test_light.py 0000664 0000000 0000000 00000020342 14673102202 0020325 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
import pytest
from pydantic.v1 import ValidationError
from tests.conftest import TEST_CAMERA_EXISTS, TEST_LIGHT_EXISTS
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
@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):
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.parametrize("level", [-1, 1, 3, 6, 7])
@pytest.mark.asyncio()
async def test_light_set_led_level(light_obj: Light, level: int):
light_obj.api.api_request.reset_mock()
light_obj.light_device_settings.led_level = 2
if level in {-1, 7}:
with pytest.raises(ValidationError):
await light_obj.set_led_level(level)
assert not light_obj.api.api_request.called
else:
await light_obj.set_led_level(level)
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json={"lightDeviceSettings": {"ledLevel": level}},
)
@pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata")
@pytest.mark.parametrize("status", [True, False])
@pytest.mark.parametrize("level", [None, -1, 1, 3, 6, 7])
@pytest.mark.asyncio()
async def test_light_set_light(light_obj: Light, status: bool, level: int | None):
light_obj.api.api_request.reset_mock()
light_obj.light_on_settings.is_led_force_on = not status
if level is not None:
light_obj.light_device_settings.led_level = 2
if level in {-1, 7}:
with pytest.raises(ValidationError):
await light_obj.set_light(status, level)
assert not light_obj.api.api_request.called
else:
await light_obj.set_light(status, level)
if level is None:
expected = {"lightOnSettings": {"isLedForceOn": status}}
else:
expected = {
"lightOnSettings": {"isLedForceOn": status},
"lightDeviceSettings": {"ledLevel": level},
}
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json=expected,
)
@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(
light_obj: Light,
sensitivity: int,
):
light_obj.api.api_request.reset_mock()
light_obj.light_device_settings.pir_sensitivity = 50
if sensitivity == -10:
with pytest.raises(ValidationError):
await light_obj.set_sensitivity(sensitivity)
assert not light_obj.api.api_request.called
else:
await light_obj.set_sensitivity(sensitivity)
expected = {"lightDeviceSettings": {"pirSensitivity": sensitivity}}
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json=expected,
)
@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(
light_obj: Light,
duration: timedelta,
):
light_obj.api.api_request.reset_mock()
light_obj.light_device_settings.pir_duration = timedelta(seconds=30)
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_duration(duration)
assert not light_obj.api.api_request.called
else:
await light_obj.set_duration(duration)
expected = {"lightDeviceSettings": {"pirDuration": to_ms(duration)}}
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json=expected,
)
@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.parametrize(
"duration",
[
None,
timedelta(seconds=1),
timedelta(seconds=15),
timedelta(seconds=900),
timedelta(seconds=1000),
],
)
@pytest.mark.parametrize("sensitivity", [None, 1, 100, -10])
@pytest.mark.asyncio()
async def test_light_set_light_settings(
light_obj: Light,
mode: LightModeType,
enable_at: LightModeEnableType | None,
duration: timedelta | None,
sensitivity: int | None,
):
light_obj.api.api_request.reset_mock()
light_obj.light_mode_settings.mode = LightModeType.MOTION
light_obj.light_mode_settings.enable_at = LightModeEnableType.DARK
light_obj.light_device_settings.pir_duration = timedelta(seconds=30)
light_obj.light_device_settings.pir_sensitivity = 50
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(
mode,
enable_at=enable_at,
duration=duration,
sensitivity=sensitivity,
)
assert not light_obj.api.api_request.called
elif sensitivity == -10:
with pytest.raises(ValidationError):
await light_obj.set_light_settings(
mode,
enable_at=enable_at,
duration=duration,
sensitivity=sensitivity,
)
assert not light_obj.api.api_request.called
else:
await light_obj.set_light_settings(
mode,
enable_at=enable_at,
duration=duration,
sensitivity=sensitivity,
)
expected = {"lightModeSettings": {"mode": mode.value}}
if enable_at is not None:
expected["lightModeSettings"].update({"enableAt": enable_at.value})
if duration is not None:
expected["lightDeviceSettings"] = expected.get("lightDeviceSettings", {})
expected["lightDeviceSettings"].update({"pirDuration": to_ms(duration)})
if sensitivity is not None:
expected["lightDeviceSettings"] = expected.get("lightDeviceSettings", {})
expected["lightDeviceSettings"].update({"pirSensitivity": sensitivity})
light_obj.api.api_request.assert_called_with(
f"lights/{light_obj.id}",
method="patch",
json=expected,
)
uiprotect-6.1.0/tests/data/test_nvr.py 0000664 0000000 0000000 00000017014 14673102202 0020025 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
import pytest
from pydantic.v1 import ValidationError
from uiprotect.data import (
NVR,
AnalyticsOption,
DoorbellMessage,
DoorbellMessageType,
)
from uiprotect.data.nvr import NVRSmartDetection
from uiprotect.exceptions import BadRequest
from uiprotect.utils import to_ms
@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
uiprotect-6.1.0/tests/data/test_sensor.py 0000664 0000000 0000000 00000024477 14673102202 0020544 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.v1 import ValidationError
from tests.conftest import TEST_CAMERA_EXISTS, TEST_SENSOR_EXISTS
from uiprotect.data.types import MountType
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("sensitivity", [1, 100, -10])
@pytest.mark.asyncio()
async def test_sensor_set_motion_sensitivity(
sensor_obj: Sensor,
sensitivity: int,
):
sensor_obj.api.api_request.reset_mock()
sensor_obj.motion_settings.sensitivity = 50
if sensitivity == -10:
with pytest.raises(ValidationError):
await sensor_obj.set_motion_sensitivity(sensitivity)
assert not sensor_obj.api.api_request.called
else:
await sensor_obj.set_motion_sensitivity(sensitivity)
sensor_obj.api.api_request.assert_called_with(
f"sensors/{sensor_obj.id}",
method="patch",
json={"motionSettings": {"sensitivity": sensitivity}},
)
@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},
)
uiprotect-6.1.0/tests/data/test_types.py 0000664 0000000 0000000 00000000440 14673102202 0020357 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import pytest
from uiprotect.data.types import ModelType
@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
uiprotect-6.1.0/tests/data/test_viewer.py 0000664 0000000 0000000 00000003421 14673102202 0020516 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 Mock
import pytest
from tests.conftest import TEST_VIEWPORT_EXISTS
from uiprotect.data.websocket import WSAction, WSSubscriptionMessage
from uiprotect.exceptions import BadRequest
if TYPE_CHECKING:
from uiprotect.data import Liveview, Viewer
@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,
),
)
uiprotect-6.1.0/tests/sample_data/ 0000775 0000000 0000000 00000000000 14673102202 0017145 5 ustar 00root root 0000000 0000000 uiprotect-6.1.0/tests/sample_data/__init__.py 0000664 0000000 0000000 00000000000 14673102202 0021244 0 ustar 00root root 0000000 0000000 uiprotect-6.1.0/tests/sample_data/constants.py 0000664 0000000 0000000 00000001460 14673102202 0021534 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()
uiprotect-6.1.0/tests/sample_data/sample_bootstrap.json 0000664 0000000 0000000 00000724773 14673102202 0023442 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": {
"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",
"modelKey": "camera"
},
{
"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": {
"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",
"modelKey": "camera"
},
{
"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": {
"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",
"modelKey": "camera"
},
{
"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": {
"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,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"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",
"modelKey": "camera"
},
{
"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": {
"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",
"modelKey": "camera"
},
{
"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": {
"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",
"modelKey": "camera"
},
{
"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": "7b4e5226-215f-44d1-bbf4-cdfe64599cdb",
"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": {
"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",
"modelKey": "camera"
},
{
"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": {
"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",
"modelKey": "camera"
},
{
"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": "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.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": {
"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",
"modelKey": "camera"
},
{
"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": {
"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",
"modelKey": "camera"
},
{
"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": {
"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",
"modelKey": "camera"
}
],
"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,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:*",
"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": "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,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"
}
],
"doorlocks": [
{
"mac": "F10599AB6955",
"host": null,
"connectionHost": "192.168.102.63",
"type": "UFP-LOCK-R",
"name": "Itvvaze Panc",
"upSince": 1643050461849,
"uptime": null,
"lastSeen": 1643052750858,
"connectedSince": 1643052765849,
"state": "CONNECTED",
"hardwareRevision": 7,
"firmwareVersion": "1.2.0",
"latestFirmwareVersion": "1.2.0",
"firmwareBuild": null,
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": false,
"canAdopt": false,
"isAttemptingToConnect": false,
"credentials": "0e84b2e9b732478bc2e0ba1d0ac7b88f90fb2ea06f31dcd779ee573487fa888c",
"lockStatus": "CLOSED",
"enableHomekit": false,
"autoCloseTimeMs": 15000,
"wiredConnectionState": {
"phyRate": null
},
"ledSettings": {
"isEnabled": true
},
"bluetoothConnectionState": {
"signalQuality": 72,
"signalStrength": -61
},
"batteryStatus": {
"percentage": 100,
"isLow": false
},
"bridge": "61b3f5c90050a703e700042a",
"camera": "e2ff0ade6be0f2a2beb61869",
"bridgeCandidates": [],
"id": "1c812e80fd693ab51535be38",
"isConnected": true,
"hasHomekit": false,
"marketName": "UP DoorLock",
"modelKey": "doorlock",
"privateToken": "XVZZQh71SN4WSAffLJUQCRJHOflBV0HEYCEOmDakPgNWUUECIRXzMQG6HWLDoGlAMQKQPyvKJ37oJVnKGjhgMPqOf3IQCKSywNTYYLGXPZZT8PqKbyqDoQhRfJsWyUP4XazFXLQQsPHONUeEZP4HBKDKQUHMQTqhS7WJDzbVV1CJMPsXIDPHmJNVRBMQWGVH"
}
],
"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"
}
]
}
uiprotect-6.1.0/tests/sample_data/sample_bridge.json 0000664 0000000 0000000 00000001505 14673102202 0022636 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"
}
uiprotect-6.1.0/tests/sample_data/sample_camera.json 0000664 0000000 0000000 00000034300 14673102202 0022631 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,
"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,
"mountPositions": [],
"smartDetectTypes": [
"person",
"vehicle"
],
"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": 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",
"guid": "00000000-0000-00 0- 000-000000000000"
}
uiprotect-6.1.0/tests/sample_data/sample_camera_heatmap.png 0000664 0000000 0000000 00000002711 14673102202 0024144 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`‚ uiprotect-6.1.0/tests/sample_data/sample_camera_snapshot.png 0000664 0000000 0000000 00000020446 14673102202 0024371 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`‚ uiprotect-6.1.0/tests/sample_data/sample_camera_thumbnail.png 0000664 0000000 0000000 00000002711 14673102202 0024510 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`‚ uiprotect-6.1.0/tests/sample_data/sample_camera_video.mp4 0000664 0000000 0000000 00000143110 14673102202 0023546 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 &