pax_global_header00006660000000000000000000000064152463770570014532gustar00rootroot0000000000000052 comment=280274607a6632ec5823fa412d5fa555777537a8 uilibs-uiprotect-317a52c/000077500000000000000000000000001524637705700153645ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.bin/000077500000000000000000000000001524637705700162125ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.bin/lib/000077500000000000000000000000001524637705700167605ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.bin/lib/common.sh000066400000000000000000000012101524637705700205760ustar00rootroot00000000000000#!/bin/bash function setRoot() { ROOT_PATH=$PWD while [[ $ROOT_PATH != / ]]; do output=$(find "$ROOT_PATH" -maxdepth 1 -mindepth 1 -name "pyproject.toml") if [[ -n $output ]]; then break fi # Note: if you want to ignore symlinks, use "$(realpath -s "$path"/..)" ROOT_PATH="$(readlink -f "$ROOT_PATH"/..)" done if [[ $ROOT_PATH == / ]]; then ROOT_PATH=$( realpath $( dirname "${BASH_SOURCE[0]}" )/../../ ) echo "Could not find \`pyproject.toml\`, following back to $( basename $ROOT_PATH )" else echo "Using project $( basename $ROOT_PATH )" fi } uilibs-uiprotect-317a52c/.bin/run-mypy000077500000000000000000000000511524637705700177340ustar00rootroot00000000000000#!/bin/sh poetry run mypy src/uiprotect uilibs-uiprotect-317a52c/.bin/test-code000077500000000000000000000011771524637705700200350ustar00rootroot00000000000000#!/bin/bash set -o errexit set -o pipefail set -o nounset BASE_DIR=$( realpath $( dirname "${BASH_SOURCE[0]}" )/../ ) source "$BASE_DIR/.bin/lib/common.sh" setRoot WS_TIMEOUT="${WS_TIMEOUT:-40}" PYTEST_EXTRA_ARGS="${PYTEST_EXTRA_ARGS:-}" pushd "$ROOT_PATH" 2>&1 >/dev/null rm -rf .coverage.* .coverage echo -e "\nRunning tests (no benchmarks)..." poetry run pytest --timeout=10 --color=yes --cov-report=xml --benchmark-skip --maxfail=10 $PYTEST_EXTRA_ARGS echo -e "\nRunning benchmark tests..." poetry run pytest --timeout=$WS_TIMEOUT --cov-report=term --color=yes --benchmark-only -n=0 -rP $PYTEST_EXTRA_ARGS popd 2>&1 >/dev/null uilibs-uiprotect-317a52c/.bin/update-release-cache000077500000000000000000000001611524637705700220770ustar00rootroot00000000000000#!/bin/bash set -o errexit set -o pipefail set -o nounset poetry install poetry run uiprotect release-versions uilibs-uiprotect-317a52c/.copier-answers.yml000066400000000000000000000010441524637705700211250ustar00rootroot00000000000000# Changes here will be overwritten by Copier _commit: 368c483 _src_path: gh:browniebroke/pypackage-template add_me_as_contributor: false cli_name: uiprotect copyright_year: '2024' documentation: true email: ui@koston.org full_name: UI Protect Maintainers github_username: uilibs has_cli: true initial_commit: true open_source_license: MIT package_name: uiprotect project_name: uiprotect project_short_description: Python API for Unifi Protect (Unofficial) project_slug: uiprotect run_poetry_install: true setup_github: true setup_pre_commit: true uilibs-uiprotect-317a52c/.coveragerc000066400000000000000000000014171524637705700175100ustar00rootroot00000000000000[run] source = src/uiprotect omit = site/* src/uiprotect/cli/* src/uiprotect/test_util/* [report] omit = site/* src/uiprotect/cli/* src/uiprotect/test_util/* # Regexes for lines to exclude from consideration exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about missing debug-only code: def __repr__ # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError raise exceptions.NotSupportedError # TYPE_CHECKING and @overload blocks are never executed during pytest run # except ImportError: are never executed as well if TYPE_CHECKING: @overload except ImportError: if _LOGGER.isEnabledFor(logging.DEBUG): uilibs-uiprotect-317a52c/.devcontainer/000077500000000000000000000000001524637705700201235ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.devcontainer/devcontainer.json000066400000000000000000000065261524637705700235100ustar00rootroot00000000000000{ "name": "uiprotect-dev", "build": { "dockerfile": "../Dockerfile", "context": "..", "target": "devcontainer" }, "updateRemoteUserUID": true, "features": { "ghcr.io/devcontainers/features/github-cli:1": {} }, "initializeCommand": "touch ~/.gitconfig", "postCreateCommand": "poetry install --no-interaction --no-ansi && poetry run pre-commit install --install-hooks", "mounts": [ "source=${localEnv:HOME}/.claude,target=/root/.claude,type=bind,consistency=cached", "source=${localEnv:HOME}/.gitconfig,target=/root/.gitconfig,type=bind,consistency=cached" ], "remoteUser": "root", "containerEnv": { "GIT_AUTHOR_NAME": "${localEnv:GIT_AUTHOR_NAME:${localEnv:USER:devcontainer}}", "GIT_AUTHOR_EMAIL": "${localEnv:GIT_AUTHOR_EMAIL:${localEnv:USER:devcontainer}@localhost}", "GIT_COMMITTER_NAME": "${localEnv:GIT_COMMITTER_NAME:${localEnv:USER:devcontainer}}", "GIT_COMMITTER_EMAIL": "${localEnv:GIT_COMMITTER_EMAIL:${localEnv:USER:devcontainer}@localhost}" }, "remoteEnv": { "LANG": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8" }, "customizations": { "vscode": { "settings": { "terminal.integrated.defaultProfile.linux": "bash", "python.defaultInterpreterPath": "/usr/local/bin/python3", "python.terminal.activateEnvironment": false, "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, "python.testing.pytestArgs": [ "tests", "-v", "--cov=uiprotect", "--cov-report=term-missing:skip-covered", "-n=auto" ], "python.analysis.typeCheckingMode": "strict", "python.analysis.autoImportCompletions": true, "editor.formatOnSave": true, "editor.rulers": [ 88 ], "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.codeActionsOnSave": { "source.organizeImports.ruff": "explicit", "source.fixAll.ruff": "explicit" } }, "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } }, "extensions": [ "ms-python.python", "ms-python.vscode-pylance", "ms-azuretools.vscode-docker", "charliermarsh.ruff", "esbenp.prettier-vscode", "ms-python.mypy-type-checker", "ms-vscode.test-adapter-converter", "littlefoxteam.vscode-python-test-adapter", "GitHub.vscode-pull-request-github", "usernamehw.errorlens", "anthropic.claude-code" ] } } } uilibs-uiprotect-317a52c/.docker/000077500000000000000000000000001524637705700167115ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.docker/bashrc000066400000000000000000000013301524637705700200730ustar00rootroot00000000000000BOLD="\[$(tput bold)\]" BLACK="\[$(tput setaf 0)\]" RED="\[$(tput setaf 1)\]" GREEN="\[$(tput setaf 2)\]" YELLOW="\[$(tput setaf 3)\]" BLUE="\[$(tput setaf 4)\]" MAGENTA="\[$(tput setaf 5)\]" CYAN="\[$(tput setaf 6)\]" WHITE="\[$(tput setaf 7)\]" RESET="\[$(tput sgr0)\]" function prompt_command { RET=$? if [[ "$(id -u)" -eq 0 ]]; then PS1="$BOLD$RED" else PS1="$GREEN" fi branch="$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/ (\1)/')" PS1+="\u$RESET:$YELLOW\w$RESET$CYAN$branch$RESET " if [[ "$RET" -eq 0 ]]; then PS1+="$BOLD$GREEN" else PS1+="$RET $BOLD$RED" fi PS1+="\\$ $RESET" export PS1 } export PROMPT_COMMAND=prompt_command uilibs-uiprotect-317a52c/.docker/docker-fix.sh000066400000000000000000000014541524637705700213040ustar00rootroot00000000000000#!/bin/bash set -o errexit set -o pipefail set -o nounset DOCKER_SOCK="" if [[ -e /var/run/docker-host.sock ]]; then DOCKER_SOCK="/var/run/docker-host.sock" else if [[ -e /var/run/docker.sock ]]; then DOCKER_SOCK="/var/run/docker.sock" fi fi # fix the group ID of the docker group so it can write to /var/run/docker.sock if [[ -n "$DOCKER_SOCK" ]]; then DOCKER_GID=$(ls -la $DOCKER_SOCK | awk '{print $4}') if [[ $DOCKER_GID != 'docker' ]]; then sudo groupmod -g $DOCKER_GID docker if [[ -f '/.codespaces' ]]; then echo -e '\e[1;31mYou must stop and restart the Codespace to be able to access docker properly' else echo -e '\e[1;31mYou must run the `Reload Window` command for be able to access docker properly' fi fi fi uilibs-uiprotect-317a52c/.docker/entrypoint.sh000066400000000000000000000001321524637705700214540ustar00rootroot00000000000000#!/bin/bash set -o errexit set -o pipefail set -o nounset /usr/local/bin/uiprotect "$@" uilibs-uiprotect-317a52c/.dockerignore000066400000000000000000000001651524637705700200420ustar00rootroot00000000000000.vscode test-data ufp-data *.mp3 *.mp4 .* *.xml Dockerfile LICENSE *.md !README.md tests/** *.egg-info *.js !.docker uilibs-uiprotect-317a52c/.editorconfig000066400000000000000000000004441524637705700200430ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab uilibs-uiprotect-317a52c/.github/000077500000000000000000000000001524637705700167245ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.github/CODE_OF_CONDUCT.md000066400000000000000000000120561524637705700215270ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting @uilibs. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. uilibs-uiprotect-317a52c/.github/FUNDING.yml000066400000000000000000000000231524637705700205340ustar00rootroot00000000000000github: ["uilibs"] uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/000077500000000000000000000000001524637705700211075ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/1-bug_report.yml000066400000000000000000000035311524637705700241420ustar00rootroot00000000000000name: Bug report description: Create a report to help us improve labels: [bug] body: - type: textarea id: description attributes: label: Describe the bug description: A clear and concise description of what the bug is. placeholder: Describe the bug validations: required: true - type: textarea id: reproduce attributes: label: To Reproduce description: Steps to reproduce the behavior. placeholder: To Reproduce validations: required: true - type: textarea id: context attributes: label: Additional context description: Add any other context about the problem here. placeholder: Additional context - type: input id: version attributes: label: Version description: Version of the project. placeholder: Version validations: required: true - type: input id: platform attributes: label: Platform description: Platform where the bug was found. placeholder: "Example: Windows 11 / macOS 12.0.1 / Ubuntu 20.04" validations: required: true - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/uilibs/uiprotect/blob/main/.github/CODE_OF_CONDUCT.md). options: - label: I agree to follow this project's Code of Conduct. required: true - type: checkboxes id: no-duplicate attributes: label: No Duplicate description: Please check [existing issues](https://github.com/uilibs/uiprotect/issues) to avoid duplicates. options: - label: I have checked existing issues to avoid duplicates. required: true - type: markdown attributes: value: 👋 Have a great day and thank you for the bug report! uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/2-feature-request.yml000066400000000000000000000036101524637705700251120ustar00rootroot00000000000000name: Feature request description: Suggest an idea for this project labels: [enhancement] body: - type: textarea id: description attributes: label: Is your feature request related to a problem? Please describe. description: A clear and concise description of what the problem is. value: I'm always frustrated when validations: required: true - type: textarea id: solution attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. placeholder: Describe alternatives you've considered validations: required: true - type: textarea id: context attributes: label: Additional context description: Add any other context or screenshots about the feature request here. placeholder: Additional context - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/uilibs/uiprotect/blob/main/.github/CODE_OF_CONDUCT.md). options: - label: I agree to follow this project's Code of Conduct required: true - type: checkboxes id: willing attributes: label: Are you willing to resolve this issue by submitting a Pull Request? description: Remember that first-time contributors are welcome! 🙌 options: - label: Yes, I have the time, and I know how to start. - label: Yes, I have the time, but I don't know how to start. I would need guidance. - label: No, I don't have the time, although I believe I could do it if I had the time... - label: No, I don't have the time and I wouldn't even know how to start. - type: markdown attributes: value: 👋 Have a great day and thank you for the feature request! uilibs-uiprotect-317a52c/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000002701524637705700230760ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: Questions url: https://github.com/uilibs/uiprotect/discussions/categories/q-a about: Please ask and answer questions here. uilibs-uiprotect-317a52c/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000035431524637705700225320ustar00rootroot00000000000000 ### Description of change ### Pull-Request Checklist - [ ] Code is up-to-date with the `main` branch - [ ] This pull request follows the [contributing guidelines](https://github.com/uilibs/uiprotect/blob/main/CONTRIBUTING.md). - [ ] This pull request links relevant issues as `Fixes #0000` - [ ] There are new or updated unit tests validating the change - [ ] Documentation has been updated to reflect this change - [ ] The pull request title follows the [conventional commit spec](https://www.conventionalcommits.org/en/v1.0.0/), such as "fix(api): prevent racing of requests" (PRs are squash-merged, so the title becomes the commit on `main`). > - If pre-commit.ci is failing, try `pre-commit run -a` for further information. > - If CI / test is failing, try `poetry run pytest` for further information. uilibs-uiprotect-317a52c/.github/dependabot.yml000066400000000000000000000013451524637705700215570ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" commit-message: prefix: "chore(ci): " groups: github-actions: patterns: - "*" - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" uilibs-uiprotect-317a52c/.github/labels.toml000066400000000000000000000035151524637705700210670ustar00rootroot00000000000000[breaking] color = "ffcc00" name = "breaking" description = "Breaking change." [bug] color = "d73a4a" name = "bug" description = "Something isn't working" [dependencies] color = "0366d6" name = "dependencies" description = "Pull requests that update a dependency file" [github_actions] color = "000000" name = "github_actions" description = "Update of github actions" [documentation] color = "1bc4a5" name = "documentation" description = "Improvements or additions to documentation" [duplicate] color = "cfd3d7" name = "duplicate" description = "This issue or pull request already exists" [enhancement] color = "a2eeef" name = "enhancement" description = "New feature or request" ["good first issue"] color = "7057ff" name = "good first issue" description = "Good for newcomers" ["help wanted"] color = "008672" name = "help wanted" description = "Extra attention is needed" [invalid] color = "e4e669" name = "invalid" description = "This doesn't seem right" [nochangelog] color = "555555" name = "nochangelog" description = "Exclude pull requests from changelog" [question] color = "d876e3" name = "question" description = "Further information is requested" [removed] color = "e99695" name = "removed" description = "Removed piece of functionalities." [tests] color = "bfd4f2" name = "tests" description = "CI, CD and testing related changes" [wontfix] color = "ffffff" name = "wontfix" description = "This will not be worked on" [discussion] color = "c2e0c6" name = "discussion" description = "Some discussion around the project" [hacktoberfest] color = "ffa663" name = "hacktoberfest" description = "Good issues for Hacktoberfest" [answered] color = "0ee2b6" name = "answered" description = "Automatically closes as answered after a delay" [waiting] color = "5f7972" name = "waiting" description = "Automatically closes if no answer after a delay" uilibs-uiprotect-317a52c/.github/workflows/000077500000000000000000000000001524637705700207615ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.github/workflows/ci.yml000066400000000000000000000140731524637705700221040ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: types: [opened, edited, synchronize, reopened] concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true env: UV_PYTHON_PREFERENCE: only-managed jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true - name: Install poetry run: uv tool install poetry - uses: actions/setup-python@v7 with: python-version: 3.x cache: "poetry" - name: Install Dependencies run: | poetry install --all-extras - name: Run pre-commit (prek) uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 # Make sure the PR title follows the conventional commits convention: # https://www.conventionalcommits.org # PRs are squash-merged, so the PR title becomes the commit on main and # drives python-semantic-release's version bump. pr-title: name: Lint PR Title if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: pull-requests: read steps: - uses: amannn/action-semantic-pull-request@v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: subjectPattern: ^(?![A-Z]).+$ subjectPatternError: | The subject "{subject}" found in the pull request title "{title}" didn't match the configured pattern. Please ensure that the subject starts with a lowercase character. test: strategy: fail-fast: false matrix: python-version: - "3.11" - "3.12" - "3.13" - "3.14" os: - ubuntu-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true - name: Install poetry run: uv tool install poetry - name: Set up Python uses: actions/setup-python@v7 id: setup-python with: python-version: ${{ matrix.python-version }} cache: "poetry" allow-prereleases: true - run: echo "Cache hit:${{ steps.setup-python.outputs.cache-hit }}" # true if cache-hit occurred on the primary key - name: Install Dependencies run: poetry install --all-extras - name: Test with Pytest run: ./.bin/test-code shell: bash - name: Upload coverage to Codecov uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} benchmarks: name: Run CodSpeed benchmarks runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true - name: Install poetry run: uv tool install poetry - name: Set up Python 3.13 uses: actions/setup-python@v7 with: python-version: "3.13" cache: "poetry" - name: Install Dependencies run: poetry install --only=main,dev - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@v4 with: run: poetry run pytest tests/benchmarks --no-cov -o addopts="" --codspeed mode: simulation # Dry run on PRs and non-main pushes. No environment, no publish # permissions, no OIDC, so PR runs carry no release blast radius. release-dry-run: needs: - test - lint if: github.ref_name != 'main' runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ github.ref }} - name: Create local branch name env: BRANCH: ${{ github.head_ref || github.ref_name }} run: git switch -C "$BRANCH" - name: Test release uses: python-semantic-release/python-semantic-release@v10.6.1 with: no_operation_mode: true # Real release, only on main. The release environment and write/OIDC # permissions are scoped to this job so they never apply to PR runs. release: needs: - test - lint if: github.ref_name == 'main' runs-on: ubuntu-latest environment: release concurrency: group: release-${{ github.ref }} cancel-in-progress: false permissions: id-token: write contents: write steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # Use the merge commit ref so fork-PR branches (which only exist in # the contributor's fork) don't cause a 404. PSR needs a branch # name, so we recreate one locally in the next step. ref: ${{ github.ref }} # Fall back to GITHUB_TOKEN on dependabot PRs, where # secrets.BOT_ACCESS_TOKEN is not exposed and would otherwise # leave actions/checkout's `token` input empty. token: ${{ secrets.BOT_ACCESS_TOKEN || github.token }} - name: Create local branch env: BRANCH: ${{ github.ref_name }} run: git switch -C "$BRANCH" - name: Setup Git run: | git config user.name "uiprotectbot" git config user.email "uiprotect@koston.org" # On main branch: actual PSR + upload to PyPI & GitHub - name: Release uses: python-semantic-release/python-semantic-release@v10.6.1 id: release with: github_token: ${{ secrets.BOT_ACCESS_TOKEN }} - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 if: steps.release.outputs.released == 'true' - name: Publish package distributions to GitHub Releases uses: python-semantic-release/publish-action@v10.6.1 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.BOT_ACCESS_TOKEN }} uilibs-uiprotect-317a52c/.github/workflows/docker.yml000066400000000000000000000052751524637705700227640ustar00rootroot00000000000000name: CD - Build Docker Image on: release: types: [published] workflow_dispatch: inputs: rebuild: description: "Rebuild tag?" required: true default: "no" type: choice options: - "no" - "yes" concurrency: group: docker-${{ github.event.workflow_run.head_branch || github.ref }} cancel-in-progress: true permissions: packages: write env: DEFAULT_PYTHON: "3.12" jobs: docker: name: Build Docker Image runs-on: ubuntu-latest environment: name: release steps: - name: Check repo uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install poetry run: pipx install poetry - name: Set up Python uses: actions/setup-python@v7 with: python-version: "${{ env.DEFAULT_PYTHON }}" cache: "poetry" - name: Install dependencies run: | poetry install --no-root - name: Get current version (rebuild) if: ${{ inputs.rebuild == 'yes' }} run: | UIPROTECT_VERSION=$(git describe --tags --abbrev=0) echo "UIPROTECT_VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:dev,ghcr.io/uilibs/uiprotect:$UIPROTECT_VERSION" >> $GITHUB_ENV - name: Get current version (no rebuild) if: ${{ inputs.rebuild != 'yes' }} run: | UIPROTECT_VERSION=v$(poetry version -s) echo "UIPROTECT_VERSION=$UIPROTECT_VERSION" >> $GITHUB_ENV echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:dev,ghcr.io/uilibs/uiprotect:$(echo $UIPROTECT_VERSION | tr "+" -)" >> $GITHUB_ENV - name: Add Latest Docker Tag run: | if [[ ! "$UIPROTECT_VERSION" == *"dev"* ]]; then echo "DOCKER_TAGS=ghcr.io/uilibs/uiprotect:latest,$DOCKER_TAGS" >> $GITHUB_ENV fi - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and Push uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 target: prod push: true build-args: | UIPROTECT_VERSION=${{ env.UIPROTECT_VERSION }} cache-from: ghcr.io/uilibs/uiprotect:buildcache cache-to: type=registry,ref=ghcr.io/uilibs/uiprotect:buildcache,mode=max tags: ${{ env.DOCKER_TAGS }} uilibs-uiprotect-317a52c/.github/workflows/issue-manager.yml000066400000000000000000000013401524637705700242420ustar00rootroot00000000000000name: Issue Manager on: schedule: - cron: "0 0 * * *" issue_comment: types: - created issues: types: - labeled pull_request_target: types: - labeled workflow_dispatch: jobs: issue-manager: runs-on: ubuntu-latest steps: - uses: tiangolo/issue-manager@0.8.1 with: token: ${{ secrets.GITHUB_TOKEN }} config: > { "answered": { "message": "Assuming the original issue was solved, it will be automatically closed now." }, "waiting": { "message": "Automatically closing. To re-open, please provide the additional information requested." } } uilibs-uiprotect-317a52c/.github/workflows/poetry-upgrade.yml000066400000000000000000000003401524637705700244500ustar00rootroot00000000000000name: Upgrader on: workflow_dispatch: schedule: - cron: "36 13 6 * *" jobs: upgrade: uses: browniebroke/github-actions/.github/workflows/poetry-upgrade.yml@v1 secrets: gh_pat: ${{ secrets.GH_PAT }} uilibs-uiprotect-317a52c/.github/workflows/spec-validation.yml000066400000000000000000000177211524637705700245760ustar00rootroot00000000000000name: Spec conformance # Validate the public-API client against Ubiquiti's Integration OpenAPI spec. # # The expensive validation runs at most ONCE per Protect release: the daily cron # only queries the firmware-latest API (seconds, no download) and short-circuits # while the validated version is current or a drift issue is already open. The # spec (~400 KB, from the developer portal) is fetched ephemerally and never # committed or uploaded. on: schedule: # Daily at 06:17 UTC. Cheap until a release actually drifts. - cron: "17 6 * * *" workflow_dispatch: inputs: version: description: "Protect version to validate (MAJOR.MINOR.PATCH); blank = latest release" required: false type: string dry_run: description: "Run validation but do not open/close issues or PRs" required: false type: boolean default: false permissions: contents: write issues: write pull-requests: write concurrency: group: spec-validation cancel-in-progress: false jobs: validate: runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} DRY_RUN: ${{ inputs.dry_run }} # Passed via env (never interpolated into a run script) to avoid shell # template injection from the workflow_dispatch input. VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@v7 - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true - name: Install poetry run: uv tool install poetry - uses: actions/setup-python@v7 with: python-version: 3.x cache: "poetry" # Step 1: resolve the latest (or requested) release — API call only, no download. - name: Resolve target version id: resolve run: | set -euo pipefail if [ -n "$VERSION" ]; then latest=$(python scripts/fetch_openapi.py --print-version --version "$VERSION") else latest=$(python scripts/fetch_openapi.py --print-version) fi validated=$(cat openapi/.validated-version) echo "latest=$latest" >> "$GITHUB_OUTPUT" echo "validated=$validated" >> "$GITHUB_OUTPUT" echo "Latest release: $latest | validated marker: $validated" # Step 2: steady state — nothing to do. - name: Short-circuit if already validated id: steady run: | set -euo pipefail if [ "${{ steps.resolve.outputs.latest }}" = "${{ steps.resolve.outputs.validated }}" ]; then echo "Marker already at latest release; nothing to validate." echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" fi # Step 3: known drift — an issue for this version already exists. Exit BEFORE # downloading the spec so steady-state and known-drift days never fetch. - name: Short-circuit if drift issue already open id: known if: steps.steady.outputs.skip != 'true' run: | set -euo pipefail title="spec drift: protect ${{ steps.resolve.outputs.latest }}" count=$(gh issue list --state open --limit 200 --search "in:title \"$title\"" --json title \ --jq "[.[] | select(.title == \"$title\")] | length") if [ "$count" -gt 0 ]; then echo "Drift issue already open for ${{ steps.resolve.outputs.latest }}; skipping fetch." echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" fi # Step 4a: fetch the spec (~400 KB) and validate — only when drift is new. - name: Install dependencies if: steps.steady.outputs.skip != 'true' && steps.known.outputs.skip != 'true' run: poetry install --all-extras - name: Fetch spec and validate id: validate if: steps.steady.outputs.skip != 'true' && steps.known.outputs.skip != 'true' run: | set -euo pipefail if [ -n "$VERSION" ]; then poetry run python scripts/fetch_openapi.py --version "$VERSION" else poetry run python scripts/fetch_openapi.py fi set +e poetry run python scripts/validate_spec.py > report.md echo "exit_code=$?" >> "$GITHUB_OUTPUT" set -e cat report.md # Step 4b (green): bump the marker via PR. - name: Open marker-bump PR (green) if: >- steps.steady.outputs.skip != 'true' && steps.known.outputs.skip != 'true' && steps.validate.outputs.exit_code == '0' env: LATEST: ${{ steps.resolve.outputs.latest }} # A PAT, not github.token: the repository's "Allow GitHub Actions to # create and approve pull requests" setting overrides the job's # pull-requests permission, so GITHUB_TOKEN's createPullRequest call # is always rejected. A user token is exempt, and its PR triggers CI # (a GITHUB_TOKEN one does not). Scoped to this step so the issue # steps keep using github.token; the fallback keeps # workflow_dispatch usable where the secret is absent. GH_TOKEN: ${{ secrets.BOT_ACCESS_TOKEN || github.token }} run: | set -euo pipefail branch="spec-validation/bump-$LATEST" if [ "$DRY_RUN" = "true" ]; then echo "[dry-run] would open PR bumping .validated-version to $LATEST" exit 0 fi open=$(gh pr list --state open --limit 200 --head "$branch" --json number --jq "length") if [ "$open" -gt 0 ]; then echo "Marker-bump PR already open for $branch; nothing to do." exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # -B resets the local branch if a prior run left it around; the # force-push overwrites any orphan remote branch (no open PR) so a # stale spec-validation/bump-* left by a failed run can't cause a # non-fast-forward rejection. The content is deterministic — a single # line in openapi/.validated-version — so overwriting is safe. git checkout -B "$branch" printf '%s\n' "$LATEST" > openapi/.validated-version git add openapi/.validated-version git commit -m "chore(spec): bump validated version to $LATEST" git push --force --set-upstream origin "$branch" gh pr create \ --title "chore(spec): bump validated version to $LATEST" \ --body "Automated: public-API client conforms to the Protect $LATEST Integration spec. The spec itself is not committed (Ubiquiti IP); reproduce locally with \`python scripts/fetch_openapi.py --version $LATEST && python scripts/validate_spec.py\`." \ --base main --head "$branch" # Step 4c (red): open one drift issue, close superseded ones. - name: Open drift issue (red) if: >- steps.steady.outputs.skip != 'true' && steps.known.outputs.skip != 'true' && steps.validate.outputs.exit_code != '0' env: LATEST: ${{ steps.resolve.outputs.latest }} run: | set -euo pipefail title="spec drift: protect $LATEST" if [ "$DRY_RUN" = "true" ]; then echo "[dry-run] would open issue: $title" cat report.md exit 0 fi gh issue create --title "$title" --body-file report.md # Close older drift issues as superseded. gh issue list --state open --limit 200 --search 'in:title "spec drift: protect"' \ --json number,title \ --jq ".[] | select(.title != \"$title\") | .number" | while read -r num; do [ -z "$num" ] && continue gh issue close "$num" \ --comment "Superseded by drift detected for Protect $LATEST." done uilibs-uiprotect-317a52c/.gitignore000066400000000000000000000061021524637705700173530ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # settings.json is user-specific overrides for devcontainer.json .vscode/settings.json test-data ufp-data *.mp3 *.mp4 *.js *.json *.csv backup .benchmarks .ruff_cache # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder {{package_name}} settings .spyderproject .spyproject # Rope {{package_name}} settings # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ .DS_Store test.py camerasnapshot.py dumpeventdata.py eventdata.json dumpeventdata.py heatmap_snapshot.py smartdetect.json dumpcameradata.py cameras.json setstatuslight.py # websocket.py test_function.py test_event.py events.json event_obj.json src/uiprotect/unifi_protect_server_ws.py websocket_test.py test_ws.py camera_cloudkey.json camera_cloudkey_privacy.json cameras_upsense.json event.json test_websocket.py test_raw.py rawdata.json # IDE settings .vscode/ .idea/ # Public Integration API spec: fetched on demand, never committed (Ubiquiti IP). openapi/integration.json # …except the validated-version marker — a bare version string carries no IP and # its git history is the audit trail of when spec conformance last moved forward. !openapi/.validated-version uilibs-uiprotect-317a52c/.gitpod.yml000066400000000000000000000003231524637705700174510ustar00rootroot00000000000000tasks: - command: | pip install poetry PIP_USER=false poetry install --all-extras - command: | pip install pre-commit pre-commit install PIP_USER=false pre-commit install-hooks uilibs-uiprotect-317a52c/.pre-commit-config.yaml000066400000000000000000000027061524637705700216520ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks exclude: "CHANGELOG.md|.copier-answers.yml" default_stages: [pre-commit] ci: autofix_commit_msg: "chore(pre-commit.ci): auto fixes" autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" skip: [mypy] repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: debug-statements - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first - id: check-toml - id: check-xml - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/python-poetry/poetry rev: 2.4.1 hooks: - id: poetry-check - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/codespell-project/codespell rev: v2.4.3 hooks: - id: codespell additional_dependencies: [tomli] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: local hooks: - id: mypy name: mypy language: script entry: ./.bin/run-mypy types_or: [python, pyi] require_serial: true files: ^(src/uiprotect)/.+\.(py|pyi)$ uilibs-uiprotect-317a52c/.readthedocs.yml000066400000000000000000000011161524637705700204510ustar00rootroot00000000000000# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-22.04 tools: python: "3.12" jobs: post_create_environment: # Install poetry - python -m pip install poetry post_install: # Install dependencies, reusing RTD virtualenv - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --only main,docs # Build documentation in the docs directory with mkdocs mkdocs: configuration: mkdocs.yml uilibs-uiprotect-317a52c/.vscode/000077500000000000000000000000001524637705700167255ustar00rootroot00000000000000uilibs-uiprotect-317a52c/.vscode/launch.json000066400000000000000000000026771524637705700211060ustar00rootroot00000000000000{ "version": "0.2.0", "configurations": [ { "name": "Run Subcommand: shell", "type": "debugpy", "request": "launch", "module": "uiprotect", "args": [ "-u", "shell", ] }, { "name": "Run Subcommand: generate-sample-data", "type": "debugpy", "request": "launch", "module": "uiprotect", "args": [ "generate-sample-data", "-w", "30", "--actual", ], "env": { "UFP_SAMPLE_DIR": "${workspaceFolder}/test-data" } }, { "name": "Debug Tests (pytest/poetry)", "type": "debugpy", "request": "launch", "module": "pytest", "justMyCode": false, "console": "integratedTerminal", "args": [ "-v" ], "python": "/usr/local/bin/python3", "cwd": "${workspaceFolder}" }, { "name": "Python: Debug Test File", "type": "debugpy", "request": "launch", "module": "pytest", "justMyCode": false, "console": "integratedTerminal", "args": [ "${file}" ], "cwd": "${workspaceFolder}" } ] } uilibs-uiprotect-317a52c/.vscode/tasks.json000066400000000000000000000041071524637705700207470ustar00rootroot00000000000000{ "version": "2.0.0", "tasks": [ { "label": "Test Code", "type": "shell", "command": "${workspaceFolder}/.bin/test-code", "problemMatcher": [] }, { "label": "Update requirements", "type": "shell", "command": "${workspaceFolder}/.bin/update-requirements", "problemMatcher": [] }, { "label": "MkDocs: Serve", "type": "shell", "command": "mkdocs serve", "problemMatcher": [] }, { "label": "MkDocs: Build", "type": "shell", "command": "mkdocs build", "problemMatcher": [] }, { "label": "Generate Sample Data", "type": "shell", "command": "uiprotect generate-sample-data -w ${input:sampleTime} ${input:sampleAnonymize}", "problemMatcher": [], "options": { "env": { "UFP_SAMPLE_DIR": "${workspaceFolder}/${input:sampleLocation}" } } }, { "label": "Regenerate Release Cache", "type": "shell", "command": "uiprotect release-versions", "problemMatcher": [], }, ], "inputs": [ { "id": "sampleLocation", "description": "Location to generate sample data in", "default": "test-data", "type": "pickString", "options": [ "test-data", "tests/sample_data", ], }, { "id": "sampleTime", "description": "Length of time to generate sample data", "default": "30", "type": "promptString", }, { "id": "sampleAnonymize", "description": "Anonymize parameter for generate sample data", "default": "", "type": "pickString", "options": [ "", "--actual", ], }, ] } uilibs-uiprotect-317a52c/AGENTS.md000066400000000000000000000626121524637705700166760ustar00rootroot00000000000000# Notes for LLM contributors A short orientation file for an LLM working in this repo. Skim before making changes; keep edits consistent with what's described here. Read [README.md](README.md) for the user-facing intro and [CONTRIBUTING.md](CONTRIBUTING.md) for the human-facing contributor guide. ## What this project is `uiprotect` is an unofficial async Python client + CLI for the UniFi Protect surveillance NVR. It is the library that the Home Assistant `unifiprotect` integration is built on top of, and it is the de-facto Python binding for talking to a UniFi Protect console — Ubiquiti does not publish an official one. Public API is exported from the top-level `uiprotect` package; the main entry point is `uiprotect.ProtectApiClient` in `src/uiprotect/api.py`. There is no upstream protocol owner. The wire format is whatever the UniFi Protect server in front of you serves: a partially documented REST API plus a binary WebSocket update stream. Both shift across firmware releases, and the test corpus under `tests/sample_data/` is the closest thing to a reference. Behaviour changes that depend on a specific Protect firmware version should say so in the commit message. The project was forked from `pyunifiprotect` after that project relicensed away from MIT; staying MIT-licensed is a hard requirement so the Home Assistant core integration can keep depending on it. ## Code style - **Docstrings: terse, default to single-line.** A docstring is the function's _contract_, not its narrative. Almost every docstring should be one line — `"""Summary."""` — describing what the function does and what the caller can pass. Multi-line is the exception, only justified when there is non-obvious caller-visible behaviour the type signature and parameter names don't already convey. Ruff is configured with most of the `D` family disabled (`D100`–`D107`, `D2xx`, `D4xx`) precisely so that missing docstrings on internal helpers don't flag — only add one when it earns its keep. **What does NOT belong in docstrings or comments:** - Rationale / motivation / "why we used to do X" — that's the PR description and the commit message. Git already remembers. - Cross-references to issue numbers ("closes #N", "follow-up to #M") — the PR body carries those. - Restatement of the function body in prose. If the next line of the docstring is just describing what the next line of code does, delete the docstring line. - Test docstrings retelling the production-side story. A test docstring should name what the test pins, in one sentence — not re-explain the bug, the fix, or the surrounding flow. - **Comments**: same bar. Default to writing no comments. Add one only when the _why_ is non-obvious: a hidden constraint, a subtle invariant, a workaround for a specific Protect firmware quirk, behaviour that would surprise a reader. Firmware-version citations are useful when the reason for a branch is "this server returns the field, that one doesn't" — leave those in. If removing the comment wouldn't confuse a future reader, don't write it. Three anti-patterns that keep showing up in generated code — the downstream Home Assistant integration enforces the same bar, so match it: - **No section / divider banners.** Comments like `# ---- Convenience setters ----` or `# === websocket ===`, inside or outside a function, carry no information the code doesn't, drift out of sync, and go stale. Group related members with a blank line and let the names carry the structure — don't draw a banner. - **Never restate the next line(s).** A comment that paraphrases what the following statement plainly does (`# increment the counter` above `count += 1`) is noise. Comment the non-obvious _why_, or write nothing. - **Keep `try` clauses minimal.** Wrap only the statement(s) that can raise the caught exception, not a large block, and don't catch from calls that aren't expected to raise. Not a comment rule, but the same "say only what matters" hygiene and enforced downstream. **Don't remove existing comments** unless the code they describe is gone — the original author left them for a reason, often a specific Protect firmware regression. The one exception is the two anti-patterns above (divider banners and lines that merely restate the following code): those carry no author intent worth preserving and should be deleted on sight. - **Don't pad commits, docstrings, or comments with cross- references** to old codepaths or issue numbers unless there's a clear reason a future reader needs that link. - **Method order**: public API at the top, private helpers (`_underscore_prefixed`) at the bottom. The supported surface is what `src/uiprotect/__init__.py` re-exports plus the public attributes on `ProtectApiClient`, `Bootstrap`, and the device / NVR model classes. Anything else is internal and can change between releases. - **Line length**: 88 (ruff `line-length = 88`, not the more common 100/110). `requires-python = ">=3.11"`, `target-version = "py311"` for ruff; pyupgrade runs `--py311-plus`. - **Imports**: ruff/isort sorted, `known_first_party = ["uiprotect", "tests"]`. Prefer `from __future__ import annotations` in new modules so modern type syntax works without runtime forward-reference juggling, especially given the pydantic model graph. - **Pydantic v2 only.** Models live under `src/uiprotect/data/` and are built on pydantic v2 (`pydantic >= 2.13.4`). Do not reintroduce v1 idioms (`.dict()`, `Config` inner class, `validator` decorator) when touching this code — match the v2 style already present (`model_dump()`, `model_config`, `field_validator`). The pinned-floor on pydantic is deliberate; do not loosen it without checking what changed across versions. - **`orjson` for serialisation.** The codebase uses `orjson` everywhere it serialises or parses Protect payloads. Don't reach for stdlib `json` in hot paths; match the existing `orjson.loads` / `orjson.dumps` usage. - **Async-only public surface.** UniFi Protect itself is async, and so is this library. New public functions on `ProtectApiClient` and friends should be `async def`. Don't introduce sync wrappers around async I/O; callers that need sync should drive the event loop themselves. - **Typed strictly.** mypy runs with `disallow_untyped_defs`, `disallow_incomplete_defs`, `check_untyped_defs`, `no_implicit_optional`, `warn_unreachable`, and `warn_unused_ignores`, and is wired into pre-commit through `.bin/run-mypy` against `src/uiprotect/`. New code under `src/uiprotect/` must type-check cleanly; tests are exempted via the `tests.*` override. Don't paper over a real type error with `# type: ignore` — `warn_unused_ignores` will eventually catch you, and the right answer is almost always a tighter annotation upstream. ## Commit / PR conventions - **Conventional Commits PR title, lowercase subject.** PRs are squash-merged, so the **PR title** becomes the commit on `main` and is the only string that needs to parse as a Conventional Commit. The repo enforces this via the `pr-title` CI job in `ci.yml` using `amannn/action-semantic-pull-request`. The _type_ prefix is required: `feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`, etc., and the subject (text after `type(scope):`) must start lowercase (enforced by `subjectPattern: ^(?![A-Z]).+$`). Per-commit messages on the PR branch are **not** linted — they get collapsed at squash-merge. `python-semantic-release` excludes `chore*` and `ci*` from the changelog (see `[tool.semantic_release.changelog]` in `pyproject.toml`), so use those prefixes for housekeeping and reserve `feat`/`fix`/`perf` for user-visible changes that should land in `CHANGELOG.md`. - **No `Co-Authored-By` trailers from automated agents.** Project preference; releases are cut by `python-semantic-release` from the commit log, and a trailer from an LLM ends up in the generated changelog. - Imperative-mood subject after the type prefix ("fix(api): handle empty bootstrap", not "fix(api): handled empty bootstrap"). Scopes in parentheses are encouraged where useful (`api`, `cli`, `data`, `websocket`, `stream`). - The repo **does** ship a PR template at [.github/PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md). Fill out the "Description of change" section in prose and tick the checklist items that apply (write `N/A` rather than silently leaving rows blank). Reference closing issues with `Fixes #NNNN` so the issue auto-closes on merge. - Pre-commit runs ruff (lint + format), mypy, prettier, poetry-check, and the standard hygiene hooks (trailing whitespace, end-of-file fixer, debug-statements, detect-private-key, check-toml/xml). Run pre-commit locally before pushing; the CI lint job is just `pre-commit run -a`, so a green local run = a green CI lint job. The `pre-commit.ci` bot fixes formatting drift automatically with a `chore(pre-commit.ci): auto fixes` commit, so don't bother hand-fixing whitespace-only failures. - **Releases are automated.** `python-semantic-release` cuts releases from `main` based on commit type prefixes — `feat` bumps minor, `fix`/`perf` bump patch, a `BREAKING CHANGE:` footer (or `!` after the type) bumps major. Mis-typed commits ship the wrong version. If a change is user-visible, type it honestly. ## Running tests Requires Python >=3.11. **Install all extras to run the full suite** — the `cli` extra provides Pillow, typer and rich. Without it the CLI/Pillow test modules (`tests/test_api.py`, `tests/test_cli.py`, `tests/test_dunder_main.py`) are skipped via `pytest.importorskip` rather than run, so a bare install silently exercises a reduced suite: ```bash poetry install --all-extras poetry run pytest ``` The default `addopts` in `pyproject.toml` already pass `-v -Wdefault --cov=uiprotect --cov-report=term-missing:skip-covered -n=auto` and `pythonpath = ["src"]`, so a bare `pytest` produces a coverage-aware, xdist-parallel run. `pytest-asyncio` is used in the default per-test mode (no auto mode); async tests are marked explicitly with `@pytest.mark.asyncio`. **Every PR must reach 100% patch coverage.** Each line you add or change under `src/uiprotect/` has to be exercised by a test — check the `--cov-report=term-missing:skip-covered` output and confirm none of the diff's lines appear as missing before pushing. Don't ship a change with uncovered new code; if a line is genuinely unreachable, mark it `# pragma: no cover` with a one-line reason rather than leaving it untested. This applies to automated contributions too: a PR that adds production code without the tests to cover it is incomplete. The test suite is fixture-driven: `tests/sample_data/` holds captured bootstrap JSON / WebSocket frames from real Protect consoles and `tests/conftest.py` loads them into the mock client. When fixing a bug that depends on a specific server response, add a fixture or extend an existing one rather than mocking ad-hoc in the test — the goal is for the corpus to grow alongside firmware drift. See [TESTDATA.md](TESTDATA.md) for how the corpus is generated. CodSpeed benchmarks live under `tests/benchmarks/` and run in CI through the CodSpeed integration. Don't regress benchmark numbers on hot paths (`api.py` request building, `data/convert.py`, `websocket.py` decode) without flagging the trade-off in the PR body. `tests/test_cli.py` covers the typer-based CLI surface. If you add a new subcommand under `src/uiprotect/cli/`, extend the CLI tests as well — the CLI tests are how we catch import-time regressions that would otherwise only fire for users on `uiprotect --help`. The CI matrix runs CPython 3.11 – 3.14 across Linux/macOS, and a separate live-data leg can run against a real Protect NVR on a self-hosted runner — see [LIVE_DATA_CI.md](LIVE_DATA_CI.md). The live leg is optional and is not gated for typical PRs. ## Useful entry points | Path | What | | ---------------------------------------- | -------------------------------------------------------------------------------- | | `src/uiprotect/__init__.py` | Public package — re-exports `ProtectApiClient` and key exceptions | | `src/uiprotect/api.py` | `ProtectApiClient` — REST surface, auth, bootstrap, subscribe, request plumbing | | `src/uiprotect/websocket.py` | `Websocket` — the long-lived update connection that drives live state | | `src/uiprotect/stream.py` | PyAV-backed audio streaming to camera speakers (talkback) | | `src/uiprotect/utils.py` | Shared helpers: URL building, time parsing, type coercion, dict diffing | | `src/uiprotect/exceptions.py` | Public exception hierarchy raised from `ProtectApiClient` | | `src/uiprotect/data/__init__.py` | Pydantic model re-exports — the typed surface most callers consume | | `src/uiprotect/data/base.py` | `ProtectBaseObject` / `ProtectModel` base classes shared by every Protect model | | `src/uiprotect/data/bootstrap.py` | `Bootstrap` — the cached snapshot returned by `protect.bootstrap` | | `src/uiprotect/data/devices.py` | Camera / Light / Sensor / Viewer / Chime / AiPort device models | | `src/uiprotect/data/nvr.py` | `NVR`, `Event`, `Liveview`, related top-level models | | `src/uiprotect/data/user.py` | User / permission / cloud-account models | | `src/uiprotect/data/websocket.py` | WebSocket frame decoder + `WSSubscriptionMessage` definition | | `src/uiprotect/data/convert.py` | Raw-JSON → pydantic-model conversion entry point used by API + WS paths | | `src/uiprotect/data/types.py` | Enums, typed dicts, and protocol-level constants | | `src/uiprotect/data/public_bootstrap.py` | Public-API bootstrap shape (parallels `bootstrap.py` for the newer API) | | `src/uiprotect/data/public_devices.py` | Public-API device models | | `src/uiprotect/cli/` | typer-based CLI — one module per device family plus `base.py` for shared options | | `src/uiprotect/test_util/` | Helpers used by `generate-sample-data`; not part of the public API | | `tests/` | Pytest suite | | `tests/sample_data/` | Captured Protect bootstrap / WS / event JSON used as fixtures | | `tests/benchmarks/` | CodSpeed benchmarks | | `tests/conftest.py` | Fixture wiring: builds a mock `ProtectApiClient` from `sample_data/` | | `templates/` | Rich templates used by the CLI for human-readable output | ## API strategy The project is migrating from the **private API** (reverse-engineered, undocumented endpoints under `/api/…` and the binary WebSocket stream; models in `src/uiprotect/data/devices.py`) to the **Public Integration API** (Ubiquiti's officially documented REST API under `/integration/v1/…`; models in `src/uiprotect/data/public_devices.py` and `src/uiprotect/data/public_bootstrap.py`; tests under `tests/test_api_*_public.py`). **Do not implement new features on the private API.** If a capability is missing from the public API, the right answer is to wait for or request the public endpoint — not to add it via the private path. Derive shapes from the existing public models and tests in this repo. **Public-only client mode.** `ProtectApiClient` can be constructed with only an API key and no private credentials — `ProtectApiClient.public_only(host, port, api_key=...)`, or by passing `api_key=` with `username`/`password` omitted. In that mode `is_public_only` is `True` and the private-session entry points (`authenticate`, `ensure_authenticated`, `update`, `get_bootstrap`) raise `PublicOnlyModeError`; only the Public Integration API surface is available (`update_public`, `subscribe_events`, `subscribe_devices`, the `*_public` getters/setters, `get_meta_info`). A revoked/invalid/missing key surfaces as `NotAuthorized` across REST and the public websockets. `MetaInfo.version` parses `applicationVersion` into a `Version` comparable to the private `NVR.version` min-version gate. API-key _provisioning_ (`create_api_key`) is private-API and out of scope for public-only clients — the key is supplied pre-provisioned. **Deprecate private-API counterparts when the public API is feature- complete for a given capability.** Once a device method or endpoint is fully covered by the public API, mark the corresponding private-API method with a `DeprecationWarning` pointing to the public replacement. **Remove private-API code that the Home Assistant integration no longer uses.** Before removing a private-API method or model, check whether the latest released version of the HA integration still references it: search `homeassistant/components/unifiprotect/` in the `home-assistant/core` GitHub repository. If the symbol does not appear there, it is safe to remove. ## Public Integration API spec The OpenAPI spec is not committed (Ubiquiti's IP). Fetch it on demand — no auth or console access needed: ```bash python scripts/fetch_openapi.py # latest release python scripts/fetch_openapi.py --version 7.0.104 # pin to a version ``` Output: `openapi/integration.json` (gitignored) — request/response shapes, required fields, enums, and allowed HTTP methods for every `/integration/v1/…` endpoint. If the file is absent, run the script first. ### Spec conformance validation `scripts/validate_spec.py` checks the public-API client against a fetched spec and reports drift: spec endpoints with no covering `*_public` method (warning), model fields the spec dropped/retyped (error) or added (warning), new values on a named, tracked enum (warning), and any spec enum — named **or** inline — that is not faithfully typed in the library (**error**, so the check gates: `main()` exits non-zero and the cron opens a drift issue instead of a green marker-bump PR). The enum-coverage check walks the whole spec keyed by value-set (the `unknown` forward-compat sentinel ignored on both sides) and counts a value-set covered only when it _equals_ a single library enum, or is explicitly pinned in `_MODELLED_AS_SUBSET` to one named superset enum the public models already type the field with — never a coincidental subset of _any_ enum, which is the value-set collision an earlier any-subset check let slip. Coverage is decided on the value-set alone: an exact match proves some library enum carries those values, not that the owning model field is annotated with it. Enums are classified inbound (reachable from a response the library deserializes) vs. outbound-only (request param/body) by `$ref` reachability, per enum _occurrence_ — `(owning component schema, value-set)` — so an outbound-only enum whose values coincide with an inbound one is not dragged inbound with it. Reusable responses (`components.responses` or a `$ref`-ed response entry) are not resolved and fail the check loudly rather than silently misclassifying. Outbound-only enums are waived by direction, while an inbound enum left untyped needs an explicit, documented entry in `_ENUM_COVERAGE_WAIVERS`, keyed by `(owning component schema, value-set)` so a waiver for a generic set like `{closed, open}` cannot silently cover an unrelated enum on another schema. The default is to model; waivers are the rare exception. Single-value `const` discriminators (`modelKey`, an event `type`) are walked too, under a membership rule rather than the exact match an `enum` array must make: no library enum can ever _equal_ a one-value set, so a const counts as covered when some library enum defines the value, and non-string consts — `sirenDuration`, `activationDelay` and friends, which enumerate allowed _numbers_ — are skipped. `check_event_types` pins the axis that membership alone leaves loose: `PUBLIC_EVENT_TYPES` must equal the `type` consts of the spec's `event` `oneOf`, both directions an error, because `EventType` has no `unknown` member and an unmodelled public event type is dropped on parse. `oneOf`/`allOf` nodes resolve to the _union_ of their object variants (a property the variants disagree on becomes a nested union, so per-event `metadata` blocks merge too), which is what lets the single all-optional `PublicEvent` model be field-checked against the 38-variant `event` union. Spec `required` vs. model `optional` is not checked — the library models every public-API field optional by design, so it would be guaranteed noise. It exits non-zero on any error and prints a markdown summary. Reproduce locally with: ```bash python scripts/fetch_openapi.py --version "$(cat openapi/.validated-version)" python scripts/validate_spec.py ``` The only committed artifact is `openapi/.validated-version` — a bare version string (no IP) whose git history records when conformance last moved forward. `tests/test_public_schema_conformance.py` runs the same checks when a spec is present and skips cleanly when it is absent (the CI default); the logic itself is unit-tested network-free against in-memory mock specs in `tests/test_validate_spec.py`. The `.github/workflows/spec-validation.yml` cron runs the full validation at most once per Protect release — opening a marker-bump PR when green or a single drift issue when red — and short-circuits on a firmware-API check before downloading anything while the marker is current or a drift issue is already open. Endpoint coverage is **derived, not hand- maintained**: the declarative `@public_*` decorator registry (`uiprotect._public_api.registry`) covers every uniform endpoint, and the hand-written exception methods are covered by one recorded example call each (`_EXAMPLE_CALLS` — a request spy captures `(verb, path)` then short-circuits). `check_completeness` asserts every public-API coroutine is accounted for, so a new method that nobody wired up fails the suite instead of silently leaving its endpoint uncovered. ## Reporting security issues Suspected security vulnerabilities go through GitHub's [private vulnerability reporting][gh-report], not public issues or pull requests. The policy is spelled out in [SECURITY.md](SECURITY.md). The library holds credentials for users' UniFi Protect consoles, so a bug class disclosed in a public PR title is a credible information leak even before a patch ships. If a user describes what sounds like a vulnerability in chat, point them at that route instead of opening a public issue, PR, or commit that names the bug class and the affected code path. [gh-report]: https://github.com/uilibs/uiprotect/security/advisories/new ## Things not to do - **Don't relicense or pull in non-MIT dependencies.** Staying MIT is the reason this fork exists; an incompatible licence would break the Home Assistant integration that depends on this library. - **Don't break pydantic v2 compatibility.** No v1 idioms (`.dict()`, `Config` inner class, `validator` decorator) when editing model code — match the v2 style already in the `src/uiprotect/data/` tree. - **Don't introduce sync wrappers around async I/O.** The public surface is async; callers needing sync drive the loop themselves. - **Don't paper over typing errors with `# type: ignore`.** mypy runs with `warn_unused_ignores`, so a bare ignore will rot. Fix the upstream annotation instead. - **Don't add `Co-Authored-By` trailers from automated agents to commits** in this repo. `python-semantic-release` reads the commit log when generating the changelog and the trailer leaks into release notes. - **Don't introduce a PR title that violates Conventional Commits.** The `pr-title` CI job will reject it, and because the PR title is what lands on `main` at squash-merge time, a mis-typed title bumps the wrong version on the next semantic-release run. - **Don't reach for stdlib `json` in hot paths.** The codebase uses `orjson` everywhere; match the existing style. - **Don't hand-edit `CHANGELOG.md`.** It is generated by `python-semantic-release` from commit messages; manual edits are overwritten on the next release. - **Don't commit captured Protect data without scrubbing.** Bootstrap and event JSON from a real NVR contains `authUserId`, `accessKey`, and user records; see [TESTDATA.md](TESTDATA.md) for the keys that must be removed before a sample lands in `tests/sample_data/`. - **Don't drop Python 3.11 support without coordination.** `requires-python = ">=3.11"` is set deliberately; the Home Assistant integration's minimum tracks this floor. - **Don't store mutable state (caches, dedupe sets, etc.) module-globally or as a class-level mutable default.** One process can run multiple `ProtectApiClient` instances against different consoles (e.g. several Home Assistant config entries), so per-instance state must live on the instance — `self.…`, the `Bootstrap`/`PublicBootstrap` snapshot, or a per-client dataclass field (use `field(default_factory=…)`), never a module global or shared class attribute. A global cache leaks and collides across NVRs and is invisible in single-console tests. uilibs-uiprotect-317a52c/CHANGELOG.md000066400000000000000000002512321524637705700172020ustar00rootroot00000000000000# Changelog ## v16.10.0 (2026-09-03) ### Features - Route remaining write callsites through the public api ([`dc913ad`](https://github.com/uilibs/uiprotect/commit/dc913ada462f712fb273d34d75be962d6dde6e02)) ## v16.9.0 (2026-09-03) ### Features - Add publiccamerafeatureflags.has_highfps ([`0546eee`](https://github.com/uilibs/uiprotect/commit/0546eee67b855e8d85d43b9197fd9bc277a67b68)) ## v16.8.0 (2026-09-03) ### Features - Add camera.can_detect(smart_type) ([`16c10e3`](https://github.com/uilibs/uiprotect/commit/16c10e36ecd59b9e31d139f756390954d5aa52d4)) ## v16.7.0 (2026-09-02) ### Features - Add publiccamera.can_detect(smart_type) ([`5daf56c`](https://github.com/uilibs/uiprotect/commit/5daf56ca28b1e32c79af632c2c4cfa85bdccd5dc)) ## v16.6.1 (2026-08-29) ### Bug fixes - Honour override_connection_host for public rtsps urls ([`bdd7495`](https://github.com/uilibs/uiprotect/commit/bdd7495b12466750907ae69c4852bd21853065c5)) ## v16.6.0 (2026-08-29) ### Features - Allow clearing the doorbell lcd message over the public api ([`4009fe1`](https://github.com/uilibs/uiprotect/commit/4009fe10b167d68f1dc3a1e63a3779e91b1bb706)) ## v16.5.0 (2026-08-28) ### Features - Announce siren timed-run expiry over the devices websocket ([`55cec39`](https://github.com/uilibs/uiprotect/commit/55cec394bfe3a925e9b9ee1093b0600f2200a547)) ## v16.4.0 (2026-08-28) ### Features - Cover pos transactions and widen the spec conformance checks ([`f4b99e4`](https://github.com/uilibs/uiprotect/commit/f4b99e438b797f042c74cd17603d43dc100f9ebb)) ## v16.3.0 (2026-08-28) ### Features - Add datetime accessors for public epoch-ms timestamps ([`d8d85b3`](https://github.com/uilibs/uiprotect/commit/d8d85b39d0481d630c07b3fda49a42989bc3a369)) ## v16.2.0 (2026-08-28) ### Features - Complete the public event enums, types and metadata ([`820f314`](https://github.com/uilibs/uiprotect/commit/820f314d1b52fb8e4f2e6e4eefd6b51fc73d766b)) - Expose publicbootstrap.store_for for a model type ([`42e1162`](https://github.com/uilibs/uiprotect/commit/42e1162798d5af8b35c4f43dd8bb7a4c5e0fe399)) ### Bug fixes - Open the marker-bump pr with a user token ([`f2e65d4`](https://github.com/uilibs/uiprotect/commit/f2e65d40f304f95fd3aa7aecfd10c38e1d02c91a)) - Normalise resolved protect version to a bare major.minor.patch ([`f2e65d4`](https://github.com/uilibs/uiprotect/commit/f2e65d40f304f95fd3aa7aecfd10c38e1d02c91a)) ## v16.1.0 (2026-08-24) ### Bug fixes - Include customfilteronly in is_ir_led_slider_enabled ([`ec29041`](https://github.com/uilibs/uiprotect/commit/ec29041341b3ee2c7075c2db8231bbdf7f00634f)) ### Features - Emit add/remove devices-ws frames for the public resync ([`38719f9`](https://github.com/uilibs/uiprotect/commit/38719f9bdd9d4cdaf76839ae738ca26b8c93f244)) ## v16.0.0 (2026-08-24) ### Features - Remove the private setters superseded by their public twins ([`8e73f8a`](https://github.com/uilibs/uiprotect/commit/8e73f8aee5bebf6d393bd488b1874e59d60dbae0)) ### Refactoring - Swap the write callsites that already have a public twin ([`5104dee`](https://github.com/uilibs/uiprotect/commit/5104dee8094ba5e5622b6f8b104182f6323724e6)) ## v15.14.3 (2026-08-17) ### Bug fixes - Give the private websocket a heartbeat ([`c3c7e5b`](https://github.com/uilibs/uiprotect/commit/c3c7e5b69536f26272da5087842cff394239d450)) ### Documentation - Note public-api doorbell settings are read-only (spec gap) ([`8a8ead2`](https://github.com/uilibs/uiprotect/commit/8a8ead2bbe6a862d3e6b747c431ab2388d58a47d)) - Record public-api doorbell settings are read-only ([`8a8ead2`](https://github.com/uilibs/uiprotect/commit/8a8ead2bbe6a862d3e6b747c431ab2388d58a47d)) ## v15.14.2 (2026-07-15) ### Bug fixes - Redact rtspsstreams urls in anonymize_data ([`fcd2a88`](https://github.com/uilibs/uiprotect/commit/fcd2a8874884af94d1785ebec7fc266baa5e9321)) ## v15.14.1 (2026-07-15) ### Bug fixes - Wrap body-read failures in nvrerror ([`9cbf8ef`](https://github.com/uilibs/uiprotect/commit/9cbf8ef41150e75b6ccd8362d66c3d77f3f9db9c)) ## v15.14.0 (2026-07-15) ### Features - Add publicbootstrap.all_devices enumeration helper ([`59a1875`](https://github.com/uilibs/uiprotect/commit/59a1875bfd2f6efc973b5fc3138cb95b172ff505)) ## v15.13.1 (2026-07-15) ### Bug fixes - Fetch /v1/nvrs in resolve_nvr_mac on an unprimed client ([`387b870`](https://github.com/uilibs/uiprotect/commit/387b870cb0193f22a4d19b9b3925085a00731906)) ## v15.13.0 (2026-07-15) ### Features - Add combined set_light setter to public light tree ([`eff3045`](https://github.com/uilibs/uiprotect/commit/eff304580d88d74664541238e278b032f822f3ec)) ### Documentation - Codify comment anti-patterns and sweep src/uiprotect ([`5c3e239`](https://github.com/uilibs/uiprotect/commit/5c3e2399e21cc470030a253f1a27f7b286328f98)) ## v15.12.2 (2026-07-13) ### Bug fixes - Emit disconnected before auth_failed ([`cb2b57e`](https://github.com/uilibs/uiprotect/commit/cb2b57e26fa79021f5fdbaa5b6acc1ad6207fab3)) ## v15.12.1 (2026-07-13) ### Bug fixes - Notify subscribers when background rtsps refresh writes streams ([`f71fb78`](https://github.com/uilibs/uiprotect/commit/f71fb78811748d45e34568b6683ad839597ced59)) ## v15.12.0 (2026-07-13) ### Features - Make update_public() independent of websocket subscription order ([`b3de73e`](https://github.com/uilibs/uiprotect/commit/b3de73ec665dc5f66791fa5c06f9da393422d12f)) ## v15.11.0 (2026-07-12) ### Features - Shared identity interface across model trees ([`20c48cd`](https://github.com/uilibs/uiprotect/commit/20c48cd43e8913d012f36cdfbb01ac455c2d1832)) ## v15.10.1 (2026-07-12) ### Bug fixes - Apply update_public bootstrap batch atomically ([`fa934fd`](https://github.com/uilibs/uiprotect/commit/fa934fddd89a2411aedd28d27c9c3ede17d809bd)) ## v15.10.0 (2026-07-12) ### Features - Backfill public nvr mac from console fallback on older firmware ([`d9d5e3b`](https://github.com/uilibs/uiprotect/commit/d9d5e3b4bf4c254187430de129ea17f2bee63f5b)) ## v15.9.0 (2026-07-11) ### Features - Unify device convenience setters on the public model tree ([`ceedc47`](https://github.com/uilibs/uiprotect/commit/ceedc47bcc2f58765ad1d63614fac4328f588960)) ### Testing - Cover public device convenience setters and write-through ([`ceedc47`](https://github.com/uilibs/uiprotect/commit/ceedc47bcc2f58765ad1d63614fac4328f588960)) ### Documentation - Document public-tree device convenience setters ([`ceedc47`](https://github.com/uilibs/uiprotect/commit/ceedc47bcc2f58765ad1d63614fac4328f588960)) ## v15.8.0 (2026-07-11) ### Features - Surface public-ws auth failure via auth_failed state ([`11a1d90`](https://github.com/uilibs/uiprotect/commit/11a1d90a5d809cb0cded72fe6d1f2230f3383330)) ## v15.7.1 (2026-07-10) ### Bug fixes - Flush stale active events on events-ws reconnect ([`6b1388d`](https://github.com/uilibs/uiprotect/commit/6b1388dd183098bbe0250adfef1cab34ad94f165)) ## v15.7.0 (2026-07-10) ### Features - Resolve nvr mac via public→private→console fallback ([`8800440`](https://github.com/uilibs/uiprotect/commit/88004402a4bb86d5acccaa707f75aed9156c038b)) ## v15.6.0 (2026-07-10) ### Features - Expose rtsps quality ↔ channel-id mapping publicly ([`0a175b8`](https://github.com/uilibs/uiprotect/commit/0a175b860909dac2907848848adcdc21dfac4daa)) ## v15.5.0 (2026-07-09) ### Features - Expose derived detection/config properties on publiccamera ([`245407b`](https://github.com/uilibs/uiprotect/commit/245407ba755a6b07b8bc486a716cf2a364189128)) - Expose derived detection/config properties on publiccamera ([`245407b`](https://github.com/uilibs/uiprotect/commit/245407ba755a6b07b8bc486a716cf2a364189128)) ## v15.4.4 (2026-07-09) ### Documentation - Use absolute agents.md link so strict mkdocs build passes ([`be0cfec`](https://github.com/uilibs/uiprotect/commit/be0cfecd6fadf4e52e61441f1e89d93be7239295)) ### Bug fixes - Validate public numeric setters before the request ([`c3c3858`](https://github.com/uilibs/uiprotect/commit/c3c3858197a3f602beaac1a4d69417dd07fefebe)) - Validate public numeric setters before the request ([`c3c3858`](https://github.com/uilibs/uiprotect/commit/c3c3858197a3f602beaac1a4d69417dd07fefebe)) ## v15.4.3 (2026-07-07) ### Bug fixes - Tolerate unknown smart detect types on public models ([`eb3a1ac`](https://github.com/uilibs/uiprotect/commit/eb3a1ac3974b957bb5dfdd452ded3e7b2772c2e4)) ## v15.4.2 (2026-07-05) ### Bug fixes - Emit connected on connect and keepalive public ws ([`f4f96da`](https://github.com/uilibs/uiprotect/commit/f4f96da661097666ca4ff38f71583bdada946372)) ## v15.4.1 (2026-07-03) ### Bug fixes - Bound and surface rtsps stream priming failures in update_public ([`b577028`](https://github.com/uilibs/uiprotect/commit/b577028fc90123833cf673cf7c0d79b49f75163e)) - Bound and surface rtsps stream priming failures in update_public ([`b577028`](https://github.com/uilibs/uiprotect/commit/b577028fc90123833cf673cf7c0d79b49f75163e)) ## v15.4.0 (2026-07-02) ### Features - Unify public device models under publicdevicemodel ([`1e54765`](https://github.com/uilibs/uiprotect/commit/1e547656a575e43a0ecb43ad4e860642f1171e8a)) ## v15.3.0 (2026-06-26) ### Features - Derive remaining smart-audio detection booleans on publiccamera ([`6d5a048`](https://github.com/uilibs/uiprotect/commit/6d5a04816e121b8d1700d8ed8cf2cfc2425cec2b)) ## v15.2.0 (2026-06-26) ### Features - Emit publiccamera devices-ws update on detection transition ([`f342e3d`](https://github.com/uilibs/uiprotect/commit/f342e3d10ae786b8aab80def4b89e3819ef0d594)) ## v15.1.0 (2026-06-25) ### Features - Derive public-events-ws detection state on publiccamera ([`f6c1f0c`](https://github.com/uilibs/uiprotect/commit/f6c1f0c9ea46b11bca1b8a7b58d537227dadb604)) ## v15.0.0 (2026-06-25) ### Features - Expose linkstation.last_event as datetime ([`e941549`](https://github.com/uilibs/uiprotect/commit/e94154935ef0e5e02cb36f6e68029282ca3a4df6)) ## v14.3.0 (2026-06-25) ### Features - Add viewer.set_liveview_public and set_name_public ([`c908bb8`](https://github.com/uilibs/uiprotect/commit/c908bb826efd9dc7c0533477011b527627da9c06)) ## v14.2.0 (2026-06-25) ### Features - Add public-api sensor setters (patch /v1/sensors/{id}) ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046)) - Extend update_sensor_public with armed/glass-break fields ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046)) - Add public-api sensor setters on sensor ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046)) - Expose public-api sensor setters in the cli ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046)) - Add granular single-positional public sensor setters ([`7e85c23`](https://github.com/uilibs/uiprotect/commit/7e85c23c729093d078ba328a09e5db8278258046)) ## v14.1.1 (2026-06-24) ### Bug fixes - Add missing irledmode value customfilteronly ([`8b0ee1a`](https://github.com/uilibs/uiprotect/commit/8b0ee1ac437d96e41b0f0eb1d5d374ed8b0a402c)) ### Refactoring - Route private viewer liveview set through public api ([`125034f`](https://github.com/uilibs/uiprotect/commit/125034f9c664c8a74c15b002240a064d852d16ab)) - Migrate chime cli to public-api setters ([`c2a5517`](https://github.com/uilibs/uiprotect/commit/c2a5517e77c34e125b038c047fd951870222b380)) - Migrate chime cli to public-api setters ([`c2a5517`](https://github.com/uilibs/uiprotect/commit/c2a5517e77c34e125b038c047fd951870222b380)) - Delegate per-camera chime repeat-times to model setter ([`c2a5517`](https://github.com/uilibs/uiprotect/commit/c2a5517e77c34e125b038c047fd951870222b380)) ## v14.1.0 (2026-06-21) ### Features - Add chime.set_repeat_times_for_camera_public ([`dfcd552`](https://github.com/uilibs/uiprotect/commit/dfcd552cf4c9bbead6393423faa2f57b3d19078a)) ## v14.0.0 (2026-06-21) ### Features - Remove standalone doorlock device support ([`339ac55`](https://github.com/uilibs/uiprotect/commit/339ac5572c07476491cdabcc4b54dfac8fcfba22)) ## v13.5.1 (2026-06-20) ### Bug fixes - Re-auth and retry once on private 401 ([`93201f4`](https://github.com/uilibs/uiprotect/commit/93201f4a45ac5cebc2c597b6b09035f91e892481)) - Re-auth and retry once on private 401 ([`93201f4`](https://github.com/uilibs/uiprotect/commit/93201f4a45ac5cebc2c597b6b09035f91e892481)) ## v13.5.0 (2026-06-20) ### Features - Decouple public events path onto a typed public event model ([`018c05e`](https://github.com/uilibs/uiprotect/commit/018c05e79cf8445282773938e4ba95d79e21bea7)) - Add strongly-typed public event model and metadata enums ([`018c05e`](https://github.com/uilibs/uiprotect/commit/018c05e79cf8445282773938e4ba95d79e21bea7)) ### Documentation - Refresh readme for the public-api direction and clean up ([`a9bc45d`](https://github.com/uilibs/uiprotect/commit/a9bc45d17e9a45407535f9a6794ca28d4415ea84)) - Reorganize and de-duplicate the readme ([`a9bc45d`](https://github.com/uilibs/uiprotect/commit/a9bc45d17e9a45407535f9a6794ca28d4415ea84)) ## v13.4.0 (2026-06-19) ### Features - Add device type/guid, sensor feature flags, and event alarm_type ([`7a60cf7`](https://github.com/uilibs/uiprotect/commit/7a60cf7e06fd952883f9be43d1211470ab4cd866)) - Add device type/guid, sensor feature flags, and event alarm_type ([`7a60cf7`](https://github.com/uilibs/uiprotect/commit/7a60cf7e06fd952883f9be43d1211470ab4cd866)) ### Documentation - Add contributing section (no new private-api features, ai policy) ([`593af0b`](https://github.com/uilibs/uiprotect/commit/593af0b75163c57297f206ad0d7863bb43ca20df)) ### Refactoring - Decorate uniform alarm-hub public methods ([`3cbc521`](https://github.com/uilibs/uiprotect/commit/3cbc52197e7963e9fee731050b47199572233693)) ## v13.3.0 (2026-06-18) ### Features - Add camerachannel.rtsps_quality mapping ([`958ebdd`](https://github.com/uilibs/uiprotect/commit/958ebdd75a84c43871c05180d211765d8454084a)) - Add camerachannel.rtsps_quality mapping ([`958ebdd`](https://github.com/uilibs/uiprotect/commit/958ebdd75a84c43871c05180d211765d8454084a)) ## v13.2.0 (2026-06-18) ### Features - Add publicdevicemodel base for public mac/state ([`2e2e5ff`](https://github.com/uilibs/uiprotect/commit/2e2e5ffc8ab68a3f0a194a7000096e25e48a7cba)) ### Refactoring - Add publicdevicemodel base for public mac/state fields ([`2e2e5ff`](https://github.com/uilibs/uiprotect/commit/2e2e5ffc8ab68a3f0a194a7000096e25e48a7cba)) ## v13.1.2 (2026-06-14) ### Bug fixes - Deliver born-closed public events (lone update, no add) ([`a3a503a`](https://github.com/uilibs/uiprotect/commit/a3a503a5003b84a37e5a20b6850e36aa8a94c146)) ## v13.1.1 (2026-06-12) ### Bug fixes - Relax av floor to >=17.0.1 for home assistant compatibility ([`c4ed9e1`](https://github.com/uilibs/uiprotect/commit/c4ed9e1fb44841e5c9c1468becb4133ccd91b630)) ## v13.1.0 (2026-06-12) ### Features - Pace public path with in-house rate limiter (alt to #984) ([`45c721d`](https://github.com/uilibs/uiprotect/commit/45c721d5c636966f30bac48faf8bff858fc144c1)) - Pace public path with in-house rate limiter seeded from ratelimit-policy ([`45c721d`](https://github.com/uilibs/uiprotect/commit/45c721d5c636966f30bac48faf8bff858fc144c1)) ### Refactoring - Declarative decorators for uniform public-api endpoints ([`755cd5c`](https://github.com/uilibs/uiprotect/commit/755cd5c67cb2489ed29a2d70b57cd73e7283d84d)) - Declarative decorators for uniform public-api endpoints ([`755cd5c`](https://github.com/uilibs/uiprotect/commit/755cd5c67cb2489ed29a2d70b57cd73e7283d84d)) ## v13.0.0 (2026-06-10) ### Features - Rename metainfo.applicationversion to application_version ([`e682d4d`](https://github.com/uilibs/uiprotect/commit/e682d4d82f0b9468a3711cce8a73a9e459f09f64)) ## v12.1.0 (2026-06-09) ### Features - Resolve console mac for public-only clients via /api/system ([`bb62fff`](https://github.com/uilibs/uiprotect/commit/bb62fffce4be9327d8fbb1dc0d580833d7e9872d)) - Resolve console mac for public-only clients via /api/system ([`bb62fff`](https://github.com/uilibs/uiprotect/commit/bb62fffce4be9327d8fbb1dc0d580833d7e9872d)) ## v12.0.0 (2026-06-08) ### Features - Own rtsps stream lifecycle on publiccamera ([`e05fff5`](https://github.com/uilibs/uiprotect/commit/e05fff58b9a0e0959438708f03377f0c82fd37d9)) - Own rtsps stream lifecycle on publiccamera ([`e05fff5`](https://github.com/uilibs/uiprotect/commit/e05fff58b9a0e0959438708f03377f0c82fd37d9)) ## v11.10.0 (2026-06-08) ### Bug fixes - Keep the public rtsps cache never-empty via in-place refresh ([`c40b2d4`](https://github.com/uilibs/uiprotect/commit/c40b2d410d7f01417322cfa8b4c935af110ce87d)) ### Features - Refresh the public rtsps cache in place instead of emptying it ([`c40b2d4`](https://github.com/uilibs/uiprotect/commit/c40b2d410d7f01417322cfa8b4c935af110ce87d)) ## v11.9.0 (2026-06-08) ### Features - Cache rtsps streams on the public bootstrap ([`bee1b85`](https://github.com/uilibs/uiprotect/commit/bee1b858526e1289d29fd7d2e64fcce326500d80)) - Cache rtsps streams on the public bootstrap ([`bee1b85`](https://github.com/uilibs/uiprotect/commit/bee1b858526e1289d29fd7d2e64fcce326500d80)) ## v11.8.0 (2026-06-07) ### Features - Public-stream helpers for a thin ha integration ([`795df19`](https://github.com/uilibs/uiprotect/commit/795df194b44289f781ce77781af2832ecda56ed1)) - Add public-stream helpers for thin ha integration ([`795df19`](https://github.com/uilibs/uiprotect/commit/795df194b44289f781ce77781af2832ecda56ed1)) ### Refactoring - Strip exact ?enablesrtp suffix in get_stream_url ([`795df19`](https://github.com/uilibs/uiprotect/commit/795df194b44289f781ce77781af2832ecda56ed1)) ## v11.7.1 (2026-06-07) ### Bug fixes - Default lcdmessage.text so missing text no longer raises ([`2aa7bbd`](https://github.com/uilibs/uiprotect/commit/2aa7bbd5cd6916bb65a96bacf2277ba7e4f7b892)) ## v11.7.0 (2026-06-07) ### Features - Raise typed armedmodeerror when alarm is armed ([`5f0336c`](https://github.com/uilibs/uiprotect/commit/5f0336ccc7f54a28836f858479af34a70b9e3a3e)) - Raise typed armedmodeerror when an operation is rejected because the alarm is armed ([`5f0336c`](https://github.com/uilibs/uiprotect/commit/5f0336ccc7f54a28836f858479af34a70b9e3a3e)) ### Testing - Skip cli-extra modules when pillow/typer are absent ([`ccc5360`](https://github.com/uilibs/uiprotect/commit/ccc5360428912b49bd2ec1b44d235a9e15811488)) ## v11.6.0 (2026-06-06) ### Features - Support public-only (api-key-only) client mode ([`72c4a7a`](https://github.com/uilibs/uiprotect/commit/72c4a7ae84dfadb746112b1f1dcbddd232273c23)) - Support public-only (api-key-only) client mode ([`72c4a7a`](https://github.com/uilibs/uiprotect/commit/72c4a7ae84dfadb746112b1f1dcbddd232273c23)) ### Testing - Clean up pytest hygiene warnings on python 3.14 ([`87fedca`](https://github.com/uilibs/uiprotect/commit/87fedcaff2172530e732e3b492f61c6f0009134b)) - Silence pytest hygiene warnings on 3.14 ([`87fedca`](https://github.com/uilibs/uiprotect/commit/87fedcaff2172530e732e3b492f61c6f0009134b)) - Drop benchmark scaffolding from ws subscription tests ([`87fedca`](https://github.com/uilibs/uiprotect/commit/87fedcaff2172530e732e3b492f61c6f0009134b)) ### Build system - Re-raise av floor to >=17.0.1 ([`1cd3af1`](https://github.com/uilibs/uiprotect/commit/1cd3af13d6cbe7caa4b3a20bb064c0e0db83ad82)) ### Documentation - Require 100% patch coverage for every pr ([`a14fb24`](https://github.com/uilibs/uiprotect/commit/a14fb24f5310901c331a1908858d68473424f458)) - Require 100% patch coverage for every pr ([`a14fb24`](https://github.com/uilibs/uiprotect/commit/a14fb24f5310901c331a1908858d68473424f458)) - Audit and fix doc set against current codebase ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500)) - Audit and fix doc set against current codebase ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500)) - Clarify public-api cli group wording in usage.md ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500)) - Fix create-api-key api class, websocket auth note, and tls defaults ([`526b027`](https://github.com/uilibs/uiprotect/commit/526b027e57c47c31807d2daf2f097c9053a50500)) ## v11.5.0 (2026-06-05) ### Features - Add typed subscribe_devices() device-state lifecycle ([`4bd6ef7`](https://github.com/uilibs/uiprotect/commit/4bd6ef786486cde9a70e54fa82cb814d51914a2a)) - Add typed subscribe_devices() device-state lifecycle ([`4bd6ef7`](https://github.com/uilibs/uiprotect/commit/4bd6ef786486cde9a70e54fa82cb814d51914a2a)) ### Build system - Remove dead sphinx docs stack ([`b73d07b`](https://github.com/uilibs/uiprotect/commit/b73d07bb85c39293b0f0f94c1cd32f4a05957b90)) - Remove dead sphinx stack, drop starlette/uvicorn transitives ([`b73d07b`](https://github.com/uilibs/uiprotect/commit/b73d07bb85c39293b0f0f94c1cd32f4a05957b90)) ## v11.4.0 (2026-06-05) ### Features - Back public camera/light/sensor/chime with dedicated models ([`f3076fe`](https://github.com/uilibs/uiprotect/commit/f3076fec22fa1b221456bfca618e45270834a16f)) ## v11.3.1 (2026-06-04) ### Bug fixes - Skip unknown event types on the live stream ([`9bf519b`](https://github.com/uilibs/uiprotect/commit/9bf519be2a6cfd19bd0e94e6f95ba129ec393c9a)) ## v11.3.0 (2026-06-04) ### Features - Move cli-only dependencies (rich, dateparser, pillow) into the cli extra ([`297a63e`](https://github.com/uilibs/uiprotect/commit/297a63e4b1163242a5d7622f4bb00a0e48869b65)) ## v11.2.0 (2026-06-04) ### Features - Make typer an optional cli extra ([`1bb4ddd`](https://github.com/uilibs/uiprotect/commit/1bb4dddf28a21214fee7b70303576a9dcc414003)) ## v11.1.0 (2026-06-04) ### Bug fixes - Tolerate unknown videomode values from newer firmware ([`46a030a`](https://github.com/uilibs/uiprotect/commit/46a030a1fdd303bb63f66b980eeaccb18c734dcd)) - Make alarm hub battery/cover fields optional ([`94b4b21`](https://github.com/uilibs/uiprotect/commit/94b4b210355578574aa757a2dacac630fe2ba0c9)) ### Features - Single-source protectevent + subscribe_events ([`47efd08`](https://github.com/uilibs/uiprotect/commit/47efd08d6e725dfeb5b1b6a479cdce467a8eb7fc)) ## v11.0.2 (2026-06-04) ### Bug fixes - Relax pyjwt and av floors to unblock the ha lib bump ([`ff5b676`](https://github.com/uilibs/uiprotect/commit/ff5b67674cc1548733281a92c5152a28545e9763)) ## v11.0.1 (2026-06-03) ### Bug fixes - Tolerate unknown osdoverlaylocation values from newer firmware ([`66bf72b`](https://github.com/uilibs/uiprotect/commit/66bf72b2c40bc9c804727347d31b3bfd4acd1871)) ## v11.0.0 (2026-06-03) ### Features - Type siren.state as devicestate and add sirenconnectiontype enum ([`04e8976`](https://github.com/uilibs/uiprotect/commit/04e89765d1b56dc4364cb7d158751ad681ae3125)) ## v10.18.1 (2026-06-03) ### Bug fixes - Sanitize camera names in backup paths to block traversal ([`741adcd`](https://github.com/uilibs/uiprotect/commit/741adcde0be341688827b96bd2c911688685358a)) ## v10.18.0 (2026-06-03) ### Features - Add public api convenience setters on light ([`3484cbb`](https://github.com/uilibs/uiprotect/commit/3484cbb6d8d918ad422dbea89c8a7c0369c93e9a)) ## v10.17.0 (2026-06-02) ### Features - Type alarm hub status fields as enums ([`cc75a00`](https://github.com/uilibs/uiprotect/commit/cc75a0004ab937c099b1e07a8061fab319f0ac5f)) ### Documentation - Fix typo in get_camera_video ([`8f4cfd5`](https://github.com/uilibs/uiprotect/commit/8f4cfd5f10b22a0b6e532f2709e082de40717a84)) ## v10.16.0 (2026-06-02) ### Features - Add typed alarm hub accessors to linkstation ([`5754734`](https://github.com/uilibs/uiprotect/commit/57547349d445804266eb4cad15c1fb14f2d5ca29)) ## v10.15.0 (2026-06-02) ### Features - Add channelquality enum for rtsps helpers ([`2187b12`](https://github.com/uilibs/uiprotect/commit/2187b121caa7c86419ae181abbed9272ca50b4ee)) ## v10.14.0 (2026-06-02) ### Features - Add assetfiletype enum for publicfile.type and files api ([`7560c5f`](https://github.com/uilibs/uiprotect/commit/7560c5faec92897bca20f06680ad4148e3d4e1c0)) ## v10.13.0 (2026-06-02) ### Features - Tighten relay public-api field types to spec enums ([`bf589f8`](https://github.com/uilibs/uiprotect/commit/bf589f8f287916fae3882d3481d9898deaab40f9)) ## v10.12.0 (2026-06-02) ### Features - Add ulpuserstatus enum for publiculpuser.status ([`377d728`](https://github.com/uilibs/uiprotect/commit/377d72838e9919fdfccde9099a35ae2517733821)) ## v10.11.0 (2026-06-02) ### Features - Add public users, ulp-users, files, disable-mic endpoints ([`9e6107f`](https://github.com/uilibs/uiprotect/commit/9e6107f06e19d047078ccc08e235ef464fb04b1e)) ## v10.10.2 (2026-06-02) ### Bug fixes - Refuse to retry past tls failure with credentials ([`4c7550b`](https://github.com/uilibs/uiprotect/commit/4c7550b0df880b352c0cac854f4e3a6d3c4f8a5b)) ### Refactoring - Correct list_from_unifi_list dict value annotation ([`ce1f140`](https://github.com/uilibs/uiprotect/commit/ce1f140f49e8bc54b3fc5a5ef5cfb17be6d801c3)) ## v10.10.1 (2026-06-02) ### Bug fixes - Stripping of all instances of -e in thumbnail and heatmap generations ([`c4b3fd2`](https://github.com/uilibs/uiprotect/commit/c4b3fd28aa51ba9297f8889d6c6c63f10b8c8d33)) ## v10.10.0 (2026-06-02) ### Features - Add public api bridges and viewers endpoint groups ([`1a0ed11`](https://github.com/uilibs/uiprotect/commit/1a0ed11cc296058a0b2f3314d5df0e26cdea97bf)) ## v10.9.0 (2026-06-02) ### Features - Add link-stations and alarm-hubs public api endpoints ([`a55887f`](https://github.com/uilibs/uiprotect/commit/a55887ff100e780405d9685ca240675b04d31c4f)) ## v10.8.0 (2026-05-29) ### Features - Add public api liveviews endpoint group (list/get/update/create) ([`8153836`](https://github.com/uilibs/uiprotect/commit/8153836efa6dc1c4b7a8360ef923a3ef6cbe0a9e)) ## v10.7.0 (2026-05-28) ### Features - Add public api speakers endpoint group (list/get/update/test-sound) ([`a5f6068`](https://github.com/uilibs/uiprotect/commit/a5f606811dd37b2fe21538d86f158b39f3ca24b0)) ## v10.6.0 (2026-05-26) ### Features - Add public api fobs endpoint group (list/get/update) ([`514d87e`](https://github.com/uilibs/uiprotect/commit/514d87ec0114f9ac148c6108d13a8cdfd0fc56f8)) ### Documentation - Add script and agent instructions to fetch integration openapi spec ([`fe16ee0`](https://github.com/uilibs/uiprotect/commit/fe16ee02e8a48d001ee0374d1ac42ae1c15da2cb)) - Document api migration strategy in agents.md ([`fe16ee0`](https://github.com/uilibs/uiprotect/commit/fe16ee02e8a48d001ee0374d1ac42ae1c15da2cb)) - Add script and agent instructions to fetch integration openapi spec ([`fe16ee0`](https://github.com/uilibs/uiprotect/commit/fe16ee02e8a48d001ee0374d1ac42ae1c15da2cb)) ### Testing - Port session and video tests off sync io ([`a7deac1`](https://github.com/uilibs/uiprotect/commit/a7deac15360f949f5a51cedf179810d464bc86b3)) ## v10.5.1 (2026-05-22) ### Bug fixes - Catch blocking io in async tests with blockbuster ([`afbdbb7`](https://github.com/uilibs/uiprotect/commit/afbdbb7900092cc93eb4370b51833d3166c99182)) ### Documentation - Document api migration strategy in agents.md ([`d5df437`](https://github.com/uilibs/uiprotect/commit/d5df4378b81b122262ef032d4b80278012f8bc7d)) - Add security.md with private vulnerability reporting policy ([`55a61c5`](https://github.com/uilibs/uiprotect/commit/55a61c5f406f090789885cc0f52f7a6f7f2e7ec1)) ## v10.5.0 (2026-05-18) ### Features - Add public api methods for camera configuration ([`d7e9fb6`](https://github.com/uilibs/uiprotect/commit/d7e9fb65aa074c9917eeff12b93ed70a4fdf930d)) ### Documentation - Add agents.md orientation file for llm contributors ([`c0172a5`](https://github.com/uilibs/uiprotect/commit/c0172a56a38de23c4702cbfef3dc502048c1a0b3)) ## v10.4.1 (2026-04-26) ### Bug fixes - Always send json body; add sirenduration enum and turn_off_at ([`d89d8ac`](https://github.com/uilibs/uiprotect/commit/d89d8ac11cd1f968de532dd310aa6753e78140cf)) ## v10.4.0 (2026-04-25) ### Features - Add publicnvr, nvrarmmodestatus and relayoutputstate enums ([`2c92650`](https://github.com/uilibs/uiprotect/commit/2c926509342374092f1bb436e9c5973b56a0efef)) ## v10.3.1 (2026-04-23) ### Bug fixes - Add missing public event websocket types ([`8460c84`](https://github.com/uilibs/uiprotect/commit/8460c8453489e375a6dfa7230f8dafb0a03dae6a)) ## v10.3.0 (2026-04-23) ### Features - Add some public integration api endpoints ([`687d4bc`](https://github.com/uilibs/uiprotect/commit/687d4bcebf9bae22ba807ba1c7b10a9b506dab81)) ## v10.2.6 (2026-04-11) ### Bug fixes - Add missing max_event_history_in_state_machine import in bootstrap.py ([`63dce4c`](https://github.com/uilibs/uiprotect/commit/63dce4c8ae6d1e2e0d319993cd1097fb2a961603)) ## v10.2.5 (2026-04-10) ### Bug fixes - Keep sensor on when concurrent smart detect events overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Keep sensor on when concurrent smart detect events overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Prefer active event over ended when no tracking exists ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Keep sensor on when concurrent smart detect events overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Overlap edge case ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Bound bootstrap.events to prevent unbounded memory growth ([`386f806`](https://github.com/uilibs/uiprotect/commit/386f806944d23dfc508e6f703937168cd704265c)) ### Testing - Add coverage for current_id=none with active event branch ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Cover stale event cleanup in active index ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Decouple processing-order assertion from timestamp order ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) - Assert sensor state during concurrent smart-detect overlap ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) ### Refactoring - Extract reset_smart_detect fixture to reduce duplication ([`78a17d6`](https://github.com/uilibs/uiprotect/commit/78a17d6826745d771b24a2ba6f68fd7d176f6a02)) ## v10.2.4 (2026-04-10) ### Performance improvements - Add codspeed benchmarks for websocket packet processing ([`65a97df`](https://github.com/uilibs/uiprotect/commit/65a97df05d5b558d19019f8e79cfa794a6a46f32)) ## v10.2.3 (2026-03-24) ### Bug fixes - Handle unknown enum values and keyerror in ws processing ([`c9dadf7`](https://github.com/uilibs/uiprotect/commit/c9dadf75f081281e4d547873af7d957ce3ae5fdc)) - Handle unknown enum values and keyerror in ws processing ([`c9dadf7`](https://github.com/uilibs/uiprotect/commit/c9dadf75f081281e4d547873af7d957ce3ae5fdc)) - Improve resilience in ws error handling by checking for pending refresh tasks ([`c9dadf7`](https://github.com/uilibs/uiprotect/commit/c9dadf75f081281e4d547873af7d957ce3ae5fdc)) - Resolve deadlock in talkbackstream.stop() during concurrent playback ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973)) - Resolve deadlock in talkbackstream.stop() during concurrent playback ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973)) - Prevent start() from clearing a pending stop signal in talkbackstream ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973)) - Coverage, add restart test (not that we do, but latent bug if we start doing that) ([`0a27948`](https://github.com/uilibs/uiprotect/commit/0a2794817b0b79164e6fa00b5b1747f67ed80973)) ## v10.2.2 (2026-02-26) ### Bug fixes - Handle duplicate camelcase/snake_case keys from protect 7.x ([`c6d5532`](https://github.com/uilibs/uiprotect/commit/c6d553292e0cdbd7cf03f8950ace108fb88252ed)) ## v10.2.1 (2026-02-22) ### Bug fixes - Protect7 missing modelkey ([`0aeb5d3`](https://github.com/uilibs/uiprotect/commit/0aeb5d39185894077f573a9ad27aebbf9d7d83bc)) ## v10.2.0 (2026-02-22) ### Features - Record public api websockets during sample data generation ([`35c30d2`](https://github.com/uilibs/uiprotect/commit/35c30d2f7760ac880bf48a36c44f13b188ae2f62)) ## v10.1.0 (2026-01-31) ### Features - Add retry with exponential backoff ([`14e3617`](https://github.com/uilibs/uiprotect/commit/14e36178d322557858695b9f0e5918c77b27f76d)) ## v10.0.1 (2026-01-21) ### Bug fixes - Update is_package detection logic for g6 entry ([`5ba9026`](https://github.com/uilibs/uiprotect/commit/5ba90263eb64354696014a3095973a0a514058a8)) ## v10.0.0 (2026-01-20) ### Features - Migrate ptz control to public api ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e)) - Migrate ptz control to public api ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e)) - Add ptz preset and patrol retrieval tests ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e)) ### Testing - Add activepatrolslot field to sample data files ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e)) ### Bug fixes - Address pr review comments ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e)) - Restore activepatrolslot in sample_aiport.json for consistency ([`82ac7f8`](https://github.com/uilibs/uiprotect/commit/82ac7f8610eece008e76b636b1a80a2881eacf8e)) ## v9.0.0 (2026-01-20) ### Chores - Drop python 3.10 support ([`0836912`](https://github.com/uilibs/uiprotect/commit/0836912b20ac3677484bbd4d99ae5123803131cb)) ## v8.1.1 (2026-01-12) ### Bug fixes - Re-release ([`dbabddb`](https://github.com/uilibs/uiprotect/commit/dbabddbf61ab68e1fa7ed5e0fd6e7d5a406090a5)) ## v8.1.0 (2026-01-12) ### Features - Add public api support for chime ring volume per camera ([`87782fb`](https://github.com/uilibs/uiprotect/commit/87782fbe688e970cb0e1e1e448683417fb5f57c4)) ## v8.0.0 (2026-01-06) ### Features - Replace ffmpeg with pyav for talkback streaming ([`9998db0`](https://github.com/uilibs/uiprotect/commit/9998db00b0a0f541befe009aee84ad5e92dfb6b9)) ## v7.33.4 (2026-01-05) ### Bug fixes - Update schemas for latest protect version ([`9fa5afa`](https://github.com/uilibs/uiprotect/commit/9fa5afae647ec73206255342dabf2cd08d1285e5)) ## v7.33.3 (2025-12-21) ### Bug fixes - Handle ipv6 addresses in ipv4-typed fields ([`98a39b9`](https://github.com/uilibs/uiprotect/commit/98a39b9aeb94d4d976e9e8b568a214599815365f)) ## v7.33.2 (2025-12-05) ### Bug fixes - Allow dns hostnames with override_connection_host (#679) ([`4f84e69`](https://github.com/uilibs/uiprotect/commit/4f84e69618bfe38e2312dfe558c0649f170928b1)) ### Testing - Add test coverage for _update_bootstrap_soon method ([`cf11161`](https://github.com/uilibs/uiprotect/commit/cf111612fb18409654cda86f8dd4feea80f414b8)) ## v7.33.1 (2025-12-01) ### Bug fixes - Remove deprecated python 3.16 and pydantic v3 patterns ([`b09dab7`](https://github.com/uilibs/uiprotect/commit/b09dab7139e404eaeb8a555da56ae3e0129f1d73)) ### Testing - Add coverage for json deserialization in api_request ([`00eab75`](https://github.com/uilibs/uiprotect/commit/00eab75378fb70f1d9dacc152afb6ec2b06fcaec)) - Improve utils.py test coverage from 45% to 94% ([`2aff97f`](https://github.com/uilibs/uiprotect/commit/2aff97f7fee7cc6816f024b26fab33ea8709b358)) - Add unit tests for _auth_websocket method ([`d1f3841`](https://github.com/uilibs/uiprotect/commit/d1f3841d8838e770d22700bfac273b34fc7f09c4)) ## v7.33.0 (2025-11-30) ### Bug fixes - Make rx_bytes and tx_bytes optional in camerastats ([`8d20059`](https://github.com/uilibs/uiprotect/commit/8d20059c1adc83685c9ccd18e524b1480be7e381)) ### Features - Add ptz public api methods ([`a4e2013`](https://github.com/uilibs/uiprotect/commit/a4e2013124dde43c4aea1975362e01c28013f8f2)) ## v7.32.0 (2025-11-29) ### Features - Add ssl verification prompt with default to disabled ([`f9a07f7`](https://github.com/uilibs/uiprotect/commit/f9a07f707aea417fc7a000fcec6a5fe560964034)) ## v7.31.0 (2025-11-29) ### Bug fixes - Pydantic serialization warnings for camerazone color ([`5869b88`](https://github.com/uilibs/uiprotect/commit/5869b88c549e5f98d31eaf799ae006f30f04bd5d)) ### Features - Add update_light_public() method and fix lux_sensitivity handling ([`ef4e378`](https://github.com/uilibs/uiprotect/commit/ef4e3789fcfdb797ebe0e9b2c437cb4cfb977576)) ## v7.30.1 (2025-11-29) ### Bug fixes - Camera speaker volume ([`ea66817`](https://github.com/uilibs/uiprotect/commit/ea66817c5e05f3acc2da74544f8c431e8e3b28f6)) ### Documentation - Update readme with current features and requirements ([`7301124`](https://github.com/uilibs/uiprotect/commit/73011248abfa7b7febd1a66f4c07a9bd2818d77d)) ## v7.30.0 (2025-11-28) ### Features - Improve devcontainer configuration ([`f32aba2`](https://github.com/uilibs/uiprotect/commit/f32aba29c790a85b8d76686cf86b90dc92b20a94)) ## v7.29.0 (2025-11-26) ### Features - Implement session management with clear_session and clear_all_sessions methods ([`20dbf23`](https://github.com/uilibs/uiprotect/commit/20dbf2336052adf442cf508f5c96bd5a9eff2fb4)) - Enhance rtsp url handling for stacked nvr scenarios ([`80e5370`](https://github.com/uilibs/uiprotect/commit/80e5370e79ea6cd68d0ca0a3b1af9279b0789f63)) ## v7.28.0 (2025-11-24) ### Features - Add support for ufp 6.x detected license plate and face name properties ([`811e2e2`](https://github.com/uilibs/uiprotect/commit/811e2e2b2019a950c8276e21db10f8c209d42d43)) ## v7.27.0 (2025-11-24) ### Features - Update ledsettings for protect 6.x compatibility and add new fields ([`45ab777`](https://github.com/uilibs/uiprotect/commit/45ab77734ac674f4f7ca32441fad118e677ba52d)) ## v7.26.0 (2025-11-21) ### Features - Add ipv6 support for host formatting and connection urls ([`958f139`](https://github.com/uilibs/uiprotect/commit/958f139b0a3becfc8f30cf4509af0aa28cb46696)) ## v7.25.0 (2025-11-21) ### Features - Handle optional doorlocks field in bootstrap class ([`bb589b2`](https://github.com/uilibs/uiprotect/commit/bb589b20132bfbb54c2a053053ff2bf665a7823e)) ## v7.24.0 (2025-11-21) ### Features - Extract and set x-csrf-token during authentication ([`57d878a`](https://github.com/uilibs/uiprotect/commit/57d878a908728ae2d5b5a461d2e749939e8e671d)) ## v7.23.0 (2025-10-16) ### Features - Add events and devices websocket support with subscription handling ([`bf71cb4`](https://github.com/uilibs/uiprotect/commit/bf71cb411e0c90ed008d17e2003f66312ad652d6)) ## v7.22.0 (2025-10-14) ### Features - Remove last uses of pydantic.v1 ([`1d2004b`](https://github.com/uilibs/uiprotect/commit/1d2004bcb5b2c4c78c175c28b1226e797d50ebb8)) ## v7.21.1 (2025-08-14) ### Bug fixes - Remove typer dependency to resolve home assistant version conflicts ([`3619dd2`](https://github.com/uilibs/uiprotect/commit/3619dd2c203eed36ee5c2e77b1b6f20fb35b6380)) ## v7.21.0 (2025-08-06) ### Features - Implement get public api methods for nvr, lights, cameras, and chimes ([`94cc56a`](https://github.com/uilibs/uiprotect/commit/94cc56a9f5bd1feba3a3cf690008966e53d32083)) ## v7.20.0 (2025-07-27) ### Features - Add publicapi rtsps streams management for cameras with create, get, and delete functionalities ([`a53f4b6`](https://github.com/uilibs/uiprotect/commit/a53f4b6e787551aed522e3969914f0a70aa70c96)) ## v7.19.0 (2025-07-22) ### Features - Add methods to set and check api key for nvr ([`166751f`](https://github.com/uilibs/uiprotect/commit/166751fbf403def10763df2294006a96fe49e7db)) ## v7.18.1 (2025-07-21) ### Bug fixes - Ensure highquality parameter is a string in camera pub api snapshot requests ([`57db9e0`](https://github.com/uilibs/uiprotect/commit/57db9e0bdf65b161fcb9d4d7aaf39f8bd8fd4c77)) ## v7.18.0 (2025-07-21) ### Features - Update public api camera snapshot method to accept highquality parameter ([`3df7b70`](https://github.com/uilibs/uiprotect/commit/3df7b7067e8555a405202e49bfe75982c2d9bc71)) ## v7.17.0 (2025-07-21) ### Features - Add support for full hd snapshot feature flag ([`3059e84`](https://github.com/uilibs/uiprotect/commit/3059e84a6d5d6c034b1fb32a897d954aebf661cf)) ## v7.16.0 (2025-07-20) ### Features - Add public api camera snapshot ([`baba0e3`](https://github.com/uilibs/uiprotect/commit/baba0e3ed72dde13fe7113960907b54b87eafcc9)) - Add public api camera snapshot retrieval and related tests ([`baba0e3`](https://github.com/uilibs/uiprotect/commit/baba0e3ed72dde13fe7113960907b54b87eafcc9)) ## v7.15.1 (2025-07-20) ### Bug fixes - Update create_api_key to use 'self' instead of userid ([`755a1b7`](https://github.com/uilibs/uiprotect/commit/755a1b7273d4a97434564f88dbb2502aef61c173)) - Update create_api_key to use 'self' instead of userid and remove related tests ([`755a1b7`](https://github.com/uilibs/uiprotect/commit/755a1b7273d4a97434564f88dbb2502aef61c173)) ## v7.15.0 (2025-07-19) ### Features - Add public api session without cookie and update tests ([`89416ef`](https://github.com/uilibs/uiprotect/commit/89416ef8fe06048677254b95eb0913e6f0106161)) ## v7.14.2 (2025-07-08) ### Bug fixes - Add note about switching to the new public api ([`eb478d2`](https://github.com/uilibs/uiprotect/commit/eb478d2c981fe4c8faa3ad6fa18c47cbc9a7760e)) ### Refactoring - Removes release version retrieval return false for compatibility ([`e646655`](https://github.com/uilibs/uiprotect/commit/e6466558819c414395112fb1858bc5dd28a39c90)) ## v7.14.1 (2025-06-21) ### Bug fixes - Improve uuid handling in convert_unifi_data function ([`6f45e1d`](https://github.com/uilibs/uiprotect/commit/6f45e1d26641a51e43d541c28f3cf9abab355e98)) ## v7.14.0 (2025-06-18) ### Features - Add lpr_reflex to videomode enum ([`bc86bf1`](https://github.com/uilibs/uiprotect/commit/bc86bf15e4437c8ba2d29ed3e4bb7b49c8f09bba)) ## v7.13.0 (2025-06-09) ### Features - Add get_meta_info using public api ([`f30b50a`](https://github.com/uilibs/uiprotect/commit/f30b50a45bb551816bc2284d45d30611ee0d95e4)) ## v7.12.0 (2025-06-08) ### Features - Add optional api key to protectapiclient initialization ([`86e8b54`](https://github.com/uilibs/uiprotect/commit/86e8b54bb662aaf07587452c2a92bf3718756910)) ## v7.11.0 (2025-05-31) ### Features - Add adaptive mode to recordingmode enum ([`4b4155a`](https://github.com/uilibs/uiprotect/commit/4b4155aec2f74076669648194231136774f27198)) - Add adaptive mode to recordingmode enum and update sample_bootstrap.json ([`4b4155a`](https://github.com/uilibs/uiprotect/commit/4b4155aec2f74076669648194231136774f27198)) ## v7.10.1 (2025-05-27) ### Bug fixes - Update codec_to_encoder for correct codec format in talkbackstream ([`dfba227`](https://github.com/uilibs/uiprotect/commit/dfba227a2845c855c275234f5363f42d72eab4f5)) ## v7.10.0 (2025-05-25) ### Features - Add lpr_none_reflex to videomode enum ([`9ab42a0`](https://github.com/uilibs/uiprotect/commit/9ab42a0970c7ba7534996e142dc80a0f20210dc8)) ### Unknown ### Bug fixes - Initialize config dictionary in _read_auth_config method ([`f63e1b3`](https://github.com/uilibs/uiprotect/commit/f63e1b3d8357e82e3f88c07c7c17164140a5aa36)) ## v7.9.2 (2025-05-24) ### Bug fixes - Docker builds ([`f458c4a`](https://github.com/uilibs/uiprotect/commit/f458c4a35aeea78a601ff92719114449412f2a1d)) ## v7.9.1 (2025-05-24) ### Bug fixes - Docker builds ([`8b18b8f`](https://github.com/uilibs/uiprotect/commit/8b18b8fa479c1df833a4a36ad605abdd8cd69bc4)) ## v7.9.0 (2025-05-24) ### Features - Add create api key functionality and corresponding tests ([`3b74740`](https://github.com/uilibs/uiprotect/commit/3b74740cfe256f29970710426165cdb415a15a86)) ## v7.8.0 (2025-05-22) ### Features - Add option to force on flood light for light devices ([`c7c5331`](https://github.com/uilibs/uiprotect/commit/c7c533178722aa49e51fe1d2f8e35f63918f3007)) ## v7.7.0 (2025-05-22) ### Features - Support face detection ([`7f77238`](https://github.com/uilibs/uiprotect/commit/7f772388b7e2a280c2195ec71e6ab936d873abec)) ## v7.6.1 (2025-05-22) ### Bug fixes - Add error handling and logging to get_camera_video() ([`0080f5b`](https://github.com/uilibs/uiprotect/commit/0080f5b5bc6df4661dd4fea0ae2d70ff78099294)) ## v7.6.0 (2025-05-07) ### Features - Add codec mapping for audio encoding in talkbackstream ([`eae52d4`](https://github.com/uilibs/uiprotect/commit/eae52d464eff2a4a4011b760b547ce5ea666e917)) ## v7.5.6 (2025-05-02) ### Bug fixes - Update poetry to v2 ([`ccd89ed`](https://github.com/uilibs/uiprotect/commit/ccd89ed61d19298ba317b2f5a38c449dd3436935)) ## v7.5.5 (2025-04-24) ### Bug fixes - Change phy_rate to float ([`54f60fe`](https://github.com/uilibs/uiprotect/commit/54f60fec70ba5bb75b4efea43a40be70f56e2aeb)) ## v7.5.4 (2025-04-12) ### Bug fixes - Add spdx license identifier ([`85722f6`](https://github.com/uilibs/uiprotect/commit/85722f680d8b727646a165ab13ffa9f8888f5697)) ## v7.5.3 (2025-04-11) ### Bug fixes - Pydantic deprecationwarning ([`0aeb912`](https://github.com/uilibs/uiprotect/commit/0aeb912134fdc203e4fca358b408acada781043c)) ## v7.5.2 (2025-03-30) ### Bug fixes - Support non-integer zoom levels ([`a4976cc`](https://github.com/uilibs/uiprotect/commit/a4976cc50784e246526faa2fe494b51e1f77d8d9)) - Support non-integer zoom levels ([`a4976cc`](https://github.com/uilibs/uiprotect/commit/a4976cc50784e246526faa2fe494b51e1f77d8d9)) ## v7.5.1 (2025-02-04) ### Bug fixes - Handle fps being none ([`c988946`](https://github.com/uilibs/uiprotect/commit/c98894640ca7d70830890a4200cd92df4bf4a029)) - Handle fps being none ([`c988946`](https://github.com/uilibs/uiprotect/commit/c98894640ca7d70830890a4200cd92df4bf4a029)) - Handle fps being none ([`c988946`](https://github.com/uilibs/uiprotect/commit/c98894640ca7d70830890a4200cd92df4bf4a029)) ## v7.5.0 (2025-01-24) ### Features - Update data models to allow none for optional fields to support access devices ([`c6102e4`](https://github.com/uilibs/uiprotect/commit/c6102e4c94899ceebe75d4daddc15981d2368cb3)) - Update data models to allow none for optional fields ([`c6102e4`](https://github.com/uilibs/uiprotect/commit/c6102e4c94899ceebe75d4daddc15981d2368cb3)) - Add new optional fields to recording and camera settings for intercom ([`c6102e4`](https://github.com/uilibs/uiprotect/commit/c6102e4c94899ceebe75d4daddc15981d2368cb3)) ## v7.4.1 (2025-01-05) ### Bug fixes - Handle missing keys in bootstrap data and log an error ([`e06cd7b`](https://github.com/uilibs/uiprotect/commit/e06cd7b2811e2fa292917aec924dfeadc6c43644)) ## v7.4.0 (2025-01-04) ### Features - Add missing ispsettings enum values ([`e593606`](https://github.com/uilibs/uiprotect/commit/e593606debb26c5c7f596037f1739a1881e8d8c8)) ## v7.3.0 (2025-01-04) ### Features - Add none option to autoexposuremode enum ([`04ad788`](https://github.com/uilibs/uiprotect/commit/04ad78889d12a49d2ec415186bc9c8de46903ae9)) ## v7.2.0 (2025-01-03) ### Features - Add set_light_is_led_force_on method ([`5488b1d`](https://github.com/uilibs/uiprotect/commit/5488b1d7accb3d0f3a3df05101b8bc87ef67f25b)) - Add ringtone model and update related functionality ([`d0c93b5`](https://github.com/uilibs/uiprotect/commit/d0c93b5cf562d24acd90dea6e6a77c1ea56b00c1)) - Add ringtone_id and track_no parameters to play chime tones ([`d0c93b5`](https://github.com/uilibs/uiprotect/commit/d0c93b5cf562d24acd90dea6e6a77c1ea56b00c1)) ### Unknown ## v7.1.0 (2024-12-18) ### Features - Add aiport support ([`ba459ff`](https://github.com/uilibs/uiprotect/commit/ba459ff1619957123f71fcf48da7042e3e086ddd)) - Add aiport support ([`ba459ff`](https://github.com/uilibs/uiprotect/commit/ba459ff1619957123f71fcf48da7042e3e086ddd)) ## v7.0.2 (2024-12-11) ### Bug fixes - Migrate more deprecated pydantic calls ([`50ef161`](https://github.com/uilibs/uiprotect/commit/50ef1616429ea013fcb82155beb7510fa0ea156c)) ## v7.0.1 (2024-12-11) ### Bug fixes - Treat no access to keyrings/users as empty ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e)) - Treat no access to keyrings/users as empty ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e)) - Rushed logic ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e)) - Should be a 403 ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e)) - Use internal exception ([`c068aca`](https://github.com/uilibs/uiprotect/commit/c068aca46f37f71f52c077b1ab4821bb54d4b26e)) ## v7.0.0 (2024-12-11) ### Features - Remove pydantic v1 shims ([`44063a0`](https://github.com/uilibs/uiprotect/commit/44063a050d831893e7e8eded35ff292401511414)) ## v6.8.0 (2024-12-09) ### Bug fixes - Import of self for python 3.10 ([`fe7fc3a`](https://github.com/uilibs/uiprotect/commit/fe7fc3ad42d1166625b2d0cb3d1f8447b273a1a6)) ### Features - Refactor keyrings and ulpusers to add internal indices ([`705df32`](https://github.com/uilibs/uiprotect/commit/705df32514254b754ebea1ebbc659f669b7ffa10)) - Refactor keyrings and ulpusers ([`705df32`](https://github.com/uilibs/uiprotect/commit/705df32514254b754ebea1ebbc659f669b7ffa10)) ## v6.7.0 (2024-12-07) ### Features - Add keyring and ulp-user ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Add sample data for testing purposes ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Add ulp-user and keyring integration ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Work in progress on adding keyrings functionality ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Add dict_from_unifi_list function and refactor keyrings and ulpusers retrieval ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Define nfc fingerprint support version as constant ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) ### Refactoring - Streamline keyrings and ulpusers handling in protectapiclient ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Replace get_keyrings and get_ulpusers methods with direct dict_from_unifi_list calls ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Update dict_from_unifi_list to use any type for return dictionary ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Rename keyring and ulp_user update methods for clarity and improve message processing ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Improve bootstrap update pop after keyring ulpusr requests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Remove to_snake_case from update_from_dict ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Typed dict_from_unifi_list ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Update dict_from_unifi_list to use protectmodelwithid type ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Consolidate keyring and ulp user message processing into a single method ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Update device key retrieval and add ulp user management tests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Improve object removal and update handling in bootstrap class ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Streamline action handling in bootstrap class ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Remove unused user message processing method in bootstrap class ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Move dict_from_unifi_list function to convert module ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) ### Bug fixes - Conditionally assign keyrings and ulp_users based on nvr version ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Convert keys to snake_case in protectbaseobject data processing ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Enhance type checking for model class in bootstrap and update return type in create_from_unifi_dict ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Improve type handling in bootstrap and convert functions for better type safety ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Enhance type safety by casting keyrings and ulp_users in protectapiclient ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Remove type check for protectmodelwithid and enhance mock data in tests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Add debug logging for unexpected websocket actions and enhance tests for user removal and updates ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Initialize keyrings and ulp_users as empty dictionaries; update return type in dict_from_unifi_list ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) ### Testing - Add websocket tests for keyring add, update, and remove actions ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Add websocket tests for keyring add actions with nfc and fingerprint ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Improve formatting in nfc keyring add test ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Add tests for force update with version checks ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Remove outdated nfc fingerprint support version tests ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) - Remove additional keys from obj_dict in bootstrap test ([`c8a3f4c`](https://github.com/uilibs/uiprotect/commit/c8a3f4c728f888c575d5f3d232149513599e0b5c)) ## v6.6.5 (2024-12-02) ### Bug fixes - Add isthirdpartycamera field to camera model ([`828b510`](https://github.com/uilibs/uiprotect/commit/828b5109f225613f04066eafa1063e6ce715fe3a)) ## v6.6.4 (2024-11-29) ### Bug fixes - Update permission logic for get_snapshot method ([`207959b`](https://github.com/uilibs/uiprotect/commit/207959bf1598acd4ad9e1da1146058b8a18de99c)) - Update permission logic for get_snapshot method ([`207959b`](https://github.com/uilibs/uiprotect/commit/207959bf1598acd4ad9e1da1146058b8a18de99c)) ## v6.6.3 (2024-11-27) ### Bug fixes - Improve partitioned cookie back-compat patching for python 3.13+ ([`a352283`](https://github.com/uilibs/uiprotect/commit/a3522837e283595905907a76671f022b8e680c1d)) ## v6.6.2 (2024-11-24) ### Bug fixes - Bot release token ([`1868448`](https://github.com/uilibs/uiprotect/commit/18684484c76d87d14820f484814097714e71f1a8)) - Update release process to allow the bot to do releases ([`3f7c839`](https://github.com/uilibs/uiprotect/commit/3f7c8393227c9fa78212fa176186027bdb4e9c4d)) - Allow get snapshot with liveonly permissions ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d)) ### Features - Allow snapshots with `read_live` permissions ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d)) - Enable `read_live` permission for `get_package_snapshot` ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d)) ### Testing - Add test data for `read_live` permissions ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d)) - Add unit tests for get_snapshot method ([`b2cf95b`](https://github.com/uilibs/uiprotect/commit/b2cf95b45d3815a2c6c5fab962746e8d9d85388d)) ## v6.6.1 (2024-11-20) ### Bug fixes - Handle indexerror selecting value "below 1 lux" for icr_custom_value ([`41f5a3b`](https://github.com/uilibs/uiprotect/commit/41f5a3b4a5fe8999b742a0b245fef6056f5422a7)) ## v6.6.0 (2024-11-18) ### Features - Add feature flags for nfc and fingerprint ([`e208d9e`](https://github.com/uilibs/uiprotect/commit/e208d9e2a73c9a665ef08e8e9341c183f23969aa)) ## v6.5.0 (2024-11-17) ### Features - Add processing for nfc scan and fingerprint identified events ([`0a58b29`](https://github.com/uilibs/uiprotect/commit/0a58b29cef1b4ab67eb25a765c21fc9cb001d1a4)) ## v6.4.0 (2024-11-04) ### Features - Add support for fetching the rtsp url without srtp ([`7d0cfd3`](https://github.com/uilibs/uiprotect/commit/7d0cfd3fe4ae49e485284886b516fbef3ac76a22)) ## v6.3.2 (2024-10-29) ### Bug fixes - Talkback stream bitrate settings ([`f10dedf`](https://github.com/uilibs/uiprotect/commit/f10dedfc5f1108c42310ac15a929b054f57160b8)) ## v6.3.1 (2024-10-06) ### Bug fixes - Typing with version of propcache older than 1.0.0 ([`94f9eaa`](https://github.com/uilibs/uiprotect/commit/94f9eaaa4c17f33f096099d696074ab59377b1ed)) ## v6.3.0 (2024-10-06) ### Features - Add support for propcache v1.0.0+ ([`b37f833`](https://github.com/uilibs/uiprotect/commit/b37f833ed64906a076e13251eb74f8fce605042f)) ## v6.2.0 (2024-10-03) ### Features - Switch to using fast cached_property from propcache ([`e5ce415`](https://github.com/uilibs/uiprotect/commit/e5ce4153cbb42d3f820d40444c2ede8a56133f5a)) ## v6.1.0 (2024-09-19) ### Bug fixes - Add additional types to device_events ([`072bc7c`](https://github.com/uilibs/uiprotect/commit/072bc7cbc6a8af634f4638ac79658715cb31379a)) - Bump psr to 9.8.8 to fix release process ([`b109433`](https://github.com/uilibs/uiprotect/commit/b1094333c8767dd7588fe0d0f97f4c711b7e2595)) ### Features - Speed up url joins ([`a10fc5a`](https://github.com/uilibs/uiprotect/commit/a10fc5adc88a1cf78199f5ca2e4a995032f58743)) ## v6.0.2 (2024-08-13) ### Bug fixes - Bump aiofiles requirement to >=24 ([`1eb9ea7`](https://github.com/uilibs/uiprotect/commit/1eb9ea7c5fb2036ad0af42eb607604652d1b0210)) ## v6.0.1 (2024-08-09) ### Bug fixes - Simplify ssl verify flag in websocket class ([`c36e19a`](https://github.com/uilibs/uiprotect/commit/c36e19a549c78f4fd123b89f562669fdaa5f78a5)) ## v6.0.0 (2024-08-08) ### Bug fixes - Remove default websocket receive timeout ([`8b0b303`](https://github.com/uilibs/uiprotect/commit/8b0b3033880532ddbf00cb59df881100db273dcb)) ## v5.4.0 (2024-07-20) ### Features - Improve performance of convert_unifi_data ([`45f66b4`](https://github.com/uilibs/uiprotect/commit/45f66b4d6f35cbd02abae21f0905089b0e329d59)) ## v5.3.0 (2024-07-16) ### Features - Speed up camera snapshots ([`d333865`](https://github.com/uilibs/uiprotect/commit/d3338658c2fa714e993c3d668945b44a1e7ebd27)) ## v5.2.2 (2024-07-04) ### Bug fixes - Reflection of chime duration seconds ([`0266b8e`](https://github.com/uilibs/uiprotect/commit/0266b8e2470084df63422d4971c04354710b1ae8)) ## v5.2.1 (2024-07-04) ### Bug fixes - Avoid reflecting back smoke_cmonx when changing smart audio ([`7270a5c`](https://github.com/uilibs/uiprotect/commit/7270a5cb40ed9c83db353677abc0496dc7b59f9e)) ## v5.2.0 (2024-07-03) ### Features - Remove deepcopy before calling update_from_dict ([`23bc68f`](https://github.com/uilibs/uiprotect/commit/23bc68f2ca31c06e224cb5f5600ce87e1c842ec6)) ## v5.1.0 (2024-07-03) ### Features - Small cleanups to smart detect lookups ([`ef21763`](https://github.com/uilibs/uiprotect/commit/ef217638129bc48fb67d9e60fe828f78daf2a017)) ## v5.0.0 (2024-07-02) ### Features - Do not auto convert enums to values for fetching attrs ([`f6d7ead`](https://github.com/uilibs/uiprotect/commit/f6d7eade0e2b1dc4073b5e45f7f2a75909180a30)) ## v4.2.0 (2024-06-27) ### Features - Replace manual dict deletes with convertertools ([`22f7df8`](https://github.com/uilibs/uiprotect/commit/22f7df8852d5dcb252337a3f4620932619b6c5be)) ## v4.1.0 (2024-06-27) ### Features - Avoid the need to deepcopy in the ws stats ([`5318b02`](https://github.com/uilibs/uiprotect/commit/5318b0219c89a1183218c94525fe08319208bc30)) ## v4.0.0 (2024-06-26) ### Features - Remove is_ringing property and ring ping back from camera ([`b400435`](https://github.com/uilibs/uiprotect/commit/b400435366c859d0350a9095ae6e9136afb2b08a)) ## v3.8.0 (2024-06-26) ### Bug fixes - Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3)) - Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3)) - Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3)) - Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3)) - Use id checks for type compares ([`0e54ac6`](https://github.com/uilibs/uiprotect/commit/0e54ac6d82e010a6553c7ee7d42d884e8ec0bbd3)) - Do not swallow asyncio.cancellederror ([`09bc38b`](https://github.com/uilibs/uiprotect/commit/09bc38b419b26c00363b47c5ae8ce0e6a7280133)) - Remove unreachable code ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe)) - Remove unreachable code ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe)) ### Features - Improve websocket error handling ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe)) - Pass existing data to _handle_ws_error instead of creating it again ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe)) - Cleanup duplicate code in _handle_ws_error ([`b70d071`](https://github.com/uilibs/uiprotect/commit/b70d071dc52fa179710134e023c34ac0c8caebbe)) ## v3.7.0 (2024-06-25) ### Features - Small cleanups to packet packing/unpacking ([`00cb125`](https://github.com/uilibs/uiprotect/commit/00cb125e89f5f43f7c759719d5fc581fb631af3c)) - Small cleanups to devices ([`1b64a8e`](https://github.com/uilibs/uiprotect/commit/1b64a8e89259e9d791a9c9703ced088e4fc7622c)) - Cleanup some additional dupe attr lookups ([`24849d8`](https://github.com/uilibs/uiprotect/commit/24849d819cfbba582a0f21c975de895d3754ef3b)) ## v3.6.0 (2024-06-25) ### Features - Reduce some duplicate attr lookups in devices ([`8ea72ea`](https://github.com/uilibs/uiprotect/commit/8ea72eae1c8c0e37206a1268937287b0b1f29b28)) ## v3.5.0 (2024-06-25) ### Features - Use more list/dict comps where possible ([`9c1ef3f`](https://github.com/uilibs/uiprotect/commit/9c1ef3f30b8e1c01edb5a6d44b0126edd9e3610d)) ## v3.4.0 (2024-06-25) ### Features - Reduce duplicate code to do unifi_dict_to_dict conversions ([`f616c52`](https://github.com/uilibs/uiprotect/commit/f616c528cc94a313dd2ac0ba7e302bfcfca4afde)) ## v3.3.1 (2024-06-24) ### Bug fixes - License classifier ([`ac048d7`](https://github.com/uilibs/uiprotect/commit/ac048d7325529823ab7d2840dc63aaa822008b32)) ## v3.3.0 (2024-06-24) ### Features - Skip empty models in unifi_dict ([`d42023f`](https://github.com/uilibs/uiprotect/commit/d42023f9f07d3bdf097669637e1ad754a70ea0b7)) ## v3.2.0 (2024-06-24) ### Features - Refactor internal object tracking ([`ad1b2b4`](https://github.com/uilibs/uiprotect/commit/ad1b2b45f3d72243ca8cb24c326b4f0fcd0bd71f)) ## v3.1.9 (2024-06-24) ### Bug fixes - Remove event is in range check ([`2847f40`](https://github.com/uilibs/uiprotect/commit/2847f402a19655e9dee1d596b331e70b25bf3da3)) ## v3.1.8 (2024-06-23) ### Bug fixes - Small tweaks to compact code ([`aa136ba`](https://github.com/uilibs/uiprotect/commit/aa136badd8ff7dbad6b74fcd1418de5f8ca04d73)) ## v3.1.7 (2024-06-23) ### Bug fixes - Remove unreachable code in the websocket decoder ([`235cdef`](https://github.com/uilibs/uiprotect/commit/235cdef8bf930fc7b86084fc44cccea96fb316ef)) ## v3.1.6 (2024-06-23) ### Bug fixes - Remove unreachable api in data checks ([`c7772a9`](https://github.com/uilibs/uiprotect/commit/c7772a9ecdf8d29290d0ba84e31a6f104fcb1dd1)) - Make creation of update sync primitives lazy ([`b05af57`](https://github.com/uilibs/uiprotect/commit/b05af578a1ed9b30a1c986a13d006fbaf89b760f)) ## v3.1.5 (2024-06-23) ### Bug fixes - Exclude_fields would mutate the classvar ([`1c461e1`](https://github.com/uilibs/uiprotect/commit/1c461e1a481eb1c022c1dc5aa09529fc1abfec0e)) ## v3.1.4 (2024-06-23) ### Bug fixes - Ensure test harness does not delete coveragerc ([`02bd064`](https://github.com/uilibs/uiprotect/commit/02bd0640fc6ce917db180a410ab0d102b6c8c73a)) ## v3.1.3 (2024-06-23) ### Bug fixes - Add test coverage for updating to none ([`b2adeac`](https://github.com/uilibs/uiprotect/commit/b2adeac94fcef09bac8fe06c9795c8a41694ff95)) ## v3.1.2 (2024-06-23) ### Bug fixes - Coveragerc fails to omit cli and tests ([`d1a4052`](https://github.com/uilibs/uiprotect/commit/d1a4052984e8545b5ac876337909ae235813db7f)) ## v3.1.1 (2024-06-22) ### Bug fixes - _raise_for_status when raise_exception is not set ([`0a6ff9e`](https://github.com/uilibs/uiprotect/commit/0a6ff9e358e66058f2f7ca3bff12925f3b1d4e90)) ## v3.1.0 (2024-06-22) ### Features - Add websocket state subscription ([`d7083ab`](https://github.com/uilibs/uiprotect/commit/d7083ab8ced2dc3cc65dcaf6ea2dd8c869e70a96)) ## v3.0.0 (2024-06-22) ### Features - Remove the force flag from update ([`0bee3e6`](https://github.com/uilibs/uiprotect/commit/0bee3e64d8f1a540e6bfde7b3ab282bc26e6f150)) ## v2.3.0 (2024-06-22) ### Features - Handle websocket auth errors on restart ([`7026491`](https://github.com/uilibs/uiprotect/commit/7026491ac909cb2ed2bf3d9457cf86a1a44de025)) ## v2.2.0 (2024-06-22) ### Features - Decrease websocket logging for known errors ([`05df499`](https://github.com/uilibs/uiprotect/commit/05df499863006b8d66d2ca0e3c76c639730e30de)) ## v2.1.0 (2024-06-22) ### Features - Improve websocket error handling ([`813ac9c`](https://github.com/uilibs/uiprotect/commit/813ac9ca2eaefa2623b15f43d9cdf4f3fab31bcb)) ## v2.0.0 (2024-06-22) ### Features - Rework websocket ([`574a846`](https://github.com/uilibs/uiprotect/commit/574a846ff4e34737169b49ec418b4a112fa12f3e)) ## v1.20.0 (2024-06-21) ### Features - Include getter builder utils for fetching ufp object values ([`9056edf`](https://github.com/uilibs/uiprotect/commit/9056edf85ecf8cd59d053411ae18f1d05093d9e5)) ## v1.19.3 (2024-06-21) ### Bug fixes - Pin and drop pydantic compat imports now that pydantic is fixed ([`00adc2c`](https://github.com/uilibs/uiprotect/commit/00adc2cc39cf004e93952a8ef489ef1051c1fb83)) ## v1.19.2 (2024-06-20) ### Bug fixes - Ensure update_from_dict creates the object is it was previously none ([`f268c01`](https://github.com/uilibs/uiprotect/commit/f268c01bac2b9969f10de70dae2295ce87a6f70b)) ## v1.19.1 (2024-06-19) ### Bug fixes - Update broken documentation readme link ([`1580c04`](https://github.com/uilibs/uiprotect/commit/1580c042d04d989e1ebe4b919df3d232ae4e8ae9)) ## v1.19.0 (2024-06-17) ### Features - Simplify websocket stats logic ([`5b01f34`](https://github.com/uilibs/uiprotect/commit/5b01f34b9c5cc8bcb3cae9f274acd687870a4091)) ### Bug fixes - Refactoring error in 83 ([`ed477c2`](https://github.com/uilibs/uiprotect/commit/ed477c288047fd1fba39f51d6e695adb6a72ba08)) ## v1.18.1 (2024-06-17) ### Bug fixes - Ensure camera and chime keys are not included in the base ignored set ([`02ab5f6`](https://github.com/uilibs/uiprotect/commit/02ab5f696db9497610ec6b34739452abdfe6ca68)) - Ignore cameraids for chime updates ([`3a7e48d`](https://github.com/uilibs/uiprotect/commit/3a7e48dea4111eb6b0a6012ffe08cafcd66cf4d6)) ## v1.18.0 (2024-06-17) ### Features - Add repr for websocket packets ([`60dd356`](https://github.com/uilibs/uiprotect/commit/60dd356a233ab183c31375417ded3f6e53427e5d)) ### Refactoring - Avoid writing out some more key converts ([`851c798`](https://github.com/uilibs/uiprotect/commit/851c7987b772a185fd4c448dddd9e180fd4f16da)) ## v1.17.0 (2024-06-17) ### Features - Improve performance of websocket packet processing ([`58df1c3`](https://github.com/uilibs/uiprotect/commit/58df1c3ac1c050c418d6ea6255ce18ad64422168)) ### Refactoring - Remove and consolidate unused code in base ([`523d931`](https://github.com/uilibs/uiprotect/commit/523d931f6a06b7c66fc7af7cdfac2abf8ebaa737)) - Use tuples for all the delete iterators ([`9ec88ce`](https://github.com/uilibs/uiprotect/commit/9ec88ce68ab5c0d9f6cb30175eb4ffd9b4a47d43)) - Cleanup debug ([`7883c24`](https://github.com/uilibs/uiprotect/commit/7883c24c9b9a08e41ec044e943e6fab3b66a56f1)) - Reduce code to remove keys ([`7b496cb`](https://github.com/uilibs/uiprotect/commit/7b496cb72b3b5efffad18bb86f58355e910122e7)) ## v1.16.0 (2024-06-17) ### Features - Refactor protect obj methods to use comprehensions ([`ae4cdb9`](https://github.com/uilibs/uiprotect/commit/ae4cdb914b162c756f8384c0c25f256fbaa634d7)) ## v1.15.0 (2024-06-17) ### Features - Small cleanup to get device functions ([`86f18d8`](https://github.com/uilibs/uiprotect/commit/86f18d8901d8fd9b6e2ebfa9c3926ed1d1d0e45c)) ## v1.14.0 (2024-06-17) ### Features - Optimize update_from_dict ([`1b8ed6d`](https://github.com/uilibs/uiprotect/commit/1b8ed6dc146c0351927eeb15c47373481b3ad40e)) ## v1.13.0 (2024-06-16) ### Features - Improve performance of processing websocket messages ([`84277cb`](https://github.com/uilibs/uiprotect/commit/84277cb3ac8b47e8d6b483ace8e31c0d9b07baad)) ## v1.12.1 (2024-06-16) ### Bug fixes - Ensure ping back messages are called back and empty updates excluded ([`b319dba`](https://github.com/uilibs/uiprotect/commit/b319dba4b88e0a7d7b237ec57f2e89ca46c1cc6c)) ## v1.12.0 (2024-06-16) ### Bug fixes - Add missing eventstats key to stats_keys ([`6c8be31`](https://github.com/uilibs/uiprotect/commit/6c8be3129c763d6ade16c57df01cc79d57190fef)) ### Features - Small cleanups to bootstrap code ([`78e6dbb`](https://github.com/uilibs/uiprotect/commit/78e6dbb8165b97522b7f42d8f9e885f0e23cd1eb)) ## v1.11.1 (2024-06-16) ### Bug fixes - Revert to using protected attrs for property cache ([`f0b259c`](https://github.com/uilibs/uiprotect/commit/f0b259caaf7c990de68f1a51a0bd166f94eb3bf7)) ## v1.11.0 (2024-06-16) ### Features - Speed up bootstrap by adding cached_property ([`c6b746d`](https://github.com/uilibs/uiprotect/commit/c6b746d8e4d961c0fc1f98d693357e9becd26baa)) ## v1.10.0 (2024-06-16) ### Features - Make websocket dataclasses sloted ([`58e42f6`](https://github.com/uilibs/uiprotect/commit/58e42f69b7603ab77ffe170d091051febe22e48f)) ### Performance improvements - Make websocket dataclass sloted ([`58e42f6`](https://github.com/uilibs/uiprotect/commit/58e42f69b7603ab77ffe170d091051febe22e48f)) ## v1.9.0 (2024-06-15) ### Features - Improve performance of websocket message processing ([`d6a6472`](https://github.com/uilibs/uiprotect/commit/d6a6472d3516e27dcfdd2ed3b5d8ca68428e273f)) ## v1.8.0 (2024-06-15) ### Features - Replace some attrs with cached methods ([`fc0fc57`](https://github.com/uilibs/uiprotect/commit/fc0fc5717a171eb705dce4f88dca79509bd889b4)) - Replace some never used attrs with cached methods ([`fc0fc57`](https://github.com/uilibs/uiprotect/commit/fc0fc5717a171eb705dce4f88dca79509bd889b4)) ### Refactoring - Delete unused bootstrap constants ([`0283c45`](https://github.com/uilibs/uiprotect/commit/0283c4564c905bee1b1f82cc4c0280a02e07ec5d)) - Small cleanups to _process_add_packet ([`8fd8280`](https://github.com/uilibs/uiprotect/commit/8fd82800b63c7cb8c70da164dcc3e1853fc170a6)) ## v1.7.2 (2024-06-14) ### Bug fixes - Pingback did not hold a strong reference to the task ([`7b11ce9`](https://github.com/uilibs/uiprotect/commit/7b11ce952a9e2f66fc5ac9ceccd1a21e74c218b9)) ## v1.7.1 (2024-06-14) ### Bug fixes - Refactoring error in _process_add_packet ([`e21516b`](https://github.com/uilibs/uiprotect/commit/e21516b212762955a49d6da66f2f823a1b252ca2)) ## v1.7.0 (2024-06-14) ### Features - Add debug logging when saving device changes ([`1c57d00`](https://github.com/uilibs/uiprotect/commit/1c57d005f8f97c148b70401256929c262ba5a8a1)) - Add debug logging when saving device changes ([`1c57d00`](https://github.com/uilibs/uiprotect/commit/1c57d005f8f97c148b70401256929c262ba5a8a1)) - Add debug logging when saving device changes ([`1c57d00`](https://github.com/uilibs/uiprotect/commit/1c57d005f8f97c148b70401256929c262ba5a8a1)) ### Refactoring - Cleanup duplicate doorbell text code ([`5e3fac8`](https://github.com/uilibs/uiprotect/commit/5e3fac8b862dfe7df83fe7b5b565578f494b8bf1)) - Cleanup duplicate doorbell text code ([`5e3fac8`](https://github.com/uilibs/uiprotect/commit/5e3fac8b862dfe7df83fe7b5b565578f494b8bf1)) ## v1.6.0 (2024-06-14) ### Features - Simplify object conversions ([`feb8236`](https://github.com/uilibs/uiprotect/commit/feb8236d7e1817a604186a493d57511fff455e47)) ## v1.5.0 (2024-06-14) ### Features - Make audio_type a cached_property ([`50d22de`](https://github.com/uilibs/uiprotect/commit/50d22de5bbf03328c307c7710015e6ec62ab6826)) ## v1.4.1 (2024-06-14) ### Bug fixes - Use none instead of ... for privateattr ([`fc06f42`](https://github.com/uilibs/uiprotect/commit/fc06f420b6c4531dd59bfa3db8b53a965409cac0)) ## v1.4.0 (2024-06-14) ### Features - Only process incoming websocket packet model type once ([`57d7c10`](https://github.com/uilibs/uiprotect/commit/57d7c10d3915fbf45dd81a855298530a36b9e3c7)) ## v1.3.0 (2024-06-13) ### Features - Cleanup duplicate object lookups in event processing ([`ec00121`](https://github.com/uilibs/uiprotect/commit/ec001218a39f7ec10bcc18005e59a1130f16f8aa)) ## v1.2.2 (2024-06-13) ### Bug fixes - Restore some unreachable code in _process_device_update ([`c638cd3`](https://github.com/uilibs/uiprotect/commit/c638cd3b087d63279bd8f798bd8831fc2e11a916)) ## v1.2.1 (2024-06-13) ### Bug fixes - Blocking i/o in the event loop ([`36a4355`](https://github.com/uilibs/uiprotect/commit/36a4355170566b9d7cfb1632d9c35c28b693d9ce)) ## v1.2.0 (2024-06-13) ### Features - Avoid fetching and iterating convert keys when empty ([`7c9ae89`](https://github.com/uilibs/uiprotect/commit/7c9ae89ed667bbe3e9ca2f5561489d4b8335180e)) ### Code style - Remove ide workspace files and add the directories for them to the gitignore ([`486e3f9`](https://github.com/uilibs/uiprotect/commit/486e3f92f4d12ab195f0433e599c9eac0f008aef)) ## v1.1.0 (2024-06-12) ### Features - Remove _get_frame_data helper ([`21d6768`](https://github.com/uilibs/uiprotect/commit/21d6768132d553cc9f59e73cc7adbfde02a42915)) ### Refactoring - Consolidate logic to remove keys ([`9da56d2`](https://github.com/uilibs/uiprotect/commit/9da56d2c0f094d31b0cf8cba07c4c07fd96c64ea)) - Use new _event_is_in_range helper in _process_camera_event ([`49e0a67`](https://github.com/uilibs/uiprotect/commit/49e0a67c5f2473ae1a6bfbe3db513a77786a68df)) - Reduce duplicate code to process sensor events ([`78c291b`](https://github.com/uilibs/uiprotect/commit/78c291b76a0cbce1f891f91c9c01236d71edbf81)) ## v1.0.1 (2024-06-11) ### Bug fixes - New cookie flag preventing auth cookie from being stored ([`b6eb7fc`](https://github.com/uilibs/uiprotect/commit/b6eb7fcef23885d734ba0f9031bf15bdbba91bc5)) ## v1.0.0 (2024-06-11) ### Bug fixes - Remove unused is_ready property from the api client ([`c36ee42`](https://github.com/uilibs/uiprotect/commit/c36ee422ddd04f811019d2e99cbb1d6b398eae01)) ### Refactoring - Use internal self._api inside the object ([`c20e7a9`](https://github.com/uilibs/uiprotect/commit/c20e7a9690a15f42ff0f17105141f21b2e6e4020)) ## v0.15.1 (2024-06-11) ### Bug fixes - Missing url param in websocket disconnected error log message ([`60e6511`](https://github.com/uilibs/uiprotect/commit/60e651110ed935bb0c35b09aedbc2253a73c35a4)) ## v0.15.0 (2024-06-11) ### Features - Cache bootstrap on the protectapiclient once it has been initialized ([`185e47f`](https://github.com/uilibs/uiprotect/commit/185e47fed693c5a6f8383cece10c5267dbb7e046)) ## v0.14.0 (2024-06-11) ### Features - Cache parsing of datetimes ([`8b6747a`](https://github.com/uilibs/uiprotect/commit/8b6747ae41d483da7395f49e402e29f68112fe83)) ### Refactoring - Use f-strings in more places ([`22706c8`](https://github.com/uilibs/uiprotect/commit/22706c896121eac3b6847a951ef516f350119072)) ## v0.13.0 (2024-06-11) ### Features - Cleanup processing camera events ([`2c1a266`](https://github.com/uilibs/uiprotect/commit/2c1a266a3f7c290e4ae9724642eb427ca41cabf1)) ## v0.12.0 (2024-06-11) ### Features - Cleanup websocket add/remove packet processing ([`fdf0f6e`](https://github.com/uilibs/uiprotect/commit/fdf0f6eef96c17c0d2afe008444c24ce8fad72ee)) - Use a single function to normalize mac addresses ([`7ce8654`](https://github.com/uilibs/uiprotect/commit/7ce86543d4ec1efa9143839b1b7be1c6dd977ca1)) ## v0.11.0 (2024-06-11) ### Features - Cleanup processing of websocket packets ([`b59e19c`](https://github.com/uilibs/uiprotect/commit/b59e19c13ea48e5ab235090c1b02d8d73c3aac24)) ## v0.10.1 (2024-06-11) ### Bug fixes - Remove useless time check ([`749cfef`](https://github.com/uilibs/uiprotect/commit/749cfef9b44f87397153977c673c577659450a48)) ## v0.10.0 (2024-06-11) ### Features - Improve performance of process websocket packets ([`7b59c98`](https://github.com/uilibs/uiprotect/commit/7b59c98d02d2f874375b168979a1db253da58914)) ## v0.9.0 (2024-06-10) ### Features - Avoid linear searches to process websocket packets ([`86d5f19`](https://github.com/uilibs/uiprotect/commit/86d5f198071b0478b480804d055ed80c88341ee1)) ## v0.8.0 (2024-06-10) ### Features - Guard debug logging that reformats data in the arguments ([`0cfdea8`](https://github.com/uilibs/uiprotect/commit/0cfdea8d27c0a35d71cd98d65120288218f4ca4c)) ### Refactoring - Remove useless .keys() calls ([`ec1fd12`](https://github.com/uilibs/uiprotect/commit/ec1fd129deb06b5d2334d49ccd0b238033c5b904)) ## v0.7.0 (2024-06-10) ### Features - Refactor protect object subtype bucketing ([`e4123ac`](https://github.com/uilibs/uiprotect/commit/e4123ac13015c186f141c1bfec3a7c064bb2d732)) ## v0.6.0 (2024-06-10) ### Features - Small code cleanups ([`f1668ae`](https://github.com/uilibs/uiprotect/commit/f1668ae2c9c9f49f6e703a387159d305c2cba847)) ## v0.5.0 (2024-06-10) ### Features - Memoize enum type check to speed up data conversion ([`73b0c4a`](https://github.com/uilibs/uiprotect/commit/73b0c4a813e99d3f353a8fbf3d8a997158cedf3a)) ## v0.4.1 (2024-06-10) ### Bug fixes - Handle unifi os 4 token change ([`a6aab8f`](https://github.com/uilibs/uiprotect/commit/a6aab8f1eefd631119288f6d29d643f3984c5b0d)) ## v0.4.0 (2024-06-10) ### Features - Avoid parsing last_update_id ([`ac86b13`](https://github.com/uilibs/uiprotect/commit/ac86b13b3efc8fc619471536ea993f3741882264)) ## v0.3.10 (2024-06-10) ### Bug fixes - Add missing doorbellmessagetype image ([`eaed04b`](https://github.com/uilibs/uiprotect/commit/eaed04bbc1697553895a64edc573d1acc9112a1a)) ## v0.3.9 (2024-06-09) ### Bug fixes - Revert global flags check ([`8dc437f`](https://github.com/uilibs/uiprotect/commit/8dc437f38dc4f6f6081d9a8a80f9f295b31bf579)) ## v0.3.8 (2024-06-09) ### Bug fixes - Improve readme and testdata docs ([`90ae6a8`](https://github.com/uilibs/uiprotect/commit/90ae6a8cec7a10c1631b301a5d64c94bffdee16d)) ## v0.3.7 (2024-06-09) ### Bug fixes - Revert pydantic changes for ha compat ([`c7770c1`](https://github.com/uilibs/uiprotect/commit/c7770c135deaa52da078794c67d5e3f5dbe3455d)) ## v0.3.6 (2024-06-09) ### Bug fixes - Switch readthedocs to mkdocs ([`6009f9d`](https://github.com/uilibs/uiprotect/commit/6009f9dbb5beed141a8af866eb6e1dfd081af067)) - More docs fixes ([`52261ef`](https://github.com/uilibs/uiprotect/commit/52261eff11919768d75e73f9f3a85243c7eff90a)) ## v0.3.5 (2024-06-09) ### Bug fixes - Add missing docs deps ([`399de45`](https://github.com/uilibs/uiprotect/commit/399de45721cb72c1cd6c945ad9aa0d73d82dea8f)) ## v0.3.4 (2024-06-09) ### Bug fixes - Small fixes for readme.md ([`7a0acf4`](https://github.com/uilibs/uiprotect/commit/7a0acf4da9cfcc1cbf6111cc9d2083be68aa9d93)) ## v0.3.3 (2024-06-09) ### Bug fixes - Ensure uv is installed for docker image ([`d286198`](https://github.com/uilibs/uiprotect/commit/d286198ce4d26ff5151c9b937058b4c223aa95f2)) ## v0.3.2 (2024-06-09) ### Bug fixes - Docker file ([`8474862`](https://github.com/uilibs/uiprotect/commit/84748626bbe29492997801759164a6242ebf7b72)) - Update typer ([`54f26b1`](https://github.com/uilibs/uiprotect/commit/54f26b16223d0ed83c2e249df458ec5ccc407fb6)) - Make package installable ([`169e790`](https://github.com/uilibs/uiprotect/commit/169e7903bc72ad513f475c5477c0b6f4cd5c7653)) ## v0.3.1 (2024-06-09) ### Bug fixes - Dockerfile ([`b25d8a1`](https://github.com/uilibs/uiprotect/commit/b25d8a1218158368ec50d1a2b20280b94696ccee)) - Docker ci ([`3d8e9fe`](https://github.com/uilibs/uiprotect/commit/3d8e9fe294c7c75a7efc2d2653a51fdb052fbf29)) ## v0.3.0 (2024-06-09) ### Features - Migrate docs ([`1e62ec2`](https://github.com/uilibs/uiprotect/commit/1e62ec204c6d1b26f95486a8c27a61bb40a8219b)) ## v0.2.2 (2024-06-09) ### Bug fixes - Readme updates ([`8cf5d24`](https://github.com/uilibs/uiprotect/commit/8cf5d24915e9aed2ffbdce4390dd061c9c40d4a1)) ## v0.2.1 (2024-06-09) ### Bug fixes - Adjust jinja check for changelog template ([`e5f55c1`](https://github.com/uilibs/uiprotect/commit/e5f55c1f1af84d3f9053bf9b36c3662dab706882)) - Changelog generation ([`2b770e9`](https://github.com/uilibs/uiprotect/commit/2b770e9a4a6ccfa352fd0fc2b30099ef07b59db8)) ## v0.2.0 (2024-06-09) ### Features - Update classifiers ([`0d4eaf6`](https://github.com/uilibs/uiprotect/commit/0d4eaf6e5fe30c83c52d30d388d65ebe33ee7c3f)) ### Unknown ### Bug fixes - Re-enable changelog ([`68620b0`](https://github.com/uilibs/uiprotect/commit/68620b09b65ee553982c2c54bfc1e0a3c6ba4380)) ## v0.1.0 (2024-06-09) ### Bug fixes - Pre-commit auto update ([`27c1514`](https://github.com/uilibs/uiprotect/commit/27c1514064b5b44d13abd57fc5df3f81dc741c78)) - Cli test ([`b2e4e8e`](https://github.com/uilibs/uiprotect/commit/b2e4e8ef3536bbedc8d3765afb4fd3cb45b478ba)) - Only pyupgrade non-typer code ([`8a5f9b6`](https://github.com/uilibs/uiprotect/commit/8a5f9b644b80a2f739bc5d9720e316150e938ab6)) - Ensure workers ([`d7578de`](https://github.com/uilibs/uiprotect/commit/d7578dedd0443f5ce4333475dde06c28882cbfd0)) - Tests in ci ([`f008537`](https://github.com/uilibs/uiprotect/commit/f0085378ac15125e7e75d80daae7876b37fa8b6d)) - Add mypy to dev deps ([`bde29f2`](https://github.com/uilibs/uiprotect/commit/bde29f236622ec8c3756add4ddb8103644b04c8f)) - More mypy fixes ([`f889c50`](https://github.com/uilibs/uiprotect/commit/f889c5061dd3428bf47bcf1294c0117880e3f20b)) - Add more missing types ([`6d959f9`](https://github.com/uilibs/uiprotect/commit/6d959f9f48b0fd14a43468d52abd8593311bfe10)) - Disable some more rules inline ([`03c726f`](https://github.com/uilibs/uiprotect/commit/03c726f0c0594fcde0a4bb93020c1c99dce6a149)) - Add missing types ([`ef87e72`](https://github.com/uilibs/uiprotect/commit/ef87e72b73e1ec5372bc19260916f093f2b2fe45)) - Disable some rules ([`6cfd103`](https://github.com/uilibs/uiprotect/commit/6cfd103beba3d8689f2c9730831efd89bc0fd679)) ### Unknown ## v0.0.0 (2024-06-09) ### Unknown ### Testing - Add tests for set_person_track ([`a00de52`](https://github.com/uilibs/uiprotect/commit/a00de52bda63a822f2ffce53bd9188aa7a91def8)) ### Documentation - Add documentation for set_person_track command ([`a00de52`](https://github.com/uilibs/uiprotect/commit/a00de52bda63a822f2ffce53bd9188aa7a91def8)) ### Bug fixes - Actually set chime_duration ([`e7edd26`](https://github.com/uilibs/uiprotect/commit/e7edd26823505f73e97b1a46e70f397a95126a3f)) ### Features - Make chime duration adjustable ([`b4d13c1`](https://github.com/uilibs/uiprotect/commit/b4d13c146f292eae216109f747d3bee6608b0f28)) uilibs-uiprotect-317a52c/CLAUDE.md000066400000000000000000000000131524637705700166350ustar00rootroot00000000000000@AGENTS.md uilibs-uiprotect-317a52c/CONTRIBUTING.md000066400000000000000000000077121524637705700176240ustar00rootroot00000000000000# Contributing Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given. You can contribute in many ways: ## Types of Contributions ### Report Bugs Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include: - Your operating system name and version. - Any details about your local setup that might be helpful in troubleshooting. - Detailed steps to reproduce the bug. ### Fix Bugs Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. ### Implement Features Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. ### Write Documentation uiprotect could always use more documentation, whether as part of the official uiprotect docs, in docstrings, or even on the web in blog posts, articles, and such. ### Submit Feedback The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature: - Explain in detail how it would work. - Keep the scope as narrow as possible, to make it easier to implement. - Remember that this is a volunteer-driven project, and that contributions are welcome 😊 ## Get Started! Ready to contribute? Here's how to set yourself up for local development. 1. Fork the repo on GitHub. 2. Clone your fork locally: ```shell $ git clone git@github.com:your_name_here/uiprotect.git ``` 3. Install the project dependencies with [Poetry](https://python-poetry.org): ```shell $ poetry install --all-extras ``` `--all-extras` pulls in the optional `cli` extra (`typer`) so the CLI and its tests (`tests/test_cli.py`) work in your environment. 4. Create a branch for local development: ```shell $ git checkout -b name-of-your-bugfix-or-feature ``` Now you can make your changes locally. 5. When you're done making changes, check that your changes pass our tests: ```shell $ poetry run pytest ``` 6. Linting is done through [pre-commit](https://pre-commit.com). Provided you have the tool installed globally, you can run them all as one-off: ```shell $ pre-commit run -a ``` Or better, install the hooks once and have them run automatically each time you commit: ```shell $ pre-commit install ``` 7. Commit your changes and push your branch to GitHub: ```shell $ git add . $ git commit -m "feat(something): your detailed description of your changes" $ git push origin name-of-your-bugfix-or-feature ``` Note: PRs are squash-merged, so the **pull request title** is what lands on `main` and must follow [the conventional commits](https://www.conventionalcommits.org). We run [`amannn/action-semantic-pull-request`](https://github.com/amannn/action-semantic-pull-request) on CI to validate the PR title. Per-commit messages on the PR branch are not linted. 8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed): ```shell $ gh pr create --fill ``` ## Pull Request Guidelines We like to have the pull request open as soon as possible, that's a great place to discuss any piece of work, even unfinished. You can use draft pull request if it's still a work in progress. Here are a few guidelines to follow: 1. Include tests for feature or bug fixes. 2. Update the documentation for significant features. 3. Ensure tests are passing on CI. ## Tips To run a subset of tests: ```shell $ pytest tests ``` ## Making a new release The deployment should be automated and can be triggered from the Semantic Release workflow in GitHub. The next version will be based on [the commit logs](https://python-semantic-release.readthedocs.io/en/latest/commit-log-parsing.html#commit-log-parsing). This is done by [python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/index.html) via a GitHub action. [gh-issues]: https://github.com/uilibs/uiprotect/issues uilibs-uiprotect-317a52c/Dockerfile000066400000000000000000000075131524637705700173640ustar00rootroot00000000000000FROM python:3.13-slim-bookworm AS base LABEL org.opencontainers.image.source=https://github.com/uilibs/uiprotect ENV PYTHONUNBUFFERED=1 ENV UV_SYSTEM_PYTHON=true ARG TARGETPLATFORM RUN addgroup --system --gid 1000 app \ && adduser --system --shell /bin/bash --uid 1000 --home /home/app --ingroup app app FROM base AS builder RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \ apt-get update -qq \ && apt-get install -yqq build-essential git RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ pip install --root-user-action=ignore -U pip uv poetry FROM builder AS prod-builder ARG UIPROTECT_VERSION WORKDIR /tmp/build COPY pyproject.toml poetry.lock ./ RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ poetry config virtualenvs.create false \ && poetry install --only main --no-root --no-interaction --no-ansi COPY . . RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ SETUPTOOLS_SCM_PRETEND_VERSION=${UIPROTECT_VERSION} poetry build -f wheel FROM base AS prod COPY --from=builder /usr/local/bin/uv /usr/local/bin/ COPY --from=prod-builder /tmp/build/dist/*.whl /tmp/ RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ uv pip install -U "uiprotect[cli] @ file://$(echo /tmp/*.whl)" \ && rm /tmp/*.whl COPY .docker/entrypoint.sh /usr/local/bin/entrypoint RUN chmod +x /usr/local/bin/entrypoint \ && mkdir /data \ && chown app:app /data USER app VOLUME /data WORKDIR /data ENTRYPOINT ["/usr/local/bin/entrypoint"] FROM builder AS builder-dev WORKDIR /workspaces/uiprotect COPY pyproject.toml poetry.lock ./ RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ poetry config virtualenvs.create false \ && poetry install --with dev --all-extras --no-root --no-interaction --no-ansi FROM base AS dev # Python will not automatically write .pyc files ENV PYTHONDONTWRITEBYTECODE=1 # Enables Python development mode, see https://docs.python.org/3/library/devmode.html ENV PYTHONDEVMODE=1 COPY --from=builder-dev /usr/local/bin/ /usr/local/bin/ COPY --from=builder-dev /usr/local/lib/python3.13/ /usr/local/lib/python3.13/ COPY ./.docker/docker-fix.sh /usr/local/bin/docker-fix COPY ./.docker/bashrc /root/.bashrc COPY ./.docker/bashrc /home/app/.bashrc RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \ apt-get update -qq \ && apt-get install -yqq git curl vim procps jq sudo \ && echo 'app ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers \ && chown app:app /home/app/.bashrc \ && chmod +x /usr/local/bin/docker-fix ENV PYTHONPATH=/workspaces/uiprotect/ ENV PATH=$PATH:/workspaces/uiprotect/.bin USER app WORKDIR /workspaces/uiprotect/ # ============================================================================= # Development Container (for VS Code devcontainer) # ============================================================================= FROM python:3.13-slim-bookworm AS devcontainer ARG TARGETPLATFORM RUN --mount=type=cache,mode=0755,id=apt-$TARGETPLATFORM,target=/var/lib/apt/lists \ apt-get update -qq \ && apt-get install -yqq curl git build-essential bash locales \ && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ && locale-gen en_US.UTF-8 \ && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 ENV LANG=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8 SHELL ["/bin/bash", "-c"] RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ curl -sSL https://install.python-poetry.org | POETRY_VERSION=2.2.1 python3 - ENV PATH="/root/.local/bin:${PATH}" WORKDIR /workspace ENV POETRY_VIRTUALENVS_CREATE=false COPY pyproject.toml poetry.lock* ./ RUN --mount=type=cache,mode=0755,id=pip-$TARGETPLATFORM,target=/root/.cache \ poetry install --with dev --all-extras --no-root uilibs-uiprotect-317a52c/LICENSE000066400000000000000000000021271524637705700163730ustar00rootroot00000000000000 MIT License Copyright (c) 2024 UI Protect Maintainers Copyright (c) 2020 Bjarne Riis Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. uilibs-uiprotect-317a52c/LIVE_DATA_CI.md000066400000000000000000000120521524637705700176110ustar00rootroot00000000000000# How to make a GHA Workflow to Test Against your UFP Instance ## Fork vs. PR to main Repo It is recommended you do this all on your personal fork. Test it and make it all works. Then if you would like to submit your workflow to the main repo so we can use data from it, make a PR and coorindate with @uilibs to get it merged. **NOTE** If you do choose to make the PR and submit it, we will **not** have access to the sample data that is generated by your NVR. In the event the tests fail, we may request the files from you so we can reproduce any issues. ## Create a self-hosted GHA Runner You can use any method for creating a GHA runner you want. It is just required you use the labels `self-hosted,linux,ufp,YOUR_USERNAME`. To help out, 3 possible install methods are listed below. The only other additional requirement for the GHA runner is that it _must_ be able to communicate directly to your UniFi Protect instance. ### Getting Required variables Before starting any of the 3 options, you need 4 pieces of data: - `REPO_URL`: the URL for the repo the GHA action runner will be for - Either `https://github.com/uilibs/uiprotect` or the URL of your fork - `RUNNER_NAME`: an identifiable name for your runner. Can be anything, but make sure it unique. - `LABELS`: should be `self-hosted,linux,ufp,YOUR_USERNAME` - `RUNNER_TOKEN`: See below #### Generating Runner Token --- ## **NOTE**: If you want your workflow running on the main `uiprotect` repo, you will need to get @uilibs to do this step and give you the token. To create a self-hosted GHA runner, you need owner level access to the repo and then you need to go and generate a time-based auth token to create the runner (token expires in ~1 hour). 1. On your Github repo, Go to Settings -> Actions -> Runners and click "New self-hosted runner" 2. Copy the value after the `--token` argument under the "Configure" section ### Using a Home Assistant Add-on To help make the process as easy as possible, I made a simple Home Assistant add-on that wraps the "Using Docker" method below. 1. Go to "Supervisor -> Add-on Store -> Triple dots in corner -> Repositories" 2. Add the URL `https://github.com/uilibs/ha-addons` 3. Install the new "Github Actions Runner" add-on 4. Click the "Configuration" tab and enter your values from above 5. Start up the add-on `workdir` can be left as the default. It should already be a unique location that will not cause any issues. ### Using Docker You can use the awesome [premade Docker GHA runner](https://github.com/myoung34/docker-github-actions-runner). Just start it in your preferred method. To be able to access the data from outside of the docker container to debug and such, you will also need to make a fodler on the host machine for the runner workflow. Replace `/path/to/host/folder/for/data` for the path to your folder. ```bash docker run --rm -it \ -e REPO_URL=REPO_URL \ -e RUNNER_NAME=RUNNER_NAME \ -e LABELS=LABELS \ -e RUNNER_TOKEN=RUNNER_TOKEN \ -e RUNNER_WORKDIR=/data \ -v /path/to/host/folder/for/data:/data \ myoung34/github-runner:ubuntu-bionic ``` ### Roll your Own Runner If you would prefer to make your own runner to ensure the complete security of your home network. Go for it. The docs for making your own runner can be found on [Github's docs site](https://docs.github.com/en/actions/hosting-your-own-runners/adding-self-hosted-runners) ## Create a Local User in UFP for GHA Go to your UFP instance and create a Local User with admin permissions for GHA to use to access your instance. If you are already using the HA Integration for UFP, you can just reuse those credentials if you want. ## Add Secrets to Github Repo --- ## **NOTE**: If you want your workflow running on the main `uiprotect` repo, you will need to give the secrets to @uilibs and add the environment for you. Next step is to create an "environment" for your workflow to run it with the secrets you will need to connect to your UFP instance. We will also want to lock it down to prevent other users from dumping your secrets. 1. Go to "Settings -> Environments -> New Environment" and name it your Github username. 2. Change "Deployment Branches" to "Selected Branches" and add `master` 3. Under "Environment Secrets" add the following secrets: - `UFP_ADDRESS`: IP or host name to your UFP instance - `UFP_PORT`: Port for your UFP instance - `UFP_SSL_VERIFY`: True or False. Whether or not to verify SSL certs for instance - `UFP_USERNAME`: Username for your local admin user - `UFP_PASSWORD`: Password for your local admin user ## Create a workflow to Test data 1. Copy one of the existing workflows under `.github/workflows/test-live-*.yml` 2. Rename it to `test-live-YOUR_USERNAME.yml` 3. Open the file and change the username in the `name` section at the top 4. Replace the username in the `runs-on` section 5. Replace the username in the `environment` section 6. Replace `/share/gha-runner` in `UFP_SAMPLE_DIR: /share/gha-runner/ufp-data` to match the root directory of your GHA runner you configured above. if you are using the HA Add-on, you may not need to change anything here. uilibs-uiprotect-317a52c/README.md000066400000000000000000000453711524637705700166550ustar00rootroot00000000000000# Unofficial UniFi Protect Python API and CLI

CI Status Documentation Status Test coverage percentage CodSpeed Badge

Poetry Ruff pre-commit

PyPI Version Supported Python versions License

--- **Documentation**: https://uiprotect.readthedocs.io **Source Code**: https://github.com/uilibs/uiprotect --- ## About Python API and CLI for UniFi Protect (Unofficial). This module communicates with UniFi Protect surveillance software installed on a UniFi OS Console such as a Ubiquiti CloudKey+ (Cloud Key Gen2 Plus), a UniFi Network Video Recorder (UNVR or UNVR Pro), or a UniFi Dream Machine Pro, SE, or Pro Max. `uiprotect` is increasingly built on Ubiquiti's official, documented Public Integration API. Where a capability is not yet available there, it falls back to the older private API, which is undocumented and can change as Ubiquiti evolves the software — so those parts may have gaps or shift between firmware releases. The module is primarily written for the purpose of being used in Home Assistant core [integration for UniFi Protect](https://www.home-assistant.io/integrations/unifiprotect) but might be used for other purposes also. Full documentation for the project is available at [uiprotect.readthedocs.io](https://uiprotect.readthedocs.io/). ## Requirements If you want to install `uiprotect` natively, the below are the requirements: - [UniFi Protect](https://ui.com/camera-security) version 7.1+ - The library is generally tested against the latest stable version. - [Python](https://www.python.org/) 3.11+ - POSIX compatible system - [PyAV](https://pyav.org/) (av) - included as a dependency - PyAV is used for audio streaming to camera speakers (talkback feature) Alternatively you can use the [provided Docker container](#using-docker-container), in which case the only requirement is [Docker](https://docs.docker.com/desktop/) or another OCI compatible orchestrator (such as Kubernetes or podman). Windows is **not supported**. If you need to use `uiprotect` on Windows, use Docker Desktop and the provided docker container or [WSL](https://docs.microsoft.com/en-us/windows/wsl/install). ## Installation ### From PyPI `uiprotect` is available on PyPI: ```bash pip install uiprotect ``` To use the command-line interface, install the `cli` extra (it pulls in `typer`): ```bash pip install "uiprotect[cli]" ``` ### From GitHub ```bash pip install git+https://github.com/uilibs/uiprotect.git#egg=uiprotect # with the CLI: pip install "uiprotect[cli] @ git+https://github.com/uilibs/uiprotect.git" ``` ### Using Docker Container A Docker container is also provided, so you do not need to install/manage Python as well. You can add the following to your `.bashrc` or similar. ```bash function uiprotect() { docker run --rm -it \ -e UFP_USERNAME=YOUR_USERNAME_HERE \ -e UFP_PASSWORD=YOUR_PASSWORD_HERE \ -e UFP_ADDRESS=YOUR_IP_ADDRESS \ -e UFP_PORT=443 \ -e UFP_SSL_VERIFY=false \ -e TZ=America/New_York \ -v $PWD:/data ghcr.io/uilibs/uiprotect:latest "$@" } ``` Some notes about the Docker version since it is running inside a container: - You can update at any time using the command `docker pull ghcr.io/uilibs/uiprotect:latest` - Your local current working directory (`$PWD`) will automatically be mounted to `/data` inside of the container. For commands that output files, this is the _only_ path you can write to and have the file persist. - The container supports `linux/amd64` and `linux/arm64` natively. This means it will also work well on macOS or Windows using Docker Desktop. - `TZ` should be the [Olson timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the timezone your UniFi Protect instance is in. - For more details on `TZ` and other environment variables, check the [command line docs](https://uilibs.github.io/uiprotect/latest/cli/) ## Quickstart ### CLI > [!WARNING] > Ubiquiti SSO accounts are not supported and actively discouraged from being used. There is no option to use MFA. You are expected to use local access user. `uiprotect` is not designed to allow you to use your owner account to access the console or to be used over the public internet as both pose a security risk. > [!NOTE] > `uiprotect` is increasingly built on Ubiquiti's official Public Integration API, which authenticates with a console-scoped **API key** instead of a username/password — no SSO, MFA, or owner account involved. New functionality targets this path first, and it is expected to become the primary — and eventually the only — supported authentication method. See [Public-only mode](#public-only-mode) below. ```bash export UFP_USERNAME=YOUR_USERNAME_HERE export UFP_PASSWORD=YOUR_PASSWORD_HERE export UFP_ADDRESS=YOUR_IP_ADDRESS export UFP_PORT=443 # set to true if you have a valid HTTPS certificate for your instance export UFP_SSL_VERIFY=false # Alternatively, use an API key for authentication (required for public API operations) export UFP_API_KEY=YOUR_API_KEY_HERE uiprotect --help uiprotect nvr ``` #### Available CLI Commands **Top-level commands:** - `uiprotect shell` - Start an interactive Python shell with the API client - `uiprotect create-api-key ` - Create a new API key for authentication - `uiprotect get-meta-info` - Get metadata information - `uiprotect generate-sample-data` - Generate sample data for testing - `uiprotect profile-ws` - Profile WebSocket performance - `uiprotect decode-ws-msg` - Decode WebSocket messages **Device management commands:** - `uiprotect nvr` - NVR information and settings - `uiprotect events` - Event management and export - `uiprotect cameras` - Camera management - `uiprotect lights` - Light device management - `uiprotect sensors` - Sensor management - `uiprotect viewers` - Viewer management - `uiprotect liveviews` - Live view configuration - `uiprotect chimes` - Chime management - `uiprotect aiports` - AI port management For more details on any command, use `uiprotect --help`. ### Python UniFi Protect itself is 100% async, so as such this library is primarily designed to be used in an async context. The main interface for the library is the `uiprotect.ProtectApiClient`: ```python from uiprotect import ProtectApiClient # Initialize with username/password protect = ProtectApiClient(host, port, username, password, verify_ssl=True) # Or with API key (required for public API operations) protect = ProtectApiClient(host, port, username, password, api_key=api_key, verify_ssl=True) await protect.update() # this will initialize the protect .bootstrap and open a Websocket connection for updates # get names of your cameras for camera in protect.bootstrap.cameras.values(): print(camera.name) # subscribe to Websocket for updates to UFP def callback(msg: WSSubscriptionMessage): # do stuff unsub = protect.subscribe_websocket(callback) # remove subscription unsub() ``` #### Public-only mode You can also build a client that does no private login at all — just an API key. Private-session entry points (`update()`, `authenticate()`, `get_bootstrap()`) raise `PublicOnlyModeError`; drive everything through `update_public()`, `subscribe_events()`, `subscribe_devices()`, the `get_*_public()` / `update_*_public()` methods, and `get_meta_info()`. A revoked key surfaces as `NotAuthorized`. ```python from uiprotect import ProtectApiClient protect = ProtectApiClient.public_only(host, port, api_key=api_key, verify_ssl=True) await protect.update_public() # work with the public-API device snapshots for siren in await protect.get_sirens_public(): print(siren.name) # the public API exposes no NVR mac; get_console_mac() resolves it out-of-band # via the UniFi-OS /api/system endpoint. For new code, prefer the public-API # primary key (nvr.id) as the device identity rather than the mac. console_mac = await protect.get_console_mac() ``` ## Usage ### Subscribing to events `ProtectApiClient` exposes two parallel websocket contracts. The raw `subscribe_events_websocket` continues to deliver `WSSubscriptionMessage` frames for advanced callers, and the typed `subscribe_events` API delivers `(ProtectEvent, EventChange)` pairs intended for application code. The typed path goes through the Public Integration API, so the `ProtectApiClient` must be configured with an API key and `update_public()` must have been called at least once before calling `subscribe_events`. Use `subscribe_events_and_prime()` to subscribe and prime in one order-independent call. ```python import logging from uiprotect import EventChange, ProtectApiClient, ProtectEvent _LOGGER = logging.getLogger(__name__) protect = ProtectApiClient(..., api_key="...") def on_event(event: ProtectEvent, change: EventChange) -> None: if change is EventChange.STARTED: _LOGGER.info("%s on %s: %s", event.type, event.device_id, event.identity) elif change is EventChange.ENDED: _LOGGER.info("%s ended after %s", event.type, event.end - event.start) # Order-independent: subscribes and primes in the correct order. unsubscribe = await protect.subscribe_events_and_prime(on_event) # ... unsubscribe() ``` Notes: - `subscribe_events` delivers only events whose `EventType` maps to a non-`OTHER` `ProtectEventChannel` (detection / sensor / alarm-hub / access). Administrative events such as `provision`, `factoryReset` and `fwUpdate` are dropped. Callers that need the unfiltered stream should use `subscribe_events_websocket`. - `event.raw` is a permanent escape hatch onto the underlying private-API `Event` model when the public contract does not expose the field you need. In particular, smart-detect _detected attributes_ (license-plate text, face-match name) are **not** available over the public API today, so consumers that need them must fall back to the private path via `event.raw`. - `EventChange.UPDATED` may carry no public-visible delta — diff `event.raw` if you need to know exactly what changed. - `protect.active_events(device_id=...)` returns the in-flight set, derived directly from the public bootstrap cache. Useful for restoring binary-sensor state after a reload — it works before any `subscribe_events` call as long as `update_public()` has primed the cache. - All runtime state is sourced from `public_bootstrap`: lifecycle/active state from `public_bootstrap.events`, credential-event identity from `public_bootstrap.ulp_users` (UniFi Identity), and `event.device_mac` from the bootstrap device stores. All are refreshed by `update_public()` — including automatically on websocket reconnect — and resolve with eventual consistency: an `identity` that resolves to `UnknownIdentity(reason="ulp_user_not_cached")` for a freshly-enrolled ULP user, or a `device_mac` of `None` for a device not yet in the bootstrap, both fill in on the next `update_public()` / reconnect resync. ### Subscribing to device state `subscribe_devices` is the device-side analog of `subscribe_events`: it delivers a typed `ProtectDeviceChange` for each `ADDED` / `UPDATED` / `REMOVED` device over the Public Integration API. Together with `public_bootstrap` (the device snapshot) and `subscribe_events` (detection / sensor events), it gives a thin consumer the three concern-separated primitives it needs without any model-type routing or merge logic of its own. Like `subscribe_events`, it delivers merged public models from the primed cache, so `subscribe_devices` requires `update_public()` to have run first and raises `RuntimeError` otherwise. To avoid encoding that ordering yourself, use `subscribe_devices_and_prime()`, which connects the websocket and primes in the correct order in one call so the prime/subscribe ordering is moot. With the combined helper, updates that arrive while `update_public()` is priming are buffered and replayed onto the fresh snapshot, so the already-connected subscriber does not miss an update from the prime window; with the explicit two-step form the subscriber is registered after priming and starts from the refreshed cache. ```python import logging from uiprotect import DeviceChange, ProtectApiClient, ProtectDeviceChange _LOGGER = logging.getLogger(__name__) protect = ProtectApiClient(..., api_key="...") def on_device(change: ProtectDeviceChange) -> None: if change.change is DeviceChange.UPDATED and "state" in change.changed_fields: _LOGGER.info("%s -> %s", change.device_id, change.model.state) # Order-independent: subscribes and primes in the correct order. unsubscribe = await protect.subscribe_devices_and_prime(on_device) # ...or the explicit two-step form, which must prime before subscribing: # await protect.update_public() # unsubscribe = protect.subscribe_devices(on_device) unsubscribe() ``` Notes: - Each change carries the merged `Public*` model in `change.model` (`None` for `REMOVED`, where only an id / `modelKey` reference is delivered). `change.changed_fields` is populated only for `UPDATED`. - Single and bulk WS envelopes are expanded transparently to one change per device, so consumers never see batched `id` arrays. - Connection / `state` transitions surface as ordinary `UPDATED`s with `state` in `changed_fields` — there is no separate side channel. - The callback must not raise: an exception is caught and logged but otherwise swallowed. - `change.device_mac` resolves with eventual consistency — a device not yet in the bootstrap yields `None` until the next `update_public()` / reconnect resync. - This is device _state_ only. It does not synthesize detection / motion (use `subscribe_events`) and there is no adoption concept folded in. ## Roadmap & limitations The library is moving from the legacy private API to Ubiquiti's official Public Integration API. The private API is considered legacy and is being phased out — new work targets the public API, implemented spec-conformantly: covering the features the spec exposes and staying as close to it as possible. An explicit architectural goal is to keep the library shaped so the [Home Assistant integration](https://www.home-assistant.io/integrations/unifiprotect) can stay thin — capabilities, device models, and events are surfaced here so the integration carries as little logic of its own as possible. ## Contributing Please **open an issue and agree on the approach before implementing** anything — it avoids wasted effort on changes that don't fit the project's direction. > [!IMPORTANT] > **This library does not accept new features built on the private API.** > uiprotect is migrating from the reverse-engineered private API to UniFi's official > Public Integration API. If a capability is missing from the public API, the right > path is to request it from Ubiquiti / wait for it to be exposed there — **not** to > add it on the private path. Issues or PRs that introduce new private-API > functionality will be closed. **Using AI? Fine — but you have to drive it.** We use AI tooling ourselves, so an unreviewed AI-generated PR or issue doesn't save us anything; it just shifts the review and cleanup cost onto us. AI-assisted contributions are welcome **only when you genuinely understand the architecture and the project's strategic direction, and the approach has been agreed in an issue first.** Where a contribution actually helps is the part AI can't supply — often because it involves a device none of the maintainers happen to own. We have plenty of UniFi hardware, just not every model, so testing and validation on a device we don't have, sanitized payload captures from it, or first-hand knowledge of how it behaves in the field are genuinely valuable — shaped to fit the architecture (see [`AGENTS.md`](https://github.com/uilibs/uiprotect/blob/main/AGENTS.md)). Raw AI output that skips the prior discussion or ignores these guidelines just creates review burden and will be closed. ### Developer Setup The recommended way to develop is using the provided **devcontainer** with VS Code: 1. Install [VS Code](https://code.visualstudio.com/) and the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 2. Open the project in VS Code 3. When prompted, click "Reopen in Container" (or use Command Palette: "Dev Containers: Reopen in Container") 4. The devcontainer will automatically set up Python, Poetry, pre-commit hooks, and all dependencies Alternatively, if you want to develop natively without devcontainer: ```bash # Install dependencies (--all-extras installs the cli extra for CLI tests) poetry install --with dev --all-extras # Install pre-commit hooks poetry run pre-commit install --install-hooks # Run tests poetry run pytest # Run pre-commit checks manually poetry run pre-commit run --all-files ``` ## History This project was split off from `pyunifiprotect` because that project changed its license to one that would not be accepted in Home Assistant. This project is committed to keeping the MIT license. ## Credits - Bjarne Riis ([@briis](https://github.com/briis/)) for the original pyunifiprotect package - Christopher Bailey ([@AngellusMortis](https://github.com/AngellusMortis/)) for the maintaining the pyunifiprotect package uilibs-uiprotect-317a52c/SECURITY.md000066400000000000000000000054321524637705700171610ustar00rootroot00000000000000# Security Policy ## Reporting a vulnerability Please report security vulnerabilities privately through GitHub's [private vulnerability reporting][gh-report] for this repository. That route sends the report directly to the maintainers and lets us coordinate a fix, a CVE, and a release before public disclosure. **Do not** open a regular GitHub issue, a pull request, or post to a public channel (mailing list, chat room, Stack Overflow, etc.) for a suspected vulnerability. The library holds credentials for users' UniFi Protect consoles, so a bug class disclosed in a public PR title is a credible information leak even before a patch ships. If you are unsure whether something is a vulnerability, use the private report — we would rather see a false alarm than a public one. We aim to acknowledge new reports within a few business days. [gh-report]: https://github.com/uilibs/uiprotect/security/advisories/new ## Supported versions Security fixes are released against the latest version on PyPI. Older releases are not maintained — please upgrade to the current release before reporting, and confirm the issue still reproduces there. ## Scope `uiprotect` is an async Python client for the UniFi Protect surveillance NVR. It authenticates against a Protect console with the user's credentials and parses REST responses plus a binary WebSocket update stream whose wire formats are not publicly documented and shift across firmware releases. In-scope issues include: - Memory-safety, parsing, or denial-of-service issues triggered by crafted server responses reaching `ProtectApiClient`, the WebSocket decoder in `data/websocket.py`, the JSON → pydantic-model conversion in `data/convert.py`, or any of the pydantic models under `src/uiprotect/data/`. - Credential or session-token handling bugs: logging or serialising credentials, leaking the API token into logs or exceptions, TLS validation that can be silently disabled, or authentication state surviving where it should be cleared. - Logic bugs that cause the client to act on behalf of a different user, expose data across NVR accounts, or persist credentials to disk in unexpected locations. - Issues in the build / packaging pipeline (wheel contents, release flow) that could lead to a compromised wheel on PyPI. Out of scope: - Risks inherent to handing the library credentials for a UniFi Protect console — it is a client that drives the Protect API on the user's behalf and inherits whatever permissions those credentials carry. - Vulnerabilities in the upstream UniFi Protect server itself, or in Ubiquiti firmware. Report those to Ubiquiti through their published security channels, not here. - Misconfiguration of a downstream application that uses the library (for example, the Home Assistant `unifiprotect` integration). uilibs-uiprotect-317a52c/TESTDATA.md000066400000000000000000000031711524637705700171210ustar00rootroot00000000000000# Generating Sample Data to help with Testing/Features ## Setup Python ### With Home Assistant (via the `unifiprotect` integration) 1. Make sure you have the _Community_ [SSH & Web Terminal Add-On](https://github.com/hassio-addons/addon-ssh) install 2. Open an SSH or Web terminal to the add-on 3. Run ```bash docker exec -it homeassistant bash ``` Use `/config/ufp-data` for your `-o` argument below. ### Without Home Assistant 1. Ensure Python 3.11+ is installed 2. Install uiprotect by issuing this command: `pip install uiprotect` Use `./ufp-data` for your `-o` argument below. ## Generate Data Inside the Python environment from above, run the following command. If you are using Home Assistant, use `-o /config/ufp-data` so it will output data in your config folder to make it easy to get off of your HA instance. ```bash uiprotect generate-sample-data -o /path/to/ufp-data --actual -w 300 -v -U your-unifi-protect-username -P your-unifi-protect-password -a ip-address-to-unifi-protect ``` This will generate a ton of data from your UniFi Protect instance for 5 minutes. During this time, go do stuff with your sensor to trigger events. When it is all done, you will have a bunch of json files in `/path/to/ufp-data`. Download those and zip them up and send them to us. It is recommended that you _do not_ post these files publicly as they do have some sensitive data in them related to your UniFi Network. If you would like you manually clean out the sensitive data from these files, feel free. The most critical data for you to remove are the `authUserId`, `accessKey`, and `users` keys from the `sample_bootstrap.json` file. uilibs-uiprotect-317a52c/docs/000077500000000000000000000000001524637705700163145ustar00rootroot00000000000000uilibs-uiprotect-317a52c/docs/api.md000066400000000000000000000015341524637705700174120ustar00rootroot00000000000000--- hide: - navigation toc_depth: 3 --- # API Reference Rendering options (hidden sources, signature annotations, heading level) are configured globally for the `mkdocstrings` Python handler in `mkdocs.yml`. ## API Client (`uiprotect.api`) ::: uiprotect.api ## Data Models (`uiprotect.data`) ::: uiprotect.data ## Enums and Types (`uiprotect.data.types`) ::: uiprotect.data.types ## Public Bootstrap (`uiprotect.data.public_bootstrap`) ::: uiprotect.data.public_bootstrap ## Public Devices (`uiprotect.data.public_devices`) ::: uiprotect.data.public_devices ## Events (`uiprotect.events`) ::: uiprotect.events ## Exceptions (`uiprotect.exceptions`) ::: uiprotect.exceptions ## Stream (`uiprotect.stream`) ::: uiprotect.stream ## Utils (`uiprotect.utils`) ::: uiprotect.utils ## Websocket (`uiprotect.websocket`) ::: uiprotect.websocket uilibs-uiprotect-317a52c/docs/cli.md000066400000000000000000000725241524637705700174170ustar00rootroot00000000000000--- hide: - navigation --- # Command Line The `uiprotect` command is provided to give a CLI interface to interact with your UniFi Protect instance as well. All commands support JSON output so it works great with `jq` for complex scripting. ## Authentication Following traditional [twelve factor app design](https://12factor.net/), the preferred way to provided authentication credentials to provided environment variables, but CLI args are also supported. !!! warning "About Ubiquiti SSO accounts" Ubiquiti SSO accounts are not supported and actively discouraged from being used. There is no option to use MFA. You are expected to use local access user. `uiprotect` is not designed to allow you to use your owner account to access the your console or to be used over the public Internet as both pose a security risk. ### Environment Variables ```bash export UFP_USERNAME=YOUR_USERNAME_HERE export UFP_PASSWORD=YOUR_PASSWORD_HERE export UFP_ADDRESS=YOUR_IP_ADDRESS export UFP_PORT=443 # optional: set to false if you have a self-signed certificate # export UFP_SSL_VERIFY=false uiprotect nvr ``` ### CLI Args ```bash uiprotect -U YOUR_USERNAME_HERE -P YOUR_PASSWORD_HERE -a YOUR_IP_ADDRESS -p 443 --no-verify-ssl nvr ``` ## Timezones A number of commands allow you to enter a datetime as an argument or output files with the datetime in the filename. As a result, it is very important for `uiprotect` to know your consoles local timezone. If you on a physical machine (not docker/VM), chances are this is already set up correctly for you (`/etc/localtime`), but otherwise you may need to set the `TZ` environment variable. `TZ` can also be used to override your system timezone as well if for whatever reason you need to. It should be the [Olson timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the timezone that your UniFi Protect Instance is in. ```bash TZ=America/New_York uiprotect --help ``` ## Reference ```bash $ uiprotect --help Usage: uiprotect [OPTIONS] COMMAND [ARGS]... UniFi Protect CLI ``` ### Options | | Option | Required? | Env | Type | Default | Description | | ---- | ------------------------------ | ------------------ | -------------- | -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- | | `-U` | `--username` | :white_check_mark: | `UFP_USERNAME` | text | | UniFi Protect username | | `-P` | `--password` | :white_check_mark: | `UFP_PASSWORD` | text | | UniFi Protect password | | `-a` | `--address` | :white_check_mark: | `UFP_ADDRESS` | text | | UniFi Protect IP address or hostname | | `-p` | `--port` | | `UFP_PORT` | integer | `443` | UniFi Protect port | | | `--verify-ssl/--no-verify-ssl` | | | boolean | `True` | Verify SSL certificate. Use `--no-verify-ssl` for self-signed certificates. Will prompt to disable if verification fails. | | | `--output-format` | | | `json`,`plain` | `plain` | Preferred output format. Not all commands support both JSON and plain and may still output in one or the other. | | `-u` | `--include-unadopted` | | | | | Include devices not adopted by this NVR. | | | `--show-completion` | | | | | Show completion for the current shell, to copy it or customize the installation. | | | `--help` | | | | | Show help message and exit. | ### Subcommands For any subcommand you can use `uiprotect COMMAND --help` Commands are split between the reverse-engineered **private** API (username/password auth) and Ubiquiti's documented **Public Integration API** (API-key auth — see [Public vs. private API](usage.md#public-vs-private-api)). The API column marks which is which; note that `viewers` (private) and `viewers-public` (public) are distinct command groups. `Hybrid` groups still select the device from the private bootstrap, but most of their write commands issue their PATCH through the Public Integration API. They therefore need username/password auth for the lookup _and_ an API key for those writes. | Command | API | Description | | ---------------------- | ------- | ---------------------------------------------------------------- | | `aiports` | Private | AiPort device CLI. | | `arm` | Public | Arm profile and alarm commands. | | `backup` | Private | [Backup CLI](#backup-cli). | | `bridges` | Public | Bridge commands. | | `cameras` | Hybrid | Camera device CLI. | | `chimes` | Private | Chime device CLI. | | `create-api-key` | Private | Create a new API key for the current user. | | `decode-ws-msg` | — | Decodes a base64 encoded UniFi Protect Websocket binary message. | | `events` | Private | Events CLI. | | `files-public` | Public | Device asset file commands. | | `fobs` | Public | Key fob commands. | | `generate-sample-data` | Private | Generates sample data for UniFi Protect instance. | | `get-meta-info` | Public | Get metadata about the current UniFi Protect instance. | | `lights` | Hybrid | Lights device CLI. | | `link-stations` | Public | Link station and alarm hub commands. | | `liveviews` | Public | Liveview commands. | | `nvr` | Private | NVR device CLI. | | `profile-ws` | Private | Profiles Websocket messages for UniFi Protect instance. | | `relays` | Public | Relay commands. | | `sensors` | Hybrid | Sensors device CLI. | | `shell` | Private | Opens iPython shell with Protect client initialized. | | `sirens` | Public | Siren commands. | | `speakers` | Public | Speaker commands. | | `ulp-users-public` | Public | UniFi Identity (ULP) user commands. | | `users-public` | Public | Protect user commands. | | `viewers` | Private | Viewers device CLI. | | `viewers-public` | Public | Viewer commands. | #### Multiple Item CLI Commands All adoptable device CLIs and the event CLI work on the idea you have multiple cameras, multiple lights or multiple events. As such, they have four variations: ```bash # list all devices (or events) uiprotect cameras # list short list of all devices (or events) uiprotect cameras list-ids # list a specific device (or event) uiprotect cameras DEVICE_ID # run a command against a specific device (or event) uiprotect cameras DEVICE_ID COMMAND ``` !!! note The "list all devices" and "list a specific device" commands always return raw JSON. These commands can be paired with [jq](https://stedolan.github.io/jq/) to parse and quick extra device data from them. | Command | Description | | ------------------ | ------------------------------------------------------------------------ | | `list-ids` | Requires no device ID. Prints list of "id name" for each device. | | `set-person-track` | Requires device ID. Sets person auto tracking on or off for PTZ cameras. | ##### Examples ###### List All Cameras === "Plain" ```bash $ uiprotect cameras list-ids 61b3f5c7033ea703e7000424: G4 Bullet 61f9824e004adc03e700132c: G4 PTZ 61be1d2f004bda03e700ab12: G4 Dome ``` === "JSON" ```bash $ uiprotect --output-format json cameras list-ids [ [ "61b3f5c7033ea703e7000424", "G4 Bullet" ], [ "61f9824e004adc03e700132c", "G4 PTZ" ], [ "61be1d2f004bda03e700ab12", "G4 Dome" ], ... ] ``` ###### Check if a Camera is Online ```bash $ uiprotect cameras 61ddb66b018e2703e7008c19 | jq .isConnected true ``` ###### Take Snapshot of Camera ```bash $ uiprotect cameras 61ddb66b018e2703e7008c19 save-snapshot output.jpg ``` #### Adoptable Devices CLI Commands Adoptable devices (Cameras, Chimes, Lights, Sensors, Viewers) all have some commands in common. | Command | Description | | -------------- | ------------------------------------------------- | | `adopt` | Adopts a device. | | `bridge` | Returns bridge device if connected via Bluetooth. | | `is-bluetooth` | Returns if the device has Bluetooth or not. | | `is-wifi` | Returns if the device has WiFi or not. | | `is-wired` | Returns if the device is wired or not. | | `protect-url` | Gets UniFi Protect management URL. | | `reboot` | Reboots the device. | | `unadopt` | Unadopt/Unmanage adopted device. | | `update` | Updates the device. | #### Liveviews CLI The `liveviews` command group is driven by the Public Integration API and authenticates with an API key only (no username/password required). It exposes four subcommands: | Command | Description | | -------- | -------------------------------------------- | | `list` | List all liveviews. | | `show` | Show a single liveview by ID. | | `create` | Create a new liveview. | | `update` | Patch an existing liveview (partial update). | ```bash # list all liveviews uiprotect liveviews list # show a single liveview uiprotect liveviews show LIVEVIEW_ID # create a new liveview uiprotect liveviews create \ --name "Garage" \ --owner USER_ID \ --layout 4 \ --slots '[{"cameras":["CAMERA_ID"],"cycleMode":"motion","cycleInterval":10}]' # patch an existing liveview uiprotect liveviews update LIVEVIEW_ID --name "New name" ``` `--slots` takes a JSON array of slot objects with keys `cameras`, `cycleMode` (`motion` or `time`), and `cycleInterval`. `--layout` must be between 1 and 26. See `uiprotect liveviews --help` (and `--help` on each subcommand) for the full flag list. #### Public Integration API Device Groups The following command groups are driven by Ubiquiti's documented [Public Integration API](usage.md#public-vs-private-api) and authenticate with an API key (set `UFP_API_KEY`, or create one with `uiprotect create-api-key`), not username/password. Each exposes `list` and (where applicable) `show` subcommands plus group-specific actions; run `uiprotect --help` for the full flag list. | Group | Common subcommands | | ------------------ | ----------------------------------------------------------------------------- | | `arm` | `list`, `status`, `set-profile`, `enable-alarm`, `disable-alarm`, `trigger` | | `bridges` | `list`, `show`, `set-name` | | `fobs` | `list`, `show`, `set-name` | | `files-public` | `list`, `upload` | | `link-stations` | `list`, `show`, `set-name`, `trigger-output` | | `relays` | `list`, `show`, `activate`, `set-name`, `set-status-light` | | `sirens` | `list`, `show`, `play`, `stop`, `test-sound`, `set-volume` | | `speakers` | `list`, `show`, `set-name`, `set-volume`, `set-mic-volume`, `set-mic-enabled` | | `users-public` | `list`, `show` | | `ulp-users-public` | `list`, `show` | | `viewers-public` | `list`, `show`, `set-name`, `set-liveview` | ```bash # list public-API sirens (API key auth) uiprotect sirens list # rename a viewer over the public API uiprotect viewers-public set-name VIEWER_ID "Living Room" ``` The top-level `uiprotect get-meta-info` command is also Public-API driven. `uiprotect create-api-key NAME` runs against the private, session-authenticated (username/password) surface — you must already have a private session to mint an API key. #### Backup CLI ```bash $ uiprotect backup --help Usage: uiprotect backup [OPTIONS] COMMAND [ARGS]... Backup CLI. The backup CLI is still very WIP in progress and consider experimental and potentially unstable (interface may change in the future). ``` ##### Backup Options | | Option | Env | Type | Default | Description | | ---- | ----------------- | ------------------- | -------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `-s` | `--start` | `UFP_BACKUP_START` | datetime | | Cutoff for start of backup. Defaults to start of recording for NVR. | | `-e` | `--end` | `UFP_BACKUP_END` | datetime | | Cutoff for end of backup. Defaults to now. | | | `--output-folder` | `UFP_BACKUP_OUTPUT` | path | `$PWD` | Base dir for creating files. Defaults to $PWD. | | | `--thumb-format` | | text | `{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}thumb.jpg` | Filename format to save event thumbnails to. Set to empty string ("") to skip saving event thumbnails. | | | `--gif-format` | | text | `{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}animated.gif]` | Filename format to save event gifs to. Set to empty string ("") to skip saving event gif. | | | `--event-format` | | text | `{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}.mp4` | Filename format to save event gifs to. Set to empty string ("") to skip saving event videos. | | | `--title-format` | | text | `{time_sort_pretty_local} {sep} {camera_name} {sep} {event_type_pretty} {sep} {length_pretty}` | Format to use to tag title for video metadata. | | `-v` | `--verbose` | | boolean | `False` | Debug logging. | | `-d` | `--max-download` | | integer | `5` | Max number of concurrent downloads. Adds additional loads to NVR. | | | `--page-size` | | integer | `1000` | Number of events fetched at once from local database. Increases memory usage. | | | `--length-cutoff` | | integer | `3600` | Event size cutoff for detecting abnormal events (in seconds). | | | `--sep` | | boolean | `-` | Separator used for formatting. | | | `--help` | | | | Show help message and exit. | ##### File Name and Title Formatting There are [5 options](#backup-options) controlling output format for file names and metadata. This allows you to customize backups to your liking. All 5 options are a template string. Here are all of the available templating variables: | Variable | Description | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | `year` | UTC year of start of export. | | `month` | UTC month of start of export. | | `day` | UTC day of start of export. | | `hour` | UTC hour of start of export. | | `minute` | UTC minute of start of export. | | `datetime` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted UTC datetime of start of export. Uses `sep` between parts. | | `date` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted UTC date of start of export. Uses `sep` between parts. | | `time` | UTC time of start of export. Uses `sep` between parts. 24 hour time. | | `time_sort_pretty` | UTC time of start of export. Uses `:` between parts. 24 hour time. | | `time_pretty` | UTC time of start of export. Uses `:` between parts. 12 hour time with AM/PM. | | `year_local` | [Local](#timezones) year of start of export. | | `month_local` | [Local](#timezones) month of start of export. | | `day_local` | [Local](#timezones) day of start of export. | | `hour_local` | [Local](#timezones) hour of start of export. | | `minute_local` | [Local](#timezones) minute of start of export. | | `datetime_local` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted [Local](#timezones) datetime of start of export. Uses `sep` between parts. | | `date_local` | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted [Local](#timezones) date of start of export. Uses `sep` between parts. | | `time_local` | [Local](#timezones) time of start of export. Uses `sep` between parts. 24 hour time. | | `time_sort_pretty_local` | [Local](#timezones) time of start of export. Uses `:` between parts. 24 hour time. | | `time_pretty_local` | [Local](#timezones) time of start of export. Uses `:` between parts. 12 hour time with AM/PM. | | `mac` | MAC address of camera. | | `camera_name` | Name of camera. | | `camera_slug` | Lowercased name of camera with spaces replaced with `sep`. | | `event_type` | Lowercased name of the event exported. | | `event_type_pretty` | More human readable name of event exported. | | `length_pretty` | Human readable version of the length of the clip exported. | | `sep` | Separator to use in many cases. | ###### Datetimes All datetimes for the Backup CLi can either be in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format or can be a human readable format that the Python library [dateparse](https://github.com/scrapinghub/dateparser) can understand. This will allow relative datetimes to be passed, such as `"1 hour ago"` which will make backing up incremental for cron jobs. ###### Formatting for Plex You are able to export your Camera events and then access them in [Plex](https://www.plex.tv/) relatively well. For setup in Plex, the following is recommended: - Enable the "Local Media Assets" Agent Source for the Movies Library Type (Settings -> Agents -> Movies). [Plex docs](https://support.plex.tv/articles/200265246-personal-media-movies/). - Create a "Other Videos" library pointing to the same folder as your [--output-folder](#backup-options) folder. - Scanner: "Plex Video Files Scanner" - Agent: "Personal Media" Recommended formats for the backup command: | Option | Format | | ---------------- | ----------------------------------------------------------------- | | `--thumb-format` | `{year_local}/{month_local}/{day_local}/{hour_local}/{title}.jpg` | | `--gif-format` | `{year_local}/{month_local}/{day_local}/{hour_local}/{title}.gif` | | `--event-format` | `{year_local}/{month_local}/{day_local}/{hour_local}/{title}.mp4` | | `--title-format` | `default` or whatever you want the title to be in Plex. | ##### Backing Up Camera Events ```bash $ uiprotect backup events --help Usage: uiprotect backup events [OPTIONS] Backup thumbnails and video clips for camera events. ``` | | Option | Type | Default | Description | | ---- | -------------- | ----------------------------------- | ----------------------------------- | ----------------------------------------------------------- | | `-t` | `--event-type` | `motion`, `ring`, `smartDetectZone` | `motion`, `ring`, `smartDetectZone` | Events to export. Can be used multiple time. | | `-m` | `--smart-type` | `person`, `vehicle`, `package` | `person`, `vehicle`, `package` | Smart Detection types to export. Can be used multiple time. | | `-p` | `--prune` | boolean | `False` | Prune events older then start. | | `-f` | `--force` | boolean | `False` | Force update all events and redownload all clips. | | `-v` | `--verify` | boolean | `False` | Verifies files on disk. | | | `--no-input` | boolean | `False` | Disables confirmation prompt if `-p` and `-f` both passed. | | | `--help` | | | Show help message and exit. | The `backup events` command essentially mirrors all of the selected events from your UniFi Protect instance into a local sqlite database (`events.db` inside of the `--output-folder`). As a result, the initial run make take a _really long time_ to run if your UniFi Protect instance has a lot of events inside of it. As an example using a UniFi Protect instance with ~200k events and ~8 months of camera footage: - Building the database is in the range of hours - Doing the initial download of event thumbnails, gifs and video clips is in the range of tens of hours (potentially 1-2 days) - Incremental or targeted backups are much faster (<1 per event) !!! note "Cron Usage" For incremental backups in crons, it is recommended you run the command with an absolute start first to build your events database and do an initial download of files. This will significantly speed up the incremental backup commands. ##### Examples ###### Backup All Events ```bash uiprotect backup events ``` ###### Backup All Smart Detections for the Past Hour ```bash uiprotect backup --start "1 hour ago" events -t smartDetectZone ``` ###### Backup All Person Smart Detections from December 31st at 10PM to January 1st at 5AM ```bash uiprotect backup --start "2021-12-31T22:00:00" --end "2022-1-1T05:00:00" events -t smartDetectZone -m person ``` #### Camera CLI Inherits [Multiple Item CLI Commands](#multiple-item-cli-commands) and [Adoptable Devices CLI Commands](#adoptable-devices-cli-commands). ##### Examples ###### Take Snapshot of Camera ```bash $ uiprotect cameras 61ddb66b018e2703e7008c19 save-snapshot output.jpg ``` ###### Export Video From Camera ```bash $ uiprotect cameras 61ddb66b018e2703e7008c19 save-video export.mp4 2022-6-1T00:00:00 2022-6-1T00:00:30 ``` !!! note "Timezones" See the section on [Timezones](#timezones) for determining what timezone your datetimes are in. ###### Play Audio File to Cameras Speaker ```bash $ uiprotect cameras 61ddb66b018e2703e7008c19 play-audio test.mp3 ``` ###### Include Unadopted Cameras in list ```bash $ uiprotect -u cameras list-ids ``` ###### Adopt an Unadopted Camera ```bash $ uiprotect -u cameras 61ddb66b018e2703e7008c19 adopt ``` ###### Enable SSH on Camera ```bash $ uiprotect cameras 61ddb66b018e2703e7008c19 set-ssh true # get current value to verify $ uiprotect cameras 61ddb66b018e2703e7008c19 | jq .isSshEnabled true ``` ###### Reboot Camera ```bash $ uiprotect cameras 61ddb66b018e2703e7008c19 reboot ``` ###### Reboot All Cameras ```bash for id in $(uiprotect cameras list-ids | awk '{ print $1 }'); do uiprotect cameras $id reboot done ``` #### Chime CLI Inherits [Multiple Item CLI Commands](#multiple-item-cli-commands) and [Adoptable Devices CLI Commands](#adoptable-devices-cli-commands). ##### Examples ###### Set Paired Cameras ```bash $ uiprotect chimes 6275b22e00e3c403e702a019 cameras 61ddb66b018e2703e7008c19 61f9824e004adc03e700132c ``` uilibs-uiprotect-317a52c/docs/contributing.md000066400000000000000000000000541524637705700213440ustar00rootroot00000000000000{% include-markdown "../CONTRIBUTING.md" %} uilibs-uiprotect-317a52c/docs/dev.md000066400000000000000000000171051524637705700174200ustar00rootroot00000000000000--- hide: - navigation --- # Development ## Setup ### With VS Code Development with this project is designed to be done via VS Code + Docker. It is a pretty standard Python package, so feel free to use anything else, but all documentation assumes you are using VS Code. - [VS Code](https://code.visualstudio.com/) + [Remote Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) - [Docker](https://docs.docker.com/get-docker/) - If you are using Linux, you need Docker Engine 19.0 or newer and you need to enable [Docker Buildkit](https://docs.docker.com/develop/develop-images/build_enhancements/) - If you are using Docker Desktop on MacOS or Windows, you will need Docker Desktop 3.2.0 or newer Once you have all three setup, 1. Clone repo 2. Open the main folder 3. You should be prompted to "Reopen folder to develop in a container". If you are not, you can open the [Command Palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) run the "Remote-Containers: Reopen in Container" command. This should be all you need to do to get a working development environment. The docker container will automatically be build and VS Code will attach itself to it. The integrated terminal in VS Code will already be set up with the `uiprotect` command. ### Docker (without VS Code) You can still setup develop without VS Code, but it is still recommended to use the development container to ensure you have all of the required dependencies. As a result, the above requirement for Docker is still needed. Once you have Docker setup, 1. Clone repo 2. Build and open dev container ```bash docker buildx build -f Dockerfile --target=dev -t uiprotect-dev . docker run --rm -it -v "$(pwd)":/workspaces/uiprotect uiprotect-dev bash ``` ## Authenticating with your Local Protect Instance The project allows you to create an environment file to put your local protect instance data into so you do not need to constantly enter in or accidentally commit it to the Git repo. Make a file in the root of the project named `.env` with the following and change accordingly: ``` UFP_USERNAME=YOUR_USERNAME_HERE UFP_PASSWORD=YOUR_PASSWORD_HERE UFP_ADDRESS=YOUR_IP_ADDRESS UFP_PORT=443 # set to true if you have a valid HTTPS certificate for your instance UFP_SSL_VERIFY=false ``` ## Linting and Testing The following scripts exist to easily format, lint and test code in the same fashion as CI: ``` pre-commit run --all-files .bin/test-code ``` These commands are also all available as [VS Code tasks](https://code.visualstudio.com/Docs/editor/tasks) as well. Tests are also fully integration with the Testing panel in VS Code and can be easily debug from there. If you are not using the dev container, the same checks run under Poetry: ```bash poetry install --all-extras poetry run pre-commit run --all-files poetry run pytest ``` ## Updating Requirements To regenerate the pinned release cache used for testing and CI, run the `.bin/update-release-cache` script. There is also a [VS Code task](https://code.visualstudio.com/Docs/editor/tasks) to run this as well. ## Generating Test Data All of the tests in the project are ran against that is generated from a real UniFi Protect instance and then anonymized so it is safe to commit to a Git repo. To generate new sample test data: ``` uiprotect generate-sample-data ``` This will gather test data for 30 seconds and write it all into the `tests/sample_data` directory. During this time, it is a good idea to generate some good events that can tested. An example would be to generate a motion event for a FloodLight, Camera and/or Doorbell and then also ring a Doorbell. - All of the data that is generated is automatically anonymized so nothing sensitive about your NVR is exposed. To skip anonymization, use the `--actual` option. - To change output directory for sample data use the `-o / --output` option. - To adjust the time adjust how long to wait for Websocket messages, use the `-w / --wait` option. - To automatically zip up the generated sample data, use the `--zip` option. ```bash export UFP_SAMPLE_DIR=/workspaces/uiprotect/test-data uiprotect generate-sample-data ``` ### Real Data in Tests `pytest` will automatically also use the `UFP_SAMPLE_DIR` environment variable to locate sample data for running tests. This allows you to run `pytest` against a real NVR instance. ```bash export UFP_SAMPLE_DIR=/workspaces/uiprotect/test-data pytest ``` ## Adding a public API endpoint The uniform slice of the Public Integration API surface on `ProtectApiClient` is declared with decorators from `uiprotect._public_api`. You keep the hand-written `async def` signature and one-line docstring (mypy-strict and the rendered API reference depend on them); the decorator supplies the body — path binding, payload assembly, dispatch through the existing `api_request_*` helpers, and model construction. The body is a stub (`raise NotImplementedError`, which is never reached because the decorator replaces it). There are three forms: ```python from ._public_api import public_get, public_patch, public_post # List GET — one model per array entry (items=, plural) @public_get("/v1/cameras", items=PublicCamera) async def get_cameras_public(self) -> list[PublicCamera]: """Get all cameras using public API.""" raise NotImplementedError # Object GET — a single model (item=, singular); placeholders bind from same-named params @public_get("/v1/cameras/{camera_id}", item=PublicCamera) async def get_camera_public(self, camera_id: str) -> PublicCamera: """Get a specific camera using public API.""" raise NotImplementedError # Flat PATCH — body is the non-None keyword params, snake_case → camelCase @public_patch("/v1/fobs/{fob_id}", item=Fob) async def update_fob_public(self, fob_id: str, *, name: str | None = None) -> Fob: """Patch key-fob settings using public API.""" raise NotImplementedError # Fire-and-forget POST — path-only, no body, no return value @public_post("/v1/sirens/{siren_id}/stop") async def stop_siren_public(self, siren_id: str) -> None: """Stop an active siren.""" raise NotImplementedError ``` `item=` always means a single model, `items=` a list; `public_get` takes exactly one of them (XOR), while `public_patch` returns one `item=` and `public_post` takes neither. Each decorator registers its `(verb, path)` against the class at import time; declaring the same `(verb, path)` twice raises immediately. The signature is validated at decoration too — every `{placeholder}` must name a real parameter, and a body-less `public_get`/`public_post` may not carry any non-placeholder parameter — so a declaration that lies fails the import, not a later call. The model class is passed as an argument (`item=`/`items=`), so `_public_api.py` imports nothing from `uiprotect.data` and stays circular-import-safe. **The PATCH form is flat-body only.** A `@public_patch` body is built mechanically: every non-`None` keyword parameter becomes one camelCase wire key, and an empty body raises `BadRequest("At least one parameter must be provided")`. Path placeholders never leak into the body. This rule is correct only for genuinely uniform methods — anything that groups keys into nested objects (`ledSettings`/`osdSettings`), renames non-mechanically, validates ranges, carries a nullable `_UNSET` sentinel, or writes through to the public bootstrap cache (e.g. `update_camera_public`, `update_light_public`, `update_viewer_public`) stays hand-written. When in doubt, keep the body hand-written — behavior parity beats terseness. The hand-written exceptions are covered by their recorded example calls. uilibs-uiprotect-317a52c/docs/index.md000066400000000000000000000001041524637705700177400ustar00rootroot00000000000000--- hide: - navigation --- {% include-markdown "../README.md" %} uilibs-uiprotect-317a52c/docs/installation.md000066400000000000000000000012121524637705700213330ustar00rootroot00000000000000# Installation The package is published on [PyPI](https://pypi.org/project/uiprotect/) and can be installed with `pip` (or any equivalent): ```bash pip install uiprotect ``` To use the command-line interface (the `uiprotect` console script), install the `cli` extra, which pulls in [`typer`](https://typer.tiangolo.com/): ```bash pip install "uiprotect[cli]" ``` `uiprotect` supports **Python 3.11+**. Next, see the [usage](usage.md) page to see how to use it. If you plan to talk to the documented [Public Integration API](usage.md#public-vs-private-api) (API-key authentication), the usage page covers when to prefer it over the private API. uilibs-uiprotect-317a52c/docs/overrides/000077500000000000000000000000001524637705700203165ustar00rootroot00000000000000uilibs-uiprotect-317a52c/docs/overrides/main.html000066400000000000000000000002731524637705700221320ustar00rootroot00000000000000{% extends "base.html" %} {% block outdated %} You're not viewing the latest version. Click here to go to latest. {% endblock %} uilibs-uiprotect-317a52c/docs/overrides/partials/000077500000000000000000000000001524637705700221355ustar00rootroot00000000000000uilibs-uiprotect-317a52c/docs/overrides/partials/toc-item.html000066400000000000000000000007021524637705700245430ustar00rootroot00000000000000{% if not page.meta.toc_depth or toc_item.level <= page.meta.toc_depth %}
  • {{ toc_item.title }} {% if toc_item.children %} {% endif %}
  • {% endif %} uilibs-uiprotect-317a52c/docs/usage.md000066400000000000000000000357371524637705700177610ustar00rootroot00000000000000# Usage Assuming that you've followed the [installation steps](installation.md), you're now ready to use this package. `uiprotect` is an async library, so the examples below drive an event loop with `asyncio.run`. ## Command line Most day-to-day tasks are available through the bundled CLI: ```bash uiprotect --help ``` See the [Command Line](cli.md) reference for the full list of subcommands. ## Connecting from Python Instantiate `ProtectApiClient` with your console's address and a local-access user, call `update()` once to fetch the bootstrap snapshot, then read devices off the cached `bootstrap`: ```python import asyncio from uiprotect import ProtectApiClient async def main() -> None: protect = ProtectApiClient( host="10.0.0.1", port=443, username="YOUR_USERNAME", password="YOUR_PASSWORD", verify_ssl=True, # set False only for a self-signed certificate ) # Fetch the bootstrap snapshot (cameras, lights, sensors, NVR, ...). await protect.update() for camera in protect.bootstrap.cameras.values(): print(camera.id, camera.name, camera.is_connected) await protect.close_session() asyncio.run(main()) ``` !!! warning "About Ubiquiti SSO accounts" Ubiquiti SSO (cloud) accounts are not supported. Use a local-access user. Driving the cloud owner account over the public Internet is a security risk and there is no MFA support. ## Subscribing to events The typed event stream delivers `(ProtectEvent, EventChange)` pairs. It is backed by the Public Integration API, so the client must be configured with an `api_key` and `update_public()` must have run at least once before calling `subscribe_events`. The example below primes then subscribes; to avoid encoding that ordering yourself, call `subscribe_events_and_prime()` (or `subscribe_devices_and_prime()` for device state), which connects the WebSocket and primes in the correct order in a single call. With the combined helper, frames that arrive while `update_public()` is priming are buffered and replayed onto the fresh snapshot, so the already-connected subscriber does not miss an update from the prime window; with the explicit two-step form the subscriber is registered after priming and simply starts from the refreshed cache: ```python import asyncio import logging from uiprotect import EventChange, ProtectApiClient, ProtectEvent _LOGGER = logging.getLogger(__name__) async def main() -> None: protect = ProtectApiClient( host="10.0.0.1", port=443, username="YOUR_USERNAME", password="YOUR_PASSWORD", api_key="YOUR_API_KEY", verify_ssl=True, # set False only for a self-signed certificate ) await protect.update_public() def on_event(event: ProtectEvent, change: EventChange) -> None: if change is EventChange.STARTED: _LOGGER.info("%s on %s", event.type, event.device_id) unsubscribe = protect.subscribe_events(on_event) try: await asyncio.sleep(60) finally: unsubscribe() await protect.close_session() asyncio.run(main()) ``` The lower-level `subscribe_websocket` continues to deliver raw `WSSubscriptionMessage` frames for advanced callers over the private API and does not require an API key. The parallel `subscribe_events_websocket` drives the Public Integration API WebSocket and **does** require an API key. See the project README for the full notes on the typed event contract. ### Detecting a revoked API key A revoked or invalid API key can't be re-authenticated from inside the client — the key is static — so the public WebSocket would otherwise redial forever on repeated `401` handshakes without the consumer ever learning the key died. After two consecutive `401`s the client emits `WebsocketState.AUTH_FAILED` over the existing state channels (`subscribe_events_websocket_state` / `subscribe_devices_websocket_state`) and switches to a longer backoff to stop hammering the NVR. `AUTH_FAILED` **implies disconnected** — the socket is closed and cannot recover without a new key — so the client always emits a `DISCONNECTED` transition first when it trips. A consumer that tracks connectivity as `state is WebsocketState.CONNECTED` and one that only reacts to `DISCONNECTED` edges both observe the loss; `AUTH_FAILED` is the additional signal that the cause is a dead key rather than a transient drop. Subscribe to that channel to be notified, then install a fresh key with `set_api_key()` — it re-arms both public WebSockets immediately so recovery doesn't wait out the backoff: ```python def on_state(state: WebsocketState) -> None: if state is WebsocketState.AUTH_FAILED: protect.set_api_key(fetch_new_api_key()) unsub = protect.subscribe_events_websocket_state(on_state) ``` Events arrive **only** on the events WebSocket, so if it drops and reconnects while the devices WebSocket stays up, an `end` frame missed during the gap would otherwise leave the event active — a camera's derived `is_*_currently_detected` stuck ON until the periodic TTL sweep (~45 min worst case) closes it. On events-WS reconnect the client therefore **force-ends active detection events regardless of age** (a still-active detection re-asserts on its next frame) and flushes other channels past the 1 h staleness window. Both the typed `subscribe_events` stream (an `ENDED` change) and `subscribe_devices` (a camera update naming the flipped `is_*_detected` fields) see the drop immediately, and the derived camera flags read correct on the next synchronous access. A device added to (or removed from) the console while the devices WebSocket is down produces no wire frame either. The reconnect resync re-fetches the public bootstrap and announces that difference on the same channel: one `add` frame per device the refresh newly reports and one `remove` frame per device it no longer reports, delivered after the whole snapshot has merged so a subscriber reacting to a frame already sees a consistent bootstrap. `subscribe_devices` surfaces them as `DeviceChange.ADDED` / `DeviceChange.REMOVED`, exactly like live traffic, so consumers need no add/remove bookkeeping of their own. The initial `update_public()` prime stays silent — it is the baseline, not a diff. ## Camera RTSPS streams RTSPS stream URLs live on the camera as `PublicCamera.rtsps_streams`. The library owns their entire lifecycle: `update_public()` primes them, so a consumer reads the field synchronously and carries no fetch/cache code: ```python await protect.update_public() # Read synchronously off the camera — primed by update_public(). camera = protect.public_bootstrap.cameras[camera_id] streams = camera.rtsps_streams ``` Priming is **always** run by `update_public()`: connected cameras without streams are fetched under a bounded-concurrency semaphore, best-effort (one slow/offline camera is skipped, never aborting the rest), and disconnected cameras are skipped. `get_camera_rtsps_streams(camera_id)` is a flag-free fetch primitive used internally — it issues a plain GET with no cache side-effects. The field stays correct from the sources the client controls — stream create/delete is **not** signalled over the WebSocket, so passive observation is impossible: - **Write-through.** `create_camera_rtsps_streams` writes its result onto the camera's `rtsps_streams`; `delete_camera_rtsps_streams` drops the deleted qualities (and clears the field to `None` once no streams remain). - **Prime / refresh on connect.** When a public devices-WS frame moves a camera to `CONNECTED` (a reconnect or firmware change can rotate the `rtsp_alias`), a background fetch is scheduled. A camera that was **offline at `update_public()` time** — so skipped by the connected-only prime — is **primed** when it comes online mid-session, not left streamless until the next reload. An already-populated camera is **refreshed in place** instead. Either way a WebSocket reconnect resync also refreshes every populated camera. The field is **never emptied** — the old URLs stay readable until the fresh ones land, so synchronous consumers reading `camera.rtsps_streams` never observe a spurious `None`. It is only ever cleared by a camera `remove` frame (which drops the whole camera) or the client's own `delete_camera_rtsps_streams`. - **Observable.** A background prime/refresh that actually changes a camera's streams is announced: the client emits a synthetic devices-WS `update` for that camera (`new_obj` is the refreshed camera) through the existing devices subscription, so both `subscribe_devices_websocket` and typed `subscribe_devices` consumers observe stream availability without polling. A refresh that yields no change — an identity-guard backoff, a fetch failure, or a re-fetch equal to the cached value — emits nothing. ## Siren timed runs `siren.play(duration)` starts a timed run and the console reports it on `sirenStatus` (`isActive`, `activatedAt`, and `duration` in **milliseconds** — the play request takes seconds). It reports nothing when the run ends, so `Siren.is_active` compares the clock against `siren_status.turn_off_at`. The client also **announces** the end of the run: it arms a timer at `turn_off_at`, and when the run expires it clears the cached `sirenStatus.isActive` and emits a synthetic devices-WS `update` for the siren (`new_obj` is the siren) through the existing devices subscription. Both `subscribe_devices_websocket` and typed `subscribe_devices` consumers observe the siren stopping without scheduling a timer of their own. The timer follows the device: every siren frame re-derives the deadline from the merged status (so a restarted or manually stopped run re-arms or disarms it), a `remove` frame drops it, a devices-websocket disconnect drops it and the reconnect re-arms it from the cached status (the resync refreshes that status when it runs), and `close_session()` drains any that are still pending. The expiry is **inferred, not observed**: `activatedAt` is server time and the deadline is compared against the local clock, so clock skew between the console and the client shifts the announcement by the same amount. ## Public vs. private API `uiprotect` can talk to UniFi Protect two ways, and is actively migrating from the second to the first: - **Public Integration API** — Ubiquiti's officially documented REST API under `/integration/v1/…`. It authenticates with an **API key** (create one with `uiprotect create-api-key NAME`), is stable across firmware releases, and is the forward-looking path. The typed `subscribe_events` stream and the public-API CLI groups (`viewers-public`, `users-public`, `liveviews`, …) are driven by this API. Requests on this path are **auto-paced** to stay under the server's per-API-key rate budget — the client seeds its rate from the `RateLimit-Policy` header on the first response (with safety margin for the shared-budget public WebSocket) and falls back to a conservative default until that header is seen, so a bootstrap fan-out no longer trips a `429` storm. Rotating the key via `set_api_key()` resets the pacing. - **Private API** — the reverse-engineered, undocumented endpoints under `/api/…` plus the binary WebSocket update stream. It authenticates with username/password and powers most of the historical `bootstrap`-based surface. It is **not** documented by Ubiquiti, can shift between firmware versions, and is being deprecated capability-by-capability as the public API gains coverage. Prefer the public API for new code. Reach for the private API only for capabilities the public API does not yet expose; treat that as a temporary escape hatch rather than the default path. ## Device convenience setters (public API) Camera, light, sensor and chime devices from `public_bootstrap` expose `set_*` convenience methods that patch a single setting and write the server's response straight back into the cached device — so a **public-only client** (API key, no username/password) can mutate a device without hand-building nested `update_*_public` bodies: ```python await protect.update_public() camera = next(iter(protect.public_bootstrap.cameras.values())) # Write-through: the cached camera reflects the change on return. await camera.set_status_light(True) await camera.set_mic_volume(50) assert protect.public_bootstrap.cameras[camera.id].mic_volume == 50 ``` Each setter validates the device's capability flags (e.g. a camera without a microphone rejects `set_mic_volume`), applies the same numeric bounds the server enforces, and serialises concurrent read-modify-write setters (chime ring volume, camera smart-detect toggles, light mode/device settings) on one device under a per-object lock. `PublicCamera.rtsps_streams` is owned out-of-band by the library and is never touched by a setter. The private-API `Device.set_*_public` methods remain and are unchanged; when a public bootstrap is loaded they keep the cached public twin fresh through the same `update_*_public` endpoints, so the Home Assistant integration and the CLI continue to work as before. ## POS transaction overlay (public API) `create_pos_transaction_public()` records a point-of-sale transaction as a camera event so the receipt can be overlaid on recorded footage: ```python from uiprotect.data import PosTransactionType result = await protect.create_pos_transaction_public( camera_id, type=PosTransactionType.SALE, external_id="order-4711", amount=12.50, currency="USD", line_items=[{"title": "Coffee", "quantity": 2}], ) ``` `external_id` is unique per camera and drives best-effort idempotency: a repeat comes back with `created=False` and `event_id` echoing the event already ingested, while a concurrent repeat raises `BadRequest` on the server's 409 and should be retried shortly. That de-duplication is in-memory and per-process, so it does not survive a Protect restart. Overlay capture is best-effort too — a 200 confirms the event was recorded, not that footage exists for the window. ## Shared identity interface The public device models expose the same derived identity attributes as the private tree: `display_name` (falling back `name → type`, mirroring the private `name → market_name → type`) and a `type` alias for the raw `device_type` field. So `PublicNVR().display_name` and `PublicCamera().type` work the same as their private-tree counterparts. Code that handles "the NVR" or "a camera" generically — for example the Home Assistant integration's device-info paths, which must now accept either tree — can type against the `ProtectDeviceIdentity` protocol instead of `cast()`-ing between the unrelated private (`NVR`, `Camera`, …) and public (`PublicNVR`, `PublicCamera`, …) types. Both trees satisfy it structurally: ```python from uiprotect.data import ProtectDeviceIdentity def label(device: ProtectDeviceIdentity) -> str: # Accepts an NVR or a PublicNVR (a Camera or a PublicCamera, …) unchanged. return f"{device.display_name} ({device.type}) [{device.mac}] {device.id}" ``` The protocol covers `id`, `mac`, `display_name`, `type`, and `model`; `mac` and `type` are optional because the public tree omits them on older firmware. uilibs-uiprotect-317a52c/mkdocs.yml000066400000000000000000000042761524637705700174000ustar00rootroot00000000000000site_name: Unofficial UniFi Protect Python API and CLI site_url: https://uilibs.github.io/uiprotect/latest/ site_description: Unofficial UniFi Protect Python API and CLI repo_name: uiprotect repo_url: https://github.com/uilibs/uiprotect copyright: uiprotect is an unofficial API for UniFi Protect. There is no affiliation with Ubiquiti. markdown_extensions: - abbr - admonition - toc: permalink: true toc_depth: "1-5" - pymdownx.highlight: anchor_linenums: true use_pygments: true auto_title: true linenums: true - pymdownx.superfences - pymdownx.tabbed: alternate_style: true - attr_list - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg nav: - Home: "index.md" - Installation: "installation.md" - Usage: "usage.md" - Command Line: "cli.md" - API Reference: "api.md" - Development: "dev.md" - Contributing: "contributing.md" - Changelog: "https://github.com/uilibs/uiprotect/releases" plugins: - search - mike: canonical_version: null version_selector: true css_dir: css javascript_dir: js - git-revision-date-localized: enable_creation_date: true - include-markdown - mkdocstrings: default_handler: python handlers: python: paths: [src] options: show_root_toc_entry: false show_signature_annotations: true show_source: false heading_level: 3 theme: name: material custom_dir: docs/overrides features: - navigation.instant - navigation.tracking - navigation.tabs - navigation.top - search.suggest - search.highlight - search.share - header.autohide palette: - media: "(prefers-color-scheme: light)" scheme: default toggle: icon: material/brightness-7 name: Switch to dark mode primary: blue accent: light blue - media: "(prefers-color-scheme: dark)" scheme: slate toggle: icon: material/brightness-4 name: Switch to light mode primary: blue accent: light blue extra: version: provider: mike uilibs-uiprotect-317a52c/openapi/000077500000000000000000000000001524637705700170175ustar00rootroot00000000000000uilibs-uiprotect-317a52c/openapi/.validated-version000066400000000000000000000000101524637705700224270ustar00rootroot000000000000007.2.105 uilibs-uiprotect-317a52c/poetry.lock000066400000000000000000012040401524637705700175610ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "aiofiles" version = "25.1.0" description = "File support for asyncio." optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, ] [[package]] name = "aiohappyeyeballs" version = "2.6.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, ] [[package]] name = "aiohttp" version = "3.14.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b"}, {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a"}, {file = "aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5"}, {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f"}, {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43"}, {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9"}, {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8"}, {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479"}, {file = "aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b"}, {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d"}, {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d"}, {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2"}, {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48"}, {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f"}, {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32"}, {file = "aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e"}, {file = "aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c"}, {file = "aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb"}, {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3"}, {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a"}, {file = "aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8"}, {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239"}, {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f"}, {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06"}, {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929"}, {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db"}, {file = "aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce"}, {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c"}, {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15"}, {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c"}, {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae"}, {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910"}, {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7"}, {file = "aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa"}, {file = "aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d"}, {file = "aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39"}, {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5"}, {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228"}, {file = "aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee"}, {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a"}, {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b"}, {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529"}, {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787"}, {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42"}, {file = "aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b"}, {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043"}, {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427"}, {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d"}, {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0"}, {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d"}, {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19"}, {file = "aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559"}, {file = "aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a"}, {file = "aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c"}, {file = "aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86"}, {file = "aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627"}, {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82"}, {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c"}, {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f"}, {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80"}, {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0"}, {file = "aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf"}, {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd"}, {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807"}, {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8"}, {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24"}, {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5"}, {file = "aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4"}, {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9"}, {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1"}, {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371"}, {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde"}, {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e"}, {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71"}, {file = "aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0"}, {file = "aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883"}, {file = "aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2"}, {file = "aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062"}, {file = "aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6"}, {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919"}, {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7"}, {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0"}, {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924"}, {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646"}, {file = "aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b"}, {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30"}, {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9"}, {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f"}, {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d"}, {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147"}, {file = "aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c"}, {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a"}, {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0"}, {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661"}, {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22"}, {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41"}, {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf"}, {file = "aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da"}, {file = "aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100"}, {file = "aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc"}, {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b"}, {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0"}, {file = "aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e"}, {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716"}, {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f"}, {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553"}, {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100"}, {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85"}, {file = "aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33"}, {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f"}, {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0"}, {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098"}, {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25"}, {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9"}, {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb"}, {file = "aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963"}, {file = "aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b"}, {file = "aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7"}, {file = "aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc"}, ] [package.dependencies] aiohappyeyeballs = ">=2.5.0" aiosignal = ">=1.4.0" attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" propcache = ">=0.2.0" typing_extensions = {version = ">=4.4", markers = "python_version < \"3.13\""} yarl = ">=1.17.0,<2.0" [package.extras] speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\" and sys_platform != \"android\" and sys_platform != \"ios\"", "aiodns (>=3.3.0) ; sys_platform != \"android\" and sys_platform != \"ios\"", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\" and sys_platform != \"android\" and sys_platform != \"ios\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] [package.dependencies] frozenlist = ">=1.1.0" typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} [[package]] name = "aiosqlite" version = "0.22.1" description = "asyncio bridge to the standard sqlite3 module" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, ] [package.extras] dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] [[package]] name = "aiozoneinfo" version = "0.2.3" description = "Tools to fetch zoneinfo with asyncio" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "aiozoneinfo-0.2.3-py3-none-any.whl", hash = "sha256:5423f0354c9eed982e3f1c35edeeef1458d4cc6a10f106616891a089a8455661"}, {file = "aiozoneinfo-0.2.3.tar.gz", hash = "sha256:987ce2a7d5141f3f4c2e9d50606310d0bf60d688ad9f087aa7267433ba85fff3"}, ] [package.dependencies] tzdata = ">=2024.1" [[package]] name = "annotated-doc" version = "0.0.4" description = "Document parameters, class attributes, return types, and variables inline, with Annotated." optional = true python-versions = ">=3.8" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, ] [[package]] name = "annotated-types" version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] [[package]] name = "ast-serialize" version = "0.4.0" description = "Python bindings for mypy AST serialization" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "ast_serialize-0.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a6f26937ce0293aafbece0e39019e020369a5a70486ff4088227f0cc888844a9"}, {file = "ast_serialize-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:074032142777e3e6091977dc3c5146a8ca58ae6825b7f64e9a0b604153ddabd8"}, {file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:404f3462b4532e13a70b8849bba241dbd82e30043ff58d98c7e762fd925b116a"}, {file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97c55336e16f5c4ca2bde7be94cca4b8f7d665d64f7008925a82e02707ba14ac"}, {file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:732b4ef76adcb0f298a7d18c4558336d83b1384f9ae0c7eaa1dc8d031b0a4390"}, {file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b3db87c4772097c0782250bcd550d66b1189a8c889793c7bcf153f4fee70005c"}, {file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43729a5e369ebbe7750635c0c206bc616fcd36e703cb9c4497d6b4df0291ee64"}, {file = "ast_serialize-0.4.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:91d3786f3929786cdc4eeedfd110abb4603e7f6c1390c5af398f333a947b742d"}, {file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7fba7315fd4bd87cb5560792709f6e66e0606402d362c0a38dd32dfb66ba6066"}, {file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4db9769d57deb5545ce56ebbbbe3436dcc0ae2688ce14c295cd14e106624ece7"}, {file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dcd04f85a29deb80400e8987cfaceb9907140f763453cbffdbd6ff36f1b32c12"}, {file = "ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:905fc11940831454d93589bd7ce2acb6a5eb01c2936156f751d2a21087c98cd3"}, {file = "ast_serialize-0.4.0-cp314-cp314t-win32.whl", hash = "sha256:3bdde2c4570143791f636aed4e3ef868f5b46eb90a18f8d5c41dd045aab08bef"}, {file = "ast_serialize-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6551d55b8607b97a7755683d743200b398c61a0b71a11b7f00c89c335a11d0f4"}, {file = "ast_serialize-0.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7234ff086cb152ea2a3b7ef895b5ebeb6d80779df049d5c6431c8e3536d5b03c"}, {file = "ast_serialize-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dcded5056d9f3d201df7833082c07ebcbc566ffc3d4105c9fc9fe278fa086ecb"}, {file = "ast_serialize-0.4.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bd50d201098aae0d202805fe9606c0545492f69a3ec4403337e32c54ad29fc41"}, {file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6615b39cd747967c3aabe68bf3f5f26748e823cc6b474ddc1510ed188a824149"}, {file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91362c0a9fdf1c344b7f50a5b0508b11a0732102998fbd754a191f7187e77031"}, {file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70d9c5d527bbfa69bd3c7d17dac11fb6781e36186a434a06d7d5892e0b2f88f9"}, {file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4738790cf54d8b416de992b87ee567056980bc82134d52458bd4985f389d1658"}, {file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:faa008dccfcb793ae9101325e4d6d026caaa5d845c2182f03749c759834b0a3a"}, {file = "ast_serialize-0.4.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c5245228e65d38cb48e1251f0ca71b0fa417e527141491e8c92f740e8e2d121"}, {file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8f5153e9c44a02e61f4042c5f9249d2e8a759773d621a0b2f445a899e536e181"}, {file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1e1fb90def261f6a0db885876f7e1a49ad2dbac38ad9f2f62dba2f9543af16e7"}, {file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf2ff7b654c8e95143e20f5d75878cbb78b65b928b26c4d58ef71cdba9d6d981"}, {file = "ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90fc5c0d35a22f1a92dd33635508626d50f8fc64deb897c23e78e666a60804c9"}, {file = "ast_serialize-0.4.0-cp39-abi3-win32.whl", hash = "sha256:9ecd6a1fc1b86f1f4e8ae206759b6319c10019706b3496b01b54d02b9b2cd918"}, {file = "ast_serialize-0.4.0-cp39-abi3-win_amd64.whl", hash = "sha256:79c8d015c771c8bfdb1208003b227b27c40034790a2c29c09f2317a041825ce2"}, {file = "ast_serialize-0.4.0-cp39-abi3-win_arm64.whl", hash = "sha256:1026f565a7ab846337c630909089b3346a2fe417bf1552b1581ab01852137407"}, {file = "ast_serialize-0.4.0.tar.gz", hash = "sha256:74e4e634ab82d1466acf0be27043178570b98ebeaa3165f9240a6fad4c286471"}, ] [[package]] name = "attrs" version = "25.4.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] [[package]] name = "av" version = "17.1.0" description = "Pythonic bindings for FFmpeg's libraries." optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "av-17.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:19c84fd72af5ef81a20f18fbc6f9aedff9e1455e53a7062c1d4c95926d73da4e"}, {file = "av-17.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:19264c9bb4bee404accc7ce9ec461f2044b7f577a70234d29aafde31ed17de46"}, {file = "av-17.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22dff0ae582d10ef08c75c2150a4fd27cfc26653b54930c7c27b9f7b3aa20723"}, {file = "av-17.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:90c49bc9608377d01e82e747377505419a229464873341db18202d5dddecce5a"}, {file = "av-17.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cc5a5247622cb77e24c342364eb68f88c1442ddfaab60c1f1f483359d3cc7879"}, {file = "av-17.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff457ed419348e5b8e8c811d341389b052c5e4d5839da3794d019b125b9fe830"}, {file = "av-17.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1370b11a697eb3f2555906f8ab3519b0cfe48425d7830a3996ad42e6bffafda5"}, {file = "av-17.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd41e53f53f9a3260751d9c3c11d34e93d70d61e506c81f13dbc1e3606e07b"}, {file = "av-17.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3453b06075c7bb973fdb6de52563f7692ff05cbc64c0bb45f4fd6e8709131f2f"}, {file = "av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41"}, {file = "av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9"}, {file = "av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847"}, {file = "av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f"}, {file = "av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702"}, {file = "av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f"}, {file = "av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0"}, {file = "av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e"}, {file = "av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c"}, {file = "av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6"}, {file = "av-17.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240"}, {file = "av-17.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa"}, {file = "av-17.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c"}, {file = "av-17.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63"}, {file = "av-17.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a"}, {file = "av-17.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19"}, {file = "av-17.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b"}, {file = "av-17.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02"}, {file = "av-17.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f"}, {file = "av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4"}, {file = "av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3"}, ] [[package]] name = "babel" version = "2.17.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, ] [package.extras] dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "backrefs" version = "5.9" description = "A wrapper around re and regex that adds additional back references." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f"}, {file = "backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf"}, {file = "backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa"}, {file = "backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b"}, {file = "backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9"}, {file = "backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60"}, {file = "backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59"}, ] [package.extras] extras = ["regex"] [[package]] name = "blockbuster" version = "1.5.26" description = "Utility to detect blocking calls in the async event loop" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb"}, {file = "blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629"}, ] [package.dependencies] forbiddenfruit = {version = ">=0.1.4", markers = "implementation_name == \"cpython\""} [[package]] name = "bracex" version = "2.6" description = "Bash style brace expander." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952"}, {file = "bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7"}, ] [[package]] name = "certifi" version = "2025.10.5" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] [[package]] name = "cfgv" version = "3.5.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, ] [[package]] name = "charset-normalizer" version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] [[package]] name = "click" version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, ] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev", "docs"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] markers = {main = "extra == \"cli\" and platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "convertertools" version = "1.1.0" description = "Tools for converting python data types" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "convertertools-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cb6881bb503f9b42b7846ce3b90e55596e5452f70faa8fd35aeb3dd9fe49bc61"}, {file = "convertertools-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c42e1701fa72039fedaa49649a295be4c622053df86ce2c0e9671a99ed10930"}, {file = "convertertools-1.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef5d1920b2fc52408edf25e8aede88cecd636860794838b4042ebd8aa595f206"}, {file = "convertertools-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c500dd22f69eeea9c691b63f25505875c4fae74f89d775c87a67af0dd80d2fc"}, {file = "convertertools-1.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:abbbbe1310ad9cc89c3c06f89a9d35ac54f8c6559a2c40376548a22932401c71"}, {file = "convertertools-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a144e00a7f50e75f04a10a3ba57d7ff2d9fe0e22a9d4f61c15380a4f1f843004"}, {file = "convertertools-1.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0fda8421f5e08f717646fe4e27f3dae09638d85257324643342c42780c85149d"}, {file = "convertertools-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238f09f7e856d55a13a2973a006984f9f1885e03f9ba2d504f7543444106b599"}, {file = "convertertools-1.1.0-cp311-cp311-win32.whl", hash = "sha256:0d83077ad836453b9f8fa2e2159557098699ed2c5c41f1be84748b2ccada0d91"}, {file = "convertertools-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc09d3f8cd7f80eef3090c2e2e972e9f4eb5d89b82c0c85a032f365558a6f58b"}, {file = "convertertools-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dccdf095f3c99a7c55f23c644ec85501514abb55f9835b57ef810d65e4bce243"}, {file = "convertertools-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56322511183c841f9009cdd42f09e3d9bf0c2d0dc9a5cc839e396225af63fe18"}, {file = "convertertools-1.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6a690029a9d4ab4440e8737de89ae9f974f1f4f41082363f9052b6bcc04e9a83"}, {file = "convertertools-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a9c08655c2c99b837159056b68fc0f23a960d5bc4dd6174d048d1db38acfb9f"}, {file = "convertertools-1.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0bc6e9de7a5b664a5f1d4198e74fa3b031a1bb0109dccb618deeb8894b522533"}, {file = "convertertools-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc30a186d25451c925ac7833a4ba817f2ee6ccc831c3baaf4c7e11d56bba4d36"}, {file = "convertertools-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:68da6eaaa651ee95fc716e33dafcff3b621d7fd5dc6d92c5a0d719d7ca9d6d44"}, {file = "convertertools-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:10c50914ac3d8189b4d2c28b6ba14939dc00a88addf1ff7c4b3509938b228a99"}, {file = "convertertools-1.1.0-cp312-cp312-win32.whl", hash = "sha256:c36480cc8a4e1d193ec8b56113d858f146ad1273aacca3bc2b854f2b0f684eb0"}, {file = "convertertools-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:00cc09e85a2f8445488601c5721815151d3d8a5e3c8ff1795e0cf9e49433c03c"}, {file = "convertertools-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78002227ca997ad4a3a0f84ab4342067aa550292026a3baabd69ab710536a2ba"}, {file = "convertertools-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dddae2c8c73895f694dba6b7b24e2994674b31d2b618b9455d88f8fc1a960959"}, {file = "convertertools-1.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:25db34e9711a26be4d11fc0a11fdd815d95ddc69402ecf3e2decc3cf2a86c3ca"}, {file = "convertertools-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fc5528a853853abe0f2d44594c565a49ae14eb9326257e30458af3f9e9729f5"}, {file = "convertertools-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96404443cbc3e90e52761910eba7a493d85381efe956299341b4971a34dea001"}, {file = "convertertools-1.1.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:e52e53ba05d1dadf0f5078032bb8a6a320899bb432324da877092c3cfefcb154"}, {file = "convertertools-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1287671ca84400fc6265350eaa169446b7c28af911ababa134dd84bf2c032770"}, {file = "convertertools-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:708ac55e43068cb92581dd324de197d455e80e27e93ddc6ea0460dcfe5232d54"}, {file = "convertertools-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:46c939000304e6f68ed9256757628ede27a8e84747b6179c94f4a8fa18f16cdc"}, {file = "convertertools-1.1.0-cp313-cp313-win32.whl", hash = "sha256:e91c3fa55c4275195a6bc005a9c5581f6b572c16df090603adc3114b5936c332"}, {file = "convertertools-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f574c48adaa9409d59dcf379be0b152195ce23c3aa8beccfaae36c435799d912"}, {file = "convertertools-1.1.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e1edfb9685a945597335ea3cab6a94d4f39897ff3b6e08f9644c0c33abd95f28"}, {file = "convertertools-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dc9d99f91aba49a9a5f0a7f427583e46472bde21e1882af438302cb9f405936"}, {file = "convertertools-1.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98af0203b102ce1e058faba9da0b56e1e76fdd3946ab8386d756cafe303649c7"}, {file = "convertertools-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:834b14b508fdf0c91bfb77db0085ed437882151de2aef709e52abcb78b0441fa"}, {file = "convertertools-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a2d334ae552abb9ff5a6ee4aa9320387d0906daeb5116bfaf8fd4c3d17971653"}, {file = "convertertools-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0e38f134b8ae1ec237f717e5d4e358a21a6f3159a75d8f88ec753821efd7416e"}, {file = "convertertools-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:223b1ca690e126f8bd721adc6cd72345027b868094f51bcf82bc02bdcdb3b2c4"}, {file = "convertertools-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:615f915e3d3fff40a5075cecf8d88b6c5a6a2bc0667944c10d81f8797fbfd323"}, {file = "convertertools-1.1.0-cp314-cp314-win32.whl", hash = "sha256:e4cc195222e359084496f0a36e436742bc9c7b9ebb37534164c51ef642d926e5"}, {file = "convertertools-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:2c7fceb2359898e37c28535315f2e11cfb122ec35b7a33ac0e5a349e478b9380"}, {file = "convertertools-1.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:abde0b0e9d2c09de6dbef7585c5d03afd541dad3b322f54c14b8e0cf450e0be3"}, {file = "convertertools-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1492a9a88603e05c0c9fa51a2e72cb4f2263699b5f9fcf5973d1d07c947574b1"}, {file = "convertertools-1.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:55bbf257409a5bf9ce9457619edec8dc4ac2bc024914805128b6017dff0e2a64"}, {file = "convertertools-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6e10696da03491852ccf99ef1c17628554b4ff16e79a90040b149c00d196b61"}, {file = "convertertools-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d80ecbc13d07ec2a932a792551c8ba07ddc0cd2d272703d1bd77d953878474a"}, {file = "convertertools-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f3d3401b652e8dafa95f8fdd6cacc0de7c04b9d3117a2a30d3590f501335a83d"}, {file = "convertertools-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:73378b4275579862b97c8f316363bfc00e5b8b8498fe39223ba114001e4c9ee0"}, {file = "convertertools-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:991137feb7a878303bd6774ea19f84e87841da745af9a7188f898699906bb9ec"}, {file = "convertertools-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:5c42f55e3eb5e427447f7920e66d6d83fb51fc81728b0d427821687d7348e4c0"}, {file = "convertertools-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfdcb297219ee7ef99d9c20679ba9efdb1170e0d4fd90c8644b1fa5ea2f04db2"}, {file = "convertertools-1.1.0.tar.gz", hash = "sha256:c23fc6848d3b1e74fbcc5dfa3a9729834e86114bf6d0fd9c9e35b9062ace93ca"}, ] [[package]] name = "coverage" version = "7.10.7" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "dateparser" version = "1.4.1" description = "Date parsing library designed to parse dates from HTML pages" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "dateparser-1.4.1-py3-none-any.whl", hash = "sha256:f25d4e051a84be27a35bd297e3e1dc59ff78373701b89be352ba80372d22d0d0"}, {file = "dateparser-1.4.1.tar.gz", hash = "sha256:f265df13c0380e2e07543ba74b67c0681aaa1096981ffcd35227e1aa0cb81c7c"}, ] [package.dependencies] python-dateutil = ">=2.7.0" pytz = ">=2024.2" regex = ">=2024.9.11" tzlocal = ">=0.2" [package.extras] calendars = ["convertdate (>=2.2.1)", "hijridate"] langdetect = ["langdetect (>=1.0.0)"] [[package]] name = "distlib" version = "0.4.0" description = "Distribution utilities" optional = false python-versions = "*" groups = ["dev"] files = [ {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] [[package]] name = "execnet" version = "2.1.1" description = "execnet: rapid multi-Python deployment" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, ] [package.extras] testing = ["hatch", "pre-commit", "pytest", "tox"] [[package]] name = "filelock" version = "3.20.3" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, ] [[package]] name = "forbiddenfruit" version = "0.1.4" description = "Patch python built-in objects" optional = false python-versions = "*" groups = ["dev"] markers = "implementation_name == \"cpython\"" files = [ {file = "forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"}, ] [[package]] name = "frozenlist" version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] [[package]] name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." optional = false python-versions = "*" groups = ["docs"] files = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, ] [package.dependencies] python-dateutil = ">=2.8.1" [package.extras] dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "gitdb" version = "4.0.12" description = "Git Object Database" optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, ] [package.dependencies] smmap = ">=3.0.1,<6" [[package]] name = "gitpython" version = "3.1.58" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f"}, {file = "gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" [package.extras] doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] test = ["basedpyright (==1.39.9) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "griffelib" version = "2.0.0" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f"}, {file = "griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934"}, ] [package.extras] pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] [[package]] name = "identify" version = "2.6.15" description = "File identification library for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, ] [package.extras] license = ["ukkonen"] [[package]] name = "idna" version = "3.15" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["main", "docs"] files = [ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, ] [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "iniconfig" version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] [[package]] name = "librt" version = "0.11.0" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"}, {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"}, {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"}, {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"}, {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"}, {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"}, {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"}, {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"}, {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"}, {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"}, {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"}, {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"}, {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"}, {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"}, {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"}, {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"}, {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"}, {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"}, {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"}, {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"}, {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"}, {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"}, {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"}, {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"}, {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"}, {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"}, {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"}, {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"}, {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"}, {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"}, {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"}, {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"}, {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"}, {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"}, {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"}, {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"}, {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"}, {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"}, {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"}, {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"}, {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"}, {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"}, {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"}, {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"}, {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"}, {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"}, {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"}, {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"}, {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"}, {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"}, {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"}, {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"}, {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"}, {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"}, {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"}, {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"}, {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"}, {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"}, {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"}, {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"}, ] [[package]] name = "markdown" version = "3.9" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"}, {file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"}, ] [package.extras] docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] [[package]] name = "markdown-it-py" version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, ] markers = {main = "extra == \"cli\""} [package.dependencies] mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] [[package]] name = "markupsafe" version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] markers = {main = "extra == \"cli\""} [[package]] name = "mergedeep" version = "1.3.4" description = "A deep merge function for ðŸ��." optional = false python-versions = ">=3.6" groups = ["docs"] files = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] [[package]] name = "mike" version = "2.2.0" description = "Manage multiple versions of your MkDocs-powered documentation" optional = false python-versions = "*" groups = ["docs"] files = [ {file = "mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040"}, {file = "mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e"}, ] [package.dependencies] jinja2 = ">=2.7" mkdocs = ">=1.0,<2.0" pyparsing = ">=3.0" pyyaml = ">=5.1" pyyaml-env-tag = "*" verspec = "*" [package.extras] dev = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] test = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] [[package]] name = "mkdocs" version = "1.6.1" description = "Project documentation with Markdown." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, ] [package.dependencies] click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" mergedeep = ">=1.3.4" mkdocs-get-deps = ">=0.2.0" packaging = ">=20.5" pathspec = ">=0.11.1" pyyaml = ">=5.1" pyyaml-env-tag = ">=0.1" watchdog = ">=2.0" [package.extras] i18n = ["babel (>=2.9.0)"] min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] [[package]] name = "mkdocs-autorefs" version = "1.4.3" description = "Automatically link across pages in MkDocs." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9"}, {file = "mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75"}, ] [package.dependencies] Markdown = ">=3.3" markupsafe = ">=2.0.1" mkdocs = ">=1.1" [[package]] name = "mkdocs-get-deps" version = "0.2.0" description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, ] [package.dependencies] mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" [[package]] name = "mkdocs-git-revision-date-localized-plugin" version = "1.5.3" description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "mkdocs_git_revision_date_localized_plugin-1.5.3-py3-none-any.whl", hash = "sha256:cd96e432de6a7e59b31c7041574b22f84179c8636835419ff458877ecfaaaf05"}, {file = "mkdocs_git_revision_date_localized_plugin-1.5.3.tar.gz", hash = "sha256:873444b54cab4d47c69bd6e85da05ef5fbe81fee27e64508114c46a0e4f81e37"}, ] [package.dependencies] babel = ">=2.7.0" gitpython = ">=3.1.44" mkdocs = ">=1.0,<2" tzdata = {version = ">=2023.3", markers = "sys_platform == \"win32\""} [[package]] name = "mkdocs-include-markdown-plugin" version = "7.3.0" description = "Mkdocs Markdown includer plugin." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "mkdocs_include_markdown_plugin-7.3.0-py3-none-any.whl", hash = "sha256:5b5c99b5d3c9b9ce0114a9e60353bbafb6be53a26c2d3b74ec6b767a7a8e55ca"}, {file = "mkdocs_include_markdown_plugin-7.3.0.tar.gz", hash = "sha256:2800126746452e31c2e321bbd43c8190b356e0de353e20cbc16a34a3c3d6796c"}, ] [package.dependencies] mkdocs = ">=1.4" wcmatch = "*" [package.extras] cache = ["platformdirs"] [[package]] name = "mkdocs-material" version = "9.7.7" description = "Documentation that simply works" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f"}, {file = "mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855"}, ] [package.dependencies] babel = ">=2.10" backrefs = ">=5.7.post1" colorama = ">=0.4" jinja2 = ">=3.1" markdown = ">=3.2" mkdocs = ">=1.6,<2" mkdocs-material-extensions = ">=1.3" paginate = ">=0.5" pygments = ">=2.16" pymdown-extensions = ">=10.2" requests = ">=2.30" [package.extras] git = ["mkdocs-git-committers-plugin-2 (>=1.1)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4)"] imaging = ["cairosvg (>=2.6)", "pillow (>=10.2)"] recommended = ["mkdocs-minify-plugin (>=0.7)", "mkdocs-redirects (>=1.2)", "mkdocs-rss-plugin (>=1.6)"] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" description = "Extension pack for Python Markdown and MkDocs Material." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, ] [[package]] name = "mkdocstrings" version = "1.0.6" description = "Automatic documentation from sources, for MkDocs." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7"}, {file = "mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd"}, ] [package.dependencies] Jinja2 = ">=3.1" Markdown = ">=3.6" MarkupSafe = ">=1.1" mkdocs = ">=1.6" mkdocs-autorefs = ">=1.4" pymdown-extensions = ">=6.3" [package.extras] crystal = ["mkdocstrings-crystal (>=0.3.4)"] python = ["mkdocstrings-python (>=1.16.2)"] python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" version = "2.0.5" description = "A Python handler for mkdocstrings." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d"}, {file = "mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594"}, ] [package.dependencies] griffelib = ">=2.0" mkdocs-autorefs = ">=1.4" mkdocstrings = ">=0.30" [[package]] name = "multidict" version = "6.7.0" description = "multidict implementation" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"}, {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"}, {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"}, {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"}, {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"}, {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"}, {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"}, {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"}, {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"}, {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"}, {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"}, {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"}, {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"}, {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"}, {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"}, {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"}, {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"}, {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"}, {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"}, {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"}, {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"}, {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"}, {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"}, {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"}, {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"}, {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"}, {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"}, {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"}, {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"}, {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"}, {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"}, {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"}, {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"}, {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"}, {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"}, {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"}, {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"}, {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"}, {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"}, {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"}, {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"}, {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"}, {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"}, {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"}, {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"}, {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"}, {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"}, {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"}, {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"}, {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"}, {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"}, {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"}, {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"}, {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"}, {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"}, {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"}, {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"}, {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"}, {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"}, {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"}, {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"}, {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"}, {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"}, {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"}, {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"}, {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"}, {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"}, {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"}, {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"}, {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"}, {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"}, {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"}, {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"}, {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"}, {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"}, {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"}, {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"}, {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"}, {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"}, {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"}, {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"}, {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"}, {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"}, {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"}, {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"}, {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"}, {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"}, {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"}, {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"}, {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"}, {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"}, {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"}, {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"}, {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"}, {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"}, {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"}, {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"}, {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"}, {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"}, {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"}, {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"}, {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"}, {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"}, {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"}, {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"}, {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"}, {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"}, {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"}, {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"}, {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"}, {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"}, {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"}, {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"}, {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"}, {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"}, {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"}, {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"}, {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"}, {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"}, {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"}, {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"}, {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"}, {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"}, {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"}, {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"}, {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"}, {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c"}, {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40"}, {file = "multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851"}, {file = "multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687"}, {file = "multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5"}, {file = "multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb"}, {file = "multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6"}, {file = "multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e"}, {file = "multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e"}, {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32"}, {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c"}, {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84"}, {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329"}, {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e"}, {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4"}, {file = "multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91"}, {file = "multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f"}, {file = "multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546"}, {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"}, {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"}, ] [[package]] name = "mypy" version = "2.1.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc"}, {file = "mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849"}, {file = "mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd"}, {file = "mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166"}, {file = "mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8"}, {file = "mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8"}, {file = "mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e"}, {file = "mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41"}, {file = "mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca"}, {file = "mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538"}, {file = "mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398"}, {file = "mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563"}, {file = "mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389"}, {file = "mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666"}, {file = "mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af"}, {file = "mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6"}, {file = "mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211"}, {file = "mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b"}, {file = "mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22"}, {file = "mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b"}, {file = "mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8"}, {file = "mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5"}, {file = "mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e"}, {file = "mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e"}, {file = "mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285"}, {file = "mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5"}, {file = "mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65"}, {file = "mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d"}, {file = "mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2"}, {file = "mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f"}, {file = "mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4"}, {file = "mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef"}, {file = "mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135"}, {file = "mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21"}, {file = "mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57"}, {file = "mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e"}, {file = "mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780"}, {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd"}, {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08"}, {file = "mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081"}, {file = "mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7"}, {file = "mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6"}, {file = "mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289"}, {file = "mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633"}, ] [package.dependencies] ast-serialize = ">=0.3.0,<1.0.0" librt = {version = ">=0.11.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=1.0.0" typing_extensions = [ {version = ">=4.6.0", markers = "python_version < \"3.15\""}, {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, ] [package.extras] dmypy = ["psutil (>=4.0)"] faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] name = "mypy-extensions" version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] [[package]] name = "nodeenv" version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] [[package]] name = "orjson" version = "3.11.9" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce"}, {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd"}, {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4"}, {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4"}, {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e"}, {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb"}, {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47"}, {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d"}, {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13"}, {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92"}, {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48"}, {file = "orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94"}, {file = "orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244"}, {file = "orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f"}, {file = "orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c"}, {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5"}, {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c"}, {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd"}, {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62"}, {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877"}, {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1"}, {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd"}, {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff"}, {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980"}, {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2"}, {file = "orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180"}, {file = "orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02"}, {file = "orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697"}, {file = "orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49"}, {file = "orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291"}, {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09"}, {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4"}, {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882"}, {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff"}, {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe"}, {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61"}, {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2"}, {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206"}, {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f"}, {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa"}, {file = "orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470"}, {file = "orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be"}, {file = "orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624"}, {file = "orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021"}, {file = "orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c"}, {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81"}, {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a"}, {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10"}, {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362"}, {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97"}, {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218"}, {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9"}, {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677"}, {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4"}, {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0"}, {file = "orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32"}, {file = "orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979"}, {file = "orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254"}, {file = "orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e"}, {file = "orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e"}, {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0"}, {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124"}, {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c"}, {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7"}, {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1"}, {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db"}, {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b"}, {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972"}, {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0"}, {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586"}, {file = "orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673"}, {file = "orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b"}, {file = "orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9"}, {file = "orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f"}, ] [[package]] name = "packaging" version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "dev", "docs"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] name = "paginate" version = "0.5.7" description = "Divides large result sets into pages for easier browsing" optional = false python-versions = "*" groups = ["docs"] files = [ {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, ] [package.extras] dev = ["pytest", "tox"] lint = ["black"] [[package]] name = "pathspec" version = "1.0.4" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" groups = ["dev", "docs"] files = [ {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, ] [package.extras] hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "pillow" version = "12.3.0" description = "Python Imaging Library (fork)" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a"}, {file = "pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7"}, {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f"}, {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec"}, {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468"}, {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed"}, {file = "pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1"}, {file = "pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb"}, {file = "pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f"}, {file = "pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756"}, {file = "pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6"}, {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd"}, {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd"}, {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c"}, {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5"}, {file = "pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b"}, {file = "pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a"}, {file = "pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26"}, {file = "pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"}, {file = "pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"}, {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"}, {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"}, {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c"}, {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df"}, {file = "pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f"}, {file = "pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"}, {file = "pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510"}, {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89"}, {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace"}, {file = "pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec"}, {file = "pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66"}, {file = "pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35"}, {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65"}, {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3"}, {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a"}, {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e"}, {file = "pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f"}, {file = "pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8"}, {file = "pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b"}, {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330"}, {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217"}, {file = "pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930"}, {file = "pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8"}, {file = "pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0"}, {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321"}, {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b"}, {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198"}, {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130"}, {file = "pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a"}, {file = "pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d"}, {file = "pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838"}, {file = "pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e"}, {file = "pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17"}, {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385"}, {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c"}, {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d"}, {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931"}, {file = "pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7"}, {file = "pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c"}, {file = "pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45"}, {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139"}, {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402"}, {file = "pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c"}, {file = "pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f"}, {file = "pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701"}, {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace"}, {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4"}, {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39"}, {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71"}, {file = "pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827"}, {file = "pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5"}, {file = "pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658"}, {file = "pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf"}, {file = "pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64"}, {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e"}, {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777"}, {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1"}, {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9"}, {file = "pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8"}, {file = "pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418"}, {file = "pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59"}, {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468"}, {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94"}, {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e"}, {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3"}, {file = "pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a"}, {file = "pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce"}, ] [package.extras] docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] name = "platformdirs" version = "4.11.4" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["main", "dev", "docs"] files = [ {file = "platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586"}, {file = "platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d"}, ] [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pre-commit" version = "4.6.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"}, {file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"}, ] [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" [[package]] name = "propcache" version = "0.5.2" description = "Accelerated property cache" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"}, {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"}, {file = "propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb"}, {file = "propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e"}, {file = "propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e"}, {file = "propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b"}, {file = "propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d"}, {file = "propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d"}, {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0"}, {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b"}, {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf"}, {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf"}, {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e"}, {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274"}, {file = "propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe"}, {file = "propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d"}, {file = "propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5"}, {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78"}, {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959"}, {file = "propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7"}, {file = "propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511"}, {file = "propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660"}, {file = "propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66"}, {file = "propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b"}, {file = "propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67"}, {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f"}, {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c"}, {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0"}, {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6"}, {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27"}, {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f"}, {file = "propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0"}, {file = "propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82"}, {file = "propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab"}, {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba"}, {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a"}, {file = "propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf"}, {file = "propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144"}, {file = "propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9"}, {file = "propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42"}, {file = "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476"}, {file = "propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba"}, {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a"}, {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64"}, {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913"}, {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1"}, {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33"}, {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a"}, {file = "propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031"}, {file = "propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42"}, {file = "propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84"}, {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a"}, {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117"}, {file = "propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098"}, {file = "propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4"}, {file = "propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e"}, {file = "propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7"}, {file = "propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d"}, {file = "propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a"}, {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2"}, {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa"}, {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853"}, {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a"}, {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704"}, {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4"}, {file = "propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d"}, {file = "propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757"}, {file = "propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f"}, {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d"}, {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa"}, {file = "propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94"}, {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164"}, {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f"}, {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c"}, {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc"}, {file = "propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f"}, {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb"}, {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751"}, {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836"}, {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f"}, {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55"}, {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568"}, {file = "propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191"}, {file = "propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7"}, {file = "propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96"}, {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999"}, {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e"}, {file = "propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539"}, {file = "propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e"}, {file = "propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979"}, {file = "propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80"}, {file = "propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825"}, {file = "propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39"}, {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4"}, {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5"}, {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702"}, {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3"}, {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5"}, {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4"}, {file = "propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0"}, {file = "propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c"}, {file = "propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0"}, {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb"}, {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078"}, {file = "propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa"}, {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917"}, {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe"}, {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03"}, {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335"}, {file = "propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285"}, {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837"}, {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8"}, {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366"}, {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56"}, {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d"}, {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2"}, {file = "propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821"}, {file = "propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370"}, {file = "propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6"}, {file = "propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe"}, {file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"}, ] [[package]] name = "py-cpuinfo" version = "9.0.0" description = "Get CPU info with pure Python" optional = false python-versions = "*" groups = ["dev"] files = [ {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"}, {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, ] [[package]] name = "pydantic" version = "2.13.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, ] [package.dependencies] annotated-types = ">=0.6.0" pydantic-core = "2.46.4" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" version = "2.46.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, ] [package.dependencies] typing-extensions = ">=4.14.1" [[package]] name = "pydantic-extra-types" version = "2.11.2" description = "Extra Pydantic types." optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "pydantic_extra_types-2.11.2-py3-none-any.whl", hash = "sha256:683b8943252543e49760f89733b1519bc62f31d1a287ebbdc5a7b7959fb4acfd"}, {file = "pydantic_extra_types-2.11.2.tar.gz", hash = "sha256:3a2b83b61fe920925688e7838b59caa90a45637d1dbba2b1364b8d1f7ff72a0a"}, ] [package.dependencies] pydantic = ">=2.5.2" typing-extensions = "*" [package.extras] all = ["cron-converter (>=1.2.2)", "jsonschema (>=4.0.0)", "pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<10)", "pycountry (>=23)", "pymongo (>=4.0.0,<5.0.0)", "python-ulid (>=1,<2) ; python_version < \"3.9\"", "python-ulid (>=1,<4) ; python_version >= \"3.9\"", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)", "uuid-utils (>=0.6.0) ; python_version < \"3.14\""] cron = ["cron-converter (>=1.2.2)"] jsonschema = ["jsonschema (>=4.0.0)"] pendulum = ["pendulum (>=3.0.0,<4.0.0)"] phonenumbers = ["phonenumbers (>=8,<10)"] pycountry = ["pycountry (>=23)"] python-ulid = ["python-ulid (>=1,<2) ; python_version < \"3.9\"", "python-ulid (>=1,<4) ; python_version >= \"3.9\""] semver = ["semver (>=3.0.2)"] uuid-utils = ["uuid-utils (>=0.6.0) ; python_version < \"3.14\""] [[package]] name = "pygments" version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" groups = ["main", "dev", "docs"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] markers = {main = "extra == \"cli\""} [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.extras] crypto = ["cryptography (>=3.4.0)"] [[package]] name = "pymdown-extensions" version = "11.0.1" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2"}, {file = "pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0"}, ] [package.dependencies] markdown = ">=3.6" pyyaml = "*" [package.extras] extra = ["pygments (>=2.19.1)"] [[package]] name = "pyparsing" version = "3.2.5" description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, ] [package.extras] diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" version = "1.4.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, ] [package.dependencies] pytest = ">=8.4,<10" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-benchmark" version = "5.2.3" description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803"}, {file = "pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779"}, ] [package.dependencies] py-cpuinfo = "*" pytest = ">=8.1" [package.extras] aspect = ["aspectlib"] elasticsearch = ["elasticsearch"] histogram = ["pygal", "pygaljs", "setuptools"] [[package]] name = "pytest-codspeed" version = "5.0.3" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_codspeed-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:005348ea52ace3ede2e2f595913912ad2564cca7b124211a88dc78a9cb1fca63"}, {file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbe6a4a00b449b6ba2771f644cbc38bdf55acf5c812e60e5659110e19dd9f510"}, {file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac4344f34bbcdd17f6f8c30dbac3da2f80d223dd112e568fd7f7c2cd4cbc693"}, {file = "pytest_codspeed-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f56d0339cd98d26f6e561987be25bdd2761a5d53d8f73493b1ebe02d0d451093"}, {file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c682f6645d4eb472f3bd95dbda1805e3af4243610572cb7d6bf94a88e8a0b6c"}, {file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f852bee785a7a124cb1720b1915670c6742af87747dc4d838f3ffdbd365ce9d9"}, {file = "pytest_codspeed-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2eeb25fb1ac3f73c4de50e739e78fea396b89782bdb740bf2a7cd2df21f8d4ee"}, {file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73c5c9d98a3372a42611989ccfa437cce3842431ac6d6b9ab42c4f0e59c070f7"}, {file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2e0ab65df73e837666d12357280ca50ff6d6ac03ea5266703be518b68170edf"}, {file = "pytest_codspeed-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6524c57fec279a22ffef6112af404036afc71b4704758ae9f0abda429b8478d4"}, {file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c383c9121deb58a69f174188e9e4488ffc0daced0ed276abf87747182511901"}, {file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4bcdb4b6522738152885ef067e0c8524d5699828d780fb6f464cdb3db44369c"}, {file = "pytest_codspeed-5.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25464363c7f9b9bd5022e969c0addba616fa40ac9b8f0fc9e030c4538863b32d"}, {file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efd43f82ea03ced8488a767ded9473f050791ab7783ea8654107e1e0ac66af40"}, {file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:782f9985b6f6b45b8bc20152d206d3a52b56dd088ba81cb70a71f0b39841be9e"}, {file = "pytest_codspeed-5.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9aa0815b90196f3c20d736ea8691381e97f12bbe8c7d87af10a351e434b452cb"}, {file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85505c96a3477c346ec2d2b7dced8478f4c651e2b1666ee102d53a832b511853"}, {file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20eba63765be9d1b6cacbbfad84b87d49eb04b357a7045a0899880da181f81e3"}, {file = "pytest_codspeed-5.0.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ec9fa6f0af0a9feb0e0bd517fb59ef28f806fbd50c0c6900ac26cbb4d080eba5"}, {file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8df77b3409f54f4a268f77f3ff74992fe1d995cdbaf2cecf8ad74d32db217ce7"}, {file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d8695a227ea1c3a41d25db5b3fe720bf1b4808bd38862be811a4efd902c792"}, {file = "pytest_codspeed-5.0.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bf4cc4178cbace8f4d2bd240408276bc4da3850ac5fcb5fb5f8a74ab417615bb"}, {file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abe793da40f87295d33988673d34f06ea569848b44490b847552cd416816258a"}, {file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3a9ed38dfa776443b86f4b49a982e8443d0953db4974bd2673d63cc904ae1ad"}, {file = "pytest_codspeed-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bce0a6ea93a5b19658f713312bb67554c19283ab15b454a1e3e55a13e78130f8"}, {file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a2097247985f5d915a94b80c5552d10979ca858c859fc3edef1bf2baa5c9b7a"}, {file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e192905a2230f9956e6160732f76577836953a4a1fb2b1e7be74e51ac7b2a0"}, {file = "pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378"}, {file = "pytest_codspeed-5.0.3.tar.gz", hash = "sha256:91afef90e6a96b013495e4702ef5d6358614a449e71008cdc194ef668778b92f"}, ] [package.dependencies] pytest = ">=3.8" rich = ">=13.8.1" [package.extras] compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"] [[package]] name = "pytest-cov" version = "7.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, ] [package.dependencies] coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-sugar" version = "1.1.1" description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." optional = false python-versions = "*" groups = ["dev"] files = [ {file = "pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d"}, {file = "pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8"}, ] [package.dependencies] pytest = ">=6.2.0" termcolor = ">=2.1.0" [package.extras] dev = ["black", "flake8", "pre-commit"] [[package]] name = "pytest-timeout" version = "2.4.0" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, ] [package.dependencies] pytest = ">=7.0.0" [[package]] name = "pytest-xdist" version = "3.8.0" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, ] [package.dependencies] execnet = ">=2.1" pytest = ">=7.0.0" [package.extras] psutil = ["psutil (>=3.0)"] setproctitle = ["setproctitle"] testing = ["filelock"] [[package]] name = "python-dateutil" version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main", "docs"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] markers = {main = "extra == \"cli\""} [package.dependencies] six = ">=1.5" [[package]] name = "pytz" version = "2025.2" description = "World timezone definitions, modern and historical" optional = true python-versions = "*" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, ] [[package]] name = "pyyaml" version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] name = "pyyaml-env-tag" version = "1.1" description = "A custom YAML tag for referencing environment variables in YAML files." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, ] [package.dependencies] pyyaml = "*" [[package]] name = "regex" version = "2025.9.18" description = "Alternative regular expression module, to replace re." optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "regex-2025.9.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:12296202480c201c98a84aecc4d210592b2f55e200a1d193235c4db92b9f6788"}, {file = "regex-2025.9.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:220381f1464a581f2ea988f2220cf2a67927adcef107d47d6897ba5a2f6d51a4"}, {file = "regex-2025.9.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87f681bfca84ebd265278b5daa1dcb57f4db315da3b5d044add7c30c10442e61"}, {file = "regex-2025.9.18-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34d674cbba70c9398074c8a1fcc1a79739d65d1105de2a3c695e2b05ea728251"}, {file = "regex-2025.9.18-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:385c9b769655cb65ea40b6eea6ff763cbb6d69b3ffef0b0db8208e1833d4e746"}, {file = "regex-2025.9.18-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8900b3208e022570ae34328712bef6696de0804c122933414014bae791437ab2"}, {file = "regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c204e93bf32cd7a77151d44b05eb36f469d0898e3fba141c026a26b79d9914a0"}, {file = "regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3acc471d1dd7e5ff82e6cacb3b286750decd949ecd4ae258696d04f019817ef8"}, {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6479d5555122433728760e5f29edb4c2b79655a8deb681a141beb5c8a025baea"}, {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:431bd2a8726b000eb6f12429c9b438a24062a535d06783a93d2bcbad3698f8a8"}, {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0cc3521060162d02bd36927e20690129200e5ac9d2c6d32b70368870b122db25"}, {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a021217b01be2d51632ce056d7a837d3fa37c543ede36e39d14063176a26ae29"}, {file = "regex-2025.9.18-cp310-cp310-win32.whl", hash = "sha256:4a12a06c268a629cb67cc1d009b7bb0be43e289d00d5111f86a2efd3b1949444"}, {file = "regex-2025.9.18-cp310-cp310-win_amd64.whl", hash = "sha256:47acd811589301298c49db2c56bde4f9308d6396da92daf99cba781fa74aa450"}, {file = "regex-2025.9.18-cp310-cp310-win_arm64.whl", hash = "sha256:16bd2944e77522275e5ee36f867e19995bcaa533dcb516753a26726ac7285442"}, {file = "regex-2025.9.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:51076980cd08cd13c88eb7365427ae27f0d94e7cebe9ceb2bb9ffdae8fc4d82a"}, {file = "regex-2025.9.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:828446870bd7dee4e0cbeed767f07961aa07f0ea3129f38b3ccecebc9742e0b8"}, {file = "regex-2025.9.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28821d5637866479ec4cc23b8c990f5bc6dd24e5e4384ba4a11d38a526e1414"}, {file = "regex-2025.9.18-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726177ade8e481db669e76bf99de0b278783be8acd11cef71165327abd1f170a"}, {file = "regex-2025.9.18-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5cca697da89b9f8ea44115ce3130f6c54c22f541943ac8e9900461edc2b8bd4"}, {file = "regex-2025.9.18-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dfbde38f38004703c35666a1e1c088b778e35d55348da2b7b278914491698d6a"}, {file = "regex-2025.9.18-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2f422214a03fab16bfa495cfec72bee4aaa5731843b771860a471282f1bf74f"}, {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a295916890f4df0902e4286bc7223ee7f9e925daa6dcdec4192364255b70561a"}, {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5db95ff632dbabc8c38c4e82bf545ab78d902e81160e6e455598014f0abe66b9"}, {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb967eb441b0f15ae610b7069bdb760b929f267efbf522e814bbbfffdf125ce2"}, {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f04d2f20da4053d96c08f7fde6e1419b7ec9dbcee89c96e3d731fca77f411b95"}, {file = "regex-2025.9.18-cp311-cp311-win32.whl", hash = "sha256:895197241fccf18c0cea7550c80e75f185b8bd55b6924fcae269a1a92c614a07"}, {file = "regex-2025.9.18-cp311-cp311-win_amd64.whl", hash = "sha256:7e2b414deae99166e22c005e154a5513ac31493db178d8aec92b3269c9cce8c9"}, {file = "regex-2025.9.18-cp311-cp311-win_arm64.whl", hash = "sha256:fb137ec7c5c54f34a25ff9b31f6b7b0c2757be80176435bf367111e3f71d72df"}, {file = "regex-2025.9.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:436e1b31d7efd4dcd52091d076482031c611dde58bf9c46ca6d0a26e33053a7e"}, {file = "regex-2025.9.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c190af81e5576b9c5fdc708f781a52ff20f8b96386c6e2e0557a78402b029f4a"}, {file = "regex-2025.9.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4121f1ce2b2b5eec4b397cc1b277686e577e658d8f5870b7eb2d726bd2300ab"}, {file = "regex-2025.9.18-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:300e25dbbf8299d87205e821a201057f2ef9aa3deb29caa01cd2cac669e508d5"}, {file = "regex-2025.9.18-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b47fcf9f5316c0bdaf449e879407e1b9937a23c3b369135ca94ebc8d74b1742"}, {file = "regex-2025.9.18-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:57a161bd3acaa4b513220b49949b07e252165e6b6dc910ee7617a37ff4f5b425"}, {file = "regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f130c3a7845ba42de42f380fff3c8aebe89a810747d91bcf56d40a069f15352"}, {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f96fa342b6f54dcba928dd452e8d8cb9f0d63e711d1721cd765bb9f73bb048d"}, {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f0d676522d68c207828dcd01fb6f214f63f238c283d9f01d85fc664c7c85b56"}, {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40532bff8a1a0621e7903ae57fce88feb2e8a9a9116d341701302c9302aef06e"}, {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:039f11b618ce8d71a1c364fdee37da1012f5a3e79b1b2819a9f389cd82fd6282"}, {file = "regex-2025.9.18-cp312-cp312-win32.whl", hash = "sha256:e1dd06f981eb226edf87c55d523131ade7285137fbde837c34dc9d1bf309f459"}, {file = "regex-2025.9.18-cp312-cp312-win_amd64.whl", hash = "sha256:3d86b5247bf25fa3715e385aa9ff272c307e0636ce0c9595f64568b41f0a9c77"}, {file = "regex-2025.9.18-cp312-cp312-win_arm64.whl", hash = "sha256:032720248cbeeae6444c269b78cb15664458b7bb9ed02401d3da59fe4d68c3a5"}, {file = "regex-2025.9.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a40f929cd907c7e8ac7566ac76225a77701a6221bca937bdb70d56cb61f57b2"}, {file = "regex-2025.9.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c90471671c2cdf914e58b6af62420ea9ecd06d1554d7474d50133ff26ae88feb"}, {file = "regex-2025.9.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a351aff9e07a2dabb5022ead6380cff17a4f10e4feb15f9100ee56c4d6d06af"}, {file = "regex-2025.9.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc4b8e9d16e20ddfe16430c23468a8707ccad3365b06d4536142e71823f3ca29"}, {file = "regex-2025.9.18-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b8cdbddf2db1c5e80338ba2daa3cfa3dec73a46fff2a7dda087c8efbf12d62f"}, {file = "regex-2025.9.18-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a276937d9d75085b2c91fb48244349c6954f05ee97bba0963ce24a9d915b8b68"}, {file = "regex-2025.9.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92a8e375ccdc1256401c90e9dc02b8642894443d549ff5e25e36d7cf8a80c783"}, {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dc6893b1f502d73037cf807a321cdc9be29ef3d6219f7970f842475873712ac"}, {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a61e85bfc63d232ac14b015af1261f826260c8deb19401c0597dbb87a864361e"}, {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ef86a9ebc53f379d921fb9a7e42b92059ad3ee800fcd9e0fe6181090e9f6c23"}, {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d3bc882119764ba3a119fbf2bd4f1b47bc56c1da5d42df4ed54ae1e8e66fdf8f"}, {file = "regex-2025.9.18-cp313-cp313-win32.whl", hash = "sha256:3810a65675845c3bdfa58c3c7d88624356dd6ee2fc186628295e0969005f928d"}, {file = "regex-2025.9.18-cp313-cp313-win_amd64.whl", hash = "sha256:16eaf74b3c4180ede88f620f299e474913ab6924d5c4b89b3833bc2345d83b3d"}, {file = "regex-2025.9.18-cp313-cp313-win_arm64.whl", hash = "sha256:4dc98ba7dd66bd1261927a9f49bd5ee2bcb3660f7962f1ec02617280fc00f5eb"}, {file = "regex-2025.9.18-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fe5d50572bc885a0a799410a717c42b1a6b50e2f45872e2b40f4f288f9bce8a2"}, {file = "regex-2025.9.18-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b9d9a2d6cda6621551ca8cf7a06f103adf72831153f3c0d982386110870c4d3"}, {file = "regex-2025.9.18-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:13202e4c4ac0ef9a317fff817674b293c8f7e8c68d3190377d8d8b749f566e12"}, {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874ff523b0fecffb090f80ae53dc93538f8db954c8bb5505f05b7787ab3402a0"}, {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d13ab0490128f2bb45d596f754148cd750411afc97e813e4b3a61cf278a23bb6"}, {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:05440bc172bc4b4b37fb9667e796597419404dbba62e171e1f826d7d2a9ebcef"}, {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5514b8e4031fdfaa3d27e92c75719cbe7f379e28cacd939807289bce76d0e35a"}, {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:65d3c38c39efce73e0d9dc019697b39903ba25b1ad45ebbd730d2cf32741f40d"}, {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae77e447ebc144d5a26d50055c6ddba1d6ad4a865a560ec7200b8b06bc529368"}, {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3ef8cf53dc8df49d7e28a356cf824e3623764e9833348b655cfed4524ab8a90"}, {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9feb29817df349c976da9a0debf775c5c33fc1c8ad7b9f025825da99374770b7"}, {file = "regex-2025.9.18-cp313-cp313t-win32.whl", hash = "sha256:168be0d2f9b9d13076940b1ed774f98595b4e3c7fc54584bba81b3cc4181742e"}, {file = "regex-2025.9.18-cp313-cp313t-win_amd64.whl", hash = "sha256:d59ecf3bb549e491c8104fea7313f3563c7b048e01287db0a90485734a70a730"}, {file = "regex-2025.9.18-cp313-cp313t-win_arm64.whl", hash = "sha256:dbef80defe9fb21310948a2595420b36c6d641d9bea4c991175829b2cc4bc06a"}, {file = "regex-2025.9.18-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c6db75b51acf277997f3adcd0ad89045d856190d13359f15ab5dda21581d9129"}, {file = "regex-2025.9.18-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8f9698b6f6895d6db810e0bda5364f9ceb9e5b11328700a90cae573574f61eea"}, {file = "regex-2025.9.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29cd86aa7cb13a37d0f0d7c21d8d949fe402ffa0ea697e635afedd97ab4b69f1"}, {file = "regex-2025.9.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c9f285a071ee55cd9583ba24dde006e53e17780bb309baa8e4289cd472bcc47"}, {file = "regex-2025.9.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5adf266f730431e3be9021d3e5b8d5ee65e563fec2883ea8093944d21863b379"}, {file = "regex-2025.9.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1137cabc0f38807de79e28d3f6e3e3f2cc8cfb26bead754d02e6d1de5f679203"}, {file = "regex-2025.9.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cc9e5525cada99699ca9223cce2d52e88c52a3d2a0e842bd53de5497c604164"}, {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bbb9246568f72dce29bcd433517c2be22c7791784b223a810225af3b50d1aafb"}, {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6a52219a93dd3d92c675383efff6ae18c982e2d7651c792b1e6d121055808743"}, {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ae9b3840c5bd456780e3ddf2f737ab55a79b790f6409182012718a35c6d43282"}, {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d488c236ac497c46a5ac2005a952c1a0e22a07be9f10c3e735bc7d1209a34773"}, {file = "regex-2025.9.18-cp314-cp314-win32.whl", hash = "sha256:0c3506682ea19beefe627a38872d8da65cc01ffa25ed3f2e422dffa1474f0788"}, {file = "regex-2025.9.18-cp314-cp314-win_amd64.whl", hash = "sha256:57929d0f92bebb2d1a83af372cd0ffba2263f13f376e19b1e4fa32aec4efddc3"}, {file = "regex-2025.9.18-cp314-cp314-win_arm64.whl", hash = "sha256:6a4b44df31d34fa51aa5c995d3aa3c999cec4d69b9bd414a8be51984d859f06d"}, {file = "regex-2025.9.18-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b176326bcd544b5e9b17d6943f807697c0cb7351f6cfb45bf5637c95ff7e6306"}, {file = "regex-2025.9.18-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0ffd9e230b826b15b369391bec167baed57c7ce39efc35835448618860995946"}, {file = "regex-2025.9.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec46332c41add73f2b57e2f5b642f991f6b15e50e9f86285e08ffe3a512ac39f"}, {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b80fa342ed1ea095168a3f116637bd1030d39c9ff38dc04e54ef7c521e01fc95"}, {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4d97071c0ba40f0cf2a93ed76e660654c399a0a04ab7d85472239460f3da84b"}, {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0ac936537ad87cef9e0e66c5144484206c1354224ee811ab1519a32373e411f3"}, {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec57f96d4def58c422d212d414efe28218d58537b5445cf0c33afb1b4768571"}, {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48317233294648bf7cd068857f248e3a57222259a5304d32c7552e2284a1b2ad"}, {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:274687e62ea3cf54846a9b25fc48a04459de50af30a7bd0b61a9e38015983494"}, {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a78722c86a3e7e6aadf9579e3b0ad78d955f2d1f1a8ca4f67d7ca258e8719d4b"}, {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06104cd203cdef3ade989a1c45b6215bf42f8b9dd705ecc220c173233f7cba41"}, {file = "regex-2025.9.18-cp314-cp314t-win32.whl", hash = "sha256:2e1eddc06eeaffd249c0adb6fafc19e2118e6308c60df9db27919e96b5656096"}, {file = "regex-2025.9.18-cp314-cp314t-win_amd64.whl", hash = "sha256:8620d247fb8c0683ade51217b459cb4a1081c0405a3072235ba43a40d355c09a"}, {file = "regex-2025.9.18-cp314-cp314t-win_arm64.whl", hash = "sha256:b7531a8ef61de2c647cdf68b3229b071e46ec326b3138b2180acb4275f470b01"}, {file = "regex-2025.9.18-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3dbcfcaa18e9480669030d07371713c10b4f1a41f791ffa5cb1a99f24e777f40"}, {file = "regex-2025.9.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1e85f73ef7095f0380208269055ae20524bfde3f27c5384126ddccf20382a638"}, {file = "regex-2025.9.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9098e29b3ea4ffffeade423f6779665e2a4f8db64e699c0ed737ef0db6ba7b12"}, {file = "regex-2025.9.18-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90b6b7a2d0f45b7ecaaee1aec6b362184d6596ba2092dd583ffba1b78dd0231c"}, {file = "regex-2025.9.18-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c81b892af4a38286101502eae7aec69f7cd749a893d9987a92776954f3943408"}, {file = "regex-2025.9.18-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3b524d010973f2e1929aeb635418d468d869a5f77b52084d9f74c272189c251d"}, {file = "regex-2025.9.18-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b498437c026a3d5d0be0020023ff76d70ae4d77118e92f6f26c9d0423452446"}, {file = "regex-2025.9.18-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0716e4d6e58853d83f6563f3cf25c281ff46cf7107e5f11879e32cb0b59797d9"}, {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:065b6956749379d41db2625f880b637d4acc14c0a4de0d25d609a62850e96d36"}, {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d4a691494439287c08ddb9b5793da605ee80299dd31e95fa3f323fac3c33d9d4"}, {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ef8d10cc0989565bcbe45fb4439f044594d5c2b8919d3d229ea2c4238f1d55b0"}, {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4baeb1b16735ac969a7eeecc216f1f8b7caf60431f38a2671ae601f716a32d25"}, {file = "regex-2025.9.18-cp39-cp39-win32.whl", hash = "sha256:8e5f41ad24a1e0b5dfcf4c4e5d9f5bd54c895feb5708dd0c1d0d35693b24d478"}, {file = "regex-2025.9.18-cp39-cp39-win_amd64.whl", hash = "sha256:50e8290707f2fb8e314ab3831e594da71e062f1d623b05266f8cfe4db4949afd"}, {file = "regex-2025.9.18-cp39-cp39-win_arm64.whl", hash = "sha256:039a9d7195fd88c943d7c777d4941e8ef736731947becce773c31a1009cb3c35"}, {file = "regex-2025.9.18.tar.gz", hash = "sha256:c5ba23274c61c6fef447ba6a39333297d0c247f53059dba0bca415cac511edc4"}, ] [[package]] name = "requests" version = "2.33.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, ] [package.dependencies] certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rich" version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.9.0" groups = ["main", "dev"] files = [ {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] markers = {main = "extra == \"cli\""} [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "shellingham" version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = true python-versions = ">=3.7" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] [[package]] name = "six" version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main", "docs"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] markers = {main = "extra == \"cli\""} [[package]] name = "smmap" version = "5.0.2" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, ] [[package]] name = "termcolor" version = "3.1.0" description = "ANSI color formatting for output in terminal" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa"}, {file = "termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970"}, ] [package.extras] tests = ["pytest", "pytest-cov"] [[package]] name = "typer" version = "0.27.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56"}, {file = "typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df"}, ] [package.dependencies] annotated-doc = ">=0.0.2" colorama = {version = "*", markers = "platform_system == \"Windows\""} rich = ">=13.8.0" shellingham = ">=1.3.0" [[package]] name = "types-aiofiles" version = "25.1.0.20260518" description = "Typing stubs for aiofiles" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "types_aiofiles-25.1.0.20260518-py3-none-any.whl", hash = "sha256:f776bdfb4bec17f743d9ef042e61edf03bdcc7821fc08556fba9b63d873fdea9"}, {file = "types_aiofiles-25.1.0.20260518.tar.gz", hash = "sha256:c0c95eb78755d4fa7b397d4f0332c632714dd7cd0d17f49b96e31d4d7a8d8c76"}, ] [[package]] name = "types-dateparser" version = "1.4.1.20260617" description = "Typing stubs for dateparser" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "types_dateparser-1.4.1.20260617-py3-none-any.whl", hash = "sha256:af5619f005b56880898c54a9cb25bbc1c145751710d456be4f94c50751316ddb"}, {file = "types_dateparser-1.4.1.20260617.tar.gz", hash = "sha256:aaf1958a88e2bca849ff36b4696476c829c7b0ad06ff8d473635a8a985468346"}, ] [[package]] name = "typing-extensions" version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "typing-inspection" version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] typing-extensions = ">=4.12.0" [[package]] name = "tzdata" version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main", "docs"] files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] markers = {docs = "sys_platform == \"win32\""} [[package]] name = "tzlocal" version = "5.3.1" description = "tzinfo object for the local timezone" optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"cli\"" files = [ {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, ] [package.dependencies] tzdata = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] [[package]] name = "urllib3" version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "verspec" version = "0.1.0" description = "Flexible version handling" optional = false python-versions = "*" groups = ["docs"] files = [ {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, ] [package.extras] test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] [[package]] name = "virtualenv" version = "20.36.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "watchdog" version = "6.0.0" description = "Filesystem events monitoring" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, ] [package.extras] watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "wcmatch" version = "10.1" description = "Wildcard/glob file name matcher." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a"}, {file = "wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af"}, ] [package.dependencies] bracex = ">=2.1.1" [[package]] name = "yarl" version = "1.24.5" description = "Yet another URL library" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"}, {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"}, {file = "yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871"}, {file = "yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0"}, {file = "yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e"}, {file = "yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2"}, {file = "yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621"}, {file = "yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba"}, {file = "yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950"}, {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00"}, {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed"}, {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440"}, {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1"}, {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6"}, {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d"}, {file = "yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224"}, {file = "yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13"}, {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3"}, {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a"}, {file = "yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840"}, {file = "yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966"}, {file = "yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723"}, {file = "yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb"}, {file = "yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780"}, {file = "yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e"}, {file = "yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2"}, {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58"}, {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61"}, {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6"}, {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f"}, {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077"}, {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd"}, {file = "yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25"}, {file = "yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a"}, {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d"}, {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec"}, {file = "yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c"}, {file = "yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54"}, {file = "yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12"}, {file = "yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d"}, {file = "yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1"}, {file = "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9"}, {file = "yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027"}, {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b"}, {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293"}, {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e"}, {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b"}, {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce"}, {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba"}, {file = "yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b"}, {file = "yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c"}, {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2"}, {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb"}, {file = "yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075"}, {file = "yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff"}, {file = "yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448"}, {file = "yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f"}, {file = "yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd"}, {file = "yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16"}, {file = "yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213"}, {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24"}, {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385"}, {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c"}, {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4"}, {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144"}, {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4"}, {file = "yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740"}, {file = "yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1"}, {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76"}, {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d"}, {file = "yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75"}, {file = "yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9"}, {file = "yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede"}, {file = "yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca"}, {file = "yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027"}, {file = "yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9"}, {file = "yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41"}, {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373"}, {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36"}, {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0"}, {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5"}, {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5"}, {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4"}, {file = "yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad"}, {file = "yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f"}, {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88"}, {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba"}, {file = "yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928"}, {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f"}, {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95"}, {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc"}, {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da"}, {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a"}, {file = "yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0"}, {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498"}, {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104"}, {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331"}, {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550"}, {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6"}, {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047"}, {file = "yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104"}, {file = "yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688"}, {file = "yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7"}, {file = "yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f"}, ] [package.dependencies] idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.1" [extras] cli = ["dateparser", "pillow", "rich", "typer"] [metadata] lock-version = "2.1" python-versions = ">=3.11" content-hash = "d4ef814cf1a63a9d01f3d416928f0493d432320ae41d3ae84ade9756c4092638" uilibs-uiprotect-317a52c/pyproject.toml000066400000000000000000000216021524637705700203010ustar00rootroot00000000000000[project] name = "uiprotect" version = "16.10.0" license = "MIT" description = "Python API for Unifi Protect (Unofficial)" authors = [{ name = "UI Protect Maintainers", email = "ui@koston.org" }] readme = "README.md" requires-python = ">=3.11" dynamic = ["classifiers", "dependencies"] [project.optional-dependencies] cli = [ "typer>=0.27.0", "rich>=15.0.0", "dateparser>=1.4.1", "pillow>=12.3.0", ] [project.urls] "Repository" = "https://github.com/uilibs/uiprotect" "Documentation" = "https://uiprotect.readthedocs.io" "Bug Tracker" = "https://github.com/uilibs/uiprotect/issues" "Changelog" = "https://github.com/uilibs/uiprotect/blob/main/CHANGELOG.md" [project.scripts] uiprotect = "uiprotect.cli:app" [tool.poetry] classifiers = [ "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Build Tools", "Development Status :: 5 - Production/Stable", ] packages = [ { include = "uiprotect", from = "src" }, ] [tool.poetry.dependencies] python = ">=3.11" rich = { version = ">=15.0.0", optional = true } aiofiles = ">=24" aiohttp = ">=3.14.3" av = ">=17.0.1" dateparser = { version = ">=1.4.1", optional = true } orjson = ">=3.11.9" packaging = ">=26.2" pillow = { version = ">=12.3.0", optional = true } platformdirs = ">=4.10.1" pydantic = ">=2.13.4" # Temporarily lowered from >=2.13.0 so the Home Assistant lib bump is not # blocked on home-assistant/core#172893 (PyJWT 2.12->2.13). uiprotect only # decodes with verify_signature=False, so the 2.13.0 change is irrelevant # here; re-raise once that PR lands. pyjwt = ">=2.12.1" yarl = ">=1.24.5" typer = { version = ">=0.27.0", optional = true } convertertools = ">=0.5.0" propcache = ">=0.5.2" pydantic-extra-types = ">=2.10.1" aiozoneinfo = ">=0.2.3" [tool.poetry.group.dev.dependencies] pytest = ">=9.1.1,<10" pytest-cov = ">=3,<8" aiosqlite = ">=0.20.0" pytest-asyncio = ">=1.4.0,<1.5.0" pytest-benchmark = ">=4,<6" pytest-codspeed = "^5.0.3" pytest-sugar = "^1.1.1" pytest-timeout = "^2.4.0" pytest-xdist = "^3.7.0" types-aiofiles = ">=25.1.0.20260518,<26.0.0.0" types-dateparser = "^1.4.1.20260617" mypy = "^2.1.0" pre-commit = "^4.6.0" blockbuster = ">=1.5.5,<2.0.0" [tool.poetry.group.docs] optional = true [tool.poetry.group.docs.dependencies] mike = "^2.2.0" mkdocs-material = "^9.7.7" mkdocs-material-extensions = "^1.3.1" pymdown-extensions = "^11.0.1" mkdocs-git-revision-date-localized-plugin = "^1.5.3" mkdocs-include-markdown-plugin = ">=7.3.0,<8.0.0" mkdocstrings = ">=1.0.6,<1.1.0" mkdocstrings-python = "^2.0.5" [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] version_variables = [ "src/uiprotect/__init__.py:__version__", ] build_command = "pip install poetry && poetry build" [tool.semantic_release.changelog] exclude_commit_patterns = [ "chore*", "ci*", ] [tool.semantic_release.changelog.environment] keep_trailing_newline = true [tool.semantic_release.branches.main] match = "main" [tool.semantic_release.branches.noop] match = "(?!main$)" prerelease = true [tool.pytest.ini_options] addopts = "-v -Wdefault --cov=uiprotect --cov-report=term-missing:skip-covered -n=auto" pythonpath = ["src"] [tool.codespell] skip = "poetry.lock,*.lock,*/sample_data/*,*/openapi/*,CHANGELOG.md,*/site/*,site/*" # domain terms / protocol field names that are not English typos: # - aiport: UniFi AiPort device codename (AiPort/aiport/AIPORT) # - tempature: misspelled wire-format disk-health key served by Protect firmware ignore-words-list = "aiport,tempature" [tool.coverage.run] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "@overload", "if TYPE_CHECKING", "raise NotImplementedError", 'if __name__ == "__main__":', ] [tool.ruff] target-version = "py311" line-length = 88 [tool.ruff.lint] ignore = [ "S101", # use of assert "D203", # 1 blank line required before class docstring "D212", # Multi-line docstring summary should start at the first line "D100", # Missing docstring in public module "D101", # Missing docstring in public module "D102", # Missing docstring in public method "D103", # Missing docstring in public module "D104", # Missing docstring in public package "D105", # Missing docstring in magic method "D107", # Missing docstring in `__init__` "D400", # First line should end with a period "D401", # First line of docstring should be in imperative mood "D205", # 1 blank line required between summary line and description "D415", # First line should end with a period, question mark, or exclamation point "D417", # Missing argument descriptions in the docstring "E501", # Line too long "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` "B008", # Do not perform function call "S110", # `try`-`except`-`pass` detected, consider logging the exception "D106", # Missing docstring in public nested class "UP007", # typer needs Optional syntax "S603", # check for execution of untrusted input "PERF203", # too many to fix right now "PLR2004", # Magic value used in comparison "PLR0913", # Too many arguments to function call "PLR0917", # Too many positional arguments (public signatures and typer CLI args) "PLW1641", # Object does not implement __hash__ method # Mirrored from esphome/aioesphomeapi (see issue #872) — pre-ignored ahead # of enabling the parent rule families in future tiers. "TRY003", # avoid specifying long messages outside the exception class "ANN401", # disallow Any "ASYNC109", # async function with timeout parameter "TID252", # relative imports — this repo uses `from ..exceptions import ...` throughout ] select = [ "A", # flake8-builtins "B", # flake8-bugbear "D", # flake8-docstrings "C4", # flake8-comprehensions "DTZ", # flake8-datetimez "ERA", # eradicate (commented-out code) "FA", # flake8-future-annotations "FLY", # flynt (f-string conversion) "G", # flake8-logging-format "ICN", # flake8-import-conventions "INP", # flake8-no-pep420 (implicit namespace packages) "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "N", # pep8-naming "PGH", # pygrep-hooks "PIE", # flake8-pie "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise "SLOT", # flake8-slots "T10", # flake8-debugger "T20", # flake8-print "TC", # flake8-type-checking "TD", # flake8-todos "FIX", # flake8-fixme "YTT", # flake8-2020 "S", # flake8-bandit "F", # pyflake "FURB", # refurb rules "E", # pycodestyle "W", # pycodestyle "UP", # pyupgrade "I", # isort "PERF", # performance "PL", # pylint "RET", # return rules "RUF", # ruff specific "SIM", # simplify ] [tool.ruff.lint.per-file-ignores] "tests/**/*" = [ "D100", "D101", "D102", "D103", "D104", "S101", "PLR0911", "PLR0912", "PLR0915", "DTZ001", # tests intentionally construct naive datetimes "DTZ005", # tests intentionally call datetime.now() without tz "ERA001", # test fixtures keep commented-out reference snippets "E402", # optional-dependency guards (pytest.importorskip) precede imports ] "setup.py" = ["D100"] "conftest.py" = ["D100"] "scripts/**/*" = [ "T201", # standalone CLI scripts print to stderr by design "INP001", # scripts/ is a runnable folder, not an importable package ] "src/uiprotect/data/**/*" = [ # pydantic v2 model_rebuild() needs field-type classes resolvable at runtime "TC001", "TC002", "TC003", ] "src/uiprotect/events/**/*" = [ # pydantic v2 model field types must resolve at runtime for validation "TC001", "TC002", "TC003", ] "src/uiprotect/devices/**/*" = [ # pydantic v2 model field types must resolve at runtime for validation "TC001", "TC002", "TC003", ] "src/uiprotect/cli/**/*" = [ # typer inspects runtime annotations on command callbacks "TC001", "TC002", "TC003", ] "src/uiprotect/exceptions.py" = [ # public exception names — renaming would break the API consumers depend on "N818", ] [tool.ruff.lint.isort] known-first-party = ["uiprotect", "tests"] [tool.mypy] disable_error_code = "import-untyped,unused-ignore" check_untyped_defs = true ignore_missing_imports = true disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true mypy_path = "src/" no_implicit_optional = true show_error_codes = true warn_unreachable = true warn_unused_ignores = true exclude = [ 'docs/.*', 'setup.py', ] [[tool.mypy.overrides]] module = "tests.*" allow_untyped_defs = true [[tool.mypy.overrides]] module = "docs.*" ignore_errors = true [build-system] requires = ["poetry-core>=2.1.0"] build-backend = "poetry.core.masonry.api" uilibs-uiprotect-317a52c/renovate.json000066400000000000000000000001011524637705700200720ustar00rootroot00000000000000{ "extends": ["github>browniebroke/renovate-configs:python"] } uilibs-uiprotect-317a52c/scripts/000077500000000000000000000000001524637705700170535ustar00rootroot00000000000000uilibs-uiprotect-317a52c/scripts/fetch_openapi.py000066400000000000000000000160571524637705700222420ustar00rootroot00000000000000#!/usr/bin/env python3 """ Fetch the UniFi Protect integration OpenAPI spec. Primary source is Ubiquiti's developer portal (https://developer.ui.com/protect/), which serves the spec as a small JSON document per version. The official deb package remains available as a fallback via ``--from-deb``. Writes openapi/integration.json (gitignored). Requires only stdlib. """ from __future__ import annotations import argparse import io import json import re import sys import tarfile import urllib.error import urllib.request from pathlib import Path FIRMWARE_API = "https://fw-update.ubnt.com/api/v2/firmware-latest" PORTAL_INDEX = "https://developer.ui.com/protect/" PORTAL_SPEC = "https://developer.ui.com/protect/v{version}/openapi.json" SPEC_MEMBER = "./usr/share/unifi-protect/app/fixtures/integration/openapi.json" DEFAULT_OUTPUT = Path(__file__).resolve().parent.parent / "openapi" / "integration.json" def fetch_spec( version: str | None = None, output: Path = DEFAULT_OUTPUT, *, from_deb: bool = False, ) -> None: """Fetch the integration OpenAPI spec and write it to ``output``.""" deb_url, ver = _query_firmware(version) print(f"unifi-protect {ver}", file=sys.stderr) spec_bytes = _fetch_from_deb(deb_url) if from_deb else _fetch_from_portal(ver) spec = json.loads(spec_bytes) # The portal spec carries a placeholder ``info.version`` ("0.0.0"); the # real version only lives in the URL. Stamp it so consumers can trust it. if spec.get("info", {}).get("version") in (None, "0.0.0"): spec.setdefault("info", {})["version"] = ver spec_bytes = json.dumps(spec, indent=2).encode() + b"\n" output.parent.mkdir(parents=True, exist_ok=True) output.write_bytes(spec_bytes) print( f"Written {output} " f"(version {spec['info']['version']}, " f"{len(spec['paths'])} paths)", file=sys.stderr, ) def _fetch_from_portal(version: str) -> bytes: """Download the spec from the developer portal (~400 KB).""" url = PORTAL_SPEC.format(version=version.removeprefix("v")) print(f"Downloading: {url}", file=sys.stderr) request = urllib.request.Request(url) # noqa: S310 try: with urllib.request.urlopen(request, timeout=60) as resp: # noqa: S310 if "json" not in resp.headers.get("Content-Type", ""): raise RuntimeError(f"Unexpected content type from {url}") return resp.read() except urllib.error.HTTPError as err: if err.code == 404: raise RuntimeError( f"No spec for version {version} on the developer portal; " "see --list for published versions or retry with --from-deb" ) from err raise def _fetch_from_deb(url: str) -> bytes: """Download the unifi-protect deb (~74 MB) and extract the spec.""" print(f"Downloading (~74 MB): {url}", file=sys.stderr) with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310 deb_bytes = resp.read() print("Extracting spec …", file=sys.stderr) return _extract_from_deb(deb_bytes) def list_versions() -> list[str]: """Return the spec versions published on the developer portal, newest first.""" request = urllib.request.Request(PORTAL_INDEX) with urllib.request.urlopen(request, timeout=60) as resp: # noqa: S310 html = resp.read().decode(errors="replace") # The portal is a Next.js app; the version picker's data is embedded in # the page payload as an escaped JSON array of {"version": "vX.Y.Z"}. match = re.search(r'versions\\":\[(.*?)\]', html) if match is None: raise RuntimeError(f"Could not find the version list on {PORTAL_INDEX}") return re.findall(r"v(\d+\.\d+\.\d+)", match.group(1)) def _query_firmware(version: str | None) -> tuple[str, str]: """ Return (deb_download_url, version) for the requested protect version. The version is normalised to a bare ``MAJOR.MINOR.PATCH``; the firmware API reports it ``v``-prefixed. """ params = [ "filter=eq~~product~~unifi-protect", "filter=eq~~platform~~uos-deb11-arm64", ] if version: parts = version.split(".") if len(parts) != 3: raise ValueError(f"version must be MAJOR.MINOR.PATCH, got {version!r}") major, minor, patch = parts params += [ f"filter=eq~~version_major~~{major}", f"filter=eq~~version_minor~~{minor}", f"filter=eq~~version_patch~~{patch}", ] else: params.append("filter=eq~~channel~~release") url = f"{FIRMWARE_API}?{'&'.join(params)}" with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310 data = json.loads(resp.read()) entries = data["_embedded"]["firmware"] if not entries: raise RuntimeError(f"No firmware found for version={version!r}") fw = entries[0] return fw["_links"]["data"]["href"], fw["version"].removeprefix("v") def _extract_from_deb(deb_bytes: bytes) -> bytes: """Extract the integration openapi.json from a .deb (ar archive).""" if deb_bytes[:8] != b"!\n": raise ValueError("Not an ar archive") offset = 8 while offset < len(deb_bytes): name = deb_bytes[offset : offset + 16].decode("ascii").rstrip() size = int(deb_bytes[offset + 48 : offset + 58].decode("ascii").strip()) offset += 60 # ar header is exactly 60 bytes member_data = deb_bytes[offset : offset + size] offset += size + (size % 2) # ar pads members to even byte boundary if name.startswith("data.tar"): with tarfile.open(fileobj=io.BytesIO(member_data)) as tf: f = tf.extractfile(tf.getmember(SPEC_MEMBER)) if f is None: raise RuntimeError(f"{SPEC_MEMBER!r} is not a regular file in deb") return f.read() raise FileNotFoundError(f"{SPEC_MEMBER!r} not found in deb") if __name__ == "__main__": parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--version", help="Protect version to fetch, e.g. 7.0.104 (default: latest release)", ) parser.add_argument( "--output", type=Path, default=DEFAULT_OUTPUT, metavar="PATH", help=f"Output path (default: {DEFAULT_OUTPUT})", ) parser.add_argument( "--print-version", action="store_true", help="Print the resolved version to stdout and exit (no download)", ) parser.add_argument( "--list", action="store_true", help="List spec versions published on the developer portal and exit", ) parser.add_argument( "--from-deb", action="store_true", help="Extract the spec from the official deb (~74 MB) instead of the portal", ) args = parser.parse_args() if args.list: print("\n".join(list_versions())) elif args.print_version: _, ver = _query_firmware(args.version) print(ver) else: fetch_spec(args.version, args.output, from_deb=args.from_deb) uilibs-uiprotect-317a52c/scripts/validate_spec.py000066400000000000000000001133501524637705700222330ustar00rootroot00000000000000#!/usr/bin/env python3 """ Validate the public-API client against the Integration OpenAPI spec. Reads ``openapi/integration.json`` (fetched on demand by ``scripts/fetch_openapi.py``; gitignored, never committed) and reports drift between Ubiquiti's spec and the hand-maintained client surface: * spec endpoints with no covering client method — derived from the declarative ``@public_*`` decorator registry plus one recorded example call per hand-written exception method, never a hand-maintained table (warning), * model fields the spec dropped or retyped (error) and fields the spec added that the model lacks (warning), * spec enum values absent from the matching library enum (warning), * inbound spec enums and ``const`` discriminators with no library counterpart (error), * spec ``event`` union types absent from ``PUBLIC_EVENT_TYPES``, or modelled types the spec no longer declares (error). The library deliberately models every public-API field optional (older firmware and partial/reference responses omit them), so spec ``required`` vs. model ``optional`` is not checked — it would be guaranteed noise on every run. Exits non-zero on any error and prints a markdown summary the spec-validation workflow embeds in a drift issue. The check functions are also imported by ``tests/test_public_schema_conformance.py`` so a contributor who fetches the spec locally runs the full validation for free. """ from __future__ import annotations import asyncio import contextlib import enum import functools import importlib import inspect import pkgutil import re import sys from pathlib import Path from typing import TYPE_CHECKING, Any, get_args import orjson # ``scripts/`` is not an importable package, so make ``src/`` importable when # this file is run directly or imported by the test via a sys.path insert. _SRC = Path(__file__).resolve().parents[1] / "src" if str(_SRC) not in sys.path: sys.path.insert(0, str(_SRC)) from uiprotect import data as uiprotect_data # noqa: E402 from uiprotect._public_api import registry # noqa: E402 from uiprotect.api import ProtectApiClient # noqa: E402 from uiprotect.data import ( # noqa: E402 PUBLIC_EVENT_TYPES, ArmProfile, Fob, LinkStation, PublicBridge, PublicCamera, PublicChime, PublicEvent, PublicLight, PublicLiveview, PublicNVR, PublicPosTransactionResponse, PublicSensor, PublicUlpUser, PublicUser, PublicViewer, Relay, Siren, Speaker, ) from uiprotect.data.base import ProtectBaseObject # noqa: E402 from uiprotect.data.types import DeviceState # noqa: E402 from uiprotect.utils import to_snake_case # noqa: E402 if TYPE_CHECKING: from collections.abc import Awaitable, Callable SPEC_PATH = Path(__file__).resolve().parents[1] / "openapi" / "integration.json" # --------------------------------------------------------------------------- # Endpoint coverage source. # # The set of covered endpoints is DERIVED, never hand-maintained: # # * Declarative endpoints come straight from the import-time ``@public_get`` / # ``@public_patch`` / ``@public_post`` registry — the decorator *is* the # implementation, so it cannot drift out of sync with the client. # * The hand-written exception methods (grouped/validated PATCHes, the # alarm-hub URL aliases, file upload, ``update_public``, etc.) are covered by # one executable example call each, recorded by spying on # ``BaseApiClient.request`` (see ``_record_example_calls``). # * The two websocket subscriptions are not REST calls; their paths are read # straight off the client's ``*_ws_path`` attributes. # # A spec endpoint absent from this derived union reads as a new/uncovered # endpoint and raises a warning. ``check_completeness`` separately asserts every # public-API coroutine is itself accounted for, so a newly added method that # nobody wired up cannot silently leave a gap. # --------------------------------------------------------------------------- # Registry path templates use Python parameter names (``/v1/cameras/{camera_id}``) # while the spec uses its own (``/v1/cameras/{id}``); recorded example calls # inject this sentinel where a path parameter goes. Normalizing every ``{...}`` # placeholder and the sentinel to a bare ``{}`` lets the three sources compare # equal regardless of the parameter name. _PATH_PARAM_RE = re.compile(r"\{[^/{}]+\}") _RECORD_SENTINEL = "__spec_param__" def _normalize_path(path: str) -> str: """Collapse named path placeholders and the record sentinel to a bare ``{}``.""" return _PATH_PARAM_RE.sub("{}", path).replace(_RECORD_SENTINEL, "{}") # Public-API coroutines that issue requests but are neither decorated nor named # ``*_public`` (so the completeness scan would otherwise miss them). _EXTRA_PUBLIC_COROUTINES = frozenset( { "get_public_api_camera_snapshot", "create_camera_rtsps_streams", "get_camera_rtsps_streams", "delete_camera_rtsps_streams", } ) # One executable example call per hand-written (non-declarative) public-API # coroutine. Each is invoked against a throw-away client whose ``request`` is # spied on, so the call records its ``(verb, path)`` and then short-circuits # before any network I/O. Path parameters get ``_RECORD_SENTINEL``; PATCH bodies # get a single field so the "at least one parameter" guards pass. _S = _RECORD_SENTINEL _EXAMPLE_CALLS: dict[str, Callable[[ProtectApiClient], Awaitable[Any]]] = { "get_public_api_camera_snapshot": lambda c: c.get_public_api_camera_snapshot(_S), "create_camera_rtsps_streams": lambda c: c.create_camera_rtsps_streams(_S, "high"), "get_camera_rtsps_streams": lambda c: c.get_camera_rtsps_streams(_S), "delete_camera_rtsps_streams": lambda c: c.delete_camera_rtsps_streams(_S, "high"), "create_talkback_session_public": lambda c: c.create_talkback_session_public(_S), "disable_camera_mic_permanently_public": ( lambda c: c.disable_camera_mic_permanently_public(_S) ), "update_camera_public": lambda c: c.update_camera_public(_S, name=_S), "update_light_public": lambda c: c.update_light_public(_S, name=_S), "update_sensor_public": lambda c: c.update_sensor_public(_S, name=_S), "update_siren_public": lambda c: c.update_siren_public(_S, name=_S), "play_siren_public": lambda c: c.play_siren_public(_S), "test_siren_sound_public": lambda c: c.test_siren_sound_public(_S), "update_relay_public": lambda c: c.update_relay_public(_S, name=_S), "activate_relay_output_public": ( lambda c: c.activate_relay_output_public(_S, _S, state="on") ), "test_speaker_sound_public": lambda c: c.test_speaker_sound_public(_S), "trigger_alarm_hub_output_public": ( lambda c: c.trigger_alarm_hub_output_public(_S, _S, enable=True) ), "get_bridge_public": lambda c: c.get_bridge_public(_S), "update_bridge_public": lambda c: c.update_bridge_public(_S, name=_S), "get_viewer_public": lambda c: c.get_viewer_public(_S), "update_viewer_public": lambda c: c.update_viewer_public(_S, name=_S), "get_liveview_public": lambda c: c.get_liveview_public(_S), "create_liveview_public": lambda c: c.create_liveview_public( name=_S, is_default=False, is_global=False, owner=_S, layout=1, slots=[] ), "update_liveview_public": lambda c: c.update_liveview_public(_S, name=_S), "send_alarm_webhook_public": lambda c: c.send_alarm_webhook_public(_S), "get_arm_profiles_public": lambda c: c.get_arm_profiles_public(), "create_arm_profile_public": lambda c: c.create_arm_profile_public( name=_S, automations=[], schedules=[], record_everything=False, activation_delay=0, ), "update_arm_profile_public": lambda c: c.update_arm_profile_public(_S, name=_S), "delete_arm_profile_public": lambda c: c.delete_arm_profile_public(_S), "get_arm_manager_settings_public": lambda c: c.get_arm_manager_settings_public(), "set_current_arm_profile_public": lambda c: c.set_current_arm_profile_public(_S), "enable_arm_alarm_public": lambda c: c.enable_arm_alarm_public(), "disable_arm_alarm_public": lambda c: c.disable_arm_alarm_public(), "get_files_public": lambda c: c.get_files_public(_S), "upload_file_public": lambda c: c.upload_file_public(_S, b"x", "asset.png"), "update_public": lambda c: c.update_public(), } class _ShortCircuitError(Exception): """Raised by the request spy to abort an example call before network I/O.""" async def _record_example_calls() -> set[tuple[str, str]]: """Run every example call against a spied client; return recorded ``(verb, path)``.""" client = ProtectApiClient.public_only("127.0.0.1", 443, api_key="x") recorded: set[tuple[str, str]] = set() prefix = client.public_api_path async def _spy(method: str, url: str, *_args: Any, **_kwargs: Any) -> Any: recorded.add((method.upper(), _normalize_path(url.removeprefix(prefix)))) raise _ShortCircuitError client.request = _spy # type: ignore[method-assign] for call in _EXAMPLE_CALLS.values(): # ``update_public`` gathers its fetches with ``return_exceptions=True`` # and swallows the sentinel; the others propagate it. Either way the # spy has already recorded every path the call reached. with contextlib.suppress(_ShortCircuitError): await call(client) return recorded def _registry_endpoints() -> set[tuple[str, str]]: """Covered ``(VERB, path)`` for every declaratively-decorated endpoint.""" return { (verb.upper(), _normalize_path(path)) for verb, path in registry.for_class(ProtectApiClient.__name__) } def _subscribe_endpoints() -> set[tuple[str, str]]: """The two websocket subscription paths, read off the client's path attributes.""" prefix = ProtectApiClient.public_api_path endpoints: set[tuple[str, str]] = set() for attr in ("events_ws_path", "devices_ws_path"): path: str = getattr(ProtectApiClient, attr) endpoints.add(("GET", _normalize_path(path.removeprefix(prefix)))) return endpoints @functools.cache def covered_endpoints() -> frozenset[tuple[str, str]]: """Derived union of declarative, recorded, and websocket-subscription endpoints.""" recorded = asyncio.run(_record_example_calls()) return frozenset(_registry_endpoints() | recorded | _subscribe_endpoints()) # (model class, spec schema name) pairs validated field-for-field. _MODEL_SCHEMAS: list[tuple[type[ProtectBaseObject], str]] = [ (PublicCamera, "camera"), (PublicLight, "light"), (PublicSensor, "sensor"), (PublicChime, "chime"), (PublicNVR, "nvr"), (PublicViewer, "viewer"), (PublicBridge, "bridge"), (PublicLiveview, "liveview"), (Siren, "siren"), (Relay, "relay"), (Speaker, "speaker"), (Fob, "fob"), (LinkStation, "linkStation"), (PublicUser, "user"), (PublicUlpUser, "ulpUser"), (ArmProfile, "armProfile"), # ``event`` is a ``oneOf`` union; its variants are merged into one property # map, matching the single all-optional ``PublicEvent`` model. (PublicEvent, "event"), (PublicPosTransactionResponse, "posTransactionResponse"), ] # Spec enum schemas with a known library counterpart. Spec values absent from # the lib enum are warnings; the lib's ``UNKNOWN`` sentinel is ignored. _ENUM_SCHEMAS: list[tuple[type[Any], str]] = [ (DeviceState, "deviceState"), ] # The library's forward-compat sentinel value, ignored on both sides of the # enum-coverage comparison so a lib enum that models ``UNKNOWN`` still counts as # covering a spec enum regardless of whether either side lists a literal # ``unknown``. _ENUM_SENTINEL = "unknown" # Spec enums whose value-set is a strict *subset* of a single, explicitly named # library enum that the public models already type the field with. The library # enum is a forward-compatible superset — it also carries values from the # private API or newer firmware (e.g. ``VideoMode``'s ``homekit``) — so an exact # value-set match is impossible by design, yet the field IS faithfully typed. # Each entry is pinned to ONE named enum and verified ``spec ⊆ lib`` at runtime, # so a coincidental value-set collision can never satisfy it (that was the v1 # any-subset false-negative). A spec value that grows beyond the named enum # changes the value-set, breaks the key match, and re-surfaces the enum as an # unmodelled warning. _MODELLED_AS_SUBSET: dict[frozenset[str], str] = { frozenset( {"default", "highFps", "sport", "slowShutter", "lprReflex", "lprNoneReflex"} ): "VideoMode", frozenset({"neutral", "low", "safe", "high"}): "SensorStatusType", frozenset( {"animal", "face", "licensePlate", "package", "person", "vehicle"} ): "SmartDetectObjectType", frozenset( { "alrmBabyCry", "alrmBark", "alrmBurglar", "alrmCarHorn", "alrmCmonx", "alrmGlassBreak", "alrmSiren", "alrmSmoke", "alrmSpeak", } ): "SmartDetectAudioType", } # Inbound spec enums (reachable from a response the library deserializes) # intentionally left untyped, kept opaque pending a dedicated modelling effort. # Outbound-only enums (request param / body) need no entry — they are waived by # direction. Each waiver is keyed by ``(owning component schema, value-set)`` — # the value-set alone would let a waiver for a generic set like ``{closed, # open}`` silently cover an unrelated future enum on another schema. Waivers are # the rare, documented exception: the default is to model. _ENUM_COVERAGE_WAIVERS: dict[tuple[str, frozenset[str]], str] = { # ``alarmHubStatus`` electrical internals (input power, current-meter, # terminal and e-fuse status). Kept as opaque ``additionalProperties`` — a # private value space deferred to a separate modelling effort. ( "alarmHubStatus", frozenset({"high", "low"}), ): "alarm-hub inputPower status (deferred)", ( "alarmHubStatus", frozenset({"alert", "normal"}), ): "alarm-hub criticalAlarm status (deferred)", ( "alarmHubStatus", frozenset({"fault", "normal", "warning"}), ): "alarm-hub efuse status (deferred)", ( "alarmHubStatus", frozenset({"closed", "open"}), ): "alarm-hub input idleSubState (deferred)", ( "alarmHubStatus", frozenset( {"cut", "disabled", "idle", "not-connected", "short", "tamper", "triggered"} ), ): "alarm-hub plusPinStatus (deferred)", ( "alarmHubStatus", frozenset( { "cut", "disabled", "idle", "not-connected", "partially-connected", "short", "tamper", "triggered", } ), ): "alarm-hub terminalStatus (deferred)", ( "alarmHubStatus", frozenset({"high-current", "none", "over-current"}), ): "alarm-hub output efuseAlert (deferred)", ( "alarmHubStatus", frozenset({"dry-contact", "powered-12v"}), ): "alarm-hub output mode (deferred)", ( "alarmHubStatus", frozenset({"active", "disabled", "off"}), ): "alarm-hub output statusLabel (deferred)", ( "alarmHubStatus", frozenset({"+", "-", "com", "nc", "no"}), ): "alarm-hub output wiredPins (deferred)", ( "alarmHubStatus", frozenset({"connected", "not-connected", "partially-connected"}), ): "alarm-hub auxiliaryPower status (deferred)", } # Library-owned fields populated out-of-band, absent from the spec schema. # Fields the library owns that no spec revision lists (computed convenience). _LIBRARY_OWNED_FIELDS: dict[str, set[str]] = { "camera": {"rtsps_streams"}, } # Model fields not present in the spec schema; excluded from the drift error. _EXTRA_MODEL_FIELDS: dict[str, set[str]] = { "camera": {"device_type", "device_guid"}, "light": {"device_type", "device_guid"}, "sensor": {"device_type", "device_guid", "feature_flags"}, "chime": {"device_type", "device_guid"}, # ``mac`` is exposed on ``GET /v1/nvrs`` only from Protect newer than 7.1; # older validated specs omit it, so exempt it from the drift error. "nvr": {"device_type", "device_guid", "mac"}, "viewer": {"device_type", "device_guid"}, "bridge": {"device_type", "device_guid"}, } _HTTP_METHODS = ("get", "post", "put", "patch", "delete") def _merge_variants( variants: list[dict[str, Any]], schemas: dict[str, Any] ) -> dict[str, dict[str, Any]] | None: """Union the object variants of an ``allOf`` / ``oneOf`` into one property map.""" merged: dict[str, dict[str, Any]] = {} is_object = False for sub in variants: resolved = _resolve_object_props(sub, schemas) if resolved is None: continue is_object = True for key, schema in resolved.items(): previous = merged.get(key) if previous is not None and previous != schema: # A property several variants disagree on (the per-event-type # ``metadata`` blocks) is itself a union; re-wrap it so a # recursive resolve merges those the same way. merged[key] = {"oneOf": [previous, schema]} else: merged[key] = schema return merged if is_object else None def _resolve_object_props( node: dict[str, Any], schemas: dict[str, Any] ) -> dict[str, dict[str, Any]] | None: """Return ``{property: schema}`` for an object node, ``None`` for scalar leaves.""" if "$ref" in node: return _resolve_object_props(schemas[node["$ref"].split("/")[-1]], schemas) if "allOf" in node: return _merge_variants(node["allOf"], schemas) if "oneOf" in node: return _merge_variants(node["oneOf"], schemas) if node.get("type") == "array": return _resolve_object_props(node.get("items", {}), schemas) if "properties" in node: return dict(node["properties"]) return None def _resolve_const(node: dict[str, Any], schemas: dict[str, Any]) -> str | None: """ Follow ``$ref`` / ``allOf`` / ``oneOf`` to the string ``const`` a node declares. A ``oneOf`` resolves only when every variant agrees on the same value — which is what ``_merge_variants`` produces when several union members declare the same discriminator with differing siblings. Genuine disagreement (or a variant carrying no const at all) returns ``None``. """ if "$ref" in node: return _resolve_const(schemas.get(node["$ref"].split("/")[-1], {}), schemas) if "allOf" in node: for sub in node["allOf"]: const = _resolve_const(sub, schemas) if const is not None: return const return None if "oneOf" in node: consts = {_resolve_const(sub, schemas) for sub in node["oneOf"]} return consts.pop() if len(consts) == 1 else None value = node.get("const") return value if isinstance(value, str) else None def _union_variants( node: dict[str, Any], schemas: dict[str, Any] ) -> list[dict[str, Any]] | None: """Flatten a ``$ref``/``oneOf`` chain into its leaf variants, ``None`` if not a union.""" if "$ref" in node: return _union_variants(schemas.get(node["$ref"].split("/")[-1], {}), schemas) variants = node.get("oneOf") if not variants: return None leaves: list[dict[str, Any]] = [] for sub in variants: nested = _union_variants(sub, schemas) leaves.extend(nested if nested is not None else [sub]) return leaves def _leaf_model(annotation: Any) -> type[ProtectBaseObject] | None: """Unwrap ``Optional`` / ``list`` to the ``ProtectBaseObject`` leaf, if any.""" if isinstance(annotation, type) and issubclass(annotation, ProtectBaseObject): return annotation for arg in get_args(annotation): leaf = _leaf_model(arg) if leaf is not None: return leaf return None def _spec_field_name(key: str, remaps: dict[str, str]) -> str: """Return the model field name for a spec key after remap + snake-case normalization.""" return to_snake_case(remaps.get(key) or key) def check_endpoints(spec: dict[str, Any]) -> tuple[list[str], list[str]]: """Spec endpoints absent from the derived covered set are new-endpoint warnings.""" errors: list[str] = [] warnings: list[str] = [] covered = covered_endpoints() for path, operations in spec.get("paths", {}).items(): for method in operations: if method not in _HTTP_METHODS: continue if (method.upper(), _normalize_path(path)) not in covered: warnings.append( f"new endpoint `{method.upper()} {path}` has no covering client " f"method (declarative decorator or recorded example call)" ) return errors, warnings def _public_api_coroutines() -> set[str]: """Names of every public-API coroutine on ``ProtectApiClient``.""" return { name for name, member in inspect.getmembers( ProtectApiClient, inspect.iscoroutinefunction ) if name.endswith("_public") or hasattr(member, "__public_endpoint__") or name in _EXTRA_PUBLIC_COROUTINES } def check_completeness() -> list[str]: """ Every public-API coroutine must be covered, declaratively or by example call. The endpoint coverage set is only as complete as the example-call table, so this guards the table itself: a newly added public method that nobody wired up surfaces here instead of silently leaving its endpoint uncovered. Spec- independent — it checks the client against itself, not against the spec. """ declarative = set(registry.for_class(ProtectApiClient.__name__).values()) accounted = declarative | set(_EXAMPLE_CALLS) missing = _public_api_coroutines() - accounted return [ f"public-API coroutine `{name}` has no declarative decorator and no " f"recorded example call (add one to `_EXAMPLE_CALLS`)" for name in sorted(missing) ] def check_model_fields(spec: dict[str, Any]) -> tuple[list[str], list[str]]: """Compare spec schema properties against public model fields, both directions.""" errors: list[str] = [] warnings: list[str] = [] schemas = spec.get("components", {}).get("schemas", {}) for cls, schema_name in _MODEL_SCHEMAS: if schema_name not in schemas: errors.append( f"{schema_name}: tracked schema absent from spec " f"(server removed/renamed it)" ) continue props = _resolve_object_props({"$ref": f"#/.../{schema_name}"}, schemas) if props is None: errors.append(f"{schema_name}: spec schema is not object-shaped") continue remaps = cls._get_unifi_remaps() spec_fields = {_spec_field_name(key, remaps) for key in props} model_fields = set(cls.model_fields) owned = _LIBRARY_OWNED_FIELDS.get(schema_name, set()) extra = _EXTRA_MODEL_FIELDS.get(schema_name, set()) removed = model_fields - spec_fields - owned - extra errors.extend( f"{schema_name}: model field `{name}` absent from spec " f"(server removed/retyped it)" for name in sorted(removed) ) added = spec_fields - model_fields warnings.extend( f"{schema_name}: spec field `{name}` has no model counterpart" for name in sorted(added) ) return errors, warnings def check_enums(spec: dict[str, Any]) -> tuple[list[str], list[str]]: """Spec enum values absent from the matching library enum are warnings.""" errors: list[str] = [] warnings: list[str] = [] schemas = spec.get("components", {}).get("schemas", {}) for enum_cls, schema_name in _ENUM_SCHEMAS: schema = schemas.get(schema_name) if schema is None: errors.append( f"{schema_name}: tracked schema absent from spec " f"(server removed/renamed it)" ) continue if "enum" not in schema: errors.append( f"{schema_name}: tracked enum schema no longer declares `enum`" ) continue lib_values = {member.value for member in enum_cls} warnings.extend( f"{schema_name}: spec enum value `{value}` absent from " f"`{enum_cls.__name__}`" for value in schema["enum"] if value not in lib_values ) return errors, warnings def _iter_data_modules(data_pkg: Any) -> list[Any]: """Import and return every submodule of the ``uiprotect.data`` package.""" return [ importlib.import_module(info.name) for info in pkgutil.walk_packages(data_pkg.__path__, f"{data_pkg.__name__}.") ] @functools.cache def _library_enum_classes() -> list[type[enum.Enum]]: """Every ``enum.Enum`` declared under the ``uiprotect.data`` package.""" return [ obj for module in (uiprotect_data, *_iter_data_modules(uiprotect_data)) for obj in vars(module).values() if isinstance(obj, type) and issubclass(obj, enum.Enum) ] def _library_enums_by_name() -> dict[str, frozenset[str]]: """Value-set (minus the sentinel) of every ``enum.Enum`` under ``uiprotect.data``.""" out: dict[str, frozenset[str]] = {} for obj in _library_enum_classes(): value_set = frozenset(str(m.value) for m in obj) - {_ENUM_SENTINEL} if value_set: out[obj.__name__] = value_set return out def _library_enum_values() -> frozenset[str]: """ Every value any library enum defines, sentinel included. The const rule compares against this rather than the sentinel-stripped per-enum sets: a spec ``const`` of ``unknown`` is a real wire value, and an enum declaring an ``unknown`` member does define it. """ return frozenset(str(m.value) for obj in _library_enum_classes() for m in obj) def _enum_owner(path: str) -> str: """Component schema an enum occurrence belongs to (the raw path elsewhere).""" parts = path.split(".") if len(parts) > 2 and parts[0] == "components": return parts[2] return path def _enum_value_set(node: dict[str, Any]) -> frozenset[str]: """Value-set of a node's ``enum`` array, sentinel stripped.""" values = node.get("enum") if not isinstance(values, list): return frozenset() return frozenset(str(value) for value in values) - {_ENUM_SENTINEL} def _const_value_set(node: dict[str, Any]) -> frozenset[str]: """ One-value set for a node's string ``const``. Unlike an ``enum`` array, ``unknown`` is kept: in a spec ``const`` it is a real wire value the server pins, not the library's forward-compat sentinel. Non-string consts are skipped: they enumerate allowed *numbers* (``sirenDuration``, ``activationDelay``), which the library models as plain ints rather than as an enum. """ value = node.get("const") return frozenset({value}) if isinstance(value, str) else frozenset() def _node_value_sets(node: dict[str, Any]) -> tuple[frozenset[str], ...]: """Every non-empty value-set a single schema node declares.""" return tuple( value_set for value_set in (_enum_value_set(node), _const_value_set(node)) if value_set ) def _iter_spec_value_sets( spec: dict[str, Any], extract: Callable[[dict[str, Any]], frozenset[str]], ) -> list[tuple[frozenset[str], str]]: """Every value-set ``extract`` finds in the spec, once per owning schema.""" found: dict[tuple[str, frozenset[str]], str] = {} def _walk(node: Any, path: str) -> None: if isinstance(node, dict): key = extract(node) if key: found.setdefault((_enum_owner(path), key), path) for key_name, child in node.items(): _walk(child, f"{path}.{key_name}") elif isinstance(node, list): for index, child in enumerate(node): _walk(child, f"{path}[{index}]") _walk(spec.get("components", {}).get("schemas", {}), "components.schemas") _walk(spec.get("paths", {}), "paths") return sorted( ((key[1], path) for key, path in found.items()), key=lambda item: item[1] ) def _iter_spec_enums(spec: dict[str, Any]) -> list[tuple[frozenset[str], str]]: """Every enum value-set in the spec (named or inline), once per owning schema.""" return _iter_spec_value_sets(spec, _enum_value_set) def _iter_spec_consts(spec: dict[str, Any]) -> list[tuple[frozenset[str], str]]: """Every string ``const`` in the spec, once per owning schema.""" return _iter_spec_value_sets(spec, _const_value_set) def _reachable_enum_ids( roots: list[tuple[Any, str]], schemas: dict[str, Any] ) -> set[tuple[str, frozenset[str]]]: """Enum occurrence identities reachable from ``roots``, following ``$ref``.""" out: set[tuple[str, frozenset[str]]] = set() seen_refs: set[str] = set() def _walk(node: Any, path: str, owner: str | None) -> None: if isinstance(node, dict): ref = node.get("$ref") if isinstance(ref, str): name = ref.split("/")[-1] if name not in seen_refs: seen_refs.add(name) _walk(schemas.get(name, {}), f"components.schemas.{name}", name) return for key in _node_value_sets(node): out.add((owner if owner is not None else path, key)) for key_name, child in node.items(): _walk(child, f"{path}.{key_name}", owner) elif isinstance(node, list): for index, child in enumerate(node): _walk(child, f"{path}[{index}]", owner) for root, root_path in roots: _walk(root, root_path, None) return out def _reusable_response_error(spec: dict[str, Any]) -> str | None: """Reject reusable responses — ``_inbound_enum_ids`` does not resolve them.""" unsupported = "the inbound enum classification does not cover reusable responses" if spec.get("components", {}).get("responses"): return f"spec declares `components.responses`; {unsupported}" for path, operations in spec.get("paths", {}).items(): if not isinstance(operations, dict): continue for method, operation in operations.items(): if not isinstance(operation, dict): continue for code, response in operation.get("responses", {}).items(): if isinstance(response, dict) and "$ref" in response: return ( f"response `paths.{path}.{method}.responses.{code}` is a " f"`$ref`; {unsupported}" ) return None def _inbound_enum_ids(spec: dict[str, Any]) -> set[tuple[str, frozenset[str]]]: """Enum occurrence identities reachable from any response body schema.""" schemas = spec.get("components", {}).get("schemas", {}) roots: list[tuple[Any, str]] = [ ( media["schema"], f"paths.{path}.{method}.responses.{code}.content.{media_type}.schema", ) for path, operations in spec.get("paths", {}).items() if isinstance(operations, dict) for method, operation in operations.items() if isinstance(operation, dict) for code, response in operation.get("responses", {}).items() if isinstance(response, dict) for media_type, media in response.get("content", {}).items() if isinstance(media, dict) and "schema" in media ] return _reachable_enum_ids(roots, schemas) def check_enum_coverage(spec: dict[str, Any]) -> tuple[list[str], list[str]]: """ Every inbound spec enum and ``const`` must be modelled or explicitly waived. Coverage is decided on the value-set alone — an exact match proves some library enum carries those values, not that the owning model field is annotated with it. The ``const`` half is weaker still, and is a **lint for new vocabulary, not a conformance check**. A const declares a single value, so no library enum can ever *equal* its value-set; it is treated as covered when any library enum anywhere defines the value. A const that changes from one value the library already knows to another is therefore invisible here — only a value no enum defines at all is reported. ``check_event_types`` closes that gap for the ``event`` union's ``type`` const and nothing else: the remaining const occurrences (``*ModelKey``, ``WSAction`` ``type``, the ``lightMode`` and ``enableAt`` discriminators) have no equivalent per-axis pin. """ errors: list[str] = [] reusable = _reusable_response_error(spec) if reusable is not None: return [reusable], [] lib_by_name = _library_enums_by_name() exact_sets = set(lib_by_name.values()) const_values = _library_enum_values() inbound = _inbound_enum_ids(spec) def _reportable(value_set: frozenset[str], path: str) -> bool: """Inbound occurrence carrying no waiver; outbound-only is waived by direction.""" identity = (_enum_owner(path), value_set) return identity not in _ENUM_COVERAGE_WAIVERS and identity in inbound for value_set, path in _iter_spec_enums(spec): if value_set in exact_sets: continue mapped = _MODELLED_AS_SUBSET.get(value_set) if mapped is not None: missing = value_set - lib_by_name.get(mapped, frozenset()) if not missing: continue errors.append( f"spec enum at `{path}` is mapped to `{mapped}` in " f"`_MODELLED_AS_SUBSET` but `{mapped}` no longer defines value(s) " f"{sorted(missing)} (enum renamed or members removed)" ) continue if _reportable(value_set, path): errors.append( f"inbound spec enum at `{path}` (values {sorted(value_set)}) is not " f"modelled by any library enum, not mapped in `_MODELLED_AS_SUBSET`, " f"and not waived in `_ENUM_COVERAGE_WAIVERS`" ) errors.extend( f"inbound spec const at `{path}` (value {min(value_set)!r}) is not " f"defined by any library enum and not waived in `_ENUM_COVERAGE_WAIVERS`" for value_set, path in _iter_spec_consts(spec) if not value_set <= const_values and _reportable(value_set, path) ) return errors, [] def check_event_types(spec: dict[str, Any]) -> tuple[list[str], list[str]]: """ ``PUBLIC_EVENT_TYPES`` must equal the ``type`` consts of the ``event`` union. ``EventType`` has no ``unknown`` member, so an event type the spec declares and the library does not raises on parse and the event is dropped; both directions are therefore errors. Nested unions are flattened first: the server is free to group a family of event schemas behind its own ``oneOf`` without that being drift. """ errors: list[str] = [] schemas = spec.get("components", {}).get("schemas", {}) schema = schemas.get("event") if schema is None: return ["event: tracked schema absent from spec (server removed it)"], [] variants = _union_variants(schema, schemas) if not variants: return ["event: spec schema no longer declares a `oneOf` union"], [] spec_types: set[str] = set() for index, variant in enumerate(variants): props = _resolve_object_props(variant, schemas) or {} const = _resolve_const(props.get("type", {}), schemas) if const is not None: spec_types.add(const) else: errors.append( f"event: `oneOf[{index}]` " f"({variant.get('$ref', '')}) declares no `type` const" ) modelled = {event_type.value for event_type in PUBLIC_EVENT_TYPES} errors.extend( f"event: spec event type `{value}` is absent from `PUBLIC_EVENT_TYPES`" for value in sorted(spec_types - modelled) ) errors.extend( f"event: `PUBLIC_EVENT_TYPES` models `{value}`, absent from the spec " f"`event` union (server removed it)" for value in sorted(modelled - spec_types) ) return errors, [] _CHECKS = ( check_endpoints, check_model_fields, check_enums, check_enum_coverage, check_event_types, ) def run_checks(spec: dict[str, Any]) -> tuple[list[str], list[str]]: """Run every check and return the merged ``(errors, warnings)``.""" errors: list[str] = [] warnings: list[str] = [] for check in _CHECKS: e, w = check(spec) errors.extend(e) warnings.extend(w) return errors, warnings def format_summary( errors: list[str], warnings: list[str], version: str | None = None ) -> str: """Render a markdown summary of the validation result.""" header = "## Public-API spec conformance" if version: header += f" — Protect {version}" lines = [header, ""] if not errors and not warnings: lines.append("No drift: client surface matches the spec.") return "\n".join(lines) if errors: lines.append(f"### Errors ({len(errors)})") lines.extend(f"- {item}" for item in errors) lines.append("") if warnings: lines.append(f"### Warnings ({len(warnings)})") lines.extend(f"- {item}" for item in warnings) return "\n".join(lines).rstrip() def main() -> int: """Load the on-disk spec, run all checks, print a summary, exit non-zero on error.""" if not SPEC_PATH.exists(): print( f"spec not found at {SPEC_PATH}; run `python scripts/fetch_openapi.py` first", file=sys.stderr, ) return 2 spec = orjson.loads(SPEC_PATH.read_bytes()) version = spec.get("info", {}).get("version") errors, warnings = run_checks(spec) errors.extend(check_completeness()) print(format_summary(errors, warnings, version)) return 1 if errors else 0 if __name__ == "__main__": raise SystemExit(main()) uilibs-uiprotect-317a52c/setup.py000066400000000000000000000003571524637705700171030ustar00rootroot00000000000000#!/usr/bin/env python # This is a shim to allow GitHub to detect the package, build is done with poetry # Taken from https://github.com/Textualize/rich import setuptools if __name__ == "__main__": setuptools.setup(name="uiprotect") uilibs-uiprotect-317a52c/src/000077500000000000000000000000001524637705700161535ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/000077500000000000000000000000001524637705700201715ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/__init__.py000066400000000000000000000024451524637705700223070ustar00rootroot00000000000000"""Unofficial UniFi Protect Python API and Command Line Interface.""" from __future__ import annotations from .api import ProtectApiClient, calculate_retry_delay, parse_retry_after from .devices import DeviceChange, ProtectDeviceChange from .events import ( EventChange, EventIdentity, ProtectEvent, ProtectEventChannel, UlpUserIdentity, UnknownIdentity, ) from .exceptions import ( ArmedModeError, BadRequest, GlobalAlarmManagerError, Invalid, NotAuthorized, NvrError, PublicOnlyModeError, ) from .utils import ( get_nested_attr, get_nested_attr_as_bool, get_top_level_attr_as_bool, make_enabled_getter, make_required_getter, make_value_getter, ) __all__ = [ "ArmedModeError", "BadRequest", "DeviceChange", "EventChange", "EventIdentity", "GlobalAlarmManagerError", "Invalid", "NotAuthorized", "NvrError", "ProtectApiClient", "ProtectDeviceChange", "ProtectEvent", "ProtectEventChannel", "PublicOnlyModeError", "UlpUserIdentity", "UnknownIdentity", "calculate_retry_delay", "get_nested_attr", "get_nested_attr_as_bool", "get_top_level_attr_as_bool", "make_enabled_getter", "make_required_getter", "make_value_getter", "parse_retry_after", ] uilibs-uiprotect-317a52c/src/uiprotect/__main__.py000066400000000000000000000007101524637705700222610ustar00rootroot00000000000000from __future__ import annotations from pathlib import Path try: from dotenv import load_dotenv except ImportError: load_dotenv = None # type: ignore[assignment] from .cli import app def start() -> None: if load_dotenv is not None: env_file = Path.cwd() / ".env" if env_file.exists(): load_dotenv(dotenv_path=env_file) else: load_dotenv() app() if __name__ == "__main__": start() uilibs-uiprotect-317a52c/src/uiprotect/_compat.py000066400000000000000000000004561524637705700221720ustar00rootroot00000000000000"""Compat for external lib versions.""" from typing import TYPE_CHECKING if TYPE_CHECKING: from functools import cached_property else: try: from propcache.api import cached_property except ImportError: from propcache import cached_property __all__ = ("cached_property",) uilibs-uiprotect-317a52c/src/uiprotect/_public_api.py000066400000000000000000000145601524637705700230170ustar00rootroot00000000000000""" Declarative endpoint decorators for the uniform public-API surface. These decorators turn the *genuinely uniform* slice of :class:`uiprotect.api.ProtectApiClient`'s Public Integration API methods into one-line declarations. The hand-written ``async def`` signature and docstring are kept (mypy-strict and mkdocstrings rely on them); the decorator supplies the body — path binding, payload assembly, dispatch through the existing ``api_request_*`` helpers, and model construction. The module imports nothing from :mod:`uiprotect.data`: model classes arrive as decorator arguments (``item=``/``items=``), so it stays import-clean and circular-import-safe. It depends only on the stdlib plus the public exception type. """ from __future__ import annotations import functools import inspect import re from collections.abc import Awaitable, Callable from typing import Any, Concatenate, ParamSpec, TypeVar, cast from .exceptions import BadRequest _P = ParamSpec("_P") _R = TypeVar("_R") _Method = Callable[Concatenate[Any, _P], Awaitable[_R]] _PLACEHOLDER_RE = re.compile(r"{(\w+)}") _EMPTY_BODY_MESSAGE = "At least one parameter must be provided" def _to_camel(name: str) -> str: """Convert a snake_case parameter name to camelCase wire key.""" head, *rest = name.split("_") return head + "".join(word.capitalize() for word in rest) class _PublicEndpointRegistry: """Write-once per-class table mapping ``(verb, path)`` to a method name.""" def __init__(self) -> None: self._by_class: dict[str, dict[tuple[str, str], str]] = {} def register(self, cls_name: str, verb: str, path: str, method_name: str) -> None: table = self._by_class.setdefault(cls_name, {}) key = (verb, path) if key in table: raise RuntimeError( f"Duplicate public endpoint {verb.upper()} {path} on " f"{cls_name}: already registered as {table[key]!r}, " f"cannot also register {method_name!r}" ) table[key] = method_name def for_class(self, cls_name: str) -> dict[tuple[str, str], str]: return dict(self._by_class.get(cls_name, {})) def all_endpoints(self) -> dict[str, dict[tuple[str, str], str]]: return {cls: dict(table) for cls, table in self._by_class.items()} registry = _PublicEndpointRegistry() def _build_body( sig: inspect.Signature, arguments: dict[str, Any], placeholders: frozenset[str], ) -> dict[str, Any]: """Assemble a flat camelCase JSON body from non-``None`` keyword params.""" body: dict[str, Any] = {} for name, value in arguments.items(): if name == "self" or name in placeholders: continue if value is None: continue body[_to_camel(name)] = value return body def _validate_signature( func: _Method[_P, _R], sig: inspect.Signature, placeholders: frozenset[str], has_body: bool, ) -> None: """Fail loudly at decoration if the declaration and signature disagree.""" params = sig.parameters missing = sorted(name for name in placeholders if name not in params) if missing: raise TypeError( f"{func.__qualname__}: path placeholder(s) {missing} have no " f"matching parameter" ) if not has_body: extras = sorted( name for name in params if name != "self" and name not in placeholders ) if extras: raise TypeError( f"{func.__qualname__}: body-less endpoint has non-placeholder " f"parameter(s) {extras}" ) def _endpoint( verb: str, path: str, *, item: type[Any] | None, items: type[Any] | None, has_body: bool, ) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]: placeholders = frozenset(_PLACEHOLDER_RE.findall(path)) def decorator(func: _Method[_P, _R]) -> _Method[_P, _R]: sig = inspect.signature(func) _validate_signature(func, sig, placeholders, has_body) cls_name = func.__qualname__.rsplit(".", 1)[0] registry.register(cls_name, verb, path, func.__name__) @functools.wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: bound = sig.bind(*args, **kwargs) bound.apply_defaults() arguments = bound.arguments self = arguments["self"] url = path.format(**{name: arguments[name] for name in placeholders}) if items is not None: data = await self.api_request_list(url=url, public_api=True) return [items.from_unifi_dict(**entry, api=self) for entry in data] if has_body: body = _build_body(sig, arguments, placeholders) if not body: raise BadRequest(_EMPTY_BODY_MESSAGE) result = await self.api_request_obj( url=url, method=verb, json=body, public_api=True ) return item.from_unifi_dict(**result, api=self) # type: ignore[union-attr] if item is not None: data = await self.api_request_obj(url=url, public_api=True) return item.from_unifi_dict(**data, api=self) await self.api_request_raw(url=url, method=verb, public_api=True) return None wrapper.__public_endpoint__ = (verb, path) # type: ignore[attr-defined] return cast("_Method[_P, _R]", wrapper) return decorator def public_get( path: str, *, item: type[Any] | None = None, items: type[Any] | None = None, ) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]: """Declare a GET endpoint: ``item=`` for one object, ``items=`` for a list.""" if (item is None) == (items is None): raise TypeError(f"public_get('{path}') requires exactly one of item= or items=") return _endpoint("get", path, item=item, items=items, has_body=False) def public_patch( path: str, *, item: type[Any] ) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]: """Declare a flat-body PATCH endpoint; empty body raises ``BadRequest``.""" return _endpoint("patch", path, item=item, items=None, has_body=True) def public_post( path: str, *, item: type[Any] | None = None ) -> Callable[[_Method[_P, _R]], _Method[_P, _R]]: """Declare a POST endpoint: path-only and fire-and-forget, or ``item=`` with a body.""" return _endpoint("post", path, item=item, items=None, has_body=item is not None) uilibs-uiprotect-317a52c/src/uiprotect/_rate_limit.py000066400000000000000000000052401524637705700230340ustar00rootroot00000000000000"""Lightweight event-loop-safe pacer for the Public Integration API.""" from __future__ import annotations import math from asyncio import Lock, get_running_loop, sleep # Requests/second used until a ``RateLimit-Policy`` header seeds the real rate. # The observed budget is 10/1s; default conservatively to leave WS headroom. DEFAULT_RATE: float = 8.0 # Fraction of the server-advertised budget we actually consume, leaving room # for the shared-budget public WebSocket. SAFETY_MARGIN: float = 0.8 class PublicApiRateLimiter: """Fixed-interval async gate for one client's public path.""" def __init__(self, rate: float = DEFAULT_RATE) -> None: self._lock = Lock() self._interval = 1.0 / rate self._next_allowed = 0.0 self._seeded = False async def acquire(self) -> None: """Block until the next request is permitted under the current rate.""" async with self._lock: now = get_running_loop().time() if now < self._next_allowed: await sleep(self._next_allowed - now) self._next_allowed += self._interval else: self._next_allowed = now + self._interval def seed_from_policy(self, policy: str | None) -> None: """Seed the rate once from a draft-8 ``RateLimit-Policy`` header value.""" if self._seeded: return rate = _parse_policy_rate(policy) if rate is None: return new_interval = 1.0 / (rate * SAFETY_MARGIN) # Seeding a slower rate must not let one request slip out at the old, # faster spacing — push the next slot out by the widening delta. if self._next_allowed and new_interval > self._interval: self._next_allowed += new_interval - self._interval self._interval = new_interval self._seeded = True def _parse_policy_rate(policy: str | None) -> float | None: """Return requests/second from ``q`` / ``w`` of a RateLimit-Policy header.""" if not policy: return None quota: float | None = None window: float | None = None # Only the first policy member matters; its params are ``;``-separated. for part in policy.split(",", 1)[0].split(";"): key, _, value = part.strip().partition("=") if key == "q": quota = _to_float(value) elif key == "w": window = _to_float(value) if quota is None or window is None or quota <= 0 or window <= 0: return None return quota / window def _to_float(value: str) -> float | None: try: result = float(value) except ValueError: return None return result if math.isfinite(result) else None uilibs-uiprotect-317a52c/src/uiprotect/api.py000066400000000000000000006342271524637705700213320ustar00rootroot00000000000000"""UniFi Protect Server Wrapper.""" from __future__ import annotations import asyncio import contextlib import hashlib import logging import random import re import sys import time import warnings from datetime import UTC, datetime, timedelta from functools import partial from http import HTTPStatus, cookies from http.cookies import Morsel, SimpleCookie from ipaddress import IPv4Address, IPv6Address, ip_address from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, NotRequired, Self, TypedDict, cast from urllib.parse import SplitResult, quote import aiofiles import aiohttp import orjson from aiofiles import os as aos from aiohttp import ClientResponse, CookieJar, client_exceptions from aiozoneinfo import async_get_time_zone from platformdirs import user_cache_dir, user_config_dir from yarl import URL from uiprotect.data.convert import list_from_unifi_list from uiprotect.data.nvr import MetaInfo from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers from ._compat import cached_property from ._public_api import public_get, public_patch, public_post from ._rate_limit import PublicApiRateLimiter from .data import ( NVR, ArmProfile, Bootstrap, Bridge, Camera, ChannelQuality, DeviceState, Event, EventCategories, EventType, Fob, Light, LinkStation, Liveview, ModelType, NvrArmMode, NvrArmModeStatus, OsdOverlayLocation, ProtectAdoptableDeviceModel, ProtectModel, PublicArmScheduleDict, PublicBootstrap, PublicBridge, PublicCamera, PublicChime, PublicDeviceModel, PublicEvent, PublicFile, PublicHdrMode, PublicLight, PublicLiveview, PublicLiveviewSlotDict, PublicNVR, PublicPosLineItemDict, PublicPosLocationDict, PublicPosTransactionResponse, PublicSensor, PublicSensorAlarmSettings, PublicSensorGlassBreakSettingsWrite, PublicSensorHumiditySettings, PublicSensorLightSettings, PublicSensorMotionSettings, PublicSensorTemperatureSettings, PublicUlpUser, PublicUser, PublicViewer, Relay, RTSPSStreams, Sensor, Siren, SmartDetectAudioType, SmartDetectObjectType, SmartDetectTrack, Speaker, Version, VideoMode, Viewer, WSAction, WSPacket, WSSubscriptionMessage, create_from_unifi_dict, ) from .data.devices import AiPort, Chime from .data.types import ( AssetFileType, IteratorCallback, PosTransactionType, ProgressCallback, PTZPatrol, PTZPreset, SensorScheduleMode, SirenDuration, ) from .exceptions import ( ArmedModeError, BadRequest, ClientError, GlobalAlarmManagerError, NotAuthorized, NvrError, PublicOnlyModeError, ) from .stream import TalkbackSession if TYPE_CHECKING: from .devices.dispatcher import DeviceDispatcher from .events.dispatcher import EventDispatcher from .utils import ( decode_token_cookie, format_host_for_url, get_response_reason, ip_from_host, normalize_mac, pybool_to_json_bool, set_debug, to_js_time, utc_now, ) from .websocket import Websocket, WebsocketState if TYPE_CHECKING: from collections.abc import Callable from uiprotect.data.devices import LightDeviceSettings, LightModeSettings from uiprotect.data.public_devices import ( PublicLightDeviceSettings, PublicLightModeSettings, ) from .data.base import ProtectModelWithId from .data.public_bootstrap import DeviceWSResult, FetchDiff from .devices import ProtectDeviceChange from .events import EventChange, ProtectEvent if "partitioned" not in cookies.Morsel._reserved: # type: ignore[attr-defined] # See: https://github.com/python/cpython/issues/112713 cookies.Morsel._reserved["partitioned"] = "partitioned" # type: ignore[attr-defined] cookies.Morsel._flags.add("partitioned") # type: ignore[attr-defined] async def _async_warm_nvr_timezone(nvr_data: dict[str, Any]) -> None: """ Warm zoneinfo's cache for ``nvr_data["timezone"]`` off the event loop. ``NVR.unifi_dict_conversions`` constructs ``ZoneInfo(name)`` synchronously during parse; first construction does an ``os.stat`` lookup that blocks the loop. Awaiting :func:`async_get_time_zone` here primes ZoneInfo's internal cache so the later sync construction is a free hit. """ tz_name = nvr_data.get("timezone") if isinstance(tz_name, str): await async_get_time_zone(tz_name) class LightPatchRequest(TypedDict, total=False): """Type for PATCH /v1/lights/{id} request body.""" name: str isLightForceEnabled: bool lightModeSettings: dict[str, Any] lightDeviceSettings: dict[str, Any] class PublicApiChimeRingSettingRequest(TypedDict): """Type for ringSettings items in PATCH /v1/chimes/{id} request body (Public API).""" cameraId: str repeatTimes: int ringtoneId: NotRequired[str | None] volume: int class PublicApiChimePatchRequest(TypedDict, total=False): """Type for PATCH /v1/chimes/{id} request body (Public API).""" name: str cameraIds: list[str] ringSettings: list[PublicApiChimeRingSettingRequest] class CameraPublicApiLcdMessageRequest(TypedDict, total=False): """ Type for lcdMessage in PATCH /v1/cameras/{id} request body (Public API). Per the integration spec, ``type`` is required for every message variant; ``text`` is required for CUSTOM_MESSAGE and IMAGE; ``resetAt`` is optional for all variants (UNIX timestamp in ms; omit to use the NVR default, pass ``None`` for "forever"). A ``resetAt`` already in the past clears the message. """ type: str text: str resetAt: int | None TOKEN_COOKIE_MAX_EXP_SECONDS = 60 # how many seconds before the bootstrap is refreshed from Protect DEVICE_UPDATE_INTERVAL = 900 # retry timeout for thumbnails/heatmaps RETRY_TIMEOUT = 10 # Retry configuration constants RETRY_DEFAULT_ATTEMPTS = 3 RETRY_BASE_DELAY = 1.0 RETRY_MAX_DELAY = 30.0 RETRY_EXPONENTIAL_BASE = 2.0 RETRY_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504}) TYPES_BUG_MESSAGE = """There is currently a bug in UniFi Protect that makes `start` / `end` not work if `types` is not provided. This means uiprotect has to iterate over all of the events matching the filters provided to return values. If your Protect instance has a lot of events, this request will take much longer then expected. It is recommended adding additional filters to speed the request up.""" _LOGGER = logging.getLogger(__name__) _COOKIE_RE = re.compile(r"^set-cookie: ", re.IGNORECASE) # Sentinel used by ``update_viewer_public`` to distinguish "do not change" (the # default) from "explicitly set to null". The viewer's ``liveview`` wire field # is legitimately nullable, so a plain ``None`` cannot serve both meanings. # A dedicated singleton type keeps the sentinel out of ``Any`` so callers # can't accidentally smuggle it through the typed surface. class _UnsetType: # ``Self`` rather than ``_UnsetType`` so the singleton stays correctly # typed under any (currently hypothetical) subclass — also satisfies PYI034. _instance: Self | None = None def __new__(cls) -> Self: if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __repr__(self) -> str: return "" _UNSET: _UnsetType = _UnsetType() # Substring present in the 400 error reason returned by the NVR when the # alarm-manager endpoint is *not local* (i.e. set to Global instead of Local). # Matched case-insensitively so minor server-side capitalisation changes are # tolerated. Extracted as a constant to make the match visible and easy to update. _GLOBAL_ALARM_MANAGER_REASON = "global alarm manager" _ARM_ALARM_ARMED_REASON = "arm alarm is armed" def _log_or_raise( label: str, exc: BaseException, *, tolerate_not_authorized: bool = False ) -> None: """ Log ``BadRequest``/``NvrError`` (endpoint unavailable); re-raise anything else. ``NotAuthorized`` (a ``BadRequest`` subclass) is re-raised unless ``tolerate_not_authorized`` is set, in which case it is logged. """ if isinstance(exc, NotAuthorized): if tolerate_not_authorized: _LOGGER.debug( "%s endpoint not authorized (feature disabled?): %s", label, exc ) return raise exc if isinstance(exc, (BadRequest, NvrError)): _LOGGER.debug("%s endpoint unavailable: %s", label, exc) else: raise exc NFC_FINGERPRINT_SUPPORT_VERSION = Version("5.1.57") # Minimum interval (seconds) between two automatic public-bootstrap resyncs # triggered by devices-websocket reconnects. Guards against reconnect storms # on flaky networks or controller reboots. PUBLIC_RESYNC_MIN_INTERVAL = 10.0 # WebSocket heartbeat (seconds) for the public integration WS connections. The # UniFi OS nginx reverse proxy closes an idle tunnel after ``proxy_read_timeout # 10m``; the quiet ``subscribe/devices`` channel only pushes on a ~10-minute # cadence, so without keepalive the idle timer drops it every cycle. A ping # elicits a server pong — an upstream read that resets the proxy timer — and # also gives aiohttp liveness detection. 180s keeps ~3 touches per 10-min # window so a single lost frame does not trip the timeout. PUBLIC_WS_HEARTBEAT = 180.0 # Same keepalive for the private WS. Traffic volume is not liveness: when the # path drops without a close frame — a switch/controller reboot mid-stream — # ``receive()`` blocks forever on the half-open socket, so the state stays # CONNECTED and the reconnect loop never runs. A ping is the only thing that # fails on a dead peer. PRIVATE_WS_HEARTBEAT = 180.0 # Max concurrent RTSPS-stream fetches issued while priming cameras in # ``update_public``. Bounds the GET fan-out so a large install does not fire # dozens of requests at once. RTSPS_PRIME_CONCURRENCY = 8 # Per-camera timeout for a single RTSPS-stream prime fetch. The public API # session carries no ``ClientTimeout``, so without this a camera whose GET hangs # (console under load right after an upgrade reboot) would hold its concurrency # slot for aiohttp's 300s default and stall ``update_public`` for minutes. RTSPS_PRIME_TIMEOUT = 10.0 # Number of extra attempts per camera after the first prime fetch fails. One # short retry heals a single transient failure during a boot storm without # leaving the camera streamless for the whole session. RTSPS_PRIME_RETRIES = 1 def calculate_retry_delay(attempt: int, retry_after: float | None = None) -> float: """ Calculate delay before next retry attempt with exponential backoff and jitter. Args: attempt: Current retry attempt number (0-based). retry_after: Optional Retry-After header value in seconds. Returns: Delay in seconds before next retry. """ if retry_after is not None and retry_after > 0: delay = min(retry_after, RETRY_MAX_DELAY) # Only add positive jitter when server specified a delay jitter_range = delay * 0.25 delay += random.uniform(0, jitter_range) # noqa: S311 else: delay = RETRY_BASE_DELAY * (RETRY_EXPONENTIAL_BASE**attempt) delay = min(delay, RETRY_MAX_DELAY) # Full jitter (±25% of delay) for calculated delays jitter_range = delay * 0.25 delay += random.uniform(-jitter_range, jitter_range) # noqa: S311 # Ensure delay stays within bounds [0.1, RETRY_MAX_DELAY] return max(0.1, min(delay, RETRY_MAX_DELAY)) def parse_retry_after(response: ClientResponse) -> float | None: """ Parse Retry-After header from response. Args: response: HTTP response object. Returns: Retry delay in seconds, or None if header not present/parseable. """ retry_after = response.headers.get("Retry-After") if retry_after is None: return None try: # Retry-After can be seconds or HTTP-date, we only handle seconds return float(retry_after) except ValueError: _LOGGER.debug("Could not parse Retry-After header: %s", retry_after) return None def get_user_hash(host: str, username: str) -> str: session = hashlib.sha256() session.update(host.encode("utf8")) session.update(username.encode("utf8")) return session.hexdigest() class BaseApiClient: _host: str _port: int _username: str | None _password: str | None _api_key: str | None = None # True when constructed with only an API key (no private credentials). # Private-session entry points fail fast in this mode. _public_only: bool = False _verify_ssl: bool _ws_timeout: int _is_authenticated: bool = False _last_token_cookie: Morsel[str] | None = None _last_token_cookie_decode: dict[str, Any] | None = None _session: aiohttp.ClientSession | None = None _public_api_session: aiohttp.ClientSession | None = None _loaded_session: bool = False _cookiename = "TOKEN" _max_retries: int = RETRY_DEFAULT_ATTEMPTS headers: dict[str, str] | None = None _private_websocket: Websocket | None = None _events_websocket: Websocket | None = None _devices_websocket: Websocket | None = None _public_resync_task: asyncio.Task[None] | None = None private_api_path: str = "/proxy/protect/api/" public_api_path: str = "/proxy/protect/integration" private_ws_path: str = "/proxy/protect/ws/updates" events_ws_path: str = "/proxy/protect/integration/v1/subscribe/events" devices_ws_path: str = "/proxy/protect/integration/v1/subscribe/devices" cache_dir: Path config_dir: Path store_sessions: bool def __init__( self, host: str, port: int, username: str | None = None, password: str | None = None, api_key: str | None = None, verify_ssl: bool = True, session: aiohttp.ClientSession | None = None, public_api_session: aiohttp.ClientSession | None = None, ws_timeout: int = 30, cache_dir: Path | None = None, config_dir: Path | None = None, store_sessions: bool = True, ws_receive_timeout: int | None = None, max_retries: int = RETRY_DEFAULT_ATTEMPTS, ) -> None: # Public-only when no private credentials are supplied but an API key # is. The private session is never opened in this mode. public_only = not username and not password and bool(api_key) # Reject incomplete private credentials (one of username/password # missing) so the failure is at construction, not a malformed # /api/auth/login with a null field at first request. if not (username and password) and not public_only: raise BadRequest( "Provide both username and password, or an api_key, " "to construct a client" ) self._auth_lock = asyncio.Lock() # Serializes ``update_public()``: an overlapping prime could apply an # older snapshot over a newer one (and over live WS merges in between). self._public_update_lock = asyncio.Lock() self._host = host self._port = port self._username = username self._password = password self._api_key = api_key self._public_only = public_only self._verify_ssl = verify_ssl self._ws_timeout = ws_timeout self._ws_receive_timeout = ws_receive_timeout self._loaded_session = False self._update_task: asyncio.Task[Bootstrap | None] | None = None # Per-camera background RTSPS-stream refresh tasks, keyed by camera id. # Per-instance (never a class-level mutable default — one process can # drive several consoles). Cancelled in :meth:`close_session`. self._rtsps_refresh_tasks: dict[str, asyncio.Task[None]] = {} # Per-siren timers announcing the end of a timed run, keyed by siren id. # Per-instance for the same reason as above; cancelled in # :meth:`close_session` and on a devices-websocket disconnect. self._siren_off_tasks: dict[str, asyncio.Task[None]] = {} # Proactive per-API-key pacer for the public path. Per-instance so # several consoles in one process never share a budget. self._public_rate_limiter = PublicApiRateLimiter() self._max_retries = max_retries self.config_dir = config_dir or (Path(user_config_dir()) / "ufp") self.cache_dir = cache_dir or (Path(user_cache_dir()) / "ufp_cache") self.store_sessions = store_sessions if session is not None: self._session = session if public_api_session is not None: self._public_api_session = public_api_session self._update_url() def _update_cookiename(self, cookie: SimpleCookie) -> None: if "UOS_TOKEN" in cookie: self._cookiename = "UOS_TOKEN" def _update_url(self) -> None: """Updates the url after changing _host or _port.""" formatted_host = format_host_for_url(self._host) if self._port != 443: self._url = URL(f"https://{formatted_host}:{self._port}") self._ws_url = URL( f"wss://{formatted_host}:{self._port}{self.private_ws_path}" ) self._events_ws_url = URL( f"https://{formatted_host}:{self._port}{self.events_ws_path}" ) self._devices_ws_url = URL( f"https://{formatted_host}:{self._port}{self.devices_ws_path}" ) else: self._url = URL(f"https://{formatted_host}") self._ws_url = URL(f"wss://{formatted_host}{self.private_ws_path}") self._events_ws_url = URL(f"https://{formatted_host}{self.events_ws_path}") self._devices_ws_url = URL( f"https://{formatted_host}{self.devices_ws_path}" ) self.base_url = str(self._url) @property def _ws_url_object(self) -> URL: """Get Websocket URL.""" if last_update_id := self._get_last_update_id(): return self._ws_url.with_query(lastUpdateId=last_update_id) return self._ws_url @property def ws_url(self) -> str: """Get Websocket URL.""" return str(self._ws_url_object) @property def events_ws_url(self) -> str: """Get Events Websocket URL.""" return str(self._events_ws_url) @property def devices_ws_url(self) -> str: """Get Devices Websocket URL.""" return str(self._devices_ws_url) @property def is_public_only(self) -> bool: """Whether this client was built with only an API key (no private login).""" return self._public_only @property def config_file(self) -> Path: return self.config_dir / "unifi_protect.json" async def get_session(self) -> aiohttp.ClientSession: """Gets or creates current client session""" if self._session is None or self._session.closed: if self._session is not None and self._session.closed: _LOGGER.debug("Session was closed, creating a new one") # need unsafe to access httponly cookies self._session = aiohttp.ClientSession(cookie_jar=CookieJar(unsafe=True)) return self._session async def get_public_api_session(self) -> aiohttp.ClientSession: """Gets or creates current public API client session""" if self._public_api_session is None or self._public_api_session.closed: if self._public_api_session is not None and self._public_api_session.closed: _LOGGER.debug("Public API session was closed, creating a new one") self._public_api_session = aiohttp.ClientSession() return self._public_api_session async def _auth_websocket(self, force: bool) -> dict[str, str] | None: """Authenticate for Websocket.""" if force: await self._reauthenticate() else: await self.ensure_authenticated() return self.headers async def _auth_public_api_websocket( self, force: bool = False ) -> dict[str, str] | None: """Authenticate for Public API Websocket.""" if self._api_key is None: raise NotAuthorized("API key is required for public API WebSocket") return {"X-API-KEY": self._api_key} def _get_websocket(self) -> Websocket: """Gets or creates current Websocket.""" if self._private_websocket is None: self._private_websocket = Websocket( self._get_websocket_url, self._auth_websocket, self._update_bootstrap_soon, self.get_session, self._process_ws_message, self._on_websocket_state_change, verify=self._verify_ssl, timeout=self._ws_timeout, receive_timeout=self._ws_receive_timeout, heartbeat=PRIVATE_WS_HEARTBEAT, ) return self._private_websocket def _get_events_websocket(self) -> Websocket: """Gets or creates current Events Websocket.""" if self._events_websocket is None: self._events_websocket = Websocket( lambda: self._events_ws_url, self._auth_public_api_websocket, lambda: None, self.get_public_api_session, self._process_events_ws_message, self._on_events_websocket_state_change, verify=self._verify_ssl, timeout=self._ws_timeout, receive_timeout=self._ws_receive_timeout, heartbeat=PUBLIC_WS_HEARTBEAT, ) return self._events_websocket def _get_devices_websocket(self) -> Websocket: """Gets or creates current Devices Websocket.""" if self._devices_websocket is None: self._devices_websocket = Websocket( lambda: self._devices_ws_url, self._auth_public_api_websocket, lambda: None, self.get_public_api_session, self._process_devices_ws_message, self._on_devices_websocket_state_change, verify=self._verify_ssl, timeout=self._ws_timeout, receive_timeout=self._ws_receive_timeout, heartbeat=PUBLIC_WS_HEARTBEAT, ) return self._devices_websocket def _update_bootstrap_soon(self) -> None: """Update bootstrap soon.""" _LOGGER.debug("Updating bootstrap soon") # Force the next bootstrap update # since the lastUpdateId is not valid anymore if self._update_task and not self._update_task.done(): return self._update_task = asyncio.create_task(self.update()) async def close_session(self) -> None: """Closing and deletes all client sessions.""" await self._cancel_update_task() await self._cancel_public_resync_task() await self._cancel_rtsps_refresh_tasks() await self._cancel_siren_off_tasks() if self._session is not None: await self._session.close() self._session = None self._loaded_session = False if self._public_api_session is not None: await self._public_api_session.close() self._public_api_session = None async def close_public_api_session(self) -> None: """Closing and deletes public API client session.""" if self._public_api_session is not None: await self._public_api_session.close() self._public_api_session = None async def _cancel_update_task(self) -> None: if self._update_task: self._update_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._update_task self._update_task = None async def _cancel_public_resync_task(self) -> None: # If a subclass tracks queued follow-up resync work, clear it before # cancellation so shutdown cannot re-schedule a new task in a # ``finally`` block. self._public_resync_pending = False if self._public_resync_task is not None: self._public_resync_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._public_resync_task self._public_resync_task = None async def _cancel_rtsps_refresh_tasks(self) -> None: """Cancel and await every pending background RTSPS refresh task.""" if not self._rtsps_refresh_tasks: return tasks = list(self._rtsps_refresh_tasks.values()) self._rtsps_refresh_tasks.clear() for task in tasks: task.cancel() for task in tasks: with contextlib.suppress(asyncio.CancelledError): await task def _cancel_all_siren_offs(self) -> list[asyncio.Task[None]]: """Cancel every pending siren expiry announcement, returning the tasks.""" tasks = list(self._siren_off_tasks.values()) self._siren_off_tasks.clear() for task in tasks: task.cancel() return tasks async def _cancel_siren_off_tasks(self) -> None: """Cancel and await every pending siren expiry announcement.""" for task in self._cancel_all_siren_offs(): with contextlib.suppress(asyncio.CancelledError): await task def set_header(self, key: str, value: str | None) -> None: """Set header.""" self.headers = self.headers or {} if value is None: self.headers.pop(key, None) else: self.headers[key] = value async def _paced_request( self, session: aiohttp.ClientSession, method: str, request_url: URL, headers: dict[str, str], public_api: bool, **kwargs: Any, ) -> aiohttp.ClientResponse: """Gate the public path through the rate limiter, then send the request.""" if public_api: await self._public_rate_limiter.acquire() response = await self._do_request( session, method, request_url, headers, **kwargs ) if public_api: self._public_rate_limiter.seed_from_policy( response.headers.get("RateLimit-Policy") ) return response async def _do_request( self, session: aiohttp.ClientSession, method: str, request_url: URL, headers: dict[str, str], **kwargs: Any, ) -> aiohttp.ClientResponse: """Execute a single HTTP request with disconnect retry.""" last_err: aiohttp.ServerDisconnectedError | None = None for _attempt in range(2): try: req_context = session.request( method, request_url, headers=headers, **kwargs, ) response = await req_context.__aenter__() try: await self._update_last_token_cookie(response) except Exception: response.release() raise return response except aiohttp.ServerDisconnectedError as err: # If the server disconnected, try again # since HTTP/1.1 allows the server to disconnect at any time last_err = err except client_exceptions.ClientError as err: raise NvrError( f"Error requesting data from {self._host}: {err}", ) from err raise NvrError( f"Error requesting data from {self._host}: {last_err}", ) from last_err async def request( self, method: str, url: str, require_auth: bool = False, auto_close: bool = True, public_api: bool = False, **kwargs: Any, ) -> aiohttp.ClientResponse: """ Make a request to UniFi Protect with automatic retry on transient errors. Automatically retries requests that receive 408, 429, 500, 502, 503, or 504 status codes using exponential backoff. """ if require_auth and not public_api: await self.ensure_authenticated() request_url = self._url.join( URL(SplitResult("", "", url, "", ""), encoded=True) ) headers = kwargs.get("headers") or self.headers or {} if require_auth and public_api: if self._api_key is None: raise NotAuthorized("API key is required for public API requests") headers = {"X-API-KEY": self._api_key} _LOGGER.debug("Request url: %s", request_url) if not self._verify_ssl: kwargs["ssl"] = False if public_api: session = await self.get_public_api_session() else: session = await self.get_session() # First attempt (always happens, even with max_retries=0) response = await self._paced_request( session, method, request_url, headers, public_api, **kwargs ) # Retry loop for transient errors for retry_attempt in range(self._max_retries): if response.status not in RETRY_STATUS_CODES: break retry_after = parse_retry_after(response) response.release() delay = calculate_retry_delay(retry_attempt, retry_after) # Escalate log level: DEBUG (1st) → INFO (2nd) → WARNING (3rd+) log_level = (logging.DEBUG, logging.INFO, logging.WARNING)[ min(retry_attempt, 2) ] _LOGGER.log( log_level, "Request to %s returned %s, retrying in %.2f seconds (attempt %d/%d)", request_url, response.status, delay, retry_attempt + 1, self._max_retries, ) await asyncio.sleep(delay) response = await self._paced_request( session, method, request_url, headers, public_api, **kwargs ) # Re-auth-and-retry guard for early server-side session invalidation. # UniFi OS can drop a private session before its JWT cookie expires, so # is_authenticated() still trusts the dead cookie and the request 401s. # Mirror the websocket path: clear the session, log in fresh, retry once. # The public-API path is excluded — a 401 there is a bad API key, not a # recoverable session. if ( require_auth and not public_api and response.status == HTTPStatus.UNAUTHORIZED.value ): response.release() await self._reauthenticate() # headers references self.headers, which _reauthenticate refreshes # in place; rebuild only when the caller supplied its own headers. if kwargs.get("headers") is None: headers = self.headers or {} response = await self._paced_request( session, method, request_url, headers, public_api, **kwargs ) if auto_close: try: _LOGGER.debug( "%s %s %s", response.status, response.content_type, response, ) response.release() except Exception: # make sure response is released response.release() # re-raise exception raise return response async def api_request_raw( self, url: str, method: str = "get", require_auth: bool = True, raise_exception: bool = True, api_path: str | None = None, public_api: bool = False, **kwargs: Any, ) -> bytes | None: """Make an API request; transport failures surface as ``NvrError``.""" path = self.private_api_path if api_path is not None: path = api_path elif public_api: path = self.public_api_path response = await self.request( method, f"{path}{url}", require_auth=require_auth, auto_close=False, public_api=public_api, **kwargs, ) try: # Check for successful status codes (2xx range) if not (200 <= response.status < 300): await self._raise_for_status(response, raise_exception) return None try: data: bytes | None = await response.read() except (client_exceptions.ClientError, TimeoutError) as err: raise NvrError( f"Error reading response from {self._host}: {err}", ) from err response.release() return data except Exception: # make sure response is released response.release() # re-raise exception raise async def _raise_for_status( self, response: aiohttp.ClientResponse, raise_exception: bool = True ) -> None: """Raise an exception based on the response status.""" url = response.url reason = await get_response_reason(response) msg = "Request failed: %s - Status: %s - Reason: %s" status = response.status # Success status codes (2xx) should not raise exceptions if 200 <= status < 300: return if raise_exception: if status in { HTTPStatus.UNAUTHORIZED.value, HTTPStatus.FORBIDDEN.value, }: raise NotAuthorized(msg % (url, status, reason)) if status == HTTPStatus.TOO_MANY_REQUESTS.value: _LOGGER.debug("Too many requests - Login is rate limited: %s", response) raise NvrError(msg % (url, status, reason)) # Handle 400 Bad Request specifically; check for two typed # sub-cases before the generic fallback: the global alarm manager # error (returned when alarm-manager is not local, e.g. set to # global instead) and the arm-alarm-armed error (returned when an # operation is rejected because the alarm is currently armed). # Any other 400 reason is treated as a generic bad request. if status == HTTPStatus.BAD_REQUEST.value: if _GLOBAL_ALARM_MANAGER_REASON in reason.lower(): raise GlobalAlarmManagerError(msg % (url, status, reason)) if _ARM_ALARM_ARMED_REASON in reason.lower(): raise ArmedModeError(msg % (url, status, reason)) raise BadRequest(msg % (url, status, reason)) # Other 4xx client errors also raise BadRequest if ( status > HTTPStatus.BAD_REQUEST.value and status < HTTPStatus.INTERNAL_SERVER_ERROR.value ): raise BadRequest(msg % (url, status, reason)) raise NvrError(msg % (url, status, reason)) _LOGGER.debug(msg, url, status, reason) async def api_request( self, url: str, method: str = "get", require_auth: bool = True, raise_exception: bool = True, api_path: str | None = None, public_api: bool = False, **kwargs: Any, ) -> list[Any] | dict[str, Any] | None: data = await self.api_request_raw( url=url, method=method, require_auth=require_auth, raise_exception=raise_exception, api_path=api_path, public_api=public_api, **kwargs, ) if data is not None: json_data: list[Any] | dict[str, Any] try: json_data = orjson.loads(data) return json_data except orjson.JSONDecodeError as ex: _LOGGER.error("Could not decode JSON from %s", url) raise NvrError(f"Could not decode JSON from {url}") from ex return None async def api_request_obj( self, url: str, method: str = "get", require_auth: bool = True, raise_exception: bool = True, public_api: bool = False, **kwargs: Any, ) -> dict[str, Any]: data = await self.api_request( url=url, method=method, require_auth=require_auth, raise_exception=raise_exception, public_api=public_api, **kwargs, ) if not isinstance(data, dict): raise NvrError(f"Could not decode object from {url}") return data async def api_request_list( self, url: str, method: str = "get", require_auth: bool = True, raise_exception: bool = True, public_api: bool = False, **kwargs: Any, ) -> list[Any]: data = await self.api_request( url=url, method=method, require_auth=require_auth, raise_exception=raise_exception, public_api=public_api, **kwargs, ) if not isinstance(data, list): raise NvrError(f"Could not decode list from {url}") return data async def ensure_authenticated(self) -> None: """Ensure we are authenticated.""" if self._public_only: raise PublicOnlyModeError( "Private authentication is unavailable on a public-only client; " "use the public API surface (update_public, subscribe_*, public setters)" ) await self._load_session() if self.is_authenticated() is False: await self.authenticate() async def authenticate(self) -> None: """Authenticate and get a token.""" if self._public_only: raise PublicOnlyModeError( "Private authentication is unavailable on a public-only client; " "use the public API surface (update_public, subscribe_*, public setters)" ) if self._auth_lock.locked(): # If an auth is already in progress # do not start another one async with self._auth_lock: return async with self._auth_lock: url = "/api/auth/login" if self._session is not None: self._session.cookie_jar.clear() self.set_header("cookie", None) auth = { "username": self._username, "password": self._password, "rememberMe": self.store_sessions, } response = await self.request("post", url=url, json=auth) if response.status != 200: await self._raise_for_status(response, True) csrf_token = response.headers.get("x-csrf-token") if csrf_token: self.set_header("x-csrf-token", csrf_token) cookie_header = response.headers.get("set-cookie", "") self.set_header("cookie", cookie_header) self._is_authenticated = True # parse and store the cookie for session persistence if self.store_sessions and cookie_header: # extract cookie from header to save in session file cookie = SimpleCookie(cookie_header) if cookie: for cookie_obj in cookie.values(): self._last_token_cookie = cookie_obj await self._update_auth_config(cookie_obj) break # auth response only contains single cookie (TOKEN or UOS_TOKEN) _LOGGER.debug("Authenticated successfully!") async def _reauthenticate(self) -> None: """ Force a fresh private-API login after a dead session. Clears the invalidated session (cookie jar + headers + ``_is_authenticated``) so ``ensure_authenticated`` performs a real login instead of trusting the stale JWT cookie. The clear is synchronous and the re-login serializes on ``authenticate``'s ``_auth_lock``, so concurrent callers (e.g. simultaneous 401s) collapse onto a single re-login without a thundering herd. """ if self._session is not None: self._session.cookie_jar.clear() self.set_header("cookie", None) self.set_header("x-csrf-token", None) self._is_authenticated = False await self.ensure_authenticated() async def _update_last_token_cookie(self, response: aiohttp.ClientResponse) -> None: """Update the last token cookie.""" csrf_token = response.headers.get("x-csrf-token") if ( csrf_token is not None and self.headers and csrf_token != self.headers.get("x-csrf-token") ): self.set_header("x-csrf-token", csrf_token) self._update_cookiename(response.cookies) if ( token_cookie := response.cookies.get(self._cookiename) ) and token_cookie != self._last_token_cookie: self._last_token_cookie = token_cookie if self.store_sessions: await self._update_auth_config(self._last_token_cookie) self._last_token_cookie_decode = None async def _update_auth_config(self, cookie: Morsel[str]) -> None: """Updates auth cookie on disk for persistent sessions.""" username = self._username if self._last_token_cookie is None or self._public_only or username is None: return await aos.makedirs(self.config_dir, exist_ok=True) config: dict[str, Any] = {} session_hash = get_user_hash(str(self._url), username) try: async with aiofiles.open(self.config_file, "rb") as f: config_data = await f.read() if config_data: try: config = orjson.loads(config_data) except Exception: _LOGGER.warning("Invalid config file, ignoring.") except FileNotFoundError: pass config["sessions"] = config.get("sessions", {}) config["sessions"][session_hash] = { "metadata": dict(cookie), "cookiename": self._cookiename, "value": cookie.value, "csrf": self.headers.get("x-csrf-token") if self.headers else None, } async with aiofiles.open(self.config_file, "wb") as f: await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2)) async def _load_session(self) -> None: if self._public_only: return if self._session is None: await self.get_session() assert self._session is not None if not self._loaded_session and self.store_sessions: session_cookie = await self._read_auth_config() self._loaded_session = True if session_cookie: _LOGGER.debug("Successfully loaded session from config") self._session.cookie_jar.update_cookies(session_cookie) async def _read_auth_config(self) -> SimpleCookie | None: """Read auth cookie from config.""" config: dict[str, Any] = {} try: async with aiofiles.open(self.config_file, "rb") as f: config_data = await f.read() if config_data: try: config = orjson.loads(config_data) except Exception: _LOGGER.warning("Invalid config file, ignoring.") return None except FileNotFoundError: _LOGGER.debug("no config file, not loading session") return None session_hash = get_user_hash(str(self._url), self._username or "") session = config.get("sessions", {}).get(session_hash) if not session: _LOGGER.debug("No existing session for %s", session_hash) return None cookie = SimpleCookie() cookie_name = session.get("cookiename") if cookie_name is None: return None cookie[cookie_name] = session.get("value") for key, value in session.get("metadata", {}).items(): cookie[cookie_name][key] = value cookie_value = _COOKIE_RE.sub("", str(cookie[cookie_name])) self._last_token_cookie = cookie[cookie_name] self._last_token_cookie_decode = None # Only mark as authenticated if we have both cookie and CSRF token csrf_token = session.get("csrf") if not csrf_token: _LOGGER.debug("Session found but missing CSRF token, will re-authenticate") return None self._is_authenticated = True self.set_header("cookie", cookie_value) self.set_header("x-csrf-token", csrf_token) return cookie def is_authenticated(self) -> bool: """Check to see if we are already authenticated.""" if self._session is None: return False if self._is_authenticated is False: return False if self._last_token_cookie is None: return False # Lazy decode the token cookie if self._last_token_cookie and self._last_token_cookie_decode is None: self._last_token_cookie_decode = decode_token_cookie( self._last_token_cookie, ) if ( self._last_token_cookie_decode is None or "exp" not in self._last_token_cookie_decode ): return False token_expires_at = cast("int", self._last_token_cookie_decode["exp"]) max_expire_time = time.time() + TOKEN_COOKIE_MAX_EXP_SECONDS return token_expires_at >= max_expire_time async def clear_session(self) -> None: """Clears stored session for this specific user/host combination.""" if not self.store_sessions: return username = self._username if username is None: return config: dict[str, Any] = {} session_hash = get_user_hash(str(self._url), username) try: async with aiofiles.open(self.config_file, "rb") as f: config_data = await f.read() if config_data: try: config = orjson.loads(config_data) except orjson.JSONDecodeError: _LOGGER.warning("Invalid config file, ignoring.") return except FileNotFoundError: return if "sessions" in config and session_hash in config["sessions"]: del config["sessions"][session_hash] async with aiofiles.open(self.config_file, "wb") as f: await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2)) _LOGGER.debug("Cleared session for %s", session_hash) # Clear authentication state only when session was actually removed self._is_authenticated = False self._last_token_cookie = None self._last_token_cookie_decode = None async def clear_all_sessions(self) -> None: """Clears all stored sessions from the config file.""" if not self.store_sessions: return try: await aos.remove(self.config_file) except FileNotFoundError: # File already gone - either never existed or removed by another process return # If we get here, the file was successfully removed (no exception raised) _LOGGER.debug("Cleared all sessions from config file") # Clear authentication state only after successful deletion self._is_authenticated = False self._last_token_cookie = None self._last_token_cookie_decode = None def _get_websocket_url(self) -> URL: """Get Websocket URL.""" return self._ws_url_object async def async_disconnect_ws(self) -> None: """Disconnect from Websocket.""" if self._private_websocket: websocket = self._get_websocket() websocket.stop() await websocket.wait_closed() self._private_websocket = None if self._events_websocket: events_websocket = self._get_events_websocket() events_websocket.stop() await events_websocket.wait_closed() self._events_websocket = None if self._devices_websocket: devices_websocket = self._get_devices_websocket() devices_websocket.stop() await devices_websocket.wait_closed() self._devices_websocket = None def _process_ws_message(self, msg: aiohttp.WSMessage) -> None: raise NotImplementedError def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None: """Process events websocket message - to be implemented by subclass.""" raise NotImplementedError def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None: """Process devices websocket message - to be implemented by subclass.""" raise NotImplementedError def _get_last_update_id(self) -> str | None: raise NotImplementedError async def update(self) -> Bootstrap: raise NotImplementedError def _on_websocket_state_change(self, state: WebsocketState) -> None: """Websocket state changed.""" _LOGGER.debug("Websocket state changed: %s", state) def _on_events_websocket_state_change(self, state: WebsocketState) -> None: """Events websocket state changed.""" _LOGGER.debug("Events websocket state changed: %s", state) def _on_devices_websocket_state_change(self, state: WebsocketState) -> None: """Devices websocket state changed.""" _LOGGER.debug("Devices websocket state changed: %s", state) class ProtectApiClient(BaseApiClient): """ Main UFP API Client UniFi Protect is a full async application. "normal" use of interacting with it is to call `.update()` which will initialize the `.bootstrap` and create a Websocket connection to UFP. This Websocket connection will emit messages that will automatically update the `.bootstrap` over time. You can use the `.get_` methods to one off pull devices from the UFP API, but should not be used for building an application on top of. All objects inside of `.bootstrap` have a reference back to the API client so they can use `.save_device()` and update themselves using their own `.set_` methods on the object. Args: ---- host: UFP hostname / IP address port: UFP HTTPS port username: UFP username password: UFP password api_key: API key for UFP verify_ssl: Verify HTTPS certificate (default: `True`) session: Optional aiohttp session to use (default: generate one) override_connection_host: Use `host` as your `connection_host` for the private RTSP(S) URLs and the public RTSPS URLs instead of using the one provided by UniFi Protect. minimum_score: minimum score for events (default: `0`) subscribed_models: Model types you want to filter events for WS. You will need to manually check the bootstrap for updates for events that not subscibred. ignore_stats: Ignore storage, system, etc. stats/metrics from NVR and cameras (default: false) debug: Use full type validation (default: false) """ _minimum_score: int _subscribed_models: set[ModelType] # ``None`` means "inherit ``_subscribed_models``"; an explicit (possibly # empty) set overrides the global filter for that websocket. An empty set # therefore means "allow all models" — matching the private-WS behaviour. _events_ws_subscribed_models: set[ModelType] | None _devices_ws_subscribed_models: set[ModelType] | None _ignore_stats: bool _ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]] _events_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]] _devices_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]] _ws_state_subscriptions: list[Callable[[WebsocketState], None]] _events_ws_state_subscriptions: list[Callable[[WebsocketState], None]] _devices_ws_state_subscriptions: list[Callable[[WebsocketState], None]] _bootstrap: Bootstrap | None = None _public_bootstrap: PublicBootstrap | None = None # True after the first time the devices WS transitions to CONNECTED; used # to distinguish the *initial* connect from a *reconnect*. Only the # latter triggers an automatic `update_public()` resync. _devices_ws_has_been_connected: bool = False # True after the first time the events WS transitions to CONNECTED; only a # *reconnect* (not the initial connect) force-ends events left open across # the gap. _events_ws_has_been_connected: bool = False # Monotonic timestamp of the last successful/attempted reconnect resync, # used to debounce a flapping websocket into a single refresh. _last_public_resync: float = 0.0 # Set when another reconnect happens while a public resync task is # still running; consumed in ``_resync_public_bootstrap`` to run one # follow-up refresh. _public_resync_pending: bool = False _last_update_dt: datetime | None = None _connection_host: IPv4Address | IPv6Address | str | None = None _override_connection_host: bool = False # Lazy dispatcher; ``subscribe_events`` materialises it. _event_dispatcher: EventDispatcher | None = None # Internal events-WS adapter unsubscribe; populated while the typed # ``subscribe_events`` callback list is non-empty. _event_ws_adapter_unsub: Callable[[], None] | None = None # Lazy dispatcher; ``subscribe_devices`` materialises it. _device_dispatcher: DeviceDispatcher | None = None # Internal devices-WS adapter unsubscribe; populated while the typed # ``subscribe_devices`` callback list is non-empty. _device_ws_adapter_unsub: Callable[[], None] | None = None # Monotonic timestamp of the last "REMOVE for unknown event" INFO log; # throttled so a burst of unknown-id removes does not flood the log. # Seeded to -inf (not 0.0) so the first unknown remove always logs: with # 0.0 the gate ``now - last >= 60`` only opens once ``monotonic()`` has # passed 60s, silently swallowing an early remove on a freshly-booted host. _events_remove_unknown_last_log: float = float("-inf") # Non-throttled running total of unknown-id REMOVE frames. The INFO log is # rate-limited, so this counter keeps a sustained cache/server desync # observable even when individual lines are suppressed. _events_remove_unknown_count: int = 0 # Public events/devices WS frames that arrive while ``update_public`` is # priming are captured here and replayed after the fresh snapshot is # applied, so an update landing in the prime window is never lost to the # snapshot replacing the cached objects. ``None`` outside a prime; primes # are serialized by ``_public_update_lock``, so exactly one prime owns it. _prime_ws_buffer: ( list[tuple[Callable[[aiohttp.WSMessage], None], aiohttp.WSMessage]] | None ) = None # Devices whose add/remove a real WS frame already announced while # ``update_public`` was running, so the membership diff does not # announce the same change a second time. ``None`` outside a prime. _public_membership_seen: set[tuple[ModelType, str]] | None = None ignore_unadopted: bool def __init__( self, host: str, port: int, username: str | None = None, password: str | None = None, api_key: str | None = None, verify_ssl: bool = True, session: aiohttp.ClientSession | None = None, public_api_session: aiohttp.ClientSession | None = None, ws_timeout: int = 30, cache_dir: Path | None = None, config_dir: Path | None = None, store_sessions: bool = True, override_connection_host: bool = False, minimum_score: int = 0, subscribed_models: set[ModelType] | None = None, events_ws_subscribed_models: set[ModelType] | None = None, devices_ws_subscribed_models: set[ModelType] | None = None, ignore_stats: bool = False, ignore_unadopted: bool = True, debug: bool = False, ws_receive_timeout: int | None = None, max_retries: int = RETRY_DEFAULT_ATTEMPTS, ) -> None: super().__init__( host=host, port=port, username=username, password=password, api_key=api_key, verify_ssl=verify_ssl, session=session, public_api_session=public_api_session, ws_timeout=ws_timeout, ws_receive_timeout=ws_receive_timeout, cache_dir=cache_dir, config_dir=config_dir, store_sessions=store_sessions, max_retries=max_retries, ) self._minimum_score = minimum_score self._subscribed_models = subscribed_models or set() # Preserve ``None`` vs. empty-set distinction: ``None`` inherits from # ``_subscribed_models``; an explicit empty set means "allow all". self._events_ws_subscribed_models = events_ws_subscribed_models self._devices_ws_subscribed_models = devices_ws_subscribed_models self._ignore_stats = ignore_stats self._ws_subscriptions = [] self._events_ws_subscriptions = [] self._devices_ws_subscriptions = [] self._ws_state_subscriptions = [] self._events_ws_state_subscriptions = [] self._devices_ws_state_subscriptions = [] self._event_dispatcher = None self._device_dispatcher = None self.ignore_unadopted = ignore_unadopted self._update_lock = asyncio.Lock() self._override_connection_host = override_connection_host if override_connection_host: self._connection_host = self._host if debug: set_debug() @classmethod def public_only( cls, host: str, port: int, api_key: str, *, verify_ssl: bool = True, public_api_session: aiohttp.ClientSession | None = None, ws_timeout: int = 30, ws_receive_timeout: int | None = None, subscribed_models: set[ModelType] | None = None, events_ws_subscribed_models: set[ModelType] | None = None, devices_ws_subscribed_models: set[ModelType] | None = None, ignore_unadopted: bool = True, override_connection_host: bool = False, max_retries: int = RETRY_DEFAULT_ATTEMPTS, ) -> Self: """ Construct a client that operates entirely on the Public Integration API. No private login is performed. Private-session entry points (:meth:`authenticate`, :meth:`update`, :meth:`get_bootstrap`) raise :class:`PublicOnlyModeError`; use :meth:`update_public`, :meth:`subscribe_events`, :meth:`subscribe_devices`, the public ``get_*_public`` / ``update_*_public`` methods, and :meth:`get_meta_info` instead. A revoked or invalid key surfaces as :class:`NotAuthorized`. ``override_connection_host`` rewrites the host of the public RTSPS URLs to ``host``; it needs no private bootstrap. """ return cls( host, port, api_key=api_key, verify_ssl=verify_ssl, public_api_session=public_api_session, ws_timeout=ws_timeout, ws_receive_timeout=ws_receive_timeout, subscribed_models=subscribed_models, events_ws_subscribed_models=events_ws_subscribed_models, devices_ws_subscribed_models=devices_ws_subscribed_models, ignore_unadopted=ignore_unadopted, override_connection_host=override_connection_host, max_retries=max_retries, ) def _set_connection_host_from_bootstrap(self) -> None: """ Set connection host from bootstrap NVR hosts (sync). NOTE: Must stay in sync with _async_set_connection_host_from_bootstrap(). Sync version for property getter, async version for update() method. """ index = 0 try: index = self.bootstrap.nvr.hosts.index(self._host) except ValueError: try: host = ip_address(self._host) with contextlib.suppress(ValueError): index = self.bootstrap.nvr.hosts.index(host) except ValueError: pass self._connection_host = self.bootstrap.nvr.hosts[index] async def _async_set_connection_host_from_bootstrap( self, bootstrap: Bootstrap, ) -> None: """ Set connection host from bootstrap NVR hosts (async). NOTE: Must stay in sync with _set_connection_host_from_bootstrap(). Async version allows DNS resolution via ip_from_host(). """ index = 0 try: index = bootstrap.nvr.hosts.index(self._host) except ValueError: try: host_ip = await ip_from_host(self._host) index = bootstrap.nvr.hosts.index(host_ip) except ValueError: pass self._connection_host = bootstrap.nvr.hosts[index] @cached_property def bootstrap(self) -> Bootstrap: if self._bootstrap is None: raise BadRequest("Client not initialized, run `update` first") return self._bootstrap @property def public_bootstrap(self) -> PublicBootstrap: """ In-memory cache of Public Integration API resources. Must be populated via :meth:`update_public` first; raises :class:`BadRequest` otherwise. This is a conscious mirror of :attr:`bootstrap` so that merely *reading* the property never changes WS-handler semantics (which keys off ``_public_bootstrap is not None``). """ if self._public_bootstrap is None: raise BadRequest( "Public bootstrap not initialized, run `update_public` first" ) return self._public_bootstrap @property def has_public_bootstrap(self) -> bool: """Whether :meth:`update_public` has been called at least once.""" return self._public_bootstrap is not None @property def override_connection_host(self) -> bool: """Whether `host` overrides the console-reported connection host.""" return self._override_connection_host @property def connection_host(self) -> IPv4Address | IPv6Address | str: """Connection host to use for generating RTSP URLs.""" if self._connection_host is None: self._set_connection_host_from_bootstrap() assert self._connection_host is not None return self._connection_host async def update(self) -> Bootstrap: """ Updates the state of devices, initializes `.bootstrap` The websocket is auto connected once there are any subscriptions to it. update must be called at least once before subscribing to the websocket. You can use the various other `get_` methods if you need one off data from UFP """ if self._public_only: raise PublicOnlyModeError( "Private bootstrap is unavailable on a public-only client; " "use update_public() instead" ) async with self._update_lock: bootstrap = await self.get_bootstrap() if bootstrap.nvr.version >= NFC_FINGERPRINT_SUPPORT_VERSION: try: keyrings = await self.api_request_list("keyrings") except NotAuthorized as err: _LOGGER.debug("No access to keyrings %s, skipping", err) keyrings = [] try: ulp_users = await self.api_request_list("ulp-users") except NotAuthorized as err: _LOGGER.debug("No access to ulp-users %s, skipping", err) ulp_users = [] bootstrap.keyrings = Keyrings.from_list( cast("list[Keyring]", list_from_unifi_list(self, keyrings)) ) bootstrap.ulp_users = UlpUsers.from_list( cast("list[UlpUser]", list_from_unifi_list(self, ulp_users)) ) self.__dict__.pop("bootstrap", None) self._bootstrap = bootstrap # Set connection host if not set via override_connection_host if self._connection_host is None: await self._async_set_connection_host_from_bootstrap(bootstrap) return bootstrap async def poll_events(self) -> None: """Poll for events.""" now_dt = utc_now() max_event_dt = now_dt - timedelta(hours=1) events = await self.get_events( start=self._last_update_dt or max_event_dt, end=now_dt, ) for event in events: self.bootstrap.process_event(event) self._last_update_dt = now_dt def emit_message(self, msg: WSSubscriptionMessage) -> None: """Emit message to all subscriptions.""" if _LOGGER.isEnabledFor(logging.DEBUG): if msg.new_obj is not None: _LOGGER.debug( "emitting message: %s:%s:%s:%s", msg.action, msg.new_obj.model, msg.new_obj.id, list(msg.changed_data), ) elif msg.old_obj is not None: _LOGGER.debug( "emitting message: %s:%s:%s", msg.action, msg.old_obj.model, msg.old_obj.id, ) else: _LOGGER.debug("emitting message: %s", msg.action) for sub in self._ws_subscriptions: try: sub(msg) except Exception: _LOGGER.exception("Exception while running subscription handler") def emit_events_message(self, msg: WSSubscriptionMessage) -> None: """Emit message to all events subscriptions.""" if _LOGGER.isEnabledFor(logging.DEBUG): if msg.new_obj is not None: _LOGGER.debug( "emitting events message: %s:%s:%s:%s", msg.action, msg.new_obj.model, msg.new_obj.id, list(msg.changed_data), ) elif msg.old_obj is not None: _LOGGER.debug( "emitting events message: %s:%s:%s", msg.action, msg.old_obj.model, msg.old_obj.id, ) else: _LOGGER.debug("emitting events message: %s", msg.action) for sub in self._events_ws_subscriptions: try: sub(msg) except Exception: _LOGGER.exception("Exception while running events subscription handler") def emit_devices_message(self, msg: WSSubscriptionMessage) -> None: """Emit message to all devices subscriptions.""" if _LOGGER.isEnabledFor(logging.DEBUG): if msg.new_obj is not None: _LOGGER.debug( "emitting devices message: %s:%s:%s:%s", msg.action, msg.new_obj.model, msg.new_obj.id, list(msg.changed_data), ) elif msg.old_obj is not None: _LOGGER.debug( "emitting devices message: %s:%s:%s", msg.action, msg.old_obj.model, msg.old_obj.id, ) else: _LOGGER.debug("emitting devices message: %s", msg.action) for sub in self._devices_ws_subscriptions: try: sub(msg) except Exception: _LOGGER.exception( "Exception while running devices subscription handler" ) def _get_last_update_id(self) -> str | None: if self._bootstrap is None: return None return self._bootstrap.last_update_id def _process_ws_message(self, msg: aiohttp.WSMessage) -> None: packet = WSPacket(msg.data) processed_message = self.bootstrap.process_ws_packet( packet, models=self._subscribed_models, ignore_stats=self._ignore_stats, ) if processed_message is None: return self.emit_message(processed_message) def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None: """ Process events websocket message (Public API - JSON format). ``add`` payloads may be minimal/partial (e.g. motion start without ``score`` / ``smartDetect*``), but must still be constructable as an :class:`Event` — see the defaults on :class:`Event` for the optional fields. ``update`` messages carry partial diffs (typically ``end``) that are merged into the cached event by :class:`PublicBootstrap`. """ if msg.type != aiohttp.WSMsgType.TEXT: _LOGGER.debug("Ignoring non-text websocket message: %s", msg.type) return if self._prime_ws_buffer is not None: self._prime_ws_buffer.append((self._process_events_ws_message, msg)) return try: data = orjson.loads(msg.data) action_type = data.get("type") item = data.get("item", {}) model_key = item.get("modelKey") if not action_type or not model_key: _LOGGER.debug("Invalid public API websocket message: %s", data) return model_type = ModelType.from_string(model_key) if model_type is ModelType.UNKNOWN: _LOGGER.debug("Unknown model type in public API message: %s", model_key) return # Respect ``subscribed_models`` for the events WS too. # ``None`` => inherit the global filter; an explicit (possibly # empty) per-WS set overrides it. _events_filter = ( self._subscribed_models if self._events_ws_subscribed_models is None else self._events_ws_subscribed_models ) if _events_filter and model_type not in _events_filter: return update_id = item.get("id", "") new_obj: ProtectModelWithId | None = None old_obj: ProtectModelWithId | None = None # Device-model updates produced by processing the events frame — a # first-class output (e.g. a camera whose derived detection booleans # flipped). Emitted through the standard devices channel below with # no event-specific branching here. model_updates: list[WSSubscriptionMessage] = [] if self._public_bootstrap is not None and model_type is ModelType.EVENT: result = self._public_bootstrap.process_events_ws_message(self, data) new_obj = result.new_event old_obj = result.old_event model_updates = result.model_updates msg_obj = WSSubscriptionMessage( action=WSAction(action_type), new_update_id=update_id, changed_data=item, new_obj=new_obj, old_obj=old_obj, ) self.emit_events_message(msg_obj) for update in model_updates: self.emit_devices_message(update) except Exception: _LOGGER.exception("Error processing public API events websocket message") def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None: """Process devices websocket message (Public API - JSON format).""" if msg.type != aiohttp.WSMsgType.TEXT: _LOGGER.debug("Ignoring non-text websocket message: %s", msg.type) return if self._prime_ws_buffer is not None: self._prime_ws_buffer.append((self._process_devices_ws_message, msg)) return try: data = orjson.loads(msg.data) action_type = data.get("type") # "update", "add", "remove" item = data.get("item", {}) model_key = item.get("modelKey") if not action_type or not model_key: _LOGGER.debug("Invalid public API websocket message: %s", data) return # Create a WSSubscriptionMessage similar to private WS model_type = ModelType.from_string(model_key) if model_type is ModelType.UNKNOWN: _LOGGER.debug("Unknown model type in public API message: %s", model_key) return if self._devices_ws_filtered_out(model_type): return # Apply the change to the PublicBootstrap cache when it has been # materialised via `update_public`. Without that opt-in, the # subscription message carries ``new_obj=None`` and subscribers # must fall back to ``changed_data`` (the raw payload). This # preserves legacy behaviour exactly and avoids producing # partially-validated model instances for consumers that haven't # opted into the cache. if self._public_bootstrap is not None: # Bulk envelopes carry an ``id`` array sharing one payload; # expand to one frame per device so every subscriber sees # clean single-device messages regardless of batching. for result in self._public_bootstrap.process_devices_ws_messages( self, data ): self._note_membership_frame(action_type, result) self.emit_devices_message( WSSubscriptionMessage( action=WSAction(action_type), new_update_id=result.item.get("id", ""), changed_data=result.item, new_obj=result.new_obj, old_obj=result.old_obj, ) ) return self.emit_devices_message( WSSubscriptionMessage( action=WSAction(action_type), new_update_id=item.get("id", ""), changed_data=item, new_obj=None, old_obj=None, ) ) except Exception: _LOGGER.exception("Error processing public API devices websocket message") def _devices_ws_filtered_out(self, model_type: ModelType) -> bool: """ Whether the devices-WS ``subscribed_models`` filter excludes a model. Empty set means "all" (matches private-WS behaviour). ``None`` => inherit the global filter; an explicit (possibly empty) per-WS set overrides it. """ model_filter = ( self._subscribed_models if self._devices_ws_subscribed_models is None else self._devices_ws_subscribed_models ) return bool(model_filter) and model_type not in model_filter def _note_membership_frame(self, action_type: str, result: DeviceWSResult) -> None: """Record a wire ``add``/``remove`` so the fetch diff does not repeat it.""" seen = self._public_membership_seen if seen is None or result.model_type is None: return # An ``add`` whose model could not be built announces nothing a # subscriber can act on, so it must not suppress the fetch diff's own # announcement for the same device. if action_type == "remove" or ( action_type == "add" and result.new_obj is not None ): seen.add((result.model_type, str(result.item.get("id", "")))) async def _get_event_paginate( # noqa: PLR0912 self, params: dict[str, Any], *, start: datetime, end: datetime | None, ) -> list[dict[str, Any]]: start_int = to_js_time(start) end_int = to_js_time(end) if end else None offset = 0 current_start = sys.maxsize events: list[dict[str, Any]] = [] request_count = 0 logged = False params["limit"] = 100 # greedy algorithm # always force desc to receive faster results in the vast majority of cases params["orderDirection"] = "DESC" _LOGGER.debug("paginate desc %s %s", start_int, end_int) while current_start > start_int: params["offset"] = offset _LOGGER.debug("page desc %s %s", offset, current_start) new_events = await self.api_request_list("events", params=params) request_count += 1 if not new_events: break if end_int is not None: _LOGGER.debug("page end %s (%s)", new_events[0]["end"], end_int) for event in new_events: if event["start"] <= end_int: events.append(event) else: break else: events += new_events offset += 100 if events: current_start = events[-1]["start"] if not logged and request_count > 5: logged = True _LOGGER.warning(TYPES_BUG_MESSAGE) to_remove = 0 for event in reversed(events): if event["start"] < start_int: to_remove += 1 else: break if to_remove: events = events[:-to_remove] return events async def get_events_raw( # noqa: PLR0912 self, *, start: datetime | None = None, end: datetime | None = None, limit: int | None = None, offset: int | None = None, types: list[EventType] | None = None, smart_detect_types: list[SmartDetectObjectType] | None = None, sorting: Literal["asc", "desc"] = "asc", descriptions: bool = True, all_cameras: bool | None = None, category: EventCategories | None = None, # used for testing _allow_manual_paginate: bool = True, ) -> list[dict[str, Any]]: """ Get list of events from Protect Args: ---- start: start time for events end: end time for events limit: max number of events to return offset: offset to start fetching events from types: list of EventTypes to get events for smart_detect_types: Filters the Smart detection types for the events sorting: sort events by ascending or descending, defaults to ascending (chronologic order) description: included additional event metadata category: event category, will provide additional category/subcategory fields If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours. If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or `limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect """ # if no parameters are passed in, default to all events from last 24 hours if limit is None and start is None and end is None: end = utc_now() + timedelta(seconds=10) start = end - timedelta(hours=1) params: dict[str, Any] = { "orderDirection": sorting.upper(), "withoutDescriptions": str(not descriptions).lower(), } if limit is not None: params["limit"] = limit if offset is not None: params["offset"] = offset if start is not None: params["start"] = to_js_time(start) if end is not None: params["end"] = to_js_time(end) if types is not None: params["types"] = [e.value for e in types] if smart_detect_types is not None: params["smartDetectTypes"] = [e.value for e in smart_detect_types] if all_cameras is not None: params["allCameras"] = str(all_cameras).lower() if category is not None: params["categories"] = category # manual workaround for a UniFi Protect bug # if types if missing from query params if _allow_manual_paginate and "types" not in params and start is not None: if sorting == "asc": events = await self._get_event_paginate( params, start=start, end=end, ) events = list(reversed(events)) else: events = await self._get_event_paginate( params, start=start, end=end, ) if limit: offset = offset or 0 events = events[offset : limit + offset] elif offset: events = events[offset:] return events return await self.api_request_list("events", params=params) async def get_events( self, start: datetime | None = None, end: datetime | None = None, limit: int | None = None, offset: int | None = None, types: list[EventType] | None = None, smart_detect_types: list[SmartDetectObjectType] | None = None, sorting: Literal["asc", "desc"] = "asc", descriptions: bool = True, category: EventCategories | None = None, # used for testing _allow_manual_paginate: bool = True, ) -> list[Event]: """ Same as `get_events_raw`, except * returns actual `Event` objects instead of raw Python dictionaries * filers out non-device events * filters out events with too low of a score Args: ---- start: start time for events end: end time for events limit: max number of events to return offset: offset to start fetching events from types: list of EventTypes to get events for smart_detect_types: Filters the Smart detection types for the events sorting: sort events by ascending or descending, defaults to ascending (chronologic order) description: included additional event metadata category: event category, will provide additional category/subcategory fields If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours. If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or `limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect """ response = await self.get_events_raw( start=start, end=end, limit=limit, offset=offset, types=types, smart_detect_types=smart_detect_types, sorting=sorting, descriptions=descriptions, category=category, _allow_manual_paginate=_allow_manual_paginate, ) events = [] for event_dict in response: # ignore unknown events if ( "type" not in event_dict or event_dict["type"] not in EventType.values_set() ): _LOGGER.debug("Unknown event type: %s", event_dict) continue event = create_from_unifi_dict(event_dict, api=self) # should never happen if not isinstance(event, Event): continue if ( event.type.value in EventType.device_events_set() and event.score >= self._minimum_score ): events.append(event) return events def subscribe_websocket( self, ws_callback: Callable[[WSSubscriptionMessage], None], ) -> Callable[[], None]: """ Subscribe to websocket events. Returns a callback that will unsubscribe. """ _LOGGER.debug("Adding subscription: %s", ws_callback) self._ws_subscriptions.append(ws_callback) self._get_websocket().start() return partial(self._unsubscribe_websocket, ws_callback) def subscribe_events_websocket( self, ws_callback: Callable[[WSSubscriptionMessage], None], ) -> Callable[[], None]: """ Subscribe to events websocket events. Returns a callback that will unsubscribe. """ _LOGGER.debug("Adding events subscription: %s", ws_callback) self._events_ws_subscriptions.append(ws_callback) self._get_events_websocket().start() return partial(self._unsubscribe_events_websocket, ws_callback) def subscribe_devices_websocket( self, ws_callback: Callable[[WSSubscriptionMessage], None], ) -> Callable[[], None]: """ Subscribe to devices websocket events. Returns a callback that will unsubscribe. """ _LOGGER.debug("Adding devices subscription: %s", ws_callback) self._devices_ws_subscriptions.append(ws_callback) self._get_devices_websocket().start() return partial(self._unsubscribe_devices_websocket, ws_callback) def subscribe_events( self, callback: Callable[[ProtectEvent, EventChange], None], ) -> Callable[[], None]: """ Subscribe to typed public event lifecycle callbacks. Only events whose ``EventType`` maps to a non-``OTHER`` ``ProtectEventChannel`` (detection / sensor / alarm-hub / access) are delivered; administrative events such as ``provision``, ``factoryReset`` and ``fwUpdate`` are dropped. Callers needing the unfiltered stream should use ``subscribe_events_websocket`` instead. The callback must not raise: an exception is caught and logged but otherwise swallowed, so a raising callback silently loses that delivery (e.g. an ``ENDED`` that never reaches the consumer). Do any fallible work inside a guard the callback owns. Identity and ``device_mac`` resolve with eventual consistency: the first event for a freshly-enrolled ULP user can resolve to ``UnknownIdentity(reason="ulp_user_not_cached")``, and a device not yet in the bootstrap yields ``device_mac=None``, until the next ``update_public()`` / reconnect resync refreshes ``public_bootstrap.ulp_users`` and the device stores. Smart-detect detected attributes (license-plate text, face-match name) are not exposed over the public API today — fall back to the private path via ``event.raw`` when you need them. A revoked or invalid API key surfaces as ``WebsocketState.AUTH_FAILED`` via ``subscribe_events_websocket_state`` after repeated 401 handshakes; call ``set_api_key()`` with a fresh key to re-arm. """ if self._public_bootstrap is None: raise RuntimeError( "subscribe_events() requires update_public() to have been called" " at least once" ) return self._register_event_subscriber(callback) def _register_event_subscriber( self, callback: Callable[[ProtectEvent, EventChange], None], ) -> Callable[[], None]: """Wire a typed events subscriber and connect the WS on the first one.""" # Local import to avoid circular import (events.dispatcher → api). from .events.dispatcher import EventDispatcher # noqa: PLC0415 if self._event_dispatcher is None: self._event_dispatcher = EventDispatcher(self) dispatcher = self._event_dispatcher first = dispatcher.subscriber_count == 0 dispatcher.add_subscriber(callback) if first: self._event_ws_adapter_unsub = self.subscribe_events_websocket( self._adapt_events_ws_message ) dispatcher.start_ttl_sweep() return partial(self._unsubscribe_events, callback) async def subscribe_events_and_prime( self, callback: Callable[[ProtectEvent, EventChange], None], ) -> Callable[[], None]: """ Subscribe to typed event changes and prime the public bootstrap, order-independent. The events-side analog of :meth:`subscribe_devices_and_prime`: connects the events websocket *before* priming, then awaits :meth:`update_public`. Frames arriving during the prime are buffered and replayed onto the fresh snapshot, so ordering is moot and the bootstrap need not be primed beforehand. Returns the same unsubscribe callable as :meth:`subscribe_events`. If priming raises, the subscription is rolled back before the error propagates. """ unsub = self._register_event_subscriber(callback) try: await self.update_public() except BaseException: unsub() raise return unsub def _unsubscribe_events( self, callback: Callable[[ProtectEvent, EventChange], None], ) -> None: if self._event_dispatcher is None: return self._event_dispatcher.remove_subscriber(callback) if self._event_dispatcher.subscriber_count == 0: self._event_dispatcher.stop_ttl_sweep() unsub = getattr(self, "_event_ws_adapter_unsub", None) if unsub is not None: unsub() self._event_ws_adapter_unsub = None def active_events(self, device_id: str | None = None) -> list[ProtectEvent]: """ Return the in-flight public events, optionally filtered by device. Derived directly from ``public_bootstrap.events``, so it works before any ``subscribe_events`` call (e.g. restoring state after a reload). Returns ``[]`` until ``update_public()`` has primed the public bootstrap. """ if self._public_bootstrap is None: return [] if self._event_dispatcher is None: # Local import to avoid circular import (events.dispatcher → api). from .events.dispatcher import EventDispatcher # noqa: PLC0415 self._event_dispatcher = EventDispatcher(self) return self._event_dispatcher.active_events(device_id=device_id) def _adapt_events_ws_message(self, msg: WSSubscriptionMessage) -> None: if self._event_dispatcher is None: return dispatcher = self._event_dispatcher old_obj = msg.old_obj if isinstance(msg.old_obj, PublicEvent) else None if msg.action in (WSAction.ADD, WSAction.UPDATE): if msg.new_obj is None: # Benign in normal desync/reconnect: an UPDATE for an event id # the store has not cached (or a merge/construct failure already # surfaced by PublicBootstrap) arrives without a merged Event. # Not a server contract change — drop quietly. event_id = msg.changed_data.get("id") if msg.changed_data else None _LOGGER.debug( "Events-WS %s without merged Event obj (id=%s) — dropping frame", msg.action, event_id, ) return if not isinstance(msg.new_obj, PublicEvent): # A merged object of the wrong type is a genuine shape violation. _LOGGER.warning( "Events-WS %s merged obj is not an Event — dropping frame" " (possible server contract change)", msg.action, ) return action = msg.action if action is WSAction.UPDATE and old_obj is None: # Born-closed event: the store synthesized a brand-new Event # from a lone ``update`` (no preceding ``add``). A normal # unknown-id update yields no merged Event and was dropped # above, so a merged Event with no prior snapshot can only be # this promotion. Treat it as an ADD so the close-window logic # emits STARTED + ENDED. action = WSAction.ADD dispatcher.dispatch(action, msg.new_obj, old_obj) return if msg.action is WSAction.REMOVE: if old_obj is None: # The store handed back no pre-removal object — a remove for # an event we never cached. event_id = msg.changed_data.get("id") if msg.changed_data else None self._events_remove_unknown_count += 1 now = time.monotonic() if now - self._events_remove_unknown_last_log >= 60.0: _LOGGER.info( "Events-WS remove for unknown event %s — skipping" " (throttled, logged at most once per 60s; %d total" " unknown removes so far)", event_id, self._events_remove_unknown_count, ) self._events_remove_unknown_last_log = now return dispatcher.dispatch(WSAction.REMOVE, None, old_obj) def subscribe_devices( self, callback: Callable[[ProtectDeviceChange], None], ) -> Callable[[], None]: """ Subscribe to typed public device-state lifecycle callbacks. The device-side analog of ``subscribe_events``: each callback receives a :class:`~uiprotect.devices.ProtectDeviceChange` describing an ``ADDED`` / ``UPDATED`` / ``REMOVED`` device, carrying the merged public model (``None`` for ``REMOVED``) and, for ``UPDATED``, the changed-field set. Single and bulk WS envelopes are expanded transparently to one change per device; ``state`` / connection transitions surface as ordinary ``UPDATED``s. Requires ``update_public()`` to have primed the public bootstrap at least once before subscribing (a ``RuntimeError`` is raised otherwise) — the merged public models depend on that cache. Callers that need the websocket live *during* priming should use the raw ``subscribe_devices_websocket`` instead, which has no such ordering requirement. The callback must not raise: an exception is caught and logged but otherwise swallowed. ``device_mac`` resolves with eventual consistency — a device not yet in the bootstrap yields ``None`` until the next ``update_public()`` / reconnect resync. A revoked or invalid API key surfaces as ``WebsocketState.AUTH_FAILED`` via ``subscribe_devices_websocket_state`` after repeated 401 handshakes; call ``set_api_key()`` with a fresh key to re-arm. """ if self._public_bootstrap is None: raise RuntimeError( "subscribe_devices() requires update_public() to have been called" " at least once" ) return self._register_device_subscriber(callback) def _register_device_subscriber( self, callback: Callable[[ProtectDeviceChange], None], ) -> Callable[[], None]: """Wire a typed devices subscriber and connect the WS on the first one.""" # Local import to avoid circular import (devices.dispatcher → api). from .devices.dispatcher import DeviceDispatcher # noqa: PLC0415 if self._device_dispatcher is None: self._device_dispatcher = DeviceDispatcher(self) dispatcher = self._device_dispatcher first = dispatcher.subscriber_count == 0 dispatcher.add_subscriber(callback) if first: self._device_ws_adapter_unsub = self.subscribe_devices_websocket( self._adapt_devices_ws_message ) return partial(self._unsubscribe_devices, callback) async def subscribe_devices_and_prime( self, callback: Callable[[ProtectDeviceChange], None], ) -> Callable[[], None]: """ Subscribe to typed device changes and prime the public bootstrap, order-independent. Connects the devices websocket *before* priming, then awaits :meth:`update_public`. Frames arriving during the prime are buffered and replayed onto the fresh snapshot, so no update is lost regardless of when the caller invokes this — it makes the prime/subscribe ordering question moot. Unlike :meth:`subscribe_devices`, it does not require the bootstrap to be primed beforehand. Returns the same unsubscribe callable as :meth:`subscribe_devices`. If priming raises (e.g. :class:`NotAuthorized`), the subscription is rolled back before the error propagates. """ unsub = self._register_device_subscriber(callback) try: await self.update_public() except BaseException: unsub() raise return unsub def _unsubscribe_devices( self, callback: Callable[[ProtectDeviceChange], None], ) -> None: if self._device_dispatcher is None: return self._device_dispatcher.remove_subscriber(callback) if self._device_dispatcher.subscriber_count == 0: unsub = getattr(self, "_device_ws_adapter_unsub", None) if unsub is not None: unsub() self._device_ws_adapter_unsub = None def _adapt_devices_ws_message(self, msg: WSSubscriptionMessage) -> None: if self._device_dispatcher is None: return self._device_dispatcher.dispatch(msg) def _unsubscribe_websocket( self, ws_callback: Callable[[WSSubscriptionMessage], None], ) -> None: """Unsubscribe to websocket events.""" _LOGGER.debug("Removing subscription: %s", ws_callback) self._ws_subscriptions.remove(ws_callback) if not self._ws_subscriptions: self._get_websocket().stop() def _unsubscribe_events_websocket( self, ws_callback: Callable[[WSSubscriptionMessage], None], ) -> None: """Unsubscribe to events websocket events.""" _LOGGER.debug("Removing events subscription: %s", ws_callback) self._events_ws_subscriptions.remove(ws_callback) if not self._events_ws_subscriptions: self._get_events_websocket().stop() def _unsubscribe_devices_websocket( self, ws_callback: Callable[[WSSubscriptionMessage], None], ) -> None: """Unsubscribe to devices websocket events.""" _LOGGER.debug("Removing devices subscription: %s", ws_callback) self._devices_ws_subscriptions.remove(ws_callback) if not self._devices_ws_subscriptions: self._get_devices_websocket().stop() def subscribe_websocket_state( self, ws_callback: Callable[[WebsocketState], None], ) -> Callable[[], None]: """ Subscribe to websocket state changes. Returns a callback that will unsubscribe. """ self._ws_state_subscriptions.append(ws_callback) return partial(self._unsubscribe_websocket_state, ws_callback) def subscribe_events_websocket_state( self, ws_callback: Callable[[WebsocketState], None], ) -> Callable[[], None]: """ Subscribe to events websocket state changes. Returns a callback that will unsubscribe. """ self._events_ws_state_subscriptions.append(ws_callback) return partial(self._unsubscribe_events_websocket_state, ws_callback) def subscribe_devices_websocket_state( self, ws_callback: Callable[[WebsocketState], None], ) -> Callable[[], None]: """ Subscribe to devices websocket state changes. Returns a callback that will unsubscribe. """ self._devices_ws_state_subscriptions.append(ws_callback) return partial(self._unsubscribe_devices_websocket_state, ws_callback) def _unsubscribe_websocket_state( self, ws_callback: Callable[[WebsocketState], None], ) -> None: """Unsubscribe to websocket state changes.""" self._ws_state_subscriptions.remove(ws_callback) def _unsubscribe_events_websocket_state( self, ws_callback: Callable[[WebsocketState], None], ) -> None: """Unsubscribe to events websocket state changes.""" self._events_ws_state_subscriptions.remove(ws_callback) def _unsubscribe_devices_websocket_state( self, ws_callback: Callable[[WebsocketState], None], ) -> None: """Unsubscribe to devices websocket state changes.""" self._devices_ws_state_subscriptions.remove(ws_callback) def _on_websocket_state_change(self, state: WebsocketState) -> None: """Websocket state changed.""" super()._on_websocket_state_change(state) for sub in self._ws_state_subscriptions: try: sub(state) except Exception: _LOGGER.exception("Exception while running websocket state handler") def _on_events_websocket_state_change(self, state: WebsocketState) -> None: """Events Websocket state changed.""" # Events (motion / smart / sensor / …) arrive *only* on this WS. On a # reconnect an END missed during the gap leaves the event active, so a # camera's derived ``is_*_currently_detected`` sticks ON until the TTL # sweep (~45 min worst case). Force-end active detection events (and # flush other stale channels) so the derived state clears immediately. # No-op on the first connect and when the cache was never materialised. if state is WebsocketState.CONNECTED: if not self._events_ws_has_been_connected: self._events_ws_has_been_connected = True elif self._public_bootstrap is not None: # Materialise the dispatcher if needed: the force-end fixes the # camera model + emits devices-WS updates, which matter to # subscribe_devices / pull consumers even with no events # subscriber. if self._event_dispatcher is None: from .events.dispatcher import ( # noqa: PLC0415 EventDispatcher, ) self._event_dispatcher = EventDispatcher(self) # Never let a raising force-end abort the CONNECTED state # delivery below — subscribers must always see the transition. try: count = self._event_dispatcher.force_end_on_events_reconnect() if count > 0: _LOGGER.warning( "Events websocket reconnected after gap; some frames" " may have been missed (force-ended %d active/stale" " events).", count, ) except Exception: _LOGGER.exception( "Exception while force-ending events on events" " websocket reconnect" ) for sub in self._events_ws_state_subscriptions: try: sub(state) except Exception: _LOGGER.exception( "Exception while running events websocket state handler" ) def _on_devices_websocket_state_change(self, state: WebsocketState) -> None: """Devices Websocket state changed.""" # On *reconnect* any add/update/remove emitted while we were offline # is lost — schedule a background `update_public()` to re-sync the # public bootstrap cache. No-op when the cache was never # materialised (callers not using the cache) or # on the very first connect (the caller is expected to prime the # cache via `update_public()` themselves). Flapping websockets are # debounced via :attr:`PUBLIC_RESYNC_MIN_INTERVAL` so a reconnect # storm collapses into a single refresh. if state is WebsocketState.CONNECTED: # Re-arm the siren timers from the cached status immediately: the # resync that would refresh it is debounced and may not run at all, # while the deadlines already in hand stay valid across the gap. if self._public_bootstrap is not None: self._reschedule_siren_offs(self._public_bootstrap) if not self._devices_ws_has_been_connected: self._devices_ws_has_been_connected = True elif self._public_bootstrap is not None: if ( self._public_resync_task is not None and not self._public_resync_task.done() ): self._public_resync_pending = True else: now = time.monotonic() if now - self._last_public_resync >= PUBLIC_RESYNC_MIN_INTERVAL: # Deliberately updated *before* the task runs (not after # success) so a flapping WS combined with a persistently # failing NVR cannot spin up a continuous resync storm. # Trade-off: a single failed attempt suppresses the next # reconnect within the debounce window. self._last_public_resync = now self._public_resync_task = asyncio.create_task( self._resync_public_bootstrap() ) else: _LOGGER.debug( "Skipping public bootstrap resync (debounced, last was %.1fs ago)", now - self._last_public_resync, ) self._flush_stale_events_on_reconnect() else: # The cached siren status stops being live once the frames do, so # the armed expiry timers stop being trustworthy. Drop them; the # reconnect resync re-arms from the refreshed snapshot. self._cancel_all_siren_offs() for sub in self._devices_ws_state_subscriptions: try: sub(state) except Exception: _LOGGER.exception( "Exception while running devices websocket state handler" ) def _flush_stale_events_on_reconnect(self) -> None: """ Force-end events that stayed open across a devices-websocket gap. The reconnect resync refreshes identity/devices; events arrive only via WS, so this sweep is what guarantees no stuck-active sensor. Gated on a live subscriber so a reconnect after the last unsubscribe does not mutate the shared event store. """ if ( self._event_dispatcher is None or self._event_dispatcher.subscriber_count == 0 ): return # Guard the flush so a raising sweep cannot skip the CONNECTED state # delivery to devices-WS subscribers. try: count = self._event_dispatcher.flush_stale_on_reconnect() except Exception: _LOGGER.exception( "Exception while flushing stale events on devices websocket reconnect" ) else: if count > 0: _LOGGER.warning( "Websocket reconnected after gap; some events may" " have been missed (force-ended %d stale active" " events).", count, ) async def _resync_public_bootstrap(self) -> None: """Re-sync the public bootstrap cache after a websocket reconnect.""" try: await self.update_public() # A reconnect gap can hide a full camera flap (the disconnect *and* # the reconnect both missed), which rotates the ``rtsp_alias`` # without leaving a visible state transition for the WS-path # refresh to catch. Re-fetch every camera's already-populated RTSPS # streams in place so synchronous consumers reading # ``camera.rtsps_streams`` never see an emptied field — the stale # URLs are kept until the fresh ones overwrite them. await self._refresh_all_cached_rtsps() except Exception: _LOGGER.exception("Failed to resync public bootstrap after reconnect") finally: if self._public_resync_pending: self._public_resync_pending = False self._last_public_resync = time.monotonic() self._public_resync_task = asyncio.create_task( self._resync_public_bootstrap() ) def _schedule_rtsps_refresh(self, camera_id: str) -> None: """ Schedule a coalesced re-fetch of a cached camera's RTSPS streams. Handles both a *refresh* of an already-populated camera and a *prime* of a present-but-streamless one — a camera offline at ``update_public()`` time and brought online via the devices WS gets filled here, not just on the next full resync. """ pb = self._public_bootstrap if pb is None: return camera = pb.cameras.get(camera_id) if camera is None: return existing = self._rtsps_refresh_tasks.get(camera_id) if existing is not None and not existing.done(): return self._rtsps_refresh_tasks[camera_id] = asyncio.create_task( self._refresh_camera_rtsps(camera_id) ) async def _refresh_camera_rtsps(self, camera_id: str) -> None: """Re-fetch one camera's RTSPS streams, writing the cached field in place.""" pb = self._public_bootstrap camera = pb.cameras.get(camera_id) if pb is not None else None initial = camera.rtsps_streams if camera is not None else None try: streams = await self.get_camera_rtsps_streams(camera_id) except Exception: _LOGGER.exception( "Failed to refresh RTSPS streams for camera %s", camera_id ) else: # Write back only if the *same* camera instance is still cached and # its ``rtsps_streams`` is the same object captured before the fetch. # Every mutation (prime, create, delete) replaces the object, so an # identity mismatch means a concurrent write landed mid-flight — # back off rather than resurrect (delete) or clobber (create) it. # The check and the write share no await, so they cannot race the # eviction. pb = self._public_bootstrap if ( streams is not None and pb is not None and camera is not None and pb.cameras.get(camera_id) is camera and camera.rtsps_streams is initial ): camera.rtsps_streams = streams # The write is otherwise silent: no wire frame announces a # library-primed field. Emit a synthetic devices-WS ``update`` # so subscribers observe the streams becoming available through # the existing channel. Suppress it when the re-fetch is equal to # the cached value so a no-op refresh stays quiet. if streams != initial: self.emit_devices_message(self._build_rtsps_update(camera, streams)) finally: self._rtsps_refresh_tasks.pop(camera_id, None) @staticmethod def _build_rtsps_update( camera: PublicCamera, streams: RTSPSStreams ) -> WSSubscriptionMessage: """Build a synthetic devices-WS ``update`` announcing a camera's streams.""" return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=camera.id, changed_data={ "modelKey": ModelType.CAMERA.value, "id": camera.id, "rtsps_streams": streams.unifi_dict(), }, new_obj=camera, old_obj=None, ) def _cancel_rtsps_refresh(self, camera_id: str) -> None: """Cancel a pending background RTSPS refresh for a camera, if any.""" task = self._rtsps_refresh_tasks.pop(camera_id, None) if task is not None and not task.done(): task.cancel() def _schedule_siren_off(self, siren_id: str) -> None: """ Arm (or re-arm) the announcement of a timed siren run ending. The console emits no frame when the run expires, so the deadline derived from ``sirenStatus`` is the only signal there is. Any timer already armed for the siren is dropped first: the newest status wins. An idle siren (no ``turn_off_at``) simply disarms, and so does a call from outside a running event loop. """ pb = self._public_bootstrap siren = pb.sirens.get(siren_id) if pb is not None else None self._cancel_siren_off(siren_id) if siren is None: return turn_off_at = siren.siren_status.turn_off_at if turn_off_at is None: return try: loop = asyncio.get_running_loop() except RuntimeError: # ``process_devices_ws_message`` is synchronous and may be driven # from outside the loop; a caller with no loop cannot be served a # timer anyway. _LOGGER.debug("No running event loop, siren %s expiry not armed", siren_id) return delay = (turn_off_at - datetime.now(UTC)).total_seconds() # A deadline already in the past still goes through the task rather # than announcing inline, so the synthetic frame lands *after* the # frame that revealed the expired run instead of ahead of it. self._siren_off_tasks[siren_id] = loop.create_task( self._announce_siren_off_at(siren_id, turn_off_at, max(delay, 0.0)) ) async def _announce_siren_off_at( self, siren_id: str, turn_off_at: datetime, delay: float ) -> None: """Wait out a siren's remaining run time, then announce it stopped.""" try: await asyncio.sleep(delay) pb = self._public_bootstrap siren = pb.sirens.get(siren_id) if pb is not None else None # Announce only while the cached status still describes the run this # task was armed for. A snapshot merged mid-sleep may carry a later # run, and clearing its flag would report a playing siren as stopped # with nothing left to re-arm. if siren is not None and siren.siren_status.turn_off_at == turn_off_at: self._announce_siren_off(siren) finally: # A re-arm cancels this task and stores its replacement under the # same key before this cleanup runs, so only clear the slot while # it still points at this task. if self._siren_off_tasks.get(siren_id) is asyncio.current_task(): del self._siren_off_tasks[siren_id] def _announce_siren_off(self, siren: Siren) -> None: """Fold the expiry into the cached status and emit it like a server frame.""" # The timing fields are left as the server set them (a manual stop # leaves them populated too); clearing the flag is what makes # ``turn_off_at`` — and so this announcement — idempotent. siren.siren_status.is_active = False if self._devices_ws_filtered_out(ModelType.SIREN): return self.emit_devices_message(self._build_siren_off_update(siren)) @staticmethod def _build_siren_off_update(siren: Siren) -> WSSubscriptionMessage: """Build a synthetic devices-WS ``update`` announcing a siren stopped.""" return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=siren.id, changed_data={ "modelKey": ModelType.SIREN.value, "id": siren.id, "sirenStatus": siren.siren_status.unifi_dict(), }, new_obj=siren, old_obj=None, ) def _cancel_siren_off(self, siren_id: str) -> None: """Cancel a pending siren expiry announcement, if any.""" task = self._siren_off_tasks.pop(siren_id, None) if task is not None and not task.done(): task.cancel() def _reschedule_siren_offs(self, pb: PublicBootstrap) -> None: """Re-arm every siren expiry announcement against a freshly applied snapshot.""" for siren_id in list(self._siren_off_tasks): if siren_id not in pb.sirens: self._cancel_siren_off(siren_id) for siren_id in pb.sirens: self._schedule_siren_off(siren_id) async def _refresh_all_cached_rtsps(self) -> None: """Re-fetch every camera that already has RTSPS streams in place, coalesced.""" pb = self._public_bootstrap if pb is None: return for camera_id, camera in list(pb.cameras.items()): if camera.rtsps_streams is not None: self._schedule_rtsps_refresh(camera_id) pending = [ task for task in self._rtsps_refresh_tasks.values() if not task.done() ] if pending: await asyncio.gather(*pending, return_exceptions=True) async def get_bootstrap(self) -> Bootstrap: """ Gets bootstrap object from UFP instance This is a great alternative if you need metadata about the NVR without connecting to the Websocket """ if self._public_only: raise PublicOnlyModeError( "Private bootstrap is unavailable on a public-only client; " "use update_public() instead" ) data = await self.api_request_obj("bootstrap") await _async_warm_nvr_timezone(data["nvr"]) return Bootstrap.from_unifi_dict(**data, api=self) async def get_devices_raw(self, model_type: ModelType) -> list[dict[str, Any]]: """Gets a raw device list given a model_type""" return await self.api_request_list(model_type.devices_key) async def get_devices( self, model_type: ModelType, expected_type: type[ProtectModel] | None = None, ) -> list[ProtectModel]: """Gets a device list given a model_type, converted into Python objects""" objs: list[ProtectModel] = [] for obj_dict in await self.get_devices_raw(model_type): obj = create_from_unifi_dict(obj_dict, api=self) if expected_type is not None and not isinstance(obj, expected_type): raise NvrError(f"Unexpected model returned: {obj.model}") if ( self.ignore_unadopted and isinstance(obj, ProtectAdoptableDeviceModel) and not obj.is_adopted ): continue objs.append(obj) return objs async def get_cameras(self) -> list[Camera]: """ Gets the list of cameras straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.cameras` """ return cast("list[Camera]", await self.get_devices(ModelType.CAMERA, Camera)) async def get_lights(self) -> list[Light]: """ Gets the list of lights straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.lights` .. deprecated:: Use :meth:`get_lights_public` instead. This method uses the private API and will be removed in a future version. """ return cast("list[Light]", await self.get_devices(ModelType.LIGHT, Light)) async def get_sensors(self) -> list[Sensor]: """ Gets the list of sensors straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.sensors` """ return cast("list[Sensor]", await self.get_devices(ModelType.SENSOR, Sensor)) async def get_chimes(self) -> list[Chime]: """ Gets the list of chimes straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.chimes` """ return cast("list[Chime]", await self.get_devices(ModelType.CHIME, Chime)) async def get_aiports(self) -> list[AiPort]: """ Gets the list of aiports straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.aiports` """ return cast("list[AiPort]", await self.get_devices(ModelType.AIPORT, AiPort)) async def get_viewers(self) -> list[Viewer]: """ Gets the list of viewers straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.viewers` """ return cast("list[Viewer]", await self.get_devices(ModelType.VIEWPORT, Viewer)) async def get_bridges(self) -> list[Bridge]: """ Gets the list of bridges straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.bridges` """ return cast("list[Bridge]", await self.get_devices(ModelType.BRIDGE, Bridge)) async def get_liveviews(self) -> list[Liveview]: """ Gets the list of liveviews straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews` """ return cast( "list[Liveview]", await self.get_devices(ModelType.LIVEVIEW, Liveview), ) async def get_device_raw( self, model_type: ModelType, device_id: str, ) -> dict[str, Any]: """Gets a raw device give the device model_type and id""" return await self.api_request_obj(f"{model_type.value}s/{device_id}") async def get_device( self, model_type: ModelType, device_id: str, expected_type: type[ProtectModelWithId] | None = None, ) -> ProtectModelWithId: """Gets a device give the device model_type and id, converted into Python object""" obj = create_from_unifi_dict( await self.get_device_raw(model_type, device_id), api=self, ) if expected_type is not None and not isinstance(obj, expected_type): raise NvrError(f"Unexpected model returned: {obj.model}") if ( self.ignore_unadopted and isinstance(obj, ProtectAdoptableDeviceModel) and not obj.is_adopted ): raise NvrError("Device is not adopted") return cast("ProtectModelWithId", obj) async def get_nvr(self) -> NVR: """ Gets an NVR object straight from the NVR. This is a great alternative if you need metadata about the NVR without connecting to the Websocket """ data = await self.api_request_obj("nvr") await _async_warm_nvr_timezone(data) return NVR.from_unifi_dict(**data, api=self) async def get_event(self, event_id: str) -> Event: """ Gets an event straight from the NVR. This is a great alternative if the event is no longer in the `self.bootstrap.events[event_id]` cache """ return cast("Event", await self.get_device(ModelType.EVENT, event_id, Event)) async def get_camera(self, device_id: str) -> Camera: """ Gets a camera straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.cameras[device_id]` """ return cast( "Camera", await self.get_device(ModelType.CAMERA, device_id, Camera) ) async def get_light(self, device_id: str) -> Light: """ Gets a light straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.lights[device_id]` .. deprecated:: Use :meth:`get_light_public` instead. This method uses the private API and will be removed in a future version. """ return cast("Light", await self.get_device(ModelType.LIGHT, device_id, Light)) async def get_sensor(self, device_id: str) -> Sensor: """ Gets a sensor straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.sensors[device_id]` """ return cast( "Sensor", await self.get_device(ModelType.SENSOR, device_id, Sensor) ) async def get_chime(self, device_id: str) -> Chime: """ Gets a chime straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.chimes[device_id]` """ return cast("Chime", await self.get_device(ModelType.CHIME, device_id, Chime)) async def get_aiport(self, device_id: str) -> AiPort: """ Gets a AiPort straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.aiport[device_id]` """ return cast( "AiPort", await self.get_device(ModelType.AIPORT, device_id, AiPort) ) async def get_viewer(self, device_id: str) -> Viewer: """ Gets a viewer straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.viewers[device_id]` """ return cast( "Viewer", await self.get_device(ModelType.VIEWPORT, device_id, Viewer), ) async def get_bridge(self, device_id: str) -> Bridge: """ Gets a bridge straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.bridges[device_id]` """ return cast( "Bridge", await self.get_device(ModelType.BRIDGE, device_id, Bridge) ) async def get_liveview(self, device_id: str) -> Liveview: """ Gets a liveview straight from the NVR. The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews[device_id]` """ return cast( "Liveview", await self.get_device(ModelType.LIVEVIEW, device_id, Liveview), ) async def get_camera_snapshot( self, camera_id: str, width: int | None = None, height: int | None = None, dt: datetime | None = None, ) -> bytes | None: """ Gets snapshot for a camera. Datetime of screenshot is approximate. It may be +/- a few seconds. """ params: dict[str, Any] = {} if dt is not None: path = "recording-snapshot" params["ts"] = to_js_time(dt) else: path = "snapshot" params["ts"] = int(time.time() * 1000) params["force"] = "true" if width is not None: params["w"] = width if height is not None: params["h"] = height return await self.api_request_raw( f"cameras/{camera_id}/{path}", params=params, raise_exception=False, ) async def get_public_api_camera_snapshot( self, camera_id: str, high_quality: bool = False, package: bool = False, ) -> bytes | None: """ Gets snapshot for a camera using public api. Args: camera_id: Camera ID. high_quality: Force 1080P+ resolution. package: If ``True``, fetch from the package camera (only supported on cameras with ``hasPackageCamera: true``). Requires Protect on the NVR. """ params: dict[str, Any] = {"highQuality": pybool_to_json_bool(high_quality)} if package: params["channel"] = "package" return await self.api_request_raw( public_api=True, raise_exception=False, url=f"/v1/cameras/{camera_id}/snapshot", params=params, ) async def create_camera_rtsps_streams( self, camera_id: str, qualities: list[ChannelQuality | str] | ChannelQuality | str, ) -> RTSPSStreams | None: """ Creates RTSPS streams for a camera using public API. On success the result is written through to the matching cached :class:`PublicCamera`'s ``rtsps_streams`` field (when ``update_public()`` has primed it), since stream creation is not signalled over the websocket. """ if isinstance(qualities, str): qualities = [qualities] data = {"qualities": [str(q) for q in qualities]} response = await self.api_request_raw( public_api=True, url=f"/v1/cameras/{camera_id}/rtsps-stream", method="POST", json=data, ) if response is None: return None try: response_json = orjson.loads(response) streams = RTSPSStreams(api=self, **response_json) except (orjson.JSONDecodeError, TypeError) as ex: _LOGGER.error( "Could not decode JSON response for create RTSPS streams (camera %s): %s", camera_id, ex, ) return None pb = self._public_bootstrap if pb is not None: camera = pb.cameras.get(camera_id) if camera is not None: camera.rtsps_streams = streams return streams async def get_camera_rtsps_streams( self, camera_id: str, ) -> RTSPSStreams | None: """ Fetch a camera's RTSPS streams from the public API. A flag-free fetch primitive with no cache side-effects: caching policy lives in the population layer (:meth:`update_public` priming, the WS-reconnect refresh, and create/delete write-through). """ response = await self.api_request_raw( public_api=True, url=f"/v1/cameras/{camera_id}/rtsps-stream", method="GET", ) if response is None: return None try: response_json = orjson.loads(response) return RTSPSStreams(api=self, **response_json) except (orjson.JSONDecodeError, TypeError) as ex: _LOGGER.error( "Could not decode JSON response for get RTSPS streams (camera %s): %s", camera_id, ex, ) return None async def delete_camera_rtsps_streams( self, camera_id: str, qualities: list[ChannelQuality | str] | ChannelQuality | str, ) -> bool: """ Deletes RTSPS streams for a camera using public API. On success the deleted qualities are removed from the matching cached :class:`PublicCamera`'s ``rtsps_streams`` (when present); a camera left with no streams has the field cleared to ``None``. """ if isinstance(qualities, str): qualities = [qualities] quality_strs = [str(quality) for quality in qualities] # Build query parameters for qualities params = [("qualities", quality) for quality in quality_strs] response = await self.api_request_raw( public_api=True, url=f"/v1/cameras/{camera_id}/rtsps-stream", method="DELETE", params=params, ) success = response is not None if success and self._public_bootstrap is not None: camera = self._public_bootstrap.cameras.get(camera_id) cached = camera.rtsps_streams if camera is not None else None if camera is not None and cached is not None: # Replace (never mutate in place) so every write to # ``rtsps_streams`` swaps the object — that single invariant lets # the in-flight refresh self-guard on object identity. Survivors # are the *available* (keyed) qualities, dropping inactive # keys too; an empty set clears the field to ``None``. extra = cached.__pydantic_extra__ or {} survivors = { quality: url for quality, url in extra.items() if quality not in quality_strs } camera.rtsps_streams = ( RTSPSStreams(api=self, **survivors) if survivors else None ) return success async def get_package_camera_snapshot( self, camera_id: str, width: int | None = None, height: int | None = None, dt: datetime | None = None, ) -> bytes | None: """ Gets snapshot from the package camera. Datetime of screenshot is approximate. It may be +/- a few seconds. """ params: dict[str, Any] = {} if dt is not None: path = "recording-snapshot" params["ts"] = to_js_time(dt) params["lens"] = 2 else: path = "package-snapshot" params["ts"] = int(time.time() * 1000) params["force"] = "true" if width is not None: params["w"] = width if height is not None: params["h"] = height return await self.api_request_raw( f"cameras/{camera_id}/{path}", params=params, raise_exception=False, ) async def _stream_response( self, response: aiohttp.ClientResponse, chunk_size: int, iterator_callback: IteratorCallback | None = None, progress_callback: ProgressCallback | None = None, ) -> None: total = response.content_length or 0 current = 0 if iterator_callback is not None: await iterator_callback(total, None) async for chunk in response.content.iter_chunked(chunk_size): step = len(chunk) current += step if iterator_callback is not None: await iterator_callback(total, chunk) if progress_callback is not None: await progress_callback(step, current, total) async def get_camera_video( self, camera_id: str, start: datetime, end: datetime, channel_index: int = 0, validate_channel_id: bool = True, output_file: Path | None = None, iterator_callback: IteratorCallback | None = None, progress_callback: ProgressCallback | None = None, chunk_size: int = 65536, fps: int | None = None, ) -> bytes | None: """ Exports MP4 video from a given camera at a specific time. Start/End of video export are approximate. It may be +/- a few seconds. It is recommended to provide a output file or progress callback for larger video clips, otherwise the full video must be downloaded to memory before being written. Providing the `fps` parameter creates a "timelapse" export with the given FPS value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x (fps=20), and 600x (fps=40). """ if validate_channel_id and self._bootstrap is not None: camera = self._bootstrap.cameras[camera_id] try: camera.channels[channel_index] except IndexError as e: raise BadRequest from e params = { "camera": camera_id, "start": to_js_time(start), "end": to_js_time(end), } if fps is not None: params["fps"] = fps params["type"] = "timelapse" if channel_index == 3: params.update({"lens": 2}) else: params.update({"channel": channel_index}) path = "video/export" if ( iterator_callback is None and progress_callback is None and output_file is None ): return await self.api_request_raw( path, params=params, raise_exception=False, ) _LOGGER.debug( "Requesting camera video: %s%s %s", self.private_api_path, path, params ) r = await self.request( "get", f"{self.private_api_path}{path}", auto_close=False, timeout=0, params=params, ) if r.status != 200: await self._raise_for_status(r, True) if output_file is not None: async with aiofiles.open(output_file, "wb") as output: async def callback(total: int, chunk: bytes | None) -> None: if iterator_callback is not None: await iterator_callback(total, chunk) if chunk is not None: await output.write(chunk) await self._stream_response(r, chunk_size, callback, progress_callback) else: await self._stream_response( r, chunk_size, iterator_callback, progress_callback, ) r.close() return None async def _get_image_with_retry( self, path: str, retry_timeout: int = RETRY_TIMEOUT, **kwargs: Any, ) -> bytes | None: """ Retries image request until it returns or timesout. Used for event images like thumbnails and heatmaps. Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then your retry timeout will always return None. """ now = time.monotonic() timeout = now + retry_timeout data: bytes | None = None while data is None and now < timeout: data = await self.api_request_raw(path, raise_exception=False, **kwargs) if data is None: await asyncio.sleep(0.5) now = time.monotonic() return data async def get_event_thumbnail( self, thumbnail_id: str, width: int | None = None, height: int | None = None, retry_timeout: int = RETRY_TIMEOUT, ) -> bytes | None: """ Gets given thumbanail from a given event. Thumbnail response is a JPEG image. Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then your retry timeout will always return 404. """ params: dict[str, Any] = {} if width is not None: params.update({"w": width}) if height is not None: params.update({"h": height}) # old thumbnail URL use thumbnail ID, which is just `e-{event_id}` thumbnail_id = thumbnail_id.removeprefix("e-") return await self._get_image_with_retry( f"events/{thumbnail_id}/thumbnail", params=params, retry_timeout=retry_timeout, ) async def get_event_animated_thumbnail( self, thumbnail_id: str, width: int | None = None, height: int | None = None, *, speedup: int = 10, retry_timeout: int = RETRY_TIMEOUT, ) -> bytes | None: """ Gets given animated thumbanil from a given event. Animated thumbnail response is a GIF image. Note: thumbnails / do not generate _until after the event ends_. Events that last longer then your retry timeout will always return 404. """ params: dict[str, Any] = { "keyFrameOnly": "true", "speedup": speedup, } if width is not None: params.update({"w": width}) if height is not None: params.update({"h": height}) # old thumbnail URL use thumbnail ID, which is just `e-{event_id}` thumbnail_id = thumbnail_id.removeprefix("e-") return await self._get_image_with_retry( f"events/{thumbnail_id}/animated-thumbnail", params=params, retry_timeout=retry_timeout, ) async def get_event_heatmap( self, heatmap_id: str, retry_timeout: int = RETRY_TIMEOUT, ) -> bytes | None: """ Gets given heatmap from a given event. Heatmap response is a PNG image. Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then your retry timeout will always return None. """ # old heatmap URL use heatmap ID, which is just `e-{event_id}` heatmap_id = heatmap_id.removeprefix("e-") return await self._get_image_with_retry( f"events/{heatmap_id}/heatmap", retry_timeout=retry_timeout, ) async def get_event_smart_detect_track_raw(self, event_id: str) -> dict[str, Any]: """Gets raw Smart Detect Track for a Smart Detection""" return await self.api_request_obj(f"events/{event_id}/smartDetectTrack") async def get_event_smart_detect_track(self, event_id: str) -> SmartDetectTrack: """Gets raw Smart Detect Track for a Smart Detection""" data = await self.api_request_obj(f"events/{event_id}/smartDetectTrack") return SmartDetectTrack.from_unifi_dict(api=self, **data) async def update_device( self, model_type: ModelType, device_id: str, data: dict[str, Any], ) -> None: """ Sends an update for a device back to UFP USE WITH CAUTION, all possible combinations of updating objects have not been fully tested. May have unexpected side effects. Tested updates have been added a methods on applicable devices. """ await self.api_request( f"{model_type.value}s/{device_id}", method="patch", json=data, ) async def update_nvr(self, data: dict[str, Any]) -> None: """ Sends an update for main UFP NVR device USE WITH CAUTION, all possible combinations of updating objects have not been fully tested. May have unexpected side effects. Tested updates have been added a methods on applicable devices. """ await self.api_request("nvr", method="patch", json=data) async def reboot_nvr(self) -> None: """Reboots NVR""" await self.api_request("nvr/reboot", method="post") async def reboot_device(self, model_type: ModelType, device_id: str) -> None: """Reboots an adopted device""" await self.api_request(f"{model_type.value}s/{device_id}/reboot", method="post") async def unadopt_device(self, model_type: ModelType, device_id: str) -> None: """Unadopt/Unmanage adopted device""" await self.api_request(f"{model_type.value}s/{device_id}", method="delete") async def adopt_device(self, model_type: ModelType, device_id: str) -> None: """Adopts a device""" key = model_type.devices_key data = await self.api_request_obj( "devices/adopt", method="post", json={key: {device_id: {}}}, ) if not data.get(key, {}).get(device_id, {}).get("adopted", False): raise BadRequest("Could not adopt device") async def play_speaker( self, device_id: str, *, volume: int | None = None, repeat_times: int | None = None, ringtone_id: str | None = None, track_no: int | None = None, ) -> None: """ Plays chime tones on a chime. Args: ---- device_id: The chime device ID volume: Volume level for playback (0-100) repeat_times: Number of times to repeat the tone ringtone_id: The ringtone ID (UUID) to play. Preferred over track_no. track_no: Legacy track number from speakerTrackList. .. deprecated:: Use ringtone_id instead. track_no maps to the speaker track list but ringtone_id is the current API standard. """ data: dict[str, Any] | None = None if ( volume is not None or repeat_times is not None or ringtone_id is not None or track_no is not None ): chime = self.bootstrap.chimes.get(device_id) if chime is None: raise BadRequest(f"Invalid chime ID {device_id}") data = { "volume": volume if volume is not None else chime.volume, "repeatTimes": repeat_times if repeat_times is not None else chime.repeat_times, } if ringtone_id is not None: data["ringtoneId"] = ringtone_id elif track_no is not None: warnings.warn( "track_no is deprecated, use ringtone_id instead", DeprecationWarning, stacklevel=2, ) data["trackNo"] = track_no # If neither ringtone_id nor track_no provided, don't include in payload await self.api_request( f"chimes/{device_id}/play-speaker", method="post", json=data, ) async def play_buzzer(self, device_id: str) -> None: """Plays chime tones on a chime""" await self.api_request(f"chimes/{device_id}/play-buzzer", method="post") async def set_light_is_led_force_on( self, device_id: str, is_led_force_on: bool ) -> None: """ Sets isLedForceOn for light. .. deprecated:: Use :meth:`update_light_public` instead. This method uses the private API and will be removed in a future version. """ await self.api_request( f"lights/{device_id}", method="patch", json={"lightOnSettings": {"isLedForceOn": is_led_force_on}}, ) async def clear_tamper_sensor(self, device_id: str) -> None: """Clears tamper status for sensor""" await self.api_request(f"sensors/{device_id}/clear-tamper-flag", method="post") async def _get_versions_from_api( self, url: str, package: str = "unifi-protect", ) -> set[Version]: session = await self.get_session() versions: set[Version] = set() try: async with session.get(url) as response: is_package = False for line in (await response.text()).split("\n"): if line.startswith("Package: "): is_package = False if line == f"Package: {package}": is_package = True if is_package and line.startswith("Version: "): versions.add(Version(line.split(": ")[-1])) except ( TimeoutError, aiohttp.ServerDisconnectedError, client_exceptions.ClientError, ) as err: raise NvrError(f"Error packages from {url}: {err}") from err return versions async def create_api_key(self, name: str) -> str: """Create an API key with the given name and return the full API key.""" if not name: raise BadRequest("API key name cannot be empty") response = await self.api_request( api_path="/proxy/users/api/v2", url="/user/self/keys", method="post", json={"name": name}, ) if ( not isinstance(response, dict) or "data" not in response or not isinstance(response["data"], dict) or "full_api_key" not in response["data"] ): raise BadRequest("Failed to create API key") return response["data"]["full_api_key"] def set_api_key(self, api_key: str) -> None: """Set the API key for the NVR.""" if not api_key: raise BadRequest("API key cannot be empty") self._api_key = api_key # The budget is per-key; a new key gets a fresh limiter (re-seeded from # the next response's RateLimit-Policy header). self._public_rate_limiter = PublicApiRateLimiter() # A revoked key parks the public websockets on the long auth-failed # backoff; a fresh key should recover them without waiting it out. for websocket in (self._events_websocket, self._devices_websocket): if websocket is not None: websocket.reset_auth_failure() def is_api_key_set(self) -> bool: """Check if the API key is set.""" return bool(self._api_key) @public_get("/v1/meta/info", item=MetaInfo) async def get_meta_info(self) -> MetaInfo: """Get NVR metadata via the Public Integration API.""" raise NotImplementedError async def get_console_mac(self) -> str | None: """ Resolve the console/NVR mac via the UniFi-OS ``/api/system`` endpoint. The public Protect Integration API only exposes the NVR ``mac`` on Protect newer than 7.1; older firmware omits it (:attr:`PublicNVR.mac` is then ``None``). This helper fills that gap so a :meth:`public_only` client on older firmware can still derive the console's stable, mac-based identity. This is a **transitional workaround** for the hybrid/parallel phase in which the private and public paths coexist: identity must stay mac-based to match what the private path already produces, so a feature toggling between the two paths doesn't churn entity ids. The end-state identity is the public API's own primary key (``nvr.id``); this helper is expected to be retired once the private path is dropped and consumers migrate to ``id``. ``/api/system`` is an **off-contract UniFi-OS endpoint** (not the public Protect API): unauthenticated, and it returns the mac of the device at the configured Protect host. Returns the mac string (e.g. ``"AABBCCDDEEFF"``, matching the private ``NVR.mac`` format) or ``None`` when the endpoint is unreachable or carries no mac. """ # Off-contract UniFi-OS endpoint, the fallback when the bootstraps # carry no NVR mac (older firmware). Unauthenticated; targets the # configured Protect host. Transitional — retire once consumers # migrate to nvr.id. try: data = await self.api_request( url="/system", api_path="/api", require_auth=False, raise_exception=False, ) except (NvrError, TimeoutError) as err: # A connection refusal surfaces as ClientError, which _do_request # wraps into NvrError. A timeout (non-routable host / hanging # connection) is not a ClientError and is never wrapped, so catch # TimeoutError too to honour the "None when unreachable" contract. _LOGGER.debug("Failed to resolve console mac from /api/system: %s", err) return None if not isinstance(data, dict): return None mac = data.get("mac") return mac if isinstance(mac, str) and mac else None async def resolve_nvr_mac(self) -> str | None: """ Resolve the NVR mac, normalized, by source priority: the public NVR (cached ``_public_bootstrap`` if primed, else a direct ``/v1/nvrs`` fetch), then private bootstrap, then the ``/api/system`` console fallback; ``None`` if none resolve. """ if self._public_bootstrap is not None: nvr = self._public_bootstrap.nvr if nvr is not None and nvr.mac: return normalize_mac(nvr.mac) else: try: public_nvr = await self.get_nvr_public() except (ClientError, TimeoutError): # The public session carries no ClientTimeout, so a hung # /v1/nvrs raises a bare TimeoutError that is never wrapped # into ClientError; fall through to the private/console tiers. public_nvr = None if public_nvr is not None and public_nvr.mac: return normalize_mac(public_nvr.mac) if self._bootstrap is not None and self._bootstrap.nvr.mac: return normalize_mac(self._bootstrap.nvr.mac) mac = await self.get_console_mac() return normalize_mac(mac) if mac else None # Public API Methods @public_get("/v1/nvrs", item=PublicNVR) async def get_nvr_public(self) -> PublicNVR: """Get NVR information using public API.""" raise NotImplementedError @public_get("/v1/lights", items=PublicLight) async def get_lights_public(self) -> list[PublicLight]: """Get all lights using public API.""" raise NotImplementedError @public_get("/v1/lights/{light_id}", item=PublicLight) async def get_light_public(self, light_id: str) -> PublicLight: """Get a specific light using public API.""" raise NotImplementedError async def update_light_public( self, light_id: str, *, name: str | None = None, is_light_force_enabled: bool | None = None, light_mode_settings: LightModeSettings | PublicLightModeSettings | None = None, light_device_settings: ( LightDeviceSettings | PublicLightDeviceSettings | None ) = None, ) -> PublicLight: """ Update light settings using public API. Args: ---- light_id: The light's ID name: Light name is_light_force_enabled: Force enable the light light_mode_settings: Light mode settings (mode, enable_at) light_device_settings: Light device settings (LED level, PIR settings, etc.) Returns: ------- Updated Light object Raises: ------ BadRequest: If no parameters are provided """ data: LightPatchRequest = {} if name is not None: data["name"] = name if is_light_force_enabled is not None: data["isLightForceEnabled"] = is_light_force_enabled if light_mode_settings is not None: data["lightModeSettings"] = light_mode_settings.unifi_dict() if light_device_settings is not None: # luxSensitivity may come from Private API but is not settable - filter it out device_dict = light_device_settings.unifi_dict() device_dict.pop("luxSensitivity", None) data["lightDeviceSettings"] = device_dict if not data: raise BadRequest("At least one parameter must be provided") result = await self.api_request_obj( url=f"/v1/lights/{light_id}", method="patch", json=data, public_api=True, ) light = PublicLight.from_unifi_dict(**result, api=self) self._write_through_public_twin(light) return light @public_get("/v1/cameras", items=PublicCamera) async def get_cameras_public(self) -> list[PublicCamera]: """Get all cameras using public API.""" raise NotImplementedError @public_get("/v1/cameras/{camera_id}", item=PublicCamera) async def get_camera_public(self, camera_id: str) -> PublicCamera: """Get a specific camera using public API.""" raise NotImplementedError async def update_camera_public( self, camera_id: str, *, name: str | None = None, hdr_type: PublicHdrMode | None = None, video_mode: VideoMode | None = None, led_is_enabled: bool | None = None, led_welcome_led: bool | None = None, led_flood_led: bool | None = None, mic_volume: int | None = None, smart_detect_object_types: list[SmartDetectObjectType] | None = None, smart_detect_audio_types: list[SmartDetectAudioType] | None = None, lcd_message: CameraPublicApiLcdMessageRequest | None = None, osd_name_enabled: bool | None = None, osd_date_enabled: bool | None = None, osd_logo_enabled: bool | None = None, osd_nerd_mode_enabled: bool | None = None, osd_overlay_location: OsdOverlayLocation | None = None, ) -> PublicCamera: """ Patch camera settings using public API. Returns a fresh Camera object deserialized from the PATCH response. When a public bootstrap is loaded and holds this camera, the response is also written through into the cached twin (out-of-band ``rtsps_streams`` is preserved); the private bootstrap, if any, is left to the private-tree convenience methods. """ body = self._filter_none( ( ("name", name), ("hdrType", hdr_type), ("videoMode", video_mode), ("lcdMessage", lcd_message), ) ) led = self._filter_none( ( ("isEnabled", led_is_enabled), ("welcomeLed", led_welcome_led), ("floodLed", led_flood_led), ) ) if led: body["ledSettings"] = led if mic_volume is not None: if not 0 <= mic_volume <= 100: raise BadRequest("mic_volume must be between 0 and 100") body["micVolume"] = mic_volume detect: dict[str, Any] = {} if smart_detect_object_types is not None: detect["objectTypes"] = list(smart_detect_object_types) if smart_detect_audio_types is not None: detect["audioTypes"] = list(smart_detect_audio_types) if detect: body["smartDetectSettings"] = detect osd = self._filter_none( ( ("isNameEnabled", osd_name_enabled), ("isDateEnabled", osd_date_enabled), ("isLogoEnabled", osd_logo_enabled), ("isDebugEnabled", osd_nerd_mode_enabled), ("overlayLocation", osd_overlay_location), ) ) if osd: body["osdSettings"] = osd if not body: raise BadRequest("At least one parameter must be provided") result = await self.api_request_obj( url=f"/v1/cameras/{camera_id}", method="patch", json=body, public_api=True, ) camera = PublicCamera.from_unifi_dict(**result, api=self) self._write_through_public_twin(camera) return camera @public_post( "/v1/pos/cameras/{camera_id}/transactions", item=PublicPosTransactionResponse, ) async def create_pos_transaction_public( self, camera_id: str, *, type: PosTransactionType, # noqa: A002 # wire key is ``type`` external_id: str, amount: float, currency: str | None = None, line_items: list[PublicPosLineItemDict] | None = None, location: PublicPosLocationDict | None = None, payment_types: list[str] | None = None, timestamp: int | None = None, ) -> PublicPosTransactionResponse: """ Ingest a point-of-sale transaction as a camera event using public API. ``external_id`` (1-255 chars) is unique per camera and drives best-effort idempotency: a repeat returns ``created=False`` with ``event_id`` echoing the already-ingested event, while a concurrent repeat raises ``BadRequest`` on the server's 409 and should be retried shortly. Idempotency is in-memory and per-process, so it does not survive a Protect restart. ``amount`` is >= 0, ``currency`` an uppercase ISO 4217 code, ``line_items`` holds at most 200 entries (each ``quantity`` >= 1), ``payment_types`` at most 20, and ``timestamp`` is epoch milliseconds within the last 24 hours and no more than 5 minutes ahead of server time (a value inside that skew is clamped to now). """ raise NotImplementedError @public_get("/v1/chimes", items=PublicChime) async def get_chimes_public(self) -> list[PublicChime]: """Get all chimes using public API.""" raise NotImplementedError @public_get("/v1/chimes/{chime_id}", item=PublicChime) async def get_chime_public(self, chime_id: str) -> PublicChime: """Get a specific chime using public API.""" raise NotImplementedError @public_patch("/v1/chimes/{chime_id}", item=PublicChime) async def update_chime_public( self, chime_id: str, *, name: str | None = None, camera_ids: list[str] | None = None, ring_settings: list[PublicApiChimeRingSettingRequest] | None = None, ) -> PublicChime: """Update chime settings using public API.""" raise NotImplementedError # PTZ Control Private API Methods async def get_presets_ptz_camera(self, device_id: str) -> list[PTZPreset]: """Get PTZ Presets for camera.""" presets = await self.api_request(f"cameras/{device_id}/ptz/preset") if not presets: return [] presets = cast("list[dict[str, Any]]", presets) return [PTZPreset(**p) for p in presets] async def get_patrols_ptz_camera(self, device_id: str) -> list[PTZPatrol]: """Get PTZ Patrols for camera.""" patrols = await self.api_request(f"cameras/{device_id}/ptz/patrol") if not patrols: return [] patrols = cast("list[dict[str, Any]]", patrols) return [PTZPatrol(**p) for p in patrols] # PTZ Control Public API Methods @public_post("/v1/cameras/{camera_id}/ptz/goto/{slot}") async def ptz_goto_preset_public(self, camera_id: str, *, slot: int) -> None: """Move PTZ camera to preset position using public API.""" raise NotImplementedError @public_post("/v1/cameras/{camera_id}/ptz/patrol/start/{slot}") async def ptz_patrol_start_public(self, camera_id: str, *, slot: int) -> None: """Start a PTZ patrol using public API.""" raise NotImplementedError @public_post("/v1/cameras/{camera_id}/ptz/patrol/stop") async def ptz_patrol_stop_public(self, camera_id: str) -> None: """Stop the active PTZ patrol using public API.""" raise NotImplementedError async def create_talkback_session_public(self, camera_id: str) -> TalkbackSession: """ Create a talkback session for a camera using public API. Returns the talkback stream URL and audio configuration. """ data = await self.api_request_obj( url=f"/v1/cameras/{camera_id}/talkback-session", method="post", public_api=True, ) return TalkbackSession.from_unifi_dict(**data) async def disable_camera_mic_permanently_public( self, camera_id: str ) -> PublicCamera: """ Permanently disable a camera's microphone. Irreversible without a factory reset of the camera. The spec returns the updated camera object (not a 204). """ data = await self.api_request_obj( url=f"/v1/cameras/{camera_id}/disable-mic-permanently", method="post", public_api=True, ) return PublicCamera.from_unifi_dict(**data, api=self) @public_get("/v1/sensors", items=PublicSensor) async def get_sensors_public(self) -> list[PublicSensor]: """Get all sensors using public API.""" raise NotImplementedError @public_get("/v1/sensors/{sensor_id}", item=PublicSensor) async def get_sensor_public(self, sensor_id: str) -> PublicSensor: """Get a specific sensor using public API.""" raise NotImplementedError async def update_sensor_public( self, sensor_id: str, *, name: str | None = None, light_settings: PublicSensorLightSettings | None = None, humidity_settings: PublicSensorHumiditySettings | None = None, temperature_settings: PublicSensorTemperatureSettings | None = None, motion_settings: PublicSensorMotionSettings | None = None, glass_break_settings: PublicSensorGlassBreakSettingsWrite | None = None, alarm_settings: PublicSensorAlarmSettings | None = None, schedule_mode: SensorScheduleMode | str | None = None, arm_profile_ids: list[str] | None = None, has_custom_sensitivity_when_armed: bool | None = None, ) -> PublicSensor: """ Patch sensor settings using public API. Each ``*_settings`` argument is a :class:`~typing.TypedDict` with ``total=False`` — pass only the keys you want to change. """ body: dict[str, Any] = {} if name is not None: body["name"] = name if light_settings is not None: body["lightSettings"] = dict(light_settings) if humidity_settings is not None: body["humiditySettings"] = dict(humidity_settings) if temperature_settings is not None: body["temperatureSettings"] = dict(temperature_settings) if motion_settings is not None: body["motionSettings"] = dict(motion_settings) if glass_break_settings is not None: body["glassBreakSettings"] = dict(glass_break_settings) if alarm_settings is not None: body["alarmSettings"] = dict(alarm_settings) if schedule_mode is not None: body["scheduleMode"] = str(schedule_mode) if arm_profile_ids is not None: body["armProfileIds"] = list(arm_profile_ids) if has_custom_sensitivity_when_armed is not None: body["hasCustomSensitivityWhenArmed"] = has_custom_sensitivity_when_armed if not body: raise BadRequest("At least one parameter must be provided") result = await self.api_request_obj( url=f"/v1/sensors/{sensor_id}", method="patch", json=body, public_api=True, ) sensor = PublicSensor.from_unifi_dict(**result, api=self) self._write_through_public_twin(sensor) return sensor @staticmethod def _build_named_led_patch_body( *, name: str | None = None, led_is_enabled: bool | None = None ) -> dict[str, Any]: """Build common PATCH body for resources with name + ledSettings.""" body: dict[str, Any] = {} if name is not None: body["name"] = name if led_is_enabled is not None: body["ledSettings"] = {"isEnabled": led_is_enabled} return body @staticmethod def _filter_none(items: tuple[tuple[str, Any], ...]) -> dict[str, Any]: """Return a dict from key-value pairs, dropping any pair whose value is None.""" return {k: v for k, v in items if v is not None} def _write_through_public_twin(self, obj: PublicDeviceModel) -> None: """ Refresh the cached public-bootstrap twin of ``obj`` from a PATCH response. Keeps the public device cache fresh for every mutation path — both the public-tree ``set_*`` setters and the private ``set_*_public`` methods (which drive the same ``update_*_public`` endpoints) — without inserting new devices. A no-op when no public bootstrap is loaded or the device is not cached. """ pb = self._public_bootstrap if pb is None: return model = obj.model if model is None: # pragma: no cover - device models always carry a modelKey return twin = pb.get(model, obj.id) if not isinstance(twin, PublicDeviceModel): return if twin is obj: # pragma: no cover - a fresh response is never the cached obj return twin._apply_from_response(obj) @public_get("/v1/sirens", items=Siren) async def get_sirens_public(self) -> list[Siren]: """Get all sirens using public API.""" raise NotImplementedError @public_get("/v1/sirens/{siren_id}", item=Siren) async def get_siren_public(self, siren_id: str) -> Siren: """Get a specific siren using public API.""" raise NotImplementedError async def update_siren_public( self, siren_id: str, *, name: str | None = None, volume: int | None = None, led_is_enabled: bool | None = None, ) -> Siren: """Patch siren settings using public API.""" body = self._build_named_led_patch_body( name=name, led_is_enabled=led_is_enabled, ) if volume is not None: body["volume"] = volume if not body: raise BadRequest("At least one parameter must be provided") result = await self.api_request_obj( url=f"/v1/sirens/{siren_id}", method="patch", json=body, public_api=True, ) return Siren.from_unifi_dict(**result, api=self) async def play_siren_public( self, siren_id: str, *, duration: SirenDuration | int | None = None ) -> None: """Activate a siren. ``duration`` may be a supported integer or :class:`SirenDuration`; defaults to 5 seconds.""" if duration is None: norm_duration = SirenDuration.FIVE elif isinstance(duration, SirenDuration): norm_duration = duration else: try: norm_duration = SirenDuration(duration) except ValueError as err: raise BadRequest( "duration must be one of the supported siren durations " f"{', '.join(str(item.value) for item in SirenDuration)} seconds" ) from err await self.api_request_raw( url=f"/v1/sirens/{siren_id}/play", method="post", public_api=True, json={"duration": norm_duration}, ) @public_post("/v1/sirens/{siren_id}/stop") async def stop_siren_public(self, siren_id: str) -> None: """Stop an active siren.""" raise NotImplementedError async def test_siren_sound_public( self, siren_id: str, *, volume: int | None = None ) -> None: """Test the siren sound (5 seconds).""" body: dict[str, Any] = {} if volume is not None: body["volume"] = volume await self.api_request_raw( url=f"/v1/sirens/{siren_id}/test-sound", method="post", public_api=True, json=body, ) @public_get("/v1/relays", items=Relay) async def get_relays_public(self) -> list[Relay]: """Get all relays using public API.""" raise NotImplementedError @public_get("/v1/relays/{relay_id}", item=Relay) async def get_relay_public(self, relay_id: str) -> Relay: """Get a specific relay using public API.""" raise NotImplementedError async def update_relay_public( self, relay_id: str, *, name: str | None = None, led_is_enabled: bool | None = None, ) -> Relay: """Patch relay settings using public API.""" body = self._build_named_led_patch_body( name=name, led_is_enabled=led_is_enabled, ) if not body: raise BadRequest("At least one parameter must be provided") result = await self.api_request_obj( url=f"/v1/relays/{relay_id}", method="patch", json=body, public_api=True, ) return Relay.from_unifi_dict(**result, api=self) async def activate_relay_output_public( self, relay_id: str, output_id: int, *, state: Literal["on", "off"] | None = None, pulse_duration_ms: int | None = None, ) -> None: """ Activate, toggle or pulse a relay output. Omit ``state`` to toggle the current state. ``pulse_duration_ms`` is only valid together with ``state='on'``; the output will auto-turn off after the given milliseconds. """ if pulse_duration_ms is not None and state != "on": raise BadRequest("pulse_duration_ms may only be combined with state='on'") body: dict[str, Any] = {} if state is not None: body["state"] = state if pulse_duration_ms is not None: body["pulseDuration"] = pulse_duration_ms await self.api_request_raw( url=f"/v1/relays/{relay_id}/outputs/{output_id}/activate", method="post", public_api=True, json=body or None, ) @public_get("/v1/fobs", items=Fob) async def get_fobs_public(self) -> list[Fob]: """Get all key fobs using public API.""" raise NotImplementedError @public_get("/v1/fobs/{fob_id}", item=Fob) async def get_fob_public(self, fob_id: str) -> Fob: """Get a specific key fob using public API.""" raise NotImplementedError @public_patch("/v1/fobs/{fob_id}", item=Fob) async def update_fob_public(self, fob_id: str, *, name: str | None = None) -> Fob: """Patch key-fob settings using public API.""" raise NotImplementedError @public_get("/v1/speakers", items=Speaker) async def get_speakers_public(self) -> list[Speaker]: """Get all speakers using public API.""" raise NotImplementedError @public_get("/v1/speakers/{speaker_id}", item=Speaker) async def get_speaker_public(self, speaker_id: str) -> Speaker: """Get a specific speaker using public API.""" raise NotImplementedError @public_patch("/v1/speakers/{speaker_id}", item=Speaker) async def update_speaker_public( self, speaker_id: str, *, name: str | None = None, volume: int | None = None, mic_volume: int | None = None, is_mic_enabled: bool | None = None, ) -> Speaker: """Patch speaker settings using public API.""" raise NotImplementedError async def test_speaker_sound_public( self, speaker_id: str, *, volume: int | None = None ) -> None: """Test the speaker sound at the given volume.""" body: dict[str, Any] = {} if volume is not None: body["volume"] = volume await self.api_request_raw( url=f"/v1/speakers/{speaker_id}/test-sound", method="post", public_api=True, json=body, ) @public_get("/v1/link-stations", items=LinkStation) async def get_link_stations_public(self) -> list[LinkStation]: """Get all link stations using public API.""" raise NotImplementedError @public_get("/v1/alarm-hubs", items=LinkStation) async def get_alarm_hubs_public(self) -> list[LinkStation]: """Get all alarm hubs using public API.""" raise NotImplementedError @public_get("/v1/link-stations/{link_station_id}", item=LinkStation) async def get_link_station_public(self, link_station_id: str) -> LinkStation: """Get a specific link station using public API.""" raise NotImplementedError @public_get("/v1/alarm-hubs/{hub_id}", item=LinkStation) async def get_alarm_hub_public(self, hub_id: str) -> LinkStation: """Get a specific alarm hub using public API.""" raise NotImplementedError @public_patch("/v1/link-stations/{link_station_id}", item=LinkStation) async def update_link_station_public( self, link_station_id: str, *, name: str | None = None ) -> LinkStation: """Patch link station settings using public API.""" raise NotImplementedError @public_patch("/v1/alarm-hubs/{hub_id}", item=LinkStation) async def update_alarm_hub_public( self, hub_id: str, *, name: str | None = None ) -> LinkStation: """Patch alarm hub settings using public API.""" raise NotImplementedError async def trigger_alarm_hub_output_public( self, hub_id: str, output_id: int, *, enable: bool | None = None, delay: int | None = None, duration: int | None = None, ) -> None: """Trigger an alarm-hub output channel using public API.""" if delay is not None and delay < 0: raise BadRequest("delay must be >= 0") if duration is not None and duration < 0: raise BadRequest("duration must be >= 0") body: dict[str, Any] = {} if enable is not None: body["enable"] = enable if delay is not None: body["delay"] = delay if duration is not None: body["duration"] = duration await self.api_request_raw( url=f"/v1/alarm-hubs/{hub_id}/outputs/{output_id}/trigger", method="post", public_api=True, json=body or None, ) @public_get("/v1/bridges", items=PublicBridge) async def get_bridges_public(self) -> list[PublicBridge]: """Get all bridges using public API.""" raise NotImplementedError async def get_bridge_public(self, bridge_id: str) -> PublicBridge: """Get a specific bridge using public API.""" data = await self.api_request_obj( url=f"/v1/bridges/{bridge_id}", public_api=True ) bridge = PublicBridge.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.bridges[bridge.id] = bridge return bridge async def update_bridge_public( self, bridge_id: str, *, name: str | None = None ) -> PublicBridge: """Patch bridge settings using public API.""" body: dict[str, Any] = {} if name is not None: body["name"] = name if not body: raise BadRequest("At least one parameter must be provided") data = await self.api_request_obj( url=f"/v1/bridges/{bridge_id}", method="patch", json=body, public_api=True, ) bridge = PublicBridge.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.bridges[bridge.id] = bridge return bridge @public_get("/v1/viewers", items=PublicViewer) async def get_viewers_public(self) -> list[PublicViewer]: """Get all viewers using public API.""" raise NotImplementedError async def get_viewer_public(self, viewer_id: str) -> PublicViewer: """Get a specific viewer using public API.""" data = await self.api_request_obj( url=f"/v1/viewers/{viewer_id}", public_api=True ) viewer = PublicViewer.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.viewers[viewer.id] = viewer return viewer async def update_viewer_public( self, viewer_id: str, *, name: str | None = None, liveview: str | _UnsetType | None = _UNSET, ) -> PublicViewer: """Patch viewer settings using public API. Pass ``liveview=None`` to clear.""" body: dict[str, Any] = {} if name is not None: body["name"] = name # ``liveview`` is genuinely nullable on the wire — distinguish the # default sentinel (no change) from an explicit ``None`` (clear). if liveview is not _UNSET: body["liveview"] = liveview if not body: raise BadRequest("At least one parameter must be provided") data = await self.api_request_obj( url=f"/v1/viewers/{viewer_id}", method="patch", json=body, public_api=True, ) viewer = PublicViewer.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.viewers[viewer.id] = viewer return viewer @public_get("/v1/liveviews", items=PublicLiveview) async def get_liveviews_public(self) -> list[PublicLiveview]: """Get all liveviews using public API.""" raise NotImplementedError async def get_liveview_public(self, liveview_id: str) -> PublicLiveview: """Get a specific liveview using public API.""" data = await self.api_request_obj( url=f"/v1/liveviews/{liveview_id}", public_api=True ) liveview = PublicLiveview.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.liveviews[liveview.id] = liveview return liveview async def create_liveview_public( self, *, name: str, is_default: bool, is_global: bool, owner: str, layout: int, slots: list[PublicLiveviewSlotDict], ) -> PublicLiveview: """Create a new liveview using public API.""" if not 1 <= layout <= 26: raise BadRequest("layout must be between 1 and 26") body: dict[str, Any] = { "name": name, "isDefault": is_default, "isGlobal": is_global, "owner": owner, "layout": layout, "slots": [dict(s) for s in slots], } data = await self.api_request_obj( url="/v1/liveviews", method="post", json=body, public_api=True, ) liveview = PublicLiveview.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.liveviews[liveview.id] = liveview return liveview async def update_liveview_public( self, liveview_id: str, *, name: str | None = None, is_default: bool | None = None, is_global: bool | None = None, owner: str | None = None, layout: int | None = None, slots: list[PublicLiveviewSlotDict] | None = None, ) -> PublicLiveview: """Patch an existing liveview (partial update) using public API.""" if layout is not None and not 1 <= layout <= 26: raise BadRequest("layout must be between 1 and 26") body: dict[str, Any] = {} if name is not None: body["name"] = name if is_default is not None: body["isDefault"] = is_default if is_global is not None: body["isGlobal"] = is_global if owner is not None: body["owner"] = owner if layout is not None: body["layout"] = layout if slots is not None: body["slots"] = [dict(s) for s in slots] if not body: raise BadRequest("At least one parameter must be provided") data = await self.api_request_obj( url=f"/v1/liveviews/{liveview_id}", method="patch", json=body, public_api=True, ) liveview = PublicLiveview.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.liveviews[liveview.id] = liveview return liveview async def send_alarm_webhook_public(self, trigger_id: str) -> None: """Fire the alarm-manager webhook for the given trigger id.""" if not trigger_id: raise BadRequest("trigger_id is required") await self.api_request_raw( url=f"/v1/alarm-manager/webhook/{quote(trigger_id, safe='')}", method="post", public_api=True, ) async def get_arm_profiles_public(self) -> list[ArmProfile]: """Get all arm profiles.""" profiles = await self._fetch_arm_profiles() self._apply_arm_profiles(profiles) return profiles async def _fetch_arm_profiles(self) -> list[ArmProfile]: """Fetch arm profiles without mutating the cached bootstrap.""" data = await self.api_request_list(url="/v1/arm-profiles", public_api=True) return [ArmProfile.from_unifi_dict(**item, api=self) for item in data] def _apply_arm_profiles(self, profiles: list[ArmProfile]) -> None: """Merge fetched arm profiles into the cache in place, preserving identity.""" if self._public_bootstrap is None: return # Update in place to preserve dict identity for consumers holding # a reference to ``public_bootstrap.arm_profiles``. arm_profiles = self._public_bootstrap.arm_profiles arm_profiles.clear() arm_profiles.update({p.id: p for p in profiles}) async def create_arm_profile_public( self, *, name: str, automations: list[str], schedules: list[PublicArmScheduleDict], record_everything: bool, activation_delay: int, ) -> ArmProfile: """Create a new arm profile.""" body = { "name": name, "automations": automations, "schedules": [dict(s) for s in schedules], "recordEverything": record_everything, "activationDelay": activation_delay, } data = await self.api_request_obj( url="/v1/arm-profiles", method="post", json=body, public_api=True, ) profile = ArmProfile.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.arm_profiles[profile.id] = profile return profile async def update_arm_profile_public( self, profile_id: str, *, name: str | None = None, automations: list[str] | None = None, schedules: list[PublicArmScheduleDict] | None = None, record_everything: bool | None = None, activation_delay: int | None = None, ) -> ArmProfile: """Update an existing arm profile (partial update).""" body: dict[str, Any] = {} if name is not None: body["name"] = name if automations is not None: body["automations"] = automations if schedules is not None: body["schedules"] = [dict(s) for s in schedules] if record_everything is not None: body["recordEverything"] = record_everything if activation_delay is not None: body["activationDelay"] = activation_delay if not body: raise BadRequest("At least one parameter must be provided") data = await self.api_request_obj( url=f"/v1/arm-profiles/{profile_id}", method="patch", json=body, public_api=True, ) profile = ArmProfile.from_unifi_dict(**data, api=self) if self._public_bootstrap is not None: self._public_bootstrap.arm_profiles[profile.id] = profile return profile async def delete_arm_profile_public(self, profile_id: str) -> None: """Delete an arm profile.""" await self.api_request_raw( url=f"/v1/arm-profiles/{profile_id}", method="delete", public_api=True, ) if self._public_bootstrap is not None: self._public_bootstrap.arm_profiles.pop(profile_id, None) async def get_arm_manager_settings_public(self) -> NvrArmMode | None: """ Return current arm-manager state from the NVR cache. The arm-manager state is embedded in the NVR object (``armMode`` field of ``GET /v1/nvrs``) — there is no dedicated GET endpoint. If the bootstrap cache is not yet populated, fetches the NVR now via :meth:`get_nvr_public` so the full ``PublicNVR`` is always constructed through the same code path. """ if self._public_bootstrap is not None: return self._public_bootstrap.arm_mode nvr = await self.get_nvr_public() return nvr.arm_mode async def set_current_arm_profile_public(self, profile_id: str) -> None: """Set the currently selected arm profile.""" await self.api_request_raw( url="/v1/arm-profiles/settings", method="patch", json={"armProfileId": profile_id}, public_api=True, ) if ( self._public_bootstrap is not None and self._public_bootstrap.arm_mode is not None ): self._public_bootstrap.arm_mode.arm_profile_id = profile_id async def enable_arm_alarm_public(self) -> None: """Enable the arm alarm with the currently selected profile.""" await self.api_request_raw( url="/v1/arm-profiles/enable", method="post", public_api=True, ) if ( self._public_bootstrap is not None and self._public_bootstrap.arm_mode is not None ): self._public_bootstrap.arm_mode.status = NvrArmModeStatus.ARMING async def disable_arm_alarm_public(self) -> None: """Disable the arm alarm.""" await self.api_request_raw( url="/v1/arm-profiles/disable", method="post", public_api=True, ) if ( self._public_bootstrap is not None and self._public_bootstrap.arm_mode is not None ): self._public_bootstrap.arm_mode.status = NvrArmModeStatus.DISABLED @public_get("/v1/users", items=PublicUser) async def get_users_public(self) -> list[PublicUser]: """Get all Protect users using public API.""" raise NotImplementedError @public_get("/v1/users/{user_id}", item=PublicUser) async def get_user_public(self, user_id: str) -> PublicUser: """Get a specific Protect user using public API.""" raise NotImplementedError @public_get("/v1/ulp-users", items=PublicUlpUser) async def get_ulp_users_public(self) -> list[PublicUlpUser]: """Get all UniFi Identity users using public API.""" raise NotImplementedError @public_get("/v1/ulp-users/{ulp_user_id}", item=PublicUlpUser) async def get_ulp_user_public(self, ulp_user_id: str) -> PublicUlpUser: """Get a specific UniFi Identity user using public API.""" raise NotImplementedError async def get_files_public( self, file_type: AssetFileType | str = AssetFileType.ANIMATIONS ) -> list[PublicFile]: """List uploaded device asset files of the given type.""" data = await self.api_request_list( url=f"/v1/files/{file_type}", public_api=True ) return [PublicFile.from_unifi_dict(**item, api=self) for item in data] async def upload_file_public( self, file_type: AssetFileType | str, file: bytes, original_name: str, content_type: str = "image/png", ) -> PublicFile: """ Upload a device asset as ``multipart/form-data``. The spec accepts ``image/gif``, ``image/jpeg``, ``image/png``, ``audio/mpeg``, ``audio/mp4``, ``audio/wave``, ``audio/x-caf``; ``content_type`` defaults to ``image/png`` for the common doorbell-animation case and must be overridden for other MIME types. ``aiohttp`` sets the multipart ``Content-Type`` header (with boundary) itself when ``data=`` is a :class:`aiohttp.FormData`, so this client never sets a JSON content-type for this call. """ form = aiohttp.FormData() form.add_field( "file", file, filename=original_name, content_type=content_type, ) raw = await self.api_request_raw( url=f"/v1/files/{file_type}", method="post", public_api=True, data=form, ) if not raw: raise NvrError("Empty response from upload_file_public") return PublicFile.from_unifi_dict(**orjson.loads(raw), api=self) async def update_public(self) -> PublicBootstrap: """ Populate :attr:`public_bootstrap` from the Public Integration API. This is opt-in and completely independent of :meth:`update` / the private bootstrap. Safe to call multiple times — the :class:`PublicBootstrap` instance is created on first use and then updated in place on subsequent calls. All endpoint fetches run concurrently. Each endpoint is requested best-effort; endpoints that the NVR doesn't (yet) expose (``BadRequest`` / ``NvrError``) are logged at ``DEBUG`` and ignored, and a partial public bootstrap is returned. If an endpoint fails, its previously cached data is left unchanged (not cleared). All results are classified before any are applied: an unexpected exception (e.g. a validation error from a new server payload) propagates to the caller with the snapshot left untouched, never half-applied. A revoked or invalid API key surfaces as :class:`NotAuthorized`; catch it as the reauth signal. Public events/devices WS frames that arrive while this method is priming are captured and replayed onto the fresh snapshot before it returns, so a subscriber that is already connected does not lose an update to the prime window — the subscribe-then-prime ordering is a library guarantee, not a caller obligation. To also make the prime-then-subscribe ordering moot for the typed callbacks, use :meth:`subscribe_devices_and_prime` / :meth:`subscribe_events_and_prime`. After priming, ``public_bootstrap.nvr.mac`` carries the NVR mac whenever it is resolvable. On firmware that omits ``mac`` from the public payload it is backfilled from the console fallback and stored in the native UniFi format (uppercase, no separators) — the same format newer firmware already provides — so consumers can read a self-consistent mac regardless of firmware. Concurrent calls are serialized: an overlapping prime could otherwise apply an older snapshot over a newer one (and over live WS merges in between). Each caller returns the then-current bootstrap. """ async with self._public_update_lock: return await self._update_public_locked() async def _update_public_locked(self) -> PublicBootstrap: """Fetch and apply the public bootstrap; caller holds the prime lock.""" # Keep a candidate bootstrap local until Phase 1 classification # succeeds; on a first refresh that fails, ``_public_bootstrap`` must # stay ``None`` so the ``subscribe_events`` prime guard still fires. pb = self._public_bootstrap # The first prime turns every fetched device into an "add"; only a # refresh of an already-materialised snapshot has a meaningful diff to # announce. was_primed = pb is not None if pb is None: pb = PublicBootstrap() # Snapshot existing streams before the re-parse below replaces the # camera objects with freshly-built ones (whose ``rtsps_streams`` # default to ``None``); the prime step carries them forward by id so # the never-empty contract holds across a resync. previous_streams = { camera_id: camera.rtsps_streams for camera_id, camera in pb.cameras.items() if camera.rtsps_streams is not None } # Bind coroutines to their labels and attribute names to avoid # manual index synchronization bugs. # ``_fetch_arm_profiles`` fetches without self-applying so # arm-profiles can be applied atomically in the batch phase below. # ``armMode`` is part of the NVR response; no separate call needed. endpoints = [ (self.get_nvr_public(), "nvr", "nvr"), (self.get_cameras_public(), "cameras", "cameras"), (self.get_lights_public(), "lights", "lights"), (self.get_chimes_public(), "chimes", "chimes"), (self.get_sensors_public(), "sensors", "sensors"), (self.get_sirens_public(), "sirens", "sirens"), (self.get_relays_public(), "relays", "relays"), (self.get_fobs_public(), "fobs", "fobs"), (self.get_speakers_public(), "speakers", "speakers"), (self.get_link_stations_public(), "link-stations", "link_stations"), (self.get_liveviews_public(), "liveviews", "liveviews"), (self.get_bridges_public(), "bridges", "bridges"), (self.get_viewers_public(), "viewers", "viewers"), (self.get_ulp_users_public(), "ulp-users", "ulp_users"), (self._fetch_arm_profiles(), "arm-profiles", "arm_profiles"), ] # Capture any public-WS frame that lands while the snapshot is being # fetched/applied so it can be replayed onto the fresh cache below, # instead of being merged into (or dropped by) the about-to-be-replaced # objects. The prime lock guarantees this prime is the only one. self._prime_ws_buffer = [] # Live and replayed frames land on the same snapshot this fetch is # merging into, so both must be able to preempt the diff below. seen: set[tuple[ModelType, str]] = set() self._public_membership_seen = seen try: await self._fetch_and_apply_public_snapshot( pb, endpoints, previous_streams, seen, was_primed=was_primed ) finally: self._public_membership_seen = None return pb async def _fetch_and_apply_public_snapshot( self, pb: PublicBootstrap, endpoints: list[tuple[Any, str, str]], previous_streams: dict[str, RTSPSStreams], seen: set[tuple[ModelType, str]], *, was_primed: bool, ) -> None: """Run the ``update_public`` fetch/apply/announce sequence for ``pb``.""" diffs: list[FetchDiff] = [] try: results = await asyncio.gather( *[coro for coro, _, _ in endpoints], return_exceptions=True ) # Phase 1 — classify every result before touching the snapshot. # Tolerated endpoint-unavailable errors (BadRequest/NvrError) are # logged; any other exception re-raises here, leaving the previous # consistent bootstrap intact instead of half-applied. for (_, label, _attr), result in zip(endpoints, results, strict=True): if isinstance(result, BaseException): _log_or_raise( label, result, tolerate_not_authorized=label == "ulp-users" ) # Classification passed: publish the candidate. # ``_apply_arm_profiles`` reads ``self._public_bootstrap``, so this # must precede Phase 2. self._public_bootstrap = pb # Phase 2 — no unexpected error: apply the whole batch. No ``await`` # between writes, so a concurrent public-WS frame cannot interleave # a torn state. Tolerated-missing endpoints keep their prior data. diffs = self._apply_public_fetch_results(pb, endpoints, results) # Re-derive the siren deadlines in the same await-free window the # merge ran in: a timer armed for a superseded run must not outlive # the status it was derived from. self._reschedule_siren_offs(pb) finally: # Stop buffering and drain on success and failure alike: replayed # frames land on the fresh snapshot when the prime succeeded, and # on the previous cache when it raised (the pre-buffering live # behavior, merely delayed) — a failed prime must not swallow them. # Clearing the buffer first means replayed (and any re-entrant) # frames process live rather than re-queueing. replay = self._prime_ws_buffer or [] self._prime_ws_buffer = None for handler, msg in replay: handler(msg) await self._prime_rtsps_streams(pb, previous_streams) await self._backfill_public_nvr_mac(pb) if was_primed: self._emit_public_fetch_diffs(pb, diffs, seen) def _apply_public_fetch_results( self, pb: PublicBootstrap, endpoints: list[tuple[Any, str, str]], results: list[Any], ) -> list[FetchDiff]: """Apply the classified ``update_public`` fetch results to ``pb``.""" diffs: list[FetchDiff] = [] for (_, _label, attr), result in zip(endpoints, results, strict=True): if isinstance(result, BaseException): continue if attr == "arm_profiles": self._apply_arm_profiles(cast("list[ArmProfile]", result)) elif attr == "nvr": pb.nvr = result else: diffs.append(pb.apply_fetch_result(attr, result)) return diffs def _emit_public_fetch_diffs( self, pb: PublicBootstrap, diffs: list[FetchDiff], seen: set[tuple[ModelType, str]], ) -> None: """ Announce the devices an ``update_public`` refresh added or removed. A device that appears or disappears while the devices websocket is down produces no wire frame, so the reconnect resync is the only place subscribers can learn about it. Emitting here — after the whole batch has merged and the RTSPS prime has run — means a subscriber reacting to the frame already sees a consistent bootstrap. """ for diff in diffs: store = cast("dict[str, ProtectModelWithId]", getattr(pb, diff.attr)) for obj_id in diff.added_ids: # Re-read: a WS frame arriving during the fetch may have # removed the object again since the merge. if (obj := store.get(obj_id)) is not None: self._emit_public_membership_frame(pb, WSAction.ADD, obj, seen) for obj in diff.removed: if obj.id not in store: self._emit_public_membership_frame(pb, WSAction.REMOVE, obj, seen) def _emit_public_membership_frame( self, pb: PublicBootstrap, action: WSAction, obj: ProtectModelWithId, seen: set[tuple[ModelType, str]], ) -> None: """Emit one synthetic devices-WS ``add``/``remove`` for ``obj``.""" model = obj.model # Non-device stores (ulp-users) and objects the devices WS never # routes have no membership frame to speak of. if model is None or not pb.supports_device(model): return # A real frame already announced this change, or the subscriber asked # not to hear about this model at all. if (model, obj.id) in seen or self._devices_ws_filtered_out(model): return is_add = action is WSAction.ADD self.emit_devices_message( WSSubscriptionMessage( action=action, new_update_id=obj.id, # Identity only, like the other library-synthesized frames: the # merged object travels in ``new_obj``/``old_obj``, and this # path only runs once the cache is materialised. changed_data={"modelKey": model.value, "id": obj.id}, new_obj=obj if is_add else None, old_obj=None if is_add else obj, ) ) async def _backfill_public_nvr_mac(self, pb: PublicBootstrap) -> None: """ Stamp the NVR mac onto ``pb`` when firmware omits it from the payload. Protect newer than 7.1 already carries ``mac`` on ``GET /v1/nvrs`` in native UniFi format (uppercase, no separators), so this is a no-op there. On older firmware the field is ``None``; resolve it via the console fallback and write it in that same native format so the value does not drift across firmware versions. """ if pb.nvr is None or pb.nvr.mac: return resolved = await self.resolve_nvr_mac() if not resolved: return # ``pb.nvr`` may have been replaced by a websocket write-through while # awaiting, so re-read it and skip if it now carries a mac. if (nvr := pb.nvr) is not None and not nvr.mac: # resolve_nvr_mac() returns the normalized (lowercase, separator- # stripped) form; the public field natively holds uppercase-no- # separator on newer firmware, so upper() matches that exactly. nvr.mac = resolved.upper() async def _prime_rtsps_streams( self, pb: PublicBootstrap, previous_streams: dict[str, RTSPSStreams], ) -> None: """ Populate each camera's ``rtsps_streams`` after an ``update_public`` fetch. Carries forward the pre-re-parse streams by id, then fetches streams for the genuinely-missing connected cameras under a bounded concurrency semaphore. Each fetch is bounded by ``RTSPS_PRIME_TIMEOUT`` and retried ``RTSPS_PRIME_RETRIES`` times on transient failure. Best-effort per camera: one slow/unreachable camera cannot abort the prime, and the cameras that still fail are aggregated into a single ``WARNING`` so the failure mode is diagnosable in the field. Disconnected cameras are skipped — they yield no usable stream, only a per-camera timeout. """ for camera_id, camera in pb.cameras.items(): if camera.rtsps_streams is None and camera_id in previous_streams: camera.rtsps_streams = previous_streams[camera_id] to_prime = [ camera for camera in pb.cameras.values() if camera.rtsps_streams is None and camera.state is DeviceState.CONNECTED ] if not to_prime: return semaphore = asyncio.Semaphore(RTSPS_PRIME_CONCURRENCY) async def _prime_one(camera: PublicCamera) -> str | None: async with semaphore: streams: RTSPSStreams | None = None for attempt in range(RTSPS_PRIME_RETRIES + 1): try: async with asyncio.timeout(RTSPS_PRIME_TIMEOUT): streams = await self.get_camera_rtsps_streams(camera.id) if streams is not None: break _LOGGER.debug( "Primed no RTSPS streams for camera %s (attempt %d/%d)", camera.id, attempt + 1, RTSPS_PRIME_RETRIES + 1, ) except Exception: _LOGGER.debug( "Failed to prime RTSPS streams for camera %s " "(attempt %d/%d)", camera.id, attempt + 1, RTSPS_PRIME_RETRIES + 1, exc_info=True, ) else: return camera.id # Only write if the camera is still the cached instance and # still streamless — a concurrent write-through (create) or a # remove/replace during the fetch await must not be clobbered or # resurrected (mirrors the ``_refresh_camera_rtsps`` guard). if ( streams is not None and pb.cameras.get(camera.id) is camera and camera.rtsps_streams is None ): camera.rtsps_streams = streams return None results = await asyncio.gather(*[_prime_one(camera) for camera in to_prime]) failed = [camera_id for camera_id in results if camera_id is not None] if failed: _LOGGER.warning( "Could not prime RTSPS streams for %d camera(s): %s", len(failed), ", ".join(failed), ) uilibs-uiprotect-317a52c/src/uiprotect/cli/000077500000000000000000000000001524637705700207405ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/cli/__init__.py000066400000000000000000000350501524637705700230540ustar00rootroot00000000000000from __future__ import annotations import asyncio import base64 import hashlib import logging import ssl import sys from pathlib import Path from typing import cast import aiohttp import orjson import typer from rich.progress import track from uiprotect.api import MetaInfo, ProtectApiClient from ..data import WSPacket from ..exceptions import BadRequest from ..test_util import SampleDataGenerator from ..utils import get_local_timezone, run_async from ..utils import profile_ws as profile_ws_job from .aiports import app as aiports_app from .arm import app as arm_app from .base import CliContext, OutputFormatEnum from .bridges import app as bridges_app from .cameras import app as camera_app from .chimes import app as chime_app from .events import app as event_app from .files_public import app as files_public_app from .fobs import app as fob_app from .lights import app as light_app from .link_stations import app as link_station_app from .liveviews import app as liveview_app from .nvr import app as nvr_app from .relays import app as relay_app from .sensors import app as sensor_app from .sirens import app as siren_app from .speakers import app as speaker_app from .ulp_users_public import app as ulp_users_public_app from .users_public import app as users_public_app from .viewers import app as viewer_app from .viewers_public import app as viewer_public_app try: from .backup import app as backup_app except ImportError: backup_app = None # type: ignore[assignment] _LOGGER = logging.getLogger("uiprotect") try: from IPython import embed from termcolor import colored from traitlets.config import get_config except ImportError: embed = termcolor = get_config = None # type: ignore[assignment] # Sub-apps that only use the public API (API key) and do not need username/password _PUBLIC_ONLY_COMMAND_NAMES: tuple[str, ...] = ( "sirens", "relays", "fobs", "speakers", "link-stations", "liveviews", "bridges", "viewers-public", "users-public", "ulp-users-public", "files-public", "arm", ) _PUBLIC_ONLY_COMMANDS: frozenset[str] = frozenset(_PUBLIC_ONLY_COMMAND_NAMES) _PUBLIC_ONLY_COMMANDS_HELP: str = ", ".join(_PUBLIC_ONLY_COMMAND_NAMES) OPTION_USERNAME = typer.Option( None, "--username", "-U", help=( "UniFi Protect username (not required for public API commands: " f"{_PUBLIC_ONLY_COMMANDS_HELP})" ), envvar="UFP_USERNAME", ) OPTION_PASSWORD = typer.Option( None, "--password", "-P", help=( "UniFi Protect password (not required for public API commands: " f"{_PUBLIC_ONLY_COMMANDS_HELP})" ), hide_input=True, envvar="UFP_PASSWORD", ) OPTION_API_KEY = typer.Option( None, "--api-key", "-k", help="UniFi Protect API key (required for public API operations)", envvar="UFP_API_KEY", ) OPTION_ADDRESS = typer.Option( ..., "--address", "-a", prompt=True, help="UniFi Protect IP address or hostname", envvar="UFP_ADDRESS", ) OPTION_PORT = typer.Option( 443, "--port", "-p", help="UniFi Protect Port", envvar="UFP_PORT", ) OPTION_SECONDS = typer.Option(15, "--seconds", "-s", help="Seconds to pull events") OPTION_VERIFY_SSL = typer.Option( True, "--verify-ssl/--no-verify-ssl", help="Verify SSL certificate. Disable for self-signed certificates.", envvar="UFP_SSL_VERIFY", ) OPTION_ANON = typer.Option(True, "--actual", help="Do not anonymize test data") OPTION_ZIP = typer.Option(False, "--zip", help="Zip up data after generate") OPTION_WAIT = typer.Option( 30, "--wait", "-w", help="Time to wait for Websocket messages", ) OPTION_OUTPUT = typer.Option( None, "--output", "-o", help="Output folder, defaults to `tests` folder one level above this file", envvar="UFP_SAMPLE_DIR", ) OPTION_OUT_FORMAT = typer.Option( OutputFormatEnum.PLAIN, "--output-format", help="Preferred output format. Not all commands support both JSON and plain and may still output in one or the other.", ) OPTION_WS_FILE = typer.Option( None, "--file", "-f", help="Path or raw binary Websocket message", ) OPTION_UNADOPTED = typer.Option( False, "-u", "--include-unadopted", help="Include devices not adopted by this NVR.", ) ARG_WS_DATA = typer.Argument(None, help="base64 encoded Websocket message") SLEEP_INTERVAL = 2 app = typer.Typer(rich_markup_mode="rich") app.add_typer(nvr_app, name="nvr") app.add_typer(event_app, name="events") app.add_typer(liveview_app, name="liveviews") app.add_typer(camera_app, name="cameras") app.add_typer(chime_app, name="chimes") app.add_typer(light_app, name="lights") app.add_typer(sensor_app, name="sensors") app.add_typer(viewer_app, name="viewers") app.add_typer(viewer_public_app, name="viewers-public") app.add_typer(aiports_app, name="aiports") app.add_typer(siren_app, name="sirens") app.add_typer(relay_app, name="relays") app.add_typer(fob_app, name="fobs") app.add_typer(speaker_app, name="speakers") app.add_typer(link_station_app, name="link-stations") app.add_typer(bridges_app, name="bridges") app.add_typer(users_public_app, name="users-public") app.add_typer(ulp_users_public_app, name="ulp-users-public") app.add_typer(files_public_app, name="files-public") app.add_typer(arm_app, name="arm") if backup_app is not None: app.add_typer(backup_app, name="backup") def _is_ssl_error(exc: BaseException) -> bool: """Check if an exception is an SSL certificate verification error.""" if isinstance(exc, aiohttp.ClientConnectorCertificateError): return True if isinstance(exc, aiohttp.ClientConnectorSSLError): return True if isinstance(exc, ssl.SSLCertVerificationError): return True # Check nested exceptions if exc.__cause__ is not None: return _is_ssl_error(exc.__cause__) return False def _get_cert_fingerprint(host: str, port: int) -> str | None: """Return the SHA-256 fingerprint of the server's leaf certificate, or None.""" try: pem = ssl.get_server_certificate((host, port), timeout=5) except (OSError, ssl.SSLError): return None if not pem: return None der = ssl.PEM_cert_to_DER_cert(pem) digest = hashlib.sha256(der).hexdigest().upper() return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2)) async def _connect_and_bootstrap(protect: ProtectApiClient) -> None: """Connect to the Protect API and fetch bootstrap data.""" protect._bootstrap = await protect.get_bootstrap() await protect.close_session() await protect.close_public_api_session() @app.callback() def main( ctx: typer.Context, username: str | None = OPTION_USERNAME, password: str | None = OPTION_PASSWORD, api_key: str | None = OPTION_API_KEY, address: str = OPTION_ADDRESS, port: int = OPTION_PORT, verify_ssl: bool = OPTION_VERIFY_SSL, output_format: OutputFormatEnum = OPTION_OUT_FORMAT, include_unadopted: bool = OPTION_UNADOPTED, ) -> None: """UniFi Protect CLI""" # preload the timezone before any async code runs get_local_timezone() is_public_only = ctx.invoked_subcommand in _PUBLIC_ONLY_COMMANDS if not is_public_only: # Private API commands require username and password. # Prompt interactively if not supplied via option/env. if not username: username = typer.prompt("Username") if not password: password = typer.prompt("Password", hide_input=True) try: protect = ProtectApiClient( address, port, username=None if is_public_only else (username or ""), password=None if is_public_only else (password or ""), api_key=api_key, verify_ssl=verify_ssl, ignore_unadopted=not include_unadopted, ) except BadRequest as err: typer.secho(str(err), fg="red", err=True) raise typer.Exit(code=1) from err async def close_protect() -> None: """Close the Protect API client sessions.""" await protect.close_session() await protect.close_public_api_session() if not is_public_only: try: run_async(_connect_and_bootstrap(protect)) except Exception as exc: # Always close the session on error to avoid "Unclosed client session" warning run_async(close_protect()) if verify_ssl and _is_ssl_error(exc): typer.secho( f"SSL certificate verification failed for {address}:{port}.", fg="red", err=True, ) fingerprint = _get_cert_fingerprint(address, port) if fingerprint: typer.secho( f" Server certificate SHA-256: {fingerprint}", err=True, ) typer.secho( "Refusing to retry with verification disabled — sending " "credentials over an unauthenticated TLS channel would " "expose them to any on-path attacker.", fg="red", err=True, ) typer.secho( "If you have verified the fingerprint above out-of-band " "(e.g. via the UniFi Protect console), rerun the command " "with --no-verify-ssl to skip verification for this " "invocation.", err=True, ) raise typer.Exit(code=1) from exc typer.secho(f"Connection failed: {exc}", fg="red") raise typer.Exit(code=1) from exc ctx.obj = CliContext(protect=protect, output_format=output_format) def _setup_logger(level: int = logging.DEBUG, show_level: bool = False) -> None: console_handler = logging.StreamHandler() console_handler.setLevel(level) if show_level: formatter = logging.Formatter("%(levelname)s: %(message)s") console_handler.setFormatter(formatter) _LOGGER.setLevel(logging.DEBUG) _LOGGER.addHandler(console_handler) async def _progress_bar(wait_time: int, label: str) -> None: for i in track(range(wait_time // SLEEP_INTERVAL), description=label): if i > 0: await asyncio.sleep(SLEEP_INTERVAL) @app.command() def shell(ctx: typer.Context) -> None: """ Opens iPython shell with Protect client initialized. Requires the `shell` extra to also be installed. """ if embed is None or colored is None: typer.echo("ipython and termcolor required for shell subcommand") sys.exit(1) # locals passed to shell protect = cast( "ProtectApiClient", ctx.obj.protect, ) _setup_logger(show_level=True) async def wait_forever() -> None: await protect.update() protect.subscribe_websocket(lambda _: None) while True: await asyncio.sleep(10) await protect.update() c = get_config() c.InteractiveShellEmbed.colors = "Linux" embed( # type: ignore[no-untyped-call] header=colored("protect = ProtectApiClient(*args)", "green"), config=c, using="asyncio", ) @app.command() def generate_sample_data( ctx: typer.Context, anonymize: bool = OPTION_ANON, wait_time: int = OPTION_WAIT, output_folder: Path | None = OPTION_OUTPUT, do_zip: bool = OPTION_ZIP, ) -> None: """Generates sample data for UniFi Protect instance.""" protect = cast("ProtectApiClient", ctx.obj.protect) if output_folder is None: tests_folder = Path(__file__).parent.parent / "tests" if not tests_folder.exists(): typer.secho("Output folder required when not in dev-mode", fg="red") sys.exit(1) output_folder = (tests_folder / "sample_data").absolute() def log(msg: str) -> None: typer.echo(msg) def log_warning(msg: str) -> None: typer.secho(msg, fg="yellow") SampleDataGenerator( protect, output_folder, anonymize, wait_time, log=log, log_warning=log_warning, ws_progress=_progress_bar, do_zip=do_zip, ).generate() @app.command() def profile_ws( ctx: typer.Context, wait_time: int = OPTION_WAIT, output_path: Path | None = OPTION_OUTPUT, ) -> None: """Profiles Websocket messages for UniFi Protect instance.""" protect = cast("ProtectApiClient", ctx.obj.protect) async def callback() -> None: await protect.update() unsub = protect.subscribe_websocket(lambda _: None) await profile_ws_job( protect, wait_time, output_path=output_path, ws_progress=_progress_bar, ) unsub() await protect.async_disconnect_ws() await protect.close_session() await protect.close_public_api_session() _setup_logger() run_async(callback()) @app.command() def decode_ws_msg( ws_file: typer.FileBinaryRead = OPTION_WS_FILE, ws_data: str | None = ARG_WS_DATA, ) -> None: """Decodes a base64 encoded UniFi Protect Websocket binary message.""" if ws_file is None and ws_data is None: # type: ignore[unreachable] typer.secho("Websocket data required", fg="red") # type: ignore[unreachable] sys.exit(1) ws_data_raw = b"" if ws_file is not None: ws_data_raw = ws_file.read() elif ws_data is not None: # type: ignore[unreachable] ws_data_raw = base64.b64decode(ws_data.encode("utf8")) packet = WSPacket(ws_data_raw) response = {"action": packet.action_frame.data, "data": packet.data_frame.data} typer.echo(orjson.dumps(response).decode("utf-8")) @app.command() def create_api_key( ctx: typer.Context, name: str = typer.Argument(..., help="Name for the API key"), ) -> None: """Create a new API key for the current user.""" protect = cast("ProtectApiClient", ctx.obj.protect) async def callback() -> str: api_key = await protect.create_api_key(name) await protect.close_session() await protect.close_public_api_session() return api_key _setup_logger() result = run_async(callback()) typer.echo(result) @app.command() def get_meta_info(ctx: typer.Context) -> None: """Get metadata about the current UniFi Protect instance.""" protect = cast("ProtectApiClient", ctx.obj.protect) async def callback() -> MetaInfo: meta = await protect.get_meta_info() await protect.close_session() await protect.close_public_api_session() return meta _setup_logger() result = run_async(callback()) typer.echo(result.model_dump_json()) uilibs-uiprotect-317a52c/src/uiprotect/cli/aiports.py000066400000000000000000000030111524637705700227660ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass import typer from ..api import ProtectApiClient from ..cli import base from ..data import AiPort app = typer.Typer(rich_markup_mode="rich") ARG_DEVICE_ID = typer.Argument( None, help="ID of AiPort device to select for subcommands" ) @dataclass class AiPortContext(base.CliContext): devices: dict[str, AiPort] device: AiPort | None = None ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) @app.callback(invoke_without_command=True) def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: """ AiPort device CLI. Returns full list of AiPorts without any arguments passed. """ protect: ProtectApiClient = ctx.obj.protect context = AiPortContext( protect=ctx.obj.protect, device=None, devices=protect.bootstrap.aiports, output_format=ctx.obj.output_format, ) ctx.obj = context if device_id is not None and device_id not in ALL_COMMANDS: if (device := protect.bootstrap.aiports.get(device_id)) is None: typer.secho("Invalid aiport ID", fg="red") raise typer.Exit(1) ctx.obj.device = device if not ctx.invoked_subcommand: if device_id in ALL_COMMANDS: ctx.invoke(ALL_COMMANDS[device_id], ctx) return if ctx.obj.device is not None: base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) return base.print_unifi_dict(ctx.obj.devices) uilibs-uiprotect-317a52c/src/uiprotect/cli/arm.py000066400000000000000000000033311524637705700220710ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_PROFILE_ID = typer.Argument(..., help="Arm profile ID") @app.callback() def main(ctx: typer.Context) -> None: """Arm profile and alarm commands (Public API).""" @app.command("list") def list_profiles(ctx: typer.Context) -> None: """List all arm profiles.""" async def _fetch() -> None: profiles = await ctx.obj.protect.get_arm_profiles_public() base.print_unifi_list(profiles) base.run(ctx, _fetch()) @app.command() def status(ctx: typer.Context) -> None: """Show current arm manager settings (active profile + alarm state).""" async def _fetch() -> None: arm_mode = await ctx.obj.protect.get_arm_manager_settings_public() base.print_unifi_obj(arm_mode, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def set_profile( ctx: typer.Context, profile_id: str = ARG_PROFILE_ID, ) -> None: """Set the currently active arm profile.""" base.run(ctx, ctx.obj.protect.set_current_arm_profile_public(profile_id)) @app.command() def enable_alarm(ctx: typer.Context) -> None: """Enable the arm alarm using the currently selected profile.""" base.run(ctx, ctx.obj.protect.enable_arm_alarm_public()) @app.command() def disable_alarm(ctx: typer.Context) -> None: """Disable the arm alarm.""" base.run(ctx, ctx.obj.protect.disable_arm_alarm_public()) @app.command() def trigger( ctx: typer.Context, trigger_id: str = typer.Argument(..., help="Webhook trigger ID"), ) -> None: """Fire the alarm-manager webhook for the given trigger ID.""" base.run(ctx, ctx.obj.protect.send_alarm_webhook_public(trigger_id)) uilibs-uiprotect-317a52c/src/uiprotect/cli/backup.py000066400000000000000000001126151524637705700225650ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging import math import re import sys import time from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import StrEnum from pathlib import Path from typing import TYPE_CHECKING, Any, cast import aiofiles import aiofiles.os as aos import av import dateparser import typer from PIL import Image from rich.progress import ( BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn, TimeRemainingColumn, track, ) from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func, or_, select from sqlalchemy import event as saevent from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.orm import Mapped, declarative_base, relationship from .. import data as d from ..api import ProtectApiClient from ..cli import base from ..utils import ( format_duration, get_local_timezone, local_datetime, utc_now, ) if TYPE_CHECKING: from click.core import Parameter app = typer.Typer(rich_markup_mode="rich") Base = declarative_base() _LOGGER = logging.getLogger(__name__) _SLUG_BAD = re.compile(r"[^A-Za-z0-9._-]+") def _safe_slug(value: str, sep: str) -> str: """Sanitize a server-supplied string for safe use in a filesystem path.""" cleaned = _SLUG_BAD.sub(sep, value).strip(sep + "./") return cleaned or "unknown" def _safe_join(base: Path, file_path: str) -> Path: """Join ``file_path`` onto ``base`` and refuse paths escaping ``base``.""" candidate = (base / file_path).resolve() base_resolved = base.resolve() if not candidate.is_relative_to(base_resolved): raise ValueError( f"refusing to use path outside output dir: {candidate}", ) return candidate def _safe_first_glob_match(base: Path, pattern: str) -> Path | None: """Return first ``base.glob(pattern)`` match contained in ``base``.""" if Path(pattern).is_absolute() or ".." in Path(pattern).parts: _LOGGER.warning( "Ignoring unsafe glob pattern outside output dir: %s", pattern, ) return None base_resolved = base.resolve() for match in base.glob(pattern): resolved = match.resolve() if resolved.is_relative_to(base_resolved): return resolved _LOGGER.warning("Ignoring path outside output dir: %s", resolved) return None def _on_db_connect(dbapi_con, connection_record) -> None: # type: ignore[no-untyped-def] cursor = dbapi_con.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA synchronous=NORMAL") @dataclass class BackupContext(base.CliContext): start: datetime end: datetime | None output: Path separator: str thumbnail_format: str gif_format: str event_format: str title_format: str max_download: int page_size: int length_cutoff: timedelta _db_engine: AsyncEngine | None = None _db_session: AsyncSession | None = None @property def download_thumbnails(self) -> bool: return self.thumbnail_format != "" @property def download_gifs(self) -> bool: return self.gif_format != "" @property def download_videos(self) -> bool: return self.event_format != "" @property def db_file(self) -> Path: return self.output / "events.db" @property def db_engine(self) -> AsyncEngine: if self._db_engine is None: self._db_engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_file}") self._db_session = None saevent.listens_for(self._db_engine.sync_engine, "connect")(_on_db_connect) return self._db_engine def create_db_session(self) -> AsyncSession: return AsyncSession(bind=self.db_engine, expire_on_commit=False) async def create_db(self) -> None: async with self.db_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) class EventTypeChoice(StrEnum): MOTION = d.EventType.MOTION.value RING = d.EventType.RING.value SMART_DETECT = d.EventType.SMART_DETECT.value SMART_DETECT_LINE = d.EventType.SMART_DETECT_LINE.value class EventSmartType(Base): # type: ignore[valid-type,misc] __tablename__ = "event_smart_type" id = Column(Integer, primary_key=True) event_id = Column(String(24), ForeignKey("event.id"), nullable=False) smart_type = Column(String(32), index=True) class Event(Base): # type: ignore[valid-type,misc] __tablename__ = "event" __allow_unmapped__ = True id = Column(String(24), primary_key=True) start_naive = Column(DateTime()) end_naive = Column(DateTime(), nullable=True) camera_mac = Column(String(12), index=True) event_type = Column(String(32), index=True) smart_detect_types: Mapped[list[EventSmartType]] = relationship( "EventSmartType", lazy="joined", uselist=True, ) _start: datetime | None = None _end: datetime | None = None _smart_types: set[str] | None = None _context: dict[str, str] | None = None _glob_context: dict[str, str] | None = None @property def start(self) -> datetime: if self._start is None: self._start = self.start_naive.replace(tzinfo=UTC) # type: ignore[union-attr] return self._start @property def end(self) -> datetime | None: if self._end is None and self.end_naive is not None: self._end = self.end_naive.replace(tzinfo=UTC) return self._end @property def smart_types(self) -> set[str]: if self._smart_types is None: self._smart_types = {s.smart_type for s in self.smart_detect_types} # type: ignore[misc] return self._smart_types def get_file_context(self, ctx: BackupContext) -> dict[str, str]: if self._context is None: camera = ctx.protect.bootstrap.get_device_from_mac(self.camera_mac) # type: ignore[arg-type] camera_slug = "" display_name = "" length = timedelta(seconds=0) if camera is not None: raw_name = camera.display_name safe_name = _safe_slug( raw_name.lower().replace(" ", ctx.separator), ctx.separator, ) camera_slug = safe_name + ctx.separator display_name = raw_name if self.end is not None: length = self.end - self.start event_type = str(self.event_type) event_type_pretty = f"{event_type.title()} Event" if event_type in { d.EventType.SMART_DETECT.value, d.EventType.SMART_DETECT_LINE.value, }: smart_types = list(self.smart_types) smart_types.sort() event_type = f"{event_type}[{','.join(smart_types)}]" smart_types_title = [s.title() for s in smart_types] event_type_pretty = f"Smart Detection ({', '.join(smart_types_title)})" start_local = local_datetime(self.start) self._context = { "year": str(self.start.year), "month": str(self.start.month), "day": str(self.start.day), "hour": str(self.start.hour), "minute": str(self.start.minute), "datetime": self.start.strftime("%Y-%m-%dT%H-%M-%S%z").replace( "-", ctx.separator, ), "date": self.start.strftime("%Y-%m-%d").replace("-", ctx.separator), "time": self.start.strftime("%H-%M-%S%z").replace("-", ctx.separator), "time_sort_pretty": self.start.strftime("%H:%M:%S (%Z)"), "time_pretty": self.start.strftime("%I:%M:%S %p (%Z)"), "year_local": str(start_local.year), "month_local": str(start_local.month), "day_local": str(start_local.day), "hour_local": str(start_local.hour), "minute_local": str(start_local.minute), "datetime_local": start_local.strftime("%Y-%m-%dT%H-%M-%S%z").replace( "-", ctx.separator, ), "date_local": start_local.strftime("%Y-%m-%d").replace( "-", ctx.separator, ), "time_local": start_local.strftime("%H-%M-%S%z").replace( "-", ctx.separator, ), "time_sort_pretty_local": start_local.strftime("%H:%M:%S (%Z)"), "time_pretty_local": start_local.strftime("%I:%M:%S %p (%Z)"), "mac": str(self.camera_mac), "camera_name": display_name, "camera_slug": camera_slug, "event_type": event_type, "event_type_pretty": event_type_pretty, "length_pretty": format_duration(length), "sep": ctx.separator, } self._context["title"] = ctx.title_format.format(**self._context) return self._context def get_glob_file_context(self, ctx: BackupContext) -> dict[str, str]: if self._glob_context is None: self._glob_context = self.get_file_context(ctx).copy() self._glob_context["camera_slug"] = "*" self._glob_context["camera_name"] = "*" return self._glob_context def get_thumbnail_path(self, ctx: BackupContext) -> Path: context = self.get_file_context(ctx) file_path = ctx.thumbnail_format.format(**context) return _safe_join(ctx.output, file_path) def get_existing_thumbnail_path(self, ctx: BackupContext) -> Path | None: context = self.get_glob_file_context(ctx) file_path = ctx.thumbnail_format.format(**context) return _safe_first_glob_match(ctx.output, file_path) def get_gif_path(self, ctx: BackupContext) -> Path: context = self.get_file_context(ctx) file_path = ctx.gif_format.format(**context) return _safe_join(ctx.output, file_path) def get_existing_gif_path(self, ctx: BackupContext) -> Path | None: context = self.get_glob_file_context(ctx) file_path = ctx.gif_format.format(**context) return _safe_first_glob_match(ctx.output, file_path) def get_event_path(self, ctx: BackupContext) -> Path: context = self.get_file_context(ctx) file_path = ctx.event_format.format(**context) return _safe_join(ctx.output, file_path) def get_existing_event_path(self, ctx: BackupContext) -> Path | None: context = self.get_glob_file_context(ctx) file_path = ctx.event_format.format(**context) return _safe_first_glob_match(ctx.output, file_path) @dataclass class QueuedDownload: task: asyncio.Task[bool] | None args: list[Any] def relative_datetime(ctx: typer.Context, value: str, param: Parameter) -> datetime: if dt := dateparser.parse(value): return dt raise typer.BadParameter( "Must be a ISO 8601 format or human readable relative format", ctx, param, ) _DownloadEventQueue = asyncio.Queue[QueuedDownload] OPTION_OUTPUT = typer.Option( None, help="Base dir for creating files. Defaults to $PWD.", envvar="UFP_BACKUP_OUTPUT", ) OPTION_START = typer.Option( None, "-s", "--start", help="Cutoff for start of backup. Defaults to start of recording for NVR.", envvar="UFP_BACKUP_START", ) OPTION_PAGE_SIZE = typer.Option( 1000, "--page-size", help="Number of events fetched at once from local database. Increases memory usage.", ) OPTION_LENGTH_CUTOFF = typer.Option( timedelta(hours=1).total_seconds(), "--length-cutoff", help="Event size cutoff for detecting abnormal events (in seconds).", ) OPTION_END = typer.Option( None, "-e", "--end", help="Cutoff for end of backup. Defaults to now.", envvar="UFP_BACKUP_END", ) OPTION_EVENT_TYPES = typer.Option( list(EventTypeChoice), "-t", "--event-type", help="Events to export. Can be used multiple time.", ) OPTION_SMART_TYPES = typer.Option( list(d.SmartDetectObjectType), "-m", "--smart-type", help="Smart Detection types to export. Can be used multiple time.", ) OPTION_SEPARATOR = typer.Option("-", "--sep", help="Separator used for formatting.") OPTION_THUMBNAIL_FORMAT = typer.Option( "{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}thumb.jpg", "--thumb-format", help='Filename format to save event thumbnails to. Set to empty string ("") to skip saving event thumbnails.', ) OPTION_GIF_FORMAT = typer.Option( "{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}{sep}animated.gif", "--gif-format", help='Filename format to save event gifs to. Set to empty string ("") to skip saving event gif.', ) OPTION_EVENT_FORMAT = typer.Option( "{year}/{month}/{day}/{hour}/{datetime}{sep}{mac}{sep}{camera_slug}{event_type}.mp4", "--event-format", help='Filename format to save event gifs to. Set to empty string ("") to skip saving event videos.', ) OPTION_TITLE_FORMAT = typer.Option( "{time_sort_pretty_local} {sep} {camera_name} {sep} {event_type_pretty} {sep} {length_pretty}", "--title-format", help="Format to use to tag title for video metadata.", ) OPTION_VERBOSE = typer.Option(False, "-v", "--verbose", help="Debug logging.") OPTION_MAX_DOWNLOAD = typer.Option( 5, "-d", "--max-download", help="Max number of concurrent downloads. Adds additional loads to NVR.", ) def _setup_logger(verbose: bool) -> None: console_handler = logging.StreamHandler() log_format = "[%(asctime)s] %(levelname)s - %(message)s" if verbose: console_handler.setLevel(logging.DEBUG) elif sys.stdout.isatty(): console_handler.setLevel(logging.WARNING) log_format = "%(message)s" else: console_handler.setLevel(logging.INFO) formatter = logging.Formatter(log_format) console_handler.setFormatter(formatter) root_logger = logging.getLogger("uiprotect") root_logger.setLevel(logging.DEBUG) root_logger.addHandler(console_handler) @app.callback() def main( ctx: typer.Context, start: str | None = OPTION_START, end: str | None = OPTION_END, output_folder: Path | None = OPTION_OUTPUT, thumbnail_format: str = OPTION_THUMBNAIL_FORMAT, gif_format: str = OPTION_GIF_FORMAT, event_format: str = OPTION_EVENT_FORMAT, title_format: str = OPTION_TITLE_FORMAT, verbose: bool = OPTION_VERBOSE, max_download: int = OPTION_MAX_DOWNLOAD, page_size: int = OPTION_PAGE_SIZE, length_cutoff: int = OPTION_LENGTH_CUTOFF, separator: str = OPTION_SEPARATOR, ) -> None: """ Backup CLI. The backup CLI is still very WIP in progress and consider experimental and potentially unstable (interface may change in the future). """ _setup_logger(verbose) protect: ProtectApiClient = ctx.obj.protect local_tz = get_local_timezone() if start is None: start_dt = protect.bootstrap.recording_start else: start_dt = relative_datetime(ctx, start, ctx.command.params[0]) start_dt = start_dt.replace(tzinfo=local_tz) if start_dt is None: start_dt = utc_now() end_dt = None if end is not None: end_dt = relative_datetime(ctx, end, ctx.command.params[1]) end_dt = end_dt.replace(tzinfo=local_tz) if output_folder is None: output_folder = Path.cwd() context = BackupContext( protect=ctx.obj.protect, start=start_dt, end=end_dt, output_format=ctx.obj.output_format, output=output_folder, thumbnail_format=thumbnail_format, gif_format=gif_format, event_format=event_format, title_format=title_format, max_download=max_download, page_size=page_size, length_cutoff=timedelta(seconds=length_cutoff), separator=separator, ) ctx.obj = context def _wipe_files(ctx: BackupContext, no_input: bool) -> None: if not no_input and not typer.confirm( "Are you sure you want to delete all existing thumbnails and video clips?", ): raise typer.Exit(1) if ctx.db_file.exists(): ctx.db_file.unlink() for path in track(ctx.output.glob("**/*.jpg"), description="Deleting Thumbnails"): path.unlink() for path in track(ctx.output.glob("**/*.mp4"), description="Deleting Clips"): path.unlink() async def _newest_event(ctx: BackupContext) -> Event | None: db = ctx.create_db_session() async with db: result = await db.execute(select(Event).order_by(Event.start_naive.desc())) return result.scalars().first() async def _prune_events(ctx: BackupContext) -> int: _LOGGER.debug("Pruning events before %s", ctx.start) deleted = 0 db = ctx.create_db_session() async with db: result = await db.execute( select(Event).join(EventSmartType).where(Event.start_naive < ctx.start), ) for event in track(result.unique().scalars(), description="Pruning Events"): try: thumb_path = event.get_thumbnail_path(ctx) except ValueError as exc: _LOGGER.warning("Skipping prune of thumbnail: %s", exc) else: if thumb_path.exists(): _LOGGER.debug("Delete file %s", thumb_path) await aos.remove(thumb_path) try: event_path = event.get_event_path(ctx) except ValueError as exc: _LOGGER.warning("Skipping prune of event video: %s", exc) else: if event_path.exists(): _LOGGER.debug("Delete file %s", event_path) await aos.remove(event_path) if event.event_type in { d.EventType.SMART_DETECT.value, d.EventType.SMART_DETECT_LINE.value, }: for smart_type in event.smart_detect_types: await db.delete(smart_type) await db.delete(event) deleted += 1 await db.commit() return deleted async def _update_event(ctx: BackupContext, event: d.Event) -> None: if event.camera is None: return db = ctx.create_db_session() to_delete: list[EventSmartType] = [] async with db: result = await db.execute(select(Event).where(Event.id == event.id)) db_event = result.scalars().first() do_insert = False if db_event is None: db_event = Event(id=event.id) do_insert = True db_event.start_naive = event.start db_event.end_naive = event.end db_event.camera_mac = event.camera.mac db_event.event_type = event.type.value if event.type in { d.EventType.SMART_DETECT.value, d.EventType.SMART_DETECT_LINE.value, }: types = {e.value for e in event.smart_detect_types} result = await db.execute( select(EventSmartType).where(EventSmartType.event_id == event.id), ) for event_smart_type in result.unique().scalars(): event_type = cast("EventSmartType", event_smart_type) if event_type.smart_type not in types: to_delete.append(event_type) else: types.remove(event_type.smart_type) for smart_type_str in types: db.add(EventSmartType(event_id=event.id, smart_type=smart_type_str)) if do_insert: db.add(db_event) for smart_type in to_delete: await db.delete(smart_type) await db.commit() async def _update_ongoing_events(ctx: BackupContext) -> int: db = ctx.create_db_session() async with db: result = await db.execute( select(Event) .where(Event.event_type != "ring") .where(Event.end_naive is None), # type: ignore[arg-type] ) events = list(result.unique().scalars()) if len(events) == 0: return 0 for event in track(events, description="Updating Events"): event_id = cast("str", event.id) await _update_event(ctx, await ctx.protect.get_event(event_id)) return len(events) async def _update_events(ctx: BackupContext) -> int: # update any events that are still set as ongoing in the database updated_ongoing = await _update_ongoing_events(ctx) start = ctx.start end = ctx.end or utc_now() processed: set[str] = set() total = int((end - ctx.start).total_seconds()) _LOGGER.debug("total: %s: %s %s", total, start, end) prev_start = start with Progress() as pb: task_id = pb.add_task("Fetching New Events", total=total) task = pb.tasks[0] pb.refresh() while not pb.finished: progress = int((start - prev_start).total_seconds()) pb.update(task_id, advance=progress) _LOGGER.debug( "progress: +%s: %s/%s: %s %s", progress, task.completed, task.total, start, end, ) events = await ctx.protect.get_events( start, end, limit=100, types=[ d.EventType.MOTION, d.EventType.RING, d.EventType.SMART_DETECT, d.EventType.SMART_DETECT_LINE, ], ) prev_start = start count = 0 for event in events: start = event.start if event.id not in processed: count += 1 processed.add(event.id) await _update_event(ctx, event) if start == prev_start and count == 0: pb.update(task_id, completed=total) return updated_ongoing + len(processed) async def _download_watcher( count: int, tasks: _DownloadEventQueue, no_error_flag: asyncio.Event, ) -> int: processed = 0 loop = asyncio.get_running_loop() downloaded = 0 last_print = time.monotonic() while processed < count: download = await tasks.get() task = download.task if task is None: processed += 1 continue retries = 0 while True: try: await task except asyncio.CancelledError: return downloaded except Exception: pass event: Event = download.args[1] if exception := task.exception(): no_error_flag.clear() if retries < 5: wait = math.pow(2, retries) _LOGGER.warning( "Exception while downloading event (%s): %s. Retring in %s second(s)", event.id, exception, wait, ) await asyncio.sleep(wait) retries += 1 task = loop.create_task(_download_event(*download.args)) else: _LOGGER.error("Failed to download event %s", event.id) if exception is None or retries >= 5: no_error_flag.set() processed += 1 now = time.monotonic() if now - last_print > 60: _LOGGER.info( "Processed %s/%s (%.2f%%) events", processed, count, processed / count, ) last_print = now if exception is None and task.result(): downloaded += 1 break return downloaded def _verify_thumbnail(path: Path) -> bool: try: image = Image.open(path) image.verify() # no docs on what exception could be except Exception: return False return True async def _download_event_thumb( ctx: BackupContext, event: Event, verify: bool, force: bool, animated: bool = False, ) -> bool: if animated: thumb_type = "gif" thumb_path = event.get_gif_path(ctx) existing_thumb_path = event.get_existing_gif_path(ctx) else: thumb_type = "thumbnail" thumb_path = event.get_thumbnail_path(ctx) existing_thumb_path = event.get_existing_thumbnail_path(ctx) if force and existing_thumb_path: _LOGGER.debug("Delete file %s", existing_thumb_path) await aos.remove(existing_thumb_path) if existing_thumb_path and str(existing_thumb_path) != str(thumb_path): _LOGGER.debug( "Rename event %s file %s: %s %s %s: %s", thumb_type, event.id, event.start, event.end, event.event_type, thumb_path, ) await aos.makedirs(thumb_path.parent, exist_ok=True) await aos.rename(existing_thumb_path, thumb_path) if ( verify and thumb_path.exists() and not await asyncio.get_running_loop().run_in_executor( None, _verify_thumbnail, thumb_path ) ): _LOGGER.warning( "Corrupted event %s file for event (%s), redownloading", thumb_type, event.id, ) await aos.remove(thumb_path) if not thumb_path.exists(): _LOGGER.debug( "Download event %s %s: %s %s: %s", thumb_type, event.id, event.start, event.event_type, thumb_path, ) event_id = str(event.id) if animated: thumbnail = await ctx.protect.get_event_animated_thumbnail(event_id) else: thumbnail = await ctx.protect.get_event_thumbnail(event_id) if thumbnail is not None: await aos.makedirs(thumb_path.parent, exist_ok=True) async with aiofiles.open(thumb_path, mode="wb") as f: await f.write(thumbnail) return True return False def _verify_video_file( # type: ignore[return] path: Path, length: float, width: int, height: int, title: str, ) -> tuple[bool, bool]: try: with av.open(str(path)) as video: slength = float( video.streams.video[0].duration * video.streams.video[0].time_base, # type: ignore[operator] ) valid = ( (slength / length) > 0.80 # export is fuzzy and video.streams.video[0].codec_context.width == width and video.streams.video[0].codec_context.height == height ) metadata_valid = False if valid: metadata_valid = bool(video.metadata["title"] == title) return valid, metadata_valid # no docs on what exception could be except Exception: return False, False def _add_metadata(path: Path, creation: datetime, title: str) -> bool: creation = local_datetime(creation) output_path = path.parent / path.name.replace(".mp4", ".metadata.mp4") success = True try: with ( av.open(str(path)) as input_file, av.open(str(output_path), "w") as output_file, ): for key, value in input_file.metadata.items(): output_file.metadata[key] = value output_file.metadata["creation_time"] = creation.isoformat() output_file.metadata["title"] = title output_file.metadata["year"] = creation.date().isoformat() output_file.metadata["release"] = creation.date().isoformat() in_to_out: dict[str, Any] = {} for stream in input_file.streams: in_to_out[stream] = output_file.add_stream(template=stream) # type: ignore[index,call-overload] in_to_out[stream].metadata["creation_time"] = creation.isoformat() # type: ignore[index] for packet in input_file.demux(list(in_to_out)): if packet.dts is None: continue packet.stream = in_to_out[packet.stream] # type: ignore[index] try: output_file.mux(packet) # type: ignore[arg-type] # some frames may be corrupted on disk from NVR except ValueError: continue # no docs on what exception could be except Exception: success = False finally: if success: path.unlink() output_path.rename(path) elif output_path.exists(): output_path.unlink() return success async def _download_event_video( ctx: BackupContext, camera: d.Camera, event: Event, verify: bool, force: bool, ) -> bool: event_path = event.get_event_path(ctx) existing_event_path = event.get_existing_event_path(ctx) if force and existing_event_path: _LOGGER.debug("Delete file %s", existing_event_path) await aos.remove(existing_event_path) if existing_event_path and str(existing_event_path) != str(event_path): _LOGGER.debug( "Rename event file %s: %s %s %s: %s", event.id, event.start, event.end, event.event_type, event_path, ) await aos.makedirs(event_path.parent, exist_ok=True) await aos.rename(existing_event_path, event_path) metadata_valid = True if verify and event_path.exists(): valid = False if event.end is not None: valid, metadata_valid = await asyncio.get_running_loop().run_in_executor( None, _verify_video_file, event_path, (event.end - event.start).total_seconds(), camera.channels[0].width, camera.channels[0].height, event.get_file_context(ctx)["title"], ) if not valid: _LOGGER.warning( "Corrupted video file for event (%s), redownloading", event.id, ) await aos.remove(event_path) downloaded = False if not event_path.exists() and event.end is not None: _LOGGER.debug( "Download event %s: %s %s %s: %s", event.id, event.start, event.end, event.event_type, event_path, ) await aos.makedirs(event_path.parent, exist_ok=True) await camera.get_video(event.start, event.end, output_file=event_path) downloaded = True if (downloaded or not metadata_valid) and event.end is not None: file_context = event.get_file_context(ctx) if not await asyncio.get_running_loop().run_in_executor( None, _add_metadata, event_path, event.start, file_context["title"] ): _LOGGER.warning("Failed to write metadata for event (%s)", event.id) return downloaded async def _download_event( ctx: BackupContext, event: Event, verify: bool, force: bool, pb: Progress, ) -> bool: downloaded = False camera = ctx.protect.bootstrap.get_device_from_mac(event.camera_mac) # type: ignore[arg-type] if camera is not None: camera = cast("d.Camera", camera) downloads = [] if ctx.download_thumbnails: downloads.append(_download_event_thumb(ctx, event, verify, force)) if ctx.download_gifs: downloads.append( _download_event_thumb(ctx, event, verify, force, animated=True), ) if ctx.download_thumbnails: downloads.append(_download_event_video(ctx, camera, event, verify, force)) downloaded = any(await asyncio.gather(*downloads)) pb.update(pb.tasks[0].id, advance=1) return downloaded async def _download_events( ctx: BackupContext, event_types: list[d.EventType], smart_types: list[d.SmartDetectObjectType], verify: bool, force: bool, ) -> tuple[int, int]: start = ctx.start end = ctx.end or utc_now() db = ctx.create_db_session() async with db: count_query = ( select(func.count(Event.id)) .where(Event.event_type.in_([e.value for e in event_types])) .where(Event.start_naive >= start) .where(or_(Event.end_naive <= end, Event.end_naive is None)) # type: ignore[arg-type] ) count = cast("int", (await db.execute(count_query)).scalar()) _LOGGER.info("Downloading %s events", count) columns = [ TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), MofNCompleteColumn(), TimeRemainingColumn(), ] with Progress(*columns) as pb: task_id = pb.add_task("Downloading Events", total=count) query = ( select(Event) .where(Event.event_type.in_([e.value for e in event_types])) .where(Event.start_naive >= start) .where(or_(Event.end_naive <= end, Event.end_naive is None)) # type: ignore[arg-type] .limit(ctx.page_size) ) smart_types_set = {s.value for s in smart_types} loop = asyncio.get_running_loop() tasks: _DownloadEventQueue = asyncio.Queue(maxsize=ctx.max_download - 1) no_error_flag = asyncio.Event() no_error_flag.set() watcher_task = loop.create_task( _download_watcher(count, tasks, no_error_flag), ) offset = 0 page = query while offset < count: result = await db.execute(page) for event in result.unique().scalars(): if event.end is None: continue length = event.end - event.start if length > ctx.length_cutoff: _LOGGER.warning( "Skipping event %s because it is too long (%s)", event.id, length, ) await tasks.put(QueuedDownload(task=None, args=[])) continue # ensure no tasks are currently in a retry state await no_error_flag.wait() if event.event_type in { d.EventType.SMART_DETECT.value, d.EventType.SMART_DETECT_LINE.value, } and not event.smart_types.intersection(smart_types_set): continue task = loop.create_task( _download_event(ctx, event, verify, force, pb), ) # waits for a free processing slot await tasks.put( QueuedDownload(task=task, args=[ctx, event, verify, force, pb]), ) offset += ctx.page_size page = query.offset(offset) try: await watcher_task downloaded = watcher_task.result() except asyncio.CancelledError: downloaded = 0 pb.update(task_id, completed=count) return count, downloaded async def _events( ctx: BackupContext, event_types: list[d.EventType], smart_types: list[d.SmartDetectObjectType], prune: bool, force: bool, verify: bool, no_input: bool, ) -> None: try: await ctx.create_db() if prune and not force: _LOGGER.warning("Pruned %s old event(s)", await _prune_events(ctx)) original_start = ctx.start if not force: event = await _newest_event(ctx) if event is not None: ctx.start = event.start _LOGGER.warning("Updated %s event(s)", await _update_events(ctx)) ctx.start = original_start count, downloaded = await _download_events( ctx, event_types, smart_types, verify, force, ) verified = count - downloaded _LOGGER.warning( "Total events: %s. Verified %s existing event(s). Downloaded %s new event(s)", count, verified, downloaded, ) finally: _LOGGER.debug("Cleaning up Protect connection/database...") await ctx.protect.close_session() await ctx.protect.close_public_api_session() await ctx.db_engine.dispose() @app.command(name="events") def events_cmd( ctx: typer.Context, event_types: list[EventTypeChoice] = OPTION_EVENT_TYPES, smart_types: list[d.SmartDetectObjectType] = OPTION_SMART_TYPES, prune: bool = typer.Option( False, "-p", "--prune", help="Prune events older then start.", ), force: bool = typer.Option( False, "-f", "--force", help="Force update all events and redownload all clips.", ), verify: bool = typer.Option( False, "-v", "--verify", help="Verifies files on disk.", ), no_input: bool = typer.Option(False, "--no-input"), ) -> None: """Backup thumbnails and video clips for camera events.""" # suppress av logging messages av.logging.set_level(av.logging.PANIC) ufp_events = [d.EventType(e.value) for e in event_types] if prune and force: _wipe_files(ctx.obj, no_input) asyncio.run( _events(ctx.obj, ufp_events, smart_types, prune, force, verify, no_input), ) uilibs-uiprotect-317a52c/src/uiprotect/cli/base.py000066400000000000000000000177471524637705700222440ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from enum import StrEnum from typing import Any, TypeVar import orjson import typer from pydantic import ValidationError from ..api import ProtectApiClient from ..data import ( NVR, AiPort, Camera, Chime, Light, ProtectAdoptableDeviceModel, ProtectBaseObject, Sensor, Viewer, ) from ..exceptions import BadRequest, NvrError, StreamError from ..utils import run_async T = TypeVar("T") OPTION_FORCE = typer.Option(False, "-f", "--force", help="Skip confirmation prompt") class OutputFormatEnum(StrEnum): JSON = "json" PLAIN = "plain" @dataclass class CliContext: protect: ProtectApiClient output_format: OutputFormatEnum def run(ctx: typer.Context, func: Awaitable[T]) -> T: """Helper method to call async function and clean up API client""" async def callback() -> T: try: return await func finally: await ctx.obj.protect.close_session() await ctx.obj.protect.close_public_api_session() try: return run_async(callback()) except (BadRequest, ValidationError, StreamError, NvrError) as err: typer.secho(str(err), fg="red") raise typer.Exit(1) from err def json_output(obj: Any) -> None: typer.echo(orjson.dumps(obj, option=orjson.OPT_INDENT_2).decode("utf-8")) def print_unifi_obj( obj: ProtectBaseObject | None, output_format: OutputFormatEnum, ) -> None: """Helper method to print a single protect object""" if obj is not None: json_output(obj.unifi_dict()) elif output_format == OutputFormatEnum.JSON: json_output(None) def print_unifi_list( objs: Sequence[ProtectBaseObject], output_format: OutputFormatEnum = OutputFormatEnum.JSON, ) -> None: """Helper method to print a list of protect objects""" if objs: json_output([o.unifi_dict() for o in objs]) elif output_format == OutputFormatEnum.JSON: json_output([]) def print_unifi_dict(objs: Mapping[str, ProtectBaseObject]) -> None: """Helper method to print a dictionary of protect objects""" data = {k: v.unifi_dict() for k, v in objs.items()} json_output(data) def require_device_id(ctx: typer.Context) -> None: """Requires device ID in context""" if ctx.obj.device is None: typer.secho("Requires a valid device ID to be selected") raise typer.Exit(1) def require_no_device_id(ctx: typer.Context) -> None: """Requires no device ID in context""" if ctx.obj.device is not None: typer.secho("Requires no device ID to be selected") raise typer.Exit(1) def list_ids(ctx: typer.Context) -> None: """Requires no device ID. Prints list of "id name" for each device.""" require_no_device_id(ctx) objs: dict[str, ProtectAdoptableDeviceModel] = ctx.obj.devices to_print: list[tuple[str, str | None]] = [] for obj in objs.values(): name = obj.display_name if obj.is_adopted_by_other: name = f"{name} [Managed by Another Console]" elif obj.is_adopting: name = f"{name} [Adopting]" elif obj.can_adopt: name = f"{name} [Unadopted]" elif obj.is_rebooting: name = f"{name} [Restarting]" elif obj.is_updating: name = f"{name} [Updating]" elif not obj.is_connected: name = f"{name} [Disconnected]" to_print.append((obj.id, name)) if ctx.obj.output_format == OutputFormatEnum.JSON: json_output(to_print) else: for item in to_print: typer.echo(f"{item[0]}\t{item[1]}") def protect_url(ctx: typer.Context) -> None: """Gets UniFi Protect management URL.""" require_device_id(ctx) obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device if ctx.obj.output_format == OutputFormatEnum.JSON: json_output(obj.protect_url) else: typer.echo(obj.protect_url) def is_wired(ctx: typer.Context) -> None: """Returns if the device is wired or not.""" require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device json_output(obj.is_wired) def is_wifi(ctx: typer.Context) -> None: """Returns if the device has WiFi or not.""" require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device json_output(obj.is_wifi) def is_bluetooth(ctx: typer.Context) -> None: """Returns if the device has Bluetooth or not.""" require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device json_output(obj.is_bluetooth) def bridge(ctx: typer.Context) -> None: """Returns bridge device if connected via Bluetooth.""" require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device print_unifi_obj(obj.bridge, ctx.obj.output_format) def set_ssh(ctx: typer.Context, enabled: bool) -> None: """ Sets the isSshEnabled value for device. May not have an effect on many device types. Only seems to work for Linux and BusyBox based devices (camera, light and viewport). """ require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device run(ctx, obj.set_ssh(enabled)) def set_name(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None: """Sets name for the device""" require_device_id(ctx) obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device # AiPort subclasses Camera but has no public-API endpoint of its own, and # the public API cannot express clearing a name, so both keep the private # path. if ( name is not None and isinstance(obj, (Camera, Chime, Light, Sensor, Viewer)) and not isinstance(obj, AiPort) ): run(ctx, obj.set_name_public(name)) return run(ctx, obj.set_name(name)) def update(ctx: typer.Context, data: str) -> None: """ Updates the device. Makes a raw PATCH request to update a device. Advanced usage and usually recommended not to use. """ require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device if obj.model is not None: run(ctx, obj.api.update_device(obj.model, obj.id, orjson.loads(data))) def reboot(ctx: typer.Context, force: bool = OPTION_FORCE) -> None: """Reboots the device.""" require_device_id(ctx) obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device if force or typer.confirm(f'Confirm reboot of "{obj.name}"" (id: {obj.id})'): run(ctx, obj.reboot()) def unadopt(ctx: typer.Context, force: bool = OPTION_FORCE) -> None: """Unadopt/Unmanage adopted device.""" require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device if force or typer.confirm(f'Confirm undopt of "{obj.name}"" (id: {obj.id})'): run(ctx, obj.unadopt()) def adopt(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None: """ Adopts a device. By default, unadopted devices do not show up in the bootstrap. Use `uiprotect -u` to show unadopted devices. """ require_device_id(ctx) obj: ProtectAdoptableDeviceModel = ctx.obj.device run(ctx, obj.adopt(name)) def init_common_commands( app: typer.Typer, ) -> tuple[dict[str, Callable[..., Any]], dict[str, Callable[..., Any]]]: deviceless_commands: dict[str, Callable[..., Any]] = {} device_commands: dict[str, Callable[..., Any]] = {} deviceless_commands["list-ids"] = app.command()(list_ids) device_commands["is-wired"] = app.command()(is_wired) device_commands["is-wifi"] = app.command()(is_wifi) device_commands["is-bluetooth"] = app.command()(is_bluetooth) device_commands["bridge"] = app.command()(bridge) device_commands["set-ssh"] = app.command()(set_ssh) device_commands["set-name"] = app.command()(set_name) device_commands["update"] = app.command()(update) device_commands["reboot"] = app.command()(reboot) device_commands["unadopt"] = app.command()(unadopt) device_commands["adopt"] = app.command()(adopt) return deviceless_commands, device_commands uilibs-uiprotect-317a52c/src/uiprotect/cli/bridges.py000066400000000000000000000021571524637705700227360ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_BRIDGE_ID = typer.Argument(..., help="Bridge ID") @app.callback() def main(ctx: typer.Context) -> None: """Bridge commands (Public API).""" @app.command("list") def list_bridges(ctx: typer.Context) -> None: """List bridges.""" async def _fetch() -> None: items = await ctx.obj.protect.get_bridges_public() base.print_unifi_list(items, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, bridge_id: str = ARG_BRIDGE_ID) -> None: """Show details for a specific bridge.""" async def _fetch() -> None: obj = await ctx.obj.protect.get_bridge_public(bridge_id) base.print_unifi_obj(obj, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def set_name( ctx: typer.Context, bridge_id: str = ARG_BRIDGE_ID, name: str = typer.Argument(..., help="New name for the bridge"), ) -> None: """Rename a bridge.""" base.run(ctx, ctx.obj.protect.update_bridge_public(bridge_id, name=name)) uilibs-uiprotect-317a52c/src/uiprotect/cli/cameras.py000066400000000000000000000535571524637705700227440ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import cast import typer from rich.progress import Progress from .. import data as d from ..api import ProtectApiClient from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_DEVICE_ID = typer.Argument(None, help="ID of camera to select for subcommands") @dataclass class CameraContext(base.CliContext): devices: dict[str, d.Camera] device: d.Camera | None = None ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) @app.callback(invoke_without_command=True) def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: """ Camera device CLI. Returns full list of Cameras without any arguments passed. """ protect: ProtectApiClient = ctx.obj.protect context = CameraContext( protect=ctx.obj.protect, device=None, devices=protect.bootstrap.cameras, output_format=ctx.obj.output_format, ) ctx.obj = context if device_id is not None and device_id not in ALL_COMMANDS: if (device := protect.bootstrap.cameras.get(device_id)) is None: typer.secho("Invalid camera ID", fg="red") raise typer.Exit(1) ctx.obj.device = device if not ctx.invoked_subcommand: if device_id in ALL_COMMANDS: ctx.invoke(ALL_COMMANDS[device_id], ctx) return if ctx.obj.device is not None: base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) return base.print_unifi_dict(ctx.obj.devices) @app.command() def timelapse_url(ctx: typer.Context) -> None: """Returns UniFi Protect timelapse URL.""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if ctx.obj.output_format == base.OutputFormatEnum.JSON: base.json_output(obj.timelapse_url) else: typer.echo(obj.timelapse_url) @app.command() def privacy_mode( ctx: typer.Context, enabled: bool | None = typer.Argument(None), ) -> None: """ Returns/sets library managed privacy mode. Does not change the microphone sensitivity or recording mode. It must be changed separately. """ base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if enabled is None: base.json_output(obj.is_privacy_on) return base.run(ctx, obj.set_privacy(enabled)) @app.command() def chime_type(ctx: typer.Context, value: d.ChimeType | None = None) -> None: """Returns/sets the current chime type if the camera has a chime.""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if not obj.feature_flags.has_chime: typer.secho("Camera does not have a chime", fg="red") raise typer.Exit(1) if value is None: if ctx.obj.output_format == base.OutputFormatEnum.JSON: base.json_output(obj.chime_type) elif obj.chime_type is not None: typer.echo(obj.chime_type.name) return base.run(ctx, obj.set_chime_type(value)) @app.command() def stream_urls(ctx: typer.Context) -> None: """Returns all of the enabled RTSP(S) URLs.""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device data: list[tuple[str, str]] = [] for channel in obj.channels: if channel.is_rtsp_enabled: rtsp_url = cast("str", channel.rtsp_url) rtsps_url = cast("str", channel.rtsps_url) data.extend( ( (f"{channel.name} RTSP", rtsp_url), (f"{channel.name} RTSPS", rtsps_url), ), ) if ctx.obj.output_format == base.OutputFormatEnum.JSON: base.json_output(data) else: for name, url in data: typer.echo(f"{name:20}\t{url}") @app.command() def save_snapshot( ctx: typer.Context, output_path: Path = typer.Argument(..., help="JPEG format"), width: int | None = typer.Option(None, "-w", "--width"), height: int | None = typer.Option(None, "-h", "--height"), dt: datetime | None = typer.Option(None, "-t", "--timestamp"), package: bool = typer.Option(False, "-p", "--package", help="Get package camera"), ) -> None: """ Takes snapshot of camera. If you specify a timestamp, they are approximate. It will not export with down to the second accuracy so it may be +/- a few seconds. Timestamps use your locale timezone. If it is not configured correctly, it will default to UTC. You can override your timezone with the TZ environment variable. """ base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if dt is not None: local_tz = datetime.now(UTC).astimezone().tzinfo dt = dt.replace(tzinfo=local_tz) if package: if not obj.feature_flags.has_package_camera: typer.secho("Camera does not have package camera", fg="red") raise typer.Exit(1) snapshot = base.run(ctx, obj.get_package_snapshot(width, height, dt=dt)) else: snapshot = base.run(ctx, obj.get_snapshot(width, height, dt=dt)) if snapshot is None: typer.secho("Could not get snapshot", fg="red") raise typer.Exit(1) Path(output_path).write_bytes(snapshot) @app.command() def save_video( ctx: typer.Context, output_path: Path = typer.Argument(..., help="MP4 format"), start: datetime = typer.Argument(...), end: datetime = typer.Argument(...), channel: int = typer.Option( 0, "-c", "--channel", min=0, max=3, help="0 = High, 1 = Medium, 2 = Low, 3 = Package", ), fps: int | None = typer.Option( None, "--fps", min=1, max=40, help="Export as timelapse. 4 = 60x, 8 = 120x, 20 = 300x, 40 = 600x", ), ) -> None: """ Exports video of camera. Exports are approximate. It will not export with down to the second accuracy so it may be +/- a few seconds. Uses your locale timezone. If it is not configured correctly, it will default to UTC. You can override your timezone with the TZ environment variable. """ base.require_device_id(ctx) obj: d.Camera = ctx.obj.device local_tz = datetime.now(UTC).astimezone().tzinfo start = start.replace(tzinfo=local_tz) end = end.replace(tzinfo=local_tz) if channel == 4 and not obj.feature_flags.has_package_camera: typer.secho("Camera does not have package camera", fg="red") raise typer.Exit(1) with Progress() as pb: task_id = pb.add_task("(1/2) Exporting", total=100) async def callback(step: int, current: int, total: int) -> None: pb.update( task_id, total=total, completed=current, description="(2/2) Downloading", ) base.run( ctx, obj.get_video( start, end, channel, output_file=output_path, progress_callback=callback, fps=fps, ), ) @app.command() def play_audio( ctx: typer.Context, url: str = typer.Argument(..., help="URL or path to audio file"), ) -> None: """Plays audio file on camera speaker.""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.play_audio(url)) @app.command() def smart_detects( ctx: typer.Context, values: list[d.SmartDetectObjectType] = typer.Argument( None, help="Set to [] to empty list of detect types.", ), add: bool = typer.Option(False, "-a", "--add", help="Add values instead of set"), remove: bool = typer.Option( False, "-r", "--remove", help="Remove values instead of set", ), ) -> None: """Returns/set smart detect types for camera.""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if add and remove: typer.secho("Add and remove are mutually exclusive", fg="red") raise typer.Exit(1) if not obj.feature_flags.has_smart_detect: typer.secho("Camera does not support smart detections", fg="red") raise typer.Exit(1) if len(values) == 0: if ctx.obj.output_format == base.OutputFormatEnum.JSON: base.json_output(obj.smart_detect_settings.object_types) else: for value in obj.smart_detect_settings.object_types: typer.echo(value.value) return if len(values) == 1 and values[0] == "[]": values = [] for value in values: if value not in obj.feature_flags.smart_detect_types: typer.secho(f"Camera does not support {value}", fg="red") raise typer.Exit(1) if add: values = list(set(obj.smart_detect_settings.object_types) | set(values)) elif remove: values = list(set(obj.smart_detect_settings.object_types) - set(values)) protect: ProtectApiClient = ctx.obj.protect base.run( ctx, protect.update_camera_public(obj.id, smart_detect_object_types=values), ) @app.command() def smart_audio_detects( ctx: typer.Context, values: list[d.SmartDetectAudioType] = typer.Argument( None, help="Set to [] to empty list of detect types.", ), add: bool = typer.Option(False, "-a", "--add", help="Add values instead of set"), remove: bool = typer.Option( False, "-r", "--remove", help="Remove values instead of set", ), ) -> None: """Returns/set smart detect types for camera.""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if add and remove: typer.secho("Add and remove are mutually exclusive", fg="red") raise typer.Exit(1) if not obj.feature_flags.has_smart_detect: typer.secho("Camera does not support smart detections", fg="red") raise typer.Exit(1) obj.smart_detect_settings.audio_types = obj.smart_detect_settings.audio_types or [] obj.smart_detect_settings.audio_types = obj.smart_detect_settings.audio_types or [] if len(values) == 0: if ctx.obj.output_format == base.OutputFormatEnum.JSON: base.json_output(obj.smart_detect_settings.audio_types) else: for value in obj.smart_detect_settings.audio_types: typer.echo(value.value) return if len(values) == 1 and values[0] == "[]": values = [] for value in values: if ( obj.feature_flags.smart_detect_audio_types is None or value not in obj.feature_flags.smart_detect_audio_types ): typer.secho(f"Camera does not support {value}", fg="red") raise typer.Exit(1) if add: values = list(set(obj.smart_detect_settings.audio_types) | set(values)) elif remove: values = list(set(obj.smart_detect_settings.audio_types) - set(values)) protect: ProtectApiClient = ctx.obj.protect base.run( ctx, protect.update_camera_public(obj.id, smart_detect_audio_types=values), ) @app.command() def set_motion_detection(ctx: typer.Context, enabled: bool) -> None: """Sets motion detection on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_motion_detection(enabled)) @app.command() def set_recording_mode(ctx: typer.Context, mode: d.RecordingMode) -> None: """Sets recording mode on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_recording_mode(mode)) @app.command() def set_ir_led_mode(ctx: typer.Context, mode: d.IRLEDMode) -> None: """Sets IR LED mode on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_ir_led_model(mode)) @app.command() def set_status_light(ctx: typer.Context, enabled: bool) -> None: """Sets status indicicator light on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_status_light_public(enabled)) @app.command() def set_hdr(ctx: typer.Context, mode: d.PublicHdrMode) -> None: """Sets HDR (High Dynamic Range) mode on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_hdr_mode_public(mode)) @app.command() def set_color_night_vision(ctx: typer.Context, enabled: bool) -> None: """Sets Color Night Vision on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_color_night_vision(enabled=enabled)) @app.command() def set_person_track(ctx: typer.Context, enabled: bool) -> None: """Sets person tracking on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if not obj.feature_flags.is_ptz: typer.secho("Camera does not support person tracking", fg="red") raise typer.Exit(1) base.run(ctx, (obj.set_person_track(enabled=enabled))) @app.command() def set_video_mode(ctx: typer.Context, mode: d.VideoMode) -> None: """Sets video mode on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_video_mode_public(mode)) @app.command() def set_camera_zoom( ctx: typer.Context, level: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets zoom level for camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_camera_zoom(level)) @app.command() def set_wdr_level( ctx: typer.Context, level: int = typer.Argument(..., min=0, max=3), ) -> None: """Sets WDR (Wide Dynamic Range) on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_wdr_level(level)) @app.command() def set_mic_volume( ctx: typer.Context, level: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets the mic sensitivity level on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_mic_volume_public(level)) @app.command() def set_speaker_volume( ctx: typer.Context, level: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets the speaker output volume on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_speaker_volume(level)) @app.command() def set_volume( ctx: typer.Context, level: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets the general volume level on camera""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_volume(level)) @app.command() def set_ring_volume( ctx: typer.Context, level: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets the doorbell ring volume""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_ring_volume(level)) @app.command() def set_system_sounds(ctx: typer.Context, enabled: bool) -> None: """Sets system sound playback through speakers""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_system_sounds(enabled)) @app.command() def set_osd_name(ctx: typer.Context, enabled: bool) -> None: """Sets whether camera name is in the On Screen Display""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_osd_name_public(enabled)) @app.command() def set_osd_date(ctx: typer.Context, enabled: bool) -> None: """Sets whether current date is in the On Screen Display""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_osd_date_public(enabled)) @app.command() def set_osd_logo(ctx: typer.Context, enabled: bool) -> None: """Sets whether the UniFi logo is in the On Screen Display""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_osd_logo_public(enabled)) @app.command() def set_osd_bitrate(ctx: typer.Context, enabled: bool) -> None: """Sets whether camera bitrate is in the On Screen Display""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device base.run(ctx, obj.set_osd_nerd_mode_public(enabled)) @app.command() def set_lcd_text( ctx: typer.Context, text_type: d.DoorbellMessageType | None = typer.Argument( None, help="No value sets it back to the global default doorbell message.", ), text: str | None = typer.Argument( None, help="Only for CUSTOM_MESSAGE text type", ), reset_at: datetime | None = typer.Option( None, "-r", "--reset-time", help="Does not apply to default message", ), ) -> None: """ Sets doorbell LCD text. Uses your locale timezone. If it is not configured correctly, it will default to UTC. You can override your timezone with the TZ environment variable. """ if reset_at is not None: local_tz = datetime.now(UTC).astimezone().tzinfo reset_at = reset_at.replace(tzinfo=local_tz) base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if text_type is None: # The public setter rejects a reset time on a clear; say so here rather # than dropping the option on the floor. if reset_at is not None: typer.secho( "--reset-time does not apply when clearing the message", fg="red" ) raise typer.Exit(1) base.run(ctx, obj.set_lcd_message_public(None)) return base.run(ctx, obj.set_lcd_message_public(text_type, text, reset_at)) @app.command() def create_rtsps_streams( ctx: typer.Context, qualities: list[str] = typer.Argument( ..., help="List of stream qualities to create (e.g., high medium low)", ), ) -> None: """ Creates RTSPS streams for camera. Available qualities are typically: high, medium, low, ultra. Requires API key authentication and public API access. """ base.require_device_id(ctx) obj: d.Camera = ctx.obj.device async def create_streams() -> None: try: result = await obj.create_rtsps_streams(qualities) if result is None: typer.secho("Failed to create RTSPS streams", fg="red") raise typer.Exit(1) if ctx.obj.output_format == base.OutputFormatEnum.JSON: stream_data = { quality: result.get_stream_url(quality) for quality in result.get_available_stream_qualities() } base.json_output(stream_data) else: for quality in result.get_available_stream_qualities(): url = result.get_stream_url(quality) typer.echo(f"{quality:10}\t{url}") except Exception as e: typer.secho(f"Error creating RTSPS streams: {e}", fg="red") raise typer.Exit(1) from e base.run(ctx, create_streams()) @app.command() def get_rtsps_streams(ctx: typer.Context) -> None: """ Gets existing RTSPS streams for camera. Requires API key authentication and public API access. """ base.require_device_id(ctx) obj: d.Camera = ctx.obj.device async def get_streams() -> None: try: result = await obj.get_rtsps_streams() if result is None: typer.secho("No RTSPS streams found or failed to retrieve", fg="yellow") return if ctx.obj.output_format == base.OutputFormatEnum.JSON: stream_data = { quality: result.get_stream_url(quality) for quality in result.get_available_stream_qualities() } base.json_output(stream_data) else: available_qualities = result.get_available_stream_qualities() if not available_qualities: typer.echo("No RTSPS streams available") else: for quality in available_qualities: url = result.get_stream_url(quality) typer.echo(f"{quality:10}\t{url}") except Exception as e: typer.secho(f"Error getting RTSPS streams: {e}", fg="red") raise typer.Exit(1) from e base.run(ctx, get_streams()) @app.command() def delete_rtsps_streams( ctx: typer.Context, qualities: list[str] = typer.Argument( ..., help="List of stream qualities to delete (e.g., high medium low)", ), ) -> None: """ Deletes RTSPS streams for camera. Requires API key authentication and public API access. """ base.require_device_id(ctx) obj: d.Camera = ctx.obj.device async def delete_streams() -> None: try: result = await obj.delete_rtsps_streams(qualities) if result: typer.secho( f"Successfully deleted RTSPS streams: {', '.join(qualities)}", fg="green", ) else: typer.secho( f"Failed to delete RTSPS streams: {', '.join(qualities)}", fg="red" ) raise typer.Exit(1) except Exception as e: typer.secho(f"Error deleting RTSPS streams: {e}", fg="red") raise typer.Exit(1) from e base.run(ctx, delete_streams()) @app.command("disable-mic-permanently") def disable_mic_permanently( ctx: typer.Context, yes: bool = typer.Option( False, "--yes", "-y", help="Skip the interactive confirmation prompt", ), ) -> None: """Permanently disable the camera microphone (irreversible).""" base.require_device_id(ctx) obj: d.Camera = ctx.obj.device if not yes and not typer.confirm( f"Permanently disable the microphone on {obj.id}? " "This cannot be undone without resetting the camera." ): typer.secho("Aborted.", fg="yellow") raise typer.Exit(1) async def _disable() -> None: await ctx.obj.protect.disable_camera_mic_permanently_public(obj.id) base.run(ctx, _disable()) uilibs-uiprotect-317a52c/src/uiprotect/cli/chimes.py000066400000000000000000000125451524637705700225710ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass import typer from ..api import ProtectApiClient from ..cli import base from ..data import Chime app = typer.Typer(rich_markup_mode="rich") ARG_DEVICE_ID = typer.Argument(None, help="ID of chime to select for subcommands") ARG_REPEAT = typer.Argument(..., help="Repeat times count", min=1, max=6) ARG_VOLUME = typer.Argument(..., help="Volume", min=1, max=100) @dataclass class ChimeContext(base.CliContext): devices: dict[str, Chime] device: Chime | None = None ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) @app.callback(invoke_without_command=True) def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: """ Chime device CLI. Returns full list of Chimes without any arguments passed. """ protect: ProtectApiClient = ctx.obj.protect context = ChimeContext( protect=ctx.obj.protect, device=None, devices=protect.bootstrap.chimes, output_format=ctx.obj.output_format, ) ctx.obj = context if device_id is not None and device_id not in ALL_COMMANDS: if (device := protect.bootstrap.chimes.get(device_id)) is None: typer.secho("Invalid chime ID", fg="red") raise typer.Exit(1) ctx.obj.device = device if not ctx.invoked_subcommand: if device_id in ALL_COMMANDS: ctx.invoke(ALL_COMMANDS[device_id], ctx) return if ctx.obj.device is not None: base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) return base.print_unifi_dict(ctx.obj.devices) @app.command() def cameras( ctx: typer.Context, camera_ids: list[str] = typer.Argument( None, help="Set to [] to empty list of cameras", ), add: bool = typer.Option(False, "-a", "--add", help="Add cameras instead of set"), remove: bool = typer.Option( False, "-r", "--remove", help="Remove cameras instead of set", ), ) -> None: """Returns or sets paired doorbells for the chime.""" base.require_device_id(ctx) obj: Chime = ctx.obj.device if add and remove: typer.secho("Add and remove are mutually exclusive", fg="red") raise typer.Exit(1) if len(camera_ids) == 0: base.print_unifi_list(obj.cameras) return protect: ProtectApiClient = ctx.obj.protect if len(camera_ids) == 1 and camera_ids[0] == "[]": camera_ids = [] for camera_id in camera_ids: if (camera := protect.bootstrap.cameras.get(camera_id)) is None: typer.secho(f"Invalid camera ID: {camera_id}", fg="red") raise typer.Exit(1) if not camera.feature_flags.is_doorbell: typer.secho(f"Camera is not a doorbell: {camera_id}", fg="red") raise typer.Exit(1) if add: camera_ids = list(set(obj.camera_ids) | set(camera_ids)) elif remove: camera_ids = list(set(obj.camera_ids) - set(camera_ids)) base.run(ctx, protect.update_chime_public(obj.id, camera_ids=camera_ids)) @app.command() def set_volume( ctx: typer.Context, value: int = ARG_VOLUME, camera_id: str | None = typer.Option( None, "-c", "--camera", help="Camera ID to apply volume to", ), ) -> None: """Set volume level for chime rings.""" base.require_device_id(ctx) obj: Chime = ctx.obj.device protect: ProtectApiClient = ctx.obj.protect if camera_id is None: ring_settings = [s.to_api_dict(volume=value) for s in obj.ring_settings] base.run(ctx, protect.update_chime_public(obj.id, ring_settings=ring_settings)) else: camera = protect.bootstrap.cameras.get(camera_id) if camera is None: typer.secho(f"Invalid camera ID: {camera_id}", fg="red") raise typer.Exit(1) base.run(ctx, obj.set_volume_for_camera_public(camera, value)) @app.command() def play( ctx: typer.Context, volume: int | None = typer.Option(None, "-v", "--volume", min=1, max=100), repeat_times: int | None = typer.Option(None, "-r", "--repeat", min=1, max=6), ) -> None: """Plays chime tone.""" base.require_device_id(ctx) obj: Chime = ctx.obj.device base.run(ctx, obj.play(volume=volume, repeat_times=repeat_times)) @app.command() def play_buzzer(ctx: typer.Context) -> None: """Plays chime buzzer.""" base.require_device_id(ctx) obj: Chime = ctx.obj.device base.run(ctx, obj.play_buzzer()) @app.command() def set_repeat_times( ctx: typer.Context, value: int = ARG_REPEAT, camera_id: str | None = typer.Option( None, "-c", "--camera", help="Camera ID to apply repeat times to", ), ) -> None: """Set number of times for a chime to repeat when doorbell is rang.""" base.require_device_id(ctx) obj: Chime = ctx.obj.device protect: ProtectApiClient = ctx.obj.protect if camera_id is None: ring_settings = [s.to_api_dict(repeat_times=value) for s in obj.ring_settings] base.run(ctx, protect.update_chime_public(obj.id, ring_settings=ring_settings)) else: camera = protect.bootstrap.cameras.get(camera_id) if camera is None: typer.secho(f"Invalid camera ID: {camera_id}", fg="red") raise typer.Exit(1) base.run(ctx, obj.set_repeat_times_for_camera_public(camera, value)) uilibs-uiprotect-317a52c/src/uiprotect/cli/events.py000066400000000000000000000157411524637705700226260ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from pathlib import Path import typer from rich.progress import Progress from .. import data as d from ..api import ProtectApiClient from ..cli import base from ..exceptions import NvrError from ..utils import local_datetime app = typer.Typer(rich_markup_mode="rich") ARG_EVENT_ID = typer.Argument(None, help="ID of camera to select for subcommands") OPTION_START = typer.Option(None, "-s", "--start") OPTION_END = typer.Option(None, "-e", "--end") OPTION_LIMIT = typer.Option(None, "-l", "--limit") OPTION_OFFSET = typer.Option(None, "-o", "--offet") OPTION_TYPES = typer.Option(None, "-t", "--type") OPTION_SMART_TYPES = typer.Option( None, "-d", "--smart-detect", help="If provided, will only return smartDetectZone events", ) @dataclass class EventContext(base.CliContext): events: dict[str, d.Event] | None = None event: d.Event | None = None ALL_COMMANDS: dict[str, Callable[..., None]] = {} @app.callback(invoke_without_command=True) def main( ctx: typer.Context, event_id: str | None = ARG_EVENT_ID, start: datetime | None = OPTION_START, end: datetime | None = OPTION_END, limit: int | None = OPTION_LIMIT, offset: int | None = OPTION_OFFSET, types: list[d.EventType] | None = OPTION_TYPES, smart_types: list[d.SmartDetectObjectType] | None = OPTION_SMART_TYPES, ) -> None: """ Events CLI. Returns list of events from the last 24 hours without any arguments passed. """ protect: ProtectApiClient = ctx.obj.protect context = EventContext( protect=ctx.obj.protect, event=None, events=None, output_format=ctx.obj.output_format, ) ctx.obj = context if event_id is not None and event_id not in ALL_COMMANDS: try: ctx.obj.event = base.run(ctx, protect.get_event(event_id)) except NvrError as err: typer.secho("Invalid event ID", fg="red") raise typer.Exit(1) from err if not ctx.invoked_subcommand: if ctx.obj.event is not None: base.print_unifi_obj(ctx.obj.event, ctx.obj.output_format) return if types is not None and len(types) == 0: types = None if smart_types is not None and len(smart_types) == 0: smart_types = None events = base.run( ctx, protect.get_events( start=start, end=end, limit=limit, offset=offset, types=types, smart_detect_types=smart_types, ), ) ctx.obj.events = {} for event in events: ctx.obj.events[event.id] = event if event_id in ALL_COMMANDS: ctx.invoke(ALL_COMMANDS[event_id], ctx) return base.print_unifi_dict(ctx.obj.events) def require_event_id(ctx: typer.Context) -> None: """Requires event ID in context""" if ctx.obj.event is None: typer.secho("Requires a valid event ID to be selected") raise typer.Exit(1) def require_no_event_id(ctx: typer.Context) -> None: """Requires no device ID in context""" if ctx.obj.event is not None or ctx.obj.events is None: typer.secho("Requires no event ID to be selected") raise typer.Exit(1) @app.command() def list_ids(ctx: typer.Context) -> None: """ Prints list of "id type timestamp" for each event. Timestamps displayed in your locale timezone. If it is not configured correctly, it will default to UTC. You can override your timezone with the TZ environment variable. """ require_no_event_id(ctx) objs: dict[str, d.Event] = ctx.obj.events to_print: list[tuple[str, str, datetime]] = [] longest_event = 0 for obj in objs.values(): event_type = obj.type.value if event_type in { d.EventType.SMART_DETECT.value, d.EventType.SMART_DETECT_LINE.value, }: event_type = f"{event_type}[{','.join(obj.smart_detect_types)}]" longest_event = max(len(event_type), longest_event) dt = obj.timestamp or obj.start dt = local_datetime(dt) to_print.append((obj.id, event_type, dt)) if ctx.obj.output_format == base.OutputFormatEnum.JSON: base.json_output(to_print) else: for item in to_print: typer.echo(f"{item[0]}\t{item[1]:{longest_event}}\t{item[2]}") ALL_COMMANDS["list-ids"] = list_ids @app.command() def save_thumbnail( ctx: typer.Context, output_path: Path = typer.Argument(..., help="JPEG format"), ) -> None: """ Saves thumbnail for event. Only for ring, motion and smartDetectZone events. """ require_event_id(ctx) event: d.Event = ctx.obj.event thumbnail = base.run(ctx, event.get_thumbnail()) if thumbnail is None: typer.secho("Could not get thumbnail", fg="red") raise typer.Exit(1) Path(output_path).write_bytes(thumbnail) @app.command() def save_animated_thumbnail( ctx: typer.Context, output_path: Path = typer.Argument(..., help="GIF format"), ) -> None: """ Saves animated thumbnail for event. Only for ring, motion and smartDetectZone events. """ require_event_id(ctx) event: d.Event = ctx.obj.event thumbnail = base.run(ctx, event.get_animated_thumbnail()) if thumbnail is None: typer.secho("Could not get thumbnail", fg="red") raise typer.Exit(1) Path(output_path).write_bytes(thumbnail) @app.command() def save_heatmap( ctx: typer.Context, output_path: Path = typer.Argument(..., help="PNG format"), ) -> None: """ Saves heatmap for event. Only motion events have heatmaps. """ require_event_id(ctx) event: d.Event = ctx.obj.event heatmap = base.run(ctx, event.get_heatmap()) if heatmap is None: typer.secho("Could not get heatmap", fg="red") raise typer.Exit(1) Path(output_path).write_bytes(heatmap) @app.command() def save_video( ctx: typer.Context, output_path: Path = typer.Argument(..., help="MP4 format"), channel: int = typer.Option( 0, "-c", "--channel", min=0, max=3, help="0 = High, 1 = Medium, 2 = Low, 3 = Package", ), ) -> None: """ Exports video for event. Only for ring, motion and smartDetectZone events. """ require_event_id(ctx) event: d.Event = ctx.obj.event with Progress() as pb: task_id = pb.add_task("(1/2) Exporting", total=100) async def callback(step: int, current: int, total: int) -> None: pb.update( task_id, total=total, completed=current, description="(2/2) Downloading", ) base.run( ctx, event.get_video( channel, output_file=output_path, progress_callback=callback, ), ) uilibs-uiprotect-317a52c/src/uiprotect/cli/files_public.py000066400000000000000000000036701524637705700237600ustar00rootroot00000000000000from __future__ import annotations from pathlib import Path import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_FILE_TYPE = typer.Argument(..., help="Device asset file type, e.g. 'animations'") ARG_FILE_PATH = typer.Argument( ..., help="Path to the file to upload", exists=True, dir_okay=False, readable=True, ) OPTION_FILE_TYPE_LIST = typer.Option( "animations", "--file-type", "-t", help="Device asset file type to list (default: animations)", ) OPTION_CONTENT_TYPE = typer.Option( "image/png", "--content-type", "-c", help="MIME content type of the uploaded file", ) OPTION_ORIGINAL_NAME = typer.Option( None, "--original-name", "-n", help="Original file name reported to the server (defaults to the file's basename)", ) @app.callback() def main(ctx: typer.Context) -> None: """Device asset file commands (Public API).""" @app.command("list") def list_files( ctx: typer.Context, file_type: str = OPTION_FILE_TYPE_LIST, ) -> None: """List uploaded device asset files of the given type.""" async def _fetch() -> None: items = await ctx.obj.protect.get_files_public(file_type) base.print_unifi_list(items, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def upload( ctx: typer.Context, file_type: str = ARG_FILE_TYPE, path: Path = ARG_FILE_PATH, content_type: str = OPTION_CONTENT_TYPE, original_name: str | None = OPTION_ORIGINAL_NAME, ) -> None: """Upload a device asset file.""" payload = path.read_bytes() name = original_name if original_name is not None else path.name async def _upload() -> None: obj = await ctx.obj.protect.upload_file_public( file_type, payload, original_name=name, content_type=content_type, ) base.print_unifi_obj(obj, ctx.obj.output_format) base.run(ctx, _upload()) uilibs-uiprotect-317a52c/src/uiprotect/cli/fobs.py000066400000000000000000000020631524637705700222440ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_FOB_ID = typer.Argument(..., help="Fob ID") @app.callback() def main(ctx: typer.Context) -> None: """Fob commands (Public API).""" @app.command("list") def list_fobs(ctx: typer.Context) -> None: """List all key fobs.""" async def _fetch() -> None: fobs = await ctx.obj.protect.get_fobs_public() base.print_unifi_list(fobs) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, fob_id: str = ARG_FOB_ID) -> None: """Show details for a specific key fob.""" async def _fetch() -> None: fob = await ctx.obj.protect.get_fob_public(fob_id) base.print_unifi_obj(fob, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def set_name( ctx: typer.Context, fob_id: str = ARG_FOB_ID, name: str = typer.Argument(..., help="New name for the fob"), ) -> None: """Rename a key fob.""" base.run(ctx, ctx.obj.protect.update_fob_public(fob_id, name=name)) uilibs-uiprotect-317a52c/src/uiprotect/cli/lights.py000066400000000000000000000067551524637705700226210ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import typer from ..api import ProtectApiClient from ..cli import base from ..data import Light app = typer.Typer(rich_markup_mode="rich") ARG_DEVICE_ID = typer.Argument(None, help="ID of light to select for subcommands") @dataclass class LightContext(base.CliContext): devices: dict[str, Light] device: Light | None = None ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) @app.callback(invoke_without_command=True) def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: """ Lights device CLI. Returns full list of Viewers without any arguments passed. """ protect: ProtectApiClient = ctx.obj.protect context = LightContext( protect=ctx.obj.protect, device=None, devices=protect.bootstrap.lights, output_format=ctx.obj.output_format, ) ctx.obj = context if device_id is not None and device_id not in ALL_COMMANDS: if (device := protect.bootstrap.lights.get(device_id)) is None: typer.secho("Invalid light ID", fg="red") raise typer.Exit(1) ctx.obj.device = device if not ctx.invoked_subcommand: if device_id in ALL_COMMANDS: ctx.invoke(ALL_COMMANDS[device_id], ctx) return if ctx.obj.device is not None: base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) return base.print_unifi_dict(ctx.obj.devices) @app.command() def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None: """Returns or sets the paired camera for a light.""" base.require_device_id(ctx) obj: Light = ctx.obj.device if camera_id is None: base.print_unifi_obj(obj.camera, ctx.obj.output_format) else: protect: ProtectApiClient = ctx.obj.protect if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None: typer.secho("Invalid camera ID") raise typer.Exit(1) base.run(ctx, obj.set_paired_camera(camera_obj)) @app.command() def set_status_light(ctx: typer.Context, enabled: bool) -> None: """Sets status light for light device.""" base.require_device_id(ctx) obj: Light = ctx.obj.device base.run(ctx, obj.set_status_light_public(enabled)) @app.command() def set_led_level( ctx: typer.Context, led_level: int = typer.Argument(..., min=1, max=6), ) -> None: """Sets brightness of LED on light.""" base.require_device_id(ctx) obj: Light = ctx.obj.device base.run(ctx, obj.set_led_level_public(led_level)) @app.command() def set_sensitivity( ctx: typer.Context, sensitivity: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets motion sensitivity for the light.""" base.require_device_id(ctx) obj: Light = ctx.obj.device base.run(ctx, obj.set_sensitivity_public(sensitivity)) @app.command() def set_duration( ctx: typer.Context, duration: int = typer.Argument(..., min=15, max=900), ) -> None: """Sets timeout duration (in seconds) for light.""" base.require_device_id(ctx) obj: Light = ctx.obj.device base.run(ctx, obj.set_duration_public(timedelta(seconds=duration))) @app.command() def set_flood_light(ctx: typer.Context, enabled: bool) -> None: """Sets flood light (force on) for light device.""" base.require_device_id(ctx) obj: Light = ctx.obj.device base.run(ctx, obj.set_flood_light_public(enabled)) uilibs-uiprotect-317a52c/src/uiprotect/cli/link_stations.py000066400000000000000000000057241524637705700242030ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_LINK_STATION_ID = typer.Argument(..., help="Link station / alarm hub ID") ARG_OUTPUT_ID = typer.Argument(..., help="Output channel ID (integer)") @app.callback() def main(ctx: typer.Context) -> None: """Link station and alarm hub commands (Public API).""" @app.command("list") def list_link_stations( ctx: typer.Context, alarm_hubs_only: bool = typer.Option( False, "--alarm-hubs-only", help="Only return entries with isAlarmHub=True (calls /v1/alarm-hubs).", ), ) -> None: """List link stations (and alarm hubs).""" async def _fetch() -> None: if alarm_hubs_only: items = await ctx.obj.protect.get_alarm_hubs_public() else: items = await ctx.obj.protect.get_link_stations_public() base.print_unifi_list(items) base.run(ctx, _fetch()) @app.command() def show( ctx: typer.Context, link_station_id: str = ARG_LINK_STATION_ID, alarm_hub: bool = typer.Option( False, "--alarm-hub", help="Use /v1/alarm-hubs/{id} instead of /v1/link-stations/{id}.", ), ) -> None: """Show details for a specific link station / alarm hub.""" async def _fetch() -> None: if alarm_hub: obj = await ctx.obj.protect.get_alarm_hub_public(link_station_id) else: obj = await ctx.obj.protect.get_link_station_public(link_station_id) base.print_unifi_obj(obj, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def set_name( ctx: typer.Context, link_station_id: str = ARG_LINK_STATION_ID, name: str = typer.Argument(..., help="New name for the link station / alarm hub"), alarm_hub: bool = typer.Option( False, "--alarm-hub", help="Patch via /v1/alarm-hubs/{id} instead of /v1/link-stations/{id}.", ), ) -> None: """Rename a link station / alarm hub.""" if alarm_hub: base.run( ctx, ctx.obj.protect.update_alarm_hub_public(link_station_id, name=name), ) else: base.run( ctx, ctx.obj.protect.update_link_station_public(link_station_id, name=name), ) @app.command("trigger-output") def trigger_output( ctx: typer.Context, link_station_id: str = ARG_LINK_STATION_ID, output_id: int = ARG_OUTPUT_ID, enable: bool | None = typer.Option(None, "--enable/--disable", show_default=False), delay: int | None = typer.Option(None, "--delay", min=0, help="Delay in ms"), duration: int | None = typer.Option( None, "--duration", min=0, help="Duration in ms" ), ) -> None: """Trigger an alarm-hub output channel.""" base.run( ctx, ctx.obj.protect.trigger_alarm_hub_output_public( link_station_id, output_id, enable=enable, delay=delay, duration=duration, ), ) uilibs-uiprotect-317a52c/src/uiprotect/cli/liveviews.py000066400000000000000000000102141524637705700233250ustar00rootroot00000000000000from __future__ import annotations from typing import Any import orjson import typer from ..cli import base from ..data import PublicLiveviewSlotDict from ..exceptions import BadRequest app = typer.Typer(rich_markup_mode="rich") ARG_LIVEVIEW_ID = typer.Argument(..., help="Liveview ID") @app.callback() def main(ctx: typer.Context) -> None: """Liveview commands (Public API).""" def _parse_slots(raw: str) -> list[PublicLiveviewSlotDict]: try: data = orjson.loads(raw) except orjson.JSONDecodeError as err: typer.secho(f"--slots must be valid JSON: {err}", fg="red") raise typer.Exit(1) from err if not isinstance(data, list): typer.secho("--slots must be a JSON array of slot objects", fg="red") raise typer.Exit(1) if any(not isinstance(slot, dict) for slot in data): typer.secho("--slots entries must be JSON objects", fg="red") raise typer.Exit(1) return data @app.command("list") def list_liveviews(ctx: typer.Context) -> None: """List all liveviews.""" async def _fetch() -> None: liveviews = await ctx.obj.protect.get_liveviews_public() base.print_unifi_list(liveviews) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, liveview_id: str = ARG_LIVEVIEW_ID) -> None: """Show details for a specific liveview.""" async def _fetch() -> None: liveview = await ctx.obj.protect.get_liveview_public(liveview_id) base.print_unifi_obj(liveview, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def create( ctx: typer.Context, name: str = typer.Option(..., "--name", help="Liveview name"), owner: str = typer.Option(..., "--owner", help="Owner user ID"), layout: int = typer.Option( ..., "--layout", help="Slot count (1-26)", min=1, max=26 ), slots: str = typer.Option( ..., "--slots", help=( "JSON array of slot objects, e.g. " '\'[{"cameras":["id1"],"cycleMode":"motion","cycleInterval":10}]\'' ), ), is_default: bool = typer.Option(False, "--default/--no-default"), is_global: bool = typer.Option(False, "--global/--no-global"), ) -> None: """Create a new liveview.""" parsed = _parse_slots(slots) async def _create() -> None: liveview = await ctx.obj.protect.create_liveview_public( name=name, is_default=is_default, is_global=is_global, owner=owner, layout=layout, slots=parsed, ) base.print_unifi_obj(liveview, ctx.obj.output_format) base.run(ctx, _create()) @app.command() def update( ctx: typer.Context, liveview_id: str = ARG_LIVEVIEW_ID, name: str | None = typer.Option(None, "--name"), owner: str | None = typer.Option(None, "--owner"), layout: int | None = typer.Option(None, "--layout", min=1, max=26), slots: str | None = typer.Option( None, "--slots", help="JSON array of slot objects" ), is_default: bool | None = typer.Option( None, "--default/--no-default", show_default=False ), is_global: bool | None = typer.Option( None, "--global/--no-global", show_default=False ), ) -> None: """Patch an existing liveview (partial update).""" kwargs: dict[str, Any] = {} if name is not None: kwargs["name"] = name if owner is not None: kwargs["owner"] = owner if layout is not None: kwargs["layout"] = layout if slots is not None: kwargs["slots"] = _parse_slots(slots) if is_default is not None: kwargs["is_default"] = is_default if is_global is not None: kwargs["is_global"] = is_global if not kwargs: typer.secho("At least one field must be provided", fg="red") raise typer.Exit(1) async def _update() -> None: try: liveview = await ctx.obj.protect.update_liveview_public( liveview_id, **kwargs ) except BadRequest as err: typer.secho(str(err), fg="red") raise typer.Exit(1) from err base.print_unifi_obj(liveview, ctx.obj.output_format) base.run(ctx, _update()) uilibs-uiprotect-317a52c/src/uiprotect/cli/nvr.py000066400000000000000000000102441524637705700221200ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import orjson import typer from ..cli import base from ..data import NVR, AnalyticsOption app = typer.Typer(rich_markup_mode="rich") ARG_TIMEOUT = typer.Argument(..., help="Timeout (in seconds)") ARG_DOORBELL_MESSAGE = typer.Argument(..., help="ASCII only. Max length 30") OPTION_ENABLE_SMART = typer.Option( False, "--enable-smart", help="Automatically enable smart detections", ) @dataclass class NVRContext(base.CliContext): device: NVR @app.callback(invoke_without_command=True) def main(ctx: typer.Context) -> None: """ NVR device CLI. Return NVR object without any arguments passed. """ context = NVRContext( protect=ctx.obj.protect, device=ctx.obj.protect.bootstrap.nvr, output_format=ctx.obj.output_format, ) ctx.obj = context if not ctx.invoked_subcommand: base.print_unifi_obj(context.device, ctx.obj.output_format) app.command(name="protect-url")(base.protect_url) app.command(name="reboot")(base.reboot) app.command(name="set-name")(base.set_name) @app.command() def set_analytics(ctx: typer.Context, value: AnalyticsOption) -> None: """Sets analytics collection for NVR.""" nvr: NVR = ctx.obj.device base.run(ctx, nvr.set_analytics(value)) @app.command() def set_default_reset_timeout(ctx: typer.Context, timeout: int = ARG_TIMEOUT) -> None: """ Sets default message reset timeout. This is how long until a custom message is reset back to the default message if no timeout is passed in when the custom message is set. """ nvr: NVR = ctx.obj.device base.run(ctx, nvr.set_default_reset_timeout(timedelta(seconds=timeout))) base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) @app.command() def set_default_doorbell_message( ctx: typer.Context, msg: str = ARG_DOORBELL_MESSAGE, ) -> None: """ Sets default message for doorbell. This is the message that is set when a custom doorbell message times out or an empty one is set. """ nvr: NVR = ctx.obj.device base.run(ctx, nvr.set_default_doorbell_message(msg)) base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) @app.command() def add_custom_doorbell_message( ctx: typer.Context, msg: str = ARG_DOORBELL_MESSAGE, ) -> None: """Adds a custom doorbell message.""" nvr: NVR = ctx.obj.device base.run(ctx, nvr.add_custom_doorbell_message(msg)) base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) @app.command() def remove_custom_doorbell_message( ctx: typer.Context, msg: str = ARG_DOORBELL_MESSAGE, ) -> None: """Removes a custom doorbell message.""" nvr: NVR = ctx.obj.device base.run(ctx, nvr.remove_custom_doorbell_message(msg)) base.print_unifi_obj(nvr.doorbell_settings, ctx.obj.output_format) @app.command() def update(ctx: typer.Context, data: str) -> None: """Updates the NVR.""" nvr: NVR = ctx.obj.device base.run(ctx, nvr.api.update_nvr(orjson.loads(data))) @app.command() def set_smart_detections(ctx: typer.Context, value: bool) -> None: """Set if smart detections are globally enabled or not.""" nvr: NVR = ctx.obj.device base.run(ctx, nvr.set_smart_detections(value)) @app.command() def set_face_recognition( ctx: typer.Context, value: bool, enable_smart: bool = OPTION_ENABLE_SMART, ) -> None: """Set if face detections is enabled. Requires smart detections to be enabled.""" nvr: NVR = ctx.obj.device async def callback() -> None: if enable_smart: await nvr.set_smart_detections(True) await nvr.set_face_recognition(value) base.run(ctx, callback()) @app.command() def set_license_plate_recognition( ctx: typer.Context, value: bool, enable_smart: bool = OPTION_ENABLE_SMART, ) -> None: """Set if license plate detections is enabled. Requires smart detections to be enabled.""" nvr: NVR = ctx.obj.device async def callback() -> None: if enable_smart: await nvr.set_smart_detections(True) await nvr.set_license_plate_recognition(value) base.run(ctx, callback()) uilibs-uiprotect-317a52c/src/uiprotect/cli/relays.py000066400000000000000000000052731524637705700226200ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_RELAY_ID = typer.Argument(..., help="Relay ID") ARG_OUTPUT_ID = typer.Argument(..., help="Output channel ID (integer)") @app.callback() def main(ctx: typer.Context) -> None: """Relay commands (Public API).""" @app.command("list") def list_relays(ctx: typer.Context) -> None: """List all relays.""" async def _fetch() -> None: relays = await ctx.obj.protect.get_relays_public() base.print_unifi_list(relays) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, relay_id: str = ARG_RELAY_ID) -> None: """Show details for a specific relay.""" async def _fetch() -> None: relay = await ctx.obj.protect.get_relay_public(relay_id) base.print_unifi_obj(relay, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def activate( ctx: typer.Context, relay_id: str = ARG_RELAY_ID, output_id: int = ARG_OUTPUT_ID, state: str | None = typer.Option( None, "--state", "-s", help="Target state: 'on' or 'off'. Omit to toggle the current state.", ), pulse_duration_ms: int | None = typer.Option( None, "--pulse-duration-ms", "-p", help="Auto-off duration in milliseconds. Only valid together with --state on.", min=1, ), ) -> None: """ Activate, toggle, or pulse a relay output channel. Omit --state to toggle. Use --pulse-duration-ms with --state on to pulse. """ if state is not None and state not in ("on", "off"): typer.secho("--state must be 'on' or 'off'", fg="red") raise typer.Exit(1) if pulse_duration_ms is not None and state != "on": typer.secho("--pulse-duration-ms requires --state on", fg="red") raise typer.Exit(1) base.run( ctx, ctx.obj.protect.activate_relay_output_public( relay_id, output_id, state=state, # type: ignore[arg-type] pulse_duration_ms=pulse_duration_ms, ), ) @app.command() def set_name( ctx: typer.Context, relay_id: str = ARG_RELAY_ID, name: str = typer.Argument(..., help="New name for the relay"), ) -> None: """Rename a relay.""" base.run(ctx, ctx.obj.protect.update_relay_public(relay_id, name=name)) @app.command() def set_status_light( ctx: typer.Context, relay_id: str = ARG_RELAY_ID, enabled: bool = typer.Argument(..., help="Enable or disable the LED status light"), ) -> None: """Enable or disable the LED status light on a relay.""" base.run(ctx, ctx.obj.protect.update_relay_public(relay_id, led_is_enabled=enabled)) uilibs-uiprotect-317a52c/src/uiprotect/cli/sensors.py000066400000000000000000000350601524637705700230120ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass import typer from ..api import ProtectApiClient from ..cli import base from ..data import MountType, Sensor, SensorScheduleMode app = typer.Typer(rich_markup_mode="rich") ARG_DEVICE_ID = typer.Argument(None, help="ID of sensor to select for subcommands") @dataclass class SensorContext(base.CliContext): devices: dict[str, Sensor] device: Sensor | None = None ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) @app.callback(invoke_without_command=True) def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: """ Sensors device CLI. Returns full list of Sensors without any arguments passed. """ protect: ProtectApiClient = ctx.obj.protect context = SensorContext( protect=ctx.obj.protect, device=None, devices=protect.bootstrap.sensors, output_format=ctx.obj.output_format, ) ctx.obj = context if device_id is not None and device_id not in ALL_COMMANDS: if (device := protect.bootstrap.sensors.get(device_id)) is None: typer.secho("Invalid sensor ID", fg="red") raise typer.Exit(1) ctx.obj.device = device if not ctx.invoked_subcommand: if device_id in ALL_COMMANDS: ctx.invoke(ALL_COMMANDS[device_id], ctx) return if ctx.obj.device is not None: base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) return base.print_unifi_dict(ctx.obj.devices) @app.command() def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None: """Returns or sets the paired camera for a sensor.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device if camera_id is None: base.print_unifi_obj(obj.camera, ctx.obj.output_format) else: protect: ProtectApiClient = ctx.obj.protect if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None: typer.secho("Invalid camera ID") raise typer.Exit(1) base.run(ctx, obj.set_paired_camera(camera_obj)) @app.command() def is_tampering_detected(ctx: typer.Context) -> None: """Returns if tampering is detected for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_tampering_detected) @app.command() def is_alarm_detected(ctx: typer.Context) -> None: """Returns if alarm is detected for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_alarm_detected) @app.command() def is_contact_enabled(ctx: typer.Context) -> None: """Returns if contact sensor is enabled for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_contact_sensor_enabled) @app.command() def is_motion_enabled(ctx: typer.Context) -> None: """Returns if motion sensor is enabled for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_contact_sensor_enabled) @app.command() def is_alarm_enabled(ctx: typer.Context) -> None: """Returns if alarm sensor is enabled for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_alarm_sensor_enabled) @app.command() def is_light_enabled(ctx: typer.Context) -> None: """Returns if light sensor is enabled for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_light_sensor_enabled) @app.command() def is_temperature_enabled(ctx: typer.Context) -> None: """Returns if temperature sensor is enabled for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_temperature_sensor_enabled) @app.command() def is_humidity_enabled(ctx: typer.Context) -> None: """Returns if humidity sensor is enabled for sensor""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.json_output(obj.is_humidity_sensor_enabled) @app.command() def set_status_light(ctx: typer.Context, enabled: bool) -> None: """Sets status light for sensor device.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_status_light(enabled)) @app.command() def set_mount_type(ctx: typer.Context, mount_type: MountType) -> None: """Sets mount type for sensor device.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_mount_type(mount_type)) @app.command() def set_motion(ctx: typer.Context, enabled: bool) -> None: """Sets motion sensor status for sensor device.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_motion_status_public(enabled)) @app.command() def set_temperature(ctx: typer.Context, enabled: bool) -> None: """Sets temperature sensor status for sensor device.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_temperature_status_public(enabled)) @app.command() def set_humidity(ctx: typer.Context, enabled: bool) -> None: """Sets humidity sensor status for sensor device.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_humidity_status_public(enabled)) @app.command() def set_light(ctx: typer.Context, enabled: bool) -> None: """Sets light sensor status for sensor device.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_light_status_public(enabled)) @app.command() def set_alarm(ctx: typer.Context, enabled: bool) -> None: """Sets alarm sensor status for sensor device.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_alarm_public(enabled)) @app.command() def set_motion_sensitivity( ctx: typer.Context, sensitivity: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets motion sensitivity for the sensor.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_motion_sensitivity_public(sensitivity)) @app.command() def set_temperature_range( ctx: typer.Context, low: float = typer.Argument(..., min=0, max=44), high: float = typer.Argument(..., min=1, max=45), ) -> None: """Sets temperature safe range (in °C). Anything out side of range will trigger event.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device _require_ordered_range(low, high) base.run( ctx, obj.set_temperature_settings_public(low_threshold=low, high_threshold=high), ) @app.command() def set_humidity_range( ctx: typer.Context, low: float = typer.Argument(..., min=1, max=98), high: float = typer.Argument(..., min=2, max=99), ) -> None: """Sets humidity safe range (in relative % humidity). Anything out side of range will trigger event.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device _require_ordered_range(low, high) base.run( ctx, obj.set_humidity_settings_public(low_threshold=low, high_threshold=high), ) @app.command() def set_light_range( ctx: typer.Context, low: float = typer.Argument(..., min=1, max=999), high: float = typer.Argument(..., min=2, max=1000), ) -> None: """Sets light safe range (in lux). Anything out side of range will trigger event.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device _require_ordered_range(low, high) base.run( ctx, obj.set_light_settings_public(low_threshold=low, high_threshold=high), ) @app.command() def remove_temperature_range(ctx: typer.Context) -> None: """Removes temperature safe ranges so events will no longer fire.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.remove_temperature_safe_range()) @app.command() def remove_humidity_range(ctx: typer.Context) -> None: """Removes humidity safe ranges so events will no longer fire.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.remove_humidity_safe_range()) @app.command() def remove_light_range(ctx: typer.Context) -> None: """Removes light safe ranges so events will no longer fire.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.remove_light_safe_range()) @app.command() def set_name_public(ctx: typer.Context, name: str) -> None: """Sets sensor name via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_name_public(name)) @app.command() def set_temperature_settings_public( ctx: typer.Context, is_enabled: bool | None = typer.Option(None), low_threshold: float | None = typer.Option(None), high_threshold: float | None = typer.Option(None), margin: float | None = typer.Option(None), ) -> None: """Updates temperature alert settings via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run( ctx, obj.set_temperature_settings_public( is_enabled=is_enabled, low_threshold=low_threshold, high_threshold=high_threshold, margin=margin, ), ) @app.command() def set_humidity_settings_public( ctx: typer.Context, is_enabled: bool | None = typer.Option(None), low_threshold: int | None = typer.Option(None), high_threshold: int | None = typer.Option(None), margin: int | None = typer.Option(None), ) -> None: """Updates humidity alert settings via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run( ctx, obj.set_humidity_settings_public( is_enabled=is_enabled, low_threshold=low_threshold, high_threshold=high_threshold, margin=margin, ), ) @app.command() def set_light_settings_public( ctx: typer.Context, is_enabled: bool | None = typer.Option(None), low_threshold: int | None = typer.Option(None), high_threshold: int | None = typer.Option(None), margin: int | None = typer.Option(None), ) -> None: """Updates light (lux) alert settings via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run( ctx, obj.set_light_settings_public( is_enabled=is_enabled, low_threshold=low_threshold, high_threshold=high_threshold, margin=margin, ), ) @app.command() def set_motion_settings_public( ctx: typer.Context, is_enabled: bool | None = typer.Option(None), sensitivity: int | None = typer.Option(None), sensitivity_when_armed: int | None = typer.Option(None), ) -> None: """Updates motion detection settings via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run( ctx, obj.set_motion_settings_public( is_enabled=is_enabled, sensitivity=sensitivity, sensitivity_when_armed=sensitivity_when_armed, ), ) @app.command() def set_glass_break_settings_public( ctx: typer.Context, is_enabled: bool | None = typer.Option(None), sensitivity: int | None = typer.Option(None), sensitivity_when_armed: int | None = typer.Option(None), ) -> None: """Updates glass-break detection settings via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run( ctx, obj.set_glass_break_settings_public( is_enabled=is_enabled, sensitivity=sensitivity, sensitivity_when_armed=sensitivity_when_armed, ), ) @app.command() def set_alarm_public(ctx: typer.Context, enabled: bool) -> None: """Sets alarm detection setting via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_alarm_public(enabled)) @app.command() def set_motion_public(ctx: typer.Context, enabled: bool) -> None: """Toggles motion detection via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_motion_status_public(enabled)) @app.command() def set_motion_sensitivity_public( ctx: typer.Context, sensitivity: int = typer.Argument(..., min=0, max=100), ) -> None: """Sets motion detection sensitivity via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_motion_sensitivity_public(sensitivity)) @app.command() def set_temperature_public(ctx: typer.Context, enabled: bool) -> None: """Toggles temperature alerts via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_temperature_status_public(enabled)) @app.command() def set_humidity_public(ctx: typer.Context, enabled: bool) -> None: """Toggles humidity alerts via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_humidity_status_public(enabled)) @app.command() def set_light_public(ctx: typer.Context, enabled: bool) -> None: """Toggles light (lux) alerts via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_light_status_public(enabled)) @app.command() def set_glass_break_public(ctx: typer.Context, enabled: bool) -> None: """Toggles glass-break detection via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_glass_break_status_public(enabled)) @app.command() def set_schedule_mode_public(ctx: typer.Context, mode: SensorScheduleMode) -> None: """Sets the arm-schedule mode via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_schedule_mode_public(mode)) @app.command() def set_arm_profiles_public(ctx: typer.Context, arm_profile_ids: list[str]) -> None: """Sets the arm-profile ids via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_arm_profile_ids_public(arm_profile_ids)) @app.command() def set_custom_sensitivity_public(ctx: typer.Context, enabled: bool) -> None: """Toggles custom armed sensitivity via the public API.""" base.require_device_id(ctx) obj: Sensor = ctx.obj.device base.run(ctx, obj.set_custom_sensitivity_when_armed_public(enabled)) def _require_ordered_range(low: float, high: float) -> None: """Preserve the ordering check the private safe-range setters enforced.""" if high <= low: typer.secho("High value must be above low value", fg="red") raise typer.Exit(1) uilibs-uiprotect-317a52c/src/uiprotect/cli/sirens.py000066400000000000000000000053121524637705700226160ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_SIREN_ID = typer.Argument(..., help="Siren ID") @app.callback() def main(ctx: typer.Context) -> None: """Siren commands (Public API).""" @app.command("list") def list_sirens(ctx: typer.Context) -> None: """List all sirens.""" async def _fetch() -> None: sirens = await ctx.obj.protect.get_sirens_public() base.print_unifi_list(sirens) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, siren_id: str = ARG_SIREN_ID) -> None: """Show details for a specific siren.""" async def _fetch() -> None: siren = await ctx.obj.protect.get_siren_public(siren_id) base.print_unifi_obj(siren, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def play( ctx: typer.Context, siren_id: str = ARG_SIREN_ID, duration: int | None = typer.Option( None, "--duration", "-d", help="Duration in seconds (5, 10, 20, or 30). Omit to use server default.", min=1, ), ) -> None: """Activate a siren.""" base.run(ctx, ctx.obj.protect.play_siren_public(siren_id, duration=duration)) @app.command() def stop(ctx: typer.Context, siren_id: str = ARG_SIREN_ID) -> None: """Stop an active siren.""" base.run(ctx, ctx.obj.protect.stop_siren_public(siren_id)) @app.command() def test_sound( ctx: typer.Context, siren_id: str = ARG_SIREN_ID, volume: int | None = typer.Option( None, "--volume", "-v", help="Test volume (1-100). Omit to use the current siren volume.", min=1, max=100, ), ) -> None: """Play a 5-second test sound on a siren.""" base.run(ctx, ctx.obj.protect.test_siren_sound_public(siren_id, volume=volume)) @app.command() def set_volume( ctx: typer.Context, siren_id: str = ARG_SIREN_ID, volume: int = typer.Argument(..., help="Volume (1-100)", min=1, max=100), ) -> None: """Set the siren volume.""" base.run(ctx, ctx.obj.protect.update_siren_public(siren_id, volume=volume)) @app.command() def set_name( ctx: typer.Context, siren_id: str = ARG_SIREN_ID, name: str = typer.Argument(..., help="New name for the siren"), ) -> None: """Rename a siren.""" base.run(ctx, ctx.obj.protect.update_siren_public(siren_id, name=name)) @app.command() def set_status_light( ctx: typer.Context, siren_id: str = ARG_SIREN_ID, enabled: bool = typer.Argument(..., help="Enable or disable the LED status light"), ) -> None: """Enable or disable the LED status light on a siren.""" base.run(ctx, ctx.obj.protect.update_siren_public(siren_id, led_is_enabled=enabled)) uilibs-uiprotect-317a52c/src/uiprotect/cli/speakers.py000066400000000000000000000051041524637705700231270ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_SPEAKER_ID = typer.Argument(..., help="Speaker ID") @app.callback() def main(ctx: typer.Context) -> None: """Speaker commands (Public API).""" @app.command("list") def list_speakers(ctx: typer.Context) -> None: """List all speakers.""" async def _fetch() -> None: speakers = await ctx.obj.protect.get_speakers_public() base.print_unifi_list(speakers) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, speaker_id: str = ARG_SPEAKER_ID) -> None: """Show details for a specific speaker.""" async def _fetch() -> None: speaker = await ctx.obj.protect.get_speaker_public(speaker_id) base.print_unifi_obj(speaker, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def set_name( ctx: typer.Context, speaker_id: str = ARG_SPEAKER_ID, name: str = typer.Argument(..., help="New name for the speaker"), ) -> None: """Rename a speaker.""" base.run(ctx, ctx.obj.protect.update_speaker_public(speaker_id, name=name)) @app.command() def set_volume( ctx: typer.Context, speaker_id: str = ARG_SPEAKER_ID, volume: int = typer.Argument(..., help="Volume (0-100)", min=0, max=100), ) -> None: """Set the speaker volume.""" base.run(ctx, ctx.obj.protect.update_speaker_public(speaker_id, volume=volume)) @app.command() def set_mic_volume( ctx: typer.Context, speaker_id: str = ARG_SPEAKER_ID, mic_volume: int = typer.Argument(..., help="Mic volume (0-100)", min=0, max=100), ) -> None: """Set the speaker microphone volume.""" base.run( ctx, ctx.obj.protect.update_speaker_public(speaker_id, mic_volume=mic_volume), ) @app.command() def set_mic_enabled( ctx: typer.Context, speaker_id: str = ARG_SPEAKER_ID, enabled: bool = typer.Argument(..., help="Enable or disable the microphone"), ) -> None: """Enable or disable the speaker microphone.""" base.run( ctx, ctx.obj.protect.update_speaker_public(speaker_id, is_mic_enabled=enabled), ) @app.command() def test_sound( ctx: typer.Context, speaker_id: str = ARG_SPEAKER_ID, volume: int | None = typer.Option( None, "--volume", "-v", help="Test volume (0-100). Omit to use the current speaker volume.", min=0, max=100, ), ) -> None: """Play a test sound on a speaker.""" base.run( ctx, ctx.obj.protect.test_speaker_sound_public(speaker_id, volume=volume), ) uilibs-uiprotect-317a52c/src/uiprotect/cli/ulp_users_public.py000066400000000000000000000016301524637705700246710ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_ULP_USER_ID = typer.Argument(..., help="ULP user ID") @app.callback() def main(ctx: typer.Context) -> None: """UniFi Identity (ULP) user commands (Public API).""" @app.command("list") def list_ulp_users(ctx: typer.Context) -> None: """List UniFi Identity users.""" async def _fetch() -> None: items = await ctx.obj.protect.get_ulp_users_public() base.print_unifi_list(items, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, ulp_user_id: str = ARG_ULP_USER_ID) -> None: """Show details for a specific UniFi Identity user.""" async def _fetch() -> None: obj = await ctx.obj.protect.get_ulp_user_public(ulp_user_id) base.print_unifi_obj(obj, ctx.obj.output_format) base.run(ctx, _fetch()) uilibs-uiprotect-317a52c/src/uiprotect/cli/users_public.py000066400000000000000000000015351524637705700240150ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_USER_ID = typer.Argument(..., help="User ID") @app.callback() def main(ctx: typer.Context) -> None: """Protect user commands (Public API).""" @app.command("list") def list_users(ctx: typer.Context) -> None: """List Protect users.""" async def _fetch() -> None: items = await ctx.obj.protect.get_users_public() base.print_unifi_list(items, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, user_id: str = ARG_USER_ID) -> None: """Show details for a specific Protect user.""" async def _fetch() -> None: obj = await ctx.obj.protect.get_user_public(user_id) base.print_unifi_obj(obj, ctx.obj.output_format) base.run(ctx, _fetch()) uilibs-uiprotect-317a52c/src/uiprotect/cli/viewers.py000066400000000000000000000041351524637705700230010ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass import typer from ..api import ProtectApiClient from ..cli import base from ..data import Viewer app = typer.Typer(rich_markup_mode="rich") ARG_DEVICE_ID = typer.Argument(None, help="ID of viewer to select for subcommands") @dataclass class ViewerContext(base.CliContext): devices: dict[str, Viewer] device: Viewer | None = None ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app) @app.callback(invoke_without_command=True) def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None: """ Viewers device CLI. Returns full list of Viewers without any arguments passed. """ protect: ProtectApiClient = ctx.obj.protect context = ViewerContext( protect=ctx.obj.protect, device=None, devices=protect.bootstrap.viewers, output_format=ctx.obj.output_format, ) ctx.obj = context if device_id is not None and device_id not in ALL_COMMANDS: if (device := protect.bootstrap.viewers.get(device_id)) is None: typer.secho("Invalid viewer ID", fg="red") raise typer.Exit(1) ctx.obj.device = device if not ctx.invoked_subcommand: if device_id in ALL_COMMANDS: ctx.invoke(ALL_COMMANDS[device_id], ctx) return if ctx.obj.device is not None: base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format) return base.print_unifi_dict(ctx.obj.devices) @app.command() def liveview( ctx: typer.Context, liveview_id: str | None = typer.Argument(None), ) -> None: """Returns or sets the current liveview.""" base.require_device_id(ctx) obj: Viewer = ctx.obj.device if liveview_id is None: base.print_unifi_obj(obj.liveview, ctx.obj.output_format) else: protect: ProtectApiClient = ctx.obj.protect if liveview_id not in protect.bootstrap.liveviews: typer.secho("Invalid liveview ID") raise typer.Exit(1) base.run(ctx, protect.update_viewer_public(obj.id, liveview=liveview_id)) uilibs-uiprotect-317a52c/src/uiprotect/cli/viewers_public.py000066400000000000000000000030221524637705700243310ustar00rootroot00000000000000from __future__ import annotations import typer from ..cli import base app = typer.Typer(rich_markup_mode="rich") ARG_VIEWER_ID = typer.Argument(..., help="Viewer ID") @app.callback() def main(ctx: typer.Context) -> None: """Viewer commands (Public API).""" @app.command("list") def list_viewers(ctx: typer.Context) -> None: """List viewers.""" async def _fetch() -> None: items = await ctx.obj.protect.get_viewers_public() base.print_unifi_list(items, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def show(ctx: typer.Context, viewer_id: str = ARG_VIEWER_ID) -> None: """Show details for a specific viewer.""" async def _fetch() -> None: obj = await ctx.obj.protect.get_viewer_public(viewer_id) base.print_unifi_obj(obj, ctx.obj.output_format) base.run(ctx, _fetch()) @app.command() def set_name( ctx: typer.Context, viewer_id: str = ARG_VIEWER_ID, name: str = typer.Argument(..., help="New name for the viewer"), ) -> None: """Rename a viewer.""" base.run(ctx, ctx.obj.protect.update_viewer_public(viewer_id, name=name)) @app.command() def set_liveview( ctx: typer.Context, viewer_id: str = ARG_VIEWER_ID, liveview_id: str = typer.Argument( ..., help="Liveview ID to assign, or 'null' to clear" ), ) -> None: """Assign a liveview to a viewer.""" target: str | None = None if liveview_id.lower() == "null" else liveview_id base.run(ctx, ctx.obj.protect.update_viewer_public(viewer_id, liveview=target)) uilibs-uiprotect-317a52c/src/uiprotect/data/000077500000000000000000000000001524637705700211025ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/data/__init__.py000066400000000000000000000216371524637705700232240ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from .base import ( ProtectAdoptableDeviceModel, ProtectBaseObject, ProtectDeviceIdentity, ProtectDeviceModel, ProtectModel, ProtectModelWithId, ) from .bootstrap import Bootstrap from .convert import create_from_unifi_dict from .devices import ( CHANNEL_ID_BY_RTSPS_QUALITY, RTSPS_QUALITY_BY_CHANNEL_ID, AiPort, Bridge, Camera, CameraChannel, Chime, LCDMessage, Light, RingSetting, Sensor, Viewer, channel_id_for_quality, quality_for_channel_id, ) from .nvr import ( NVR, DoorbellMessage, Event, Liveview, NVRLocation, SmartDetectItem, SmartDetectTrack, ) from .public_bootstrap import PublicBootstrap from .public_devices import ( AlarmHubBattery, AlarmHubCover, AlarmHubInput, AlarmHubOutput, ArmProfile, Fob, LinkStation, NvrArmMode, PublicArmSchedule, PublicArmScheduleDict, PublicBatteryStatus, PublicBridge, PublicCamera, PublicCameraFeatureFlags, PublicCameraLedSettings, PublicChime, PublicDeviceModel, PublicDoorbellCustomImage, PublicDoorbellSettings, PublicFile, PublicFobFeatureFlags, PublicLcdMessage, PublicLedSettings, PublicLight, PublicLightDeviceSettings, PublicLightModeSettings, PublicLiveview, PublicLiveviewSlot, PublicLiveviewSlotDict, PublicNVR, PublicOsdSettings, PublicPosLineItemDict, PublicPosLocationDict, PublicPosTransactionResponse, PublicRelayInput, PublicRelayOutput, PublicRingSettings, PublicSensor, PublicSensorAlarmSettings, PublicSensorAlarmSettingsRead, PublicSensorFeatureCapability, PublicSensorFeatureFlags, PublicSensorGlassBreakSettings, PublicSensorGlassBreakSettingsWrite, PublicSensorHumiditySettings, PublicSensorLeakSettings, PublicSensorLightSettings, PublicSensorMetric, PublicSensorMotionSettings, PublicSensorMotionSettingsRead, PublicSensorStats, PublicSensorTemperatureSettings, PublicSensorThresholdSettings, PublicSignalState, PublicSirenStatus, PublicSmartDetectSettings, PublicSpeakerFeatureFlags, PublicSpeakerState, PublicUlpUser, PublicUser, PublicViewer, PublicWirelessBatteryStatus, PublicWirelessConnectionState, Relay, RTSPSStreams, SensorFeatureCapability, Siren, Speaker, ) from .public_event import ( PUBLIC_EVENT_TYPES, PublicEvent, PublicEventMetadata, PublicFingerprintMetadata, PublicNfcMetadata, ) from .types import ( DEFAULT, DEFAULT_TYPE, PTZ_HOME_SLOT, AlarmHubBatteryStatus, AlarmHubConnectionState, AlarmHubCoverStatus, AlarmHubInputContactType, AlarmHubInputStatus, AlarmHubInputType, AlarmHubOutputStatus, AnalyticsOption, AssetFileType, AudioStyle, ChannelQuality, ChimeType, Color, CoordType, DeviceState, DoorbellMessageType, DoorbellText, EventCategories, EventType, FixSizeOrderedDict, FobAwayState, FobButton, FobButtonLabels, HDRMode, ICRCustomValue, ICRLuxValue, IRLEDMode, LensType, LightModeEnableType, LightModeType, LiveviewCycleMode, ModelType, MountType, NvrArmModeStatus, OnOffState, OsdOverlayLocation, Percent, PermissionNode, PosTransactionType, ProtectWSPayloadFormat, PTZPatrol, PTZPosition, PTZPreset, PublicHdrMode, RecordingMode, RelayInputActionTrigger, RelayInputActionType, RelayInputState, RelayOutputRebootState, RelayOutputState, RelayOutputType, SensorAlarmType, SensorScheduleMode, SensorStatusType, SensorType, SirenConnectionType, SirenDuration, SmartDetectAudioType, SmartDetectObjectType, SpeakerMode, SpeakerStatus, StateType, StorageType, UlpUserStatus, Version, VideoMode, WDRLevel, ) from .user import CloudAccount, Group, Permission, User, UserLocation from .websocket import ( WS_HEADER_SIZE, WSAction, WSJSONPacketFrame, WSPacket, WSPacketFrameHeader, WSRawPacketFrame, WSSubscriptionMessage, ) __all__ = [ "CHANNEL_ID_BY_RTSPS_QUALITY", "DEFAULT", "DEFAULT_TYPE", "NVR", "PTZ_HOME_SLOT", "PUBLIC_EVENT_TYPES", "RTSPS_QUALITY_BY_CHANNEL_ID", "WS_HEADER_SIZE", "AiPort", "AlarmHubBattery", "AlarmHubBatteryStatus", "AlarmHubConnectionState", "AlarmHubCover", "AlarmHubCoverStatus", "AlarmHubInput", "AlarmHubInputContactType", "AlarmHubInputStatus", "AlarmHubInputType", "AlarmHubOutput", "AlarmHubOutputStatus", "AnalyticsOption", "ArmProfile", "AssetFileType", "AudioStyle", "Bootstrap", "Bridge", "Camera", "CameraChannel", "ChannelQuality", "Chime", "ChimeType", "CloudAccount", "Color", "CoordType", "DeviceState", "DoorbellMessage", "DoorbellMessageType", "DoorbellText", "Event", "EventCategories", "EventType", "FixSizeOrderedDict", "Fob", "FobAwayState", "FobButton", "FobButtonLabels", "Group", "HDRMode", "ICRCustomValue", "ICRLuxValue", "IRLEDMode", "LCDMessage", "LensType", "Light", "LightModeEnableType", "LightModeType", "LinkStation", "Liveview", "LiveviewCycleMode", "ModelType", "MountType", "NVRLocation", "NvrArmMode", "NvrArmModeStatus", "OnOffState", "OsdOverlayLocation", "PTZPatrol", "PTZPosition", "PTZPreset", "Percent", "Permission", "PermissionNode", "PosTransactionType", "ProtectAdoptableDeviceModel", "ProtectBaseObject", "ProtectDeviceIdentity", "ProtectDeviceModel", "ProtectModel", "ProtectModelWithId", "ProtectWSPayloadFormat", "PublicArmSchedule", "PublicArmScheduleDict", "PublicBatteryStatus", "PublicBootstrap", "PublicBridge", "PublicCamera", "PublicCameraFeatureFlags", "PublicCameraLedSettings", "PublicChime", "PublicDeviceModel", "PublicDoorbellCustomImage", "PublicDoorbellSettings", "PublicEvent", "PublicEventMetadata", "PublicFile", "PublicFingerprintMetadata", "PublicFobFeatureFlags", "PublicHdrMode", "PublicLcdMessage", "PublicLedSettings", "PublicLight", "PublicLightDeviceSettings", "PublicLightModeSettings", "PublicLiveview", "PublicLiveviewSlot", "PublicLiveviewSlotDict", "PublicNVR", "PublicNfcMetadata", "PublicOsdSettings", "PublicPosLineItemDict", "PublicPosLocationDict", "PublicPosTransactionResponse", "PublicRelayInput", "PublicRelayOutput", "PublicRingSettings", "PublicSensor", "PublicSensorAlarmSettings", "PublicSensorAlarmSettingsRead", "PublicSensorFeatureCapability", "PublicSensorFeatureFlags", "PublicSensorGlassBreakSettings", "PublicSensorGlassBreakSettingsWrite", "PublicSensorHumiditySettings", "PublicSensorLeakSettings", "PublicSensorLightSettings", "PublicSensorMetric", "PublicSensorMotionSettings", "PublicSensorMotionSettingsRead", "PublicSensorStats", "PublicSensorTemperatureSettings", "PublicSensorThresholdSettings", "PublicSignalState", "PublicSirenStatus", "PublicSmartDetectSettings", "PublicSpeakerFeatureFlags", "PublicSpeakerState", "PublicUlpUser", "PublicUser", "PublicViewer", "PublicWirelessBatteryStatus", "PublicWirelessConnectionState", "RTSPSStreams", "RecordingMode", "Relay", "RelayInputActionTrigger", "RelayInputActionType", "RelayInputState", "RelayOutputRebootState", "RelayOutputState", "RelayOutputType", "RingSetting", "Sensor", "SensorAlarmType", "SensorFeatureCapability", "SensorScheduleMode", "SensorStatusType", "SensorType", "Siren", "SirenConnectionType", "SirenDuration", "SmartDetectAudioType", "SmartDetectItem", "SmartDetectObjectType", "SmartDetectTrack", "Speaker", "SpeakerMode", "SpeakerStatus", "StateType", "StorageType", "UlpUserStatus", "User", "UserLocation", "Version", "VideoMode", "Viewer", "WDRLevel", "WSAction", "WSJSONPacketFrame", "WSPacket", "WSPacketFrameHeader", "WSRawPacketFrame", "WSSubscriptionMessage", "channel_id_for_quality", "create_from_unifi_dict", "quality_for_channel_id", ] if TYPE_CHECKING: def _shared_identity_probe( private_nvr: NVR, private_camera: Camera, public_nvr: PublicNVR, public_camera: PublicCamera, ) -> list[ProtectDeviceIdentity]: # Static guard, never executed: mypy fails here if a model change # breaks the structural ``ProtectDeviceIdentity`` match of either tree. return [private_nvr, private_camera, public_nvr, public_camera] uilibs-uiprotect-317a52c/src/uiprotect/data/base.py000066400000000000000000001072021524637705700223700ustar00rootroot00000000000000"""UniFi Protect Data.""" from __future__ import annotations import asyncio import logging from asyncio import timeout as asyncio_timeout from collections.abc import Callable from datetime import datetime, timedelta from functools import cache from ipaddress import IPv4Address, IPv6Address from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, runtime_checkable from uuid import UUID from convertertools import pop_dict_set_if_none, pop_dict_tuple from pydantic import BaseModel, ConfigDict from pydantic.fields import PrivateAttr from .._compat import cached_property from ..exceptions import BadRequest, ClientError, NotAuthorized from ..utils import ( convert_to_datetime, convert_unifi_data, dict_diff, is_debug, serialize_unifi_obj, to_snake_case, ) from .types import ( ModelType, PercentFloat, PermissionNode, ProtectWSPayloadFormat, StateType, get_field_type, ) from .websocket import ( WSJSONPacketFrame, WSPacket, WSPacketFrameHeader, ) if TYPE_CHECKING: from asyncio.events import TimerHandle from typing import Self # requires Python 3.11+ from ..api import ProtectApiClient from ..data.devices import Bridge from ..data.nvr import Event from ..data.user import User RECENT_EVENT_MAX = timedelta(seconds=30) EVENT_PING_INTERVAL = timedelta(seconds=3) EVENT_PING_INTERVAL_SECONDS = EVENT_PING_INTERVAL.total_seconds() _EMPTY_EVENT_PING_BACK: dict[Any, Any] = {} _LOGGER = logging.getLogger(__name__) @cache def _is_protect_base_object(cls: type[Any]) -> bool: """A cached version of `issubclass(cls, ProtectBaseObject)` to speed up the check.""" return issubclass(cls, ProtectBaseObject) class _ProtectModelObjects(NamedTuple): """ Class to track all child of UFP objects. objs are UFP objects lists are lists of UFP objects dicts are dicts of UFP objects """ objs: dict[str, type[ProtectBaseObject]] has_objs: bool lists: dict[str, type[ProtectBaseObject]] has_lists: bool dicts: dict[str, type[ProtectBaseObject]] has_dicts: bool class ProtectBaseObject(BaseModel): """ Base class for building Python objects from UniFi Protect JSON. * Provides `.unifi_dict_to_dict` to convert UFP JSON to a more Pythonic formatted dict (camel case to snake case) * Add attrs with matching Pythonic name and they will automatically be populated from the UFP JSON if passed in to the constructor * Provides `.unifi_dict` to convert object back into UFP JSON """ _api: ProtectApiClient = PrivateAttr(None) # type: ignore[assignment] model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) def __init__(self, api: ProtectApiClient | None = None, **data: Any) -> None: """ Base class for creating Python objects from UFP JSON data. Use the static method `.from_unifi_dict()` to create objects from UFP JSON data from then the main class constructor. """ super().__init__(**data) if api is not None: self._api = api @classmethod def from_unifi_dict( cls, api: ProtectApiClient | None = None, **data: Any, ) -> Self: """ Main constructor for `ProtectBaseObject` Args: ---- api: Optional reference to the ProtectAPIClient that created generated the UFP JSON **data: decoded UFP JSON `api` is is expected as a `@property`. If it is `None` and accessed, a `BadRequest` will be raised. API can be used for saving updates for the Protect object or fetching references to other objects (cameras, users, etc.) """ if api is not None: data["api"] = api data = cls.unifi_dict_to_dict(data) if is_debug(): data.pop("api", None) return cls(api=api, **data) return cls.model_construct(**data) @classmethod def model_construct( cls, _fields_set: set[str] | None = None, **values: Any ) -> Self: api: ProtectApiClient | None = values.pop("api", None) ( unifi_objs, has_unifi_objs, unifi_lists, has_unifi_lists, unifi_dicts, has_unifi_dicts, ) = cls._get_protect_model() for key, value in values.items(): if has_unifi_objs and key in unifi_objs and isinstance(value, dict): values[key] = unifi_objs[key].model_construct(**value) elif has_unifi_lists and key in unifi_lists and isinstance(value, list): values[key] = [ unifi_lists[key].model_construct(**v) if isinstance(v, dict) else v for v in value ] elif has_unifi_dicts and key in unifi_dicts and isinstance(value, dict): values[key] = { k: unifi_dicts[key].model_construct(**v) if isinstance(v, dict) else v for k, v in value.items() } obj = super().model_construct(_fields_set=_fields_set, **values) if api is not None: obj._api = api return obj @classmethod @cache def _get_excluded_changed_fields(cls) -> set[str]: """ Helper method for override in child classes for fields that excluded from calculating "changed" state for a model (`.get_changed()`) """ return set() @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: """ Helper method for overriding in child classes for remapping UFP JSON keys to Python ones that do not fit the simple camel case to snake case formula. Return format is { "ufpJsonName": "python_name" } """ return {} @classmethod @cache def _get_to_unifi_remaps(cls) -> dict[str, str]: """ Helper method for overriding in child classes for reversing remap UFP JSON keys to Python ones that do not fit the simple camel case to snake case formula. Return format is { "python_name": "ufpJsonName" } """ return { to_key: from_key for from_key, to_key in cls._get_unifi_remaps().items() } @classmethod @cache def _get_protect_model(cls) -> _ProtectModelObjects: """Helper method to detect attrs of current class that are UFP Objects themselves""" objs: dict[str, type[ProtectBaseObject]] = {} lists: dict[str, type[ProtectBaseObject]] = {} dicts: dict[str, type[ProtectBaseObject]] = {} for name, field in cls.model_fields.items(): try: origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type] if _is_protect_base_object(type_): if origin is list: lists[name] = type_ elif origin is dict: dicts[name] = type_ else: objs[name] = type_ except TypeError: pass return _ProtectModelObjects( objs, bool(objs), lists, bool(lists), dicts, bool(dicts) ) @classmethod @cache def _get_excluded_fields(cls) -> set[str]: """Helper method to get all excluded fields for the current object.""" protect_model = cls._get_protect_model() return set(protect_model.objs) | set(protect_model.lists) @classmethod def _clean_protect_obj( cls, data: Any, klass: type[ProtectBaseObject], api: ProtectApiClient | None, ) -> Any: if isinstance(data, dict): if api is not None: data["api"] = api return klass.unifi_dict_to_dict(data=data) return data @classmethod def _clean_protect_obj_list( cls, items: list[Any], klass: type[ProtectBaseObject], api: ProtectApiClient | None, ) -> list[Any]: return [cls._clean_protect_obj(item, klass, api) for item in items] @classmethod def _clean_protect_obj_dict( cls, items: dict[Any, Any], klass: type[ProtectBaseObject], api: ProtectApiClient | None, ) -> dict[Any, Any]: return {k: cls._clean_protect_obj(v, klass, api) for k, v in items.items()} @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: """ Helper method for overriding in child classes for converting UFP JSON data to Python data types. Return format is { "ufpJsonName": Callable[[Any], Any] } """ return {} @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: """ Takes a decoded UFP JSON dict and converts it into a Python dict * Remaps items from `._get_unifi_remaps()` * Converts camelCase keys to snake_case keys * Injects ProtectAPIClient into any child UFP object Dicts * Runs `.unifi_dict_to_dict` for any child UFP objects Args: ---- data: decoded UFP JSON dict """ # get the API client instance api: ProtectApiClient | None = data.get("api") or ( cls._api if isinstance(cls, ProtectBaseObject) else None ) conversions = cls.unifi_dict_conversions() for key, convert in conversions.items(): if (val := data.get(key)) is not None: data[key] = convert(val) # type: ignore[operator] remaps = cls._get_unifi_remaps() # convert to snake_case and remove extra fields _fields = cls.model_fields for key in data.copy(): if key not in data: # key may have already been consumed by a previous # iteration when two keys map to the same snake_case key continue current_key = key if current_key in remaps: # remap keys that will not be converted correctly by snake_case convert remapped_key = remaps[current_key] data[remapped_key] = data.pop(current_key) current_key = remapped_key new_key = to_snake_case(current_key) data[new_key] = data.pop(current_key) current_key = new_key if current_key == "api": continue if current_key not in _fields: del data[current_key] continue data[current_key] = convert_unifi_data( data[current_key], _fields[current_key] ) if not data: return data # clean child UFP objs ( unifi_objs, has_unifi_objs, unifi_lists, has_unifi_lists, unifi_dicts, has_unifi_dicts, ) = cls._get_protect_model() for key, value in data.items(): if has_unifi_objs and key in unifi_objs: data[key] = cls._clean_protect_obj(value, unifi_objs[key], api) elif has_unifi_lists and key in unifi_lists and isinstance(value, list): data[key] = cls._clean_protect_obj_list(value, unifi_lists[key], api) elif has_unifi_dicts and key in unifi_dicts and isinstance(value, dict): data[key] = cls._clean_protect_obj_dict(value, unifi_dicts[key], api) return data def _unifi_dict_protect_obj( self, data: dict[str, Any], key: str, use_obj: bool, klass: type[ProtectBaseObject], ) -> Any: value: Any | None = data.get(key) if use_obj: value = getattr(self, key) if isinstance(value, ProtectBaseObject): value = value.unifi_dict() elif isinstance(value, dict): value = klass.model_construct({}).unifi_dict(data=value) # type: ignore[arg-type] return value def _unifi_dict_protect_obj_list( self, data: dict[str, Any], key: str, use_obj: bool, klass: type[ProtectBaseObject], ) -> Any: value: Any | None = data.get(key) if use_obj: value = getattr(self, key) if not isinstance(value, list): return value return [ item.unifi_dict() if isinstance(item, ProtectBaseObject) else klass.model_construct({}).unifi_dict(data=item) # type: ignore[arg-type] for item in value ] def _unifi_dict_protect_obj_dict( self, data: dict[str, Any], key: str, use_obj: bool, ) -> Any: value: Any | None = data.get(key) if use_obj: value = getattr(self, key) if not isinstance(value, dict): return value return { obj_key: obj.unifi_dict() if isinstance(obj, ProtectBaseObject) else obj for obj_key, obj in value.items() } def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: """ Can either convert current Python object into UFP JSON dict or take the output of a `.dict()` call and convert it. * Remaps items from `._get_unifi_remaps()` in reverse * Converts snake_case to camelCase * Automatically removes any ProtectApiClient instances that might still be in the data * Automatically calls `.unifi_dict()` for any UFP Python objects that are detected Args: ---- data: Optional output of `.dict()` for the Python object. If `None`, will call `.dict` first exclude: Optional set of fields to exclude from convert. Useful for subclassing and having custom processing for dumping to UFP JSON data. """ use_obj = False if data is None: excluded_fields = self._get_excluded_fields() if exclude is not None: excluded_fields = excluded_fields.copy() | exclude data = self.model_dump(exclude=excluded_fields) use_obj = True ( unifi_objs, has_unifi_objs, unifi_lists, has_unifi_lists, unifi_dicts, has_unifi_dicts, ) = self._get_protect_model() if has_unifi_objs: for key, klass in unifi_objs.items(): if use_obj or key in data: data[key] = self._unifi_dict_protect_obj(data, key, use_obj, klass) if has_unifi_lists: for key, klass in unifi_lists.items(): if use_obj or key in data: data[key] = self._unifi_dict_protect_obj_list( data, key, use_obj, klass ) if has_unifi_dicts: for key in unifi_dicts: if use_obj or key in data: data[key] = self._unifi_dict_protect_obj_dict(data, key, use_obj) # all child objects have been serialized correctly do not do it twice new_data: dict[str, Any] = serialize_unifi_obj(data, levels=2) remaps = self._get_to_unifi_remaps() for to_key in set(new_data).intersection(remaps): new_data[remaps[to_key]] = new_data.pop(to_key) return new_data def update_from_dict(self, data: dict[str, Any]) -> Self: """ Updates current object from a cleaned UFP JSON dict. The api client is injected into each dict for any child UFP objects that are detected. """ ( unifi_objs, has_unifi_objs, unifi_lists, has_unifi_lists, _unifi_dicts, _has_unifi_dicts, ) = self._get_protect_model() api = self._api _fields = self.__class__.model_fields unifi_obj: ProtectBaseObject | None value: Any for key, item in data.items(): if has_unifi_objs and key in unifi_objs and isinstance(item, dict): if (unifi_obj := getattr(self, key)) is not None: value = unifi_obj.update_from_dict(item) else: value = unifi_objs[key](**item, api=api) elif has_unifi_lists and key in unifi_lists and isinstance(item, list): klass = unifi_lists[key] value = [ klass(**i, api=api) if isinstance(i, dict) else i for i in item if i is not None and isinstance(i, (dict, ProtectBaseObject)) ] else: value = convert_unifi_data(item, _fields[key]) setattr(self, key, value) return self def dict_with_excludes(self) -> dict[str, Any]: """Returns a dict of the current object without any UFP objects converted to dicts.""" excludes = self.__class__._get_excluded_changed_fields() return self.model_dump(exclude=excludes) def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]: return dict_diff(data_before_changes, self.model_dump()) @property def api(self) -> ProtectApiClient: """ ProtectApiClient that the UFP object was created with. If no API Client was passed in time of creation, will raise `BadRequest` """ if self._api is None: raise BadRequest("API Client not initialized") return self._api class ProtectModel(ProtectBaseObject): """ Base class for UFP objects with a `modelKey` attr. Provides `.from_unifi_dict()` static helper method for automatically decoding a `modelKey` object into the correct UFP object and type """ model: ModelType | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "modelKey": "model"} def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"modelKey"}) return data class UpdateSynchronization: """Helper class for managing updates to Protect devices.""" @cached_property def lock(self) -> asyncio.Lock: """Lock to prevent multiple updates at once.""" return asyncio.Lock() @cached_property def queue(self) -> asyncio.Queue[Callable[[], None]]: """Queue to store device updates.""" return asyncio.Queue() @cached_property def event(self) -> asyncio.Event: """Event to signal when a device update has been queued.""" return asyncio.Event() class ProtectModelWithId(ProtectModel): id: str _update_sync: UpdateSynchronization = PrivateAttr(None) # type: ignore[assignment] def __init__(self, **data: Any) -> None: update_sync = data.pop("update_sync", None) super().__init__(**data) self._update_sync = update_sync or UpdateSynchronization() @classmethod def model_construct( cls, _fields_set: set[str] | None = None, **values: Any ) -> Self: update_sync = values.pop("update_sync", None) obj = super().model_construct(_fields_set=_fields_set, **values) obj._update_sync = update_sync or UpdateSynchronization() return obj @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return set() async def _api_update(self, data: dict[str, Any]) -> None: raise NotImplementedError def revert_changes(self, data_before_changes: dict[str, Any]) -> None: """Reverts current changes to device and resets it back to initial state""" changed = self.get_changed(data_before_changes) for key in changed: setattr(self, key, data_before_changes[key]) def can_create(self, user: User) -> bool: if (model := self.model) is not None: return user.can(model, PermissionNode.CREATE, self) return True def can_read(self, user: User) -> bool: if (model := self.model) is not None: return user.can(model, PermissionNode.READ, self) return True def can_write(self, user: User) -> bool: if (model := self.model) is not None: return user.can(model, PermissionNode.WRITE, self) return True def can_delete(self, user: User) -> bool: if (model := self.model) is not None: return user.can(model, PermissionNode.DELETE, self) return True async def queue_update(self, callback: Callable[[], None]) -> None: """ Queues a device update. This allows aggregating devices updates so if multiple ones come in all at once, they can be combined in a single PATCH. """ self._update_sync.queue.put_nowait(callback) self._update_sync.event.set() # release execution so other `queue_update` calls can abort await asyncio.sleep(0.001) self._update_sync.event.clear() try: async with asyncio_timeout(0.05): await self._update_sync.event.wait() self._update_sync.event.clear() return except TimeoutError: async with self._update_sync.lock: # Important! Now that we have the lock, we yield to the event loop so any # updates from the websocket are processed before we generate the diff await asyncio.sleep(0) # Save the initial data before we generate the diff data_before_changes = self.dict_with_excludes() while not self._update_sync.queue.empty(): callback = self._update_sync.queue.get_nowait() callback() # Important, do not yield to the event loop before generating the diff # otherwise we may miss updates from the websocket await self._save_device_changes( data_before_changes, self.unifi_dict(data=self.get_changed(data_before_changes)), ) async def save_device( self, data_before_changes: dict[str, Any], force_emit: bool = False, revert_on_fail: bool = True, ) -> None: """ Generates a diff for unsaved changed on the device and sends them back to UFP USE WITH CAUTION, updates _all_ fields for the current object that have been changed. May have unexpected side effects. Tested updates have been added a methods on applicable devices. Args: ---- force_emit: Emit a fake UFP WS message. Should only be use for when UFP does not properly emit a WS message """ # do not allow multiple save_device calls at once release_lock = False if not self._update_sync.lock.locked(): await self._update_sync.lock.acquire() release_lock = True try: await self._save_device_changes( data_before_changes, self.unifi_dict(data=self.get_changed(data_before_changes)), force_emit=force_emit, revert_on_fail=revert_on_fail, ) finally: if release_lock: self._update_sync.lock.release() async def _save_device_changes( self, data_before_changes: dict[str, Any], updated: dict[str, Any], force_emit: bool = False, revert_on_fail: bool = True, ) -> None: """Saves the current device changes to UFP.""" _LOGGER.debug( "Saving device changes for %s (%s) data_before_changes=%s updated=%s", self.id, self.model, data_before_changes, updated, ) assert self._update_sync.lock.locked(), ( "save_device_changes should only be called when the update lock is held" ) read_only_fields = self.__class__._get_read_only_fields() if self.model is None: raise BadRequest("Unknown model type") if not self._api.bootstrap.auth_user.can( self.model, PermissionNode.WRITE, self ): if revert_on_fail: self.revert_changes(data_before_changes) raise NotAuthorized(f"Do not have write permission for obj: {self.id}") # do not patch when there are no updates if updated == {}: return read_only_keys = read_only_fields.intersection(updated) if len(read_only_keys) > 0: self.revert_changes(data_before_changes) raise BadRequest( f"{type(self)} The following key(s) are read only: {read_only_keys}, updated: {updated}", ) try: await self._api_update(updated) except ClientError: if revert_on_fail: self.revert_changes(data_before_changes) raise if force_emit: self._emit_message(updated) async def emit_message(self, updated: dict[str, Any]) -> None: """Emits fake WS message for ProtectApiClient to process.""" self._emit_message(updated) def _emit_message(self, updated: dict[str, Any]) -> None: """Emits fake WS message for ProtectApiClient to process.""" if _is_ping_back := updated is _EMPTY_EVENT_PING_BACK: _LOGGER.debug("Event ping callback started for %s", self.id) if self.model is None: raise BadRequest("Unknown model type") header = WSPacketFrameHeader( packet_type=1, payload_format=ProtectWSPayloadFormat.JSON.value, deflated=0, unknown=1, payload_size=1, ) action_frame = WSJSONPacketFrame() action_frame.header = header action_frame.data = { "action": "update", "newUpdateId": None, "modelKey": self.model.value, "id": self.id, } data_frame = WSJSONPacketFrame() data_frame.header = header data_frame.data = updated message = self._api.bootstrap.process_ws_packet( WSPacket(action_frame.packed + data_frame.packed), is_ping_back=_is_ping_back, ) if message is not None: self._api.emit_message(message) @runtime_checkable class ProtectDeviceIdentity(Protocol): """ Identity surface shared by the private and public device / NVR model trees. Consumers that handle "the NVR" or "a camera" generically can type against this instead of ``cast()``-ing between the unrelated private (``NVR`` / ``Camera`` / ...) and public (``PublicNVR`` / ``PublicCamera`` / ...) types. ``mac`` and ``type`` are ``Optional`` because the public tree omits them on older firmware. """ @property def id(self) -> str: ... @property def mac(self) -> str | None: ... @property def display_name(self) -> str: ... @property def type(self) -> str | None: ... @property def model(self) -> ModelType | None: ... class ProtectDeviceModel(ProtectModelWithId): name: str | None = None type: str mac: str host: IPv4Address | IPv6Address | str | None = None up_since: datetime | None = None uptime: timedelta | None = None last_seen: datetime | None = None hardware_revision: str | None = None firmware_version: str | None = None is_updating: bool is_ssh_enabled: bool _callback_ping: TimerHandle | None = PrivateAttr(None) @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | { "mac", "host", "type", "upSince", "uptime", "lastSeen", "hardwareRevision", "isUpdating", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "upSince": convert_to_datetime, "uptime": lambda x: timedelta(milliseconds=int(x)), "lastSeen": convert_to_datetime, # hardware revisions for all devices are not simple numbers # so cast them all to str to be consistent "hardwareRevision": str, } | super().unifi_dict_conversions() def _event_callback_ping(self) -> None: _LOGGER.debug("Event ping timer started for %s", self.id) loop = asyncio.get_running_loop() self._callback_ping = loop.call_later( EVENT_PING_INTERVAL_SECONDS, self._emit_message, _EMPTY_EVENT_PING_BACK, ) async def set_name(self, name: str | None) -> None: """Sets name for the device""" def callback() -> None: self.name = name await self.queue_update(callback) class WiredConnectionState(ProtectBaseObject): phy_rate: float | None = None class WirelessConnectionState(ProtectBaseObject): signal_quality: int | None = None signal_strength: int | None = None class BluetoothConnectionState(WirelessConnectionState): experience_score: PercentFloat | None = None class WifiConnectionState(WirelessConnectionState): phy_rate: float | None = None channel: int | None = None frequency: int | None = None ssid: str | None = None bssid: str | None = None tx_rate: float | None = None # requires 2.7.5+ ap_name: str | None = None experience: str | None = None # requires 2.7.15+ connectivity: str | None = None class ProtectAdoptableDeviceModel(ProtectDeviceModel): state: StateType connection_host: IPv4Address | IPv6Address | str | None = None connected_since: datetime | None = None latest_firmware_version: str | None = None firmware_build: str | None = None is_adopting: bool is_adopted: bool is_adopted_by_other: bool is_provisioned: bool is_rebooting: bool can_adopt: bool is_attempting_to_connect: bool is_connected: bool # requires 1.21+ market_name: str | None = None # requires 2.7.5+ fw_update_state: str | None = None # requires 2.8.14+ nvr_mac: str | None = None # requires 2.8.22+ guid: UUID | None = None # requires 2.9.20+ is_restoring: bool | None = None last_disconnect: datetime | None = None anonymous_device_id: UUID | None = None wired_connection_state: WiredConnectionState | None = None wifi_connection_state: WifiConnectionState | None = None bluetooth_connection_state: BluetoothConnectionState | None = None bridge_id: str | None = None is_downloading_firmware: bool | None = None @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | { "connectionHost", "connectedSince", "state", "latestFirmwareVersion", "firmwareBuild", "isAdopting", "isProvisioned", "isRebooting", "canAdopt", "isAttemptingToConnect", "bluetoothConnectionState", "isDownloadingFirmware", "anonymousDeviceId", } @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "bridge": "bridgeId", "isDownloadingFW": "isDownloadingFirmware", } async def _api_update(self, data: dict[str, Any]) -> None: if (model := self.model) is not None: return await self._api.update_device(model, self.id, data) return None def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none( data, {"wiredConnectionState", "wifiConnectionState", "bluetoothConnectionState"}, ) return data @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "lastDisconnect": convert_to_datetime, } | super().unifi_dict_conversions() @property def display_name(self) -> str: return self.name or self.market_name or self.type @property def is_wired(self) -> bool: return self.wired_connection_state is not None @property def is_wifi(self) -> bool: return self.wifi_connection_state is not None @property def is_bluetooth(self) -> bool: return self.bluetooth_connection_state is not None @property def bridge(self) -> Bridge | None: if (bridge_id := self.bridge_id) is not None: return self._api.bootstrap.bridges[bridge_id] return None @property def protect_url(self) -> str: """UFP Web app URL for this device""" return f"{self._api.base_url}/protect/devices/{self.id}" @property def is_adopted_by_us(self) -> bool: """Verifies device is adopted and controlled by this NVR.""" return self.is_adopted and not self.is_adopted_by_other def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]: """Gets dictionary of all changed fields""" return dict_diff(data_before_changes, self.dict_with_excludes()) async def set_ssh(self, enabled: bool) -> None: """Sets ssh status for protect device""" def callback() -> None: self.is_ssh_enabled = enabled await self.queue_update(callback) async def reboot(self) -> None: """Reboots an adopted device""" if self.model is not None: if not self._api.bootstrap.auth_user.can( self.model, PermissionNode.WRITE, self, ): raise NotAuthorized("Do not have permission to reboot device") await self._api.reboot_device(self.model, self.id) async def unadopt(self) -> None: """Unadopt/Unmanage adopted device""" if not self.is_adopted_by_us: raise BadRequest("Device is not adopted") if self.model is not None: if not self._api.bootstrap.auth_user.can( self.model, PermissionNode.DELETE, self, ): raise NotAuthorized("Do not have permission to unadopt devices") await self._api.unadopt_device(self.model, self.id) async def adopt(self, name: str | None = None) -> None: """Adopts a device""" if not self.can_adopt: raise BadRequest("Device cannot be adopted") if self.model is not None: if not self._api.bootstrap.auth_user.can(self.model, PermissionNode.CREATE): raise NotAuthorized("Do not have permission to adopt devices") await self._api.adopt_device(self.model, self.id) if name is not None: await self.set_name(name) class ProtectMotionDeviceModel(ProtectAdoptableDeviceModel): last_motion: datetime | None = None is_dark: bool # not directly from UniFi last_motion_event_id: str | None = None @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | {"lastMotion", "isDark"} def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_tuple(data, ("lastMotionEventId",)) return data @property def last_motion_event(self) -> Event | None: if (last_motion_event_id := self.last_motion_event_id) is not None: return self._api.bootstrap.events.get(last_motion_event_id) return None uilibs-uiprotect-317a52c/src/uiprotect/data/bootstrap.py000066400000000000000000000710271524637705700235000ustar00rootroot00000000000000"""UniFi Protect Bootstrap.""" from __future__ import annotations import asyncio import logging from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, cast from aiohttp.client_exceptions import ServerDisconnectedError from convertertools import pop_dict_set, pop_dict_tuple from pydantic import PrivateAttr, ValidationError from ..exceptions import ClientError, DataDecodeError from ..utils import normalize_mac, to_snake_case, utc_now from .base import ( RECENT_EVENT_MAX, ProtectBaseObject, ProtectDeviceModel, ProtectModel, ProtectModelWithId, ) from .convert import MODEL_TO_CLASS, create_from_unifi_dict from .devices import ( AiPort, Bridge, Camera, Chime, Light, ProtectAdoptableDeviceModel, Ringtone, Sensor, Viewer, ) from .nvr import MAX_EVENT_HISTORY_IN_STATE_MACHINE, NVR, Event, Liveview from .types import EventType, FixSizeOrderedDict, ModelType, SmartDetectObjectType from .user import Group, Keyrings, UlpUserKeyringBase, UlpUsers, User from .websocket import ( WSAction, WSPacket, WSSubscriptionMessage, ) if TYPE_CHECKING: from ..api import ProtectApiClient _LOGGER = logging.getLogger(__name__) STATS_KEYS = { "eventStats", "storageStats", "stats", "systemInfo", "phyRate", "wifiConnectionState", "upSince", "uptime", "lastSeen", "recordingSchedules", } IGNORE_DEVICE_KEYS = {"nvrMac", "guid"} STATS_AND_IGNORE_DEVICE_KEYS = STATS_KEYS | IGNORE_DEVICE_KEYS _IGNORE_KEYS_BY_MODEL_TYPE = { # # `lastMotion` from cameras update every 100 milliseconds when a motion event is active # this overrides the behavior to only update `lastMotion` when a new event starts # ModelType.CAMERA: {"lastMotion"}, # # `cameraIds` is updated every 10s, but we don't need to process it since bootstrap # is resynced every so often anyways. # ModelType.CHIME: {"cameraIds"}, } IGNORE_DEVICE_KEYS_BY_MODEL_TYPE = { model_type: IGNORE_DEVICE_KEYS | keys for model_type, keys in _IGNORE_KEYS_BY_MODEL_TYPE.items() } STATS_AND_IGNORE_DEVICE_KEYS_BY_MODEL_TYPE = { model_type: STATS_AND_IGNORE_DEVICE_KEYS | keys for model_type, keys in _IGNORE_KEYS_BY_MODEL_TYPE.items() } CAMERA_EVENT_ATTR_MAP: dict[EventType, tuple[str, str]] = { EventType.MOTION: ("last_motion", "last_motion_event_id"), EventType.SMART_DETECT: ("last_smart_detect", "last_smart_detect_event_id"), EventType.SMART_DETECT_LINE: ("last_smart_detect", "last_smart_detect_event_id"), EventType.SMART_AUDIO_DETECT: ( "last_smart_audio_detect", "last_smart_audio_detect_event_id", ), EventType.RING: ("last_ring", "last_ring_event_id"), EventType.NFC_CARD_SCANNED: ( "last_nfc_card_scanned", "last_nfc_card_scanned_event_id", ), EventType.FINGERPRINT_IDENTIFIED: ( "last_fingerprint_identified", "last_fingerprint_identified_event_id", ), } def _process_light_event(event: Event, light: Light) -> None: light.last_motion_event_id = event.id def _process_sensor_event(event: Event, sensor: Sensor) -> None: if event.type is EventType.MOTION_SENSOR: sensor.last_motion_event_id = event.id elif event.type in {EventType.SENSOR_CLOSED, EventType.SENSOR_OPENED}: sensor.last_contact_event_id = event.id elif event.type is EventType.SENSOR_EXTREME_VALUE: sensor.extreme_value_detected_at = event.end sensor.last_value_event_id = event.id elif event.type is EventType.SENSOR_ALARM: sensor.last_value_event_id = event.id _CAMERA_SMART_AND_LINE_EVENTS = { EventType.SMART_DETECT, EventType.SMART_DETECT_LINE, } _CAMERA_SMART_AUDIO_EVENT = EventType.SMART_AUDIO_DETECT def _find_active_smart_event( camera: Camera, smart_type: SmartDetectObjectType, ) -> Event | None: """ Find the first-registered still-active smart detection event for this type. Returns the earliest-inserted non-ended event in ``camera._active_smart_detect_events[smart_type]``. Since Python dicts preserve insertion order and active events are appended as they arrive, this matches "oldest by processing order" — not "oldest by event start time". Runs in O(active_events_per_type) and is independent of ``bootstrap.events``. """ active = camera._active_smart_detect_events.get(smart_type) if not active: return None # Return the oldest still-active event; clean up stale entries along the way. result: Event | None = None stale_ids: list[str] | None = None for eid, event in active.items(): if event.end is not None: if stale_ids is None: stale_ids = [] stale_ids.append(eid) elif result is None: result = event if stale_ids: for eid in stale_ids: del active[eid] if not active: camera._active_smart_detect_events.pop(smart_type, None) return result def _find_any_active_smart_event(camera: Camera) -> Event | None: """Return any still-active smart detect event across all types for a camera.""" for active in camera._active_smart_detect_events.values(): for event in active.values(): if event.end is None: return event return None def _singular_id_is_active(camera: Camera, singular_id: str) -> bool: """Check whether the singular last_smart_detect_event_id points to an active event.""" for active in camera._active_smart_detect_events.values(): if (event := active.get(singular_id)) is not None and event.end is None: return True return False def _process_smart_detect_event(event: Event, camera: Camera) -> None: """Process smart detect / smart detect line per-type tracking.""" event_id = event.id event_start = event.start if event.end is None: # Active event — becomes the new "last" for the camera and per-type. camera.last_smart_detect_event_id = event_id camera.last_smart_detect = event_start for smart_type in event.smart_detect_types: camera.last_smart_detect_event_ids[smart_type] = event_id camera.last_smart_detects[smart_type] = event_start # Add to per-camera active index while preserving processing order camera._active_smart_detect_events.setdefault(smart_type, {})[event_id] = ( event ) return for smart_type in event.smart_detect_types: # Event ended — remove from active index active = camera._active_smart_detect_events.get(smart_type) if active: active.pop(event_id, None) if not active: camera._active_smart_detect_events.pop(smart_type, None) current_id = camera.last_smart_detect_event_ids.get(smart_type) if current_id == event_id or current_id is None: # Tracked event ended, or no event tracked yet — find a replacement replacement = _find_active_smart_event(camera, smart_type) if replacement is not None: camera.last_smart_detect_event_ids[smart_type] = replacement.id camera.last_smart_detects[smart_type] = replacement.start elif current_id is None: # No replacement and nothing tracked — store ended event # as last known state camera.last_smart_detect_event_ids[smart_type] = event_id camera.last_smart_detects[smart_type] = event_start # If current_id is set and != event_id, another event # is already tracked — do nothing # Update the singular last_smart_detect_event_id only if it no longer # references an active event. Protect v7 emits overlapping SMART_DETECT # and SMART_DETECT_LINE events; letting an ended event overwrite the # singular field would make is_smart_currently_detected flip OFF while # the sibling event is still active. singular_id = camera.last_smart_detect_event_id if singular_id is None or not _singular_id_is_active(camera, singular_id): replacement = _find_any_active_smart_event(camera) if replacement is not None: camera.last_smart_detect_event_id = replacement.id camera.last_smart_detect = replacement.start elif singular_id is None: camera.last_smart_detect_event_id = event_id camera.last_smart_detect = event_start def _process_camera_event(event: Event, camera: Camera) -> None: event_type = event.type if event_type in _CAMERA_SMART_AND_LINE_EVENTS: _process_smart_detect_event(event, camera) return dt_attr, event_attr = CAMERA_EVENT_ATTR_MAP[event_type] event_id = event.id event_start = event.start setattr(camera, event_attr, event_id) setattr(camera, dt_attr, event_start) if event_type is _CAMERA_SMART_AUDIO_EVENT: for smart_type in event.smart_detect_types: if (audio_type := smart_type.audio_type) is None: continue camera.last_smart_audio_detect_event_ids[audio_type] = event_id camera.last_smart_audio_detects[audio_type] = event_start @dataclass class WSStat: model: str action: str keys: list[str] keys_set: list[str] size: int filtered: bool class ProtectDeviceRef(ProtectBaseObject): model: ModelType id: str class Bootstrap(ProtectBaseObject): auth_user_id: str access_key: str cameras: dict[str, Camera] users: dict[str, User] groups: dict[str, Group] liveviews: dict[str, Liveview] nvr: NVR viewers: dict[str, Viewer] lights: dict[str, Light] = {} bridges: dict[str, Bridge] = {} sensors: dict[str, Sensor] = {} chimes: dict[str, Chime] = {} aiports: dict[str, AiPort] = {} ringtones: list[Ringtone] last_update_id: str # not directly from UniFi keyrings: Keyrings = Keyrings() ulp_users: UlpUsers = UlpUsers() events: dict[str, Event] = FixSizeOrderedDict( max_size=MAX_EVENT_HISTORY_IN_STATE_MACHINE ) capture_ws_stats: bool = False mac_lookup: dict[str, ProtectDeviceRef] = {} id_lookup: dict[str, ProtectDeviceRef] = {} _ws_stats: list[WSStat] = PrivateAttr([]) _has_doorbell: bool | None = PrivateAttr(None) _has_smart: bool | None = PrivateAttr(None) _has_media: bool | None = PrivateAttr(None) _recording_start: datetime | None = PrivateAttr(None) _refresh_tasks: set[asyncio.Task[None]] = PrivateAttr(set()) @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: api: ProtectApiClient | None = data.get("api") or ( cls._api if isinstance(cls, ProtectBaseObject) else None ) mac_lookup: dict[str, dict[str, str | ModelType]] = {} id_lookup: dict[str, dict[str, str | ModelType]] = {} data["idLookup"] = id_lookup data["macLookup"] = mac_lookup # Fields that are not (always?) available in newer Protect versions optional_fields: set[str] = set() for model_type in ModelType.bootstrap_models_types_set: key = model_type.devices_key # type: ignore[attr-defined] items: dict[str, ProtectModel] = {} if key not in data: # Optional fields with defaults don't need logging or setting if key not in optional_fields: data[key] = {} _LOGGER.error( "Missing key in bootstrap: %s. This may be fixed by updating Protect.", key, ) continue for item in data[key]: if ( api is not None and api.ignore_unadopted and not item.get("isAdopted", True) ): continue id_: str = item["id"] ref = {"model": model_type, "id": id_} items[id_] = item id_lookup[id_] = ref if "mac" in item: cleaned_mac = normalize_mac(item["mac"]) mac_lookup[cleaned_mac] = ref data[key] = items return super().unifi_dict_to_dict(data) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_tuple(data, ("events", "captureWsStats", "macLookup", "idLookup")) for model_type in ModelType.bootstrap_models_types_set: attr = model_type.devices_key # type: ignore[attr-defined] if attr in data and isinstance(data[attr], dict): data[attr] = list(data[attr].values()) return data @property def ws_stats(self) -> list[WSStat]: return self._ws_stats def clear_ws_stats(self) -> None: self._ws_stats = [] @property def auth_user(self) -> User: return self._api.bootstrap.users[self.auth_user_id] @property def has_doorbell(self) -> bool: if self._has_doorbell is None: self._has_doorbell = any( c.feature_flags.is_doorbell for c in self.cameras.values() ) return self._has_doorbell @property def recording_start(self) -> datetime | None: """Get earliest recording date.""" if self._recording_start is None: try: self._recording_start = min( c.stats.video.recording_start for c in self.cameras.values() if c.stats.video.recording_start is not None ) except ValueError: return None return self._recording_start @property def has_smart_detections(self) -> bool: """Check if any camera has smart detections.""" if self._has_smart is None: self._has_smart = any( c.feature_flags.has_smart_detect for c in self.cameras.values() ) return self._has_smart @property def has_media(self) -> bool: """Checks if user can read media for any camera.""" if self._has_media is None: if self.recording_start is None: return False self._has_media = any( c.can_read_media(self.auth_user) for c in self.cameras.values() ) return self._has_media def get_device_from_mac(self, mac: str) -> ProtectAdoptableDeviceModel | None: """Retrieve a device from MAC address.""" return self._get_device_from_ref(self.mac_lookup.get(normalize_mac(mac))) def get_device_from_id(self, device_id: str) -> ProtectAdoptableDeviceModel | None: """Retrieve a device from device ID (without knowing model type).""" return self._get_device_from_ref(self.id_lookup.get(device_id)) def _get_device_from_ref( self, ref: ProtectDeviceRef | None ) -> ProtectAdoptableDeviceModel | None: if ref is None: return None devices_key = ref.model.devices_key devices: dict[str, ProtectAdoptableDeviceModel] = getattr(self, devices_key) return devices[ref.id] def process_event(self, event: Event) -> None: event_type = event.type if event_type in CAMERA_EVENT_ATTR_MAP and (camera := event.camera): _process_camera_event(event, camera) elif event_type is EventType.MOTION_LIGHT and (light := event.light): _process_light_event(event, light) elif event_type is EventType.MOTION_SENSOR and (sensor := event.sensor): _process_sensor_event(event, sensor) self.events[event.id] = event def _process_add_packet( self, model_type: ModelType, data: dict[str, Any], ) -> WSSubscriptionMessage | None: if ( model_type is ModelType.EVENT and data.get("type") not in EventType.values_set() ): # Newer firmware can introduce event types this client doesn't model # yet. Mirror the REST ``get_events`` guard (``api.py``) and skip the # frame up front instead of routing a benign forward-compat case # through the generic ``ValidationError`` backstop below. _LOGGER.debug("Unknown event type: %s", data) return None obj = create_from_unifi_dict(data, api=self._api, model_type=model_type) if model_type is ModelType.EVENT: if TYPE_CHECKING: assert isinstance(obj, Event) self.process_event(obj) elif model_type is ModelType.NVR: if TYPE_CHECKING: assert isinstance(obj, NVR) self.nvr = obj elif model_type in ModelType.bootstrap_models_types_set: if TYPE_CHECKING: assert isinstance(obj, ProtectAdoptableDeviceModel) if not self._api.ignore_unadopted or ( obj.is_adopted and not obj.is_adopted_by_other ): id_ = obj.id getattr(self, model_type.devices_key)[id_] = obj ref = ProtectDeviceRef(model=model_type, id=id_) self.id_lookup[id_] = ref self.mac_lookup[normalize_mac(obj.mac)] = ref else: _LOGGER.debug("Unexpected bootstrap model type for add: %s", model_type) return None return WSSubscriptionMessage( action=WSAction.ADD, new_update_id=self.last_update_id, changed_data=obj.model_dump(), new_obj=obj, ) def _process_remove_packet( self, model_type: ModelType, action: dict[str, Any] ) -> WSSubscriptionMessage | None: devices_key = model_type.devices_key devices: dict[str, ProtectDeviceModel] | None = getattr(self, devices_key, None) if devices is None: return None device_id: str = action["id"] self.id_lookup.pop(device_id, None) if (device := devices.pop(device_id, None)) is None: return None self.mac_lookup.pop(normalize_mac(device.mac), None) return WSSubscriptionMessage( action=WSAction.REMOVE, new_update_id=self.last_update_id, changed_data={}, old_obj=device, ) def _process_ws_keyring_or_ulp_user_message( self, action: dict[str, Any], data: dict[str, Any], model_type: ModelType, ) -> WSSubscriptionMessage | None: action_id = action["id"] obj_from_bootstrap: UlpUserKeyringBase[ProtectModelWithId] = getattr( self, to_snake_case(model_type.devices_key) ) action_type = action["action"] if action_type == "add": add_obj = create_from_unifi_dict(data, api=self._api, model_type=model_type) if TYPE_CHECKING: model_class = MODEL_TO_CLASS.get(model_type) assert model_class is not None and isinstance(add_obj, model_class) add_obj = cast("ProtectModelWithId", add_obj) obj_from_bootstrap.add(add_obj) return WSSubscriptionMessage( action=WSAction.ADD, new_update_id=self.last_update_id, changed_data=add_obj.model_dump(), new_obj=add_obj, ) if action_type == "remove": to_remove = obj_from_bootstrap.by_id(action_id) if to_remove is None: return None obj_from_bootstrap.remove(to_remove) return WSSubscriptionMessage( action=WSAction.REMOVE, new_update_id=self.last_update_id, changed_data={}, old_obj=to_remove, ) if action_type == "update": updated_obj = obj_from_bootstrap.by_id(action_id) if updated_obj is None: return None old_obj = updated_obj.model_copy() updated_data = {to_snake_case(k): v for k, v in data.items()} updated_obj.update_from_dict(updated_data) return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=self.last_update_id, changed_data=updated_data, new_obj=updated_obj, old_obj=old_obj, ) _LOGGER.debug("Unexpected ws action for %s: %s", model_type, action_type) return None def _process_nvr_update( self, action: dict[str, Any], data: dict[str, Any], ignore_stats: bool, ) -> WSSubscriptionMessage | None: if ignore_stats: pop_dict_set(data, STATS_KEYS) # nothing left to process if not data: return None # for another NVR in stack nvr_id: str | None = action.get("id") if nvr_id and nvr_id != self.nvr.id: return None # nothing left to process if not (data := self.nvr.unifi_dict_to_dict(data)): return None old_nvr = self.nvr.model_copy() self.nvr = self.nvr.update_from_dict(data) return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=self.last_update_id, changed_data=data, new_obj=self.nvr, old_obj=old_nvr, ) def _process_device_update( self, model_type: ModelType, action: dict[str, Any], data: dict[str, Any], ignore_stats: bool, is_ping_back: bool, ) -> WSSubscriptionMessage | None: """ Process a device update packet. If is_ping_back is True, the packet is an empty packet that was generated internally as a result of an event that will expire and result in a state change. """ if ignore_stats: remove_keys = STATS_AND_IGNORE_DEVICE_KEYS_BY_MODEL_TYPE.get( model_type, STATS_AND_IGNORE_DEVICE_KEYS ) else: remove_keys = IGNORE_DEVICE_KEYS_BY_MODEL_TYPE.get( model_type, IGNORE_DEVICE_KEYS ) pop_dict_set(data, remove_keys) # nothing left to process if not data and not is_ping_back: return None devices: dict[str, ProtectModelWithId] = getattr(self, model_type.devices_key) action_id: str = action["id"] if action_id not in devices: # ignore updates to events that phase out if model_type is not ModelType.EVENT: _LOGGER.debug("Unexpected %s: %s", model_type, action_id) return None obj = devices[action_id] data = obj.unifi_dict_to_dict(data) if not data and not is_ping_back: # nothing left to process return None old_obj = obj.model_copy() obj = obj.update_from_dict(data) if model_type is ModelType.EVENT: if TYPE_CHECKING: assert isinstance(obj, Event) self.process_event(obj) elif model_type is ModelType.SENSOR: if TYPE_CHECKING: assert isinstance(obj, Sensor) if "alarm_triggered_at" in data and (trigged_at := obj.alarm_triggered_at): if is_recent := trigged_at + RECENT_EVENT_MAX >= utc_now(): obj.set_alarm_timeout() _LOGGER.debug("alarm_triggered_at for %s (%s)", obj.id, is_recent) devices[action_id] = obj return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=self.last_update_id, changed_data=data, new_obj=obj, old_obj=old_obj, ) def process_ws_packet( self, packet: WSPacket, models: set[ModelType] | None = None, ignore_stats: bool = False, is_ping_back: bool = False, ) -> WSSubscriptionMessage | None: """Process a WS packet.""" capture_ws_stats = self.capture_ws_stats action = packet.action_frame.data data = packet.data_frame.data keys = list(data) if capture_ws_stats else None new_update_id: str | None = action["newUpdateId"] if new_update_id is not None: self.last_update_id = new_update_id message = self._make_ws_packet_message( action, data, models, ignore_stats, is_ping_back ) if capture_ws_stats: if TYPE_CHECKING: assert keys is not None self._ws_stats.append( WSStat( model=action["modelKey"], action=action["action"], keys=keys, keys_set=[] if message is None else list(message.changed_data), size=len(packet.raw), filtered=message is None, ), ) return message def _make_ws_packet_message( # noqa: PLR0911 self, action: dict[str, Any], data: dict[str, Any], models: set[ModelType] | None, ignore_stats: bool, is_ping_back: bool, ) -> WSSubscriptionMessage | None: """Process a WS packet.""" model_key: str = action["modelKey"] if (model_type := ModelType.from_string(model_key)) is ModelType.UNKNOWN: _LOGGER.debug("Unknown model type: %s", model_key) return None if models and model_type not in models: return None action_action: str = action["action"] try: if model_type in {ModelType.KEYRING, ModelType.ULP_USER}: return self._process_ws_keyring_or_ulp_user_message( action, data, model_type ) if action_action == "remove": return self._process_remove_packet(model_type, action) if not data and not is_ping_back: return None if action_action == "add": return self._process_add_packet(model_type, data) if action_action == "update": if model_type is ModelType.NVR: return self._process_nvr_update(action, data, ignore_stats) if model_type in ModelType.bootstrap_models_types_and_event_set: return self._process_device_update( model_type, action, data, ignore_stats, is_ping_back ) except (DataDecodeError, ValidationError, ValueError, KeyError) as err: self._handle_ws_error(action_action, model_type, action, err) _LOGGER.debug( "Unexpected bootstrap model type deviceadoptedfor update: %s", model_key ) return None def _handle_ws_error( self, action_action: str, model_type: ModelType, action: dict[str, Any], err: Exception, ) -> None: msg = "" device_id: str | None = action.get("id") if model_type is ModelType.EVENT: msg = f"Validation error processing event: {device_id}. Ignoring event." elif device_id is not None: task = asyncio.create_task(self.refresh_device(model_type, device_id)) self._refresh_tasks.add(task) task.add_done_callback(self._refresh_tasks.discard) msg = ( f"{action_action} packet caused invalid state. " f"Refreshing device: {model_type} {device_id}" ) else: msg = ( f"{action_action} packet for {model_type} " f"has no device ID, cannot refresh" ) _LOGGER.debug("%s Error: %s", msg, err) async def refresh_device(self, model_type: ModelType, device_id: str) -> None: """Refresh a device in the bootstrap.""" try: if model_type is ModelType.NVR: device: ProtectModelWithId = await self._api.get_nvr() else: device = await self._api.get_device(model_type, device_id) except ( DataDecodeError, ValidationError, TimeoutError, ClientError, ServerDisconnectedError, ): _LOGGER.warning("Failed to refresh model: %s %s", model_type, device_id) return if isinstance(device, NVR): self.nvr = device else: devices_key = model_type.devices_key devices: dict[str, ProtectModelWithId] = getattr(self, devices_key) devices[device.id] = device _LOGGER.debug("Successfully refresh model: %s %s", model_type, device_id) async def get_is_prerelease(self) -> bool: """[DEPRECATED] Always returns False. Will be removed after HA 2025.8.0.""" return False uilibs-uiprotect-317a52c/src/uiprotect/data/convert.py000066400000000000000000000060671524637705700231450ustar00rootroot00000000000000"""UniFi Protect Data Conversion.""" from __future__ import annotations from typing import TYPE_CHECKING, Any, cast from ..exceptions import DataDecodeError from .devices import ( AiPort, Bridge, Camera, Chime, Light, Sensor, Viewer, ) from .nvr import NVR, Event, Liveview from .public_devices import Fob, LinkStation, Relay, Siren, Speaker from .types import ModelType from .user import CloudAccount, Group, Keyring, UlpUser, User, UserLocation if TYPE_CHECKING: from ..api import ProtectApiClient from ..data.base import ProtectModel, ProtectModelWithId MODEL_TO_CLASS: dict[str, type[ProtectModel]] = { ModelType.EVENT: Event, ModelType.GROUP: Group, ModelType.USER_LOCATION: UserLocation, ModelType.CLOUD_IDENTITY: CloudAccount, ModelType.USER: User, ModelType.NVR: NVR, ModelType.LIGHT: Light, ModelType.CAMERA: Camera, ModelType.LIVEVIEW: Liveview, ModelType.VIEWPORT: Viewer, ModelType.BRIDGE: Bridge, ModelType.SENSOR: Sensor, ModelType.CHIME: Chime, ModelType.AIPORT: AiPort, ModelType.KEYRING: Keyring, ModelType.ULP_USER: UlpUser, # Public API only. These are not produced by the private # bootstrap, so private-API callers remain unaffected. ModelType.SIREN: Siren, ModelType.RELAY: Relay, ModelType.FOB: Fob, ModelType.SPEAKER: Speaker, ModelType.LINK_STATION: LinkStation, } def get_klass_from_dict(data: dict[str, Any]) -> type[ProtectModel]: """ Helper method to read the `modelKey` from a UFP JSON dict and get the correct Python class for conversion. Will raise `DataDecodeError` if the `modelKey` is for an unknown object. """ if "modelKey" not in data: raise DataDecodeError("No modelKey") model = ModelType(data["modelKey"]) klass = MODEL_TO_CLASS.get(model) if klass is None: raise DataDecodeError("Unknown modelKey") return klass def create_from_unifi_dict( data: dict[str, Any], api: ProtectApiClient | None = None, klass: type[ProtectModel] | None = None, model_type: ModelType | None = None, ) -> ProtectModel: """ Convert a UFP JSON dict to the correct Python class. When ``model_type`` is provided (e.g. from WS action headers), ``modelKey`` will be synthesised if absent (Protect 7+). Raises ``DataDecodeError`` if the model cannot be resolved. """ if model_type is not None: if klass is None: klass = MODEL_TO_CLASS.get(model_type) # Protect 7+ may omit modelKey from WS data payloads; add it if missing. if "modelKey" not in data: data["modelKey"] = model_type.value if "modelKey" not in data: raise DataDecodeError("No modelKey") if klass is None: klass = get_klass_from_dict(data) return klass.from_unifi_dict(**data, api=api) def list_from_unifi_list( api: ProtectApiClient, unifi_list: list[dict[str, Any]] ) -> list[ProtectModelWithId]: return [ cast("ProtectModelWithId", create_from_unifi_dict(obj_dict, api)) for obj_dict in unifi_list ] uilibs-uiprotect-317a52c/src/uiprotect/data/devices.py000066400000000000000000004374711524637705700231160ustar00rootroot00000000000000"""UniFi Protect Data.""" from __future__ import annotations import asyncio import logging import warnings from collections.abc import Callable, Mapping from datetime import datetime, timedelta from functools import cache, lru_cache from ipaddress import IPv4Address, IPv6Address from pathlib import Path from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal, cast from convertertools import pop_dict_set_if_none, pop_dict_tuple from pydantic import model_validator from pydantic.fields import PrivateAttr from ..exceptions import BadRequest, NotAuthorized, StreamError from ..stream import TalkbackSession, TalkbackStream from ..utils import ( clamp_value, convert_smart_audio_types, convert_smart_types, convert_to_datetime, convert_video_modes, format_host_for_url, from_js_time, serialize_point, timedelta_total_seconds, to_js_time, utc_now, ) from .base import ( EVENT_PING_INTERVAL, ProtectAdoptableDeviceModel, ProtectBaseObject, ProtectMotionDeviceModel, ) from .public_devices import ( _PUBLIC_HUMIDITY_LOW_RANGE, _PUBLIC_LED_LEVEL_RANGE, _PUBLIC_LIGHT_LUX_LOW_RANGE, _PUBLIC_MIC_VOLUME_RANGE, _PUBLIC_SENSITIVITY_RANGE, _PUBLIC_TEMPERATURE_LOW_RANGE, _build_public_lcd_message, _coerce_public_int, _validate_public_range, ) from .types import ( DEFAULT, DEFAULT_TYPE, AudioCodecs, AudioStyle, AutoExposureMode, ChannelQuality, ChimeType, Color, DoorbellMessageType, FocusMode, GeofencingSetting, HDRMode, ICRCustomValue, ICRLuxValue, ICRSensitivity, IRLEDMode, IteratorCallback, LEDLevel, LensType, LightModeEnableType, LightModeType, LowMedHigh, ModelType, MotionAlgorithm, MountPosition, MountType, OsdOverlayLocation, Percent, PercentInt, PermissionNode, ProgressCallback, PTZPatrol, PTZPreset, PublicHdrMode, RecordingMode, RepeatTimes, SensorScheduleMode, SensorStatusType, SmartDetectAudioType, SmartDetectObjectType, TwoByteInt, VideoMode, WDRLevel, ) from .user import User if TYPE_CHECKING: from ..api import ( PublicApiChimeRingSettingRequest, RTSPSStreams, ) from .nvr import Event, Liveview from .public_devices import ( PublicCameraLedSettings, PublicOsdSettings, PublicSensorAlarmSettings, PublicSensorGlassBreakSettingsWrite, PublicSensorHumiditySettings, PublicSensorLightSettings, PublicSensorMotionSettings, PublicSensorTemperatureSettings, ) PRIVACY_ZONE_NAME = "pyufp_privacy_zone" LUX_MAPPING_VALUES = [30, 25, 20, 15, 12, 10, 7, 5, 3, 1, 0] # Protect switches the ICR on the lux threshold only in these modes; `auto`/ # `autoFilterOnly` switch on sensitivity and discard icrCustomValue, and # `manual`/`on`/`off` force the IR cut filter, so the threshold is inert there. _ICR_LUX_IR_LED_MODES = frozenset({IRLEDMode.CUSTOM, IRLEDMode.CUSTOM_FILTER_ONLY}) _LOGGER = logging.getLogger(__name__) class LightDeviceSettings(ProtectBaseObject): # Status LED is_indicator_enabled: bool # Brightness led_level: LEDLevel pir_duration: timedelta pir_sensitivity: PercentInt # lux_sensitivity exists in Private API but not in Public API - filtered when sending updates lux_sensitivity: LowMedHigh | None = None @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "pirDuration": lambda x: timedelta(milliseconds=x) } | super().unifi_dict_conversions() class LightOnSettings(ProtectBaseObject): # Manual toggle in UI is_led_force_on: bool class LightModeSettings(ProtectBaseObject): # main "Lighting" settings mode: LightModeType enable_at: LightModeEnableType class Light(ProtectMotionDeviceModel): is_pir_motion_detected: bool is_light_on: bool is_locating: bool light_device_settings: LightDeviceSettings light_on_settings: LightOnSettings light_mode_settings: LightModeSettings camera_id: str | None = None is_camera_paired: bool @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "camera": "cameraId"} @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | { "isPirMotionDetected", "isLightOn", "isLocating", } @property def camera(self) -> Camera | None: """Paired Camera will always be none if no camera is paired""" if self.camera_id is None: return None return self._api.bootstrap.cameras[self.camera_id] async def set_paired_camera(self, camera: Camera | None) -> None: """Sets the camera paired with the light""" async with self._update_sync.lock: # yield to the event loop once we have the lock to process any pending updates await asyncio.sleep(0) data_before_changes = self.dict_with_excludes() if camera is None: self.camera_id = None else: self.camera_id = camera.id await self.save_device(data_before_changes, force_emit=True) async def set_status_light(self, enabled: bool) -> None: """Sets the status indicator light for the light""" def callback() -> None: self.light_device_settings.is_indicator_enabled = enabled await self.queue_update(callback) async def set_name_public(self, name: str) -> None: """Set light name via public API.""" updated = await self._api.update_light_public(self.id, name=name) self.name = updated.name async def set_flood_light_public(self, enabled: bool) -> None: """Force the flood light on/off via public API.""" await self._api.update_light_public(self.id, is_light_force_enabled=enabled) self.light_on_settings.is_led_force_on = enabled async def set_status_light_public(self, enabled: bool) -> None: """Toggle the status indicator LED via public API.""" device_settings = self.light_device_settings.model_copy() device_settings.is_indicator_enabled = enabled await self._api.update_light_public( self.id, light_device_settings=device_settings ) self.light_device_settings = device_settings async def set_led_level_public(self, led_level: float) -> None: """Set the LED brightness level via public API.""" led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE) device_settings = self.light_device_settings.model_copy() device_settings.led_level = LEDLevel(led_level) await self._api.update_light_public( self.id, light_device_settings=device_settings ) self.light_device_settings = device_settings async def set_light_public( self, enabled: bool, led_level: float | None = None ) -> None: """Force the light on/off, optionally setting LED brightness (1-6), via public API.""" if led_level is None: await self._api.update_light_public(self.id, is_light_force_enabled=enabled) self.light_on_settings.is_led_force_on = enabled return led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE) device_settings = self.light_device_settings.model_copy() device_settings.led_level = LEDLevel(led_level) await self._api.update_light_public( self.id, is_light_force_enabled=enabled, light_device_settings=device_settings, ) self.light_on_settings.is_led_force_on = enabled self.light_device_settings = device_settings async def set_sensitivity_public(self, sensitivity: int) -> None: """Set PIR motion sensitivity via public API.""" device_settings = self.light_device_settings.model_copy() device_settings.pir_sensitivity = PercentInt(sensitivity) await self._api.update_light_public( self.id, light_device_settings=device_settings ) self.light_device_settings = device_settings async def set_duration_public(self, duration: timedelta) -> None: """Set how long the light stays on after motion (15s-900s) via public API.""" if duration.total_seconds() < 15 or duration.total_seconds() > 900: raise BadRequest("Duration outside of 15s to 900s range") device_settings = self.light_device_settings.model_copy() device_settings.pir_duration = duration await self._api.update_light_public( self.id, light_device_settings=device_settings ) self.light_device_settings = device_settings async def set_light_mode_public( self, mode: LightModeType, enable_at: LightModeEnableType | None = None, ) -> None: """Set the lighting trigger mode (and optional schedule) via public API.""" mode_settings = self.light_mode_settings.model_copy() mode_settings.mode = mode if enable_at is not None: mode_settings.enable_at = enable_at await self._api.update_light_public(self.id, light_mode_settings=mode_settings) self.light_mode_settings = mode_settings async def set_light_settings_public( self, mode: LightModeType, enable_at: LightModeEnableType | None = None, duration: timedelta | None = None, sensitivity: int | None = None, ) -> None: """Update mode, enable schedule, duration and PIR sensitivity via public API.""" if duration is not None and ( duration.total_seconds() < 15 or duration.total_seconds() > 900 ): raise BadRequest("Duration outside of 15s to 900s range") mode_settings = self.light_mode_settings.model_copy() mode_settings.mode = mode if enable_at is not None: mode_settings.enable_at = enable_at device_settings: LightDeviceSettings | None = None if duration is not None or sensitivity is not None: device_settings = self.light_device_settings.model_copy() if duration is not None: device_settings.pir_duration = duration if sensitivity is not None: device_settings.pir_sensitivity = PercentInt(sensitivity) await self._api.update_light_public( self.id, light_mode_settings=mode_settings, light_device_settings=device_settings, ) self.light_mode_settings = mode_settings if device_settings is not None: self.light_device_settings = device_settings RTSPS_QUALITY_BY_CHANNEL_ID: Mapping[int, ChannelQuality] = MappingProxyType( { 0: ChannelQuality.HIGH, 1: ChannelQuality.MEDIUM, 2: ChannelQuality.LOW, 3: ChannelQuality.PACKAGE, } ) CHANNEL_ID_BY_RTSPS_QUALITY: Mapping[ChannelQuality, int] = MappingProxyType( {quality: channel_id for channel_id, quality in RTSPS_QUALITY_BY_CHANNEL_ID.items()} ) def quality_for_channel_id(channel_id: int) -> ChannelQuality | None: """RTSPS quality tier for a channel id (0→HIGH, 1→MEDIUM, 2→LOW, 3→PACKAGE).""" return RTSPS_QUALITY_BY_CHANNEL_ID.get(channel_id) def channel_id_for_quality(quality: ChannelQuality) -> int | None: """Channel id for an RTSPS quality tier (inverse of quality_for_channel_id).""" return CHANNEL_ID_BY_RTSPS_QUALITY.get(quality) class CameraChannel(ProtectBaseObject): id: int # read only video_id: str # read only name: str # read only enabled: bool # read only is_rtsp_enabled: bool rtsp_alias: str | None = None # read only width: int height: int fps: int | None = None # read only bitrate: int min_bitrate: int | None = None # read only max_bitrate: int | None = None # read only min_client_adaptive_bit_rate: int | None = None # read only min_motion_adaptive_bit_rate: int | None = None # read only fps_values: list[int] # read only idr_interval: int # 3.0.22+ auto_bitrate: bool | None = None auto_fps: bool | None = None _parent: Camera | None = PrivateAttr(None) _rtsp_url: str | None = PrivateAttr(None) _rtsps_url: str | None = PrivateAttr(None) _rtsps_no_srtp_url: str | None = PrivateAttr(None) def _get_connection_host(self) -> IPv4Address | IPv6Address | str: """Get connection host (camera's for stacked NVR, otherwise NVR's).""" if self._parent is not None and self._parent.connection_host is not None: return self._parent.connection_host return self._api.connection_host @property def rtsp_url(self) -> str | None: if not self.is_rtsp_enabled or self.rtsp_alias is None: return None if self._rtsp_url is not None: return self._rtsp_url host = format_host_for_url(self._get_connection_host()) self._rtsp_url = ( f"rtsp://{host}:{self._api.bootstrap.nvr.ports.rtsp}/{self.rtsp_alias}" ) return self._rtsp_url @property def rtsps_url(self) -> str | None: if not self.is_rtsp_enabled or self.rtsp_alias is None: return None if self._rtsps_url is not None: return self._rtsps_url host = format_host_for_url(self._get_connection_host()) self._rtsps_url = f"rtsps://{host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}?enableSrtp" return self._rtsps_url @property def rtsps_no_srtp_url(self) -> str | None: if not self.is_rtsp_enabled or self.rtsp_alias is None: return None if self._rtsps_no_srtp_url is not None: return self._rtsps_no_srtp_url host = format_host_for_url(self._get_connection_host()) self._rtsps_no_srtp_url = ( f"rtsps://{host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}" ) return self._rtsps_no_srtp_url @property def rtsps_quality(self) -> ChannelQuality | None: """RTSPS quality tier for this channel (id 0→HIGH, 1→MEDIUM, 2→LOW, 3→PACKAGE).""" return quality_for_channel_id(self.id) @property def is_package(self) -> bool: # NOTE: The previous logic (checking fps <= 2) stopped working with G6 Entry # due to higher FPS values. This is now aligned with the logic used in # package_camera_channel. Not optimal, but will be fixed with the switch # to the public API. return self.fps is not None and self.id == 3 class ISPSettings(ProtectBaseObject): ae_mode: AutoExposureMode ir_led_mode: IRLEDMode ir_led_level: TwoByteInt wdr: WDRLevel icr_sensitivity: ICRSensitivity brightness: int contrast: int hue: int saturation: int sharpness: int denoise: int is_flipped_vertical: bool is_flipped_horizontal: bool is_auto_rotate_enabled: bool is_ldc_enabled: bool is_3dnr_enabled: bool is_external_ir_enabled: bool is_aggressive_anti_flicker_enabled: bool is_pause_motion_enabled: bool d_zoom_center_x: int d_zoom_center_y: int d_zoom_scale: int d_zoom_stream_id: int focus_mode: FocusMode | None = None focus_position: int touch_focus_x: int | None = None touch_focus_y: int | None = None zoom_position: PercentInt mount_position: MountPosition | None = None # requires 2.8.14+ is_color_night_vision_enabled: bool | None = None # 3.0.22+ hdr_mode: HDRMode | None = None icr_custom_value: ICRCustomValue | None = None icr_switch_mode: str | None = None spotlight_duration: int | None = None def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"focusMode"}) return data class OSDSettings(ProtectBaseObject): # Overlay Information is_name_enabled: bool is_date_enabled: bool is_logo_enabled: bool is_debug_enabled: bool overlay_location: OsdOverlayLocation | None = None def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) # overlayLocation is public-API-only; always strip it from private-API payloads # to avoid rejection by endpoints that don't accept unknown fields. data.pop("overlayLocation", None) return data class LEDSettings(ProtectBaseObject): # Status Light is_enabled: bool blink_rate: int | None = ( None # in milliseconds between blinks, 0 = solid (removed in Protect 6.x) ) # 6.2+ welcome_led: bool | None = None flood_led: bool | None = None def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"blinkRate", "welcomeLed", "floodLed"}) return data class SpeakerSettings(ProtectBaseObject): is_enabled: bool # Status Sounds are_system_sounds_enabled: bool volume: PercentInt # Doorbell ring volume (for doorbells) ring_volume: PercentInt | None = None ringtone_id: str | None = None repeat_times: int | None = None # Actual speaker output volume (for cameras with speakers) speaker_volume: PercentInt | None = None def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none( data, {"ringVolume", "ringtoneId", "repeatTimes", "speakerVolume"} ) return data class RecordingSettings(ProtectBaseObject): # Seconds to record before Motion pre_padding: timedelta # Seconds to record after Motion post_padding: timedelta # Seconds of Motion Needed min_motion_event_trigger: timedelta end_motion_event_delay: timedelta suppress_illumination_surge: bool # High Frame Rate Mode mode: RecordingMode geofencing: GeofencingSetting motion_algorithm: MotionAlgorithm enable_motion_detection: bool | None = None use_new_motion_algorithm: bool # requires 2.9.20+ in_schedule_mode: str | None = None out_schedule_mode: str | None = None # 2.11.13+ retention_duration: datetime | None = None smart_detect_post_padding: timedelta | None = None smart_detect_pre_padding: timedelta | None = None # requires 5.2.39+ create_access_event: bool | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "retentionDurationMs": "retentionDuration", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "minMotionEventTrigger": lambda x: timedelta(seconds=x), "endMotionEventDelay": lambda x: timedelta(seconds=x), } | super().unifi_dict_conversions() @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: if "prePaddingSecs" in data: data["prePadding"] = timedelta(seconds=data.pop("prePaddingSecs")) if "postPaddingSecs" in data: data["postPadding"] = timedelta(seconds=data.pop("postPaddingSecs")) if "smartDetectPrePaddingSecs" in data: data["smartDetectPrePadding"] = timedelta( seconds=data.pop("smartDetectPrePaddingSecs"), ) if "smartDetectPostPaddingSecs" in data: data["smartDetectPostPadding"] = timedelta( seconds=data.pop("smartDetectPostPaddingSecs"), ) return super().unifi_dict_to_dict(data) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if "prePadding" in data: data["prePaddingSecs"] = data.pop("prePadding") // 1000 if "postPadding" in data: data["postPaddingSecs"] = data.pop("postPadding") // 1000 if ( "smartDetectPrePadding" in data and data["smartDetectPrePadding"] is not None ): data["smartDetectPrePaddingSecs"] = ( data.pop("smartDetectPrePadding") // 1000 ) if ( "smartDetectPostPadding" in data and data["smartDetectPostPadding"] is not None ): data["smartDetectPostPaddingSecs"] = ( data.pop("smartDetectPostPadding") // 1000 ) if "minMotionEventTrigger" in data: data["minMotionEventTrigger"] = data.pop("minMotionEventTrigger") // 1000 if "endMotionEventDelay" in data: data["endMotionEventDelay"] = data.pop("endMotionEventDelay") // 1000 return data class SmartDetectSettings(ProtectBaseObject): object_types: list[SmartDetectObjectType] audio_types: list[SmartDetectAudioType] | None = None # requires 2.8.22+ auto_tracking_object_types: list[SmartDetectObjectType] | None = None @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "audioTypes": convert_smart_audio_types, "objectTypes": convert_smart_types, "autoTrackingObjectTypes": convert_smart_types, } | super().unifi_dict_conversions() def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if audio_types := data.get("audioTypes"): # SMOKE_CMONX is not supported for audio types # and should not be sent to the camera data["audioTypes"] = [ t for t in audio_types if t != SmartDetectAudioType.SMOKE_CMONX.value ] return data class LCDMessage(ProtectBaseObject): type: DoorbellMessageType # Some doorbell firmwares send lcdMessage with a type but no text; a # default keeps model_construct from producing a text-less instance. text: str = "" reset_at: datetime | None = None @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "resetAt": convert_to_datetime, } | super().unifi_dict_conversions() @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: if "text" in data: # UniFi Protect bug: some times LCD messages can get into a bad state where message = DEFAULT MESSAGE, but no type if "type" not in data: data["type"] = DoorbellMessageType.CUSTOM_MESSAGE.value data["text"] = cls._fix_text(data["text"], data["type"]) return super().unifi_dict_to_dict(data) @classmethod def _fix_text(cls, text: str, text_type: str | None) -> str: if text_type is None: text_type = cls.type.value if text_type != DoorbellMessageType.CUSTOM_MESSAGE.value: text = text_type.replace("_", " ") return text def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if "text" in data: try: msg_type = self.type.value except AttributeError: msg_type = None data["text"] = self._fix_text(data["text"], data.get("type", msg_type)) if "resetAt" in data: data["resetAt"] = to_js_time(data["resetAt"]) return data class TalkbackSettings(ProtectBaseObject): type_fmt: AudioCodecs type_in: str bind_addr: IPv4Address | IPv6Address | str | None = None bind_port: int filter_addr: str | None = None # can be used to restrict sender address filter_port: int | None = None # can be used to restrict sender port channels: int # 1 or 2 sampling_rate: int # 8000, 11025, 22050, 44100, 48000 bits_per_sample: int quality: PercentInt # only for vorbis class WifiStats(ProtectBaseObject): channel: int | None = None frequency: int | None = None link_speed_mbps: int | str | None = None signal_quality: PercentInt signal_strength: int class VideoStats(ProtectBaseObject): recording_start: datetime | None = None recording_end: datetime | None = None recording_start_lq: datetime | None = None recording_end_lq: datetime | None = None timelapse_start: datetime | None = None timelapse_end: datetime | None = None timelapse_start_lq: datetime | None = None timelapse_end_lq: datetime | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "recordingStartLQ": "recordingStartLq", "recordingEndLQ": "recordingEndLq", "timelapseStartLQ": "timelapseStartLq", "timelapseEndLQ": "timelapseEndLq", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return ( dict.fromkeys( ( "recordingStart", "recordingEnd", "recordingStartLQ", "recordingEndLQ", "timelapseStart", "timelapseEnd", "timelapseStartLQ", "timelapseEndLQ", ), convert_to_datetime, ) | super().unifi_dict_conversions() ) class StorageStats(ProtectBaseObject): used: int | None = None # bytes rate: float | None = None # bytes / millisecond @property def rate_per_second(self) -> float | None: if self.rate is None: return None return self.rate * 1000 @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: if "rate" not in data: data["rate"] = None return super().unifi_dict_to_dict(data) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"rate"}) return data class CameraStats(ProtectBaseObject): rx_bytes: int | None = None # deprecated: removed in Protect 6.1+ tx_bytes: int | None = None # deprecated: removed in Protect 6.1+ wifi: WifiStats video: VideoStats storage: StorageStats | None = None wifi_quality: PercentInt wifi_strength: int @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: if "storage" in data and data["storage"] == {}: del data["storage"] return super().unifi_dict_to_dict(data) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if "storage" in data and data["storage"] is None: data["storage"] = {} return data class CameraZone(ProtectBaseObject): id: int name: str color: Color points: list[tuple[Percent, Percent]] @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "points": lambda x: [(p[0], p[1]) for p in x], } | super().unifi_dict_conversions() def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if "points" in data: data["points"] = [serialize_point(p) for p in data["points"]] if "color" in data and isinstance(data["color"], dict): # Serialize Color object to hex string to avoid Pydantic serialization warnings data["color"] = self.color.as_hex().upper() return data @staticmethod def create_privacy_zone(zone_id: int) -> CameraZone: return CameraZone( id=zone_id, name=PRIVACY_ZONE_NAME, color=Color("#85BCEC"), points=[[0, 0], [1, 0], [1, 1], [0, 1]], # type: ignore[list-item] ) class MotionZone(CameraZone): sensitivity: PercentInt class SmartMotionZone(MotionZone): object_types: list[SmartDetectObjectType] @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "objectTypes": convert_smart_types, } | super().unifi_dict_conversions() class PrivacyMaskCapability(ProtectBaseObject): max_masks: int | None = None rectangle_only: bool class HotplugExtender(ProtectBaseObject): has_flash: bool | None = None has_ir: bool | None = None has_radar: bool | None = None is_attached: bool | None = None # 3.0.22+ flash_range: Any | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "hasIR": "hasIr"} class Hotplug(ProtectBaseObject): audio: bool | None = None video: bool | None = None extender: HotplugExtender | None = None # 2.8.35+ standalone_adoption: bool | None = None class PTZRangeSingle(ProtectBaseObject): max: float | None = None min: float | None = None step: float | None = None class PTZRange(ProtectBaseObject): steps: PTZRangeSingle degrees: PTZRangeSingle def to_native_value(self, degree_value: float, is_relative: bool = False) -> float: """Convert degree values to step values.""" if ( self.degrees.max is None or self.degrees.min is None or self.degrees.step is None or self.steps.max is None or self.steps.min is None or self.steps.step is None ): raise BadRequest("degree to step conversion not supported.") if not is_relative: degree_value -= self.degrees.min step_range = self.steps.max - self.steps.min degree_range = self.degrees.max - self.degrees.min ratio = step_range / degree_range step_value = clamp_value(degree_value * ratio, self.steps.step) if not is_relative: step_value = self.steps.min + step_value return step_value class PTZZoomRange(PTZRange): ratio: int def to_native_value(self, zoom_value: float, is_relative: bool = False) -> float: """Convert zoom values to step values.""" if self.steps.max is None or self.steps.min is None or self.steps.step is None: raise BadRequest("step conversion not supported.") step_range = self.steps.max - self.steps.min # zoom levels start at 1 ratio = step_range / (self.ratio - 1) if not is_relative: zoom_value -= 1 step_value = clamp_value(zoom_value * ratio, self.steps.step) if not is_relative: step_value = self.steps.min + step_value return step_value class CameraFeatureFlags(ProtectBaseObject): can_adjust_ir_led_level: bool can_magic_zoom: bool can_optical_zoom: bool can_touch_focus: bool has_accelerometer: bool has_aec: bool has_bluetooth: bool has_chime: bool has_external_ir: bool has_icr_sensitivity: bool has_ldc: bool has_led_ir: bool has_led_status: bool has_line_in: bool has_mic: bool has_privacy_mask: bool has_rtc: bool has_sd_card: bool has_speaker: bool has_wifi: bool has_hdr: bool has_auto_icr_only: bool video_modes: list[VideoMode] video_mode_max_fps: list[int] has_motion_zones: bool has_lcd_screen: bool smart_detect_types: list[SmartDetectObjectType] motion_algorithms: list[MotionAlgorithm] has_square_event_thumbnail: bool has_package_camera: bool privacy_mask_capability: PrivacyMaskCapability has_smart_detect: bool audio: list[str] = [] audio_codecs: list[AudioCodecs] = [] mount_positions: list[MountPosition] = [] has_infrared: bool | None = None lens_type: LensType | None = None hotplug: Hotplug | None = None smart_detect_audio_types: list[SmartDetectAudioType] | None = None # 2.7.18+ is_doorbell: bool # 2.8.22+ lens_model: str | None = None # 2.9.20+ has_color_lcd_screen: bool | None = None has_line_crossing: bool | None = None has_line_crossing_counting: bool | None = None has_liveview_tracking: bool | None = None # 2.10.10+ has_flash: bool | None = None is_ptz: bool | None = None # 2.11.13+ audio_style: list[AudioStyle] | None = None has_vertical_flip: bool | None = None # 3.0.22+ flash_range: Any | None = None # 4.73.71+ support_nfc: bool | None = None has_fingerprint_sensor: bool | None = None # 6.0.0+ support_full_hd_snapshot: bool | None = None focus: PTZRange pan: PTZRange tilt: PTZRange zoom: PTZZoomRange @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "smartDetectTypes": convert_smart_types, "smartDetectAudioTypes": convert_smart_audio_types, "videoModes": convert_video_modes, } | super().unifi_dict_conversions() @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: # backport support for `is_doorbell` to older versions of Protect if "hasChime" in data and "isDoorbell" not in data: data["isDoorbell"] = data["hasChime"] return super().unifi_dict_to_dict(data) @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "hasAutoICROnly": "hasAutoIcrOnly"} @property def has_highfps(self) -> bool: return VideoMode.HIGH_FPS in self.video_modes @property def has_wdr(self) -> bool: return not self.has_hdr class CameraLenses(ProtectBaseObject): id: int video: VideoStats class CameraHomekitSettings(ProtectBaseObject): microphone_muted: bool speaker_muted: bool stream_in_progress: bool talkback_settings_active: bool class CameraAudioSettings(ProtectBaseObject): style: list[AudioStyle] @lru_cache def _chime_type_from_total_seconds(total_seconds: float) -> ChimeType: if total_seconds == 0.3: return ChimeType.MECHANICAL if total_seconds > 0.3: return ChimeType.DIGITAL return ChimeType.NONE class Camera(ProtectMotionDeviceModel): is_deleting: bool # Microphone Sensitivity mic_volume: PercentInt is_mic_enabled: bool is_recording: bool is_motion_detected: bool is_smart_detected: bool phy_rate: float | None = None hdr_mode: bool # Recording Quality -> High Frame video_mode: VideoMode is_probing_for_wifi: bool chime_duration: timedelta last_ring: datetime | None = None is_live_heatmap_enabled: bool video_reconfiguration_in_progress: bool channels: list[CameraChannel] isp_settings: ISPSettings talkback_settings: TalkbackSettings osd_settings: OSDSettings led_settings: LEDSettings speaker_settings: SpeakerSettings recording_settings: RecordingSettings smart_detect_settings: SmartDetectSettings motion_zones: list[MotionZone] privacy_zones: list[CameraZone] smart_detect_zones: list[SmartMotionZone] stats: CameraStats feature_flags: CameraFeatureFlags lcd_message: LCDMessage | None = None lenses: list[CameraLenses] platform: str | None = None has_speaker: bool has_wifi: bool audio_bitrate: int can_manage: bool is_managed: bool voltage: float | None = None # requires 1.21+ is_poor_network: bool | None = None is_wireless_uplink_enabled: bool | None = None # requires 2.6.13+ homekit_settings: CameraHomekitSettings | None = None # requires 2.6.17+ ap_mgmt_ip: IPv4Address | IPv6Address | None = None # requires 2.7.5+ is_waterproof_case_attached: bool | None = None last_disconnect: datetime | None = None # requires 2.8.14+ is_2k: bool | None = None is_4k: bool | None = None use_global: bool | None = None # requires 2.8.22+ user_configured_ap: bool | None = None # requires 2.9.20+ has_recordings: bool | None = None # requires 2.10.10+ is_ptz: bool | None = None active_patrol_slot: int | None = None # requires 2.11.13+ audio_settings: CameraAudioSettings | None = None # requires 5.0.33+ is_third_party_camera: bool | None = None # requires 5.1.78+ is_paired_with_ai_port: bool | None = None # requires 5.2.39+ is_adopted_by_access_app: bool | None = None # not directly from UniFi last_ring_event_id: str | None = None last_nfc_card_scanned_event_id: str | None = None last_nfc_card_scanned: datetime | None = None last_fingerprint_identified_event_id: str | None = None last_fingerprint_identified: datetime | None = None last_smart_detect: datetime | None = None last_smart_audio_detect: datetime | None = None last_smart_detect_event_id: str | None = None last_smart_audio_detect_event_id: str | None = None last_smart_detects: dict[SmartDetectObjectType, datetime] = {} last_smart_audio_detects: dict[SmartDetectAudioType, datetime] = {} last_smart_detect_event_ids: dict[SmartDetectObjectType, str] = {} last_smart_audio_detect_event_ids: dict[SmartDetectAudioType, str] = {} talkback_stream: TalkbackStream | None = None _active_smart_detect_events: dict[SmartDetectObjectType, dict[str, Event]] = ( PrivateAttr(default_factory=dict) ) @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "is2K": "is2k", "is4K": "is4k"} @classmethod @cache def _get_excluded_changed_fields(cls) -> set[str]: return super()._get_excluded_changed_fields() | { "last_ring_event_id", "last_nfc_card_scanned", "last_nfc_card_scanned_event_id", "last_fingerprint_identified", "last_fingerprint_identified_event_id", "last_smart_detect", "last_smart_audio_detect", "last_smart_detect_event_id", "last_smart_audio_detect_event_id", "last_smart_detects", "last_smart_audio_detects", "last_smart_detect_event_ids", "last_smart_audio_detect_event_ids", "talkback_stream", } @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | { "stats", "isDeleting", "isRecording", "isMotionDetected", "isSmartDetected", "phyRate", "isProbingForWifi", "lastRing", "isLiveHeatmapEnabled", "videoReconfigurationInProgress", "lenses", "isPoorNetwork", "featureFlags", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "chimeDuration": lambda x: timedelta(milliseconds=x), } | super().unifi_dict_conversions() @model_validator(mode="after") def _set_channel_parents(self) -> Camera: """Set parent camera reference in channels after initialization.""" for channel in getattr(self, "channels", ()): channel._parent = self return self @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: # LCD messages comes back as empty dict {} if "lcdMessage" in data and len(data["lcdMessage"]) == 0: del data["lcdMessage"] return super().unifi_dict_to_dict(data) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: if data is not None: if "motion_zones" in data: data["motion_zones"] = [ MotionZone(**z).unifi_dict() for z in data["motion_zones"] ] if "privacy_zones" in data: data["privacy_zones"] = [ CameraZone(**z).unifi_dict() for z in data["privacy_zones"] ] if "smart_detect_zones" in data: data["smart_detect_zones"] = [ SmartMotionZone(**z).unifi_dict() for z in data["smart_detect_zones"] ] data = super().unifi_dict(data=data, exclude=exclude) pop_dict_tuple( data, ( "lastFingerprintIdentified", "lastFingerprintIdentifiedEventId", "lastNfcCardScanned", "lastNfcCardScannedEventId", "lastRingEventId", "lastSmartDetect", "lastSmartAudioDetect", "lastSmartDetectEventId", "lastSmartAudioDetectEventId", "lastSmartDetects", "lastSmartAudioDetects", "lastSmartDetectEventIds", "lastSmartAudioDetectEventIds", "talkbackStream", ), ) if "lcdMessage" in data and data["lcdMessage"] is None: data["lcdMessage"] = {} return data def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]: updated = super().get_changed(data_before_changes) if "lcd_message" in updated: lcd_message = updated["lcd_message"] # to "clear" LCD message, set reset_at to a time in the past if lcd_message is None: updated["lcd_message"] = {"reset_at": utc_now() - timedelta(seconds=10)} # otherwise, pass full LCD message to prevent issues elif self.lcd_message is not None: updated["lcd_message"] = self.lcd_message.model_dump() # if reset_at is not passed in, it will default to reset in 1 minute if lcd_message is not None and "reset_at" not in lcd_message: if self.lcd_message is None: updated["lcd_message"]["reset_at"] = None else: updated["lcd_message"]["reset_at"] = self.lcd_message.reset_at return updated def update_from_dict(self, data: dict[str, Any]) -> Camera: # a message in the past is actually a signal to wipe the message if ( reset_at := data.get("lcd_message", {}).get("reset_at") ) is not None and utc_now() > from_js_time(reset_at): # Important: Make a copy of the data before modifying it # since unifi_dict_to_dict will otherwise report incorrect changes data = data.copy() data["lcd_message"] = None return super().update_from_dict(data) @property def last_ring_event(self) -> Event | None: if (last_ring_event_id := self.last_ring_event_id) is None: return None return self._api.bootstrap.events.get(last_ring_event_id) @property def last_smart_detect_event(self) -> Event | None: """Get the last smart detect event id.""" if (last_smart_detect_event_id := self.last_smart_detect_event_id) is None: return None # Check per-camera active index first (immune to bootstrap.events eviction) for active in self._active_smart_detect_events.values(): if event := active.get(last_smart_detect_event_id): return event return self._api.bootstrap.events.get(last_smart_detect_event_id) @property def last_nfc_card_scanned_event(self) -> Event | None: if ( last_nfc_card_scanned_event_id := self.last_nfc_card_scanned_event_id ) is None: return None return self._api.bootstrap.events.get(last_nfc_card_scanned_event_id) @property def last_fingerprint_identified_event(self) -> Event | None: if ( last_fingerprint_identified_event_id := self.last_fingerprint_identified_event_id ) is None: return None return self._api.bootstrap.events.get(last_fingerprint_identified_event_id) @property def hdr_mode_display(self) -> Literal["auto", "off", "always"]: """Get HDR mode similar to how Protect interface works.""" if not self.hdr_mode: return "off" if self.isp_settings.hdr_mode == HDRMode.NORMAL: return "auto" return "always" @property def icr_lux_display(self) -> int | None: """Get ICR Custom Lux value similar to how the Protect interface works.""" if self.isp_settings.icr_custom_value is None: return None return LUX_MAPPING_VALUES[10 - self.isp_settings.icr_custom_value] def get_last_smart_detect_event( self, smart_type: SmartDetectObjectType, ) -> Event | None: """Get the last smart detect event for given type.""" event_id = self.last_smart_detect_event_ids.get(smart_type) if not event_id: return None # Prefer the per-camera active index (immune to bootstrap.events eviction) active = self._active_smart_detect_events.get(smart_type) if active and (event := active.get(event_id)): return event return self._api.bootstrap.events.get(event_id) @property def last_smart_audio_detect_event(self) -> Event | None: """Get the last smart audio detect event id.""" if ( last_smart_audio_detect_event_id := self.last_smart_audio_detect_event_id ) is None: return None return self._api.bootstrap.events.get(last_smart_audio_detect_event_id) def get_last_smart_audio_detect_event( self, smart_type: SmartDetectAudioType, ) -> Event | None: """Get the last smart audio detect event for given type.""" if (event_id := self.last_smart_audio_detect_event_ids.get(smart_type)) is None: return None return self._api.bootstrap.events.get(event_id) @property def timelapse_url(self) -> str: return f"{self._api.base_url}/protect/timelapse/{self.id}" @property def is_privacy_on(self) -> bool: index, _ = self.get_privacy_zone() return index is not None @property def is_ptz_patrolling(self) -> bool: """Is PTZ camera currently running a patrol?""" return self.active_patrol_slot is not None @property def is_recording_enabled(self) -> bool: """ Is recording footage/events from the camera enabled? If recording is not enabled, cameras will not produce any footage, thumbnails, motion/smart detection events. """ if self.use_global: return self._api.bootstrap.nvr.is_global_recording_enabled return self.recording_settings.mode is not RecordingMode.NEVER @property def is_smart_detections_allowed(self) -> bool: """Is smart detections allowed for this camera?""" return ( self.is_recording_enabled and self._api.bootstrap.nvr.is_smart_detections_enabled ) @property def is_license_plate_detections_allowed(self) -> bool: """Is license plate detections allowed for this camera?""" return ( self.is_recording_enabled and self._api.bootstrap.nvr.is_license_plate_detections_enabled ) @property def is_face_detections_allowed(self) -> bool: """Is face detections allowed for this camera?""" return ( self.is_recording_enabled and self._api.bootstrap.nvr.is_face_detections_enabled ) @property def active_recording_settings(self) -> RecordingSettings: """Get active recording settings.""" if self.use_global and self._api.bootstrap.nvr.global_camera_settings: return self._api.bootstrap.nvr.global_camera_settings.recording_settings return self.recording_settings @property def active_smart_detect_settings(self) -> SmartDetectSettings: """Get active smart detection settings.""" if self.use_global and self._api.bootstrap.nvr.global_camera_settings: return self._api.bootstrap.nvr.global_camera_settings.smart_detect_settings return self.smart_detect_settings @property def active_smart_detect_types(self) -> set[SmartDetectObjectType]: """Get active smart detection types.""" if self.use_global: return set(self.smart_detect_settings.object_types).intersection( self.feature_flags.smart_detect_types ) return set(self.smart_detect_settings.object_types) @property def active_audio_detect_types(self) -> set[SmartDetectAudioType]: """Get active audio detection types.""" if not (enabled_audio_types := self.smart_detect_settings.audio_types): return set() if self.use_global: if not (feature_audio_types := self.feature_flags.smart_detect_audio_types): return set() return set(feature_audio_types).intersection(enabled_audio_types) return set(enabled_audio_types) @property def is_motion_detection_on(self) -> bool: """Is Motion Detection available and enabled (camera will produce motion events)?""" return ( self.is_recording_enabled and self.active_recording_settings.enable_motion_detection is not False ) @property def is_motion_currently_detected(self) -> bool: """Is motion currently being detected""" return ( self.is_motion_detection_on and self.is_motion_detected and self.last_motion_event is not None and self.last_motion_event.end is None ) async def set_motion_detection(self, enabled: bool) -> None: """Sets motion detection on camera""" if self.use_global: raise BadRequest("Camera is using global recording settings.") def callback() -> None: self.recording_settings.enable_motion_detection = enabled await self.queue_update(callback) async def set_use_global(self, enabled: bool) -> None: """Sets if camera should use global recording settings or not.""" def callback() -> None: self.use_global = enabled await self.queue_update(callback) def can_detect(self, smart_type: SmartDetectObjectType) -> bool: """Whether the camera advertises ``smart_type`` (audio types via ``audio_type``).""" if smart_type.audio_type is not None: return self._can_detect_audio(smart_type) return smart_type in self.feature_flags.smart_detect_types # region Object Smart Detections def _is_smart_enabled(self, smart_type: SmartDetectObjectType) -> bool: return ( self.is_recording_enabled and smart_type in self.active_smart_detect_types ) def _is_smart_detected(self, smart_type: SmartDetectObjectType) -> bool: event = self.get_last_smart_detect_event(smart_type) return ( self._is_smart_enabled(smart_type) and self.is_smart_detected and event is not None and event.end is None and smart_type in event.smart_detect_types ) @property def is_smart_currently_detected(self) -> bool: """Is smart detection currently being detected""" return ( self.is_recording_enabled and bool(self.active_smart_detect_types) and self.is_smart_detected and self.last_smart_detect_event is not None and self.last_smart_detect_event.end is None ) # region Person @property def can_detect_person(self) -> bool: return SmartDetectObjectType.PERSON in self.feature_flags.smart_detect_types @property def is_person_detection_on(self) -> bool: """ Is Person Detection available and enabled (camera will produce person smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.PERSON) @property def last_person_detect_event(self) -> Event | None: """Get the last person smart detection event.""" return self.get_last_smart_detect_event(SmartDetectObjectType.PERSON) @property def last_person_detect(self) -> datetime | None: """Get the last person smart detection event.""" return self.last_smart_detects.get(SmartDetectObjectType.PERSON) @property def is_person_currently_detected(self) -> bool: """Is person currently being detected""" return self._is_smart_detected(SmartDetectObjectType.PERSON) @property def is_person_tracking_enabled(self) -> bool: """Is person tracking enabled""" return ( self.active_smart_detect_settings.auto_tracking_object_types is not None and SmartDetectObjectType.PERSON in self.active_smart_detect_settings.auto_tracking_object_types ) # endregion # region Vehicle @property def can_detect_vehicle(self) -> bool: return SmartDetectObjectType.VEHICLE in self.feature_flags.smart_detect_types @property def is_vehicle_detection_on(self) -> bool: """ Is Vehicle Detection available and enabled (camera will produce vehicle smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.VEHICLE) @property def last_vehicle_detect_event(self) -> Event | None: """Get the last vehicle smart detection event.""" return self.get_last_smart_detect_event(SmartDetectObjectType.VEHICLE) @property def last_vehicle_detect(self) -> datetime | None: """Get the last vehicle smart detection event.""" return self.last_smart_detects.get(SmartDetectObjectType.VEHICLE) @property def is_vehicle_currently_detected(self) -> bool: """Is vehicle currently being detected""" return self._is_smart_detected(SmartDetectObjectType.VEHICLE) # endregion # region Face @property def can_detect_face(self) -> bool: return SmartDetectObjectType.FACE in self.feature_flags.smart_detect_types @property def is_face_detection_on(self) -> bool: """Is Face Detection available and enabled?""" return self._is_smart_enabled(SmartDetectObjectType.FACE) @property def last_face_detect_event(self) -> Event | None: """Get the last face smart detection event.""" return self.get_last_smart_detect_event(SmartDetectObjectType.FACE) @property def last_face_detect(self) -> datetime | None: """Get the last face smart detection event.""" return self.last_smart_detects.get(SmartDetectObjectType.FACE) @property def is_face_currently_detected(self) -> bool: """Is face currently being detected""" return self._is_smart_detected(SmartDetectObjectType.FACE) # endregion # region License Plate @property def can_detect_license_plate(self) -> bool: return ( SmartDetectObjectType.LICENSE_PLATE in self.feature_flags.smart_detect_types ) @property def is_license_plate_detection_on(self) -> bool: """ Is License Plate Detection available and enabled (camera will produce face license plate detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.LICENSE_PLATE) @property def last_license_plate_detect_event(self) -> Event | None: """Get the last license plate smart detection event.""" return self.get_last_smart_detect_event(SmartDetectObjectType.LICENSE_PLATE) @property def last_license_plate_detect(self) -> datetime | None: """Get the last license plate smart detection event.""" return self.last_smart_detects.get(SmartDetectObjectType.LICENSE_PLATE) @property def is_license_plate_currently_detected(self) -> bool: """Is license plate currently being detected""" return self._is_smart_detected(SmartDetectObjectType.LICENSE_PLATE) # endregion # region Package @property def can_detect_package(self) -> bool: return SmartDetectObjectType.PACKAGE in self.feature_flags.smart_detect_types @property def is_package_detection_on(self) -> bool: """ Is Package Detection available and enabled (camera will produce package smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.PACKAGE) @property def last_package_detect_event(self) -> Event | None: """Get the last package smart detection event.""" return self.get_last_smart_detect_event(SmartDetectObjectType.PACKAGE) @property def last_package_detect(self) -> datetime | None: """Get the last package smart detection event.""" return self.last_smart_detects.get(SmartDetectObjectType.PACKAGE) @property def is_package_currently_detected(self) -> bool: """Is package currently being detected""" return self._is_smart_detected(SmartDetectObjectType.PACKAGE) # endregion # region Animal @property def can_detect_animal(self) -> bool: return SmartDetectObjectType.ANIMAL in self.feature_flags.smart_detect_types @property def is_animal_detection_on(self) -> bool: """ Is Animal Detection available and enabled (camera will produce package smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.ANIMAL) @property def last_animal_detect_event(self) -> Event | None: """Get the last animal smart detection event.""" return self.get_last_smart_detect_event(SmartDetectObjectType.ANIMAL) @property def last_animal_detect(self) -> datetime | None: """Get the last animal smart detection event.""" return self.last_smart_detects.get(SmartDetectObjectType.ANIMAL) @property def is_animal_currently_detected(self) -> bool: """Is animal currently being detected""" return self._is_smart_detected(SmartDetectObjectType.ANIMAL) # endregion # endregion # region Audio Smart Detections def _can_detect_audio(self, smart_type: SmartDetectObjectType) -> bool: audio_type = smart_type.audio_type return ( audio_type is not None and ( smart_detect_audio_types := self.feature_flags.smart_detect_audio_types ) is not None and audio_type in smart_detect_audio_types ) def _is_audio_enabled(self, smart_type: SmartDetectObjectType) -> bool: audio_type = smart_type.audio_type return ( audio_type is not None and self.is_recording_enabled and audio_type in self.active_audio_detect_types ) def _is_audio_detected(self, smart_type: SmartDetectObjectType) -> bool: audio_type = smart_type.audio_type if audio_type is None: return False event = self.get_last_smart_audio_detect_event(audio_type) return ( self._is_audio_enabled(smart_type) and event is not None and event.end is None and smart_type in event.smart_detect_types ) @property def is_audio_currently_detected(self) -> bool: """Is audio detection currently being detected""" return ( self.is_recording_enabled and bool(self.active_audio_detect_types) and (last_smart_audio_detect_event := self.last_smart_audio_detect_event) is not None and last_smart_audio_detect_event.end is None ) # region Smoke Alarm @property def can_detect_smoke(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.SMOKE) @property def is_smoke_detection_on(self) -> bool: """ Is Smoke Alarm Detection available and enabled (camera will produce smoke smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.SMOKE) @property def last_smoke_detect_event(self) -> Event | None: """Get the last person smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SMOKE) @property def last_smoke_detect(self) -> datetime | None: """Get the last smoke smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.SMOKE) @property def is_smoke_currently_detected(self) -> bool: """Is smoke alarm currently being detected""" return self._is_audio_detected(SmartDetectObjectType.SMOKE) # endregion # region CO Alarm @property def can_detect_co(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.CMONX) @property def is_co_detection_on(self) -> bool: """ Is CO Alarm Detection available and enabled (camera will produce smoke smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.CMONX) @property def last_cmonx_detect_event(self) -> Event | None: """Get the last CO alarm smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.CMONX) @property def last_cmonx_detect(self) -> datetime | None: """Get the last CO alarm smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.CMONX) @property def is_cmonx_currently_detected(self) -> bool: """Is CO alarm currently being detected""" return self._is_audio_detected(SmartDetectObjectType.CMONX) async def set_cmonx_detection(self, enabled: bool) -> None: """Toggles smoke smart detection. Requires camera to have smart detection""" return await self._set_audio_detect(SmartDetectAudioType.CMONX, enabled) # endregion # region Siren @property def can_detect_siren(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.SIREN) @property def is_siren_detection_on(self) -> bool: """ Is Siren Detection available and enabled (camera will produce siren smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.SIREN) @property def last_siren_detect_event(self) -> Event | None: """Get the last Siren smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SIREN) @property def last_siren_detect(self) -> datetime | None: """Get the last Siren smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.SIREN) @property def is_siren_currently_detected(self) -> bool: """Is Siren currently being detected""" return self._is_audio_detected(SmartDetectObjectType.SIREN) # endregion # region Baby Cry @property def can_detect_baby_cry(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.BABY_CRY) @property def is_baby_cry_detection_on(self) -> bool: """ Is Baby Cry Detection available and enabled (camera will produce baby cry smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.BABY_CRY) @property def last_baby_cry_detect_event(self) -> Event | None: """Get the last Baby Cry smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BABY_CRY) @property def last_baby_cry_detect(self) -> datetime | None: """Get the last Baby Cry smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.BABY_CRY) @property def is_baby_cry_currently_detected(self) -> bool: """Is Baby Cry currently being detected""" return self._is_audio_detected(SmartDetectObjectType.BABY_CRY) # endregion # region Speaking @property def can_detect_speaking(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.SPEAK) @property def is_speaking_detection_on(self) -> bool: """ Is Speaking Detection available and enabled (camera will produce speaking smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.SPEAK) @property def last_speaking_detect_event(self) -> Event | None: """Get the last Speaking smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SPEAK) @property def last_speaking_detect(self) -> datetime | None: """Get the last Speaking smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.SPEAK) @property def is_speaking_currently_detected(self) -> bool: """Is Speaking currently being detected""" return self._is_audio_detected(SmartDetectObjectType.SPEAK) # endregion # region Bark @property def can_detect_bark(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.BARK) @property def is_bark_detection_on(self) -> bool: """ Is Bark Detection available and enabled (camera will produce barking smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.BARK) @property def last_bark_detect_event(self) -> Event | None: """Get the last Bark smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BARK) @property def last_bark_detect(self) -> datetime | None: """Get the last Bark smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.BARK) @property def is_bark_currently_detected(self) -> bool: """Is Bark currently being detected""" return self._is_audio_detected(SmartDetectObjectType.BARK) # endregion # region Car Alarm # (burglar in code, car alarm in Protect UI) @property def can_detect_car_alarm(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.BURGLAR) @property def is_car_alarm_detection_on(self) -> bool: """ Is Car Alarm Detection available and enabled (camera will produce car alarm smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.BURGLAR) @property def last_car_alarm_detect_event(self) -> Event | None: """Get the last Car Alarm smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BURGLAR) @property def last_car_alarm_detect(self) -> datetime | None: """Get the last Car Alarm smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.BURGLAR) @property def is_car_alarm_currently_detected(self) -> bool: """Is Car Alarm currently being detected""" return self._is_audio_detected(SmartDetectObjectType.BURGLAR) async def set_car_alarm_detection(self, enabled: bool) -> None: """Toggles car_alarm smart detection. Requires camera to have smart detection""" return await self._set_audio_detect(SmartDetectAudioType.BURGLAR, enabled) # endregion # region Car Horn @property def can_detect_car_horn(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.CAR_HORN) @property def is_car_horn_detection_on(self) -> bool: """ Is Car Horn Detection available and enabled (camera will produce car horn smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.CAR_HORN) @property def last_car_horn_detect_event(self) -> Event | None: """Get the last Car Horn smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.CAR_HORN) @property def last_car_horn_detect(self) -> datetime | None: """Get the last Car Horn smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.CAR_HORN) @property def is_car_horn_currently_detected(self) -> bool: """Is Car Horn currently being detected""" return self._is_audio_detected(SmartDetectObjectType.CAR_HORN) # endregion # region Glass Break @property def can_detect_glass_break(self) -> bool: return self._can_detect_audio(SmartDetectObjectType.GLASS_BREAK) @property def is_glass_break_detection_on(self) -> bool: """ Is Glass Break available and enabled (camera will produce glass break smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.GLASS_BREAK) @property def last_glass_break_detect_event(self) -> Event | None: """Get the last Glass Break smart detection event.""" return self.get_last_smart_audio_detect_event(SmartDetectAudioType.GLASS_BREAK) @property def last_glass_break_detect(self) -> datetime | None: """Get the last Glass Break smart detection event.""" return self.last_smart_audio_detects.get(SmartDetectAudioType.GLASS_BREAK) @property def is_glass_break_currently_detected(self) -> bool: """Is Glass Break currently being detected""" return self._is_audio_detected(SmartDetectObjectType.GLASS_BREAK) # endregion # endregion @property def chime_type(self) -> ChimeType: return _chime_type_from_total_seconds( timedelta_total_seconds(self.chime_duration) ) @property def chime_duration_seconds(self) -> float: return timedelta_total_seconds(self.chime_duration) @property def is_digital_chime(self) -> bool: return self.chime_type is ChimeType.DIGITAL @property def high_camera_channel(self) -> CameraChannel | None: if len(self.channels) >= 3: return self.channels[0] return None @property def medium_camera_channel(self) -> CameraChannel | None: if len(self.channels) >= 3: return self.channels[1] return None @property def low_camera_channel(self) -> CameraChannel | None: if len(self.channels) >= 3: return self.channels[2] return None @property def default_camera_channel(self) -> CameraChannel | None: for channel in [ self.high_camera_channel, self.medium_camera_channel, self.low_camera_channel, ]: if channel is not None and channel.is_rtsp_enabled: return channel return self.high_camera_channel @property def package_camera_channel(self) -> CameraChannel | None: if self.feature_flags.has_package_camera and len(self.channels) == 4: return self.channels[3] return None @property def is_high_fps_enabled(self) -> bool: return self.video_mode is VideoMode.HIGH_FPS @property def is_video_ready(self) -> bool: return ( lens_type := self.feature_flags.lens_type ) is None or lens_type is not LensType.NONE @property def has_removable_lens(self) -> bool: return ( hotplug := self.feature_flags.hotplug ) is not None and hotplug.video is not None @property def has_removable_speaker(self) -> bool: return ( hotplug := self.feature_flags.hotplug ) is not None and hotplug.audio is not None @property def has_mic(self) -> bool: return self.feature_flags.has_mic or self.has_removable_speaker @property def has_color_night_vision(self) -> bool: if ( (hotplug := self.feature_flags.hotplug) is not None and (extender := hotplug.extender) is not None and (is_attached := extender.is_attached) is not None ): return is_attached return False def get_privacy_zone(self) -> tuple[int | None, CameraZone | None]: for index, zone in enumerate(self.privacy_zones): if zone.name == PRIVACY_ZONE_NAME: return index, zone return None, None def add_privacy_zone(self) -> None: index, _ = self.get_privacy_zone() if index is None: zone_id = 0 privacy_zones = self.privacy_zones if len(privacy_zones) > 0: zone_id = privacy_zones[-1].id + 1 privacy_zones.append(CameraZone.create_privacy_zone(zone_id)) def remove_privacy_zone(self) -> None: index, _ = self.get_privacy_zone() if index is not None: self.privacy_zones.pop(index) async def get_snapshot( self, width: int | None = None, height: int | None = None, dt: datetime | None = None, ) -> bytes | None: """ Gets snapshot for camera. Datetime of screenshot is approximate. It may be +/- a few seconds. """ # Use READ_LIVE if dt is None, otherwise READ_MEDIA auth_user = self._api.bootstrap.auth_user if dt is None: if not ( auth_user.can(ModelType.CAMERA, PermissionNode.READ_LIVE, self) or auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self) ): raise NotAuthorized( f"Do not have permission to read live or media for camera: {self.id}" ) elif not auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self): raise NotAuthorized( f"Do not have permission to read media for camera: {self.id}" ) if height is None and width is None and self.high_camera_channel is not None: height = self.high_camera_channel.height return await self._api.get_camera_snapshot(self.id, width, height, dt=dt) async def get_public_api_snapshot( self, high_quality: bool | None = None, package: bool = False ) -> bytes | None: """Gets snapshot for camera using public API; ``package=True`` fetches the package camera.""" if self._api._api_key is None: raise NotAuthorized("Cannot get public API snapshot without an API key.") if high_quality is None: high_quality = self.feature_flags.support_full_hd_snapshot or False return await self._api.get_public_api_camera_snapshot( camera_id=self.id, high_quality=high_quality, package=package ) async def create_rtsps_streams( self, qualities: list[ChannelQuality | str] | ChannelQuality | str ) -> RTSPSStreams | None: """Creates RTSPS streams for camera using public API.""" if self._api._api_key is None: raise NotAuthorized("Cannot create RTSPS streams without an API key.") return await self._api.create_camera_rtsps_streams(self.id, qualities) async def get_rtsps_streams(self) -> RTSPSStreams | None: """Gets existing RTSPS streams for camera using public API.""" if self._api._api_key is None: raise NotAuthorized("Cannot get RTSPS streams without an API key.") return await self._api.get_camera_rtsps_streams(self.id) async def delete_rtsps_streams( self, qualities: list[ChannelQuality | str] | ChannelQuality | str ) -> bool: """Deletes RTSPS streams for camera using public API.""" if self._api._api_key is None: raise NotAuthorized("Cannot delete RTSPS streams without an API key.") return await self._api.delete_camera_rtsps_streams(self.id, qualities) async def get_package_snapshot( self, width: int | None = None, height: int | None = None, dt: datetime | None = None, ) -> bytes | None: """ Gets snapshot from the package camera. Datetime of screenshot is approximate. It may be +/- a few seconds. """ if not self.feature_flags.has_package_camera: raise BadRequest("Device does not have package camera") auth_user = self._api.bootstrap.auth_user # Use READ_LIVE if dt is None, otherwise READ_MEDIA if dt is None: if not ( auth_user.can(ModelType.CAMERA, PermissionNode.READ_LIVE, self) or auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self) ): raise NotAuthorized( f"Do not have permission to read live or media for camera: {self.id}" ) elif not auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self): raise NotAuthorized( f"Do not have permission to read media for camera: {self.id}" ) if height is None and width is None and self.package_camera_channel is not None: height = self.package_camera_channel.height return await self._api.get_package_camera_snapshot( self.id, width, height, dt=dt ) async def get_video( self, start: datetime, end: datetime, channel_index: int = 0, output_file: Path | None = None, iterator_callback: IteratorCallback | None = None, progress_callback: ProgressCallback | None = None, chunk_size: int = 65536, fps: int | None = None, ) -> bytes | None: """ Exports MP4 video from a given camera at a specific time. Start/End of video export are approximate. It may be +/- a few seconds. It is recommended to provide a output file or progress callback for larger video clips, otherwise the full video must be downloaded to memory before being written. Providing the `fps` parameter creates a "timelapse" export with the given FPS value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x (fps=20), and 600x (fps=40). """ if not self._api.bootstrap.auth_user.can( ModelType.CAMERA, PermissionNode.READ_MEDIA, self, ): raise NotAuthorized( f"Do not have permission to read media for camera: {self.id}", ) return await self._api.get_camera_video( self.id, start, end, channel_index, output_file=output_file, iterator_callback=iterator_callback, progress_callback=progress_callback, chunk_size=chunk_size, fps=fps, ) async def set_recording_mode(self, mode: RecordingMode) -> None: """Sets recording mode on camera""" if self.use_global: raise BadRequest("Camera is using global recording settings.") def callback() -> None: self.recording_settings.mode = mode await self.queue_update(callback) async def set_ir_led_model(self, mode: IRLEDMode) -> None: """Sets IR LED mode on camera""" if not self.feature_flags.has_led_ir: raise BadRequest("Camera does not have an LED IR") def callback() -> None: self.isp_settings.ir_led_mode = mode await self.queue_update(callback) async def set_icr_custom_lux(self, value: ICRLuxValue) -> None: """Set ICRCustomValue from lux value.""" if not self.feature_flags.has_led_ir: raise BadRequest("Camera does not have an LED IR") icr_value = 0 for index, threshold in enumerate(LUX_MAPPING_VALUES): if value >= threshold: icr_value = 10 - index break def callback() -> None: self.isp_settings.icr_custom_value = cast("ICRCustomValue", icr_value) await self.queue_update(callback) @property def is_ir_led_slider_enabled(self) -> bool: """Return if IR LED custom slider is enabled.""" return ( self.feature_flags.has_led_ir and self.isp_settings.ir_led_mode in _ICR_LUX_IR_LED_MODES ) async def set_status_light(self, enabled: bool) -> None: """Sets status indicicator light on camera""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") def callback() -> None: self.led_settings.is_enabled = enabled # blink_rate was removed in Protect 6.x if self.led_settings.blink_rate is not None: self.led_settings.blink_rate = 0 await self.queue_update(callback) async def set_color_night_vision(self, enabled: bool) -> None: """Sets Color Night Vision on camera""" if not self.has_color_night_vision: raise BadRequest("Camera does not have Color Night Vision") def callback() -> None: self.isp_settings.is_color_night_vision_enabled = enabled await self.queue_update(callback) async def set_camera_zoom(self, level: int) -> None: """Sets zoom level for camera""" if not self.feature_flags.can_optical_zoom: raise BadRequest("Camera cannot optical zoom") def callback() -> None: self.isp_settings.zoom_position = PercentInt(level) await self.queue_update(callback) async def set_wdr_level(self, level: int) -> None: """Sets WDR (Wide Dynamic Range) on camera""" if self.feature_flags.has_hdr: raise BadRequest("Cannot set WDR on cameras with HDR") def callback() -> None: self.isp_settings.wdr = WDRLevel(level) await self.queue_update(callback) async def set_speaker_volume(self, level: int) -> None: """Sets the speaker output volume on camera. Requires camera to have speakers""" if not self.feature_flags.has_speaker: raise BadRequest("Camera does not have speaker") def callback() -> None: self.speaker_settings.speaker_volume = PercentInt(level) await self.queue_update(callback) async def set_volume(self, level: int) -> None: """Sets the general volume level on camera. Requires camera to have speakers""" if not self.feature_flags.has_speaker: raise BadRequest("Camera does not have speaker") def callback() -> None: self.speaker_settings.volume = PercentInt(level) await self.queue_update(callback) async def set_ring_volume(self, level: int) -> None: """Sets the doorbell ring volume. Requires camera to be a doorbell""" if not self.feature_flags.is_doorbell: raise BadRequest("Camera is not a doorbell") def callback() -> None: self.speaker_settings.ring_volume = PercentInt(level) await self.queue_update(callback) async def set_chime_type(self, chime_type: ChimeType) -> None: """Sets chime type for doorbell. Requires camera to be a doorbell""" await self.set_chime_duration(timedelta(milliseconds=chime_type.value)) async def set_chime_duration(self, duration: timedelta | float) -> None: """Sets chime duration for doorbell. Requires camera to be a doorbell""" if not self.feature_flags.has_chime: raise BadRequest("Camera does not have a chime") if isinstance(duration, (float, int)): if duration < 0: raise BadRequest("Chime duration must be a positive number of seconds") duration_td = timedelta(seconds=duration) else: duration_td = duration if duration_td.total_seconds() > 10: raise BadRequest("Chime duration is too long") def callback() -> None: self.chime_duration = duration_td await self.queue_update(callback) async def set_system_sounds(self, enabled: bool) -> None: """Sets system sound playback through speakers. Requires camera to have speakers""" if not self.feature_flags.has_speaker: raise BadRequest("Camera does not have speaker") def callback() -> None: self.speaker_settings.are_system_sounds_enabled = enabled await self.queue_update(callback) async def set_osd_bitrate(self, enabled: bool) -> None: """Sets whether camera bitrate is in the On Screen Display""" if self.use_global: raise BadRequest("Camera is using global recording settings.") def callback() -> None: # mismatch between UI internal data structure debug = bitrate data self.osd_settings.is_debug_enabled = enabled await self.queue_update(callback) async def set_smart_detect_types(self, types: list[SmartDetectObjectType]) -> None: """Sets current enabled smart detection types. Requires camera to have smart detection""" if not self.feature_flags.has_smart_detect: raise BadRequest("Camera does not have a smart detections") if self.use_global: raise BadRequest("Camera is using global recording settings.") def callback() -> None: self.smart_detect_settings.object_types = types await self.queue_update(callback) async def set_smart_audio_detect_types( self, types: list[SmartDetectAudioType], ) -> None: """Sets current enabled smart audio detection types. Requires camera to have smart detection""" if not self.feature_flags.has_smart_detect: raise BadRequest("Camera does not have a smart detections") if self.use_global: raise BadRequest("Camera is using global recording settings.") def callback() -> None: self.smart_detect_settings.audio_types = types await self.queue_update(callback) async def _set_audio_detect( self, obj_to_mod: SmartDetectAudioType, enabled: bool, ) -> None: if ( self.feature_flags.smart_detect_audio_types is None or obj_to_mod not in self.feature_flags.smart_detect_audio_types ): raise BadRequest(f"Camera does not support the {obj_to_mod} detection type") if self.use_global: raise BadRequest("Camera is using global recording settings.") def callback() -> None: objects = self.smart_detect_settings.audio_types or [] if enabled: if obj_to_mod not in objects: objects = [*objects, obj_to_mod] objects.sort() elif obj_to_mod in objects: objects.remove(obj_to_mod) self.smart_detect_settings.audio_types = objects await self.queue_update(callback) async def set_lcd_text( self, text_type: DoorbellMessageType | None, text: str | None = None, reset_at: datetime | DEFAULT_TYPE | None = None, ) -> None: """Sets doorbell LCD text. Requires camera to be doorbell""" if not self.feature_flags.has_lcd_screen: raise BadRequest("Camera does not have an LCD screen") if text_type is None: async with self._update_sync.lock: # yield to the event loop once we have the lock to process any pending updates await asyncio.sleep(0) data_before_changes = self.dict_with_excludes() self.lcd_message = None # UniFi Protect bug: clearing LCD text message does _not_ emit a WS message await self.save_device(data_before_changes, force_emit=True) return if text_type != DoorbellMessageType.CUSTOM_MESSAGE: if text is not None: raise BadRequest("Can only set text if text_type is CUSTOM_MESSAGE") text = text_type.value.replace("_", " ") if reset_at == DEFAULT: reset_at = ( utc_now() + self._api.bootstrap.nvr.doorbell_settings.default_message_reset_timeout ) def callback() -> None: self.lcd_message = LCDMessage( # type: ignore[call-arg] api=self._api, type=text_type, text=text, # type: ignore[arg-type] reset_at=reset_at, # type: ignore[arg-type] ) await self.queue_update(callback) async def set_privacy( self, enabled: bool, mic_level: int | None = None, recording_mode: RecordingMode | None = None, reenable_global: bool = False, ) -> None: """Adds/removes a privacy zone that blacks out the whole camera.""" if not self.feature_flags.has_privacy_mask: raise BadRequest("Camera does not allow privacy zones") def callback() -> None: if enabled: self.use_global = False self.add_privacy_zone() else: if reenable_global: self.use_global = True self.remove_privacy_zone() if not reenable_global: if mic_level is not None: self.mic_volume = PercentInt(mic_level) if recording_mode is not None: self.recording_settings.mode = recording_mode await self.queue_update(callback) async def set_person_track(self, enabled: bool) -> None: """Sets person tracking on camera""" if not self.feature_flags.is_ptz: raise BadRequest("Camera does not support person tracking") if self.use_global: raise BadRequest("Camera is using global recording settings.") def callback() -> None: self.smart_detect_settings.auto_tracking_object_types = ( [SmartDetectObjectType.PERSON] if enabled else [] ) await self.queue_update(callback) async def create_talkback_stream( self, content_url: str, ffmpeg_path: Path | None = None, # Deprecated: no longer used *, use_public_api: bool = True, ) -> TalkbackStream: """ Creates a stream to play audio to a camera through its speaker. Uses PyAV (libav) for audio encoding - compatible with Home Assistant. Args: ---- content_url: Either a URL accessible by python or a path to a file ffmpeg_path: Deprecated, no longer used (PyAV handles encoding) use_public_api: Use the public API to get talkback session (default: True) Use either `await stream.run_until_complete()` or `await stream.start()` to start streaming after getting the stream. `.play_audio()` is a helper that wraps this method and automatically runs the stream as well """ if ffmpeg_path is not None: _LOGGER.warning( "ffmpeg_path is deprecated and ignored, PyAV is used instead" ) if self.talkback_stream is not None and self.talkback_stream.is_running: raise BadRequest("Camera is already playing audio") session: TalkbackSession | None = None if use_public_api: if self._api._api_key is None: raise NotAuthorized( "Cannot create talkback session without an API key." ) session = await self._api.create_talkback_session_public(self.id) self.talkback_stream = TalkbackStream(self, content_url, session) return self.talkback_stream async def play_audio( self, content_url: str, ffmpeg_path: Path | None = None, # Deprecated: no longer used blocking: bool = True, *, use_public_api: bool = True, ) -> None: """ Plays audio to a camera through its speaker. Uses PyAV (libav) for audio encoding - compatible with Home Assistant. Args: ---- content_url: Either a URL accessible by python or a path to a file ffmpeg_path: Deprecated, no longer used (PyAV handles encoding) blocking: Awaits stream completion use_public_api: Use the public API to get talkback session (default: True) """ stream = await self.create_talkback_stream( content_url, ffmpeg_path, use_public_api=use_public_api ) await stream.start() if blocking: await self.wait_until_audio_completes() async def wait_until_audio_completes(self) -> None: """Awaits stream completion of audio.""" stream = self.talkback_stream if stream is None: raise StreamError("No audio playing to wait for") await stream.run_until_complete() async def stop_audio(self) -> None: """Stop currently playing audio.""" if (stream := self.talkback_stream) is None: raise StreamError("No audio playing to stop") await stream.stop() def can_read_media(self, user: User) -> bool: if self.model is None: return True return user.can(self.model, PermissionNode.READ_MEDIA, self) def can_delete_media(self, user: User) -> bool: if self.model is None: return True return user.can(self.model, PermissionNode.DELETE_MEDIA, self) # region PTZ async def get_ptz_presets(self) -> list[PTZPreset]: """Get PTZ Presets for camera.""" if not self.feature_flags.is_ptz: raise BadRequest("Camera does not support PTZ features.") return await self._api.get_presets_ptz_camera(self.id) async def get_ptz_patrols(self) -> list[PTZPatrol]: """Get PTZ Patrols for camera.""" if not self.feature_flags.is_ptz: raise BadRequest("Camera does not support PTZ features.") return await self._api.get_patrols_ptz_camera(self.id) def _check_ptz_public_api(self) -> None: """Check prerequisites for PTZ public API calls.""" if not self.feature_flags.is_ptz: raise BadRequest("Camera does not support PTZ features.") async def ptz_goto_preset_public(self, *, slot: int) -> None: """Move PTZ camera to preset position using public API.""" self._check_ptz_public_api() await self._api.ptz_goto_preset_public(self.id, slot=slot) async def ptz_patrol_start_public(self, *, slot: int) -> None: """Start a PTZ patrol using public API.""" self._check_ptz_public_api() await self._api.ptz_patrol_start_public(self.id, slot=slot) async def ptz_patrol_stop_public(self) -> None: """Stop the active PTZ patrol using public API.""" self._check_ptz_public_api() await self._api.ptz_patrol_stop_public(self.id) async def set_name_public(self, name: str) -> None: """Set camera name via public API.""" updated = await self._api.update_camera_public(self.id, name=name) self.name = updated.name async def set_status_light_public(self, enabled: bool) -> None: """Set status LED via public API.""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") updated = await self._api.update_camera_public(self.id, led_is_enabled=enabled) self._merge_public_led_settings(updated.led_settings) async def set_welcome_led_public(self, enabled: bool) -> None: """Set welcome LED via public API.""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") if self.led_settings.welcome_led is None: raise BadRequest("Camera does not have welcome LED") updated = await self._api.update_camera_public(self.id, led_welcome_led=enabled) self._merge_public_led_settings(updated.led_settings) async def set_flood_led_public(self, enabled: bool) -> None: """Set flood LED via public API.""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") if self.led_settings.flood_led is None: raise BadRequest("Camera does not have flood LED") updated = await self._api.update_camera_public(self.id, led_flood_led=enabled) self._merge_public_led_settings(updated.led_settings) def _merge_public_led_settings(self, led: PublicCameraLedSettings) -> None: """Sync the private LED sub-model from a public PATCH response in place.""" # Only the public-API fields; private-only fields (e.g. ``blink_rate``) # are left untouched. self.led_settings.is_enabled = led.is_enabled self.led_settings.welcome_led = led.welcome_led self.led_settings.flood_led = led.flood_led def _merge_public_osd_settings(self, osd: PublicOsdSettings) -> None: """Sync the private OSD sub-model from a public PATCH response in place.""" self.osd_settings.is_name_enabled = osd.is_name_enabled self.osd_settings.is_date_enabled = osd.is_date_enabled self.osd_settings.is_logo_enabled = osd.is_logo_enabled self.osd_settings.is_debug_enabled = osd.is_debug_enabled self.osd_settings.overlay_location = osd.overlay_location async def set_hdr_mode_public(self, mode: PublicHdrMode) -> None: """Set HDR mode via public API.""" if not self.feature_flags.has_hdr: raise BadRequest("Camera does not have HDR") await self._api.update_camera_public(self.id, hdr_type=mode) # The public API response uses 'hdrType', not 'hdrMode'/'ispSettings', # so we derive the local state update directly from the mode we sent. hdr_on = mode != PublicHdrMode.OFF if hasattr(self, "hdr_mode"): self.hdr_mode = hdr_on isp = getattr(self, "isp_settings", None) if isp is not None: isp_hdr = getattr(isp, "hdr_mode", None) if isp_hdr is not None: isp.hdr_mode = ( HDRMode.ALWAYS_ON if mode == PublicHdrMode.ON else HDRMode.NORMAL ) async def set_video_mode_public(self, mode: VideoMode) -> None: """Set video mode via public API.""" if mode not in self.feature_flags.video_modes: raise BadRequest(f"Camera does not have {mode}") updated = await self._api.update_camera_public(self.id, video_mode=mode) self.video_mode = updated.video_mode async def set_mic_volume_public(self, level: float) -> None: """Set microphone volume via public API.""" if not self.feature_flags.has_mic: raise BadRequest("Camera does not have mic") level = _coerce_public_int("mic_volume", level, _PUBLIC_MIC_VOLUME_RANGE) updated = await self._api.update_camera_public(self.id, mic_volume=level) self.mic_volume = updated.mic_volume async def set_lcd_message_public( self, text_type: DoorbellMessageType | None, text: str | None = None, reset_at: datetime | DEFAULT_TYPE | None = DEFAULT, ) -> None: """ Set doorbell LCD message via public API. Pass ``None`` for ``text_type`` to clear the message, with ``text`` and ``reset_at`` omitted. ``text`` is required for CUSTOM_MESSAGE and IMAGE, and must be omitted for DO_NOT_DISTURB and LEAVE_PACKAGE_AT_DOOR. ``reset_at`` controls when the message is cleared: omit for the NVR default, pass ``None`` for "forever", or pass a specific datetime. """ if not self.feature_flags.has_lcd_screen: raise BadRequest("Camera does not have an LCD screen") message = _build_public_lcd_message(text_type, text, reset_at) updated = await self._api.update_camera_public(self.id, lcd_message=message) if text_type is None: # The response is not read back: the console only runs its sweep for # a provisioned, connected camera, so an offline one echoes the # request straight back and would rebuild the message just cleared. # The faked frame below only reaches an instance the bootstrap owns, # so set the local state here as ``set_lcd_text`` does. self.lcd_message = None # UniFi Protect bug: clearing the LCD message does _not_ emit a WS # message, so fake one the way ``set_lcd_text`` does. A reset time # in the past is how the console signals a wiped message. reset = to_js_time(utc_now() - timedelta(seconds=10)) await self.emit_message({"lcdMessage": {"resetAt": reset}}) return pub = updated.lcd_message # The public response carries a ``PublicLcdMessage``; rebuild the private # ``LCDMessage`` from it (``None`` when the message was cleared). self.lcd_message = ( None if pub is None or pub.type is None else LCDMessage.from_unifi_dict( type=pub.type, text=pub.text or "", resetAt=pub.reset_at, api=self._api, ) ) async def set_osd_name_public(self, enabled: bool) -> None: """Toggle name overlay (OSD) via public API.""" if self.use_global: raise BadRequest("Camera is using global recording settings.") updated = await self._api.update_camera_public( self.id, osd_name_enabled=enabled ) self._merge_public_osd_settings(updated.osd_settings) async def set_osd_date_public(self, enabled: bool) -> None: """Toggle date overlay (OSD) via public API.""" if self.use_global: raise BadRequest("Camera is using global recording settings.") updated = await self._api.update_camera_public( self.id, osd_date_enabled=enabled ) self._merge_public_osd_settings(updated.osd_settings) async def set_osd_logo_public(self, enabled: bool) -> None: """Toggle logo overlay (OSD) via public API.""" if self.use_global: raise BadRequest("Camera is using global recording settings.") updated = await self._api.update_camera_public( self.id, osd_logo_enabled=enabled ) self._merge_public_osd_settings(updated.osd_settings) async def set_osd_nerd_mode_public(self, enabled: bool) -> None: """Toggle bitrate/debug overlay (OSD) via public API.""" if self.use_global: raise BadRequest("Camera is using global recording settings.") updated = await self._api.update_camera_public( self.id, osd_nerd_mode_enabled=enabled ) self._merge_public_osd_settings(updated.osd_settings) async def set_osd_overlay_location_public( self, location: OsdOverlayLocation, ) -> None: """Set OSD overlay location via public API.""" if self.use_global: raise BadRequest("Camera is using global recording settings.") updated = await self._api.update_camera_public( self.id, osd_overlay_location=location ) self._merge_public_osd_settings(updated.osd_settings) async def set_person_detection_public(self, enabled: bool) -> None: """Toggle person smart detection via public API.""" await self._set_smart_detect_type_public(SmartDetectObjectType.PERSON, enabled) async def set_vehicle_detection_public(self, enabled: bool) -> None: """Toggle vehicle smart detection via public API.""" await self._set_smart_detect_type_public(SmartDetectObjectType.VEHICLE, enabled) async def set_package_detection_public(self, enabled: bool) -> None: """Toggle package smart detection via public API.""" await self._set_smart_detect_type_public(SmartDetectObjectType.PACKAGE, enabled) async def set_license_plate_detection_public(self, enabled: bool) -> None: """Toggle license plate smart detection via public API.""" await self._set_smart_detect_type_public( SmartDetectObjectType.LICENSE_PLATE, enabled ) async def set_animal_detection_public(self, enabled: bool) -> None: """Toggle animal smart detection via public API.""" await self._set_smart_detect_type_public(SmartDetectObjectType.ANIMAL, enabled) async def set_face_detection_public(self, enabled: bool) -> None: """Toggle face smart detection via public API.""" await self._set_smart_detect_type_public(SmartDetectObjectType.FACE, enabled) async def _set_smart_detect_type_public( self, obj_type: SmartDetectObjectType, enabled: bool ) -> None: if not getattr(self.feature_flags, "has_smart_detect", True): raise BadRequest("Camera does not have smart detections") if self.use_global: raise BadRequest("Camera is using global recording settings.") if obj_type not in self.feature_flags.smart_detect_types: raise BadRequest(f"Camera does not support {obj_type} detection") # Lock serializes concurrent callers that each read-modify-PATCH the full # list. Without the lock two concurrent calls could clobber each other's # change (last PATCH wins). Same pattern as the private queue_update path. async with self._update_sync.lock: types = list(self.smart_detect_settings.object_types) if enabled and obj_type not in types: types.append(obj_type) elif not enabled and obj_type in types: types.remove(obj_type) updated = await self._api.update_camera_public( self.id, smart_detect_object_types=types ) self.smart_detect_settings.object_types = ( updated.smart_detect_settings.object_types ) async def set_smoke_detection_public(self, enabled: bool) -> None: """Toggle smoke audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.SMOKE, enabled ) async def set_co_detection_public(self, enabled: bool) -> None: """Toggle CO audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.CMONX, enabled ) async def set_siren_detection_public(self, enabled: bool) -> None: """Toggle siren audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.SIREN, enabled ) async def set_baby_cry_detection_public(self, enabled: bool) -> None: """Toggle baby cry audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.BABY_CRY, enabled ) async def set_speaking_detection_public(self, enabled: bool) -> None: """Toggle speaking audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.SPEAK, enabled ) async def set_bark_detection_public(self, enabled: bool) -> None: """Toggle bark audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.BARK, enabled ) async def set_burglar_detection_public(self, enabled: bool) -> None: """Toggle burglar audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.BURGLAR, enabled ) async def set_car_horn_detection_public(self, enabled: bool) -> None: """Toggle car horn audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.CAR_HORN, enabled ) async def set_glass_break_detection_public(self, enabled: bool) -> None: """Toggle glass break audio detection via public API.""" await self._set_smart_detect_audio_type_public( SmartDetectAudioType.GLASS_BREAK, enabled ) async def _set_smart_detect_audio_type_public( self, audio_type: SmartDetectAudioType, enabled: bool ) -> None: if not getattr(self.feature_flags, "has_smart_detect", True): raise BadRequest("Camera does not have smart detections") if self.use_global: raise BadRequest("Camera is using global recording settings.") audio_types = self.feature_flags.smart_detect_audio_types if audio_types is None or audio_type not in audio_types: raise BadRequest(f"Camera does not support {audio_type} audio detection") # Lock serializes concurrent callers; same rationale as # _set_smart_detect_type_public above. async with self._update_sync.lock: types = list(self.smart_detect_settings.audio_types or []) if enabled and audio_type not in types: types.append(audio_type) elif not enabled and audio_type in types: types.remove(audio_type) updated = await self._api.update_camera_public( self.id, smart_detect_audio_types=types ) self.smart_detect_settings.audio_types = ( updated.smart_detect_settings.audio_types ) # endregion class Viewer(ProtectAdoptableDeviceModel): stream_limit: int software_version: str liveview_id: str @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "liveview": "liveviewId"} @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | {"softwareVersion"} @property def liveview(self) -> Liveview | None: # user may not have permission to see the liveview return self._api.bootstrap.liveviews.get(self.liveview_id) async def set_liveview(self, liveview: Liveview) -> None: """ Set the liveview for this viewer. .. deprecated:: Use :meth:`ProtectApiClient.update_viewer_public` instead; the public-API counterpart is feature-complete for renaming and liveview assignment. """ warnings.warn( "Viewer.set_liveview is deprecated; use " "ProtectApiClient.update_viewer_public(viewer_id, liveview=...) " "instead.", DeprecationWarning, stacklevel=2, ) if self._api is not None and liveview.id not in self._api.bootstrap.liveviews: raise BadRequest("Unknown liveview") async with self._update_sync.lock: # yield to the event loop once we have the lock to process any pending updates await asyncio.sleep(0) data_before_changes = self.dict_with_excludes() self.liveview_id = liveview.id # UniFi Protect bug: changing the liveview does _not_ emit a WS message await self.save_device(data_before_changes, force_emit=True) async def set_name_public(self, name: str) -> None: """Set viewer name via public API.""" updated = await self._api.update_viewer_public(self.id, name=name) self.name = updated.name async def set_liveview_public(self, liveview_id: str | None) -> None: """Set the viewer liveview via public API. Pass ``None`` to clear.""" updated = await self._api.update_viewer_public(self.id, liveview=liveview_id) # ``liveview_id`` is non-optional on the private model; the public API # represents a cleared liveview as ``None`` on the wire. self.liveview_id = updated.liveview_id or "" class Bridge(ProtectAdoptableDeviceModel): platform: str class SensorSettingsBase(ProtectBaseObject): is_enabled: bool class SensorThresholdSettings(SensorSettingsBase): margin: float # read only # "safe" thresholds for alerting # anything below/above will trigger alert low_threshold: float | None = None high_threshold: float | None = None class SensorSensitivitySettings(SensorSettingsBase): sensitivity: PercentInt # Armed-mode sensitivity override. Absent on older firmware / not carried by # every console, so it is optional and dropped from the wire dict when unset. sensitivity_when_armed: PercentInt | None = None def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"sensitivityWhenArmed"}) return data class SensorBatteryStatus(ProtectBaseObject): percentage: PercentInt | None = None is_low: bool class SensorStat(ProtectBaseObject): value: float | None = None status: SensorStatusType class SensorStats(ProtectBaseObject): light: SensorStat humidity: SensorStat temperature: SensorStat class Sensor(ProtectAdoptableDeviceModel): alarm_settings: SensorSettingsBase alarm_triggered_at: datetime | None = None battery_status: SensorBatteryStatus camera_id: str | None = None humidity_settings: SensorThresholdSettings is_motion_detected: bool is_opened: bool leak_detected_at: datetime | None = None led_settings: LEDSettings light_settings: SensorThresholdSettings motion_detected_at: datetime | None = None motion_settings: SensorSensitivitySettings open_status_changed_at: datetime | None = None stats: SensorStats tampering_detected_at: datetime | None = None temperature_settings: SensorThresholdSettings mount_type: MountType # not directly from UniFi last_motion_event_id: str | None = None last_contact_event_id: str | None = None last_value_event_id: str | None = None last_alarm_event_id: str | None = None extreme_value_detected_at: datetime | None = None _tamper_timeout: datetime | None = PrivateAttr(None) _alarm_timeout: datetime | None = PrivateAttr(None) @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "camera": "cameraId"} @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | { "batteryStatus", "isMotionDetected", "leakDetectedAt", "tamperingDetectedAt", "isOpened", "openStatusChangedAt", "alarmTriggeredAt", "motionDetectedAt", "stats", } def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_tuple( data, ( "lastMotionEventId", "lastContactEventId", "lastValueEventId", "lastAlarmEventId", "extremeValueDetectedAt", ), ) return data @property def camera(self) -> Camera | None: """Paired Camera will always be none if no camera is paired""" if (camera_id := self.camera_id) is None: return None return self._api.bootstrap.cameras[camera_id] @property def is_tampering_detected(self) -> bool: return self.tampering_detected_at is not None @property def is_alarm_detected(self) -> bool: if self._alarm_timeout is None: return False return utc_now() < self._alarm_timeout @property def is_contact_sensor_enabled(self) -> bool: return self.mount_type in {MountType.DOOR, MountType.WINDOW, MountType.GARAGE} @property def is_motion_sensor_enabled(self) -> bool: return self.mount_type is not MountType.LEAK and self.motion_settings.is_enabled @property def is_alarm_sensor_enabled(self) -> bool: return self.mount_type is not MountType.LEAK and self.alarm_settings.is_enabled @property def is_light_sensor_enabled(self) -> bool: return self.mount_type is not MountType.LEAK and self.light_settings.is_enabled @property def is_temperature_sensor_enabled(self) -> bool: return ( self.mount_type is not MountType.LEAK and self.temperature_settings.is_enabled ) @property def is_humidity_sensor_enabled(self) -> bool: return ( self.mount_type is not MountType.LEAK and self.humidity_settings.is_enabled ) @property def is_leak_sensor_enabled(self) -> bool: return self.mount_type is MountType.LEAK def set_alarm_timeout(self) -> None: self._alarm_timeout = utc_now() + EVENT_PING_INTERVAL self._event_callback_ping() @property def last_motion_event(self) -> Event | None: if (last_motion_event_id := self.last_motion_event_id) is None: return None return self._api.bootstrap.events.get(last_motion_event_id) @property def last_contact_event(self) -> Event | None: if (last_contact_event_id := self.last_contact_event_id) is None: return None return self._api.bootstrap.events.get(last_contact_event_id) @property def last_value_event(self) -> Event | None: if (last_value_event_id := self.last_value_event_id) is None: return None return self._api.bootstrap.events.get(last_value_event_id) @property def last_alarm_event(self) -> Event | None: if (last_alarm_event_id := self.last_alarm_event_id) is None: return None return self._api.bootstrap.events.get(last_alarm_event_id) @property def is_leak_detected(self) -> bool: return self.leak_detected_at is not None async def set_status_light(self, enabled: bool) -> None: """Sets the status indicator light for the sensor""" def callback() -> None: self.led_settings.is_enabled = enabled await self.queue_update(callback) async def set_mount_type(self, mount_type: MountType) -> None: """Sets current mount type for sensor""" def callback() -> None: self.mount_type = mount_type await self.queue_update(callback) async def set_motion_status(self, enabled: bool) -> None: """Sets the motion detection type for the sensor""" def callback() -> None: self.motion_settings.is_enabled = enabled await self.queue_update(callback) async def set_temperature_status(self, enabled: bool) -> None: """Sets the temperature detection type for the sensor""" def callback() -> None: self.temperature_settings.is_enabled = enabled await self.queue_update(callback) async def set_temperature_safe_range(self, low: float, high: float) -> None: """Sets the temperature safe range for the sensor""" if low < 0.0: raise BadRequest("Minimum value is 0°C") if high > 45.0: raise BadRequest("Maximum value is 45°C") if high <= low: raise BadRequest("High value must be above low value") def callback() -> None: self.temperature_settings.low_threshold = low self.temperature_settings.high_threshold = high await self.queue_update(callback) async def remove_temperature_safe_range(self) -> None: """Removes the temperature safe range for the sensor""" def callback() -> None: self.temperature_settings.low_threshold = None self.temperature_settings.high_threshold = None await self.queue_update(callback) async def set_humidity_status(self, enabled: bool) -> None: """Sets the humidity detection type for the sensor""" def callback() -> None: self.humidity_settings.is_enabled = enabled await self.queue_update(callback) async def set_humidity_safe_range(self, low: float, high: float) -> None: """Sets the humidity safe range for the sensor""" if low < 1.0: raise BadRequest("Minimum value is 1%") if high > 99.0: raise BadRequest("Maximum value is 99%") if high <= low: raise BadRequest("High value must be above low value") def callback() -> None: self.humidity_settings.low_threshold = low self.humidity_settings.high_threshold = high await self.queue_update(callback) async def remove_humidity_safe_range(self) -> None: """Removes the humidity safe range for the sensor""" def callback() -> None: self.humidity_settings.low_threshold = None self.humidity_settings.high_threshold = None await self.queue_update(callback) async def set_light_status(self, enabled: bool) -> None: """Sets the light detection type for the sensor""" def callback() -> None: self.light_settings.is_enabled = enabled await self.queue_update(callback) async def set_light_safe_range(self, low: float, high: float) -> None: """Sets the light safe range for the sensor""" if low < 1.0: raise BadRequest("Minimum value is 1 lux") if high > 1000.0: raise BadRequest("Maximum value is 1000 lux") if high <= low: raise BadRequest("High value must be above low value") def callback() -> None: self.light_settings.low_threshold = low self.light_settings.high_threshold = high await self.queue_update(callback) async def remove_light_safe_range(self) -> None: """Removes the light safe range for the sensor""" def callback() -> None: self.light_settings.low_threshold = None self.light_settings.high_threshold = None await self.queue_update(callback) async def set_alarm_status(self, enabled: bool) -> None: """Sets the alarm detection type for the sensor""" def callback() -> None: self.alarm_settings.is_enabled = enabled await self.queue_update(callback) async def set_paired_camera(self, camera: Camera | None) -> None: """Sets the camera paired with the sensor""" def callback() -> None: if camera is None: self.camera_id = None else: self.camera_id = camera.id await self.queue_update(callback) async def clear_tamper(self) -> None: """Clears tamper status for sensor""" if not self._api.bootstrap.auth_user.can( ModelType.SENSOR, PermissionNode.WRITE, self, ): raise NotAuthorized( f"Do not have permission to clear tamper for sensor: {self.id}", ) await self._api.clear_tamper_sensor(self.id) async def set_name_public(self, name: str) -> None: """Set sensor name via public API.""" updated = await self._api.update_sensor_public(self.id, name=name) self.name = updated.name async def set_temperature_settings_public( self, *, is_enabled: bool | None = None, low_threshold: float | None = None, high_threshold: float | None = None, margin: float | None = None, ) -> None: """Update temperature alert settings via public API.""" low, high = _PUBLIC_TEMPERATURE_LOW_RANGE settings: PublicSensorTemperatureSettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if low_threshold is not None: _validate_public_range("low_threshold", low_threshold, (low, high)) settings["lowThreshold"] = low_threshold if high_threshold is not None: settings["highThreshold"] = high_threshold if margin is not None: settings["margin"] = margin if not settings: raise BadRequest("At least one parameter must be provided") await self._api.update_sensor_public(self.id, temperature_settings=settings) self._merge_threshold_local( "temperature_settings", is_enabled=is_enabled, low_threshold=low_threshold, high_threshold=high_threshold, margin=margin, ) async def set_humidity_settings_public( self, *, is_enabled: bool | None = None, low_threshold: float | None = None, high_threshold: float | None = None, margin: int | None = None, ) -> None: """Update humidity alert settings via public API.""" low, high = _PUBLIC_HUMIDITY_LOW_RANGE settings: PublicSensorHumiditySettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if low_threshold is not None: _validate_public_range("low_threshold", low_threshold, (low, high)) settings["lowThreshold"] = low_threshold if high_threshold is not None: settings["highThreshold"] = high_threshold if margin is not None: settings["margin"] = margin if not settings: raise BadRequest("At least one parameter must be provided") await self._api.update_sensor_public(self.id, humidity_settings=settings) self._merge_threshold_local( "humidity_settings", is_enabled=is_enabled, low_threshold=low_threshold, high_threshold=high_threshold, margin=margin, ) async def set_light_settings_public( self, *, is_enabled: bool | None = None, low_threshold: float | None = None, high_threshold: float | None = None, margin: int | None = None, ) -> None: """Update light (lux) alert settings via public API.""" low, high = _PUBLIC_LIGHT_LUX_LOW_RANGE settings: PublicSensorLightSettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if low_threshold is not None: _validate_public_range("low_threshold", low_threshold, (low, high)) settings["lowThreshold"] = low_threshold if high_threshold is not None: settings["highThreshold"] = high_threshold if margin is not None: settings["margin"] = margin if not settings: raise BadRequest("At least one parameter must be provided") await self._api.update_sensor_public(self.id, light_settings=settings) self._merge_threshold_local( "light_settings", is_enabled=is_enabled, low_threshold=low_threshold, high_threshold=high_threshold, margin=margin, ) async def set_motion_settings_public( self, *, is_enabled: bool | None = None, sensitivity: float | None = None, sensitivity_when_armed: float | None = None, ) -> None: """Update motion detection settings via public API.""" settings: PublicSensorMotionSettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if sensitivity is not None: sensitivity = _coerce_public_int( "sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE ) settings["sensitivity"] = sensitivity if sensitivity_when_armed is not None: settings["sensitivityWhenArmed"] = _coerce_public_int( "sensitivity_when_armed", sensitivity_when_armed, _PUBLIC_SENSITIVITY_RANGE, ) if not settings: raise BadRequest("At least one parameter must be provided") await self._api.update_sensor_public(self.id, motion_settings=settings) current = self.motion_settings.model_copy() if is_enabled is not None: current.is_enabled = is_enabled if sensitivity is not None: current.sensitivity = PercentInt(sensitivity) if sensitivity_when_armed is not None: current.sensitivity_when_armed = PercentInt(sensitivity_when_armed) self.motion_settings = current async def set_glass_break_settings_public( self, *, is_enabled: bool | None = None, sensitivity: float | None = None, sensitivity_when_armed: float | None = None, ) -> None: """Update glass-break detection settings via public API.""" settings: PublicSensorGlassBreakSettingsWrite = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if sensitivity is not None: settings["sensitivity"] = _coerce_public_int( "sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE ) if sensitivity_when_armed is not None: settings["sensitivityWhenArmed"] = _coerce_public_int( "sensitivity_when_armed", sensitivity_when_armed, _PUBLIC_SENSITIVITY_RANGE, ) if not settings: raise BadRequest("At least one parameter must be provided") await self._api.update_sensor_public(self.id, glass_break_settings=settings) async def set_alarm_public(self, enabled: bool) -> None: """Toggle the (audio) alarm detection setting via public API.""" alarm_settings: PublicSensorAlarmSettings = {"isEnabled": enabled} await self._api.update_sensor_public(self.id, alarm_settings=alarm_settings) current = self.alarm_settings.model_copy() current.is_enabled = enabled self.alarm_settings = current async def set_schedule_mode_public(self, mode: SensorScheduleMode) -> None: """Set the arm-schedule mode via public API.""" await self._api.update_sensor_public(self.id, schedule_mode=mode) async def set_arm_profile_ids_public(self, arm_profile_ids: list[str]) -> None: """Set the arm-profile ids associated with the sensor via public API.""" await self._api.update_sensor_public(self.id, arm_profile_ids=arm_profile_ids) async def set_custom_sensitivity_when_armed_public(self, enabled: bool) -> None: """Toggle custom armed sensitivity via public API.""" await self._api.update_sensor_public( self.id, has_custom_sensitivity_when_armed=enabled ) async def set_motion_status_public(self, enabled: bool) -> None: """Toggle motion detection via public API.""" await self.set_motion_settings_public(is_enabled=enabled) async def set_motion_sensitivity_public(self, sensitivity: float) -> None: """Set motion detection sensitivity via public API.""" await self.set_motion_settings_public(sensitivity=sensitivity) async def set_temperature_status_public(self, enabled: bool) -> None: """Toggle temperature alerts via public API.""" await self.set_temperature_settings_public(is_enabled=enabled) async def set_humidity_status_public(self, enabled: bool) -> None: """Toggle humidity alerts via public API.""" await self.set_humidity_settings_public(is_enabled=enabled) async def set_light_status_public(self, enabled: bool) -> None: """Toggle light (lux) alerts via public API.""" await self.set_light_settings_public(is_enabled=enabled) async def set_glass_break_status_public(self, enabled: bool) -> None: """Toggle glass-break detection via public API.""" await self.set_glass_break_settings_public(is_enabled=enabled) def _merge_threshold_local( self, attr: str, *, is_enabled: bool | None, low_threshold: float | None, high_threshold: float | None, margin: float | None, ) -> None: current: SensorThresholdSettings = getattr(self, attr).model_copy() if is_enabled is not None: current.is_enabled = is_enabled if low_threshold is not None: current.low_threshold = low_threshold if high_threshold is not None: current.high_threshold = high_threshold if margin is not None: current.margin = margin setattr(self, attr, current) class ChimeFeatureFlags(ProtectBaseObject): has_wifi: bool # 2.9.20+ has_https_client_ota: bool | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "hasHttpsClientOTA": "hasHttpsClientOta"} class RingSetting(ProtectBaseObject): """Ring settings for a paired doorbell camera.""" camera_id: str repeat_times: RepeatTimes ringtone_id: str | None = None track_no: int | None = None # deprecated: use ringtone_id volume: int @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "camera": "cameraId"} def to_api_dict( self, volume: int | None = None, repeat_times: int | None = None ) -> PublicApiChimeRingSettingRequest: """ Convert to API dict format for Public API requests. Args: ---- volume: Override volume value. If None, uses current volume. repeat_times: Override repeat-times value. If None, uses current repeat_times. Returns: ------- Dict with cameraId, volume, repeatTimes, and optionally ringtoneId keys. """ result: PublicApiChimeRingSettingRequest = { "cameraId": self.camera_id, "volume": volume if volume is not None else self.volume, "repeatTimes": repeat_times if repeat_times is not None else self.repeat_times, } if self.ringtone_id is not None: result["ringtoneId"] = self.ringtone_id return result @property def camera(self) -> Camera | None: """Paired Camera will always be none if no camera is paired.""" if self.camera_id is None: return None # type: ignore[unreachable] return self._api.bootstrap.cameras[self.camera_id] class ChimeTrack(ProtectBaseObject): md5: str name: str state: str track_no: int volume: int size: int @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "track_no": "trackNo"} class Chime(ProtectAdoptableDeviceModel): volume: PercentInt is_probing_for_wifi: bool last_ring: datetime | None = None is_wireless_uplink_enabled: bool camera_ids: list[str] # requires 2.6.17+ ap_mgmt_ip: IPv4Address | IPv6Address | None = None # requires 2.7.15+ feature_flags: ChimeFeatureFlags | None = None # requires 2.8.22+ user_configured_ap: bool | None = None # requires 3.0.22+ has_https_client_ota: bool | None = None platform: str | None = None repeat_times: RepeatTimes | None = None ring_settings: list[RingSetting] = [] speaker_track_list: list[ChimeTrack] = [] @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "hasHttpsClientOTA": "hasHttpsClientOta"} @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | {"isProbingForWifi", "lastRing"} @property def cameras(self) -> list[Camera]: """Paired Cameras for chime""" if len(self.camera_ids) == 0: return [] return [self._api.bootstrap.cameras[c] for c in self.camera_ids] async def set_volume(self, level: int) -> None: """ Set the speaker volume on chime. .. deprecated:: Use :meth:`set_volume_for_camera_public` instead. This method updates the speaker volume but not the doorbell ring volume. """ warnings.warn( "set_volume is deprecated, use set_volume_for_camera_public instead", DeprecationWarning, stacklevel=2, ) old_value = self.volume new_value = PercentInt(level) def callback() -> None: self.volume = new_value for setting in self.ring_settings: if setting.volume == old_value: setting.volume = new_value await self.queue_update(callback) async def add_camera(self, camera: Camera) -> None: """Adds new paired camera to chime""" if not camera.feature_flags.is_doorbell: raise BadRequest("Camera does not have a chime") if camera.id in self.camera_ids: raise BadRequest("Camera is already paired") def callback() -> None: self.camera_ids.append(camera.id) await self.queue_update(callback) async def remove_camera(self, camera: Camera) -> None: """Removes paired camera from chime""" if camera.id not in self.camera_ids: raise BadRequest("Camera is not paired") def callback() -> None: self.camera_ids.remove(camera.id) await self.queue_update(callback) async def play( self, *, volume: int | None = None, repeat_times: int | None = None, ringtone_id: str | None = None, track_no: int | None = None, ) -> None: """ Plays chime tone. Args: ---- volume: Volume level for playback (0-100). Uses chime's current volume if None. repeat_times: Number of times to repeat the tone. ringtone_id: The ringtone ID (UUID) to play. If None, uses default tone. track_no: Legacy track number from speakerTrackList. .. deprecated:: Use ringtone_id instead. """ if track_no is not None: warnings.warn( "track_no is deprecated, use ringtone_id instead", DeprecationWarning, stacklevel=2, ) await self._api.play_speaker( self.id, volume=volume, repeat_times=repeat_times, ringtone_id=ringtone_id, track_no=track_no, ) async def play_buzzer(self) -> None: """Plays chime buzzer""" await self._api.play_buzzer(self.id) async def set_repeat_times(self, value: int) -> None: """ Set repeat times on chime. .. deprecated:: Use :meth:`set_ring_settings_public` instead. This method uses the private API. """ warnings.warn( "set_repeat_times is deprecated, use set_ring_settings_public instead", DeprecationWarning, stacklevel=2, ) old_value = self.repeat_times def callback() -> None: self.repeat_times = cast("RepeatTimes", value) for setting in self.ring_settings: if setting.repeat_times == old_value: setting.repeat_times = cast("RepeatTimes", value) await self.queue_update(callback) async def set_name_public(self, name: str) -> None: """Set chime name via public API.""" updated = await self._api.update_chime_public(self.id, name=name) self.name = updated.name async def set_ring_settings_public( self, ring_settings: list[PublicApiChimeRingSettingRequest], ) -> None: """ Update ring settings using public API. This is the preferred method to change ring volume per camera as it uses the official public API. Args: ---- ring_settings: List of ring settings per camera. Each dict should contain: - cameraId: The camera ID this setting applies to - volume: Ring volume (0-100) - repeatTimes: How many times to repeat (1-10) - ringtoneId (optional): The ringtone ID to use Example: ------- >>> await chime.set_ring_settings_public([ ... { ... "cameraId": "camera123", ... "volume": 80, ... "repeatTimes": 2, ... "ringtoneId": "ringtone456" ... } ... ]) """ updated = await self._api.update_chime_public( self.id, ring_settings=ring_settings, ) self._api._write_through_public_twin(updated) # The public response carries ``PublicRingSettings`` (every field typed # optional); rebuild the strict private ``RingSetting`` list from it, # skipping entries the wire left incomplete so a partial response can't # raise ``ValidationError`` (``RingSetting`` requires camera_id / # repeat_times / volume). self.ring_settings = [ RingSetting.from_unifi_dict(**rs.unifi_dict(), api=self._api) for rs in updated.ring_settings if rs.camera_id is not None and rs.repeat_times is not None and rs.volume is not None ] async def set_volume_for_camera_public( self, camera: Camera, level: int, ) -> None: """ Set the ring volume for a specific camera using public API. This is the preferred method to change ring volume as it uses the official public API and properly updates the doorbell ring volume. Args: ---- camera: The doorbell camera to set volume for level: Volume level (0-100) Raises: ------ BadRequest: If the camera is not paired with this chime ValidationError: If level is not in range 0-100 """ # Validate level using PercentInt (raises ValidationError if invalid) PercentInt(level) # Hold the lock across the whole read-modify-write: two concurrent # callers must not each read the pre-mutation list and clobber each # other's change (last PATCH wins). async with self._update_sync.lock: # Find the current ring setting for this camera ring_setting = None for setting in self.ring_settings: if setting.camera_id == camera.id: ring_setting = setting break if ring_setting is None: raise BadRequest(f"Camera {camera.id} is not paired with chime") # Build the update payload preserving original order ring_settings_update: list[PublicApiChimeRingSettingRequest] = [ setting.to_api_dict(volume=level) if setting.camera_id == camera.id else setting.to_api_dict() for setting in self.ring_settings ] await self.set_ring_settings_public(ring_settings_update) async def set_repeat_times_for_camera_public( self, camera: Camera, value: int, ) -> None: """ Set the ring repeat times for a specific camera using public API. This is the preferred method to change ring repeat times as it uses the official public API. Args: ---- camera: The doorbell camera to set repeat times for value: Number of times to repeat the ring (1-6) Raises: ------ BadRequest: If the camera is not paired with this chime ValidationError: If value is not in range 1-6 """ # Validate value using RepeatTimes (raises ValidationError if invalid) RepeatTimes(value) # Hold the lock across the whole read-modify-write (see # ``set_volume_for_camera_public``). async with self._update_sync.lock: # Find the current ring setting for this camera ring_setting = None for setting in self.ring_settings: if setting.camera_id == camera.id: ring_setting = setting break if ring_setting is None: raise BadRequest(f"Camera {camera.id} is not paired with chime") # Build the update payload preserving original order ring_settings_update: list[PublicApiChimeRingSettingRequest] = [ setting.to_api_dict(repeat_times=value) if setting.camera_id == camera.id else setting.to_api_dict() for setting in self.ring_settings ] await self.set_ring_settings_public(ring_settings_update) class AiPort(Camera): paired_cameras: list[str] class Ringtone(ProtectBaseObject): id: str name: str size: int is_default: bool nvr_mac: str model_key: str uilibs-uiprotect-317a52c/src/uiprotect/data/nvr.py000066400000000000000000001460651524637705700222750ustar00rootroot00000000000000"""UniFi Protect Data.""" from __future__ import annotations import asyncio import logging import zoneinfo from collections.abc import Callable from datetime import datetime, timedelta, tzinfo from functools import cache from ipaddress import IPv4Address, IPv6Address from pathlib import Path from typing import Any, ClassVar, Literal from uuid import UUID import aiofiles import orjson from convertertools import pop_dict_set_if_none, pop_dict_tuple from pydantic import ConfigDict, Field from pydantic.fields import PrivateAttr from ..exceptions import BadRequest, NotAuthorized from ..utils import convert_smart_types, convert_to_datetime from .base import ( ProtectBaseObject, ProtectDeviceModel, ProtectModelWithId, ) from .devices import ( Camera, CameraZone, Light, OSDSettings, RecordingSettings, Sensor, SmartDetectSettings, ) from .types import ( AnalyticsOption, DoorbellMessageType, DoorbellText, EventCategories, EventType, FirmwareReleaseChannel, IteratorCallback, ModelType, MountType, PercentFloat, PermissionNode, ProgressCallback, RecordingMode, RecordingType, ResolutionStorageType, SensorAlarmType, SensorStatusType, SensorType, SmartDetectObjectType, StorageType, Version, ) from .user import User, UserLocation _LOGGER = logging.getLogger(__name__) MAX_SUPPORTED_CAMERAS = 256 MAX_EVENT_HISTORY_IN_STATE_MACHINE = MAX_SUPPORTED_CAMERAS * 2 DELETE_KEYS_THUMB = {"color", "vehicleType"} DELETE_KEYS_EVENT = {"deletedAt", "category", "subCategory", "device"} class MetaInfo(ProtectBaseObject): application_version: str @property def version(self) -> Version: """Parsed application version, comparable to the private ``NVR.version``.""" return Version(self.application_version) class NVRLocation(UserLocation): is_geofencing_enabled: bool radius: int model: ModelType | None = None class SmartDetectItemAttribute(ProtectBaseObject): """Attribute value with confidence for smart detect items (e.g., color, vehicle type).""" val: str confidence: int class SmartDetectItem(ProtectBaseObject): id: str timestamp: datetime coord: tuple[int, int, int, int] object_type: SmartDetectObjectType zone_ids: list[int] duration: timedelta confidence: int first_shown_time_ms: int idle_since_time_ms: int stationary: bool license_plate: str | None = None # only populated for vehicle object_type depth: float | None = None speed: float | None = None attributes: dict[str, SmartDetectItemAttribute] | None = None lines: list[int] | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "zones": "zone_ids", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "duration": lambda x: timedelta(milliseconds=x), } | super().unifi_dict_conversions() class SmartDetectTrack(ProtectBaseObject): id: str payload: list[SmartDetectItem] camera_id: str event_id: str @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "camera": "cameraId", "event": "eventId", } @property def camera(self) -> Camera: return self._api.bootstrap.cameras[self.camera_id] @property def event(self) -> Event | None: return self._api.bootstrap.events.get(self.event_id) class EventThumbnailGroup(ProtectBaseObject): """Group information for detected thumbnails (e.g., license plate recognition).""" id: str name: str | None = None matched_name: str | None = None confidence: int def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"matchedName", "name"}) return data class EventThumbnailAttribute(ProtectBaseObject): confidence: int val: str class NfcMetadata(ProtectBaseObject): nfc_id: str | None = None user_id: str | None = None ulp_id: str | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "nfcId": "nfc_id", "userId": "user_id", "ulpId": "ulp_id", } def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"nfcId", "userId", "ulpId"}) return data class FingerprintMetadata(ProtectBaseObject): ulp_id: str | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "ulpId": "ulp_id", } class EventThumbnailAttributes(ProtectBaseObject): """ Dynamic attributes for detected thumbnails. All attributes are stored as extra fields for full flexibility. Common attribute types: - color, vehicleType, faceMask: EventThumbnailAttribute (with val and confidence) - zone: list[int] - Zone IDs where detection occurred - trackerId: int - Unique tracker ID for this detection Allows any attributes for forward compatibility with new UFP features. Example usage: >>> attrs = thumbnail.attributes >>> if attrs: ... # Access EventThumbnailAttribute objects ... color = attrs.color # EventThumbnailAttribute ... if color: ... print(f"Color: {color.val} (confidence: {color.confidence})") ... # Access primitive types ... zones = attrs.zone # list[int] | None ... tracker = attrs.trackerId # int | None """ model_config = ConfigDict(extra="allow") def get_value(self, key: str) -> str | None: """Get the string value from an EventThumbnailAttribute field, if it exists.""" attr = getattr(self, key, None) if isinstance(attr, EventThumbnailAttribute): return attr.val return None @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: # Convert nested attribute objects to EventThumbnailAttribute instances return { key: EventThumbnailAttribute.from_unifi_dict(**value) if isinstance(value, dict) and "val" in value and "confidence" in value else value for key, value in data.items() } def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) # Remove None values from extra fields return {k: v for k, v in data.items() if v is not None} class EventDetectedThumbnail(ProtectBaseObject): clock_best_wall: datetime | None = None type: str cropped_id: str attributes: EventThumbnailAttributes | None = None name: str | None = None coord: list[int] | None = None confidence: int | None = None # requires 6.0.0+ group: EventThumbnailGroup | None = None object_id: str | None = None @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return {"clockBestWall": convert_to_datetime} | super().unifi_dict_conversions() def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"name", "group", "objectId", "coord", "confidence"}) return data class EventMetadata(ProtectBaseObject): client_platform: str | None = None reason: str | None = None app_update: str | None = None light_id: str | None = None light_name: str | None = None type: str | None = None sensor_id: str | None = None sensor_name: str | None = None sensor_type: SensorType | None = None from_value: str | None = None to_value: str | None = None mount_type: MountType | None = None status: SensorStatusType | None = None alarm_type: SensorAlarmType | None = None device_id: str | None = None mac: str | None = None # requires 2.11.13+ detected_thumbnails: list[EventDetectedThumbnail] | None = None # requires 5.1.34+ nfc: NfcMetadata | None = None fingerprint: FingerprintMetadata | None = None _collapse_keys: ClassVar[set[str]] = { "lightId", "lightName", "type", "sensorId", "sensorName", "sensorType", "mountType", "status", "alarmType", "deviceId", "mac", } @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "from": "fromValue", "to": "toValue", } @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: for key in cls._collapse_keys.intersection(data): if isinstance(data[key], dict): if "text" in data[key]: data[key] = data[key]["text"] else: _LOGGER.debug( "Unexpected format for EventMetadata key %s: %s", key, data[key], ) del data[key] return super().unifi_dict_to_dict(data) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) # all metadata keys optionally appear for key, value in list(data.items()): if value is None: del data[key] for key in self._collapse_keys.intersection(data): # AI Theta/Hotplug exception if key != "type" or data[key] not in {"audio", "video", "extender"}: data[key] = {"text": data[key]} return data class Event(ProtectModelWithId): type: EventType start: datetime end: datetime | None = None # ``score`` / ``smart_detect_*`` are always present on the private API # but the Public Integration API websocket omits them on minimal # payloads (motion start, doorbell ring, etc.). Defaults keep the # strict model constructable from either source. score: int = 0 heatmap_id: str | None = None camera_id: str | None = None # Public Integration API ``add`` payloads carry the originating device # identifier under a top-level ``device`` field; private-API payloads # never populate this. Distinct from ``camera_id`` because non-camera # events (sensors, alarm hubs) flow through the public WS. device_id: str | None = None smart_detect_types: list[SmartDetectObjectType] = Field(default_factory=list) smart_detect_event_ids: list[str] = Field(default_factory=list) thumbnail_id: str | None = None user_id: str | None = None timestamp: datetime | None = None metadata: EventMetadata | None = None # requires 2.7.5+ deleted_at: datetime | None = None deletion_type: Literal["manual", "automatic"] | None = None # only appears if `get_events` is called with category category: EventCategories | None = None sub_category: str | None = None # requires 6.0.0+ is_favorite: bool | None = None favorite_object_ids: list[str] | None = None _smart_detect_events: list[Event] | None = PrivateAttr(None) _smart_detect_track: SmartDetectTrack | None = PrivateAttr(None) _smart_detect_zones: dict[int, CameraZone] | None = PrivateAttr(None) @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "camera": "cameraId", "heatmap": "heatmapId", "user": "userId", "thumbnail": "thumbnailId", "smartDetectEvents": "smartDetectEventIds", "device": "deviceId", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return ( dict.fromkeys( ("start", "end", "timestamp", "deletedAt"), convert_to_datetime ) | {"smartDetectTypes": convert_smart_types} | super().unifi_dict_conversions() ) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, DELETE_KEYS_EVENT) return data @property def camera(self) -> Camera | None: if self.camera_id is None: return None return self._api.bootstrap.cameras.get(self.camera_id) @property def light(self) -> Light | None: if self.metadata is None or self.metadata.light_id is None: return None return self._api.bootstrap.lights.get(self.metadata.light_id) @property def sensor(self) -> Sensor | None: if self.metadata is None or self.metadata.sensor_id is None: return None return self._api.bootstrap.sensors.get(self.metadata.sensor_id) @property def user(self) -> User | None: if self.user_id is None: return None return self._api.bootstrap.users.get(self.user_id) @property def smart_detect_events(self) -> list[Event]: if self._smart_detect_events is not None: return self._smart_detect_events self._smart_detect_events = [ self._api.bootstrap.events[g] for g in self.smart_detect_event_ids if g in self._api.bootstrap.events ] return self._smart_detect_events def get_detected_thumbnail(self) -> EventDetectedThumbnail | None: """ Gets best detected thumbnail for event (UFP 6.x+). Returns the thumbnail marked with clockBestWall, which indicates the optimal frame for this detection (highest confidence, best angle, etc.). Returns: EventDetectedThumbnail with the best detection frame, or None if: - Event has no metadata - No detected thumbnails available - No thumbnail has clockBestWall set Example usage: >>> # License Plate Recognition >>> thumbnail = event.get_detected_thumbnail() >>> if thumbnail and thumbnail.group: ... plate = thumbnail.group.matched_name # "ABC123" ... confidence = thumbnail.group.confidence # 95 ... if thumbnail.attributes: ... color = thumbnail.attributes.get_value("color") # "white" ... vehicle = thumbnail.attributes.get_value("vehicleType") # "sedan" >>> # Face Detection >>> thumbnail = event.get_detected_thumbnail() >>> if thumbnail and thumbnail.group: ... face_name = thumbnail.group.matched_name # "John Doe" ... confidence = thumbnail.group.confidence # 87 """ if not self.metadata or not self.metadata.detected_thumbnails: return None for thumbnail in self.metadata.detected_thumbnails: if thumbnail.clock_best_wall: return thumbnail return None async def get_thumbnail( self, width: int | None = None, height: int | None = None, ) -> bytes | None: """Gets thumbnail for event""" if self.thumbnail_id is None: return None if not self._api.bootstrap.auth_user.can( ModelType.CAMERA, PermissionNode.READ_MEDIA, self.camera, ): raise NotAuthorized( f"Do not have permission to read media for camera: {self.id}", ) return await self._api.get_event_thumbnail(self.thumbnail_id, width, height) async def get_animated_thumbnail( self, width: int | None = None, height: int | None = None, *, speedup: int = 10, ) -> bytes | None: """Gets animated thumbnail for event""" if self.thumbnail_id is None: return None if not self._api.bootstrap.auth_user.can( ModelType.CAMERA, PermissionNode.READ_MEDIA, self.camera, ): raise NotAuthorized( f"Do not have permission to read media for camera: {self.id}", ) return await self._api.get_event_animated_thumbnail( self.thumbnail_id, width, height, speedup=speedup, ) async def get_heatmap(self) -> bytes | None: """Gets heatmap for event""" if self.heatmap_id is None: return None if not self._api.bootstrap.auth_user.can( ModelType.CAMERA, PermissionNode.READ_MEDIA, self.camera, ): raise NotAuthorized( f"Do not have permission to read media for camera: {self.id}", ) return await self._api.get_event_heatmap(self.heatmap_id) async def get_video( self, channel_index: int = 0, output_file: Path | None = None, iterator_callback: IteratorCallback | None = None, progress_callback: ProgressCallback | None = None, chunk_size: int = 65536, ) -> bytes | None: """ Get the MP4 video clip for this given event Args: ---- channel_index: index of `CameraChannel` on the camera to use to retrieve video from Will raise an exception if event does not have a camera, end time or the channel index is wrong. """ if self.camera is None: raise BadRequest("Event does not have a camera") if self.end is None: raise BadRequest("Event is ongoing") if not self._api.bootstrap.auth_user.can( ModelType.CAMERA, PermissionNode.READ_MEDIA, self.camera, ): raise NotAuthorized( f"Do not have permission to read media for camera: {self.id}", ) return await self._api.get_camera_video( self.camera.id, self.start, self.end, channel_index, output_file=output_file, iterator_callback=iterator_callback, progress_callback=progress_callback, chunk_size=chunk_size, ) async def get_smart_detect_track(self) -> SmartDetectTrack: """ Gets smart detect track for given smart detect event. If event is not a smart detect event, it will raise a `BadRequest` """ if self.type not in {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE}: raise BadRequest("Not a smart detect event") if self._smart_detect_track is None: self._smart_detect_track = await self._api.get_event_smart_detect_track( self.id, ) return self._smart_detect_track async def get_smart_detect_zones(self) -> dict[int, CameraZone]: """Gets the triggering zones for the smart detection""" if self.camera is None: raise BadRequest("No camera on event") if self._smart_detect_zones is None: smart_track = await self.get_smart_detect_track() ids: set[int] = set() for item in smart_track.payload: ids |= set(item.zone_ids) self._smart_detect_zones = { z.id: z for z in self.camera.smart_detect_zones if z.id in ids } return self._smart_detect_zones class PortConfig(ProtectBaseObject): ump: int http: int https: int rtsp: int rtsps: int rtmp: int devices_wss: int camera_https: int live_ws: int live_wss: int tcp_streams: int playback: int ems_cli: int ems_live_flv: int camera_events: int tcp_bridge: int ucore: int discovery_client: int piongw: int | None = None ems_json_cli: int | None = None stacking: int | None = None # 3.0.22+ ai_feature_console: int | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "emsCLI": "emsCli", "emsLiveFLV": "emsLiveFlv", "emsJsonCLI": "emsJsonCli", } class CPUInfo(ProtectBaseObject): average_load: float temperature: float class MemoryInfo(ProtectBaseObject): available: int | None = None free: int | None = None total: int | None = None class StorageDevice(ProtectBaseObject): model: str | None = None size: int | None = None healthy: bool | str | None = None class StorageInfo(ProtectBaseObject): available: int is_recycling: bool size: int type: StorageType used: int devices: list[StorageDevice] # requires 2.8.14+ capability: str | None = None @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: if "type" in data: storage_type = data.pop("type") try: data["type"] = StorageType(storage_type) except ValueError: _LOGGER.warning("Unknown storage type: %s", storage_type) data["type"] = StorageType.UNKNOWN return super().unifi_dict_to_dict(data) class StorageSpace(ProtectBaseObject): total: int used: int available: int class TMPFSInfo(ProtectBaseObject): available: int total: int used: int path: Path class UOSDisk(ProtectBaseObject): slot: int state: str type: Literal["SSD", "HDD"] | None = None model: str | None = None serial: str | None = None firmware: str | None = None rpm: int | None = None ata: str | None = None sata: str | None = None action: str | None = None healthy: str | None = None reason: list[Any] | None = None temperature: int | None = None power_on_hours: int | None = None life_span: PercentFloat | None = None bad_sector: int | None = None threshold: int | None = None progress: PercentFloat | None = None estimate: timedelta | None = None # 2.10.10+ size: int | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "poweronhrs": "powerOnHours", "life_span": "lifeSpan", "bad_sector": "badSector", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "estimate": lambda x: timedelta(seconds=x) } | super().unifi_dict_conversions() def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) # estimate is actually in seconds, not milliseconds if "estimate" in data and data["estimate"] is not None: data["estimate"] /= 1000 if "state" in data and data["state"] == "nodisk": pop_dict_tuple( data, ( "action", "ata", "bad_sector", "estimate", "firmware", "healthy", "life_span", "model", "poweronhrs", "progress", "reason", "rpm", "sata", "serial", "tempature", "temperature", "threshold", "type", ), ) return data @property def has_disk(self) -> bool: return self.state != "nodisk" @property def is_healthy(self) -> bool: return self.state in { "initializing", "expanding", "spare", "normal", } class UOSSpace(ProtectBaseObject): device: str total_bytes: int used_bytes: int action: str progress: PercentFloat | None = None estimate: timedelta | None = None # requires 2.8.14+ health: str | None = None # requires 2.8.22+ space_type: str | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "total_bytes": "totalBytes", "used_bytes": "usedBytes", "space_type": "spaceType", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "estimate": lambda x: timedelta(seconds=x) } | super().unifi_dict_conversions() def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) # estimate is actually in seconds, not milliseconds if "estimate" in data and data["estimate"] is not None: data["estimate"] /= 1000 return data class UOSStorage(ProtectBaseObject): disks: list[UOSDisk] space: list[UOSSpace] class SystemInfo(ProtectBaseObject): cpu: CPUInfo memory: MemoryInfo storage: StorageInfo tmpfs: TMPFSInfo ustorage: UOSStorage | None = None def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) pop_dict_set_if_none(data, {"ustorage"}) return data class DoorbellMessage(ProtectBaseObject): type: DoorbellMessageType text: DoorbellText class DoorbellSettings(ProtectBaseObject): default_message_text: DoorbellText default_message_reset_timeout: timedelta all_messages: list[DoorbellMessage] custom_messages: list[DoorbellText] @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "defaultMessageResetTimeoutMs": "defaultMessageResetTimeout", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { # defaultMessageResetTimeoutMs is remapped to defaultMessageResetTimeout "defaultMessageResetTimeoutMs": lambda x: timedelta(milliseconds=x), } | super().unifi_dict_conversions() class RecordingTypeDistribution(ProtectBaseObject): recording_type: RecordingType size: int percentage: float class ResolutionDistribution(ProtectBaseObject): resolution: ResolutionStorageType size: int percentage: float class StorageDistribution(ProtectBaseObject): recording_type_distributions: list[RecordingTypeDistribution] resolution_distributions: list[ResolutionDistribution] _recording_type_dict: dict[RecordingType, RecordingTypeDistribution] | None = ( PrivateAttr(None) ) _resolution_dict: dict[ResolutionStorageType, ResolutionDistribution] | None = ( PrivateAttr(None) ) def _get_recording_type_dict( self, ) -> dict[RecordingType, RecordingTypeDistribution]: if self._recording_type_dict is None: self._recording_type_dict = {} for recording_type in self.recording_type_distributions: self._recording_type_dict[recording_type.recording_type] = ( recording_type ) return self._recording_type_dict def _get_resolution_dict( self, ) -> dict[ResolutionStorageType, ResolutionDistribution]: if self._resolution_dict is None: self._resolution_dict = {} for resolution in self.resolution_distributions: self._resolution_dict[resolution.resolution] = resolution return self._resolution_dict @property def timelapse_recordings(self) -> RecordingTypeDistribution | None: return self._get_recording_type_dict().get(RecordingType.TIMELAPSE) @property def continuous_recordings(self) -> RecordingTypeDistribution | None: return self._get_recording_type_dict().get(RecordingType.CONTINUOUS) @property def detections_recordings(self) -> RecordingTypeDistribution | None: return self._get_recording_type_dict().get(RecordingType.DETECTIONS) @property def uhd_usage(self) -> ResolutionDistribution | None: return self._get_resolution_dict().get(ResolutionStorageType.UHD) @property def hd_usage(self) -> ResolutionDistribution | None: return self._get_resolution_dict().get(ResolutionStorageType.HD) @property def free(self) -> ResolutionDistribution | None: return self._get_resolution_dict().get(ResolutionStorageType.FREE) def update_from_dict(self, data: dict[str, Any]) -> StorageDistribution: # reset internal look ups when data changes self._recording_type_dict = None self._resolution_dict = None return super().update_from_dict(data) class StorageStats(ProtectBaseObject): utilization: float capacity: timedelta | None = None remaining_capacity: timedelta | None = None recording_space: StorageSpace storage_distribution: StorageDistribution @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "capacity": lambda x: timedelta(milliseconds=x), "remainingCapacity": lambda x: timedelta(milliseconds=x), } | super().unifi_dict_conversions() class NVRFeatureFlags(ProtectBaseObject): beta: bool dev: bool notifications_v2: bool homekit_paired: bool | None = None ulp_role_management: bool | None = None # 2.9.20+ detection_labels: bool | None = None has_two_way_audio_media_streams: bool | None = None class NVRSmartDetection(ProtectBaseObject): enable: bool face_recognition: bool license_plate_recognition: bool class GlobalRecordingSettings(ProtectBaseObject): osd_settings: OSDSettings recording_settings: RecordingSettings smart_detect_settings: SmartDetectSettings class NVR(ProtectDeviceModel): can_auto_update: bool is_stats_gathering_enabled: bool timezone: tzinfo version: Version ucore_version: str hardware_platform: str ports: PortConfig last_update_at: datetime | None = None is_station: bool enable_automatic_backups: bool enable_stats_reporting: bool release_channel: FirmwareReleaseChannel hosts: list[IPv4Address | IPv6Address | str] enable_bridge_auto_adoption: bool hardware_id: UUID host_type: int host_shortname: str is_hardware: bool is_wireless_uplink_enabled: bool | None = None time_format: Literal["12h", "24h"] temperature_unit: Literal["C", "F"] recording_retention_duration: timedelta | None = None enable_crash_reporting: bool disable_audio: bool analytics_data: AnalyticsOption anonymous_device_id: UUID | None = None camera_utilization: int is_recycling: bool disable_auto_link: bool skip_firmware_update: bool location_settings: NVRLocation feature_flags: NVRFeatureFlags system_info: SystemInfo doorbell_settings: DoorbellSettings storage_stats: StorageStats is_away: bool is_setup: bool network: str max_camera_capacity: dict[Literal["4K", "2K", "HD"], int] market_name: str | None = None stream_sharing_available: bool | None = None is_db_available: bool | None = None is_insights_enabled: bool | None = None is_recording_disabled: bool | None = None is_recording_motion_only: bool | None = None ui_version: str | None = None sso_channel: FirmwareReleaseChannel | None = None is_stacked: bool | None = None is_primary: bool | None = None last_drive_slow_event: datetime | None = None is_u_core_setup: bool | None = None vault_camera_ids: list[str] = [] # requires 2.8.14+ corruption_state: str | None = None country_code: str | None = None has_gateway: bool | None = None is_vault_registered: bool | None = None public_ip: IPv4Address | IPv6Address | None = None ulp_version: str | None = None wan_ip: IPv4Address | IPv6Address | None = None # requires 2.9.20+ hard_drive_state: str | None = None is_network_installed: bool | None = None is_protect_updatable: bool | None = None is_ucore_updatable: bool | None = None # requires 2.11.13+ last_device_fw_updates_checked_at: datetime | None = None # requires 3.0.22+ smart_detection: NVRSmartDetection | None = None is_ucore_stacked: bool | None = None global_camera_settings: GlobalRecordingSettings | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "recordingRetentionDurationMs": "recordingRetentionDuration", "vaultCameras": "vaultCameraIds", "lastDeviceFWUpdatesCheckedAt": "lastDeviceFwUpdatesCheckedAt", "isUCoreStacked": "isUcoreStacked", } @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | { "version", "uiVersion", "hardwarePlatform", "ports", "lastUpdateAt", "isStation", "hosts", "hostShortname", "isDbAvailable", "isRecordingDisabled", "isRecordingMotionOnly", "cameraUtilization", "storageStats", "isRecycling", "avgMotions", "streamSharingAvailable", } @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "lastUpdateAt": convert_to_datetime, "lastDeviceFwUpdatesCheckedAt": convert_to_datetime, "timezone": zoneinfo.ZoneInfo, # recordingRetentionDurationMs is remapped to recordingRetentionDuration "recordingRetentionDurationMs": lambda x: timedelta(milliseconds=x), } | super().unifi_dict_conversions() async def _api_update(self, data: dict[str, Any]) -> None: return await self._api.update_nvr(data) @property def is_analytics_enabled(self) -> bool: return self.analytics_data is not AnalyticsOption.NONE @property def protect_url(self) -> str: return f"{self._api.base_url}/protect/devices/{self._api.bootstrap.nvr.id}" @property def display_name(self) -> str: return self.name or self.market_name or self.type @property def vault_cameras(self) -> list[Camera]: """Vault Cameras for NVR""" if not self.vault_camera_ids: return [] return [self._api.bootstrap.cameras[c] for c in self.vault_camera_ids] @property def is_global_recording_enabled(self) -> bool: """ Is recording footage/events from the camera enabled? If recording is not enabled, cameras will not produce any footage, thumbnails, motion/smart detection events. """ return ( (global_camera_settings := self.global_camera_settings) is not None and global_camera_settings.recording_settings.mode is not RecordingMode.NEVER ) @property def is_smart_detections_enabled(self) -> bool: """If smart detected enabled globally.""" return ( smart_detection := self.smart_detection ) is not None and smart_detection.enable @property def is_license_plate_detections_enabled(self) -> bool: """If smart detected enabled globally.""" return ( (smart_detection := self.smart_detection) is not None and smart_detection.enable and smart_detection.license_plate_recognition ) @property def is_face_detections_enabled(self) -> bool: """If smart detected enabled globally.""" return ( (smart_detection := self.smart_detection) is not None and smart_detection.enable and smart_detection.face_recognition ) def update_all_messages(self) -> None: """Updates doorbell_settings.all_messages after adding/removing custom message""" messages = self.doorbell_settings.custom_messages self.doorbell_settings.all_messages = [ DoorbellMessage( type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR, text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "), # type: ignore[arg-type] ), DoorbellMessage( type=DoorbellMessageType.DO_NOT_DISTURB, text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), # type: ignore[arg-type] ), *( DoorbellMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text=message, ) for message in messages ), ] async def set_insights(self, enabled: bool) -> None: """Sets analytics collection for NVR""" def callback() -> None: self.is_insights_enabled = enabled await self.queue_update(callback) async def set_analytics(self, value: AnalyticsOption) -> None: """Sets analytics collection for NVR""" def callback() -> None: self.analytics_data = value await self.queue_update(callback) async def set_anonymous_analytics(self, enabled: bool) -> None: """Enables or disables anonymous analytics for NVR""" if enabled: await self.set_analytics(AnalyticsOption.ANONYMOUS) else: await self.set_analytics(AnalyticsOption.NONE) async def set_default_reset_timeout(self, timeout: timedelta) -> None: """Sets the default message reset timeout""" def callback() -> None: self.doorbell_settings.default_message_reset_timeout = timeout await self.queue_update(callback) async def set_default_doorbell_message(self, message: str) -> None: """Sets default doorbell message""" def callback() -> None: self.doorbell_settings.default_message_text = DoorbellText(message) await self.queue_update(callback) async def add_custom_doorbell_message(self, message: str) -> None: """Adds custom doorbell message""" if len(message) > 30: raise BadRequest("Message length over 30 characters") if message in self.doorbell_settings.custom_messages: raise BadRequest("Custom doorbell message already exists") await self._update_doorbell_messages( lambda: self.doorbell_settings.custom_messages.append( DoorbellText(message) ), ) async def remove_custom_doorbell_message(self, message: str) -> None: """Removes custom doorbell message""" if message not in self.doorbell_settings.custom_messages: raise BadRequest("Custom doorbell message does not exists") await self._update_doorbell_messages( lambda: self.doorbell_settings.custom_messages.remove( DoorbellText(message) ), ) async def _update_doorbell_messages( self, update_callback: Callable[[], None] ) -> None: """Updates doorbell messages and saves to Protect.""" async with self._update_sync.lock: # yield to the event loop once we have the lock to ensure websocket updates are processed await asyncio.sleep(0) data_before_changes = self.dict_with_excludes() update_callback() await self.save_device(data_before_changes) self.update_all_messages() async def reboot(self) -> None: """Reboots the NVR""" await self._api.reboot_nvr() async def _read_cache_file(self, file_path: Path) -> set[Version] | None: versions: set[Version] | None = None try: _LOGGER.debug("Reading release cache file: %s", file_path) async with aiofiles.open(file_path, "rb") as cache_file: versions = {Version(v) for v in orjson.loads(await cache_file.read())} except FileNotFoundError: # ignore missing file pass except Exception: _LOGGER.warning("Failed to parse cache file: %s", file_path) return versions async def get_is_prerelease(self) -> bool: """[DEPRECATED] Always returns False. Will be removed after HA 2025.8.0.""" return False async def set_smart_detections(self, value: bool) -> None: """Set if smart detections are enabled.""" def callback() -> None: if self.smart_detection is not None: self.smart_detection.enable = value await self.queue_update(callback) async def set_face_recognition(self, value: bool) -> None: """Set if face detections are enabled. Requires smart detections to be enabled.""" if self.smart_detection is None or not self.smart_detection.enable: raise BadRequest("Smart detections are not enabled.") def callback() -> None: if self.smart_detection is not None: self.smart_detection.face_recognition = value await self.queue_update(callback) async def set_license_plate_recognition(self, value: bool) -> None: """Set if license plate detections are enabled. Requires smart detections to be enabled.""" if self.smart_detection is None or not self.smart_detection.enable: raise BadRequest("Smart detections are not enabled.") def callback() -> None: if self.smart_detection is not None: self.smart_detection.license_plate_recognition = value await self.queue_update(callback) async def set_global_osd_name(self, enabled: bool) -> None: """Sets whether camera name is in the On Screen Display""" def callback() -> None: if self.global_camera_settings: self.global_camera_settings.osd_settings.is_name_enabled = enabled await self.queue_update(callback) async def set_global_osd_date(self, enabled: bool) -> None: """Sets whether current date is in the On Screen Display""" def callback() -> None: if self.global_camera_settings: self.global_camera_settings.osd_settings.is_date_enabled = enabled await self.queue_update(callback) async def set_global_osd_logo(self, enabled: bool) -> None: """Sets whether the UniFi logo is in the On Screen Display""" def callback() -> None: if self.global_camera_settings: self.global_camera_settings.osd_settings.is_logo_enabled = enabled await self.queue_update(callback) async def set_global_osd_bitrate(self, enabled: bool) -> None: """Sets whether camera bitrate is in the On Screen Display""" def callback() -> None: # mismatch between UI internal data structure debug = bitrate data if self.global_camera_settings: self.global_camera_settings.osd_settings.is_debug_enabled = enabled await self.queue_update(callback) async def set_global_motion_detection(self, enabled: bool) -> None: """Sets motion detection on camera""" def callback() -> None: if self.global_camera_settings: self.global_camera_settings.recording_settings.enable_motion_detection = enabled await self.queue_update(callback) async def set_global_recording_mode(self, mode: RecordingMode) -> None: """Sets recording mode on camera""" def callback() -> None: if self.global_camera_settings: self.global_camera_settings.recording_settings.mode = mode await self.queue_update(callback) # object smart detections def _is_smart_enabled(self, smart_type: SmartDetectObjectType) -> bool: return ( self.is_global_recording_enabled and (global_camera_settings := self.global_camera_settings) is not None and smart_type in global_camera_settings.smart_detect_settings.object_types ) @property def is_global_person_detection_on(self) -> bool: """ Is Person Detection available and enabled (camera will produce person smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.PERSON) @property def is_global_person_tracking_enabled(self) -> bool: """Is person tracking enabled""" return ( (global_camera_settings := self.global_camera_settings) is not None and ( auto_tracking_object_types := global_camera_settings.smart_detect_settings.auto_tracking_object_types ) is not None and SmartDetectObjectType.PERSON in auto_tracking_object_types ) @property def is_global_vehicle_detection_on(self) -> bool: """ Is Vehicle Detection available and enabled (camera will produce vehicle smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.VEHICLE) @property def is_global_face_detection_on(self) -> bool: """Is Face Detection available and enabled?""" return self._is_smart_enabled(SmartDetectObjectType.FACE) @property def is_global_license_plate_detection_on(self) -> bool: """ Is License Plate Detection available and enabled (camera will produce face license plate detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.LICENSE_PLATE) @property def is_global_package_detection_on(self) -> bool: """ Is Package Detection available and enabled (camera will produce package smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.PACKAGE) @property def is_global_animal_detection_on(self) -> bool: """ Is Animal Detection available and enabled (camera will produce package smart detection events)? """ return self._is_smart_enabled(SmartDetectObjectType.ANIMAL) def _is_audio_enabled(self, smart_type: SmartDetectObjectType) -> bool: return ( (audio_type := smart_type.audio_type) is not None and self.is_global_recording_enabled and (global_camera_settings := self.global_camera_settings) is not None and ( audio_types := global_camera_settings.smart_detect_settings.audio_types ) is not None and audio_type in audio_types ) @property def is_global_smoke_detection_on(self) -> bool: """ Is Smoke Alarm Detection available and enabled (camera will produce smoke smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.SMOKE) @property def is_global_co_detection_on(self) -> bool: """ Is CO Alarm Detection available and enabled (camera will produce smoke smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.CMONX) @property def is_global_siren_detection_on(self) -> bool: """ Is Siren Detection available and enabled (camera will produce siren smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.SIREN) @property def is_global_baby_cry_detection_on(self) -> bool: """ Is Baby Cry Detection available and enabled (camera will produce baby cry smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.BABY_CRY) @property def is_global_speaking_detection_on(self) -> bool: """ Is Speaking Detection available and enabled (camera will produce speaking smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.SPEAK) @property def is_global_bark_detection_on(self) -> bool: """ Is Bark Detection available and enabled (camera will produce barking smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.BARK) @property def is_global_car_alarm_detection_on(self) -> bool: """ Is Car Alarm Detection available and enabled (camera will produce car alarm smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.BURGLAR) @property def is_global_car_horn_detection_on(self) -> bool: """ Is Car Horn Detection available and enabled (camera will produce car horn smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.CAR_HORN) @property def is_global_glass_break_detection_on(self) -> bool: """ Is Glass Break available and enabled (camera will produce glass break smart detection events)? """ return self._is_audio_enabled(SmartDetectObjectType.GLASS_BREAK) class LiveviewSlot(ProtectBaseObject): camera_ids: list[str] cycle_mode: str cycle_interval: int _cameras: list[Camera] | None = PrivateAttr(None) @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "cameras": "cameraIds"} @property def cameras(self) -> list[Camera]: if self._cameras is not None: return self._cameras # user may not have permission to see the cameras in the liveview self._cameras = [ self._api.bootstrap.cameras[g] for g in self.camera_ids if g in self._api.bootstrap.cameras ] return self._cameras class Liveview(ProtectModelWithId): name: str is_default: bool is_global: bool layout: int slots: list[LiveviewSlot] owner_id: str @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "owner": "ownerId"} @classmethod @cache def _get_read_only_fields(cls) -> set[str]: return super()._get_read_only_fields() | {"isDefault", "owner"} @property def owner(self) -> User | None: """ Owner of liveview. Will be none if the user only has read only access and it was not made by their user. """ return self._api.bootstrap.users.get(self.owner_id) @property def protect_url(self) -> str: return f"{self._api.base_url}/protect/liveview/{self.id}" uilibs-uiprotect-317a52c/src/uiprotect/data/public_bootstrap.py000066400000000000000000001151641524637705700250370ustar00rootroot00000000000000""" UniFi Protect Public Integration API bootstrap. This is an opt-in, separate cache for the Public Integration API. It is *not* populated by :meth:`ProtectApiClient.update` and does not touch the private :class:`~uiprotect.data.bootstrap.Bootstrap` in any way. Prime/subscribe ordering is a **library guarantee**, not a caller obligation: ``update_public()`` buffers any public WS frame that arrives while it is priming and replays it onto the fresh snapshot before returning, so a subscriber that is already connected never loses an update to the prime window. Consumers may subscribe and prime in either order: * **Typed** (``subscribe_devices`` / ``subscribe_events``): deliver merged public models, so they require a primed cache and raise ``RuntimeError`` if it is missing. Use ``subscribe_devices_and_prime`` / ``subscribe_events_and_prime`` to subscribe and prime in one call — these connect the websocket *before* priming so the buffer catches every frame, making the prime-then-subscribe question moot. * **Raw** (``subscribe_devices_websocket`` / ``subscribe_events_websocket``): subscribe in any order relative to ``update_public()``. Frames that land while priming are held and replayed once the snapshot is applied, so they reach subscribers with a merged ``new_obj`` instead of being dropped. WS messages that arrive *after* priming are merged into the fresh cache normally. An ``update`` for an object still not in the cache after a replay is dropped — the cache catches up on the next ``update_public()`` / reconnect refresh. WS messages carry partial diffs for ``update`` actions; the cache therefore *merges* updates into the existing in-memory object instead of reconstructing it from the partial payload. Only ``add`` messages are treated as full payloads. """ from __future__ import annotations import logging from collections import OrderedDict from collections.abc import Callable, Iterator from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, NamedTuple, cast from .base import ProtectModelWithId from .convert import create_from_unifi_dict from .public_devices import ( ArmProfile, Fob, LinkStation, NvrArmMode, PublicBridge, PublicCamera, PublicChime, PublicLight, PublicLiveview, PublicNVR, PublicSensor, PublicUlpUser, PublicViewer, Relay, Siren, Speaker, ) from .public_event import PublicEvent from .types import DeviceState, ModelType from .websocket import WSAction, WSSubscriptionMessage if TYPE_CHECKING: from ..api import ProtectApiClient _LOGGER = logging.getLogger(__name__) class EventsFrameResult(NamedTuple): """Outputs of applying one public events-WS frame to the cache.""" new_event: PublicEvent | None old_event: PublicEvent | None model_updates: list[WSSubscriptionMessage] # Default cap on the public event cache. Matches the private Bootstrap # default so memory behaviour is symmetric between the two caches. DEFAULT_PUBLIC_EVENT_CACHE_SIZE = 1000 # Fields a lone ``update`` event frame must carry (all non-``None``) before it # can be promoted to a synthesized ``add`` — only *completed* events whose # payload is self-sufficient qualify, so no dangling open event is ever created. _BORN_CLOSED_REQUIRED_FIELDS = ("id", "type", "start", "end", "device") # Single source of truth: ``ModelType`` -> (store attribute, public model # class | None). Only the device types the Public Integration API actually # exposes are listed; unsupported keys (``doorlock`` etc.) resolve to ``None`` # and are logged at DEBUG in :meth:`process_devices_ws_message`. # # ``public_cls`` is set for the four types whose ``ModelType`` collides with a # private ``MODEL_TO_CLASS`` entry (``camera``/``light``/``sensor``/``chime``): # the generic ``create_from_unifi_dict`` path would build the private model # (reintroducing phantom fields), so they get a dedicated public-API factory # slot via :meth:`_public_device_slot`. ``None`` means the generic path builds # the correct class. Every store here carries a ``mac`` field, so # :meth:`get_device_mac` iterates this registry directly. _PUBLIC_STORES: dict[ModelType, tuple[str, type[ProtectModelWithId] | None]] = { ModelType.CAMERA: ("cameras", PublicCamera), ModelType.LIGHT: ("lights", PublicLight), ModelType.SENSOR: ("sensors", PublicSensor), ModelType.CHIME: ("chimes", PublicChime), ModelType.SIREN: ("sirens", None), ModelType.RELAY: ("relays", None), ModelType.FOB: ("fobs", None), ModelType.SPEAKER: ("speakers", None), ModelType.LINK_STATION: ("link_stations", None), } # Collision-routed types that keep a dedicated ``_X_slot()`` factory (their # ``ModelType`` is owned by a private class in ``MODEL_TO_CLASS``). They carry # no ``mac`` and so are excluded from :data:`_PUBLIC_STORES`. _DEDICATED_SLOT_STORE_ATTRS: dict[ModelType, str] = { ModelType.LIVEVIEW: "liveviews", ModelType.BRIDGE: "bridges", ModelType.VIEWPORT: "viewers", } @dataclass(frozen=True, slots=True) class DeviceWSResult: """One expanded per-device outcome of a public devices WS frame.""" model_type: ModelType | None new_obj: ProtectModelWithId | None old_obj: ProtectModelWithId | None # Per-id raw payload (``id`` is always scalar here, even for bulk frames). item: dict[str, Any] @dataclass(frozen=True, slots=True) class FetchDiff: """Membership change a single :meth:`PublicBootstrap.apply_fetch_result` made.""" # Attribute name of the store the diff applies to. attr: str added_ids: list[str] removed: list[ProtectModelWithId] @dataclass class PublicBootstrap: """ In-memory cache of the Public Integration API resources. All device dicts map ``id`` -> object. Empty by default; populated by :meth:`ProtectApiClient.update_public`. **Staleness on websocket reconnect.** While the devices/events websocket is connected, incoming ``add``/``update``/``remove`` messages are merged into the cache. If the websocket drops and reconnects, any messages emitted during the gap are lost. :class:`ProtectApiClient` automatically schedules an :meth:`~ProtectApiClient.update_public` refresh on *reconnect* (the initial connect does not trigger a refresh), and announces the devices that refresh added or removed as synthetic devices-WS ``add``/``remove`` frames. """ # NVR metadata (populated by ``update_public``). Useful for HA's # ``DeviceInfo``. Merged in place by :meth:`process_devices_ws_message` # when an ``nvr`` update arrives. nvr: PublicNVR | None = None # Device stores (indexed by id). Only the device classes that the # Public Integration API actually exposes are kept here. # # ``cameras``/``lights``/``sensors``/``chimes`` hold dedicated ``Public*`` # models — they share a ``ModelType`` with the private classes, so they are # routed via factory slots (see :meth:`_custom_slot_for`), not the generic # ``MODEL_TO_CLASS`` path. cameras: dict[str, PublicCamera] = field(default_factory=dict) lights: dict[str, PublicLight] = field(default_factory=dict) sensors: dict[str, PublicSensor] = field(default_factory=dict) chimes: dict[str, PublicChime] = field(default_factory=dict) sirens: dict[str, Siren] = field(default_factory=dict) relays: dict[str, Relay] = field(default_factory=dict) fobs: dict[str, Fob] = field(default_factory=dict) speakers: dict[str, Speaker] = field(default_factory=dict) # Link stations *and* alarm hubs share one schema (``modelKey: "linkstation"``), # so a single store catches WS frames for both. Use :attr:`alarm_hubs` for the # filtered view. link_stations: dict[str, LinkStation] = field(default_factory=dict) # ``liveviews``, ``bridges`` and ``viewers`` are deliberately kept out of # :data:`_DEVICE_STORES` because their ``ModelType`` values # (``LIVEVIEW`` / ``BRIDGE`` / ``VIEWPORT``) are already owned by the # private ``Liveview`` / ``Bridge`` / ``Viewer`` classes in # ``MODEL_TO_CLASS``. The WS handler routes each to its own store via an # explicit branch with a public-API-aware factory. liveviews: dict[str, PublicLiveview] = field(default_factory=dict) bridges: dict[str, PublicBridge] = field(default_factory=dict) viewers: dict[str, PublicViewer] = field(default_factory=dict) # Events received via the events websocket (:meth:`process_events_ws_message`). # Bounded by :attr:`max_event_cache_size`; oldest events are evicted first. # Entries may be synthetically end-marked (``end`` set) by the public # events TTL/reconnect sweep, not only by a server-sent close frame — so a # non-``None`` ``end`` here does not always correspond to a WS payload. events: OrderedDict[str, PublicEvent] = field(default_factory=OrderedDict) max_event_cache_size: int = DEFAULT_PUBLIC_EVENT_CACHE_SIZE # UniFi Identity (ULP) users, indexed by ulp id. Single source of truth # for event-identity enrichment; refreshed by ``update_public`` (and so, # via the reconnect resync, refreshed on reconnect too). Empty when the # Identity service is disabled. ulp_users: dict[str, PublicUlpUser] = field(default_factory=dict) # Arm manager state. arm_profiles: dict[str, ArmProfile] = field(default_factory=dict) # Per-instance one-shot warning dedupe for merge/add failures. # This must not be module-global because callers can have multiple # Protect servers in one process. _warned_merge_failures: set[tuple[str, str]] = field( default_factory=set, init=False, repr=False, ) # Per-camera detection-state snapshots taken before the first mutation of an # events-WS frame, keyed by camera id. Populated while a single # :meth:`process_events_ws_message` call applies its frame (including any # eviction it triggers) and drained by :meth:`_drain_detection_updates`. # Transient within one synchronous call, so per-instance is sufficient. _detection_state_before: dict[str, dict[str, bool]] = field( default_factory=dict, init=False, repr=False, ) @property def alarm_hubs(self) -> dict[str, LinkStation]: """Subset of :attr:`link_stations` filtered to alarm hubs.""" return {k: v for k, v in self.link_stations.items() if v.is_alarm_hub} @property def arm_mode(self) -> NvrArmMode | None: """ Current arm-manager state. Shortcut for ``nvr.arm_mode``. Returns ``None`` when the NVR cache hasn't been primed yet, when the firmware does not expose the alarm manager, or when the alarm manager is set to global. """ return self.nvr.arm_mode if self.nvr is not None else None def __post_init__(self) -> None: """Validate cache bounds used by event eviction logic.""" if self.max_event_cache_size < 0: raise ValueError("max_event_cache_size must be >= 0") def store_for(self, model_type: ModelType) -> dict[str, ProtectModelWithId] | None: """ Return the live cache store for a model type, or ``None`` if unsupported. The returned dict is the cache itself, keyed by device id — not a copy. Both device registries are covered, so the collision-routed liveview/bridge/viewer slots resolve here too. """ store = _PUBLIC_STORES.get(model_type) if store is not None: attr: str | None = store[0] else: attr = _DEDICATED_SLOT_STORE_ATTRS.get(model_type) if attr is None: return None return cast("dict[str, ProtectModelWithId]", getattr(self, attr)) def get(self, model_type: ModelType, obj_id: str) -> ProtectModelWithId | None: """Look up a cached device by model type and id.""" store = self.store_for(model_type) if store is None: return None return store.get(obj_id) def get_device_mac(self, device_id: str) -> str | None: """Resolve a device id to its mac across the public device stores.""" for store_attr, _cls in _PUBLIC_STORES.values(): obj = getattr(self, store_attr).get(device_id) if obj is not None: return getattr(obj, "mac", None) return None def all_devices(self, *, include_nvr: bool = False) -> Iterator[ProtectModelWithId]: """ Iterate every cached device across all model types. Walks both device registries (:data:`_PUBLIC_STORES` and :data:`_DEDICATED_SLOT_STORE_ATTRS`), so new public device families are covered automatically as they join those registries. Liveviews are skipped — a :class:`PublicLiveview` is a saved-view configuration, not a physical device, and carries no ``mac``. The NVR lives in a dedicated single-object slot and is excluded unless ``include_nvr`` is set, in which case it is yielded first. """ if include_nvr and self.nvr is not None: yield self.nvr for store_attr, _cls in _PUBLIC_STORES.values(): yield from getattr(self, store_attr).values() for model_type, store_attr in _DEDICATED_SLOT_STORE_ATTRS.items(): if model_type is ModelType.LIVEVIEW: continue yield from getattr(self, store_attr).values() def apply_fetch_result( self, attr: str, objs: list[ProtectModelWithId] ) -> FetchDiff: """ Merge fetched objects into ``self.`` without wholesale replace. Used by :meth:`ProtectApiClient.update_public` so that ``add``/ ``update``/``remove`` WS messages arriving concurrently with the HTTP fetch are not clobbered. IDs present in ``objs`` overwrite the cache entry; IDs absent from ``objs`` but present in the cache are removed. The returned :class:`FetchDiff` reports which ids the merge added and which objects it dropped, so the caller can announce them through the devices websocket once the whole fetch has been applied. """ store = cast("dict[str, ProtectModelWithId]", getattr(self, attr)) fetched_ids = {obj.id for obj in objs} # Remove objects no longer reported by the API. removed = [ store.pop(stale) for stale in [k for k in store if k not in fetched_ids] ] added_ids = [obj.id for obj in objs if obj.id not in store] # Upsert — newer fetched payload wins over in-place WS merges. This # is the intended semantic because `update_public` is the ground # truth at the moment it returns. for obj in objs: store[obj.id] = obj return FetchDiff(attr, added_ids, removed) def supports_device(self, model_type: ModelType) -> bool: """Return whether ``model_type`` maps to a public device store.""" return model_type in _PUBLIC_STORES or model_type in _DEDICATED_SLOT_STORE_ATTRS def process_devices_ws_messages( self, api: ProtectApiClient, data: dict[str, Any], ) -> list[DeviceWSResult]: """ Apply a public *devices* WS frame to the cache, expanding bulk forms. The bulk envelopes (``devicesAdd`` / ``devicesBulkUpdate`` / ``devicesBulkRemove``) share a single ``item`` whose ``id`` is a list of ids with one shared ``modelKey`` and payload. This fans that out to one :class:`DeviceWSResult` per id (each carrying a scalar-``id`` ``item``); a single-id frame yields a one-element list. An empty ``id`` array yields an empty list. """ action_type, item, _obj_id = _parse_ws_envelope(data) if action_type is None: return [] raw_id = item.get("id") if isinstance(raw_id, list): expanded = [{**item, "id": one_id} for one_id in raw_id] else: expanded = [item] return [self._apply_one(api, action_type, one) for one in expanded] def process_devices_ws_message( self, api: ProtectApiClient, data: dict[str, Any], ) -> tuple[ModelType | None, ProtectModelWithId | None, ProtectModelWithId | None]: """ Apply a public *devices* WS payload to the cache. Back-compat façade over :meth:`process_devices_ws_messages`: returns the first expanded result's ``(model_type, new_obj, old_obj)`` 3-tuple, or ``(None, None, None)`` when nothing was produced. Callers that need per-device frames for bulk envelopes should use the plural form. ``update`` messages carry **partial diffs** — only the changed fields. They are merged into the existing cached object via :meth:`ProtectBaseObject.update_from_dict`; reconstructing from a partial payload would fail strict validation for required fields. """ results = self.process_devices_ws_messages(api, data) if not results: return None, None, None first = results[0] return first.model_type, first.new_obj, first.old_obj def _apply_one( self, api: ProtectApiClient, action_type: str, item: dict[str, Any], ) -> DeviceWSResult: """Route a single scalar-``id`` device item to its cache slot.""" model_key = item["modelKey"] model_type = ModelType.from_string(model_key) # NVR is cached in a dedicated single-object slot; collision-routed # types (Liveview / Bridge / Viewer share their ``ModelType`` with the # private ``MODEL_TO_CLASS`` entries) get their own factory-equipped # slot. Everything else routes through the generic ``store_for`` dict. # Snapshot the pre-apply camera state: ``update`` merges in place, so # the ``old`` returned by ``_apply_action`` is the same (now mutated) # instance as ``new`` and can't be compared against it afterwards. prev_state = self._camera_state_before_apply(model_type, item) custom_slot = self._custom_slot_for(model_type) if custom_slot is not None: new, old = self._apply_action( api, action_type, item, model_type, custom_slot ) self._refresh_rtsps_on_reconnect(api, model_type, new, prev_state) self._evict_rtsps_on_remove(api, model_type, action_type, item) return DeviceWSResult(model_type, new, old, item) store = self.store_for(model_type) if store is None: _LOGGER.debug( "Public WS message for unsupported model %s ignored", model_key ) return DeviceWSResult(model_type, None, None, item) new, old = self._apply_action( api, action_type, item, model_type, _dict_slot(store) ) self._track_siren_expiry(api, model_type, action_type, item) return DeviceWSResult(model_type, new, old, item) def _camera_state_before_apply( self, model_type: ModelType, item: dict[str, Any] ) -> DeviceState | None: """Return the cached camera's ``state`` prior to applying this frame.""" if model_type is not ModelType.CAMERA: return None cached = self.cameras.get(item["id"]) return cached.state if cached is not None else None def _refresh_rtsps_on_reconnect( self, api: ProtectApiClient, model_type: ModelType, new: ProtectModelWithId | None, prev_state: DeviceState | None, ) -> None: """ Schedule an RTSPS prime/refresh when a camera transitions to CONNECTED. A camera that was offline at ``update_public()`` time carries no streams; the CONNECTED transition primes it. An already-populated one is refreshed in place. """ if model_type is not ModelType.CAMERA or new is None: return if ( getattr(new, "state", None) is DeviceState.CONNECTED and prev_state is not DeviceState.CONNECTED ): api._schedule_rtsps_refresh(new.id) def _evict_rtsps_on_remove( self, api: ProtectApiClient, model_type: ModelType, action_type: str, item: dict[str, Any], ) -> None: """ Cancel any in-flight RTSPS refresh for a removed camera. A ``remove`` frame deletes the camera from the device store, taking its ``rtsps_streams`` field with it. Any in-flight background refresh for the camera is cancelled so it cannot resurrect a now-removed camera. """ if model_type is ModelType.CAMERA and action_type == "remove": api._cancel_rtsps_refresh(item["id"]) def _track_siren_expiry( self, api: ProtectApiClient, model_type: ModelType, action_type: str, item: dict[str, Any], ) -> None: """ Keep a siren's timed-run expiry announcement in step with its status. The console never reports the end of a timed run, so every siren frame re-derives the deadline from the merged status; a removed siren drops its timer with the device. """ if model_type is not ModelType.SIREN: return if action_type == "remove": api._cancel_siren_off(item["id"]) else: api._schedule_siren_off(item["id"]) def _custom_slot_for(self, model_type: ModelType) -> _Slot | None: """Return the dedicated public-API slot for collision-routed types.""" if model_type is ModelType.NVR: return self._nvr_slot() if model_type is ModelType.LIVEVIEW: return self._liveviews_slot() if model_type is ModelType.BRIDGE: return self._bridges_slot() if model_type is ModelType.VIEWPORT: return self._viewers_slot() store = _PUBLIC_STORES.get(model_type) if store is not None: store_attr, cls = store if cls is not None: return self._public_device_slot(getattr(self, store_attr), cls) return None @staticmethod def _public_device_slot( store: dict[str, Any], cls: type[ProtectModelWithId], ) -> _Slot: """Return a slot around a public device store with a public-API factory.""" def _factory(item: dict[str, Any], api: ProtectApiClient) -> ProtectModelWithId: return cls.from_unifi_dict(api=api, **item) return _dict_slot( cast("dict[str, ProtectModelWithId]", store), factory=_factory, ) def process_events_ws_message( self, api: ProtectApiClient, data: dict[str, Any], ) -> EventsFrameResult: """ Apply a public *events* WS payload to the event cache. Returns an :class:`EventsFrameResult` (a tuple-compatible ``NamedTuple`` of ``new_event``, ``old_event``, ``model_updates``). ``new_event`` / ``old_event`` may be ``None``; ``old_event`` is a **pre-merge snapshot** of the cached event taken before this frame is applied — ``update`` merges its partial diff into the cached object *in place*, so without the snapshot ``old`` and ``new`` would be the same mutated instance. The snapshot lets a consumer detect the open→closed transition (``old.end is None`` and ``new.end is not None``) and reject retransmits idempotently. ``model_updates`` are devices-WS ``update`` messages for any camera whose derived detection booleans net-flipped over this frame — the first-class device-model outputs of processing the events frame. The api handler emits them through the standard devices channel with no knowledge that they originated from a detection event. """ action_type, item, _obj_id = _parse_ws_envelope(data) if action_type is None or item.get("modelKey") != ModelType.EVENT.value: return EventsFrameResult(None, None, []) obj_id = item.get("id") cached = self.events.get(obj_id) if obj_id else None # Intentionally a shallow copy: the idempotency chokepoint only reads # the top-level ``end`` (captured pre-merge here). Nested objects are # shared, but a deep copy on every event frame is avoided on this hot # path. old_snapshot = cached.model_copy() if cached is not None else None self._detection_state_before.clear() new, _old = self._apply_action( api, action_type, item, ModelType.EVENT, self._events_slot() ) return EventsFrameResult( cast("PublicEvent | None", new), old_snapshot, self._drain_detection_updates(), ) def _drain_detection_updates(self) -> list[WSSubscriptionMessage]: """ Build devices-WS ``update`` messages for cameras whose detection flipped. Diffs each camera's pre-frame snapshot against its current derived booleans, yielding one ``update`` (``new_obj=camera``, ``changed_data`` naming only the flipped fields) per camera whose flags actually changed. Clears the pending snapshots, so a second call returns an empty list. """ updates: list[WSSubscriptionMessage] = [] for camera_id, before in self._detection_state_before.items(): camera = self.cameras.get(camera_id) if camera is None: continue after = camera._detection_state() changed = {k: v for k, v in after.items() if v != before[k]} if changed: updates.append( WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=camera.id, changed_data=cast("dict[str, Any]", changed), new_obj=camera, old_obj=None, ) ) self._detection_state_before.clear() return updates def _snapshot_camera_detection_state(self, camera: PublicCamera) -> None: """Record a camera's pre-mutation detection snapshot once per events frame.""" if camera.id not in self._detection_state_before: self._detection_state_before[camera.id] = camera._detection_state() def _sync_camera_detection_state(self, event: PublicEvent) -> None: """Fold a cached event into its owning camera's active detection set.""" if event.device_id is None: return camera = self.cameras.get(event.device_id) if camera is not None: self._snapshot_camera_detection_state(camera) camera._apply_detection_event(event) def _clear_camera_detection_event(self, event: PublicEvent) -> None: """Drop an evicted/removed event from its owning camera's active set.""" if event.device_id is None: return camera = self.cameras.get(event.device_id) if camera is not None: self._snapshot_camera_detection_state(camera) camera._clear_detection_event(event.id) def _sync_force_ended_events( self, events: list[PublicEvent] ) -> list[WSSubscriptionMessage]: """ Clear force-ended events from their cameras' active detection sets. Returns one devices-WS ``update`` per camera whose derived detection flags flipped, so a sweep that force-ends events out-of-band (TTL / reconnect) keeps the camera model and its subscribers in sync through the same choke point the normal events path uses. """ self._detection_state_before.clear() for event in events: self._clear_camera_detection_event(event) return self._drain_detection_updates() def _apply_action( self, api: ProtectApiClient, action_type: str, item: dict[str, Any], model_type: ModelType, slot: _Slot, ) -> tuple[ProtectModelWithId | None, ProtectModelWithId | None]: """ Apply ``add`` / ``update`` / ``remove`` to a cache slot. ``slot`` abstracts over the storage shape (single-object NVR vs. id-keyed dict) so all three handlers share one code path. Return value semantics ``(new, old)``: * ``add`` success → ``(new, old)`` (``old`` is whatever was cached). * ``add`` failure → ``(None, old)`` — cache untouched. * ``remove`` → ``(None, old)`` — ``old`` removed from cache. * ``update`` on unknown id → ``(None, None)`` — cache untouched. * ``update`` merge success → ``(merged, old)``. * ``update`` merge failure → ``(None, old)`` — cache still contains ``old``; subscribers should not treat this as a delete. """ obj_id = item["id"] old = slot.get(obj_id) if action_type == "remove": if old is not None: slot.delete(obj_id) return None, old if action_type == "add": try: if slot.factory is not None: new = slot.factory(item, api) else: new = cast( "ProtectModelWithId", create_from_unifi_dict(item, api=api, model_type=model_type), ) except Exception as err: _warn_once( self._warned_merge_failures, ("add", model_type.value), "Could not create %s from public API add payload: %s", model_type.value, err, ) return None, old slot.put(obj_id, new) return new, old if action_type == "update": if old is None: return self._apply_unknown_update(api, item, model_type, slot, obj_id) merged = _merge(old, item, self._warned_merge_failures) if merged is not None: slot.put(obj_id, merged) return merged, old return None, old def _apply_unknown_update( self, api: ProtectApiClient, item: dict[str, Any], model_type: ModelType, slot: _Slot, obj_id: str, ) -> tuple[ProtectModelWithId | None, ProtectModelWithId | None]: """ Handle an ``update`` whose id is not in the cache. Born-closed event: a lone ``update`` for an unseen event id whose payload is self-sufficient and already closed (package smart-detect arrives this way — one closed frame, no preceding ``add``) is promoted to an ``add`` so the dispatcher's close-window logic can emit STARTED + ENDED. Returns ``(new, None)`` on promotion. Otherwise drops: returns ``(None, None)`` — distinct from a merge failure on a *known* id, which returns ``(None, old)`` (cache entry preserved; only the diff could not be applied). The reconnect hook / next ``update_public`` refetches full state. """ synthesized = self._synthesize_born_closed_event(api, item, model_type) if synthesized is not None: slot.put(obj_id, synthesized) return synthesized, None _LOGGER.debug( "Public WS update for unknown %s id=%s; needs full refresh", model_type.value, obj_id, ) return None, None def _synthesize_born_closed_event( self, api: ProtectApiClient, item: dict[str, Any], model_type: ModelType, ) -> ProtectModelWithId | None: """ Build a closed :class:`PublicEvent` from a lone ``update`` for an unseen id. Returns the event only when scoped to the EVENT store and the payload is self-sufficient and already closed (every field in :data:`_BORN_CLOSED_REQUIRED_FIELDS` set); otherwise ``None`` so the caller keeps the drop-and-refresh behaviour. Devices/NVR are never synthesized from a partial diff. """ if model_type is not ModelType.EVENT: return None if any( item.get(field_name) is None for field_name in _BORN_CLOSED_REQUIRED_FIELDS ): return None try: return PublicEvent.from_unifi_dict(api=api, **item) except Exception as err: _warn_once( self._warned_merge_failures, ("born_closed", model_type.value), "Could not synthesize %s from public API born-closed update: %s", model_type.value, err, ) return None def _nvr_slot(self) -> _Slot: """Return a slot that stores the NVR in :attr:`nvr`.""" def _get(_id: str) -> ProtectModelWithId | None: return self.nvr def _put(_id: str, obj: ProtectModelWithId) -> None: self.nvr = cast("PublicNVR", obj) def _delete(_id: str) -> None: self.nvr = None def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicNVR: return PublicNVR.from_unifi_dict(api=api, **item) return _Slot(_get, _put, _delete, factory=_factory) def _liveviews_slot(self) -> _Slot: """Return a slot around :attr:`liveviews` with a public-API factory.""" def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicLiveview: return PublicLiveview.from_unifi_dict(api=api, **item) return _dict_slot( cast("dict[str, ProtectModelWithId]", self.liveviews), factory=_factory, ) def _bridges_slot(self) -> _Slot: """Return a slot around :attr:`bridges` with a public-API factory.""" def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicBridge: return PublicBridge.from_unifi_dict(api=api, **item) return _dict_slot( cast("dict[str, ProtectModelWithId]", self.bridges), factory=_factory, ) def _viewers_slot(self) -> _Slot: """Return a slot around :attr:`viewers` with a public-API factory.""" def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicViewer: return PublicViewer.from_unifi_dict(api=api, **item) return _dict_slot( cast("dict[str, ProtectModelWithId]", self.viewers), factory=_factory, ) def _events_slot(self) -> _Slot: """Return a slot around :attr:`events` with LRU eviction.""" events = self.events limit = self.max_event_cache_size def _get(obj_id: str) -> ProtectModelWithId | None: return events.get(obj_id) def _put(obj_id: str, obj: ProtectModelWithId) -> None: event = cast("PublicEvent", obj) events[obj_id] = event events.move_to_end(obj_id) # Sync the owning camera's active set at the single cache choke # point: an open event turns its flag(s) on, an ended one off. self._sync_camera_detection_state(event) while events and len(events) > limit: _evicted_id, evicted = events.popitem(last=False) # An evicted open event can never receive its close update, so # clear it here to keep the camera flag from sticking on (and # the active set bounded to the cache). self._clear_camera_detection_event(cast("PublicEvent", evicted)) def _delete(obj_id: str) -> None: evicted = events.pop(obj_id, None) if evicted is not None: self._clear_camera_detection_event(evicted) def _factory(item: dict[str, Any], api: ProtectApiClient) -> PublicEvent: return PublicEvent.from_unifi_dict(api=api, **item) return _Slot(_get, _put, _delete, factory=_factory) @dataclass(frozen=True, slots=True) class _Slot: """Uniform get/put/delete interface over heterogeneous storage.""" get: Callable[[str], ProtectModelWithId | None] put: Callable[[str, ProtectModelWithId], None] delete: Callable[[str], None] # Optional override for the ``add`` action; receives the raw item dict and # the ``ProtectApiClient`` instance. When ``None`` the generic # ``create_from_unifi_dict`` path is used. factory: Callable[[dict[str, Any], ProtectApiClient], ProtectModelWithId] | None = ( None ) def _dict_slot( store: dict[str, ProtectModelWithId], factory: Callable[[dict[str, Any], ProtectApiClient], ProtectModelWithId] | None = None, ) -> _Slot: """Return a slot backed by an id-keyed dict.""" def _delete(obj_id: str) -> None: store.pop(obj_id, None) return _Slot( store.get, store.__setitem__, _delete, factory=factory, ) def _parse_ws_envelope( data: dict[str, Any], ) -> tuple[str | None, dict[str, Any], str | None]: """ Extract ``(action_type, item, id)`` from a WS envelope. Returns ``(None, {}, None)`` if required fields are missing. """ action_type = data.get("type") item = data.get("item") or {} model_key = item.get("modelKey") obj_id = item.get("id") if not action_type or not model_key or not obj_id: return None, {}, None return action_type, item, obj_id def _warn_once( warned_keys: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: Any ) -> None: """Emit ``msg % args`` at WARNING on first occurrence, DEBUG afterwards.""" if key in warned_keys: _LOGGER.debug(msg, *args) return warned_keys.add(key) _LOGGER.warning(msg, *args) def _merge( old_obj: ProtectModelWithId, item: dict[str, Any], warned_keys: set[tuple[str, str]], ) -> ProtectModelWithId | None: """ Merge a partial WS payload into ``old_obj`` in place. Drops ``id`` / ``modelKey`` (identity fields, never changed via update), feeds the remaining camelCase payload through the object's own :meth:`ProtectBaseObject.unifi_dict_to_dict` (which remaps keys, snake- cases and coerces types) and then applies the cleaned diff via :meth:`update_from_dict`. Returns the updated object, the original object (for empty / no-op payloads), or ``None`` if the payload could not be applied. """ payload = {k: v for k, v in item.items() if k not in ("id", "modelKey")} if not payload: return old_obj try: cleaned = type(old_obj).unifi_dict_to_dict(dict(payload)) if not cleaned: return old_obj old_obj.update_from_dict(cleaned) except Exception as err: # First occurrence per (ModelType, first-diff-key) is WARNING so a # schema break is visible in HA logs; subsequent occurrences drop # to DEBUG to avoid flooding. first_key = next(iter(payload), "?") _warn_once( warned_keys, (type(old_obj).__name__, first_key), "Failed to merge public WS update for %s (fields=%s): %s", type(old_obj).__name__, list(payload), err, ) return None return old_obj uilibs-uiprotect-317a52c/src/uiprotect/data/public_devices.py000066400000000000000000002743761524637705700244570ustar00rootroot00000000000000""" UniFi Protect Public API (Integration) data models. These models represent resources that are exposed only through the Public Integration API. They live in a separate module so that the existing private-API data models (in :mod:`uiprotect.data.devices`) are not affected and older Protect installations remain unaffected. Fields the NVR is contractually required to return are modelled as required; only fields that are genuinely optional on the wire (bridge link, battery status on mains-powered devices, siren activation metadata while idle, ...) are ``Optional``. """ from __future__ import annotations import math from collections.abc import Callable from datetime import UTC, datetime, timedelta from enum import StrEnum from functools import cache from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, Self, TypedDict from urllib.parse import urlsplit, urlunsplit from pydantic import Field from pydantic.fields import PrivateAttr from ..exceptions import BadRequest from ..utils import ( convert_smart_audio_types, convert_smart_types, convert_to_datetime, convert_video_modes, format_host_for_url, from_js_time, to_js_time, ) from .base import ProtectBaseObject, ProtectModelWithId from .types import ( DEFAULT, DEFAULT_TYPE, AlarmHubBatteryStatus, AlarmHubConnectionState, AlarmHubCoverStatus, AlarmHubInputContactType, AlarmHubInputStatus, AlarmHubInputType, AlarmHubOutputStatus, AssetFileType, ChannelQuality, DeviceState, DoorbellMessageType, EventType, FobAwayState, FobButton, FobButtonLabels, LightModeEnableType, LightModeType, LiveviewCycleMode, ModelType, MountType, NvrArmModeStatus, OnOffState, OsdOverlayLocation, PublicHdrMode, RelayInputActionTrigger, RelayInputActionType, RelayInputState, RelayOutputRebootState, RelayOutputState, RelayOutputType, SensorScheduleMode, SensorStatusType, SirenConnectionType, SirenDuration, SmartDetectAudioType, SmartDetectObjectType, SpeakerMode, SpeakerStatus, UlpUserStatus, VideoMode, ) if TYPE_CHECKING: from ..api import ( CameraPublicApiLcdMessageRequest, ProtectApiClient, PublicApiChimeRingSettingRequest, ) from .public_event import PublicEvent # Public Integration API numeric bounds, taken from the OpenAPI spec. The # public schema is the source of truth for these and they differ from the # private/UI limits (e.g. ``PercentInt`` allows 101). The sensor # ``highThreshold`` fields are spec-unbounded, so they are not range-checked. _PUBLIC_SENSITIVITY_RANGE: tuple[float, float] = (0, 100) _PUBLIC_LED_LEVEL_RANGE: tuple[float, float] = (1, 6) _PUBLIC_MIC_VOLUME_RANGE: tuple[float, float] = (0, 100) _PUBLIC_TEMPERATURE_LOW_RANGE: tuple[float, float] = (-39, 124) _PUBLIC_HUMIDITY_LOW_RANGE: tuple[float, float] = (1, 99) _PUBLIC_LIGHT_LUX_LOW_RANGE: tuple[float, float] = (1, 503192) _PUBLIC_RING_VOLUME_RANGE: tuple[float, float] = (0, 100) _PUBLIC_RING_REPEAT_RANGE: tuple[float, float] = (1, 6) def _validate_public_range( name: str, value: float, bounds: tuple[float, float], ) -> None: """Range-check a public-API number against the spec bounds.""" if not math.isfinite(value): raise BadRequest(f"{name} must be a finite number, got {value}") minimum, maximum = bounds if value < minimum or value > maximum: raise BadRequest(f"{name} must be between {minimum} and {maximum}, got {value}") def _coerce_public_int( name: str, value: float, bounds: tuple[float, float], ) -> int: """Validate a public-API number against the spec bounds and coerce it to ``int``.""" _validate_public_range(name, value, bounds) return int(value) # Doorbell LCD message types that require accompanying ``text``; the rest must # omit it. _LCD_TYPES_REQUIRING_TEXT: frozenset[DoorbellMessageType] = frozenset( {DoorbellMessageType.CUSTOM_MESSAGE, DoorbellMessageType.IMAGE} ) def _build_public_lcd_message( text_type: DoorbellMessageType | None, text: str | None, reset_at: datetime | DEFAULT_TYPE | None, ) -> CameraPublicApiLcdMessageRequest: """Build the ``lcdMessage`` PATCH body; ``text_type`` of ``None`` clears it.""" if text_type is None: if text is not None or reset_at is not DEFAULT: raise BadRequest("Clearing the LCD message does not accept text/reset_at") # The public API validates the request against a ``oneOf`` whose variants # all require ``type``, so an empty object is rejected before any handler # runs. A message whose ``resetAt`` has already passed is removed instead # of being pushed to the camera; ``0`` avoids any client/console clock # skew, and the type is never delivered so any text-free one will do. return {"type": DoorbellMessageType.DO_NOT_DISTURB, "resetAt": 0} if text_type in _LCD_TYPES_REQUIRING_TEXT: if text is None: raise BadRequest(f"{text_type} requires text") elif text is not None: raise BadRequest(f"{text_type} does not accept text") message: CameraPublicApiLcdMessageRequest = {"type": text_type} if text is not None: message["text"] = text if isinstance(reset_at, datetime): message["resetAt"] = to_js_time(reset_at) elif reset_at is None: message["resetAt"] = None return message # Smart-detect object events. Protect 7.x emits overlapping ``smartDetectZone`` # and ``smartDetectLine`` frames for the same detection, so both drive the smart # flags — mirroring the private ``Camera`` model (see ``CAMERA_EVENT_ATTR_MAP`` # in ``bootstrap.py``, which maps both to the same ``last_smart_detect`` state). _SMART_DETECT_EVENT_TYPES = frozenset( {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE} ) _MOTION_EVENT_TYPES = frozenset({EventType.MOTION}) _SMART_AUDIO_EVENT_TYPES = frozenset({EventType.SMART_AUDIO_DETECT}) # Public events-WS event types that drive the derived detection-state booleans # on :class:`PublicCamera`. Other event types are ignored by that machinery. _DETECTION_EVENT_TYPES = ( _MOTION_EVENT_TYPES | _SMART_AUDIO_EVENT_TYPES | _SMART_DETECT_EVENT_TYPES ) # Derived detection-state boolean property names on :class:`PublicCamera`. A # transition in any of these (after an events-WS frame is folded into the active # set) is surfaced as a synthetic devices-WS update — see # :meth:`PublicCamera._detection_state` and the events-WS handler. _DETECTION_STATE_FIELDS = ( "is_motion_detected", "is_smart_currently_detected", "is_person_currently_detected", "is_vehicle_currently_detected", "is_animal_currently_detected", "is_audio_currently_detected", "is_smoke_currently_detected", "is_cmonx_currently_detected", "is_siren_currently_detected", "is_baby_cry_currently_detected", "is_speaking_currently_detected", "is_bark_currently_detected", "is_car_alarm_currently_detected", "is_car_horn_currently_detected", "is_glass_break_currently_detected", ) class PublicArmScheduleDict(TypedDict): """Arm-profile schedule entry (write shape). Fields are cron expressions.""" start: str end: str class PublicSensorLightSettings(TypedDict, total=False): isEnabled: bool lowThreshold: float highThreshold: float margin: int class PublicSensorHumiditySettings(TypedDict, total=False): isEnabled: bool lowThreshold: float highThreshold: float margin: int class PublicSensorTemperatureSettings(TypedDict, total=False): isEnabled: bool lowThreshold: float highThreshold: float margin: float class PublicSensorMotionSettings(TypedDict, total=False): isEnabled: bool sensitivity: int sensitivityWhenArmed: int class PublicSensorGlassBreakSettingsWrite(TypedDict, total=False): # Write shape for ``glassBreakSettings``; the read model is # ``PublicSensorGlassBreakSettings``. isEnabled: bool sensitivity: int sensitivityWhenArmed: int class PublicSensorAlarmSettings(TypedDict, total=False): isEnabled: bool class PublicLiveviewSlotDict(TypedDict): """Liveview slot (write shape).""" cameras: list[str] cycleMode: LiveviewCycleMode cycleInterval: int class PublicPosLineItemDict(TypedDict): """One purchased line item of a POS transaction (write shape).""" title: str quantity: int class PublicPosLocationDict(TypedDict): """Location or register a POS transaction was rung up at (write shape).""" id: str name: NotRequired[str] class PublicSignalState(ProtectBaseObject): # Nullable on the wire: a freshly-paired wireless device (e.g. a key fob) # has not yet reported its Bluetooth signal. signal_quality: int | None = None signal_strength: int | None = None class PublicWirelessBatteryStatus(ProtectBaseObject): # Nullable on the wire until the device first reports its battery level. percentage: int | None = None is_low: bool class PublicWirelessConnectionState(ProtectBaseObject): # ``signal_state`` is only present while the device is online; battery # status is absent on mains-powered devices; bridge may be unset. signal_state: PublicSignalState | None = None battery_status: PublicWirelessBatteryStatus | None = None bridge: str | None = None class PublicLedSettings(ProtectBaseObject): is_enabled: bool # Sub-object internals are not ``required`` in the spec, so every leaf field # carries a default; that keeps partial WS update diffs (which omit unchanged # nested keys) parseable without strict-validation failures. class PublicOsdSettings(ProtectBaseObject): is_name_enabled: bool = False is_date_enabled: bool = False is_logo_enabled: bool = False is_debug_enabled: bool = False overlay_location: OsdOverlayLocation | None = None class PublicCameraLedSettings(ProtectBaseObject): # Distinct from the single-field :class:`PublicLedSettings` used by # siren/relay. ``welcome_led`` / ``flood_led`` are ``None`` on cameras # without a doorbell/floodlight. is_enabled: bool = False welcome_led: bool | None = None flood_led: bool | None = None class PublicLcdMessage(ProtectBaseObject): # Response shape, not request shape: the spec models these as separate # schemas. Responses use ``lcdMessageUnion``, where every field is # optional, so all three are optional here. Requests use ``lcdMessage``, a # ``oneOf`` whose four variants all require ``type`` — an empty object is # rejected there, and a camera with no message omits the key entirely. type: DoorbellMessageType | None = None reset_at: int | None = None text: str | None = None @property def reset_at_dt(self) -> datetime | None: """``reset_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.reset_at) class PublicCameraFeatureFlags(ProtectBaseObject): support_full_hd_snapshot: bool = False has_hdr: bool = False smart_detect_types: list[SmartDetectObjectType] = Field(default_factory=list) smart_detect_audio_types: list[SmartDetectAudioType] = Field(default_factory=list) video_modes: list[VideoMode] = Field(default_factory=list) has_mic: bool = False has_led_status: bool = False has_speaker: bool = False @property def has_highfps(self) -> bool: """Does the camera support high-FPS video mode.""" return VideoMode.HIGH_FPS in self.video_modes @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "smartDetectTypes": convert_smart_types, "smartDetectAudioTypes": convert_smart_audio_types, "videoModes": convert_video_modes, } | super().unifi_dict_conversions() class PublicSmartDetectSettings(ProtectBaseObject): object_types: list[SmartDetectObjectType] = Field(default_factory=list) audio_types: list[SmartDetectAudioType] = Field(default_factory=list) @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: return { "objectTypes": convert_smart_types, "audioTypes": convert_smart_audio_types, } | super().unifi_dict_conversions() def _replace_url_host(url: str, host: str) -> str: """Replace the host component of ``url``, keeping port, path and query.""" parts = urlsplit(url) try: port = parts.port except ValueError: return url netloc = f"{host}:{port}" if port is not None else host return urlunsplit(parts._replace(netloc=netloc)) class RTSPSStreams(ProtectBaseObject): """RTSPS stream URLs for a camera.""" model_config = {"extra": "allow"} # Intentionally no variables like 'high', 'medium', 'low' are defined here. # The API naming appears inconsistent - what's called "quality" might actually be "channels". # Besides standard qualities (high/medium/low), there are special cases like "package" for doorbells # and unclear implementation for 180° cameras with dual sensors. Dynamic handling via __pydantic_extra__ is safer. def __init__(self, api: ProtectApiClient | None = None, **data: Any) -> None: """Bind to ``api`` so URLs can honour ``override_connection_host``.""" # Runtime-identical to ProtectBaseObject.__init__, but mypy synthesises a # dataclass_transform __init__ per model subclass from declared fields only; # without this redeclaration every ``RTSPSStreams(api=...)`` is a # call-arg error. super().__init__(api=api, **data) def get_stream_url(self, quality: str, srtp: bool = True) -> str | None: """Get stream URL for a quality level; ``srtp=False`` strips ``?enableSrtp``.""" url = getattr(self, quality, None) if not isinstance(url, str): return url if not srtp: # Strip only the exact ?enableSrtp suffix the server appends (mirrors the # private rtsps_url construction); go2rtc rejects the SRTP variant. A # generic query strip would be wrong if Protect ever adds other params. url = url.removesuffix("?enableSrtp") api = self._api if api is None or not api.override_connection_host: return url # A multi-homed console does not answer with the address the request # arrived on, so the server URL can name an unreachable interface. return _replace_url_host(url, format_host_for_url(api.connection_host)) def get_available_stream_qualities(self) -> list[str]: """ List available RTSPS quality keys from the server. Returns raw strings; may include values not in :class:`ChannelQuality`. """ if self.__pydantic_extra__ is None: return [] return list(self.__pydantic_extra__.keys()) def get_active_stream_qualities(self) -> list[str]: """Get list of currently active RTSPS stream quality levels (only those with stream URLs).""" if self.__pydantic_extra__ is None: return [] return [ key for key, value in self.__pydantic_extra__.items() if isinstance(value, str) and value is not None ] def get_inactive_stream_qualities(self) -> list[str]: """Get list of inactive RTSPS stream quality levels (supported but not currently active).""" if self.__pydantic_extra__ is None: return [] return [ key for key, value in self.__pydantic_extra__.items() if not (isinstance(value, str) and value is not None) ] class PublicIdentifiedModel(ProtectModelWithId): """Public model carrying the optional device self-description (``type`` / ``guid``).""" # Present on newer firmware; older consoles omit both, so they default to # ``None`` and ``from_unifi_dict`` must not raise on the old shape. # ``device_type`` is the human model name (wire ``type``), distinct from # ``model`` (the modelKey enum); ``device_guid`` (wire ``guid``) is a stable # identifier. device_type: str | None = None device_guid: str | None = None name: str | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return { **super()._get_unifi_remaps(), "type": "deviceType", "guid": "deviceGuid", } @property def type(self) -> str | None: """Alias for ``device_type`` mirroring the private tree's ``type`` field.""" return self.device_type @property def display_name(self) -> str: """Human-facing name, falling back ``name -> type`` (no ``market_name`` here).""" return self.name or self.type or "" class PublicDeviceModel(PublicIdentifiedModel): """Shared base for dedicated public device models carrying ``mac`` / ``state``.""" state: DeviceState mac: str # Fields a write-through merge must never overwrite from a PATCH response: # stable identity plus any library-owned out-of-band state. Subclasses # extend this (see :class:`PublicCamera`). ``_update_sync.lock`` (inherited # from :class:`~uiprotect.data.base.ProtectModelWithId`) is what serializes # the read-modify-write setters below — no separate lock is introduced. _WRITE_THROUGH_SKIP: ClassVar[frozenset[str]] = frozenset({"id"}) def _apply_from_response(self, response: Self) -> None: """ Merge a fresh PATCH-response model into this cached instance in place. Lock-free by design: it performs a run of plain attribute assignments with no ``await`` in between, so it is atomic with respect to the event loop. Read-modify-write setters take ``self._update_sync.lock`` around their read+patch+apply themselves; this helper must not, or a setter holding that lock would deadlock on the non-reentrant ``asyncio.Lock``. Private ``PrivateAttr`` state (detection caches, ...) is not part of ``model_fields`` and is therefore preserved untouched. """ skip = type(self)._WRITE_THROUGH_SKIP for name in type(self).model_fields: if name in skip: continue setattr(self, name, getattr(response, name)) class PublicCamera(PublicDeviceModel): """Public API camera device (``GET /v1/cameras``).""" # ``rtsps_streams`` is primed out-of-band by the library (see the field # docstring below) and is never carried on a PATCH response, so a # write-through merge must skip it or a setter call would null it. _WRITE_THROUGH_SKIP: ClassVar[frozenset[str]] = frozenset({"id", "rtsps_streams"}) model: ModelType | None = ModelType.CAMERA # Nullable on the wire (spec: ``oneOf [string, null]``). name: str | None = None is_mic_enabled: bool osd_settings: PublicOsdSettings led_settings: PublicCameraLedSettings lcd_message: PublicLcdMessage | None = None mic_volume: int # ``null`` when no patrol is running. active_patrol_slot: int | None = None video_mode: VideoMode hdr_type: PublicHdrMode feature_flags: PublicCameraFeatureFlags smart_detect_settings: PublicSmartDetectSettings has_package_camera: bool # RTSPS stream URLs for this camera. Owned and primed entirely by the # library: filled by ``ProtectApiClient.update_public`` (and kept fresh by # the WS-reconnect refresh + create/delete write-through). The Public # Integration API does not yet carry these on the camera payload, so the # field defaults to ``None`` and is populated out-of-band. Consumers read # it synchronously. rtsps_streams: RTSPSStreams | None = None # Open (not-yet-ended) detection events keyed by event id, maintained from # the public events websocket. Per-instance (one process may drive several # clients against different consoles), so it is a ``PrivateAttr`` with a # per-instance default factory rather than a shared class attribute. _active_detection_events: dict[str, PublicEvent] = PrivateAttr(default_factory=dict) # Memoized derived detection booleans. ``None`` means "stale"; the next # ``_detection_state`` call rebuilds a fresh dict and caches it. The two # mutators of ``_active_detection_events`` invalidate by setting this back to # ``None`` rather than mutating the dict in place, so a snapshot already # captured by the events-WS diff stays a distinct object from the rebuild. _detection_state_cache: dict[str, bool] | None = PrivateAttr(default=None) def hardware_stream_qualities(self) -> list[ChannelQuality]: """Stream qualities the camera hardware supports (not the server's ``available`` list).""" qualities = [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW] if self.has_package_camera: qualities.append(ChannelQuality.PACKAGE) return qualities def can_detect(self, smart_type: SmartDetectObjectType) -> bool: """Whether the camera advertises ``smart_type`` (audio types via ``audio_type``).""" if (audio_type := smart_type.audio_type) is not None: return audio_type in self.feature_flags.smart_detect_audio_types return smart_type in self.feature_flags.smart_detect_types # The Public Integration API does not carry live detection booleans on the # camera payload; they are derived here from the public events websocket, # mirroring the private :class:`~uiprotect.data.devices.Camera` accessor # names. An event turns the matching flag(s) on at start (``end is None``) # and off once it ends, so overlapping detections of the same kind are # handled by the per-id active-event set. @property def is_motion_detected(self) -> bool: """Is a motion event currently active.""" return self._detection_state()["is_motion_detected"] @property def is_smart_currently_detected(self) -> bool: """Is a smart-detect event currently active.""" return self._detection_state()["is_smart_currently_detected"] @property def is_person_currently_detected(self) -> bool: """Is a person currently being detected.""" return self._detection_state()["is_person_currently_detected"] @property def is_vehicle_currently_detected(self) -> bool: """Is a vehicle currently being detected.""" return self._detection_state()["is_vehicle_currently_detected"] @property def is_animal_currently_detected(self) -> bool: """Is an animal currently being detected.""" return self._detection_state()["is_animal_currently_detected"] @property def is_audio_currently_detected(self) -> bool: """Is an audio smart-detect event currently active.""" return self._detection_state()["is_audio_currently_detected"] @property def is_smoke_currently_detected(self) -> bool: """Is a smoke alarm currently being detected.""" return self._detection_state()["is_smoke_currently_detected"] @property def is_cmonx_currently_detected(self) -> bool: """Is a CO alarm currently being detected.""" return self._detection_state()["is_cmonx_currently_detected"] @property def is_siren_currently_detected(self) -> bool: """Is a siren currently being detected.""" return self._detection_state()["is_siren_currently_detected"] @property def is_baby_cry_currently_detected(self) -> bool: """Is a baby cry currently being detected.""" return self._detection_state()["is_baby_cry_currently_detected"] @property def is_speaking_currently_detected(self) -> bool: """Is speaking currently being detected.""" return self._detection_state()["is_speaking_currently_detected"] @property def is_bark_currently_detected(self) -> bool: """Is a bark currently being detected.""" return self._detection_state()["is_bark_currently_detected"] @property def is_car_alarm_currently_detected(self) -> bool: """Is a car alarm currently being detected.""" return self._detection_state()["is_car_alarm_currently_detected"] @property def is_car_horn_currently_detected(self) -> bool: """Is a car horn currently being detected.""" return self._detection_state()["is_car_horn_currently_detected"] @property def is_glass_break_currently_detected(self) -> bool: """Is glass breaking currently being detected.""" return self._detection_state()["is_glass_break_currently_detected"] # Config-derived parity accessors mirroring the private # :class:`~uiprotect.data.devices.Camera` names. Unlike the private model, # the public payload has no per-type feature-flag/global-settings # indirection, so each ``is_*_detection_on`` is a plain membership test over # ``smart_detect_settings`` — object types against ``object_types``, audio # types against ``audio_types``. ``is_motion_detection_on`` is deliberately # absent: its private semantics depend on ``recording_settings``, which the # public payload does not expose. @property def is_person_detection_on(self) -> bool: """Is person smart detection enabled.""" return SmartDetectObjectType.PERSON in self.smart_detect_settings.object_types @property def is_vehicle_detection_on(self) -> bool: """Is vehicle smart detection enabled.""" return SmartDetectObjectType.VEHICLE in self.smart_detect_settings.object_types @property def is_face_detection_on(self) -> bool: """Is face smart detection enabled.""" return SmartDetectObjectType.FACE in self.smart_detect_settings.object_types @property def is_license_plate_detection_on(self) -> bool: """Is license plate smart detection enabled.""" return ( SmartDetectObjectType.LICENSE_PLATE in self.smart_detect_settings.object_types ) @property def is_package_detection_on(self) -> bool: """Is package smart detection enabled.""" return SmartDetectObjectType.PACKAGE in self.smart_detect_settings.object_types @property def is_animal_detection_on(self) -> bool: """Is animal smart detection enabled.""" return SmartDetectObjectType.ANIMAL in self.smart_detect_settings.object_types @property def is_smoke_detection_on(self) -> bool: """Is smoke alarm smart detection enabled.""" return SmartDetectAudioType.SMOKE in self.smart_detect_settings.audio_types @property def is_co_detection_on(self) -> bool: """Is CO alarm smart detection enabled.""" return SmartDetectAudioType.CMONX in self.smart_detect_settings.audio_types @property def is_siren_detection_on(self) -> bool: """Is siren smart detection enabled.""" return SmartDetectAudioType.SIREN in self.smart_detect_settings.audio_types @property def is_baby_cry_detection_on(self) -> bool: """Is baby cry smart detection enabled.""" return SmartDetectAudioType.BABY_CRY in self.smart_detect_settings.audio_types @property def is_speaking_detection_on(self) -> bool: """Is speaking smart detection enabled.""" return SmartDetectAudioType.SPEAK in self.smart_detect_settings.audio_types @property def is_bark_detection_on(self) -> bool: """Is bark smart detection enabled.""" return SmartDetectAudioType.BARK in self.smart_detect_settings.audio_types @property def is_car_alarm_detection_on(self) -> bool: """Is car alarm smart detection enabled.""" return SmartDetectAudioType.BURGLAR in self.smart_detect_settings.audio_types @property def is_car_horn_detection_on(self) -> bool: """Is car horn smart detection enabled.""" return SmartDetectAudioType.CAR_HORN in self.smart_detect_settings.audio_types @property def is_glass_break_detection_on(self) -> bool: """Is glass break smart detection enabled.""" return ( SmartDetectAudioType.GLASS_BREAK in self.smart_detect_settings.audio_types ) @property def is_high_fps_enabled(self) -> bool: """Is the camera running in high-FPS video mode.""" return self.video_mode is VideoMode.HIGH_FPS @property def hdr_mode_display(self) -> Literal["auto", "off", "always"]: """HDR mode as shown in the Protect interface (inverse of ``hdr_type``).""" if self.hdr_type is PublicHdrMode.OFF: return "off" if self.hdr_type is PublicHdrMode.ON: return "always" return "auto" def _apply_detection_event(self, event: PublicEvent) -> None: """Add/remove a detection event from the active set based on its ``end``.""" if event.type not in _DETECTION_EVENT_TYPES: return if event.end is None: self._active_detection_events[event.id] = event else: self._active_detection_events.pop(event.id, None) self._detection_state_cache = None def _clear_detection_event(self, event_id: str) -> None: """Drop an event from the active set (eviction / server ``remove`` frame).""" self._active_detection_events.pop(event_id, None) self._detection_state_cache = None def _detection_state(self) -> dict[str, bool]: """ Snapshot every derived detection boolean in one pass over the active set. Single source of truth for the per-type predicates: the nine public ``is_*`` properties read their value out of this dict, so they and the snapshot used for transition diffing can never drift apart. The result is memoized until the active set mutates, so a single-property read is an O(1) cache lookup rather than a fresh pass over every active event. """ cached = self._detection_state_cache if cached is not None: return cached motion = smart = person = vehicle = animal = False audio = smoke = cmonx = siren = False baby_cry = speaking = bark = car_alarm = car_horn = glass_break = False for event in self._active_detection_events.values(): event_type = event.type if event_type in _MOTION_EVENT_TYPES: motion = True elif event_type in _SMART_DETECT_EVENT_TYPES: smart = True smart_types = event.smart_detect_types person = person or SmartDetectObjectType.PERSON in smart_types vehicle = vehicle or SmartDetectObjectType.VEHICLE in smart_types animal = animal or SmartDetectObjectType.ANIMAL in smart_types elif event_type in _SMART_AUDIO_EVENT_TYPES: audio = True smart_types = event.smart_detect_types smoke = smoke or SmartDetectObjectType.SMOKE in smart_types cmonx = cmonx or SmartDetectObjectType.CMONX in smart_types siren = siren or SmartDetectObjectType.SIREN in smart_types baby_cry = baby_cry or SmartDetectObjectType.BABY_CRY in smart_types speaking = speaking or SmartDetectObjectType.SPEAK in smart_types bark = bark or SmartDetectObjectType.BARK in smart_types car_alarm = car_alarm or SmartDetectObjectType.BURGLAR in smart_types car_horn = car_horn or SmartDetectObjectType.CAR_HORN in smart_types glass_break = ( glass_break or SmartDetectObjectType.GLASS_BREAK in smart_types ) state = dict( zip( _DETECTION_STATE_FIELDS, ( motion, smart, person, vehicle, animal, audio, smoke, cmonx, siren, baby_cry, speaking, bark, car_alarm, car_horn, glass_break, ), strict=True, ) ) self._detection_state_cache = state return state async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "Camera mutations must go through the dedicated public API helpers " "(update_camera_public)." ) async def set_status_light(self, enabled: bool) -> PublicCamera: """Set the status LED via the public API.""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") updated = await self._api.update_camera_public(self.id, led_is_enabled=enabled) self._apply_from_response(updated) return self async def set_welcome_led(self, enabled: bool) -> PublicCamera: """Set the welcome LED via the public API.""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") if self.led_settings.welcome_led is None: raise BadRequest("Camera does not have welcome LED") updated = await self._api.update_camera_public(self.id, led_welcome_led=enabled) self._apply_from_response(updated) return self async def set_flood_led(self, enabled: bool) -> PublicCamera: """Set the flood LED via the public API.""" if not self.feature_flags.has_led_status: raise BadRequest("Camera does not have status light") if self.led_settings.flood_led is None: raise BadRequest("Camera does not have flood LED") updated = await self._api.update_camera_public(self.id, led_flood_led=enabled) self._apply_from_response(updated) return self async def set_hdr_mode(self, mode: PublicHdrMode) -> PublicCamera: """Set HDR mode via the public API.""" if not self.feature_flags.has_hdr: raise BadRequest("Camera does not have HDR") updated = await self._api.update_camera_public(self.id, hdr_type=mode) self._apply_from_response(updated) return self async def set_video_mode(self, mode: VideoMode) -> PublicCamera: """Set the video mode via the public API.""" if mode not in self.feature_flags.video_modes: raise BadRequest(f"Camera does not have {mode}") updated = await self._api.update_camera_public(self.id, video_mode=mode) self._apply_from_response(updated) return self async def set_mic_volume(self, level: int) -> PublicCamera: """Set microphone volume (0-100) via the public API.""" if not self.feature_flags.has_mic: raise BadRequest("Camera does not have mic") level = _coerce_public_int("mic_volume", level, _PUBLIC_MIC_VOLUME_RANGE) updated = await self._api.update_camera_public(self.id, mic_volume=level) self._apply_from_response(updated) return self async def set_lcd_message( self, text_type: DoorbellMessageType | None, text: str | None = None, reset_at: datetime | DEFAULT_TYPE | None = DEFAULT, ) -> PublicCamera: """ Set the doorbell LCD message via the public API. Pass ``None`` for ``text_type`` to clear the message, with ``text`` and ``reset_at`` omitted. ``text`` is required for CUSTOM_MESSAGE and IMAGE and must be omitted otherwise. ``reset_at`` controls when the message clears: omit for the NVR default, pass ``None`` for "forever", or a specific datetime. """ message = _build_public_lcd_message(text_type, text, reset_at) updated = await self._api.update_camera_public(self.id, lcd_message=message) self._apply_from_response(updated) return self async def set_osd_name(self, enabled: bool) -> PublicCamera: """Toggle the name overlay (OSD) via the public API.""" updated = await self._api.update_camera_public( self.id, osd_name_enabled=enabled ) self._apply_from_response(updated) return self async def set_osd_date(self, enabled: bool) -> PublicCamera: """Toggle the date overlay (OSD) via the public API.""" updated = await self._api.update_camera_public( self.id, osd_date_enabled=enabled ) self._apply_from_response(updated) return self async def set_osd_logo(self, enabled: bool) -> PublicCamera: """Toggle the logo overlay (OSD) via the public API.""" updated = await self._api.update_camera_public( self.id, osd_logo_enabled=enabled ) self._apply_from_response(updated) return self async def set_osd_nerd_mode(self, enabled: bool) -> PublicCamera: """Toggle the bitrate/debug overlay (OSD) via the public API.""" updated = await self._api.update_camera_public( self.id, osd_nerd_mode_enabled=enabled ) self._apply_from_response(updated) return self async def set_osd_overlay_location( self, location: OsdOverlayLocation ) -> PublicCamera: """Set the OSD overlay location via the public API.""" updated = await self._api.update_camera_public( self.id, osd_overlay_location=location ) self._apply_from_response(updated) return self async def set_person_detection(self, enabled: bool) -> PublicCamera: """Toggle person smart detection via the public API.""" return await self._set_smart_detect_object( SmartDetectObjectType.PERSON, enabled ) async def set_vehicle_detection(self, enabled: bool) -> PublicCamera: """Toggle vehicle smart detection via the public API.""" return await self._set_smart_detect_object( SmartDetectObjectType.VEHICLE, enabled ) async def set_package_detection(self, enabled: bool) -> PublicCamera: """Toggle package smart detection via the public API.""" return await self._set_smart_detect_object( SmartDetectObjectType.PACKAGE, enabled ) async def set_license_plate_detection(self, enabled: bool) -> PublicCamera: """Toggle license plate smart detection via the public API.""" return await self._set_smart_detect_object( SmartDetectObjectType.LICENSE_PLATE, enabled ) async def set_animal_detection(self, enabled: bool) -> PublicCamera: """Toggle animal smart detection via the public API.""" return await self._set_smart_detect_object( SmartDetectObjectType.ANIMAL, enabled ) async def set_face_detection(self, enabled: bool) -> PublicCamera: """Toggle face smart detection via the public API.""" return await self._set_smart_detect_object(SmartDetectObjectType.FACE, enabled) async def set_smoke_detection(self, enabled: bool) -> PublicCamera: """Toggle smoke audio detection via the public API.""" return await self._set_smart_detect_audio(SmartDetectAudioType.SMOKE, enabled) async def set_co_detection(self, enabled: bool) -> PublicCamera: """Toggle CO audio detection via the public API.""" return await self._set_smart_detect_audio(SmartDetectAudioType.CMONX, enabled) async def set_siren_detection(self, enabled: bool) -> PublicCamera: """Toggle siren audio detection via the public API.""" return await self._set_smart_detect_audio(SmartDetectAudioType.SIREN, enabled) async def set_baby_cry_detection(self, enabled: bool) -> PublicCamera: """Toggle baby cry audio detection via the public API.""" return await self._set_smart_detect_audio( SmartDetectAudioType.BABY_CRY, enabled ) async def set_speaking_detection(self, enabled: bool) -> PublicCamera: """Toggle speaking audio detection via the public API.""" return await self._set_smart_detect_audio(SmartDetectAudioType.SPEAK, enabled) async def set_bark_detection(self, enabled: bool) -> PublicCamera: """Toggle bark audio detection via the public API.""" return await self._set_smart_detect_audio(SmartDetectAudioType.BARK, enabled) async def set_burglar_detection(self, enabled: bool) -> PublicCamera: """Toggle burglar audio detection via the public API.""" return await self._set_smart_detect_audio(SmartDetectAudioType.BURGLAR, enabled) async def set_car_horn_detection(self, enabled: bool) -> PublicCamera: """Toggle car horn audio detection via the public API.""" return await self._set_smart_detect_audio( SmartDetectAudioType.CAR_HORN, enabled ) async def set_glass_break_detection(self, enabled: bool) -> PublicCamera: """Toggle glass break audio detection via the public API.""" return await self._set_smart_detect_audio( SmartDetectAudioType.GLASS_BREAK, enabled ) async def _set_smart_detect_object( self, obj_type: SmartDetectObjectType, enabled: bool ) -> PublicCamera: if obj_type not in self.feature_flags.smart_detect_types: raise BadRequest(f"Camera does not support {obj_type} detection") async with self._update_sync.lock: types = list(self.smart_detect_settings.object_types) if enabled and obj_type not in types: types.append(obj_type) elif not enabled and obj_type in types: types.remove(obj_type) updated = await self._api.update_camera_public( self.id, smart_detect_object_types=types ) self._apply_from_response(updated) return self async def _set_smart_detect_audio( self, audio_type: SmartDetectAudioType, enabled: bool ) -> PublicCamera: if audio_type not in self.feature_flags.smart_detect_audio_types: raise BadRequest(f"Camera does not support {audio_type} detection") async with self._update_sync.lock: types = list(self.smart_detect_settings.audio_types) if enabled and audio_type not in types: types.append(audio_type) elif not enabled and audio_type in types: types.remove(audio_type) updated = await self._api.update_camera_public( self.id, smart_detect_audio_types=types ) self._apply_from_response(updated) return self class PublicLightModeSettings(ProtectBaseObject): mode: LightModeType | None = None enable_at: LightModeEnableType | None = None class PublicLightDeviceSettings(ProtectBaseObject): is_indicator_enabled: bool = False pir_duration: int | None = None pir_sensitivity: int | None = None led_level: int | None = None class PublicLight(PublicDeviceModel): """Public API light device (``GET /v1/lights``).""" model: ModelType | None = ModelType.LIGHT # Nullable on the wire (spec: ``oneOf [string, null]``). name: str | None = None light_mode_settings: PublicLightModeSettings light_device_settings: PublicLightDeviceSettings is_dark: bool is_light_on: bool is_light_force_enabled: bool last_motion: int | None = None is_pir_motion_detected: bool # Flat ``cameraId`` string of the paired camera, or ``null``. camera: str | None = None @property def last_motion_dt(self) -> datetime | None: """``last_motion`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.last_motion) async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "Light mutations must go through the dedicated public API helpers " "(update_light_public)." ) async def set_name(self, name: str) -> PublicLight: """Set the light name via the public API.""" updated = await self._api.update_light_public(self.id, name=name) self._apply_from_response(updated) return self async def set_flood_light(self, enabled: bool) -> PublicLight: """Force the flood light on/off via the public API.""" updated = await self._api.update_light_public( self.id, is_light_force_enabled=enabled ) self._apply_from_response(updated) return self async def set_status_light(self, enabled: bool) -> PublicLight: """Toggle the status indicator LED via the public API.""" async with self._update_sync.lock: settings = self.light_device_settings.model_copy() settings.is_indicator_enabled = enabled updated = await self._api.update_light_public( self.id, light_device_settings=settings ) self._apply_from_response(updated) return self async def set_led_level(self, led_level: int) -> PublicLight: """Set the indicator LED brightness (1-6) via the public API.""" led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE) async with self._update_sync.lock: settings = self.light_device_settings.model_copy() settings.led_level = led_level updated = await self._api.update_light_public( self.id, light_device_settings=settings ) self._apply_from_response(updated) return self async def set_light( self, enabled: bool, led_level: float | None = None ) -> PublicLight: """Force the light on/off, optionally setting LED brightness (1-6), in one call.""" if led_level is None: updated = await self._api.update_light_public( self.id, is_light_force_enabled=enabled ) self._apply_from_response(updated) return self led_level = _coerce_public_int("led_level", led_level, _PUBLIC_LED_LEVEL_RANGE) async with self._update_sync.lock: settings = self.light_device_settings.model_copy() settings.led_level = led_level updated = await self._api.update_light_public( self.id, is_light_force_enabled=enabled, light_device_settings=settings, ) self._apply_from_response(updated) return self async def set_sensitivity(self, sensitivity: int) -> PublicLight: """Set PIR motion sensitivity (0-100) via the public API.""" sensitivity = _coerce_public_int( "sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE ) async with self._update_sync.lock: settings = self.light_device_settings.model_copy() settings.pir_sensitivity = sensitivity updated = await self._api.update_light_public( self.id, light_device_settings=settings ) self._apply_from_response(updated) return self async def set_duration(self, duration: timedelta) -> PublicLight: """Set how long the light stays on after motion (15s-900s) via the public API.""" if duration.total_seconds() < 15 or duration.total_seconds() > 900: raise BadRequest("Duration outside of 15s to 900s range") async with self._update_sync.lock: settings = self.light_device_settings.model_copy() settings.pir_duration = int(duration.total_seconds() * 1000) updated = await self._api.update_light_public( self.id, light_device_settings=settings ) self._apply_from_response(updated) return self async def set_light_mode( self, mode: LightModeType, enable_at: LightModeEnableType | None = None, ) -> PublicLight: """Set the lighting trigger mode (and optional schedule) via the public API.""" async with self._update_sync.lock: settings = self.light_mode_settings.model_copy() settings.mode = mode if enable_at is not None: settings.enable_at = enable_at updated = await self._api.update_light_public( self.id, light_mode_settings=settings ) self._apply_from_response(updated) return self async def set_light_settings( self, mode: LightModeType, enable_at: LightModeEnableType | None = None, duration: timedelta | None = None, sensitivity: int | None = None, ) -> PublicLight: """Update mode, schedule, duration and PIR sensitivity via the public API.""" if duration is not None and ( duration.total_seconds() < 15 or duration.total_seconds() > 900 ): raise BadRequest("Duration outside of 15s to 900s range") async with self._update_sync.lock: mode_settings = self.light_mode_settings.model_copy() mode_settings.mode = mode if enable_at is not None: mode_settings.enable_at = enable_at device_settings: PublicLightDeviceSettings | None = None if duration is not None or sensitivity is not None: device_settings = self.light_device_settings.model_copy() if duration is not None: device_settings.pir_duration = int(duration.total_seconds() * 1000) if sensitivity is not None: device_settings.pir_sensitivity = _coerce_public_int( "sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE ) updated = await self._api.update_light_public( self.id, light_mode_settings=mode_settings, light_device_settings=device_settings, ) self._apply_from_response(updated) return self class PublicBatteryStatus(ProtectBaseObject): percentage: int | None = None is_low: bool = False class PublicSensorMetric(ProtectBaseObject): # ``value`` is ``null`` until the sensor first reports the metric. value: float | None = None status: SensorStatusType | None = None class PublicSensorStats(ProtectBaseObject): light: PublicSensorMetric | None = None humidity: PublicSensorMetric | None = None temperature: PublicSensorMetric | None = None class PublicSensorThresholdSettings(ProtectBaseObject): # Shared read-shape for the ``lightSettings`` / ``humiditySettings`` / # ``temperatureSettings`` leaves — identical field set in the spec. is_enabled: bool = False margin: float | None = None low_threshold: float | None = None high_threshold: float | None = None class PublicSensorMotionSettingsRead(ProtectBaseObject): is_enabled: bool = False sensitivity: int | None = None sensitivity_when_armed: int | None = None class PublicSensorAlarmSettingsRead(ProtectBaseObject): is_enabled: bool = False class PublicSensorGlassBreakSettings(ProtectBaseObject): is_enabled: bool = False sensitivity: int | None = None sensitivity_when_armed: int | None = None class PublicSensorLeakSettings(ProtectBaseObject): is_internal_enabled: bool = False is_external_enabled: bool = False class SensorFeatureCapability(StrEnum): """A capability a sensor can advertise in :class:`PublicSensorFeatureFlags`.""" TEMPERATURE = "temperature" HUMIDITY = "humidity" LIGHT = "light" MOTION = "motion" WATER_LEAK = "water_leak" OPEN = "open" TAMPER = "tamper" SMOKE = "smoke" class PublicSensorFeatureCapability(ProtectBaseObject): """One capability slice; ``channel_count`` defaults to ``0`` when the wire omits it.""" channel_count: int = 0 class PublicSensorFeatureFlags(ProtectBaseObject): """Per-sensor capability map. An absent key means the capability is unsupported.""" temperature: PublicSensorFeatureCapability | None = None humidity: PublicSensorFeatureCapability | None = None light: PublicSensorFeatureCapability | None = None motion: PublicSensorFeatureCapability | None = None water_leak: PublicSensorFeatureCapability | None = None open: PublicSensorFeatureCapability | None = None tamper: PublicSensorFeatureCapability | None = None smoke: PublicSensorFeatureCapability | None = None class PublicSensor(PublicDeviceModel): """Public API sensor device (``GET /v1/sensors``).""" model: ModelType | None = ModelType.SENSOR # Nullable on the wire (spec: ``oneOf [string, null]``). name: str | None = None mount_type: MountType battery_status: PublicBatteryStatus stats: PublicSensorStats light_settings: PublicSensorThresholdSettings humidity_settings: PublicSensorThresholdSettings temperature_settings: PublicSensorThresholdSettings is_opened: bool | None = None open_status_changed_at: int | None = None is_motion_detected: bool motion_detected_at: int | None = None motion_settings: PublicSensorMotionSettingsRead alarm_triggered_at: int | None = None alarm_settings: PublicSensorAlarmSettingsRead leak_detected_at: int | None = None external_leak_detected_at: int | None = None leak_settings: PublicSensorLeakSettings tampering_detected_at: int | None = None wireless_connection_state: PublicWirelessConnectionState # Firmware-new fields (Protect 7.1.76+): older consoles (e.g. 7.1.69) omit # them, so default them rather than require — the wire shape shifts across # releases and ``from_unifi_dict`` must not raise on the older shape. schedule_mode: SensorScheduleMode = SensorScheduleMode.UNKNOWN glass_break_settings: PublicSensorGlassBreakSettings = Field( default_factory=PublicSensorGlassBreakSettings ) arm_profile_ids: list[str] | None = None has_custom_sensitivity_when_armed: bool = False # Capability map, present only on newer firmware (older consoles omit it). feature_flags: PublicSensorFeatureFlags | None = None @property def open_status_changed_at_dt(self) -> datetime | None: """``open_status_changed_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.open_status_changed_at) @property def motion_detected_at_dt(self) -> datetime | None: """``motion_detected_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.motion_detected_at) @property def alarm_triggered_at_dt(self) -> datetime | None: """``alarm_triggered_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.alarm_triggered_at) @property def leak_detected_at_dt(self) -> datetime | None: """``leak_detected_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.leak_detected_at) @property def external_leak_detected_at_dt(self) -> datetime | None: """``external_leak_detected_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.external_leak_detected_at) @property def tampering_detected_at_dt(self) -> datetime | None: """``tampering_detected_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.tampering_detected_at) @property def has_feature_flags(self) -> bool: """Whether a capability map was reported; ``False`` means unavailable, not empty.""" return self.feature_flags is not None def supports(self, capability: SensorFeatureCapability) -> bool: """Whether the sensor advertises ``capability`` (``False`` without a feature map).""" if self.feature_flags is None: return False return getattr(self.feature_flags, capability.value, None) is not None @property def is_leak_detected(self) -> bool: """Whether a leak is detected on either the internal or external channel.""" return ( self.leak_detected_at is not None or self.external_leak_detected_at is not None ) @property def is_tampering_detected(self) -> bool: """Whether the tamper switch is currently triggered.""" return self.tampering_detected_at is not None # Environmental metrics are suppressed on leak mounts; the leak metric only # applies there. @property def is_temperature_sensor_enabled(self) -> bool: return ( self.mount_type is not MountType.LEAK and self.temperature_settings.is_enabled ) @property def is_humidity_sensor_enabled(self) -> bool: return ( self.mount_type is not MountType.LEAK and self.humidity_settings.is_enabled ) @property def is_light_sensor_enabled(self) -> bool: return self.mount_type is not MountType.LEAK and self.light_settings.is_enabled @property def is_motion_sensor_enabled(self) -> bool: return self.mount_type is not MountType.LEAK and self.motion_settings.is_enabled @property def is_alarm_sensor_enabled(self) -> bool: return self.mount_type is not MountType.LEAK and self.alarm_settings.is_enabled @property def is_leak_sensor_enabled(self) -> bool: return self.mount_type is MountType.LEAK @property def is_contact_sensor_enabled(self) -> bool: return self.mount_type in {MountType.DOOR, MountType.WINDOW, MountType.GARAGE} async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "Sensor mutations must go through the dedicated public API helpers " "(update_sensor_public)." ) async def set_name(self, name: str) -> PublicSensor: """Set the sensor name via the public API.""" updated = await self._api.update_sensor_public(self.id, name=name) self._apply_from_response(updated) return self async def set_temperature_settings( self, *, is_enabled: bool | None = None, low_threshold: float | None = None, high_threshold: float | None = None, margin: float | None = None, ) -> PublicSensor: """Update temperature alert settings via the public API.""" settings: PublicSensorTemperatureSettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if low_threshold is not None: _validate_public_range( "low_threshold", low_threshold, _PUBLIC_TEMPERATURE_LOW_RANGE ) settings["lowThreshold"] = low_threshold if high_threshold is not None: settings["highThreshold"] = high_threshold if margin is not None: settings["margin"] = margin if not settings: raise BadRequest("At least one parameter must be provided") updated = await self._api.update_sensor_public( self.id, temperature_settings=settings ) self._apply_from_response(updated) return self async def set_humidity_settings( self, *, is_enabled: bool | None = None, low_threshold: float | None = None, high_threshold: float | None = None, margin: int | None = None, ) -> PublicSensor: """Update humidity alert settings via the public API.""" settings: PublicSensorHumiditySettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if low_threshold is not None: _validate_public_range( "low_threshold", low_threshold, _PUBLIC_HUMIDITY_LOW_RANGE ) settings["lowThreshold"] = low_threshold if high_threshold is not None: settings["highThreshold"] = high_threshold if margin is not None: settings["margin"] = margin if not settings: raise BadRequest("At least one parameter must be provided") updated = await self._api.update_sensor_public( self.id, humidity_settings=settings ) self._apply_from_response(updated) return self async def set_light_settings( self, *, is_enabled: bool | None = None, low_threshold: float | None = None, high_threshold: float | None = None, margin: int | None = None, ) -> PublicSensor: """Update light (lux) alert settings via the public API.""" settings: PublicSensorLightSettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if low_threshold is not None: _validate_public_range( "low_threshold", low_threshold, _PUBLIC_LIGHT_LUX_LOW_RANGE ) settings["lowThreshold"] = low_threshold if high_threshold is not None: settings["highThreshold"] = high_threshold if margin is not None: settings["margin"] = margin if not settings: raise BadRequest("At least one parameter must be provided") updated = await self._api.update_sensor_public(self.id, light_settings=settings) self._apply_from_response(updated) return self async def set_motion_settings( self, *, is_enabled: bool | None = None, sensitivity: float | None = None, sensitivity_when_armed: float | None = None, ) -> PublicSensor: """Update motion detection settings via the public API.""" settings: PublicSensorMotionSettings = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if sensitivity is not None: settings["sensitivity"] = _coerce_public_int( "sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE ) if sensitivity_when_armed is not None: settings["sensitivityWhenArmed"] = _coerce_public_int( "sensitivity_when_armed", sensitivity_when_armed, _PUBLIC_SENSITIVITY_RANGE, ) if not settings: raise BadRequest("At least one parameter must be provided") updated = await self._api.update_sensor_public( self.id, motion_settings=settings ) self._apply_from_response(updated) return self async def set_glass_break_settings( self, *, is_enabled: bool | None = None, sensitivity: float | None = None, sensitivity_when_armed: float | None = None, ) -> PublicSensor: """Update glass-break detection settings via the public API.""" settings: PublicSensorGlassBreakSettingsWrite = {} if is_enabled is not None: settings["isEnabled"] = is_enabled if sensitivity is not None: settings["sensitivity"] = _coerce_public_int( "sensitivity", sensitivity, _PUBLIC_SENSITIVITY_RANGE ) if sensitivity_when_armed is not None: settings["sensitivityWhenArmed"] = _coerce_public_int( "sensitivity_when_armed", sensitivity_when_armed, _PUBLIC_SENSITIVITY_RANGE, ) if not settings: raise BadRequest("At least one parameter must be provided") updated = await self._api.update_sensor_public( self.id, glass_break_settings=settings ) self._apply_from_response(updated) return self async def set_alarm(self, enabled: bool) -> PublicSensor: """Toggle the (audio) alarm detection setting via the public API.""" alarm_settings: PublicSensorAlarmSettings = {"isEnabled": enabled} updated = await self._api.update_sensor_public( self.id, alarm_settings=alarm_settings ) self._apply_from_response(updated) return self async def set_schedule_mode(self, mode: SensorScheduleMode) -> PublicSensor: """Set the arm-schedule mode via the public API.""" updated = await self._api.update_sensor_public(self.id, schedule_mode=mode) self._apply_from_response(updated) return self async def set_arm_profile_ids(self, arm_profile_ids: list[str]) -> PublicSensor: """Set the arm-profile ids associated with the sensor via the public API.""" updated = await self._api.update_sensor_public( self.id, arm_profile_ids=arm_profile_ids ) self._apply_from_response(updated) return self async def set_custom_sensitivity_when_armed(self, enabled: bool) -> PublicSensor: """Toggle custom armed sensitivity via the public API.""" updated = await self._api.update_sensor_public( self.id, has_custom_sensitivity_when_armed=enabled ) self._apply_from_response(updated) return self async def set_motion_status(self, enabled: bool) -> PublicSensor: """Toggle motion detection via the public API.""" return await self.set_motion_settings(is_enabled=enabled) async def set_motion_sensitivity(self, sensitivity: float) -> PublicSensor: """Set motion detection sensitivity via the public API.""" return await self.set_motion_settings(sensitivity=sensitivity) async def set_temperature_status(self, enabled: bool) -> PublicSensor: """Toggle temperature alerts via the public API.""" return await self.set_temperature_settings(is_enabled=enabled) async def set_humidity_status(self, enabled: bool) -> PublicSensor: """Toggle humidity alerts via the public API.""" return await self.set_humidity_settings(is_enabled=enabled) async def set_light_status(self, enabled: bool) -> PublicSensor: """Toggle light (lux) alerts via the public API.""" return await self.set_light_settings(is_enabled=enabled) async def set_glass_break_status(self, enabled: bool) -> PublicSensor: """Toggle glass-break detection via the public API.""" return await self.set_glass_break_settings(is_enabled=enabled) class PublicRingSettings(ProtectBaseObject): camera_id: str | None = None repeat_times: int | None = None ringtone_id: str | None = None volume: int | None = None class PublicChime(PublicDeviceModel): """Public API chime device (``GET /v1/chimes``).""" model: ModelType | None = ModelType.CHIME # Nullable on the wire (spec: ``oneOf [string, null]``). name: str | None = None camera_ids: list[str] = Field(default_factory=list) ring_settings: list[PublicRingSettings] = Field(default_factory=list) async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "Chime mutations must go through the dedicated public API helpers " "(update_chime_public)." ) async def set_ring_settings( self, ring_settings: list[PublicApiChimeRingSettingRequest], ) -> PublicChime: """Replace the full per-camera ring settings via the public API.""" updated = await self._api.update_chime_public( self.id, ring_settings=ring_settings ) self._apply_from_response(updated) return self async def set_volume_for_camera(self, camera_id: str, level: int) -> PublicChime: """Set the ring volume for one paired camera via the public API.""" level = _coerce_public_int("volume", level, _PUBLIC_RING_VOLUME_RANGE) # The whole read-modify-write is held under the lock: two concurrent # callers must not each read the pre-mutation list and clobber each # other (last PATCH wins). async with self._update_sync.lock: body = self._ring_settings_body(camera_id, volume=level) updated = await self._api.update_chime_public(self.id, ring_settings=body) self._apply_from_response(updated) return self async def set_repeat_times_for_camera( self, camera_id: str, value: int ) -> PublicChime: """Set the ring repeat count for one paired camera via the public API.""" value = _coerce_public_int("repeat_times", value, _PUBLIC_RING_REPEAT_RANGE) async with self._update_sync.lock: body = self._ring_settings_body(camera_id, repeat_times=value) updated = await self._api.update_chime_public(self.id, ring_settings=body) self._apply_from_response(updated) return self def _ring_settings_body( self, camera_id: str, *, volume: int | None = None, repeat_times: int | None = None, ) -> list[PublicApiChimeRingSettingRequest]: """Build a full ring-settings PATCH body overriding one camera's entry.""" if not any(rs.camera_id == camera_id for rs in self.ring_settings): raise BadRequest(f"Camera {camera_id} is not paired with chime") body: list[PublicApiChimeRingSettingRequest] = [] for rs in self.ring_settings: override = rs.camera_id == camera_id entry: PublicApiChimeRingSettingRequest = { "cameraId": rs.camera_id or "", "volume": volume if override and volume is not None else (rs.volume or 0), "repeatTimes": ( repeat_times if override and repeat_times is not None else (rs.repeat_times or 1) ), } if rs.ringtone_id is not None: entry["ringtoneId"] = rs.ringtone_id body.append(entry) return body class PublicSirenStatus(ProtectBaseObject): is_active: bool # Only populated while the siren is active. activated_at: int | None = None duration: int | None = None @property def activated_at_dt(self) -> datetime | None: """``activated_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.activated_at) @property def turn_off_at(self) -> datetime | None: """ When the siren is expected to stop playing. Returns ``None`` when the siren is idle — either because ``activated_at`` / ``duration`` are not set, or because ``is_active`` is ``False`` (e.g. after a manual stop, where the server may leave the timing fields populated). Derived from the websocket payload so no extra API call is needed — HA can compare against ``datetime.now(UTC)`` instead of maintaining its own timer. ``duration`` is in milliseconds here even though :meth:`ProtectApiClient.play_siren_public` takes seconds: the spec types the status field as ``sirenDuration`` (5000/10000/20000/30000 ms) and the play request body as ``sirenDurationSeconds`` (5/10/20/30 s). """ if not self.is_active or self.activated_at is None or self.duration is None: return None return from_js_time(self.activated_at) + timedelta(milliseconds=self.duration) class Siren(PublicDeviceModel): """Public API siren device.""" model: ModelType | None = ModelType.SIREN name: str volume: int led_settings: PublicLedSettings siren_status: PublicSirenStatus connection_type: SirenConnectionType wireless_connection_state: PublicWirelessConnectionState | None = None async def _api_update(self, data: dict[str, Any]) -> None: # The generic private-API mutation path (``queue_update`` / # ``save_device``) does not know how to route patches to the Public # Integration endpoints; consumers must use the dedicated # ``update_siren_public`` / ``play`` / ``stop`` / ``test_sound`` # helpers instead. raise BadRequest( "Siren mutations must go through the dedicated public API helpers " "(e.g. play/stop/set_name/set_volume/set_status_light)." ) @property def is_active(self) -> bool: """ Whether the siren is currently playing. The server does not emit a stop event when the siren finishes its timed run, so ``sirenStatus.isActive`` in the WS payload stays ``True`` until the next update. ``turn_off_at`` is only set while the server flag is true and the timing fields are populated, so a clock check against it catches the timed-expiry case. A manual stop clears the server flag and ``turn_off_at`` becomes ``None``, falling back to the (now-false) server flag. """ turn_off_at = self.siren_status.turn_off_at if turn_off_at is not None: return datetime.now(UTC) < turn_off_at return self.siren_status.is_active async def play(self, duration: int | SirenDuration | None = None) -> None: """Play the siren. ``duration`` may be a supported integer or :class:`SirenDuration`; defaults to 5 seconds.""" await self._api.play_siren_public(self.id, duration=duration) async def stop(self) -> None: """Stop an active siren.""" await self._api.stop_siren_public(self.id) async def test_sound(self, volume: int | None = None) -> None: """Test the siren sound at the given volume.""" await self._api.test_siren_sound_public(self.id, volume=volume) async def set_name(self, name: str) -> Siren: return await self._api.update_siren_public(self.id, name=name) async def set_volume(self, volume: int) -> Siren: return await self._api.update_siren_public(self.id, volume=volume) async def set_status_light(self, enabled: bool) -> Siren: return await self._api.update_siren_public(self.id, led_is_enabled=enabled) class PublicRelayOutput(ProtectBaseObject): id: int name: str | None = None type: RelayOutputType | None = None delay: int | None = None pulse_duration: int | None = None state: RelayOutputState | None = None reboot_state: RelayOutputRebootState | None = None class PublicRelayInput(ProtectBaseObject): id: int name: str | None = None state: RelayInputState | None = None action_trigger: RelayInputActionTrigger | None = None action_type: RelayInputActionType | None = None action_output_id: int | None = None class Relay(PublicDeviceModel): """ Public API relay device. Use :meth:`__getitem__` (``relay[output_id]``) for "fail loud" lookup or :meth:`get_output` for the ``None``-returning variant. """ model: ModelType | None = ModelType.RELAY name: str led_settings: PublicLedSettings outputs: list[PublicRelayOutput] inputs: list[PublicRelayInput] wireless_connection_state: PublicWirelessConnectionState | None = None async def _api_update(self, data: dict[str, Any]) -> None: # See :meth:`Siren._api_update` — consumers must use # ``update_relay_public`` / ``activate_relay_output_public`` (or the # helper methods on this class). raise BadRequest( "Relay mutations must go through the dedicated public API helpers " "(e.g. activate_output/set_name/set_status_light)." ) def get_output(self, output_id: int) -> PublicRelayOutput | None: """Return the output with the given id, or ``None`` if not found.""" for out in self.outputs: if out.id == output_id: return out return None def __getitem__(self, output_id: int) -> PublicRelayOutput: """Return the output with the given id; raises :class:`KeyError` if unknown.""" if (out := self.get_output(output_id)) is None: raise KeyError(output_id) return out async def activate_output( self, output_id: int, state: Literal["on", "off"] | None = None, pulse_duration_ms: int | None = None, ) -> None: """Activate / toggle a relay output channel.""" await self._api.activate_relay_output_public( self.id, output_id, state=state, pulse_duration_ms=pulse_duration_ms, ) async def set_name(self, name: str) -> Relay: return await self._api.update_relay_public(self.id, name=name) async def set_status_light(self, enabled: bool) -> Relay: return await self._api.update_relay_public(self.id, led_is_enabled=enabled) class PublicFobFeatureFlags(ProtectBaseObject): # ``FobButton`` carries an ``unknown`` member, so button kinds added by # newer firmware coerce to ``FobButton.UNKNOWN`` instead of raising. buttons: list[FobButton] class Fob(PublicDeviceModel): """Public API key fob device.""" model: ModelType | None = ModelType.FOB # Nullable on the wire and in WS partial-updates. name: str | None = None # ``FobAwayState`` carries an ``unknown`` member, so values added by newer # firmware coerce to the ``UNKNOWN`` member rather than raising. away_state: FobAwayState button_labels: FobButtonLabels feature_flags: PublicFobFeatureFlags # Required by the spec — a fob is always a wireless battery device. wireless_connection_state: PublicWirelessConnectionState class PublicSpeakerFeatureFlags(ProtectBaseObject): has_mic: bool class PublicSpeakerState(ProtectBaseObject): # ``SpeakerStatus`` / ``SpeakerMode`` carry an ``unknown`` member, so values # added by newer firmware coerce to ``UNKNOWN`` instead of raising. status: SpeakerStatus mode: SpeakerMode class Speaker(PublicDeviceModel): """Public API speaker device.""" model: ModelType | None = ModelType.SPEAKER # Nullable on the wire and in WS partial-updates. name: str | None = None volume: int mic_volume: int is_mic_enabled: bool speaker_state: PublicSpeakerState feature_flags: PublicSpeakerFeatureFlags async def _api_update(self, data: dict[str, Any]) -> None: # See :meth:`Siren._api_update` — consumers must use # ``update_speaker_public`` / ``test_speaker_sound_public`` (or the # helper methods on this class). raise BadRequest( "Speaker mutations must go through the dedicated public API helpers " "(e.g. set_name/set_volume/set_mic_volume/set_mic_enabled/test_sound)." ) async def set_name(self, name: str) -> Speaker: return await self._api.update_speaker_public(self.id, name=name) async def set_volume(self, volume: int) -> Speaker: return await self._api.update_speaker_public(self.id, volume=volume) async def set_mic_volume(self, mic_volume: int) -> Speaker: return await self._api.update_speaker_public(self.id, mic_volume=mic_volume) async def set_mic_enabled(self, enabled: bool) -> Speaker: return await self._api.update_speaker_public(self.id, is_mic_enabled=enabled) async def test_sound(self, volume: int | None = None) -> None: """Test the speaker sound at the given volume.""" await self._api.test_speaker_sound_public(self.id, volume=volume) class AlarmHubBattery(ProtectBaseObject): """ Backup battery status of an alarm hub (``alarmHub.battery`` sub-schema). The ``battery`` object declares no ``required`` array in the OpenAPI spec, and a real hub with the backup battery disconnected emits only ``connection`` (omitting ``charging``/``voltage``/``batteryStatus``), so every field is optional. """ charging: OnOffState | None = None connection: AlarmHubConnectionState | None = None voltage: float | None = None battery_status: AlarmHubBatteryStatus | None = None class AlarmHubCover(ProtectBaseObject): """ Tamper cover status of an alarm hub (``alarmHub.cover`` sub-schema). The ``cover`` object declares no ``required`` array in the OpenAPI spec, so both fields are optional. """ status: AlarmHubCoverStatus | None = None distance: int | None = None class AlarmHubInput(ProtectBaseObject): """A single alarm-hub input zone (``alarmHub.input[]`` sub-schema).""" enable: OnOffState type: AlarmHubInputContactType status: AlarmHubInputStatus name: str | None = None # ``inputType`` is the categorical zone kind (motion/entry/smoke/...). # ``AlarmHubInputType`` carries an ``UNKNOWN`` member, so values added # by newer firmware coerce to ``UNKNOWN`` instead of raising. input_type: AlarmHubInputType | None = None last_triggered_at: int | None = None camera_id: str | None = None @property def last_triggered_at_dt(self) -> datetime | None: """``last_triggered_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.last_triggered_at) class AlarmHubOutput(ProtectBaseObject): """A single alarm-hub output channel (``alarmHub.output[]`` sub-schema).""" active: OnOffState enable: OnOffState status: AlarmHubOutputStatus name: str | None = None delay: int | None = None duration: int | None = None class LinkStation(PublicDeviceModel): """ Public API link station / alarm hub. A single wire schema (``modelKey: "linkstation"``) covers both the ``/v1/link-stations`` and ``/v1/alarm-hubs`` endpoints. The :attr:`is_alarm_hub` flag distinguishes the two; ``alarm_hub`` is only populated when :attr:`is_alarm_hub` is ``True``. """ model: ModelType | None = ModelType.LINK_STATION # Nullable on the wire (spec: ``oneOf [string, null]``). name: str | None = None is_alarm_hub: bool led_settings: PublicLedSettings # Top-level nullable timestamp of the last event, NOT an Event object. # Sent on the wire as epoch-ms; converted to ``datetime`` via # ``unifi_dict_conversions`` to match every other Protect timestamp field. last_event: datetime | None = None # Retained as an opaque dict so the electrical sub-sections # (``connector``, ``*MeterStatus``, ``*TerminalStatus``, ``buckboost``, # ...) — whose keys are not valid Python identifiers (``"12v"``, ``"+"``, # ``"-"``) and which the OpenAPI spec itself models as # ``additionalProperties`` maps — survive untouched alongside any # unmodeled / future top-level keys. The well-formed slice # (``armed``/``battery``/``cover``/``input``/``output``) is exposed via # the typed accessors on this class (``alarm_hub_armed``, # ``alarm_hub_battery``, ``alarm_hub_cover``, ``alarm_hub_inputs``, # ``alarm_hub_outputs``). alarm_hub: dict[str, Any] | None = None @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: """Parse the ``lastEvent`` epoch-ms wire value into ``datetime``.""" return {"lastEvent": convert_to_datetime} | super().unifi_dict_conversions() async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "LinkStation mutations must go through the dedicated public API helpers " "(update_link_station_public / update_alarm_hub_public / " "trigger_alarm_hub_output_public)." ) async def set_name(self, name: str) -> LinkStation: """Rename via the matching endpoint for the device's role.""" if self.is_alarm_hub: return await self._api.update_alarm_hub_public(self.id, name=name) return await self._api.update_link_station_public(self.id, name=name) async def trigger_output( self, output_id: int, *, enable: bool | None = None, delay: int | None = None, duration: int | None = None, ) -> None: """Trigger an alarm-hub output channel. Raises if this is not an alarm hub.""" if not self.is_alarm_hub: raise BadRequest("Not an alarm hub") await self._api.trigger_alarm_hub_output_public( self.id, output_id, enable=enable, delay=delay, duration=duration, ) # Re-derived from ``alarm_hub`` on every access so they stay live against # WS updates (which mutate the stored dict in place). The electrical # sub-sections (``connector``, ``*MeterStatus``, ``*TerminalStatus``, # ``buckboost``, ...) remain accessible via the raw ``alarm_hub`` dict. @property def alarm_hub_armed(self) -> OnOffState | None: """Return the typed armed flag, or ``None`` when the field is absent.""" if not self.is_alarm_hub or self.alarm_hub is None: return None armed = self.alarm_hub.get("armed") if not isinstance(armed, str): return None return OnOffState(armed) @property def alarm_hub_battery(self) -> AlarmHubBattery | None: """Return the typed backup-battery status, or ``None`` when absent.""" if not self.is_alarm_hub or self.alarm_hub is None: return None battery = self.alarm_hub.get("battery") if not isinstance(battery, dict): return None return AlarmHubBattery.from_unifi_dict(**battery) @property def alarm_hub_cover(self) -> AlarmHubCover | None: """Return the typed tamper-cover status, or ``None`` when absent.""" if not self.is_alarm_hub or self.alarm_hub is None: return None cover = self.alarm_hub.get("cover") if not isinstance(cover, dict): return None return AlarmHubCover.from_unifi_dict(**cover) @property def alarm_hub_inputs(self) -> dict[int, AlarmHubInput]: """Return input zones keyed by numeric id; non-integer keys are skipped.""" if not self.is_alarm_hub or self.alarm_hub is None: return {} raw = self.alarm_hub.get("input") if not isinstance(raw, dict): return {} return { int(key): AlarmHubInput.from_unifi_dict(**value) for key, value in raw.items() if isinstance(key, str) and key.isdigit() and isinstance(value, dict) } @property def alarm_hub_outputs(self) -> dict[int, AlarmHubOutput]: """Return output channels keyed by numeric id; non-integer keys are skipped.""" if not self.is_alarm_hub or self.alarm_hub is None: return {} raw = self.alarm_hub.get("output") if not isinstance(raw, dict): return {} return { int(key): AlarmHubOutput.from_unifi_dict(**value) for key, value in raw.items() if isinstance(key, str) and key.isdigit() and isinstance(value, dict) } class PublicArmSchedule(ProtectBaseObject): start: str end: str class ArmProfile(ProtectBaseObject): """ Public API arm profile (local alarm manager). Arm profiles are configuration objects, not devices — the ``/v1/arm-profiles`` payload has no ``modelKey``. The class therefore inherits directly from :class:`ProtectBaseObject` and carries its own ``id`` field. """ id: str name: str automations: list[str] schedules: list[PublicArmSchedule] record_everything: bool activation_delay: int creator: str | None = None created_at: datetime | None = None updated_at: datetime | None = None class NvrArmMode(ProtectBaseObject): """ Current arm-manager state embedded in the NVR object (``armMode`` field). Returned by ``GET /v1/nvrs`` as part of the NVR payload. All fields that may legitimately be ``null`` on the wire are typed as ``Optional`` for forward-compatibility. """ status: NvrArmModeStatus arm_profile_id: str | None = None armed_at: int | None = None will_be_armed_at: int | None = None breach_detected_at: int | None = None breach_event_count: int = 0 breach_trigger_event_id: str | None = None breach_event_id: str | None = None @property def armed_at_dt(self) -> datetime | None: """``armed_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.armed_at) @property def will_be_armed_at_dt(self) -> datetime | None: """``will_be_armed_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.will_be_armed_at) @property def breach_detected_at_dt(self) -> datetime | None: """``breach_detected_at`` as a timezone-aware UTC ``datetime``.""" return convert_to_datetime(self.breach_detected_at) class PublicDoorbellCustomImage(ProtectBaseObject): """A custom doorbell image entry (preview GIF + full sprite PNG).""" preview: str sprite: str class PublicDoorbellSettings(ProtectBaseObject): """ Doorbell settings exposed by the Public Integration API (``GET /v1/nvrs``). Intentionally separate from the private :class:`~uiprotect.data.nvr.DoorbellSettings` which carries additional private-API-only fields (``allMessages``, typed timedelta, etc.). All fields have defaults because the ``doorbellSettings`` OpenAPI schema declares no ``required`` array — fields may be absent on older firmware or in partial WS update diffs. Read-only: the Public Integration API exposes ``GET /v1/nvrs`` but no ``PATCH`` counterpart (nor a dedicated doorbell-message endpoint), so there is no public write path for these settings. Confirmed absent through Protect ``7.1.87``; changing the default message still requires the private API (:class:`~uiprotect.data.nvr.DoorbellSettings`). """ default_message_text: str = "" default_message_reset_timeout_ms: int = 0 custom_messages: list[str] = Field(default_factory=list) custom_images: list[PublicDoorbellCustomImage] = Field(default_factory=list) class PublicNVR(PublicIdentifiedModel): """ NVR device as exposed by the Public Integration API (``GET /v1/nvrs``). This model reflects the public schema: ``id``, ``modelKey``, ``name``, ``doorbellSettings``, and optionally ``armMode`` / ``mac``. ``name`` is nullable — the API schema declares it as ``oneOf: [string, null]``. ``mac`` is exposed on ``GET /v1/nvrs`` from Protect newer than 7.1 and is ``None`` on older firmware that omits the key (and absent from WS partial-update diffs). ``doorbell_settings`` is ``None`` on older firmware that does not yet expose the ``doorbellSettings`` key, and is absent from WS partial-update diffs (which only require ``id`` + ``modelKey``). ``arm_mode`` is ``None`` when the firmware does not yet expose the alarm manager (older releases) and also ``None`` when the alarm manager is set to global (server returns ``armMode: null``). WS device-update diffs that include ``armMode`` are handled automatically by :meth:`~uiprotect.data.base.ProtectBaseObject.update_from_dict` without any manual extraction. """ model: ModelType | None = ModelType.NVR name: str | None = None mac: str | None = None doorbell_settings: PublicDoorbellSettings | None = None arm_mode: NvrArmMode | None = None class PublicLiveviewSlot(ProtectBaseObject): """One slot in a public-API liveview (read shape).""" camera_ids: list[str] # ``LiveviewCycleMode`` carries an ``unknown`` member, so values added by # newer firmware coerce to ``UNKNOWN`` instead of raising. cycle_mode: LiveviewCycleMode cycle_interval: int @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "cameras": "cameraIds"} class PublicLiveview(ProtectModelWithId): """ Public API liveview. ``owner`` is a flat ``userId`` string (the spec types it as ``$ref: userId``, which is ``type: string``) — not an embedded user object. ``layout`` is the number of slots the liveview contains (spec: number, 1..26); the field name matches the wire key exactly. """ model: ModelType | None = ModelType.LIVEVIEW name: str is_default: bool is_global: bool owner: str layout: int slots: list[PublicLiveviewSlot] async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "Liveview mutations must go through the dedicated public API helpers " "(create_liveview_public / update_liveview_public)." ) class PublicBridge(PublicDeviceModel): """ Public API bridge device. ``ModelType.BRIDGE`` is already owned by the private :class:`Bridge` class in ``MODEL_TO_CLASS``; this public counterpart is routed via a dedicated factory on :class:`~uiprotect.data.public_bootstrap.PublicBootstrap` instead. """ model: ModelType | None = ModelType.BRIDGE name: str | None = None # ``bridgePlatform`` is typed ``[string, null]`` in the spec. platform: str | None = None clients: list[str] max_clients: int async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "Bridge mutations must go through the dedicated public API helpers " "(update_bridge_public)." ) async def set_name(self, name: str) -> PublicBridge: return await self._api.update_bridge_public(self.id, name=name) class PublicViewer(PublicDeviceModel): """ Public API viewer device. ``ModelType.VIEWPORT`` is already owned by the private :class:`Viewer` class in ``MODEL_TO_CLASS``; this public counterpart is routed via a dedicated factory on :class:`~uiprotect.data.public_bootstrap.PublicBootstrap` instead. The wire field ``liveview`` is a flat ``liveviewId`` string (nullable); snake-cased to :attr:`liveview_id` via :meth:`_get_unifi_remaps`. """ model: ModelType | None = ModelType.VIEWPORT name: str | None = None liveview_id: str | None = None stream_limit: int @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "liveview": "liveviewId"} async def _api_update(self, data: dict[str, Any]) -> None: raise BadRequest( "Viewer mutations must go through the dedicated public API helpers " "(update_viewer_public)." ) async def set_name(self, name: str) -> PublicViewer: return await self._api.update_viewer_public(self.id, name=name) async def set_liveview(self, liveview_id: str | None) -> PublicViewer: return await self._api.update_viewer_public(self.id, liveview=liveview_id) class PublicUser(ProtectModelWithId): """Public API Protect user (read-only).""" model: ModelType | None = ModelType.USER name: str # ``firstName``/``lastName``/``email``/``ucoreUserId`` are spec-``required`` # but typed ``oneOf [string, null]`` — the server returns the key but the # value may be ``null``. Pydantic field types are therefore ``str | None``. first_name: str | None = None last_name: str | None = None email: str | None = None ucore_user_id: str | None = None class PublicUlpUser(ProtectModelWithId): """Public API UniFi Identity (ULP) user (read-only).""" model: ModelType | None = ModelType.ULP_USER first_name: str last_name: str full_name: str # Spec-required and non-nullable — an empty string when UniFi Identity # holds no address — but ``sample_ulp_users.json``, captured from a real # console, carries no ``email`` key at all, so older firmware omits it and # the field stays optional. email: str | None = None status: UlpUserStatus class PublicPosTransactionResponse(ProtectBaseObject): """Result of ingesting a POS transaction for a camera.""" created: bool event_id: str | None = None class PublicFile(ProtectBaseObject): """Public API device asset file (``/v1/files/{fileType}``).""" name: str type: AssetFileType path: str original_name: str | None = None uilibs-uiprotect-317a52c/src/uiprotect/data/public_event.py000066400000000000000000000161411524637705700241360ustar00rootroot00000000000000""" Public Integration API event model. Parsed 1:1 from the public ``event`` payload (the ``oneOf`` over the ``*Event`` schemas). This is the source of truth for the public events path; it deliberately shares no structure with the private :class:`~uiprotect.data.nvr.Event` / ``EventMetadata`` models so the two paths can diverge as their respective specs do. Metadata is a single model of all-optional, strongly-typed fields — the union across every public event type. The ``{"text": }`` (and the lone ``{"number": }``) envelopes the spec wraps each metadata value in are collapsed to the inner value on parse and re-wrapped on serialise, mirroring the private ``EventMetadata`` shape without importing it. """ from __future__ import annotations from collections.abc import Callable from datetime import datetime from functools import cache from typing import Any, ClassVar from pydantic import Field from ..utils import convert_smart_types, convert_to_datetime from .base import ProtectBaseObject, ProtectModelWithId from .types import ( EventButtonType, EventType, ModelType, MountType, RelayInputCircuitState, SensorAlarmType, SensorExtremeMetricType, SensorStatusType, SmartDetectObjectType, SmokeTestSource, ) # Every ``type`` the public ``event`` union can carry. ``EventType`` is a # superset — it also carries private-API-only events — so this is the subset a # public-API consumer can actually see. ``scripts/validate_spec.py`` pins it # against the spec's ``event`` ``oneOf`` discriminator. PUBLIC_EVENT_TYPES: frozenset[EventType] = frozenset( { EventType.RING, EventType.SENSOR_EXTREME_VALUE, EventType.SENSOR_WATER_LEAK, EventType.SENSOR_TAMPER, EventType.SENSOR_BATTERY_LOW, EventType.SENSOR_ALARM, EventType.SENSOR_VAPE, EventType.SENSOR_OPENED, EventType.SENSOR_CLOSED, EventType.SENSOR_SMOKE_TEST, EventType.SENSOR_SMOKE_BATTERY_LOW, EventType.SENSOR_SMOKE_NEEDS_CLEANING, EventType.SENSOR_SMOKE_FAULT, EventType.SENSOR_CO_FAULT, EventType.SENSOR_SMOKE_END_OF_LIFE, EventType.MOTION_SENSOR, EventType.SENSOR_BUTTON_PRESSED, EventType.MOTION_LIGHT, EventType.MOTION, EventType.SMART_AUDIO_DETECT, EventType.SMART_DETECT, EventType.SMART_DETECT_LINE, EventType.SMART_DETECT_LOITER, EventType.RELAY_INPUT_CHANGED, EventType.CAMERA_DIGITAL_INPUT_CHANGED, EventType.ALARM_HUB_MOTION, EventType.ALARM_HUB_ENTRY_OPENED, EventType.ALARM_HUB_ENTRY_CLOSED, EventType.ALARM_HUB_SMOKE, EventType.ALARM_HUB_GLASS_BREAK, EventType.ALARM_HUB_BUTTON_PRESS, EventType.ALARM_HUB_TAMPER, EventType.ALARM_HUB_DEVICE_TAMPER, EventType.ALARM_HUB_RELAY_SWITCHED, EventType.ALARM_HUB_BATTERY_LOW, EventType.ALARM_HUB_BATTERY_CONNECTED, EventType.NFC_CARD_SCANNED, EventType.FINGERPRINT_IDENTIFIED, } ) class PublicNfcMetadata(ProtectBaseObject): """``nfc`` block of an ``nfcCardScanned`` event (public spec).""" ulp_id: str | None = None class PublicFingerprintMetadata(ProtectBaseObject): """``fingerprint`` block of a ``fingerprintIdentified`` event (public spec).""" ulp_id: str | None = None class PublicEventMetadata(ProtectBaseObject): """Union of every public ``*Event`` metadata field, all optional.""" # ``{"text": }`` envelopes. sensor_type: SensorExtremeMetricType | None = None sensor_value: float | None = None status: SensorStatusType | None = None sensor_mount_type: MountType | None = None alarm_type: SensorAlarmType | None = None button: EventButtonType | None = None input_state: RelayInputCircuitState | None = None input_token: str | None = None input_channel: str | None = None pin: str | None = None device_id: str | None = None device_name: str | None = None # ``{"number": }`` envelope. sensor_battery_percentage: float | None = None # Flat string (no envelope). source: SmokeTestSource | None = None # Nested identity holders (public-only, never imported from ``nvr.py``). nfc: PublicNfcMetadata | None = None fingerprint: PublicFingerprintMetadata | None = None # Wire keys carrying a ``{"text": ...}`` envelope around their value. _text_keys: ClassVar[set[str]] = { "sensorType", "sensorValue", "status", "sensorMountType", "alarmType", "button", "inputState", "inputToken", "inputChannel", "pin", "deviceId", "deviceName", } # Wire keys carrying a ``{"number": ...}`` envelope around their value. _number_keys: ClassVar[set[str]] = {"sensorBatteryPercentage"} @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: """Collapse the ``{text|number: value}`` metadata envelopes to inner values.""" for inner_key, wire_keys in ( ("text", cls._text_keys), ("number", cls._number_keys), ): for wire_key in wire_keys.intersection(data): value = data[wire_key] if isinstance(value, dict): if inner_key in value: data[wire_key] = value[inner_key] else: del data[wire_key] return super().unifi_dict_to_dict(data) def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: """Serialise, dropping ``None`` fields and re-wrapping metadata envelopes.""" data = super().unifi_dict(data=data, exclude=exclude) for key, value in list(data.items()): if value is None: del data[key] for key in self._text_keys.intersection(data): data[key] = {"text": data[key]} for key in self._number_keys.intersection(data): data[key] = {"number": data[key]} return data class PublicEvent(ProtectModelWithId): """Event delivered over the Public Integration API events websocket.""" model: ModelType | None = ModelType.EVENT type: EventType start: datetime end: datetime | None = None # Public ``add`` payloads carry the originating device under ``device``. device_id: str | None = None smart_detect_types: list[SmartDetectObjectType] = Field(default_factory=list) metadata: PublicEventMetadata | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: """Remap the wire ``device`` key onto ``deviceId``.""" return {**super()._get_unifi_remaps(), "device": "deviceId"} @classmethod @cache def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]: """Parse the ``start`` / ``end`` wire timestamps into ``datetime``.""" return ( dict.fromkeys(("start", "end"), convert_to_datetime) | {"smartDetectTypes": convert_smart_types} | super().unifi_dict_conversions() ) uilibs-uiprotect-317a52c/src/uiprotect/data/types.py000066400000000000000000000741431524637705700226310ustar00rootroot00000000000000from __future__ import annotations import enum import logging import types from collections.abc import Callable, Coroutine, Sequence from functools import cache, lru_cache from typing import Annotated, Any, Literal, TypeVar, Union, get_args, get_origin from packaging.version import Version as BaseVersion from pydantic import BaseModel, Field from pydantic.types import StringConstraints from pydantic_extra_types.color import Color # noqa: F401 from .._compat import cached_property _LOGGER = logging.getLogger(__name__) KT = TypeVar("KT") VT = TypeVar("VT") @lru_cache(maxsize=512) def get_field_type(annotation: type[Any] | None) -> tuple[type | None, Any]: """Extract the origin and type from an annotation.""" if annotation is None: raise ValueError("Type annotation cannot be None") origin = get_origin(annotation) args: Sequence[Any] if origin in (list, set): if not (args := get_args(annotation)): raise ValueError(f"Unable to determine args of type: {annotation}") return origin, args[0] if origin is dict: if not (args := get_args(annotation)): raise ValueError(f"Unable to determine args of type: {annotation}") return origin, args[1] if origin is Annotated: if not (args := get_args(annotation)): raise ValueError(f"Unable to determine args of type: {annotation}") return None, args[0] if origin is Union or origin is types.UnionType: if not (args := get_args(annotation)): raise ValueError(f"Unable to determine args of type: {annotation}") args = [get_field_type(arg) for arg in args] if len(args) == 2 and type(None) in list(zip(*args, strict=False))[1]: # Strip '| None' type from Union return next(arg for arg in args if arg[1] is not type(None)) return None, annotation return origin, annotation DEFAULT: Literal["DEFAULT_VALUE"] = "DEFAULT_VALUE" DEFAULT_TYPE = Literal["DEFAULT_VALUE"] EventCategories = Literal[ "critical", "update", "admin", "ring", "motion", "smart", "iot", ] ProgressCallback = Callable[[int, int, int], Coroutine[Any, Any, None]] IteratorCallback = Callable[[int, bytes | None], Coroutine[Any, Any, None]] class FixSizeOrderedDict(dict[KT, VT]): """A fixed size ordered dict.""" def __init__(self, *args: Any, max_size: int = 0, **kwargs: Any) -> None: """Create the FixSizeOrderedDict.""" self._max_size = max_size super().__init__(*args, **kwargs) def __setitem__(self, key: KT, value: VT) -> None: """Set an update up to the max size.""" dict.__setitem__(self, key, value) if self._max_size > 0 and len(self) > 0 and len(self) > self._max_size: del self[next(iter(self))] class ValuesEnumMixin: _values: list[str] | None = None _values_normalized: dict[str, str] | None = None @classmethod @cache def from_string(cls, value: str) -> Any: return cls(value) # type: ignore[call-arg] @classmethod @cache def values(cls) -> list[str]: if cls._values is None: cls._values = [e.value for e in cls] # type: ignore[attr-defined] return cls._values @classmethod @cache def values_set(cls) -> set[str]: return set(cls.values()) @classmethod def _missing_(cls, value: Any) -> Any | None: if cls._values_normalized is None: cls._values_normalized = {e.value.lower(): e for e in cls} # type: ignore[attr-defined] value_normal = value if isinstance(value, str): value_normal = value.lower() return cls._values_normalized.get(value_normal) class UnknownValuesEnumMixin(ValuesEnumMixin): @classmethod def _missing_(cls, value: Any) -> Any | None: result = super()._missing_(value) if result is None: _LOGGER.debug("Unknown %s value: %s", cls.__name__, value) result = cls._values_normalized.get("unknown") # type: ignore[union-attr] return result @enum.unique class ModelType(UnknownValuesEnumMixin, enum.StrEnum): CAMERA = "camera" CLOUD_IDENTITY = "cloudIdentity" EVENT = "event" GROUP = "group" LIGHT = "light" LIVEVIEW = "liveview" NVR = "nvr" USER = "user" USER_LOCATION = "userLocation" VIEWPORT = "viewer" BRIDGE = "bridge" SENSOR = "sensor" SCHEDULE = "schedule" CHIME = "chime" AIPORT = "aiport" DEVICE_GROUP = "deviceGroup" RECORDING_SCHEDULE = "recordingSchedule" ULP_USER = "ulpUser" RINGTONE = "ringtone" KEYRING = "keyring" # Public API only. Intentionally NOT part of # `_bootstrap_model_types` so the private bootstrap path is unchanged. SIREN = "siren" RELAY = "relay" FOB = "fob" SPEAKER = "speaker" LINK_STATION = "linkstation" AI_PROCESSOR = "aiprocessor" UNKNOWN = "unknown" bootstrap_model_types: tuple[ModelType, ...] bootstrap_models: tuple[str, ...] bootstrap_models_set: set[str] bootstrap_models_types_set: set[ModelType] bootstrap_models_types_and_event_set: set[ModelType] @cached_property def devices_key(self) -> str: """Return the devices key.""" return f"{self.value}s" @cached_property def name(self) -> str: """Return the name.""" return self._name_ @cached_property def value(self) -> str: """Return the value.""" return self._value_ @classmethod @cache def from_string(cls, value: str) -> ModelType: return cls(value) @classmethod def _bootstrap_model_types(cls) -> tuple[ModelType, ...]: """Return the bootstrap models as a tuple.""" return ( ModelType.CAMERA, ModelType.USER, ModelType.GROUP, ModelType.LIVEVIEW, ModelType.VIEWPORT, ModelType.LIGHT, ModelType.BRIDGE, ModelType.SENSOR, ModelType.CHIME, ModelType.AIPORT, ) @classmethod def _bootstrap_models(cls) -> tuple[str, ...]: """Return the bootstrap models strings as a tuple.""" return tuple( model_type.value for model_type in ModelType._bootstrap_model_types() ) @classmethod def _bootstrap_models_set(cls) -> set[str]: """Return the set of bootstrap models strings as a set.""" return set(ModelType._bootstrap_models()) @classmethod def _bootstrap_models_types_set(cls) -> set[ModelType]: """Return the set of bootstrap models as a set.""" return set(ModelType._bootstrap_model_types()) @classmethod def _bootstrap_models_types_and_event_set(cls) -> set[ModelType]: """Return the set of bootstrap models and the event model as a set.""" return ModelType._bootstrap_models_types_set() | {ModelType.EVENT} def _immutable(self, name: str, value: Any) -> None: raise AttributeError("Cannot modify ModelType") ModelType.bootstrap_model_types = ModelType._bootstrap_model_types() ModelType.bootstrap_models = ModelType._bootstrap_models() ModelType.bootstrap_models_set = ModelType._bootstrap_models_set() ModelType.bootstrap_models_types_set = ModelType._bootstrap_models_types_set() ModelType.bootstrap_models_types_and_event_set = ( ModelType._bootstrap_models_types_and_event_set() ) ModelType.__setattr__ = ModelType._immutable # type: ignore[method-assign, assignment] @enum.unique class EventType(ValuesEnumMixin, enum.StrEnum): DISCONNECT = "disconnect" FACTORY_RESET = "factoryReset" PROVISION = "provision" UPDATE = "update" CAMERA_POWER_CYCLE = "cameraPowerCycling" RING = "ring" DOOR_ACCESS = "doorAccess" RESOLUTION_LOWERED = "resolutionLowered" POOR_CONNECTION = "poorConnection" STREAM_RECOVERY = "streamRecovery" MOTION = "motion" NFC_CARD_SCANNED = "nfcCardScanned" FINGERPRINT_IDENTIFIED = "fingerprintIdentified" RECORDING_DELETED = "recordingDeleted" SMART_AUDIO_DETECT = "smartAudioDetect" SMART_DETECT = "smartDetectZone" SMART_DETECT_LINE = "smartDetectLine" NO_SCHEDULE = "nonScheduledRecording" RECORDING_MODE_CHANGED = "recordingModeChanged" HOTPLUG = "hotplug" FACE_GROUP_DETECTED = "faceGroupDetected" CONSOLIDATED_RESOLUTION_LOWERED = "consolidatedResolutionLowered" CONSOLIDATED_POOR_CONNECTION = "consolidatedPoorConnection" CAMERA_CONNECTED = "cameraConnected" CAMERA_REBOOTED = "cameraRebooted" CAMERA_DISCONNECTED = "cameraDisconnected" INSTALLED_DISK = "installed" CORRUPTED_DB_RECOVERED = "corruptedDbRecovered" OFFLINE = "offline" OFF = "off" REBOOT = "reboot" FIRMWARE_UPDATE = "fwUpdate" APP_UPDATE = "applicationUpdate" APPLICATION_UPDATABLE = "applicationUpdatable" ACCESS = "access" DRIVE_FAILED = "driveFailed" CAMERA_UTILIZATION_LIMIT_REACHED = "cameraUtilizationLimitReached" CAMERA_UTILIZATION_LIMIT_EXCEEDED = "cameraUtilizationLimitExceeded" DRIVE_SLOW = "driveSlow" GLOBAL_RECORDING_MODE_CHANGED = "globalRecordingModeChanged" NVR_SETTINGS_CHANGED = "nvrSettingsChanged" UNADOPTED_DEVICE_DISCOVERED = "unadoptedDeviceDiscovered" MULTIPLE_UNADOPTED_DEVICE_DISCOVERED = "multipleUnadoptedDeviceDiscovered" DEVICE_ADOPTED = "deviceAdopted" DEVICE_UNADOPTED = "deviceUnadopted" UVF_DISCOVERED = "ufvDiscovered" DEVICE_PASSWORD_UPDATE = "devicesPasswordUpdated" # noqa: S105 DEVICE_UPDATABLE = "deviceUpdatable" MULTIPLE_DEVICE_UPDATABLE = "multipleDeviceUpdatable" DEVICE_CONNECTED = "deviceConnected" DEVICE_REBOOTED = "deviceRebooted" DEVICE_DISCONNECTED = "deviceDisconnected" NETWORK_DEVICE_OFFLINE = "networkDeviceOffline" USER_LEFT = "userLeft" USER_ARRIVED = "userArrived" VIDEO_EXPORTED = "videoExported" MIC_DISABLED = "microphoneDisabled" VIDEO_DELETED = "videoDeleted" SCHEDULE_CHANGED = "recordingScheduleChanged" MOTION_SENSOR = "sensorMotion" SENSOR_BUTTON_PRESSED = "sensorButtonPressed" SENSOR_OPENED = "sensorOpened" SENSOR_CLOSED = "sensorClosed" SENSOR_ALARM = "sensorAlarm" SENSOR_EXTREME_VALUE = "sensorExtremeValues" SENSOR_WATER_LEAK = "sensorWaterLeak" SENSOR_BATTERY_LOW = "sensorBatteryLow" SENSOR_SMOKE_TEST = "sensorSmokeTest" SENSOR_TAMPER = "sensorTamper" SENSOR_VAPE = "sensorVape" SENSOR_SMOKE_BATTERY_LOW = "sensorSmokeBatteryLow" SENSOR_SMOKE_NEEDS_CLEANING = "sensorSmokeNeedsCleaning" SENSOR_SMOKE_FAULT = "sensorSmokeFault" SENSOR_CO_FAULT = "sensorCoFault" SENSOR_SMOKE_END_OF_LIFE = "sensorSmokeEndOfLife" RELAY_INPUT_CHANGED = "relayInputChanged" CAMERA_DIGITAL_INPUT_CHANGED = "cameraDigitalInputChanged" ALARM_HUB_MOTION = "alarmHubMotion" ALARM_HUB_ENTRY_OPENED = "alarmHubEntryOpened" ALARM_HUB_ENTRY_CLOSED = "alarmHubEntryClosed" ALARM_HUB_RELAY_SWITCHED = "alarmHubRelaySwitched" ALARM_HUB_BUTTON_PRESS = "alarmHubButtonPress" ALARM_HUB_SMOKE = "alarmHubSmoke" ALARM_HUB_GLASS_BREAK = "alarmHubGlassBreak" ALARM_HUB_TAMPER = "alarmHubTamper" ALARM_HUB_DEVICE_TAMPER = "alarmHubDeviceTamper" ALARM_HUB_BATTERY_CONNECTED = "alarmHubBatteryConnected" ALARM_HUB_BATTERY_LOW = "alarmHubBatteryLow" SMART_DETECT_LOITER = "smartDetectLoiterZone" MOTION_LIGHT = "lightMotion" DISRUPTED_CONDITIONS = "ringDisruptedConditions" RECORDING_OFF = "recordingOff" @staticmethod @cache def device_events() -> list[str]: return [ EventType.MOTION.value, EventType.NFC_CARD_SCANNED.value, EventType.FINGERPRINT_IDENTIFIED.value, EventType.RING.value, EventType.SMART_DETECT.value, EventType.SMART_AUDIO_DETECT.value, EventType.SMART_DETECT_LINE.value, ] @staticmethod @cache def device_events_set() -> set[str]: return set(EventType.device_events()) @staticmethod @cache def motion_events() -> list[str]: return [EventType.MOTION.value, EventType.SMART_DETECT.value] @enum.unique class StateType(ValuesEnumMixin, enum.StrEnum): CONNECTED = "CONNECTED" CONNECTING = "CONNECTING" DISCONNECTED = "DISCONNECTED" @enum.unique class ProtectWSPayloadFormat(int, enum.Enum): """Websocket Payload formats.""" JSON = 1 UTF8String = 2 NodeBuffer = 3 @enum.unique class SmartDetectObjectType(ValuesEnumMixin, enum.StrEnum): PERSON = "person" ANIMAL = "animal" VEHICLE = "vehicle" LICENSE_PLATE = "licensePlate" PACKAGE = "package" SMOKE = "alrmSmoke" CMONX = "alrmCmonx" SIREN = "alrmSiren" BABY_CRY = "alrmBabyCry" SPEAK = "alrmSpeak" BARK = "alrmBark" BURGLAR = "alrmBurglar" CAR_HORN = "alrmCarHorn" GLASS_BREAK = "alrmGlassBreak" FACE = "face" # old? CAR = "car" PET = "pet" @cached_property def audio_type(self) -> SmartDetectAudioType | None: return OBJECT_TO_AUDIO_MAP.get(self) @enum.unique class SmartDetectAudioType(ValuesEnumMixin, enum.StrEnum): SMOKE = "alrmSmoke" CMONX = "alrmCmonx" SMOKE_CMONX = "smoke_cmonx" SIREN = "alrmSiren" BABY_CRY = "alrmBabyCry" SPEAK = "alrmSpeak" BARK = "alrmBark" BURGLAR = "alrmBurglar" CAR_HORN = "alrmCarHorn" GLASS_BREAK = "alrmGlassBreak" @enum.unique class DetectionColor(ValuesEnumMixin, enum.StrEnum): BLACK = "black" BLUE = "blue" BROWN = "brown" GRAY = "gray" GREEN = "green" ORANGE = "orange" PINK = "pink" PURPLE = "purple" RED = "red" WHITE = "white" YELLOW = "yellow" OBJECT_TO_AUDIO_MAP = { SmartDetectObjectType.SMOKE: SmartDetectAudioType.SMOKE, SmartDetectObjectType.CMONX: SmartDetectAudioType.CMONX, SmartDetectObjectType.SIREN: SmartDetectAudioType.SIREN, SmartDetectObjectType.BABY_CRY: SmartDetectAudioType.BABY_CRY, SmartDetectObjectType.SPEAK: SmartDetectAudioType.SPEAK, SmartDetectObjectType.BARK: SmartDetectAudioType.BARK, SmartDetectObjectType.BURGLAR: SmartDetectAudioType.BURGLAR, SmartDetectObjectType.CAR_HORN: SmartDetectAudioType.CAR_HORN, SmartDetectObjectType.GLASS_BREAK: SmartDetectAudioType.GLASS_BREAK, } @enum.unique class DoorbellMessageType(ValuesEnumMixin, enum.StrEnum): LEAVE_PACKAGE_AT_DOOR = "LEAVE_PACKAGE_AT_DOOR" DO_NOT_DISTURB = "DO_NOT_DISTURB" CUSTOM_MESSAGE = "CUSTOM_MESSAGE" IMAGE = "IMAGE" @enum.unique class LightModeEnableType(ValuesEnumMixin, enum.StrEnum): DARK = "dark" ALWAYS = "fulltime" NIGHT = "night" @enum.unique class LightModeType(ValuesEnumMixin, enum.StrEnum): MOTION = "motion" WHEN_DARK = "always" MANUAL = "off" SCHEDULE = "schedule" @enum.unique class VideoMode(UnknownValuesEnumMixin, enum.StrEnum): """Camera video pipeline mode; unknown wire values coerce to ``UNKNOWN``.""" DEFAULT = "default" HIGH_FPS = "highFps" HOMEKIT = "homekit" SPORT = "sport" SLOW_SHUTTER = "slowShutter" LPR_NONE_REFLEX = "lprNoneReflex" LPR_REFLEX = "lprReflex" UNKNOWN = "unknown" @enum.unique class AudioStyle(UnknownValuesEnumMixin, enum.StrEnum): NATURE = "nature" NOISE_REDUCED = "noiseReduced" UNKNOWN = "unknown" @enum.unique class RecordingMode(ValuesEnumMixin, enum.StrEnum): ADAPTIVE = "adaptive" ALWAYS = "always" NEVER = "never" SCHEDULE = "schedule" DETECTIONS = "detections" @enum.unique class AnalyticsOption(ValuesEnumMixin, enum.StrEnum): NONE = "none" ANONYMOUS = "anonymous" FULL = "full" @enum.unique class RecordingType(ValuesEnumMixin, enum.StrEnum): TIMELAPSE = "timelapse" CONTINUOUS = "rotating" DETECTIONS = "detections" @enum.unique class ResolutionStorageType(ValuesEnumMixin, enum.StrEnum): UHD = "4K" HD = "HD" FREE = "free" @enum.unique class IRLEDMode(UnknownValuesEnumMixin, enum.StrEnum): AUTO = "auto" ON = "on" AUTO_NO_LED = "autoFilterOnly" OFF = "off" MANUAL = "manual" CUSTOM = "custom" CUSTOM_FILTER_ONLY = "customFilterOnly" UNKNOWN = "unknown" @enum.unique class MountType(UnknownValuesEnumMixin, enum.StrEnum): NONE = "none" LEAK = "leak" DOOR = "door" WINDOW = "window" GARAGE = "garage" UNKNOWN = "unknown" @enum.unique class SensorType(UnknownValuesEnumMixin, enum.StrEnum): TEMPERATURE = "temperature" LIGHT = "light" HUMIDITY = "humidity" UNKNOWN = "unknown" @enum.unique class SensorStatusType(UnknownValuesEnumMixin, enum.StrEnum): OFFLINE = "offline" UNKNOWN = "unknown" SAFE = "safe" NEUTRAL = "neutral" LOW = "low" HIGH = "high" @enum.unique class SensorScheduleMode(UnknownValuesEnumMixin, enum.StrEnum): ALWAYS = "always" WHEN_ARMED = "when_armed" UNKNOWN = "unknown" @enum.unique class SleepStateType(ValuesEnumMixin, enum.StrEnum): DISCONNECTED = "disconnected" AWAKE = "awake" START_SLEEP = "goingToSleep" ASLEEP = "asleep" WAKING = "waking" @enum.unique class AutoExposureMode(ValuesEnumMixin, enum.StrEnum): MANUAL = "manual" AUTO = "auto" NONE = "none" SHUTTER = "shutter" FLICK50 = "flick50" FLICK60 = "flick60" @enum.unique class FocusMode(ValuesEnumMixin, enum.StrEnum): MANUAL = "manual" AUTO = "auto" NONE = "none" ZTRIG = "ztrig" TOUCH = "touch" @enum.unique class MountPosition(UnknownValuesEnumMixin, enum.StrEnum): CEILING = "ceiling" WALL = "wall" DESK = "desk" NONE = "none" UNKNOWN = "unknown" @enum.unique class GeofencingSetting(ValuesEnumMixin, enum.StrEnum): OFF = "off" ALL_AWAY = "allAway" @enum.unique class MotionAlgorithm(ValuesEnumMixin, enum.StrEnum): STABLE = "stable" ENHANCED = "enhanced" @enum.unique class AudioCodecs(ValuesEnumMixin, enum.StrEnum): AAC = "aac" VORBIS = "vorbis" OPUS = "opus" @enum.unique class LowMedHigh(ValuesEnumMixin, enum.StrEnum): LOW = "low" MEDIUM = "medium" HIGH = "high" @enum.unique class StorageType(UnknownValuesEnumMixin, enum.StrEnum): DISK = "hdd" RAID = "raid" SD_CARD = "sdcard" INTERNAL_SSD = "internalSSD" UNKNOWN = "UNKNOWN" @enum.unique class FirmwareReleaseChannel(ValuesEnumMixin, enum.StrEnum): INTERNAL = "internal" ALPHA = "alpha" BETA = "beta" RELEASE_CANDIDATE = "release-candidate" RELEASE = "release" @enum.unique class ChimeType(int, enum.Enum): NONE = 0 MECHANICAL = 300 DIGITAL = 1000 @enum.unique class SirenDuration(int, enum.Enum): """Valid siren play durations in seconds (as accepted by the public API).""" FIVE = 5 TEN = 10 TWENTY = 20 THIRTY = 30 @enum.unique class SirenConnectionType(UnknownValuesEnumMixin, enum.StrEnum): """Public-API siren wireless connection type (``connectionType`` field).""" UCP4 = "ucp4" LORA = "lora" UNKNOWN = "unknown" @enum.unique class NvrArmModeStatus(UnknownValuesEnumMixin, enum.StrEnum): """Arm-manager status values embedded in the NVR ``armMode`` field.""" ARMING = "arming" ARMED = "armed" BREACH = "breach" DISABLED = "disabled" UNKNOWN = "unknown" @enum.unique class SensorAlarmType(UnknownValuesEnumMixin, enum.StrEnum): """Alarm sound of a ``sensorAlarm`` / ``alarmHub*`` event (``metadata.alarmType.text``).""" SMOKE = "smoke" CO = "CO" GLASS_BREAK = "glassBreak" SENSOR_BUTTON_PRESS = "sensorButtonPress" TAMPER = "tamper" SHORT = "short" CUT = "cut" UNKNOWN = "unknown" @enum.unique class SensorExtremeMetricType(UnknownValuesEnumMixin, enum.StrEnum): """Metric of a ``sensorExtremeValues`` event (``metadata.sensorType.text``).""" TEMPERATURE = "temperature" LIGHT = "light" HUMIDITY = "humidity" AQI = "aqi" VAPE = "vape" TVOC = "tvoc" PM1P0 = "pm1p0" PM2P5 = "pm2p5" PM4P0 = "pm4p0" PM10P0 = "pm10p0" NOX = "nox" CO2 = "co2" VOC = "voc" UNKNOWN = "unknown" @enum.unique class RelayInputCircuitState(UnknownValuesEnumMixin, enum.StrEnum): """Relay input circuit state (``relayInputChanged`` ``metadata.inputState.text``).""" CIRCUIT_CLOSED = "circuitClosed" CIRCUIT_OPEN = "circuitOpen" UNKNOWN = "unknown" @enum.unique class SmokeTestSource(UnknownValuesEnumMixin, enum.StrEnum): """Origin of a ``sensorSmokeTest`` event (flat ``metadata.source``).""" LOCAL = "local" REMOTE = "remote" UNKNOWN = "unknown" @enum.unique class EventButtonType(UnknownValuesEnumMixin, enum.StrEnum): """ Button of a ``sensorButtonPressed`` / ``alarmHubButtonPress`` event. Sourced from ``metadata.button.text``. """ FUNCTION = "function" ALARM_HUB_BUTTON = "alarmHubButton" ARM = "arm" DISARM = "disarm" NIGHT = "night" PANIC = "panic" LEFT = "left" RIGHT = "right" INPUT1 = "input1" INPUT2 = "input2" MAIN = "main" UNKNOWN = "unknown" @enum.unique class AlarmHubInputType(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub input zone kind (``alarmHub.input[].inputType`` field).""" MOTION = "MOTION" ENTRY = "ENTRY" SMOKE = "SMOKE" GLASS_BREAK = "GLASS_BREAK" EMERGENCY_BUTTON = "EMERGENCY_BUTTON" UNKNOWN = "unknown" @enum.unique class AlarmHubBatteryStatus(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub backup-battery health (``alarmHub.battery.batteryStatus`` field).""" OK = "ok" LOW = "low" CRITICAL = "critical" UNKNOWN = "unknown" @enum.unique class AlarmHubInputStatus(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub input zone status (``alarmHub.input[].status`` field).""" NORMAL = "normal" ALARM = "alarm" FAULT = "fault" SHORT = "short" CUT = "cut" UNKNOWN = "unknown" @enum.unique class AlarmHubCoverStatus(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub tamper-cover status (``alarmHub.cover.status`` field).""" OPEN = "open" CLOSE = "close" UNKNOWN = "unknown" @enum.unique class AlarmHubOutputStatus(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub output channel status (``alarmHub.output[].status`` field).""" WET = "wet" DRY = "dry" UNKNOWN = "unknown" @enum.unique class AlarmHubConnectionState(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub backup-battery wiring state (``alarmHub.battery.connection`` field).""" CONNECTED = "connected" DISCONNECTED = "disconnected" UNKNOWN = "unknown" @enum.unique class AlarmHubInputContactType(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub input contact wiring (``alarmHub.input[].type`` field).""" NO = "no" NC = "nc" UNKNOWN = "unknown" @enum.unique class AlarmHubTamperStatus(UnknownValuesEnumMixin, enum.StrEnum): """Alarm-hub tamper state (``alarmHubDeviceTamper`` ``metadata.status.text``).""" TAMPERED = "tampered" RESTORED = "restored" UNKNOWN = "unknown" @enum.unique class OnOffState(UnknownValuesEnumMixin, enum.StrEnum): """Generic on/off wire state used by several alarm-hub fields.""" ON = "on" OFF = "off" UNKNOWN = "unknown" @enum.unique class RelayOutputState(UnknownValuesEnumMixin, enum.StrEnum): """State of a single relay output channel (``state`` field).""" ON = "on" OFF = "off" OFF_OTP = "offOtp" UNKNOWN = "unknown" @enum.unique class RelayOutputRebootState(UnknownValuesEnumMixin, enum.StrEnum): """State the relay output is set to after a reboot/power cycle (``rebootState`` field).""" RESTORE = "restore" ON = "on" OFF = "off" UNKNOWN = "unknown" @enum.unique class RelayInputState(UnknownValuesEnumMixin, enum.StrEnum): """State of a single relay input channel.""" ON = "on" OFF = "off" UNKNOWN = "unknown" @enum.unique class RelayOutputType(UnknownValuesEnumMixin, enum.StrEnum): """Relay output device type (``outputs[].type`` field).""" GARAGE_DOOR = "garageDoor" GATE = "gate" VALVE = "valve" SIREN = "siren" CUSTOM = "custom" UNKNOWN = "unknown" @enum.unique class RelayInputActionTrigger(UnknownValuesEnumMixin, enum.StrEnum): """Relay input action trigger condition (``inputs[].actionTrigger`` field).""" SWITCHED_ON = "switchedOn" SWITCHED_OFF = "switchedOff" UNKNOWN = "unknown" @enum.unique class RelayInputActionType(UnknownValuesEnumMixin, enum.StrEnum): """Relay input action effect (``inputs[].actionType`` field).""" SET_OUTPUT_ON = "setOutputOn" SET_OUTPUT_OFF = "setOutputOff" TOGGLE_OUTPUT = "toggleOutput" FOLLOW_INPUT = "followInput" UNKNOWN = "unknown" @enum.unique class DeviceState(UnknownValuesEnumMixin, enum.StrEnum): """Public-API device connection state (``state`` field).""" CONNECTED = "CONNECTED" CONNECTING = "CONNECTING" DISCONNECTED = "DISCONNECTED" UNKNOWN = "unknown" @enum.unique class UlpUserStatus(UnknownValuesEnumMixin, enum.StrEnum): """Public-API UniFi Identity (ULP) user status (``status`` field).""" ACTIVE = "ACTIVE" DEACTIVATED = "DEACTIVATED" UNKNOWN = "unknown" @enum.unique class ChannelQuality(UnknownValuesEnumMixin, enum.StrEnum): """RTSPS channel quality (``qualities`` field on rtsps-stream endpoints).""" HIGH = "high" MEDIUM = "medium" LOW = "low" PACKAGE = "package" UNKNOWN = "unknown" @enum.unique class FobAwayState(UnknownValuesEnumMixin, enum.StrEnum): """Key-fob presence/away state (``awayState`` field).""" ONLINE = "ONLINE" RECENTLY_SEEN = "RECENTLY_SEEN" NO_RECENT_HEARTBEAT = "NO_RECENT_HEARTBEAT" DEVICE_LOST = "DEVICE_LOST" UNKNOWN = "unknown" @enum.unique class FobButton(UnknownValuesEnumMixin, enum.StrEnum): """Key-fob button kinds (``featureFlags.buttons`` items).""" FUNCTION = "function" ALARM_HUB_BUTTON = "alarmHubButton" ARM = "arm" DISARM = "disarm" NIGHT = "night" PANIC = "panic" LEFT = "left" RIGHT = "right" INPUT1 = "input1" INPUT2 = "input2" MAIN = "main" UNKNOWN = "unknown" @enum.unique class FobButtonLabels(UnknownValuesEnumMixin, enum.StrEnum): """Label style used when rendering a fob in button lists (``buttonLabels``).""" SECURITY_ACTIONS = "securityActions" POSITION_HINT = "positionHint" UNKNOWN = "unknown" @enum.unique class SpeakerStatus(UnknownValuesEnumMixin, enum.StrEnum): """Public-API speaker runtime status (``speakerState.status`` field).""" IDLE = "idle" STREAMING = "streaming" PLAYING = "playing" TTS_PLAYING = "tts_playing" UPLOADING = "uploading" UNKNOWN = "unknown" @enum.unique class SpeakerMode(UnknownValuesEnumMixin, enum.StrEnum): """Public-API speaker mode (``speakerState.mode`` field).""" LISTEN = "listen" TALK = "talk" UNKNOWN = "unknown" @enum.unique class LiveviewCycleMode(UnknownValuesEnumMixin, enum.StrEnum): """Public-API liveview slot cycle mode (``slots[].cycleMode`` field).""" MOTION = "motion" TIME = "time" UNKNOWN = "unknown" @enum.unique class PermissionNode(UnknownValuesEnumMixin, enum.StrEnum): CREATE = "create" READ = "read" WRITE = "write" DELETE = "delete" READ_MEDIA = "readmedia" DELETE_MEDIA = "deletemedia" READ_LIVE = "readlive" UNKNOWN = "unknown" @cached_property def name(self) -> str: """Return the name.""" return self._name_ @cached_property def value(self) -> str: """Return the value.""" return self._value_ @enum.unique class HDRMode(UnknownValuesEnumMixin, enum.StrEnum): NONE = "none" NORMAL = "normal" ALWAYS_ON = "superHdr" UNKNOWN = "unknown" @enum.unique class PublicHdrMode(enum.StrEnum): AUTO = "auto" ON = "on" OFF = "off" @enum.unique class AssetFileType(UnknownValuesEnumMixin, enum.StrEnum): """Public-API device asset file kind (``/v1/files/{fileType}``).""" ANIMATIONS = "animations" UNKNOWN = "unknown" @enum.unique class PosTransactionType(enum.StrEnum): """Kind of point-of-sale transaction ingested for a camera.""" SALE = "sale" REFUND = "refund" @enum.unique class LensType(enum.StrEnum): NONE = "none" FULL_360 = "360" WIDE = "wide" TELESCOPIC = "tele" DLSR_17 = "m43" @enum.unique class OsdOverlayLocation(UnknownValuesEnumMixin, enum.StrEnum): """ On-screen-display overlay location (``osdSettings.overlayLocation``). Carries an ``UNKNOWN`` member so values added by newer firmware coerce to ``UNKNOWN`` instead of raising during ``Camera`` deserialization. """ TOP_LEFT = "topLeft" TOP_MIDDLE = "topMiddle" TOP_RIGHT = "topRight" BOTTOM_LEFT = "bottomLeft" BOTTOM_MIDDLE = "bottomMiddle" BOTTOM_RIGHT = "bottomRight" UNKNOWN = "unknown" DoorbellText = Annotated[str, StringConstraints(max_length=30)] ICRCustomValue = Annotated[int, Field(ge=0, le=11)] ICRLuxValue = Annotated[int, Field(ge=1, le=30)] LEDLevel = Annotated[int, Field(ge=0, le=6)] PercentInt = Annotated[int, Field(ge=0, le=101)] TwoByteInt = Annotated[int, Field(ge=1, le=256)] PercentFloat = Annotated[float, Field(ge=0, le=100)] WDRLevel = Annotated[int, Field(ge=0, le=4)] ICRSensitivity = Annotated[int, Field(ge=0, le=4)] Percent = Annotated[float, Field(ge=0, le=1)] RepeatTimes = Annotated[int, Field(ge=1, le=6)] class PTZPositionDegree(BaseModel): pan: float tilt: float zoom: float class PTZPositionSteps(BaseModel): focus: int pan: int tilt: int zoom: int class PTZPosition(BaseModel): degree: PTZPositionDegree steps: PTZPositionSteps class PTZPresetPosition(BaseModel): pan: int tilt: int zoom: int # PTZ slot constant for home position PTZ_HOME_SLOT: int = -1 class PTZPreset(BaseModel): id: str name: str slot: int ptz: PTZPresetPosition class PTZPatrol(BaseModel): id: str name: str slot: int presets: list[int] preset_movement_speed: int | None = Field(None, alias="presetMovementSpeed") preset_duration_seconds: int = Field(alias="presetDurationSeconds") camera: str model_key: str = Field("ptzPatrol", alias="modelKey") model_config = {"populate_by_name": True} CoordType = Union[Percent, int, float] class Version(BaseVersion): def __str__(self) -> str: super_str = super().__str__() if self.pre is not None and self.pre[0] == "b": super_str = super_str.replace("b", "-beta.") return super_str uilibs-uiprotect-317a52c/src/uiprotect/data/user.py000066400000000000000000000241021524637705700224310ustar00rootroot00000000000000"""UniFi Protect User models.""" from __future__ import annotations from abc import abstractmethod from datetime import datetime from functools import cache from typing import Any, Generic, Self, TypeVar from pydantic.fields import PrivateAttr from .base import ProtectBaseObject, ProtectModel, ProtectModelWithId from .types import ModelType, PermissionNode class Permission(ProtectBaseObject): raw_permission: str model: ModelType nodes: set[PermissionNode] obj_ids: set[str] | None = None @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: permission = data.get("rawPermission", "") parts = permission.split(":") if len(parts) < 2: raise ValueError(f"Invalid permission: {permission}") data["model"] = ModelType(parts[0]) if parts[1] == "*": data["nodes"] = list(PermissionNode) else: data["nodes"] = [PermissionNode(n) for n in parts[1].split(",")] if len(parts) == 3 and parts[2] != "*": if parts[2] == "$": data["obj_ids"] = ["self"] else: data["obj_ids"] = parts[2].split(",") return super().unifi_dict_to_dict(data) def unifi_dict( # type: ignore[override] self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> str: return self.raw_permission @property def objs(self) -> list[ProtectModelWithId] | None: if self.obj_ids == {"self"} or self.obj_ids is None: return None devices = getattr(self._api.bootstrap, self.model.devices_key) return [devices[oid] for oid in self.obj_ids] class Group(ProtectModelWithId): name: str permissions: list[Permission] type: str is_default: bool @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: if "permissions" in data: permissions = data.pop("permissions") data["permissions"] = [{"rawPermission": p} for p in permissions] return super().unifi_dict_to_dict(data) class UserLocation(ProtectModel): is_away: bool latitude: float | None = None longitude: float | None = None class CloudAccount(ProtectModelWithId): first_name: str last_name: str email: str user_id: str name: str location: UserLocation | None = None profile_img: str | None = None @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "user": "userId"} def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) # id and cloud ID are always the same if "id" in data: data["cloudId"] = data["id"] if "location" in data and data["location"] is None: del data["location"] return data @property def user(self) -> User: return self._api.bootstrap.users[self.user_id] class UserFeatureFlags(ProtectBaseObject): notifications_v2: bool class User(ProtectModelWithId): permissions: list[Permission] last_login_ip: str | None = None last_login_time: datetime | None = None is_owner: bool enable_notifications: bool has_accepted_invite: bool all_permissions: list[Permission] scopes: list[str] | None = None location: UserLocation | None = None name: str first_name: str last_name: str email: str | None = None local_username: str group_ids: list[str] cloud_account: CloudAccount | None = None feature_flags: UserFeatureFlags _groups: list[Group] | None = PrivateAttr(None) _perm_cache: dict[str, bool] = PrivateAttr({}) def __init__(self, **data: Any) -> None: if "permissions" in data: permissions = data.pop("permissions") data["permissions"] = [ {"raw_permission": p} if isinstance(p, str) else p for p in permissions ] if "allPermissions" in data: permissions = data.pop("allPermissions") data["allPermissions"] = [ {"raw_permission": p} if isinstance(p, str) else p for p in permissions ] super().__init__(**data) @classmethod def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]: if "permissions" in data: permissions = data.pop("permissions") data["permissions"] = [{"rawPermission": p} for p in permissions] if "allPermissions" in data: permissions = data.pop("allPermissions") data["allPermissions"] = [{"rawPermission": p} for p in permissions] return super().unifi_dict_to_dict(data) @classmethod @cache def _get_unifi_remaps(cls) -> dict[str, str]: return {**super()._get_unifi_remaps(), "groups": "groupIds"} def unifi_dict( self, data: dict[str, Any] | None = None, exclude: set[str] | None = None, ) -> dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if "location" in data and data["location"] is None: del data["location"] return data @property def groups(self) -> list[Group]: """ Groups the user is in Will always be empty if the user only has read only access. """ if self._groups is not None: return self._groups self._groups = [ self._api.bootstrap.groups[g] for g in self.group_ids if g in self._api.bootstrap.groups ] return self._groups def can( self, model: ModelType, node: PermissionNode, obj: ProtectModelWithId | None = None, ) -> bool: """Checks if a user can do a specific action""" check_self = False if model is self.model and obj is not None and obj.id == self.id: perm_str = f"{model.value}:{node.value}:$" check_self = True else: perm_str = ( f"{model.value}:{node.value}:{obj.id if obj is not None else '*'}" ) if perm_str in self._perm_cache: return self._perm_cache[perm_str] for perm in self.all_permissions: if model is not perm.model or node not in perm.nodes: continue if perm.obj_ids is None: self._perm_cache[perm_str] = True return True if check_self and perm.obj_ids == {"self"}: self._perm_cache[perm_str] = True return True if perm.obj_ids is not None and obj is not None and obj.id in perm.obj_ids: self._perm_cache[perm_str] = True return True self._perm_cache[perm_str] = False return False T = TypeVar("T", bound="ProtectModelWithId") class UlpUserKeyringBase(Generic[T]): """Base class for collections of ULP users and keyrings.""" def __init__(self) -> None: self._id_to_item: dict[str, T] = {} def __len__(self) -> int: return len(self._id_to_item) @classmethod def from_list(cls, items: list[T]) -> Self: instance = cls() for item in items: instance.add(item) return instance def add(self, item: T) -> None: """Add an item to the collection.""" self._id_to_item[item.id] = item def remove(self, item: T) -> None: """Remove an item from the collection.""" self._id_to_item.pop(item.id, None) def by_id(self, item_id: str) -> T | None: """Retrieve an item by its ID.""" return self._id_to_item.get(item_id) @abstractmethod def by_ulp_id(self, item_id: str) -> T | None: """Retrieve an item by its ULP ID.""" def as_list(self) -> list[T]: return list(self._id_to_item.values()) def __eq__(self, other: object) -> bool: if not isinstance(other, UlpUserKeyringBase): return NotImplemented return self._id_to_item == other._id_to_item class Keyring(ProtectModelWithId): device_type: str device_id: str registry_type: str registry_id: str last_activity: datetime | None = None ulp_user: str class Keyrings(UlpUserKeyringBase[Keyring]): def __init__(self) -> None: super().__init__() self._keyrings_by_registry_id: dict[str, Keyring] = {} self._keyrings_by_ulp_user: dict[str, Keyring] = {} def add(self, keyring: Keyring) -> None: super().add(keyring) self._keyrings_by_registry_id[keyring.registry_id] = keyring self._keyrings_by_ulp_user[keyring.ulp_user] = keyring def remove(self, keyring: Keyring) -> None: super().remove(keyring) self._keyrings_by_registry_id.pop(keyring.registry_id, None) self._keyrings_by_ulp_user.pop(keyring.ulp_user, None) def by_ulp_id(self, ulp_id: str) -> Keyring | None: return self._keyrings_by_ulp_user.get(ulp_id) def by_registry_id(self, registry_id: str) -> Keyring | None: return self._keyrings_by_registry_id.get(registry_id) def __eq__(self, other: object) -> bool: if not isinstance(other, Keyrings): return NotImplemented return super().__eq__(other) class UlpUser(ProtectModelWithId): ulp_id: str first_name: str last_name: str full_name: str avatar: str status: str class UlpUsers(UlpUserKeyringBase[UlpUser]): def __init__(self) -> None: super().__init__() self._users_by_ulp_id: dict[str, UlpUser] = {} def add(self, user: UlpUser) -> None: super().add(user) self._users_by_ulp_id[user.ulp_id] = user def remove(self, user: UlpUser) -> None: super().remove(user) self._users_by_ulp_id.pop(user.ulp_id, None) def by_ulp_id(self, ulp_id: str) -> UlpUser | None: return self._users_by_ulp_id.get(ulp_id) def __eq__(self, other: object) -> bool: if not isinstance(other, UlpUsers): return NotImplemented return super().__eq__(other) uilibs-uiprotect-317a52c/src/uiprotect/data/websocket.py000066400000000000000000000152051524637705700234450ustar00rootroot00000000000000"""Classes for decoding/encoding data from UniFi OS Websocket""" from __future__ import annotations import base64 import enum import struct import zlib from dataclasses import dataclass from functools import cache from typing import TYPE_CHECKING, Any import orjson from .._compat import cached_property from ..exceptions import WSDecodeError, WSEncodeError from .types import ProtectWSPayloadFormat if TYPE_CHECKING: from .base import ProtectModelWithId WS_HEADER_SIZE = 8 @dataclass(slots=True) class WSPacketFrameHeader: packet_type: int payload_format: int deflated: int unknown: int payload_size: int @enum.unique class WSAction(enum.StrEnum): ADD = "add" UPDATE = "update" REMOVE = "remove" @dataclass(slots=True) class WSSubscriptionMessage: action: WSAction new_update_id: str changed_data: dict[str, Any] new_obj: ProtectModelWithId | None = None old_obj: ProtectModelWithId | None = None _PACKET_STRUCT = struct.Struct("!bbbbi") class BaseWSPacketFrame: unpack = _PACKET_STRUCT.unpack pack = _PACKET_STRUCT.pack data: Any position: int = 0 header: WSPacketFrameHeader | None = None payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer is_deflated: bool = False length: int = 0 def __repr__(self) -> str: return f"<{self.__class__.__name__} header={self.header} data={self.data}>" def set_data_from_binary(self, data: bytes) -> None: self.data = data if self.header is not None and self.header.deflated: self.data = zlib.decompress(self.data) def get_binary_from_data(self) -> bytes: raise NotImplementedError @staticmethod @cache def klass_from_format(format_raw: int) -> type[BaseWSPacketFrame]: payload_format = ProtectWSPayloadFormat(format_raw) if payload_format == ProtectWSPayloadFormat.JSON: return WSJSONPacketFrame return WSRawPacketFrame @staticmethod def from_binary( data: bytes, position: int = 0, klass: type[WSRawPacketFrame] | None = None, ) -> BaseWSPacketFrame: """ Decode a unifi updates websocket frame. The format of the frame is b: packet_type b: payload_format b: deflated b: unknown i: payload_size """ header_end = position + WS_HEADER_SIZE payload_size: int try: ( packet_type, payload_format, deflated, unknown, payload_size, ) = BaseWSPacketFrame.unpack( data[position:header_end], ) except struct.error as e: raise WSDecodeError from e if klass is None: frame = WSRawPacketFrame.klass_from_format(payload_format)() else: frame = klass() frame.payload_format = ProtectWSPayloadFormat(payload_format) frame.header = WSPacketFrameHeader( packet_type=packet_type, payload_format=payload_format, deflated=deflated, unknown=unknown, payload_size=payload_size, ) frame.length = WS_HEADER_SIZE + payload_size frame.is_deflated = bool(deflated) frame_end = header_end + payload_size frame.set_data_from_binary(data[header_end:frame_end]) return frame @property def packed(self) -> bytes: if self.header is None: raise WSEncodeError("No header to encode") data = self.get_binary_from_data() header = self.pack( self.header.packet_type, self.header.payload_format, self.header.deflated, self.header.unknown, len(data), ) return header + data class WSRawPacketFrame(BaseWSPacketFrame): data: bytes = b"" def get_binary_from_data(self) -> bytes: data = self.data if self.is_deflated: data = zlib.compress(data) return data class WSJSONPacketFrame(BaseWSPacketFrame): data: dict[str, Any] = {} payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer def set_data_from_binary(self, data: bytes) -> None: if self.header is not None and self.header.deflated: data = zlib.decompress(data) self.data = orjson.loads(data) def get_binary_from_data(self) -> bytes: data = self.json if self.is_deflated: data = zlib.compress(data) return data @property def json(self) -> bytes: return orjson.dumps(self.data) class WSPacket: """Class to handle a unifi protect websocket packet.""" _raw: bytes _raw_encoded: str | None = None _action_frame: BaseWSPacketFrame | None = None _data_frame: BaseWSPacketFrame | None = None def __init__(self, data: bytes) -> None: self._raw = data def __repr__(self) -> str: return f"<{self.__class__.__name__} action_frame={self.action_frame} data_frame={self.data_frame}>" def decode(self) -> None: data = self._raw self._action_frame = WSRawPacketFrame.from_binary(data) length = self._action_frame.length self._data_frame = WSRawPacketFrame.from_binary(data, length) @cached_property def action_frame(self) -> BaseWSPacketFrame: if self._action_frame is None: self.decode() if TYPE_CHECKING: assert self._action_frame is not None assert self._data_frame is not None self.__dict__["data_frame"] = self._data_frame return self._action_frame @cached_property def data_frame(self) -> BaseWSPacketFrame: if self._data_frame is None: self.decode() if TYPE_CHECKING: assert self._action_frame is not None assert self._data_frame is not None self.__dict__["action_frame"] = self._action_frame return self._data_frame @property def raw(self) -> bytes: return self._raw @raw.setter def raw(self, data: bytes) -> None: self._raw = data self._action_frame = None self._data_frame = None self._raw_encoded = None self.__dict__.pop("data_frame", None) self.__dict__.pop("action_frame", None) @property def raw_base64(self) -> str: if self._raw_encoded is None: self._raw_encoded = base64.b64encode(self._raw).decode("utf-8") return self._raw_encoded def pack_frames(self) -> bytes: self._raw_encoded = None self._raw = self.action_frame.packed + self.data_frame.packed return self._raw uilibs-uiprotect-317a52c/src/uiprotect/devices/000077500000000000000000000000001524637705700216135ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/devices/__init__.py000066400000000000000000000004371524637705700237300ustar00rootroot00000000000000"""Public device-state contract for UniFi Protect.""" from __future__ import annotations from .dispatcher import DeviceDispatcher from .protect_device_change import DeviceChange, ProtectDeviceChange __all__ = [ "DeviceChange", "DeviceDispatcher", "ProtectDeviceChange", ] uilibs-uiprotect-317a52c/src/uiprotect/devices/dispatcher.py000066400000000000000000000143711524637705700243210ustar00rootroot00000000000000"""Stateless public devices dispatcher over the PublicBootstrap device stores.""" from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from ..data.types import ModelType from ..data.websocket import WSAction from ..utils import to_snake_case from .protect_device_change import DeviceChange, ProtectDeviceChange if TYPE_CHECKING: from collections.abc import Callable from ..api import ProtectApiClient from ..data.base import ProtectModelWithId from ..data.websocket import WSSubscriptionMessage _LOGGER = logging.getLogger(__name__) class DeviceDispatcher: """Map a single per-device WS message to a typed ``ProtectDeviceChange``.""" def __init__(self, api: ProtectApiClient) -> None: self._api = api self._subscribers: list[Callable[[ProtectDeviceChange], None]] = [] def add_subscriber(self, cb: Callable[[ProtectDeviceChange], None]) -> None: self._subscribers.append(cb) def remove_subscriber(self, cb: Callable[[ProtectDeviceChange], None]) -> None: # Idempotent: the unsubscribe callable may fire more than once # (e.g. double cleanup on HA reload). if cb in self._subscribers: self._subscribers.remove(cb) @property def subscriber_count(self) -> int: return len(self._subscribers) def dispatch(self, msg: WSSubscriptionMessage) -> None: change = self._derive(msg) if change is not None: self._fan_out(change) def _derive(self, msg: WSSubscriptionMessage) -> ProtectDeviceChange | None: changed = msg.changed_data or {} model_key = changed.get("modelKey") if model_key: model_type = ModelType.from_string(model_key) else: # Synthetic frames (e.g. the detection-transition devices-WS update) # carry only the flipped fields in ``changed_data`` and no # ``modelKey``; ``new_obj`` is then the authoritative source of the # model type. Real device-WS frames always include ``modelKey``. model_type = getattr(msg.new_obj, "model", None) or ModelType.UNKNOWN if msg.action is WSAction.REMOVE: return self._derive_remove(msg, changed, model_type) return self._derive_upsert(msg, changed, model_type) def _derive_remove( self, msg: WSSubscriptionMessage, changed: dict[str, Any], model_type: ModelType, ) -> ProtectDeviceChange | None: # Only surface removals for types the public API actually exposes; # unsupported model keys (e.g. doorlock) never reach a store. if not self._api.public_bootstrap.supports_device(model_type): return None old = msg.old_obj device_id = self._object_id(old) or changed.get("id") if device_id is None: _LOGGER.debug( "Devices-WS REMOVE without resolvable id (modelKey=%s) — " "dropping frame", changed.get("modelKey"), ) return None return ProtectDeviceChange( change=DeviceChange.REMOVED, model_type=model_type, device_id=str(device_id), device_mac=getattr(old, "mac", None) if old is not None else None, model=None, ) def _derive_upsert( self, msg: WSSubscriptionMessage, changed: dict[str, Any], model_type: ModelType, ) -> ProtectDeviceChange | None: new = msg.new_obj if new is None: # Benign desync: an ADD/UPDATE whose merged model is missing (update # for an uncached id, or a merge/construct failure already surfaced # by PublicBootstrap). Not a contract change — drop quietly. _LOGGER.debug( "Devices-WS %s without merged model (id=%s) — dropping frame", msg.action, changed.get("id"), ) return None device_id = self._object_id(new) or changed.get("id") if device_id is None: return None device_mac = self._api.public_bootstrap.get_device_mac(str(device_id)) if msg.action is WSAction.ADD: return ProtectDeviceChange( change=DeviceChange.ADDED, model_type=model_type, device_id=str(device_id), device_mac=device_mac, model=new, ) return ProtectDeviceChange( change=DeviceChange.UPDATED, model_type=model_type, device_id=str(device_id), device_mac=device_mac, model=new, changed_fields=self._changed_fields(new, changed), ) @staticmethod def _object_id(obj: ProtectModelWithId | None) -> str | None: return getattr(obj, "id", None) if obj is not None else None @staticmethod def _changed_fields( model: ProtectModelWithId, changed: dict[str, object] ) -> frozenset[str]: # ``new_obj is old_obj`` for UPDATE (in-place merge), so models can't be # diffed — derive the changed set from the payload keys instead, cleaned # through the model's own remap/snake-case pass. payload = {k: v for k, v in changed.items() if k not in ("id", "modelKey")} if not payload: return frozenset() try: cleaned = type(model).unifi_dict_to_dict(dict(payload)) except Exception: # Keep the key space stable for membership checks: best-effort # snake_case the raw payload keys instead of leaking camelCase. _LOGGER.debug( "changed_fields conversion failed for %s — falling back to " "snake_cased payload keys", type(model).__name__, exc_info=True, ) return frozenset(to_snake_case(k) for k in payload) return frozenset(cleaned) def _fan_out(self, change: ProtectDeviceChange) -> None: # Snapshot: a subscriber may unsubscribe mid-delivery, mutating the list. for cb in tuple(self._subscribers): try: cb(change) except Exception: _LOGGER.exception( "Exception while running subscribe_devices subscriber" ) uilibs-uiprotect-317a52c/src/uiprotect/devices/protect_device_change.py000066400000000000000000000023551524637705700264760ustar00rootroot00000000000000"""Value types for the public device-state contract.""" from __future__ import annotations import enum from pydantic import BaseModel, ConfigDict, Field from ..data.base import ProtectModelWithId from ..data.types import ModelType class DeviceChange(enum.StrEnum): """Lifecycle tag for a ProtectDeviceChange dispatch.""" ADDED = "added" UPDATED = "updated" REMOVED = "removed" class ProtectDeviceChange(BaseModel): """Frozen public value type emitted by ``subscribe_devices``.""" model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) change: DeviceChange model_type: ModelType device_id: str # Resolved from ``device_id`` against the public bootstrap device stores at # emit time; ``None`` until the device lands in the cache on the next # ``update_public()`` / reconnect, or for a REMOVED device with no cached # pre-removal object. device_mac: str | None = None # Merged post-``process_devices_ws_messages`` public model. ``None`` for # REMOVED (only an id / modelKey reference is delivered). model: ProtectModelWithId | None = Field(default=None, repr=False) # Populated for UPDATED; empty for ADDED / REMOVED. changed_fields: frozenset[str] = frozenset() uilibs-uiprotect-317a52c/src/uiprotect/events/000077500000000000000000000000001524637705700214755ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/events/__init__.py000066400000000000000000000007711524637705700236130ustar00rootroot00000000000000"""Public events contract for UniFi Protect.""" from __future__ import annotations from .protect_event import ( EVENT_TYPE_TO_CHANNEL, INSTANTANEOUS_EVENT_TYPES, EventChange, EventIdentity, ProtectEvent, ProtectEventChannel, UlpUserIdentity, UnknownIdentity, ) __all__ = [ "EVENT_TYPE_TO_CHANNEL", "INSTANTANEOUS_EVENT_TYPES", "EventChange", "EventIdentity", "ProtectEvent", "ProtectEventChannel", "UlpUserIdentity", "UnknownIdentity", ] uilibs-uiprotect-317a52c/src/uiprotect/events/_mapping.py000066400000000000000000000023171524637705700236440ustar00rootroot00000000000000"""Map public-API ``PublicEvent`` instances onto the public ``ProtectEvent``.""" from __future__ import annotations from typing import TYPE_CHECKING from .protect_event import ( EventIdentity, ProtectEvent, ProtectEventChannel, ) if TYPE_CHECKING: from datetime import datetime from ..data.public_event import PublicEvent def event_to_protect_event( raw: PublicEvent, channel: ProtectEventChannel, identity: EventIdentity | None, *, end_override: datetime | None = None, device_mac: str | None = None, ) -> ProtectEvent: """Build a frozen ``ProtectEvent`` from a validated ``PublicEvent``.""" if raw.device_id is None: raise ValueError("device_id must be present") end = end_override if end_override is not None else raw.end metadata = raw.metadata return ProtectEvent( id=raw.id, type=raw.type, channel=channel, device_id=raw.device_id, device_mac=device_mac, start=raw.start, end=end, smart_detect_types=tuple(raw.smart_detect_types), identity=identity, alarm_type=metadata.alarm_type if metadata is not None else None, metadata=metadata, raw=raw, ) uilibs-uiprotect-317a52c/src/uiprotect/events/dispatcher.py000066400000000000000000000242411524637705700242000ustar00rootroot00000000000000"""Stateless public events dispatcher over the single PublicBootstrap store.""" from __future__ import annotations import asyncio import logging from datetime import timedelta from typing import TYPE_CHECKING from ..data.websocket import WSAction from ..utils import utc_now from ._mapping import event_to_protect_event from .enricher import EventEnricher from .protect_event import ( EVENT_TYPE_TO_CHANNEL, INSTANTANEOUS_EVENT_TYPES, EventChange, ProtectEvent, ProtectEventChannel, ) if TYPE_CHECKING: from collections.abc import Callable from ..api import ProtectApiClient from ..data.public_event import PublicEvent _LOGGER = logging.getLogger(__name__) EVENTS_ACTIVE_TTL: timedelta = timedelta(minutes=30) EVENTS_TTL_SWEEP_INTERVAL: timedelta = timedelta(minutes=15) EVENTS_RECONNECT_STALENESS_WINDOW: timedelta = timedelta(hours=1) class EventDispatcher: """Derive ``(ProtectEvent, EventChange)`` from the single event store.""" def __init__(self, api: ProtectApiClient) -> None: self._api = api self._enricher = EventEnricher(api) self._subscribers: list[Callable[[ProtectEvent, EventChange], None]] = [] self._sweep_task: asyncio.Task[None] | None = None def add_subscriber(self, cb: Callable[[ProtectEvent, EventChange], None]) -> None: self._subscribers.append(cb) def remove_subscriber( self, cb: Callable[[ProtectEvent, EventChange], None] ) -> None: # Idempotent: the unsubscribe callable returned by ``subscribe_events`` # may be invoked more than once (e.g. double cleanup on HA reload). if cb in self._subscribers: self._subscribers.remove(cb) @property def subscriber_count(self) -> int: return len(self._subscribers) def _device_mac(self, device_id: str | None) -> str | None: if device_id is None: return None return self._api.public_bootstrap.get_device_mac(device_id) def active_events(self, device_id: str | None = None) -> list[ProtectEvent]: out: list[ProtectEvent] = [] for raw in self._api.public_bootstrap.events.values(): if raw.end is not None: continue channel = EVENT_TYPE_TO_CHANNEL.get(raw.type, ProtectEventChannel.OTHER) if channel is ProtectEventChannel.OTHER or raw.device_id is None: continue if device_id is not None and raw.device_id != device_id: continue out.append( event_to_protect_event( raw, channel, self._enricher.enrich(raw), device_mac=self._device_mac(raw.device_id), ) ) return out def dispatch( self, action: WSAction, new_event: PublicEvent | None, old_event: PublicEvent | None, ) -> None: # ``new_event`` is the post-merge cached object (``None`` only on # REMOVE); ``old_event`` is the pre-merge snapshot the store handed # back. The subject is whichever one carries the event payload. subject = new_event if new_event is not None else old_event if subject is None: return channel = EVENT_TYPE_TO_CHANNEL.get(subject.type, ProtectEventChannel.OTHER) # Drop administrative events (provision, factoryReset, fwUpdate, …) # that land in the OTHER channel while keeping detection / sensor / # alarm-hub / access events that consumers care about. if channel is ProtectEventChannel.OTHER: return if subject.device_id is None: _LOGGER.warning( "Public event %s missing required 'device' field — dropping", subject.id, ) return for event, change in self._derive( action, subject, channel, new_event, old_event ): self._fan_out(event, change) def _derive( self, action: WSAction, subject: PublicEvent, channel: ProtectEventChannel, new_event: PublicEvent | None, old_event: PublicEvent | None, ) -> list[tuple[ProtectEvent, EventChange]]: identity = self._enricher.enrich(subject) device_mac = self._device_mac(subject.device_id) # The single idempotency guard: was the event already terminal in the # store before this frame? A retransmit/replay finds it so and is # suppressed. old_terminal = old_event is not None and old_event.end is not None instantaneous = subject.type in INSTANTANEOUS_EVENT_TYPES if action is WSAction.ADD and (subject.end is not None or instantaneous): # Close-window: emit STARTED + ENDED on first sight only. if old_terminal: return [] end_value = subject.end if subject.end is not None else subject.start # Close the event in the store so a replay ADD is suppressed (its # snapshot will then carry ``end``) and ``active_events`` does not # surface an instantaneous event as still open. if subject.end is None and new_event is not None: new_event.end = end_value event = event_to_protect_event( subject, channel, identity, end_override=end_value, device_mac=device_mac, ) return [(event, EventChange.STARTED), (event, EventChange.ENDED)] if action is WSAction.ADD: change = EventChange.STARTED elif action is WSAction.REMOVE: if old_terminal: return [] change = EventChange.REMOVED elif subject.end is not None: if old_terminal: return [] change = EventChange.ENDED else: change = EventChange.UPDATED return [ ( event_to_protect_event( subject, channel, identity, device_mac=device_mac ), change, ) ] def _fan_out(self, event: ProtectEvent, change: EventChange) -> None: # Snapshot: a subscriber may unsubscribe (itself or another) mid-delivery, # mutating the list and skipping callbacks if iterated live. for cb in tuple(self._subscribers): try: cb(event, change) except Exception: _LOGGER.exception("Exception while running subscribe_events subscriber") def start_ttl_sweep(self) -> None: if self._sweep_task is not None and not self._sweep_task.done(): return self._sweep_task = asyncio.create_task(self._ttl_sweep_loop()) self._sweep_task.add_done_callback(self._on_sweep_task_done) def stop_ttl_sweep(self) -> None: if self._sweep_task is not None: self._sweep_task.cancel() self._sweep_task = None @staticmethod def _on_sweep_task_done(task: asyncio.Task[None]) -> None: if task.cancelled(): return exc = task.exception() if exc is not None: _LOGGER.error("TTL sweep loop terminated unexpectedly", exc_info=exc) def sweep_stale(self) -> int: """Force-end events open past :data:`EVENTS_ACTIVE_TTL`.""" count = self._sweep(EVENTS_ACTIVE_TTL) if count > 0: _LOGGER.warning("TTL-swept %d stale active events", count) return count def flush_stale_on_reconnect(self) -> int: """Force-end events open past the reconnect staleness window.""" return self._sweep(EVENTS_RECONNECT_STALENESS_WINDOW) def force_end_on_events_reconnect(self) -> int: """ Reconcile the store after an events-WS reconnect. Events arrive *only* on the events WS, so an END missed during the gap leaves the event active and a camera's derived ``is_*_currently_detected`` stuck ON. The staleness window is 1 h, which would miss a short blip, so detection-channel events are force-ended regardless of age — a genuinely still-active detection re-asserts on its next frame. Other channels keep the age gate. """ return self._sweep(EVENTS_RECONNECT_STALENESS_WINDOW, force_detection=True) def _sweep( self, staleness_window: timedelta, *, force_detection: bool = False ) -> int: now = utc_now() cutoff = now - staleness_window pb = self._api.public_bootstrap ended: list[PublicEvent] = [] for raw in list(pb.events.values()): if raw.end is not None: continue channel = EVENT_TYPE_TO_CHANNEL.get(raw.type, ProtectEventChannel.OTHER) if channel is ProtectEventChannel.OTHER or raw.device_id is None: continue forced = force_detection and channel is ProtectEventChannel.DETECTION if not forced and raw.start >= cutoff: continue # Mark the stored event ended so a later close retransmit is # suppressed by the dispatch chokepoint and derivation stays # consistent. raw.end = now event = event_to_protect_event( raw, channel, self._enricher.enrich(raw), device_mac=self._device_mac(raw.device_id), ) self._fan_out(event, EventChange.ENDED) ended.append(raw) # Sync the owning cameras' derived detection state through the store's # single choke point and push the resulting device updates so pull and # subscribe_devices consumers see the flag drop, not just the TTL sweep. for update in pb._sync_force_ended_events(ended): self._api.emit_devices_message(update) return len(ended) async def _ttl_sweep_loop(self) -> None: try: while True: await asyncio.sleep(EVENTS_TTL_SWEEP_INTERVAL.total_seconds()) try: self.sweep_stale() except Exception: _LOGGER.exception("TTL sweep iteration failed — continuing loop") except asyncio.CancelledError: return uilibs-uiprotect-317a52c/src/uiprotect/events/enricher.py000066400000000000000000000037401524637705700236520ustar00rootroot00000000000000"""Identity enrichment for public events (ULP-only, read-only).""" from __future__ import annotations from typing import TYPE_CHECKING from ..data.types import EventType from .protect_event import EventIdentity, UlpUserIdentity, UnknownIdentity if TYPE_CHECKING: from ..api import ProtectApiClient from ..data.public_event import PublicEvent class EventEnricher: """Resolve ``EventIdentity`` for credential events.""" def __init__(self, api: ProtectApiClient) -> None: self._api = api def enrich(self, raw: PublicEvent) -> EventIdentity | None: if raw.type is EventType.NFC_CARD_SCANNED: return self._resolve_nfc(raw) if raw.type is EventType.FINGERPRINT_IDENTIFIED: return self._resolve_fingerprint(raw) return None def _resolve_nfc(self, raw: PublicEvent) -> EventIdentity: metadata = raw.metadata if metadata is None or metadata.nfc is None: return UnknownIdentity(reason="no_metadata") ulp_id = metadata.nfc.ulp_id if ulp_id is None: return UnknownIdentity(reason="ulp_id_null") return self._lookup_ulp_user(ulp_id) def _resolve_fingerprint(self, raw: PublicEvent) -> EventIdentity: metadata = raw.metadata if metadata is None or metadata.fingerprint is None: return UnknownIdentity(reason="no_metadata") ulp_id = metadata.fingerprint.ulp_id if ulp_id is None: return UnknownIdentity(reason="ulp_id_null") return self._lookup_ulp_user(ulp_id) def _lookup_ulp_user(self, ulp_id: str) -> EventIdentity: # Pure read against the single identity store; a miss self-heals on # the next ``update_public`` / reconnect resync, no hot-path # scheduling. cached = self._api.public_bootstrap.ulp_users.get(ulp_id) if cached is None: return UnknownIdentity(reason="ulp_user_not_cached") return UlpUserIdentity(ulp_id=ulp_id, user=cached) uilibs-uiprotect-317a52c/src/uiprotect/events/protect_event.py000066400000000000000000000130101524637705700247230ustar00rootroot00000000000000"""Value types for the public events contract.""" from __future__ import annotations import enum from datetime import datetime from typing import Annotated, Literal, Union from pydantic import BaseModel, ConfigDict, Field from ..data.public_devices import PublicUlpUser from ..data.public_event import PublicEvent, PublicEventMetadata from ..data.types import EventType, SensorAlarmType, SmartDetectObjectType class ProtectEventChannel(enum.StrEnum): """High-level grouping for public events.""" DETECTION = "detection" SENSOR = "sensor" ALARM_HUB = "alarm_hub" ACCESS = "access" OTHER = "other" _DETECTION: frozenset[EventType] = frozenset( { EventType.MOTION, EventType.RING, EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT, EventType.SMART_DETECT_LINE, EventType.SMART_DETECT_LOITER, EventType.FACE_GROUP_DETECTED, EventType.MOTION_LIGHT, } ) _SENSOR: frozenset[EventType] = frozenset( { EventType.MOTION_SENSOR, EventType.SENSOR_BUTTON_PRESSED, EventType.SENSOR_OPENED, EventType.SENSOR_CLOSED, EventType.SENSOR_ALARM, EventType.SENSOR_EXTREME_VALUE, EventType.SENSOR_WATER_LEAK, EventType.SENSOR_BATTERY_LOW, EventType.SENSOR_SMOKE_TEST, EventType.SENSOR_TAMPER, EventType.SENSOR_VAPE, EventType.SENSOR_SMOKE_BATTERY_LOW, EventType.SENSOR_SMOKE_NEEDS_CLEANING, EventType.SENSOR_SMOKE_FAULT, EventType.SENSOR_CO_FAULT, EventType.SENSOR_SMOKE_END_OF_LIFE, } ) _ALARM_HUB: frozenset[EventType] = frozenset( { EventType.ALARM_HUB_MOTION, EventType.ALARM_HUB_ENTRY_OPENED, EventType.ALARM_HUB_ENTRY_CLOSED, EventType.ALARM_HUB_RELAY_SWITCHED, EventType.ALARM_HUB_BUTTON_PRESS, EventType.ALARM_HUB_SMOKE, EventType.ALARM_HUB_GLASS_BREAK, EventType.ALARM_HUB_TAMPER, EventType.ALARM_HUB_BATTERY_CONNECTED, EventType.ALARM_HUB_BATTERY_LOW, } ) _ACCESS: frozenset[EventType] = frozenset( { EventType.NFC_CARD_SCANNED, EventType.FINGERPRINT_IDENTIFIED, EventType.DOOR_ACCESS, EventType.ACCESS, } ) def _build_channel_map() -> dict[EventType, ProtectEventChannel]: channels: dict[EventType, ProtectEventChannel] = {} for et in EventType: if et in _DETECTION: channels[et] = ProtectEventChannel.DETECTION elif et in _SENSOR: channels[et] = ProtectEventChannel.SENSOR elif et in _ALARM_HUB: channels[et] = ProtectEventChannel.ALARM_HUB elif et in _ACCESS: channels[et] = ProtectEventChannel.ACCESS else: channels[et] = ProtectEventChannel.OTHER return channels EVENT_TYPE_TO_CHANNEL: dict[EventType, ProtectEventChannel] = _build_channel_map() # Schema-level instantaneous types: the server never emits an "end" later # (one-shot frame). Dispatcher synthesises end == start so subscribers always # see a paired STARTED / ENDED pair. INSTANTANEOUS_EVENT_TYPES: frozenset[EventType] = frozenset({EventType.MOTION_LIGHT}) class EventChange(enum.StrEnum): """Lifecycle tag for a ProtectEvent dispatch.""" STARTED = "started" UPDATED = "updated" ENDED = "ended" REMOVED = "removed" class UlpUserIdentity(BaseModel): """ULP-cached identity attached to a credential event.""" model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) kind: Literal["ulp_user"] = "ulp_user" ulp_id: str user: PublicUlpUser | None = None class UnknownIdentity(BaseModel): """Identity unresolvable from the cached public bootstrap.""" model_config = ConfigDict(frozen=True) kind: Literal["unknown"] = "unknown" reason: Literal["no_metadata", "ulp_user_not_cached", "ulp_id_null"] EventIdentity = Annotated[ Union[UlpUserIdentity, UnknownIdentity], Field(discriminator="kind"), ] class ProtectEvent(BaseModel): """Frozen public value type emitted by ``subscribe_events``.""" model_config = ConfigDict( frozen=True, arbitrary_types_allowed=True, ) id: str type: EventType channel: ProtectEventChannel device_id: str # Resolved from ``device_id`` against the public bootstrap device stores # at emit time. ``None`` when the device is not (yet) in the bootstrap — # the same eventual-consistency property as ``identity``: a device unknown # at first sight resolves on the next ``update_public()`` / reconnect. device_mac: str | None = None start: datetime end: datetime | None = None smart_detect_types: tuple[SmartDetectObjectType, ...] = () identity: EventIdentity | None = None # Sensor alarm sound (``sensorAlarm`` events), collapsed from the public # ``metadata.alarmType.text``; ``None`` for other event types. alarm_type: SensorAlarmType | None = None # Strongly-typed metadata union (all fields optional). Consumers read typed # values off here (``metadata.sensor_type``, ``metadata.input_state``, …) # rather than parsing raw strings. ``None`` for events without metadata. # Live typed view over the cached ``PublicEvent`` (not a deep snapshot), # matching the ``raw`` semantics below. metadata: PublicEventMetadata | None = None # Live view onto the cached source event, not a deep snapshot: it aliases # the same ``PublicEvent`` the bootstrap cache holds and mutates in place. raw: PublicEvent | None = Field(default=None, repr=False, exclude=True) uilibs-uiprotect-317a52c/src/uiprotect/exceptions.py000066400000000000000000000024311524637705700227240ustar00rootroot00000000000000from __future__ import annotations class UnifiProtectError(Exception): """Base class for all other UniFi Protect errors""" class StreamError(UnifiProtectError): """Exception raised when trying to stream content""" class DataDecodeError(UnifiProtectError): """Exception raised when trying to decode a UniFi Protect object""" class WSDecodeError(UnifiProtectError): """Exception raised when decoding Websocket packet""" class WSEncodeError(UnifiProtectError): """Exception raised when encoding Websocket packet""" class ClientError(UnifiProtectError): """Base Class for all other UniFi Protect client errors""" class BadRequest(ClientError): """Invalid request from API Client""" class PublicOnlyModeError(BadRequest): """Private-API operation attempted on a public-only (API-key-only) client.""" class Invalid(ClientError): """Invalid return from Authorization Request.""" class NotAuthorized(PermissionError, BadRequest): """Wrong username, password or permission error.""" class GlobalAlarmManagerError(BadRequest): """Operation not available when global alarm manager is enabled.""" class ArmedModeError(BadRequest): """Operation not available while the arm alarm is armed.""" class NvrError(ClientError): """Other error.""" uilibs-uiprotect-317a52c/src/uiprotect/py.typed000066400000000000000000000000001524637705700216560ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/release_cache.json000066400000000000000000000006021524637705700236250ustar00rootroot00000000000000["1.13.4","1.13.7","1.14.11","1.15.0","1.16.9","1.17.1","1.17.2","1.17.3","1.17.4","1.18.0","1.18.1","1.19.0","1.19.1","1.19.2","1.20.0","1.20.1","1.20.2","1.20.3","1.21.0","1.21.2","1.21.3","1.21.4","1.21.5","1.21.6","2.0.0","2.0.1","2.1.1","2.1.2","2.10.10","2.10.11","2.11.21","2.2.11","2.2.2","2.2.6","2.2.9","2.6.17","2.7.18","2.7.33","2.7.34","2.8.28","2.8.35","2.9.42","3.0.22"] uilibs-uiprotect-317a52c/src/uiprotect/stream.py000066400000000000000000000264631524637705700220510ustar00rootroot00000000000000"""Audio streaming utilities for UniFi Protect cameras using PyAV.""" from __future__ import annotations import asyncio import functools import logging import threading import time from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast from urllib.parse import ParseResult, urlparse import av from .exceptions import BadRequest, StreamError from .utils import format_host_for_url if TYPE_CHECKING: from collections.abc import Callable from av.audio import AudioStream from .data import Camera def _convert_av_errors( func: Callable[[TalkbackStream], None], ) -> Callable[[TalkbackStream], None]: """Decorator to convert av.FFmpegError to StreamError on self._error.""" @functools.wraps(func) def wrapper(self: TalkbackStream) -> None: try: func(self) except av.FFmpegError as e: self._error = StreamError(f"Audio streaming failed: {e}") return wrapper _LOGGER = logging.getLogger(__name__) #: Default UDP port for talkback streaming. DEFAULT_TALKBACK_PORT = 7004 #: Input timeout (open, read) in seconds. INPUT_TIMEOUT = 5.0 #: Output timeout (open) in seconds. None = no read timeout for UDP. OUTPUT_TIMEOUT: tuple[float, float | None] = (5.0, None) class CodecConfig(NamedTuple): """ Audio codec configuration for talkback streaming. Attributes: encoder: PyAV/libav encoder name (e.g., "aac", "libopus"). format: Output container format (e.g., "adts", "rtp"). """ encoder: str format: str #: Supported audio codecs for talkback. Maps codec name to encoder/format config. CODEC_MAP: dict[str, CodecConfig] = { "aac": CodecConfig("aac", "adts"), "opus": CodecConfig("libopus", "rtp"), "vorbis": CodecConfig("libvorbis", "ogg"), } @dataclass(slots=True) class TalkbackSession: """ Talkback session configuration from the UniFi Protect public API. Attributes: url: Streaming URL (UDP or RTP, e.g., "rtp://192.168.1.1:7004"). codec: Audio codec name ("aac", "opus", or "vorbis"). sampling_rate: Audio sampling rate in Hz. """ url: str codec: str sampling_rate: int bits_per_sample: int = 16 _parsed_url: ParseResult = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: """Parse URL on initialization.""" self._parsed_url = urlparse(self.url) @classmethod def from_unifi_dict(cls, **data: object) -> TalkbackSession: """ Create from UniFi API response. Args: **data: Raw API response fields (url, codec, samplingRate). Returns: Configured TalkbackSession instance. """ return cls( url=str(data.get("url", "")), codec=str(data.get("codec", "")), sampling_rate=int(cast("Any", data.get("samplingRate", 0))), bits_per_sample=int(cast("Any", data.get("bitsPerSample", 16))), ) @property def host(self) -> str: """Get hostname from talkback URL.""" return self._parsed_url.hostname or "" @property def port(self) -> int: """Get port from talkback URL, defaults to 7004.""" return self._parsed_url.port or DEFAULT_TALKBACK_PORT class TalkbackStream: """ Stream audio to a UniFi Protect camera's speaker using PyAV. This class handles audio transcoding and UDP streaming to camera speakers. It runs the actual streaming in a separate thread to avoid blocking the event loop. Example: ```python stream = TalkbackStream(camera, "/path/to/audio.wav", session) await stream.run_until_complete() ``` """ __slots__ = ( "_error", "_lock", "_stop_event", "_thread", "camera", "content_url", "session", ) def __init__( self, camera: Camera, content_url: str, session: TalkbackSession | None = None, ) -> None: """ Initialize talkback stream. Args: camera: Camera device to stream audio to. content_url: URL or file path of audio source. session: Optional talkback session from public API. Raises: BadRequest: If camera does not have a speaker. """ if not camera.feature_flags.has_speaker: raise BadRequest("Camera does not have a speaker for talkback") self.camera = camera self.content_url = content_url self.session = session self._stop_event = threading.Event() self._thread: threading.Thread | None = None self._lock = asyncio.Lock() self._error: BaseException | None = None async def __aenter__(self) -> Self: """Start streaming when entering async context.""" await self.start() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object, ) -> None: """Stop streaming when exiting async context.""" await self.stop() @property def is_running(self) -> bool: """Check if the stream is currently running.""" return self._thread is not None and self._thread.is_alive() @_convert_av_errors def _stream_audio_sync(self) -> None: """Stream audio to the camera (runs in executor thread).""" if self.session: output_url = self.session.url codec = self.session.codec sample_rate = self.session.sampling_rate bits_per_sample = self.session.bits_per_sample else: ts = self.camera.talkback_settings host = format_host_for_url(str(self.camera.host)) output_url = f"udp://{host}:{ts.bind_port}" codec = ts.type_fmt.value sample_rate = ts.sampling_rate bits_per_sample = ts.bits_per_sample config = CODEC_MAP.get(codec) if not config: self._error = StreamError(f"Unsupported codec: {codec}") return _LOGGER.debug( "Talkback: %s codec=%s rate=%d bits=%d", output_url, codec, sample_rate, bits_per_sample, ) with av.open( self.content_url, timeout=(INPUT_TIMEOUT, INPUT_TIMEOUT), ) as input_container: if not input_container.streams.audio: self._error = StreamError("No audio stream found in input") return input_stream = input_container.streams.audio[0] with av.open( output_url, "w", format=config.format, timeout=OUTPUT_TIMEOUT, ) as output_container: output_stream = cast( "AudioStream", output_container.add_stream(config.encoder, rate=sample_rate), ) output_stream.layout = "mono" # Map bits_per_sample to av format (8->u8, 16->s16, 32->s32) audio_format = {8: "u8", 16: "s16", 32: "s32"}.get( bits_per_sample, "s16" ) resampler = av.AudioResampler( format=audio_format, layout="mono", rate=sample_rate ) # Real-time pacing implementation: # # Unlike FFmpeg subprocess which handles timing internally, PyAV # returns encoded packets as fast as the CPU can process them. # Without pacing, we'd flood the camera's UDP buffer causing # garbled audio or dropped packets (UDP has no flow control). # # We use absolute time reference (start_time + samples/rate) rather # than relative delays to prevent cumulative drift from processing # overhead. The stop_event.wait(timeout) provides both the delay # and immediate cancellation when stop() is called. start_time = time.monotonic() samples_sent = 0 for frame in input_container.decode(input_stream): if self._stop_event.is_set(): break for resampled in resampler.resample(frame): for packet in output_stream.encode(resampled): output_container.mux(packet) # Calculate how long we should have taken vs actual elapsed samples_sent += resampled.samples target_time = start_time + (samples_sent / sample_rate) sleep_time = target_time - time.monotonic() if sleep_time > 0 and self._stop_event.wait(sleep_time): break # Stop requested during pacing delay # Flush encoder only if completed normally if not self._stop_event.is_set(): for packet in output_stream.encode(None): output_container.mux(packet) def _start_thread_if_needed(self, *, raise_if_running: bool) -> None: """Start streaming thread if not already running. Must hold lock.""" if self._thread is not None and self._thread.is_alive(): if raise_if_running: raise StreamError("Stream already started") return # Don't clear a pending stop signal — stop() sets the event before # acquiring the lock, so a concurrent start() must not clobber it. if self._stop_event.is_set(): return self._error = None self._thread = threading.Thread( target=self._stream_audio_sync, name="TalkbackStream", daemon=True, ) self._thread.start() async def _wait_for_thread(self) -> None: """Wait for the thread to complete without blocking the event loop.""" if self._thread is not None: loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._thread.join) async def start(self) -> None: """Start the audio stream.""" async with self._lock: self._start_thread_if_needed(raise_if_running=True) async def stop(self) -> None: """Stop the audio stream gracefully and wait for completion.""" # Signal the thread to stop BEFORE acquiring the lock. # run_until_complete() holds the lock while awaiting thread.join(), # so acquiring the lock first would deadlock when both are called # concurrently (e.g. HA play_audio + stop from media player). self._stop_event.set() async with self._lock: if self._thread is not None: await self._wait_for_thread() self._thread = None self._stop_event.clear() async def run_until_complete(self) -> None: """Run the stream until it completes naturally.""" async with self._lock: self._start_thread_if_needed(raise_if_running=False) await self._wait_for_thread() if self._error is not None: error = self._error self._error = None if isinstance(error, BaseException): raise error raise StreamError(f"Unexpected error: {error}") uilibs-uiprotect-317a52c/src/uiprotect/test_util/000077500000000000000000000000001524637705700222055ustar00rootroot00000000000000uilibs-uiprotect-317a52c/src/uiprotect/test_util/__init__.py000066400000000000000000000565161524637705700243330ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging import shutil import time from collections.abc import Callable, Coroutine from copy import deepcopy from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any, overload import aiohttp import av import orjson from PIL import Image from ..data import EventType, WSJSONPacketFrame, WSPacket from ..exceptions import BadRequest from ..test_util.anonymize import ( anonymize_data, anonymize_prefixed_event_id, ) from ..utils import from_js_time, is_online, run_async, write_json if TYPE_CHECKING: from ..api import ProtectApiClient def generate_blank_video(output_path: Path, length: int) -> None: """ Generate a blank video with silent audio using PyAV. Args: output_path: Path to output MP4 file. length: Duration in seconds. """ width, height = 1280, 720 fps = 25 sample_rate = 44100 samples_per_frame = 1024 with av.open(str(output_path), "w") as container: video_stream = container.add_stream("h264", rate=fps) video_stream.width = width video_stream.height = height video_stream.pix_fmt = "yuv420p" audio_stream = container.add_stream("aac", rate=sample_rate) audio_stream.layout = "stereo" # type: ignore[assignment] # Create black frame (YUV420p: Y=16 is black, U=V=128 is neutral) black_frame = av.VideoFrame(width, height, "yuv420p") # Y plane (full resolution) y_size = width * height black_frame.planes[0].update(bytes([16] * y_size)) # U and V planes (half resolution each direction) uv_size = (width // 2) * (height // 2) black_frame.planes[1].update(bytes([128] * uv_size)) black_frame.planes[2].update(bytes([128] * uv_size)) # Create silent audio frame silent_samples = bytes( samples_per_frame * 2 * 2 ) # 2 channels, 2 bytes per sample total_frames = fps * length total_audio_frames = (sample_rate * length) // samples_per_frame for i in range(total_frames): black_frame.pts = i for packet in video_stream.encode(black_frame): container.mux(packet) for i in range(total_audio_frames): audio_frame = av.AudioFrame( format="s16", layout="stereo", samples=samples_per_frame ) audio_frame.planes[0].update(silent_samples) audio_frame.rate = sample_rate audio_frame.pts = i * samples_per_frame for packet in audio_stream.encode(audio_frame): container.mux(packet) # Flush encoders for packet in video_stream.encode(None): container.mux(packet) for packet in audio_stream.encode(None): container.mux(packet) def placeholder_image( output_path: Path, width: int, height: int | None = None, ) -> None: if height is None: height = width image = Image.new("RGB", (width, height), (128, 128, 128)) image.save(output_path, "PNG") _LOGGER = logging.getLogger(__name__) LOG_CALLABLE = Callable[[str], None] PROGRESS_CALLABLE = Callable[[int, str], Coroutine[Any, Any, None]] class SampleDataGenerator: """Generate sample data for debugging and testing purposes""" _record_num_ws: int = 0 _record_ws_start_time: float = time.monotonic() _record_listen_for_events: bool = False _record_ws_messages: dict[str, dict[str, Any]] = {} _record_events_ws_messages: dict[str, Any] = {} _record_devices_ws_messages: dict[str, Any] = {} _log: LOG_CALLABLE | None = None _log_warning: LOG_CALLABLE | None = None _ws_progress: PROGRESS_CALLABLE | None = None constants: dict[str, Any] = {} client: ProtectApiClient output_folder: Path do_zip: bool anonymize: bool wait_time: int def __init__( self, client: ProtectApiClient, output: Path, anonymize: bool, wait_time: int, log: LOG_CALLABLE | None = None, log_warning: LOG_CALLABLE | None = None, ws_progress: PROGRESS_CALLABLE | None = None, do_zip: bool = False, ) -> None: self.client = client self.output_folder = output self.do_zip = do_zip self.anonymize = anonymize self.wait_time = wait_time self._log = log self._log_warning = log_warning self._ws_progress = ws_progress if self._log_warning is None and self._log is not None: self._log_warning = self._log def log(self, msg: str) -> None: if self._log is not None: self._log(msg) else: _LOGGER.debug(msg) def log_warning(self, msg: str) -> None: if self._log_warning is not None: self._log_warning(msg) else: _LOGGER.warning(msg) def generate(self) -> None: run_async(self.async_generate()) async def async_generate(self, close_session: bool = True) -> None: self.log(f"Output folder: {self.output_folder}") self.output_folder.mkdir(parents=True, exist_ok=True) websocket = self.client._get_websocket() websocket.start() self.log("Websocket started...") websocket._subscription = self._handle_ws_message self.log("Updating devices...") await self.client.update() bootstrap: dict[str, Any] = await self.client.api_request_obj("bootstrap") bootstrap = await self.write_json_file("sample_bootstrap", bootstrap) self.constants["server_name"] = bootstrap["nvr"]["name"] self.constants["server_id"] = bootstrap["nvr"]["mac"] self.constants["server_version"] = bootstrap["nvr"]["version"] self.constants["server_ip"] = bootstrap["nvr"]["host"] self.constants["server_model"] = bootstrap["nvr"]["type"] self.constants["last_update_id"] = bootstrap["lastUpdateId"] self.constants["user_id"] = bootstrap["authUserId"] self.constants["counts"] = { "camera": len(bootstrap["cameras"]), "user": len(bootstrap["users"]), "group": len(bootstrap["groups"]), "liveview": len(bootstrap["liveviews"]), "viewer": len(bootstrap["viewers"]), "light": len(bootstrap.get("lights", [])), "bridge": len(bootstrap.get("bridges", [])), "sensor": len(bootstrap.get("sensors", [])), "chime": len(bootstrap.get("chimes", [])), "aiport": len(bootstrap.get("aiports", [])), } self.log("Generating event data...") motion_event, smart_detection = await self.generate_event_data() await self.generate_device_data(motion_event, smart_detection) self.log("Recording websocket events...") await self.record_ws_events() if close_session: await self.client.close_session() await self.client.close_public_api_session() await self.write_json_file("sample_constants", self.constants, anonymize=False) if self.do_zip: self.log("Zipping files...") def zip_files() -> None: shutil.make_archive(str(self.output_folder), "zip", self.output_folder) shutil.rmtree(self.output_folder) loop = asyncio.get_running_loop() await loop.run_in_executor(None, zip_files) async def record_ws_events(self) -> None: if self.wait_time <= 0: self.log("Skipping recording Websocket messages...") return self._record_num_ws = 0 self._record_ws_start_time = time.monotonic() self._record_listen_for_events = True self._record_ws_messages = {} self._record_events_ws_messages = {} self._record_devices_ws_messages = {} # Start public API websockets if API key is available has_public_api = self.client.is_api_key_set() if has_public_api: self.log("Starting public API websockets...") events_ws = self.client._get_events_websocket() events_ws._subscription = self._handle_events_ws_message events_ws.start() devices_ws = self.client._get_devices_websocket() devices_ws._subscription = self._handle_devices_ws_message devices_ws.start() else: self.log("No API key set, skipping public API websocket recording...") self.log(f"Waiting {self.wait_time} seconds for WS messages...") if self._ws_progress is not None: await self._ws_progress(self.wait_time, "Waiting for WS messages") else: await asyncio.sleep(self.wait_time) self._record_listen_for_events = False await self.client.async_disconnect_ws() if has_public_api: events_ws.stop() devices_ws.stop() await self.write_json_file( "sample_ws_messages", self._record_ws_messages, anonymize=False, ) if has_public_api: await self.write_json_file( "sample_ws_events_public", self._record_events_ws_messages, anonymize=False, ) await self.write_json_file( "sample_ws_devices_public", self._record_devices_ws_messages, anonymize=False, ) @overload async def write_json_file( self, name: str, data: list[Any], anonymize: bool | None = None, ) -> list[Any]: ... @overload async def write_json_file( self, name: str, data: dict[str, Any], anonymize: bool | None = None, ) -> dict[str, Any]: ... async def write_json_file( self, name: str, data: list[Any] | dict[str, Any], anonymize: bool | None = None, ) -> list[Any] | dict[str, Any]: if anonymize is None: anonymize = self.anonymize if anonymize: data = anonymize_data(data) self.log(f"Writing {name}...") await write_json(self.output_folder / f"{name}.json", data) return data async def write_binary_file( self, name: str, ext: str, raw: bytes | None, ) -> None: def write() -> None: if raw is None: self.log(f"No image data, skipping {name}...") return self.log(f"Writing {name}...") Path(self.output_folder / f"{name}.{ext}").write_bytes(raw) loop = asyncio.get_running_loop() await loop.run_in_executor(None, write) async def write_image_file(self, name: str, raw: bytes | None) -> None: await self.write_binary_file(name, "png", raw) async def generate_event_data( self, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: data = await self.client.get_events_raw() self.constants["time"] = datetime.now(tz=UTC).isoformat() self.constants["event_count"] = len(data) motion_event: dict[str, Any] | None = None smart_detection: dict[str, Any] | None = None for event_dict in reversed(data): if ( motion_event is None and event_dict["type"] == EventType.MOTION.value and event_dict["camera"] is not None and event_dict["thumbnail"] is not None and event_dict["heatmap"] is not None and event_dict["end"] is not None ): motion_event = deepcopy(event_dict) self.log(f"Using motion event: {motion_event['id']}...") elif ( smart_detection is None and event_dict["type"] == EventType.SMART_DETECT.value and event_dict["camera"] is not None and event_dict["end"] is not None ): smart_detection = deepcopy(event_dict) self.log(f"Using smart detection event: {smart_detection['id']}...") if motion_event is not None and smart_detection is not None: break # anonymize data after pulling events data = await self.write_json_file("sample_raw_events", data) return motion_event, smart_detection async def generate_device_data( self, motion_event: dict[str, Any] | None, smart_detection: dict[str, Any] | None, ) -> None: await asyncio.gather( self.generate_camera_data(), self.generate_motion_data(motion_event), self.generate_smart_detection_data(smart_detection), self.generate_light_data(), self.generate_viewport_data(), self.generate_sensor_data(), self.generate_chime_data(), self.generate_aiport_data(), self.generate_bridge_data(), self.generate_liveview_data(), ) async def generate_camera_data(self) -> None: objs = await self.client.api_request_list("cameras") device_id: str | None = None camera_is_online = False for obj_dict in objs: device_id = obj_dict["id"] if is_online(obj_dict): camera_is_online = True break if device_id is None: self.log("No camera found. Skipping camera endpoints...") return # json data obj = await self.client.api_request_obj(f"cameras/{device_id}") await self.write_json_file("sample_camera", deepcopy(obj)) self.constants["camera_online"] = camera_is_online # Check if camera has channels if not obj.get("channels") or len(obj["channels"]) == 0: self.log( "Camera has no channels, skipping snapshot, thumbnail and heatmap generation", ) return if not camera_is_online: self.log( "Camera is not online, skipping snapshot, thumbnail and heatmap generation", ) return # snapshot width = obj["channels"][0]["width"] height = obj["channels"][0]["height"] filename = "sample_camera_snapshot" if self.anonymize: self.log(f"Writing {filename}...") placeholder_image(self.output_folder / f"{filename}.png", width, height) else: snapshot = await self.client.get_camera_snapshot(obj["id"], width, height) await self.write_image_file(filename, snapshot) # public api snapshot pub_filename = "sample_camera_public_api_snapshot" if self.anonymize: self.log(f"Writing {pub_filename}...") placeholder_image(self.output_folder / f"{pub_filename}.png", width, height) else: pub_snapshot = await self.client.get_public_api_camera_snapshot(obj["id"]) await self.write_image_file(pub_filename, pub_snapshot) async def generate_motion_data( self, motion_event: dict[str, Any] | None, ) -> None: if motion_event is None: self.log("No motion event, skipping thumbnail and heatmap generation...") return # event thumbnail filename = "sample_camera_thumbnail" thumbnail_id = motion_event["thumbnail"] if self.anonymize: self.log(f"Writing {filename}...") placeholder_image(self.output_folder / f"{filename}.png", 640, 360) thumbnail_id = anonymize_prefixed_event_id(thumbnail_id) else: img = await self.client.get_event_thumbnail(thumbnail_id) await self.write_image_file(filename, img) self.constants["camera_thumbnail"] = thumbnail_id # event heatmap filename = "sample_camera_heatmap" heatmap_id = motion_event["heatmap"] if self.anonymize: self.log(f"Writing {filename}...") placeholder_image(self.output_folder / f"{filename}.png", 640, 360) heatmap_id = anonymize_prefixed_event_id(heatmap_id) else: img = await self.client.get_event_heatmap(heatmap_id) await self.write_image_file(filename, img) self.constants["camera_heatmap"] = heatmap_id # event video filename = "sample_camera_video" length = int((motion_event["end"] - motion_event["start"]) / 1000) if self.anonymize: generate_blank_video( self.output_folder / f"{filename}.mp4", length, ) else: video = await self.client.get_camera_video( motion_event["camera"], from_js_time(motion_event["start"]), from_js_time(motion_event["end"]), 2, ) await self.write_binary_file(filename, "mp4", video) self.constants["camera_video_length"] = length async def generate_smart_detection_data( self, smart_detection: dict[str, Any] | None, ) -> None: if smart_detection is None: self.log("No smart detection event, skipping smart detection data...") return try: data = await self.client.get_event_smart_detect_track_raw( smart_detection["id"], ) except BadRequest: self.log_warning("Event smart tracking missing") else: await self.write_json_file("sample_event_smart_track", data) async def generate_light_data(self) -> None: objs = await self.client.api_request_list("lights") device_id: str | None = None for obj_dict in objs: device_id = obj_dict["id"] if is_online(obj_dict): break if device_id is None: self.log("No light found. Skipping light endpoints...") return obj = await self.client.api_request_obj(f"lights/{device_id}") await self.write_json_file("sample_light", obj) async def generate_viewport_data(self) -> None: objs = await self.client.api_request_list("viewers") device_id: str | None = None for obj_dict in objs: device_id = obj_dict["id"] if is_online(obj_dict): break if device_id is None: self.log("No viewer found. Skipping viewer endpoints...") return obj = await self.client.api_request_obj(f"viewers/{device_id}") await self.write_json_file("sample_viewport", obj) async def generate_sensor_data(self) -> None: objs = await self.client.api_request_list("sensors") device_id: str | None = None for obj_dict in objs: device_id = obj_dict["id"] if is_online(obj_dict): break if device_id is None: self.log("No sensor found. Skipping sensor endpoints...") return obj = await self.client.api_request_obj(f"sensors/{device_id}") await self.write_json_file("sample_sensor", obj) async def generate_chime_data(self) -> None: objs = await self.client.api_request_list("chimes") device_id: str | None = None for obj_dict in objs: device_id = obj_dict["id"] if is_online(obj_dict): break if device_id is None: self.log("No chime found. Skipping doorlock endpoints...") return obj = await self.client.api_request_obj(f"chimes/{device_id}") await self.write_json_file("sample_chime", obj) async def generate_aiport_data(self) -> None: objs = await self.client.api_request_list("aiports") device_id: str | None = None for obj_dict in objs: device_id = obj_dict["id"] if is_online(obj_dict): break if device_id is None: self.log("No aiport found. Skipping aiport endpoints...") return obj = await self.client.api_request_obj(f"aiports/{device_id}") await self.write_json_file("sample_aiport", obj) async def generate_bridge_data(self) -> None: objs = await self.client.api_request_list("bridges") device_id: str | None = None for obj_dict in objs: device_id = obj_dict["id"] if is_online(obj_dict): break if device_id is None: self.log("No bridge found. Skipping bridge endpoints...") return obj = await self.client.api_request_obj(f"bridges/{device_id}") await self.write_json_file("sample_bridge", obj) async def generate_liveview_data(self) -> None: objs = await self.client.api_request_list("liveviews") device_id: str | None = None for obj_dict in objs: device_id = obj_dict["id"] break if device_id is None: self.log("No liveview found. Skipping liveview endpoints...") return obj = await self.client.api_request_obj(f"liveviews/{device_id}") await self.write_json_file("sample_liveview", obj) def _handle_ws_message(self, msg: aiohttp.WSMessage) -> None: if not self._record_listen_for_events: return now = time.monotonic() self._record_num_ws += 1 time_offset = now - self._record_ws_start_time if msg.type == aiohttp.WSMsgType.BINARY: packet = WSPacket(msg.data) if not isinstance(packet.action_frame, WSJSONPacketFrame): self.log_warning( f"Got non-JSON action frame: {packet.action_frame.payload_format}", ) return if not isinstance(packet.data_frame, WSJSONPacketFrame): self.log_warning( f"Got non-JSON data frame: {packet.data_frame.payload_format}", ) return if self.anonymize: packet.action_frame.data = anonymize_data(packet.action_frame.data) packet.data_frame.data = anonymize_data(packet.data_frame.data) packet.pack_frames() self._record_ws_messages[str(time_offset)] = { "raw": packet.raw_base64, "action": packet.action_frame.data, "data": packet.data_frame.data, } else: self.log_warning(f"Got non-binary message: {msg.type}") def _handle_public_api_ws_message( self, msg: aiohttp.WSMessage, store: dict[str, Any], ) -> None: if not self._record_listen_for_events: return now = time.monotonic() time_offset = now - self._record_ws_start_time if msg.type == aiohttp.WSMsgType.TEXT: try: data = orjson.loads(msg.data) except (orjson.JSONDecodeError, TypeError) as exc: preview = str(msg.data)[:200] self.log_warning( f"Failed to parse public API WS message: {preview} ({exc})" ) return if self.anonymize: data = anonymize_data(data) store[str(time_offset)] = data else: self.log_warning( f"Got non-text public API WS message: {msg.type}", ) def _handle_events_ws_message(self, msg: aiohttp.WSMessage) -> None: self._handle_public_api_ws_message(msg, self._record_events_ws_messages) def _handle_devices_ws_message(self, msg: aiohttp.WSMessage) -> None: self._handle_public_api_ws_message(msg, self._record_devices_ws_messages) uilibs-uiprotect-317a52c/src/uiprotect/test_util/anonymize.py000066400000000000000000000213061524637705700245720ustar00rootroot00000000000000from __future__ import annotations import secrets import string import uuid import warnings from typing import Any from urllib.parse import urlparse from ..data import ModelType object_id_mapping: dict[str, str] = {} def anonymize_data(value: Any, name: str | None = None) -> Any: if isinstance(value, list): value = anonymize_list(value, name=name) elif isinstance(value, dict): value = anonymize_dict(value, name=name) else: value = anonymize_value(value, name=name) return value def anonymize_user(user_dict: dict[str, Any]) -> dict[str, Any]: for index, group_id in enumerate(user_dict.get("groups", [])): user_dict["groups"][index] = anonymize_object_id(group_id) user_dict["id"] = anonymize_object_id(user_dict["id"]) if "firstName" in user_dict: user_dict["firstName"] = random_word().title() user_dict["lastName"] = random_word().title() user_dict["name"] = f"{user_dict['firstName']} {user_dict['lastName']}" user_dict["localUsername"] = random_word() user_dict["email"] = f"{user_dict['localUsername']}@example.com" if "cloudAccount" in user_dict and user_dict["cloudAccount"] is not None: user_dict["cloudAccount"]["firstName"] = user_dict["firstName"] user_dict["cloudAccount"]["lastName"] = user_dict["lastName"] user_dict["cloudAccount"]["name"] = user_dict["name"] user_dict["cloudAccount"]["email"] = user_dict["email"] user_dict["cloudAccount"]["user"] = anonymize_object_id( user_dict["cloudAccount"]["user"], ) user_dict["cloudAccount"]["id"] = anonymize_uuid( user_dict["cloudAccount"]["id"], ) user_dict["cloudAccount"]["cloudId"] = anonymize_uuid( user_dict["cloudAccount"]["cloudId"], ) camera_order = (user_dict.get("settings") or {}).get("cameraOrder") if camera_order is not None: for index, camera_id in enumerate(camera_order): camera_order[index] = anonymize_object_id(camera_id) user_dict["settings"]["cameraOrder"] = camera_order if "allPermissions" in user_dict: user_dict["allPermissions"] = anonymize_list( user_dict["allPermissions"], "allPermissions", ) if "permissions" in user_dict: user_dict["permissions"] = anonymize_list( user_dict["permissions"], "permissions", ) return user_dict def anonymize_value(value: Any, name: str | None = None) -> Any: # noqa: PLR0912 if isinstance(value, str): if name == "accessKey": value = f"{random_number(13)}:{random_hex(24)}:{random_hex(128)}" elif name == "credentials": value = f"{random_hex(64)}" elif name == "privateToken": value = f"{random_alphanum(192)}" elif name in {"host", "connectionHost", "bindAddr"}: value = anonymize_ip(value) elif name in {"anonymousDeviceId", "hardwareId"}: value = random_identifier() elif name in {"rtspAlias", "ssid"}: value = random_alphanum(16) elif name in {"mac", "server_id"}: value = anonymize_peristent_string(value, random_hex(12).upper()) elif name == "bssid": value = anonymize_peristent_string(value, random_seperated_mac()) elif name in {"latitude", "longitude"}: value = "0.0" elif name == "name" and value != "Default": value = f"{random_word()} {random_word()}".title() elif name in {"owner", "user", "camera", "liveview", "authUserId", "event"}: value = anonymize_object_id(value) elif name == "rtsp": value = anonymize_rstp_url(value) elif value.startswith("liveview:*:"): liveview_id = value.split(":")[-1] value = f"liveview:*:{anonymize_object_id(liveview_id)}" return value def anonymize_dict(obj: dict[str, Any], name: str | None = None) -> dict[str, Any]: # noqa: PLR0912 obj_type = None if "modelKey" in obj: if obj["modelKey"] in [m.value for m in ModelType]: obj_type = ModelType(obj["modelKey"]) else: warnings.warn(f"Unknown modelKey: {obj['modelKey']}", stacklevel=2) if obj_type == ModelType.USER: return anonymize_user(obj) if name == "rtspsStreams": for key, value in obj.items(): if isinstance(value, str): obj[key] = anonymize_rstp_url(value) else: obj[key] = anonymize_data(value, name=key) return obj for key, value in obj.items(): handled = False if obj_type is not None or "payload" in obj: if key == "id": obj[key] = anonymize_object_id(value) handled = True elif obj_type == ModelType.EVENT: if key in {"thumbnail", "heatmap"}: obj[key] = anonymize_prefixed_event_id(value) handled = True elif key == "metadata": if "sensorId" in obj[key]: obj[key]["sensorId"]["text"] = anonymize_object_id( obj[key]["sensorId"]["text"], ) if "sensorName" in obj[key]: obj[key]["sensorName"]["text"] = ( f"{random_word()} {random_word()}".title() ) if not handled: obj[key] = anonymize_data(value, name=key) return obj def anonymize_list(items: list[Any], name: str | None = None) -> list[Any]: for index, value in enumerate(items): handled = False if isinstance(value, str) and name in { "hosts", "smartDetectEvents", "camera", "cameras", "rtspsStreams", }: handled = True if name == "hosts": items[index] = anonymize_ip(items[index]) elif name == "rtspsStreams": items[index] = anonymize_rstp_url(value) elif name in {"smartDetectEvents", "camera", "cameras"}: items[index] = anonymize_object_id(value) if not handled: items[index] = anonymize_data(value) return items def anonymize_prefixed_event_id(event_id: str) -> str: event_id = event_id[2:] return f"e-{anonymize_object_id(event_id)}" def anonymize_ip(ip: Any) -> Any: if not isinstance(ip, str): return ip if ip in {"0.0.0.0", "127.0.0.1", "255.255.255.255"}: # noqa: S104 return ip return anonymize_peristent_string(ip, random_ip(ip)) def anonymize_uuid(uuid_str: str) -> str: return anonymize_peristent_string(uuid_str, random_identifier()) def anonymize_object_id(obj_id: str) -> str: return anonymize_peristent_string(obj_id, random_hex(24)) def anonymize_peristent_string(value: str, default: str) -> str: if value not in object_id_mapping: object_id_mapping[value] = default return object_id_mapping[value] def anonymize_rstp_url(url: str) -> str: parts = urlparse(url) port = "" if parts.port is not None and parts.port != 554: port = f":{parts.port}" return f"{parts.scheme}://{anonymize_ip(url)}{port}/{random_alphanum(16)}" def random_hex(length: int) -> str: return secrets.token_hex(length // 2) def random_seperated_mac() -> str: return ":".join(random_hex(2) for _ in range(6)) def random_str(length: int, choices: str) -> str: return "".join(secrets.choice(choices) for _ in range(length)) def random_number(length: int) -> str: return random_str(length, string.digits) def random_word() -> str: return random_char(secrets.randbelow(5) + 3) def random_char(length: int) -> str: return random_str(length, string.ascii_letters) def random_alphanum(length: int) -> str: choices = string.ascii_letters + string.ascii_letters.upper() + string.digits return random_str(length, choices) def random_ip(input_ip: str) -> str: ip = "" try: octals = [int(i) for i in input_ip.split(".")] except ValueError: pass else: if octals[0] == 10: ip = f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}.{secrets.randbelow(256)}" elif octals[0] == 172 and 16 <= octals[1] <= 31: ip = f"172.{secrets.randbelow(16) + 16}.{secrets.randbelow(256)}.{secrets.randbelow(256)}" elif octals[0] == 192 and octals[1] == 168: ip = f"192.168.{secrets.randbelow(256)}.{secrets.randbelow(256)}" if not ip: ip = f"{secrets.randbelow(255) + 1}.{secrets.randbelow(256)}.{secrets.randbelow(256)}.{secrets.randbelow(256)}" return ip def random_identifier() -> str: return str(uuid.uuid4()) uilibs-uiprotect-317a52c/src/uiprotect/utils.py000066400000000000000000000540131524637705700217060ustar00rootroot00000000000000from __future__ import annotations import asyncio import contextlib import json import logging import math import os import re import socket import time import zoneinfo from collections import Counter from collections.abc import Callable, Coroutine, Iterable from copy import deepcopy from datetime import UTC, datetime, timedelta, tzinfo from decimal import Decimal from enum import Enum from functools import cache, lru_cache, partial from hashlib import sha224 from inspect import isclass from ipaddress import IPv4Address, IPv6Address, ip_address from operator import attrgetter from pathlib import Path from typing import TYPE_CHECKING, Any, TypeVar, Union, get_args, overload from uuid import UUID import jwt from .data.types import ( Color, SmartDetectAudioType, SmartDetectObjectType, Version, VideoMode, get_field_type, ) from .exceptions import NvrError if TYPE_CHECKING: from http.cookies import Morsel from aiohttp import ClientResponse from pydantic.fields import FieldInfo from uiprotect.api import ProtectApiClient from uiprotect.data import CoordType, Event from uiprotect.data.bootstrap import WSStat T = TypeVar("T") DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" DEBUG_ENV = "UFP_DEBUG" PROGRESS_CALLABLE = Callable[[int, str], Coroutine[Any, Any, None]] SNAKE_CASE_KEYS = [ "life_span", "bad_sector", "total_bytes", "used_bytes", "space_type", ] TIMEZONE_GLOBAL: tzinfo | None = None SNAKE_CASE_MATCH_1 = re.compile("(.)([A-Z0-9][a-z]+)") SNAKE_CASE_MATCH_2 = re.compile("__([A-Z0-9])") SNAKE_CASE_MATCH_3 = re.compile("([a-z0-9])([A-Z])") _LOGGER = logging.getLogger(__name__) _CREATE_TYPES = {UUID, Color, Decimal, Path, Version} _BAD_UUID = "00000000-0000-00 0- 000-000000000000" # All IP-related types that need special handling via _cached_ip_address # Note: Union is order-independent, so Union[A, B] == Union[B, A] _IP_TYPES = frozenset( { # Individual types (from get_field_type extracting first Union arg) IPv4Address, IPv6Address, # Union types (matched against field.annotation directly) Union[IPv4Address, None], Union[IPv4Address, str], Union[IPv4Address, str, None], Union[IPv6Address, None], Union[IPv6Address, str], Union[IPv6Address, str, None], Union[IPv4Address, IPv6Address], Union[IPv4Address, IPv6Address, None], Union[IPv4Address, IPv6Address, str], Union[IPv4Address, IPv6Address, str, None], } ) # Precompute which _IP_TYPES allow str to determine empty string handling _IP_TYPES_WITH_STR = frozenset(t for t in _IP_TYPES if str in get_args(t)) @lru_cache def to_camel(string: str) -> str: return "".join(word.capitalize() for word in string.split("_")) def set_debug() -> None: """Sets ENV variable for UFP_DEBUG to on (True)""" os.environ[DEBUG_ENV] = str(True) is_debug.cache_clear() def set_no_debug() -> None: """Sets ENV variable for UFP_DEBUG to off (False)""" os.environ[DEBUG_ENV] = str(False) is_debug.cache_clear() @cache def is_debug() -> bool: """Returns if debug ENV is on (True)""" return os.environ.get(DEBUG_ENV) == str(True) async def get_response_reason(response: ClientResponse) -> str: reason = str(response.reason) try: data = await response.json() reason = data.get("error", str(data)) except Exception: with contextlib.suppress(Exception): reason = await response.text() return reason @overload def to_js_time(dt: datetime | int) -> int: ... @overload def to_js_time(dt: None) -> None: ... def to_js_time(dt: datetime | int | None) -> int | None: """Converts Python datetime to Javascript timestamp""" if dt is None: return None if isinstance(dt, int): return dt if dt.tzinfo is None: return int(time.mktime(dt.timetuple()) * 1000) return int(dt.astimezone(UTC).timestamp() * 1000) def to_ms(duration: timedelta | None) -> int | None: """Converts python timedelta to Milliseconds""" if duration is None: return None return round(duration.total_seconds() * 1000) def utc_now() -> datetime: return datetime.now(tz=UTC) def from_js_time(num: float | str | datetime) -> datetime: """Converts Javascript timestamp or ISO-8601 string to Python datetime""" if isinstance(num, datetime): return num if isinstance(num, str) and "T" in num: # ISO-8601 path — Python 3.11+ handles a trailing "Z" natively. dt = datetime.fromisoformat(num) return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) return datetime.fromtimestamp(int(num) / 1000, tz=UTC) @lru_cache(maxsize=1024) def convert_to_datetime(source_time: float | str | datetime | None) -> datetime | None: """Converts timestamp to datetime object""" return None if source_time is None else from_js_time(source_time) def format_datetime( dt: datetime | None, default: str | None = None, ) -> str | None: """Formats a datetime object in a consistent format""" return default if dt is None else dt.strftime(DATETIME_FORMAT) def is_online(data: dict[str, Any]) -> bool: return bool(data["state"] == "CONNECTED") def is_doorbell(data: dict[str, Any]) -> bool: return "doorbell" in str(data["type"]).lower() @lru_cache(maxsize=1024) def to_snake_case(name: str) -> str: """Converts string to snake_case""" name = SNAKE_CASE_MATCH_1.sub(r"\1_\2", name) name = SNAKE_CASE_MATCH_2.sub(r"_\1", name) name = SNAKE_CASE_MATCH_3.sub(r"\1_\2", name) return name.lower() def to_camel_case(name: str) -> str: """Converts string to camelCase""" # repeated runs through should not keep lowercasing if "_" in name: name = to_camel(name) return name[0].lower() + name[1:] return name _EMPTY_UUID = UUID("0" * 32) def convert_unifi_data(value: Any, field: FieldInfo) -> Any: # noqa: PLR0911, PLR0912 """Converts value from UFP data into pydantic field class""" origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type] if type_ is Any: return value if origin is not None: if origin is list and isinstance(value, list): return [convert_unifi_data(v, field) for v in value] if origin is set and isinstance(value, list): return {convert_unifi_data(v, field) for v in value} if origin is dict and isinstance(value, dict): return {k: convert_unifi_data(v, field) for k, v in value.items()} if value is not None: if type_ in _IP_TYPES: # Handle IP addresses - use _cached_ip_address to support cases where # UniFi returns IPv6 for a field typed as IPv4Address or vice versa if value == "": # Return empty string only if str is in the union, otherwise None return value if type_ in _IP_TYPES_WITH_STR else None return _cached_ip_address(value) if type_ is datetime: return from_js_time(value) if type_ in _CREATE_TYPES: # cannot do this check too soon because some types cannot be used in isinstance if isinstance(value, type_): return value if type_ is UUID: if not value: return None # handle edge case for improperly formatted UUIDs # 00000000-0000-00 0- 000-000000000000 if value == _BAD_UUID: return _EMPTY_UUID return type_(value) if _is_enum_type(type_): if _is_from_string_enum(type_): return type_.from_string(value) return type_(value) return value @lru_cache def _cached_ip_address(value: str) -> IPv4Address | IPv6Address | str: try: return ip_address(value) except ValueError: return value @lru_cache def _is_enum_type(type_: Any) -> bool: """Checks if type is an Enum.""" return isclass(type_) and issubclass(type_, Enum) @lru_cache def _is_from_string_enum(type_: Any) -> bool: """Checks if Enum has from_string method.""" return hasattr(type_, "from_string") def serialize_unifi_obj(value: Any, levels: int = -1) -> Any: # noqa: PLR0911 """Serializes UFP data""" if unifi_dict := getattr(value, "unifi_dict", None): value = unifi_dict() if levels != 0 and isinstance(value, dict): return serialize_dict(value, levels=levels - 1) if levels != 0 and isinstance(value, Iterable) and not isinstance(value, str): return serialize_list(value, levels=levels - 1) if isinstance(value, Enum): return value.value if isinstance(value, (IPv4Address, IPv6Address, UUID, Path, tzinfo, Version)): return str(value) if isinstance(value, datetime): return to_js_time(value) if isinstance(value, timedelta): return to_ms(value) if isinstance(value, Color): return value.as_hex().upper() return value def serialize_dict(data: dict[str, Any], levels: int = -1) -> dict[str, Any]: """Serializes UFP data dict""" for key in list(data): set_key = key if set_key not in SNAKE_CASE_KEYS: set_key = to_camel_case(set_key) data[set_key] = serialize_unifi_obj(data.pop(key), levels=levels) return data def serialize_coord(coord: CoordType) -> int | float: """Serializes UFP zone coordinate""" if not isinstance(coord, float): return coord if math.isclose(coord, 0) or math.isclose(coord, 1): return int(coord) return coord def serialize_point(point: tuple[CoordType, CoordType]) -> list[int | float]: """Serializes UFP zone coordinate point""" return [ serialize_coord(point[0]), serialize_coord(point[1]), ] def serialize_list(items: Iterable[Any], levels: int = -1) -> list[Any]: """Serializes UFP data list""" return [serialize_unifi_obj(i, levels=levels) for i in items] def convert_smart_types(items: Iterable[str]) -> list[SmartDetectObjectType]: """Converts list of str into SmartDetectObjectType. Any unknown values will be ignored and logged.""" types = [] for smart_type in items: try: types.append(SmartDetectObjectType(smart_type)) except ValueError: _LOGGER.warning("Unknown smart detect type: %s", smart_type) return types def convert_smart_audio_types(items: Iterable[str]) -> list[SmartDetectAudioType]: """Converts list of str into SmartDetectAudioType. Any unknown values will be ignored and logged.""" types = [] for smart_type in items: try: types.append(SmartDetectAudioType(smart_type)) except ValueError: _LOGGER.warning("Unknown smart detect audio type: %s", smart_type) return types def convert_video_modes(items: Iterable[str]) -> list[VideoMode]: """Converts list of str into VideoMode. Any unknown values will be ignored and logged.""" types = [] for video_mode in items: mode = VideoMode(video_mode) if mode is VideoMode.UNKNOWN and video_mode != VideoMode.UNKNOWN.value: _LOGGER.warning("Unknown video mode: %s", video_mode) continue types.append(mode) return types async def ip_from_host(host: str) -> IPv4Address | IPv6Address: """ Resolve hostname to IP address (IPv4 or IPv6). Raises: ValueError: If host cannot be resolved to IP address """ try: return ip_address(host) except ValueError: pass try: loop = asyncio.get_running_loop() addr_info = await loop.getaddrinfo(host, None) ip_str = addr_info[0][4][0] except (socket.gaierror, OSError) as err: raise ValueError(f"Cannot resolve hostname '{host}' to IP address") from err return ip_address(ip_str) def format_host_for_url(host: IPv4Address | IPv6Address | str) -> str: """Format host for URLs. IPv6 addresses are wrapped in brackets.""" if isinstance(host, str): try: parsed_host = ip_address(host) except ValueError: return host host = parsed_host if isinstance(host, IPv6Address): return f"[{host}]" return str(host) def dict_diff(orig: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]: changed: dict[str, Any] = {} if orig is None: return new for key, value in new.items(): if key not in orig: changed[key] = deepcopy(value) continue if isinstance(value, dict): sub_changed = dict_diff(orig[key], value) if sub_changed: changed[key] = sub_changed elif value != orig[key]: changed[key] = deepcopy(value) return changed def ws_stat_summmary( stats: list[WSStat], ) -> tuple[list[WSStat], float, Counter[str], Counter[str], Counter[str]]: if len(stats) == 0: raise ValueError("No stats to summarize") unfiltered = [s for s in stats if not s.filtered] percent = (1 - len(unfiltered) / len(stats)) * 100 keys = Counter(k for s in unfiltered for k in s.keys_set) models = Counter(k.model for k in unfiltered) actions = Counter(k.action for k in unfiltered) return unfiltered, percent, keys, models, actions async def write_json(output_path: Path, data: list[Any] | dict[str, Any]) -> None: def write() -> None: with output_path.open("w", encoding="utf-8") as f: json.dump(data, f, indent=4) f.write("\n") loop = asyncio.get_running_loop() await loop.run_in_executor(None, write) def print_ws_stat_summary( stats: list[WSStat], output: Callable[[Any], Any] | None = None, ) -> None: if output is None: # typer<0.4.1 is incompatible with click>=8.1.0, so the import is # deferred to keep that breakage CLI-only. typer is also an optional # dependency (the `cli` extra); fall back to print when it is absent. try: import typer # noqa: PLC0415 output = typer.echo except ImportError: output = print unfiltered, percent, keys, models, actions = ws_stat_summmary(stats) title = " ws stat summary " side_length = int((80 - len(title)) / 2) lines = [ "-" * side_length + title + "-" * side_length, f"packet count: {len(stats)}", f"filtered packet count: {len(unfiltered)} ({percent:.4}%)", "-" * 80, ] for key, count in models.most_common(): lines.append(f"{key}: {count}") lines.append("-" * 80) for key, count in actions.most_common(): lines.append(f"{key}: {count}") lines.append("-" * 80) for key, count in keys.most_common(10): lines.append(f"{key}: {count}") lines.append("-" * 80) output("\n".join(lines)) async def profile_ws( protect: ProtectApiClient, duration: int, output_path: Path | None = None, ws_progress: PROGRESS_CALLABLE | None = None, do_print: bool = True, print_output: Callable[[Any], Any] | None = None, ) -> None: if protect.bootstrap.capture_ws_stats: raise NvrError("Profile already in progress") _LOGGER.debug("Starting profile...") protect.bootstrap.clear_ws_stats() protect.bootstrap.capture_ws_stats = True if ws_progress is not None: await ws_progress(duration, "Waiting for WS messages") else: await asyncio.sleep(duration) protect.bootstrap.capture_ws_stats = False _LOGGER.debug("Finished profile...") if output_path: json_data = [s.__dict__ for s in protect.bootstrap.ws_stats] await write_json(output_path, json_data) if do_print: print_ws_stat_summary(protect.bootstrap.ws_stats, output=print_output) def decode_token_cookie(token_cookie: Morsel[str]) -> dict[str, Any] | None: """Decode a token cookie if it is still valid.""" try: return jwt.decode( token_cookie.value, options={"verify_signature": False, "verify_exp": True}, ) except jwt.ExpiredSignatureError: _LOGGER.debug("Authentication token has expired.") return None except Exception as broad_ex: _LOGGER.debug("Authentication token decode error: %s", broad_ex) return None def format_duration(duration: timedelta) -> str: """Formats a timedelta as a string.""" seconds = int(duration.total_seconds()) hours = seconds // 3600 seconds -= hours * 3600 minutes = seconds // 60 seconds -= minutes * 60 output = "" if hours > 0: output = f"{hours}h" if minutes > 0: output = f"{output}{minutes}m" return f"{output}{seconds}s" def _set_timezone(tz: tzinfo | str) -> tzinfo: global TIMEZONE_GLOBAL # noqa: PLW0603 if isinstance(tz, str): tz = zoneinfo.ZoneInfo(tz) TIMEZONE_GLOBAL = tz return TIMEZONE_GLOBAL def get_local_timezone() -> tzinfo: """Gets Olson timezone name for localizing datetimes""" if TIMEZONE_GLOBAL is not None: return TIMEZONE_GLOBAL try: from homeassistant.util import ( # noqa: PLC0415 dt as dt_util, # type: ignore[import-not-found] ) return _set_timezone(dt_util.DEFAULT_TIME_ZONE) except ImportError: pass timezone_name = os.environ.get("TZ") if timezone_name: return _set_timezone(timezone_name) timezone_name = "UTC" timezone_locale = Path("/etc/localtime") if timezone_locale.exists(): tzfile_digest = sha224(Path(timezone_locale).read_bytes()).hexdigest() zoneinfo_root = Path("/usr/share/zoneinfo") for root, _, filenames in os.walk(zoneinfo_root): for filename in filenames: fullname = Path(root) / filename digest = sha224(fullname.read_bytes()).hexdigest() if digest == tzfile_digest: timezone_name = fullname.relative_to(zoneinfo_root).as_posix() return _set_timezone(timezone_name) def local_datetime(dt: datetime | None = None) -> datetime: """Returns datetime in local timezone""" if dt is None: dt = datetime.now(tz=UTC) local_tz = get_local_timezone() if dt.tzinfo is None: return dt.replace(tzinfo=local_tz) return dt.astimezone(local_tz) def log_event(event: Event) -> None: from uiprotect.data import EventType # noqa: PLC0415 _LOGGER.debug("event WS msg: %s", event.model_dump()) if "smart" not in event.type.value: return camera = event.camera if camera is None: return if event.end is not None: _LOGGER.debug( "%s (%s): Smart detection ended for %s (%s)", camera.name, camera.mac, event.smart_detect_types, event.id, ) return _LOGGER.debug( "%s (%s): New smart detection started for %s (%s)", camera.name, camera.mac, event.smart_detect_types, event.id, ) smart_settings = camera.smart_detect_settings for smart_type in event.smart_detect_types: is_audio = event.type is EventType.SMART_AUDIO_DETECT if is_audio: if smart_type.audio_type is None: return is_enabled = ( smart_settings.audio_types is not None and smart_type.audio_type in smart_settings.audio_types ) last_event = camera.get_last_smart_audio_detect_event(smart_type.audio_type) else: is_enabled = smart_type in smart_settings.object_types last_event = camera.get_last_smart_detect_event(smart_type) _LOGGER.debug( "Event info (%s):\n" " is_smart_detected: %s\n" " is_recording_enabled: %s\n" " is_enabled: %s\n" " event: %s", smart_type, camera.is_smart_detected, camera.is_recording_enabled, is_enabled, last_event, ) def run_async(callback: Coroutine[Any, Any, T]) -> T: """Run async coroutine.""" return asyncio.run(callback) def clamp_value(value: float, step_size: float) -> float: """Clamps value to multiples of step size.""" ratio = 1 / step_size return int(value * ratio) / ratio @lru_cache(maxsize=1024) def normalize_mac(mac: str) -> str: """Normalize MAC address.""" return mac.lower().replace(":", "").replace("-", "").replace("_", "") _SENTINEL = object() def get_nested_attr(attrs: tuple[str, ...], obj: Any) -> Any: """Fetch a nested attribute.""" value = obj for key in attrs: if (value := getattr(value, key, _SENTINEL)) is _SENTINEL: return None return value def get_nested_attr_as_bool(attrs: tuple[str, ...], obj: Any) -> bool: """Fetch a nested attribute as a bool.""" value = obj for key in attrs: if (value := getattr(value, key, _SENTINEL)) is _SENTINEL: return False return bool(value) def get_top_level_attr_as_bool(attr: str, obj: Any) -> Any: """Fetch a top level attribute as a bool.""" return bool(getattr(obj, attr)) def make_value_getter(ufp_value: str) -> Callable[[T], Any]: """Return a function to get a value from a Protect device.""" if "." not in ufp_value: return attrgetter(ufp_value) return partial(get_nested_attr, tuple(ufp_value.split("."))) def make_enabled_getter(ufp_enabled: str) -> Callable[[T], bool]: """Return a function to get a value from a Protect device.""" if "." not in ufp_enabled: return attrgetter(ufp_enabled) return partial(get_nested_attr, tuple(ufp_enabled.split("."))) def make_required_getter(ufp_required_field: str) -> Callable[[T], bool]: """Return a function to get a value from a Protect device.""" if "." not in ufp_required_field: return partial(get_top_level_attr_as_bool, ufp_required_field) return partial(get_nested_attr_as_bool, tuple(ufp_required_field.split("."))) @lru_cache def timedelta_total_seconds(td: timedelta) -> float: return td.total_seconds() def pybool_to_json_bool(value: bool) -> str: """Convert a Python bool to a JSON boolean string ('true'/'false').""" return "true" if value else "false" uilibs-uiprotect-317a52c/src/uiprotect/websocket.py000066400000000000000000000254271524637705700225430ustar00rootroot00000000000000"""UniFi Protect Websockets.""" from __future__ import annotations import asyncio import contextlib import logging from collections.abc import Awaitable, Callable, Coroutine from enum import Enum from http import HTTPStatus from typing import TYPE_CHECKING, Any import aiohttp from aiohttp import ( ClientError, ClientSession, ClientWebSocketResponse, WSMessage, WSMsgType, WSServerHandshakeError, ) from .exceptions import NotAuthorized, NvrError if TYPE_CHECKING: from yarl import URL _LOGGER = logging.getLogger(__name__) AuthCallbackType = Callable[..., Coroutine[Any, Any, dict[str, str] | None]] GetSessionCallbackType = Callable[[], Awaitable[ClientSession]] UpdateBootstrapCallbackType = Callable[[], None] _CLOSE_MESSAGE_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED} # Consecutive 401 handshakes before AUTH_FAILED is surfaced. One retry lets a # private-WS forced re-auth heal a transient/expired session before we cry wolf; # a static public key still fails the second attempt and surfaces immediately. _AUTH_FAILURE_THRESHOLD = 2 class WebsocketState(Enum): CONNECTED = True DISCONNECTED = False # A 401 handshake that repeated past the retry threshold. For a public # (API-key) websocket the key is static and cannot self-recover, so this # is the reauth trigger a subscribe-only consumer never got before. AUTH_FAILED = "auth_failed" class Websocket: """UniFi Protect Websocket manager.""" _running = False _headers: dict[str, str] | None = None _websocket_loop_task: asyncio.Task[None] | None = None _stop_task: asyncio.Task[None] | None = None _ws_connection: ClientWebSocketResponse | None = None def __init__( self, get_url: Callable[[], URL], auth_callback: AuthCallbackType, update_bootstrap: UpdateBootstrapCallbackType, get_session: GetSessionCallbackType, subscription: Callable[[WSMessage], None], state_callback: Callable[[WebsocketState], None], *, timeout: float = 30.0, backoff: int = 10, auth_failed_backoff: int = 120, verify: bool = True, receive_timeout: float | None = None, heartbeat: float | None = None, ) -> None: """Init Websocket.""" self.get_url = get_url self.timeout = timeout self.receive_timeout = receive_timeout self.heartbeat = heartbeat self.backoff = backoff self.auth_failed_backoff = auth_failed_backoff self.verify = verify self._get_session = get_session self._auth = auth_callback self._update_bootstrap = update_bootstrap self._subscription = subscription self._seen_non_close_message = False self._websocket_state = state_callback self._current_state: WebsocketState = WebsocketState.DISCONNECTED self._consecutive_auth_failures = 0 self._reconnect_now = asyncio.Event() @property def is_connected(self) -> bool: """Return if the websocket connection is open.""" return self._ws_connection is not None and not self._ws_connection.closed async def _websocket_loop(self) -> None: """Running loop for websocket.""" await self.wait_closed() while True: url = self.get_url() auth_failed = await self._run_inner_loop(url) if ( auth_failed and self._consecutive_auth_failures >= _AUTH_FAILURE_THRESHOLD ): # AUTH_FAILED implies "not connected". On entry, emit the # DISCONNECTED transition first so a consumer that only reacts # to connect/disconnect edges observes the loss before # AUTH_FAILED latches (a no-op dedupe when already # disconnected). Skipping it once AUTH_FAILED is latched keeps # the backoff loop from flapping DISCONNECTED/AUTH_FAILED. if self._current_state is not WebsocketState.AUTH_FAILED: self._state_changed(WebsocketState.DISCONNECTED) self._state_changed(WebsocketState.AUTH_FAILED) backoff = self.auth_failed_backoff else: self._state_changed(WebsocketState.DISCONNECTED) backoff = self.backoff if self._running is False: break _LOGGER.debug("Reconnecting websocket in %s seconds", backoff) try: await asyncio.wait_for(self._reconnect_now.wait(), timeout=backoff) except TimeoutError: pass finally: self._reconnect_now.clear() async def _run_inner_loop(self, url: URL) -> bool: """Run one connect/receive cycle; return whether it ended on a 401.""" try: await self._websocket_inner_loop(url) except ClientError as ex: level = logging.ERROR if self._seen_non_close_message else logging.DEBUG if isinstance(ex, WSServerHandshakeError): if ex.status == HTTPStatus.UNAUTHORIZED.value: _LOGGER.log( level, "Websocket authentication error: %s: %s", url, ex ) await self._attempt_auth(True) self._consecutive_auth_failures += 1 return True _LOGGER.log(level, "Websocket handshake error: %s: %s", url, ex) else: _LOGGER.log(level, "Websocket disconnect error: %s: %s", url, ex) except TimeoutError: level = logging.ERROR if self._seen_non_close_message else logging.DEBUG _LOGGER.log(level, "Websocket timeout: %s", url) except Exception: _LOGGER.exception("Unexpected error in websocket loop") self._consecutive_auth_failures = 0 return False def _state_changed(self, state: WebsocketState) -> None: """State changed.""" if self._current_state is state: return self._current_state = state self._websocket_state(state) async def _websocket_inner_loop(self, url: URL) -> None: _LOGGER.debug("Connecting WS to %s", url) await self._attempt_auth(False) msg: WSMessage | None = None self._seen_non_close_message = False session = await self._get_session() # catch any and all errors for Websocket so we can clean up correctly try: self._ws_connection = await session.ws_connect( url, ssl=self.verify, headers=self._headers, timeout=aiohttp.ClientWSTimeout(ws_close=self.timeout), heartbeat=self.heartbeat, ) # Connectivity, not data receipt, defines CONNECTED: ws_connect + # auth have succeeded here, so emit it before the receive loop. # On a quiet channel the first data frame can be minutes away; # gating CONNECTED behind it flapped consumers. _seen_non_close_message # stays purely for the bootstrap-invalidation heuristic below. self._state_changed(WebsocketState.CONNECTED) self._consecutive_auth_failures = 0 while True: msg = await self._ws_connection.receive(self.receive_timeout) msg_type = msg.type if msg_type is WSMsgType.ERROR: _LOGGER.error("Error from Websocket: %s", msg.data) break if msg_type in _CLOSE_MESSAGE_TYPES: _LOGGER.debug("Websocket closed: %s", msg) break self._seen_non_close_message = True try: self._subscription(msg) except Exception: _LOGGER.exception("Error processing websocket message") finally: if ( msg is not None and msg.type is WSMsgType.CLOSE # If it closes right away or lastUpdateId is in the extra # its an indication that we should update the bootstrap # since lastUpdateId is invalid and ( not self._seen_non_close_message or (msg.extra and "lastUpdateId" in msg.extra) ) ): self._update_bootstrap() _LOGGER.debug("Websocket disconnected: last message: %s", msg) if self._ws_connection is not None and not self._ws_connection.closed: await self._ws_connection.close() self._ws_connection = None async def _attempt_auth(self, force: bool) -> None: """Attempt to authenticate.""" try: self._headers = await self._auth(force) except (NotAuthorized, NvrError) as ex: _LOGGER.debug("Error authenticating websocket: %s", ex) except Exception: _LOGGER.exception("Unknown error authenticating websocket") def start(self) -> None: """Start the websocket.""" if self._running: return self._running = True self._websocket_loop_task = asyncio.create_task(self._websocket_loop()) def stop(self) -> None: """Disconnect the websocket.""" _LOGGER.debug("Disconnecting websocket...") if not self._running: return if self._websocket_loop_task: self._websocket_loop_task.cancel() self._running = False ws_connection = self._ws_connection websocket_loop_task = self._websocket_loop_task self._ws_connection = None self._websocket_loop_task = None self._stop_task = asyncio.create_task( self._stop(ws_connection, websocket_loop_task) ) self._state_changed(WebsocketState.DISCONNECTED) def reset_auth_failure(self) -> None: """ Clear the auth-failure backoff and wake the reconnect loop. Call after installing a fresh credential (e.g. a new API key) so a websocket parked on the long ``auth_failed_backoff`` retries at once. """ self._consecutive_auth_failures = 0 self._reconnect_now.set() async def wait_closed(self) -> None: """Wait for the websocket to close.""" if self._stop_task and not self._stop_task.done(): with contextlib.suppress(asyncio.CancelledError): await self._stop_task self._stop_task = None async def _stop( self, ws_connection: ClientWebSocketResponse | None, websocket_loop_task: asyncio.Task[None] | None, ) -> None: """Stop the websocket.""" if ws_connection: await ws_connection.close() if websocket_loop_task: with contextlib.suppress(asyncio.CancelledError): await websocket_loop_task uilibs-uiprotect-317a52c/templates/000077500000000000000000000000001524637705700173625ustar00rootroot00000000000000uilibs-uiprotect-317a52c/templates/CHANGELOG.md.j2000066400000000000000000000012351524637705700215060ustar00rootroot00000000000000# Changelog {%- for version, release in context.history.released.items() %} ## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) {%- for category, commits in release["elements"].items() %} {# Category title: Breaking, Fix, Documentation #} ### {{ category | capitalize }} {# List actual changes in the category #} {%- for commit in commits %} {% if commit is not none and commit.descriptions is defined %} - {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) {% endif %} {%- endfor %}{# for commit #} {%- endfor %}{# for category, commits #} {%- endfor %}{# for version, release #} uilibs-uiprotect-317a52c/tests/000077500000000000000000000000001524637705700165265ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/__init__.py000066400000000000000000000000001524637705700206250ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/benchmarks/000077500000000000000000000000001524637705700206435ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/benchmarks/__init__.py000066400000000000000000000002401524637705700227500ustar00rootroot00000000000000""" CodSpeed benchmarks for uiprotect. These benchmarks target hot paths that have historically had regressions — primarily websocket packet processing. """ uilibs-uiprotect-317a52c/tests/benchmarks/conftest.py000066400000000000000000000063321524637705700230460ustar00rootroot00000000000000""" Fixtures for websocket processing benchmarks. Builds a fully populated Bootstrap once per session and pre-decodes the sample websocket message stream into WSPacket objects. Each benchmark then runs tight loops against already-constructed objects so the measurement reflects the production hot path rather than fixture overhead. """ from __future__ import annotations import asyncio import base64 import copy from typing import TYPE_CHECKING, Any import pytest from tests.conftest import ( SimpleMockWebsocket, read_json_file, setup_client, ) from uiprotect import ProtectApiClient from uiprotect.data import WSPacket if TYPE_CHECKING: from uiprotect.data import Bootstrap def _build_client() -> ProtectApiClient: return ProtectApiClient( "127.0.0.1", 0, "username", "password", ws_timeout=0.1, store_sessions=False, ) @pytest.fixture(scope="session") def benchmark_bootstrap() -> Bootstrap: """Return a fully bootstrapped client's Bootstrap, shared across tests.""" loop = asyncio.new_event_loop() try: client = _build_client() loop.run_until_complete(setup_client(client, SimpleMockWebsocket())) bootstrap = client.bootstrap bootstrap.capture_ws_stats = False # The client is intentionally leaked for the session — cleanup_client # would tear down the websocket/session that the bootstrap references. return bootstrap finally: loop.close() @pytest.fixture(scope="session") def ws_raw_frames() -> list[bytes]: """Return the raw websocket frame bytes from the sample stream.""" messages = read_json_file("sample_ws_messages") return [base64.b64decode(entry["raw"]) for entry in messages.values()] @pytest.fixture(scope="session") def ws_packets(ws_raw_frames: list[bytes]) -> list[WSPacket]: """ Pre-decoded WSPackets. Frames are eagerly populated so that ``process_ws_packet`` measurements exclude both wrapper construction and msgpack/json decoding. """ packets: list[WSPacket] = [] for raw in ws_raw_frames: packet = WSPacket(raw) # Force decode so later access is a no-op. _ = packet.action_frame _ = packet.data_frame packets.append(packet) return packets @pytest.fixture(scope="session") def ws_packet_templates( ws_packets: list[WSPacket], ) -> list[tuple[Any, Any]]: """ Deep-copied snapshots of each packet's decoded action/data dicts. ``process_ws_packet`` mutates these dicts in place during type conversion (e.g. ``lastSeen`` int → datetime). Benchmarks use these snapshots in an untimed ``setup`` hook to restore fresh state between rounds without re-paying decode cost. """ return [ (copy.deepcopy(p.action_frame.data), copy.deepcopy(p.data_frame.data)) for p in ws_packets ] def reset_ws_packets( packets: list[WSPacket], templates: list[tuple[Any, Any]], ) -> None: """Restore each pre-decoded packet's dicts from its template snapshot.""" for packet, (action_data, frame_data) in zip(packets, templates, strict=True): packet.action_frame.data = copy.deepcopy(action_data) packet.data_frame.data = copy.deepcopy(frame_data) uilibs-uiprotect-317a52c/tests/benchmarks/test_process_ws_packet.py000066400000000000000000000076701524637705700260040ustar00rootroot00000000000000""" Benchmarks for Bootstrap.process_ws_packet. Websocket packet processing is the hottest path in uiprotect — every Protect event flows through it and historically this path has regressed silently. These benchmarks exercise the dispatch pipeline against a real recorded message stream. The timed region calls ``process_ws_packet`` only. Packet wrapper construction and JSON decoding are paid once at session setup; an untimed ``setup`` hook restores the decoded dicts between rounds to undo the in-place mutation (e.g. ``lastSeen`` int → datetime) that would otherwise corrupt state across iterations. The tests are async so ``process_ws_packet``'s error recovery path — which schedules refreshes via ``asyncio.create_task`` — has a running loop. """ from __future__ import annotations from typing import TYPE_CHECKING, Any import pytest from tests.benchmarks.conftest import reset_ws_packets from uiprotect.data import ModelType if TYPE_CHECKING: from pytest_codspeed import BenchmarkFixture from uiprotect.data import Bootstrap, WSPacket _SUBSCRIBED_MODELS: set[ModelType] = { ModelType.EVENT, ModelType.CAMERA, ModelType.LIGHT, ModelType.VIEWPORT, ModelType.SENSOR, ModelType.LIVEVIEW, } _ROUNDS = 5 @pytest.mark.asyncio async def test_process_ws_packet_all( benchmark: BenchmarkFixture, benchmark_bootstrap: Bootstrap, ws_packets: list[WSPacket], ws_packet_templates: list[tuple[Any, Any]], ) -> None: """Process every recorded packet with no model filter.""" bootstrap = benchmark_bootstrap bootstrap.capture_ws_stats = False process = bootstrap.process_ws_packet def _setup() -> None: reset_ws_packets(ws_packets, ws_packet_templates) def _run() -> None: for packet in ws_packets: process(packet) benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS) @pytest.mark.asyncio async def test_process_ws_packet_filtered( benchmark: BenchmarkFixture, benchmark_bootstrap: Bootstrap, ws_packets: list[WSPacket], ws_packet_templates: list[tuple[Any, Any]], ) -> None: """Process every recorded packet with the default HA subscription filter.""" bootstrap = benchmark_bootstrap bootstrap.capture_ws_stats = False process = bootstrap.process_ws_packet models = _SUBSCRIBED_MODELS def _setup() -> None: reset_ws_packets(ws_packets, ws_packet_templates) def _run() -> None: for packet in ws_packets: process(packet, models) benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS) @pytest.mark.asyncio async def test_process_ws_packet_with_stats( benchmark: BenchmarkFixture, benchmark_bootstrap: Bootstrap, ws_packets: list[WSPacket], ws_packet_templates: list[tuple[Any, Any]], ) -> None: """Process every recorded packet with WS stats capture enabled.""" bootstrap = benchmark_bootstrap bootstrap.capture_ws_stats = True process = bootstrap.process_ws_packet def _setup() -> None: bootstrap.clear_ws_stats() reset_ws_packets(ws_packets, ws_packet_templates) def _run() -> None: for packet in ws_packets: process(packet) try: benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS) finally: bootstrap.capture_ws_stats = False bootstrap.clear_ws_stats() @pytest.mark.asyncio async def test_process_ws_packet_ignore_stats( benchmark: BenchmarkFixture, benchmark_bootstrap: Bootstrap, ws_packets: list[WSPacket], ws_packet_templates: list[tuple[Any, Any]], ) -> None: """Process every recorded packet with ignore_stats set.""" bootstrap = benchmark_bootstrap bootstrap.capture_ws_stats = False process = bootstrap.process_ws_packet def _setup() -> None: reset_ws_packets(ws_packets, ws_packet_templates) def _run() -> None: for packet in ws_packets: process(packet, None, True) benchmark.pedantic(_run, setup=_setup, rounds=_ROUNDS) uilibs-uiprotect-317a52c/tests/common.py000066400000000000000000000013771524637705700204000ustar00rootroot00000000000000"""Common test utils.""" from uiprotect.data.base import ProtectModel def assert_equal_dump( obj1: list[ProtectModel] | dict[str, ProtectModel] | ProtectModel, obj2: list[ProtectModel] | dict[str, ProtectModel] | ProtectModel, ) -> bool: if isinstance(obj1, dict): obj1_dumped = {k: v.model_dump() for k, v in obj1.items()} elif isinstance(obj1, list): obj1_dumped = [item.model_dump() for item in obj1] else: obj1_dumped = obj1.model_dump() if isinstance(obj2, dict): obj2_dumped = {k: v.model_dump() for k, v in obj2.items()} elif isinstance(obj2, list): obj2_dumped = [item.model_dump() for item in obj2] else: obj2_dumped = obj2.model_dump() assert obj1_dumped == obj2_dumped uilibs-uiprotect-317a52c/tests/conftest.py000066400000000000000000001054211524637705700207300ustar00rootroot00000000000000from __future__ import annotations import asyncio import base64 import json import math import os from copy import deepcopy from datetime import UTC, datetime from functools import cache from pathlib import Path from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock import aiofiles import aiohttp import av import pytest import pytest_asyncio from tests.sample_data.constants import CONSTANTS from uiprotect import ProtectApiClient from uiprotect.data import NVR, Camera, ModelType from uiprotect.data.devices import PTZRange, PTZZoomRange from uiprotect.data.nvr import Event from uiprotect.data.types import EventType from uiprotect.utils import _BAD_UUID, set_debug, set_no_debug if TYPE_CHECKING: from collections.abc import Iterator # ``scripts/`` is not an importable package; expose it so tests can import the # spec-validation helpers (``scripts/validate_spec.py``). import sys _SCRIPTS_DIR = str(Path(__file__).resolve().parents[1] / "scripts") if _SCRIPTS_DIR not in sys.path: sys.path.insert(0, _SCRIPTS_DIR) try: from blockbuster import BlockBuster, blockbuster_ctx except ImportError: BlockBuster = None # type: ignore[assignment,misc] blockbuster_ctx = None # type: ignore[assignment] _BENCHMARKS_DIR = "tests/benchmarks" # Tests that perform sync IO inside the asyncio event loop and trip # blockbuster. Marked xfail so CI is green; pop entries as they get # fixed so the underlying blocking call is gone for good. _KNOWN_BLOCKING: frozenset[str] = frozenset() def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item] ) -> None: """Mark known-blocking tests xfail so CI is green while we work through them.""" if blockbuster_ctx is None: return marker = pytest.mark.xfail( reason="blockbuster: blocking call in asyncio path, to be fixed", strict=False, ) for item in items: if item.nodeid in _KNOWN_BLOCKING: item.add_marker(marker) @pytest.fixture(autouse=True) def blockbuster( request: pytest.FixtureRequest, ) -> Iterator[BlockBuster | None]: """Fail any test that performs a blocking call inside the asyncio loop.""" if blockbuster_ctx is None or _BENCHMARKS_DIR in str(request.node.fspath): yield None return with blockbuster_ctx() as bb: yield bb UFP_SAMPLE_DIR = os.environ.get("UFP_SAMPLE_DIR") if UFP_SAMPLE_DIR: SAMPLE_DATA_DIRECTORY = Path(UFP_SAMPLE_DIR) else: SAMPLE_DATA_DIRECTORY = Path(__file__).parent / "sample_data" TEST_CAMERA_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera.json").exists() TEST_SNAPSHOT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera_snapshot.png").exists() TEST_PUBLIC_API_SNAPSHOT_EXISTS = ( SAMPLE_DATA_DIRECTORY / "sample_public_api_camera_snapshot.png" ).exists() TEST_VIDEO_EXISTS = ( SAMPLE_DATA_DIRECTORY / "sample_camera_video.mp4" ).exists() or "camera_video_length" not in CONSTANTS TEST_THUMBNAIL_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera_thumbnail.png").exists() TEST_HEATMAP_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_camera_heatmap.png").exists() TEST_SMART_TRACK_EXISTS = ( SAMPLE_DATA_DIRECTORY / "sample_event_smart_track.json" ).exists() TEST_LIGHT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_light.json").exists() TEST_SENSOR_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_sensor.json").exists() TEST_VIEWPORT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_viewport.json").exists() TEST_BRIDGE_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_bridge.json").exists() TEST_LIVEVIEW_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_liveview.json").exists() TEST_CHIME_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_chime.json").exists() TEST_AIPORT_EXISTS = (SAMPLE_DATA_DIRECTORY / "sample_aiport.json").exists() ANY_NONE = [[None], None, []] @cache def _read_binary_file_cached(name: str, ext: str) -> bytes: with (SAMPLE_DATA_DIRECTORY / f"{name}.{ext}").open("rb") as f: return f.read() def read_binary_file(name: str, ext: str = "png") -> bytes: return _read_binary_file_cached(name, ext) @cache def _read_json_file_cached(name: str) -> Any: with (SAMPLE_DATA_DIRECTORY / f"{name}.json").open(encoding="utf8") as f: return json.load(f) def read_json_file(name: str) -> Any: return deepcopy(_read_json_file_cached(name)) # Preload sample data at module import (outside the asyncio loop) so the # read_*_file helpers never touch the disk from inside a test or fixture # and trip blockbuster. for _path in SAMPLE_DATA_DIRECTORY.glob("*.json"): _read_json_file_cached(_path.stem) for _ext in ("png", "mp4"): for _path in SAMPLE_DATA_DIRECTORY.glob(f"*.{_ext}"): _read_binary_file_cached(_path.stem, _ext) CONSTANTS.data() # force the sample_constants.json read out of any loop async def async_write_bytes(path: Path, data: bytes) -> None: """Write ``data`` to ``path`` without blocking the event loop.""" async with aiofiles.open(path, "wb") as f: await f.write(data) async def async_read_bytes(path: Path) -> bytes: """Read ``path`` as bytes without blocking the event loop.""" async with aiofiles.open(path, "rb") as f: return await f.read() async def async_write_text(path: Path, text: str) -> None: """Write ``text`` to ``path`` without blocking the event loop.""" async with aiofiles.open(path, "w") as f: await f.write(text) async def async_read_text(path: Path) -> str: """Read ``path`` as text without blocking the event loop.""" async with aiofiles.open(path) as f: return await f.read() def read_bootstrap_json_file(): # tests expect global recording settings to be off bootstrap = read_json_file("sample_bootstrap") cameras = [] for camera in bootstrap["cameras"]: if camera.get("useGlobal"): camera["useGlobal"] = False cameras.append(camera) bootstrap["cameras"] = cameras return bootstrap def read_camera_json_file(): # tests expect global recording settings to be off camera = read_json_file("sample_camera") if camera.get("useGlobal"): camera["useGlobal"] = False return camera def read_aiport_json_file(): # tests expect global recording settings to be off aiport = read_json_file("sample_aiport") if aiport.get("useGlobal"): aiport["useGlobal"] = False return aiport def get_now(): return datetime.fromisoformat(CONSTANTS["time"]).replace(microsecond=0) def get_time(): return datetime.fromisoformat(CONSTANTS["time"]).replace(microsecond=0).timestamp() def validate_video_file(filepath: Path, length: int): """Validate video file using PyAV.""" with av.open(str(filepath)) as container: # Check that video stream exists assert len(container.streams.video) > 0, "No video stream found" # Check duration (in seconds) # container.duration is in av.time_base units (microseconds, 1000000/sec) # so we divide by av.time_base to convert to seconds duration = float(container.duration) / av.time_base if container.duration else 0 # it looks like UFP does not always generate a video of exact length assert length - 10 < duration < length + 10 async def mock_api_request_raw(url: str, *args, **kwargs): if url.startswith("thumbnails/") or url.endswith("thumbnail"): return read_binary_file("sample_camera_thumbnail") if url.startswith("cameras/"): return read_binary_file("sample_camera_snapshot") if url.startswith("/v1/cameras/"): return read_binary_file("sample_public_api_camera_snapshot") if url.startswith("heatmaps/") or url.endswith("heatmap"): return read_binary_file("sample_camera_heatmap") if url == "video/export": return read_binary_file("sample_camera_video", "mp4") return b"" async def mock_api_request(url: str, *args, **kwargs): if url == "bootstrap": return read_bootstrap_json_file() if url == "nvr": return read_bootstrap_json_file()["nvr"] if url == "events": return read_json_file("sample_raw_events") if url == "cameras": return [read_camera_json_file()] if url == "lights": return [read_json_file("sample_light")] if url == "sensors": return [read_json_file("sample_sensor")] if url == "viewers": return [read_json_file("sample_viewport")] if url == "bridges": return [read_json_file("sample_bridge")] if url == "liveviews": return [read_json_file("sample_liveview")] if url == "chimes": return [read_json_file("sample_chime")] if url == "aiports": return [read_json_file("sample_aiport")] if url.endswith("ptz/preset"): return { "id": "test-id", "name": "Test", "slot": 0, "ptz": { "pan": 100, "tilt": 100, "zoom": 0, }, } if url.endswith("ptz/home"): return { "id": "test-id", "name": "Home", "slot": -1, "ptz": { "pan": 100, "tilt": 100, "zoom": 0, }, } if url.startswith("cameras/"): return read_camera_json_file() if url.startswith("lights/"): return read_json_file("sample_light") if url.startswith("sensors/"): return read_json_file("sample_sensor") if url.startswith("viewers/"): return read_json_file("sample_viewport") if url.startswith("bridges/"): return read_json_file("sample_bridge") if url.startswith("liveviews/"): return read_json_file("sample_liveview") if url.startswith("chimes"): return read_json_file("sample_chime") if url.startswith("aiports"): return read_json_file("sample_aiport") if "smartDetectTrack" in url: return read_json_file("sample_event_smart_track") return {} class SimpleMockWebsocket: is_closed: bool = False now: float = 0 events: dict[str, Any] count = 0 def __init__(self): self.events = [] @property def closed(self): return self.is_closed async def close(self): self.is_closed = True def __aiter__(self): return self async def __anext__(self): if len(self.events) == 0 or self.is_closed: raise StopAsyncIteration key = next(iter(self.events.keys())) next_time = float(key) await asyncio.sleep(next_time - self.now) self.now = next_time data = self.events.pop(key) self.count += 1 return aiohttp.WSMessage( aiohttp.WSMsgType.BINARY, base64.b64decode(data["raw"]), None, ) async def receive(self, timeout): return await self.__anext__() class MockWebsocket(SimpleMockWebsocket): def __init__(self): super().__init__() self.events = read_json_file("sample_ws_messages") MockDatetime = Mock() MockDatetime.now.return_value = get_now() @pytest.fixture(autouse=True) def _ensure_debug(): set_debug() async def setup_client( client: ProtectApiClient, websocket: SimpleMockWebsocket, timeout: int = 0, ): mock_cs = AsyncMock() mock_session = AsyncMock() mock_session.ws_connect = AsyncMock(return_value=websocket) mock_cs.return_value = mock_session ws = client._get_websocket() ws.timeout = timeout ws._get_session = mock_cs # type: ignore[method-assign] client.api_request = AsyncMock(side_effect=mock_api_request) # type: ignore[method-assign] client.api_request_raw = AsyncMock(side_effect=mock_api_request_raw) # type: ignore[method-assign] client.ensure_authenticated = AsyncMock() # type: ignore[method-assign] await client.update() # make sure global recording settings are disabled for all cameras (test expect it) for camera in client.bootstrap.cameras.values(): camera.use_global = False return client async def cleanup_client(client: ProtectApiClient): await client.async_disconnect_ws() await client.close_session() await client.close_public_api_session() @pytest_asyncio.fixture async def simple_api_client(): """Create a simple ProtectApiClient for unit testing without mocked bootstrap.""" client = ProtectApiClient("test.com", 443, "username", "password") yield client await cleanup_client(client) @pytest_asyncio.fixture(name="protect_client") async def protect_client_fixture(): client = ProtectApiClient( "127.0.0.1", 0, "username", "password", ws_timeout=0.1, store_sessions=False, ) yield await setup_client(client, SimpleMockWebsocket()) await cleanup_client(client) @pytest_asyncio.fixture async def protect_client_no_debug(): set_no_debug() client = ProtectApiClient( "127.0.0.1", 0, "username", "password", ws_timeout=0.1, store_sessions=False, ) yield await setup_client(client, SimpleMockWebsocket()) await cleanup_client(client) @pytest_asyncio.fixture async def protect_client_ws(): set_no_debug() client = ProtectApiClient( "127.0.0.1", 0, "username", "password", ws_timeout=0.1, ws_receive_timeout=0.1, store_sessions=False, ) yield await setup_client(client, MockWebsocket(), timeout=30) await cleanup_client(client) @pytest_asyncio.fixture async def smart_dectect_obj(protect_client: ProtectApiClient, raw_events): event_dict = None for event in raw_events: if event["type"] == EventType.SMART_DETECT.value: event_dict = event break if event_dict is None: yield None else: yield Event.from_unifi_dict(api=protect_client, **event_dict) @pytest_asyncio.fixture async def nvr_obj(protect_client: ProtectApiClient): yield protect_client.bootstrap.nvr @pytest_asyncio.fixture async def camera_obj(protect_client: ProtectApiClient): if not TEST_CAMERA_EXISTS: return None return next(iter(protect_client.bootstrap.cameras.values())) @pytest_asyncio.fixture async def ptz_camera(protect_client: ProtectApiClient): if not TEST_CAMERA_EXISTS: return None camera = next(iter(protect_client.bootstrap.cameras.values())) # G4 PTZ camera.is_ptz = True camera.feature_flags.is_ptz = True camera.feature_flags.focus = PTZRange( steps={ # type: ignore[arg-type] "max": 1560, "min": 0, "step": 1, }, degrees={ # type: ignore[arg-type] "max": None, "min": None, "step": None, }, ) camera.feature_flags.pan = PTZRange( steps={ # type: ignore[arg-type] "max": 35200, "min": 0, "step": 1, }, degrees={ # type: ignore[arg-type] "max": 360, "min": 0, "step": 0.1, }, ) camera.feature_flags.tilt = PTZRange( steps={ # type: ignore[arg-type] "max": 9777, "min": 1, "step": 1, }, degrees={ # type: ignore[arg-type] "max": 90, "min": -20, "step": 0.1, }, ) camera.feature_flags.zoom = PTZZoomRange( ratio=22, steps={ # type: ignore[arg-type] "max": 2010, "min": 0, "step": 1, }, degrees={ # type: ignore[arg-type] "max": None, "min": None, "step": None, }, ) protect_client.bootstrap.cameras[camera.id] = camera return camera @pytest_asyncio.fixture async def light_obj(protect_client: ProtectApiClient): if not TEST_LIGHT_EXISTS: return None return next(iter(protect_client.bootstrap.lights.values())) @pytest_asyncio.fixture async def viewer_obj(protect_client: ProtectApiClient): if not TEST_VIEWPORT_EXISTS: return None return next(iter(protect_client.bootstrap.viewers.values())) @pytest_asyncio.fixture async def sensor_obj(protect_client: ProtectApiClient): if not TEST_SENSOR_EXISTS: return None return next(iter(protect_client.bootstrap.sensors.values())) @pytest_asyncio.fixture(name="chime_obj") async def chime_obj_fixture(protect_client: ProtectApiClient): if not TEST_CHIME_EXISTS: return None return next(iter(protect_client.bootstrap.chimes.values())) @pytest_asyncio.fixture(name="aiport_obj") async def aiport_obj_fixture(protect_client: ProtectApiClient): if not TEST_AIPORT_EXISTS: return None return next(iter(protect_client.bootstrap.aiports.values())) @pytest_asyncio.fixture async def liveview_obj(protect_client: ProtectApiClient): if not TEST_LIVEVIEW_EXISTS: return None return next(iter(protect_client.bootstrap.liveviews.values())) @pytest_asyncio.fixture async def user_obj(protect_client: ProtectApiClient): return protect_client.bootstrap.auth_user @pytest.fixture() def liveview(): if not TEST_LIVEVIEW_EXISTS: return None return read_json_file("sample_liveview") @pytest.fixture() def viewport(): if not TEST_VIEWPORT_EXISTS: return None return read_json_file("sample_viewport") @pytest.fixture() def light(): if not TEST_LIGHT_EXISTS: return None return read_json_file("sample_light") @pytest.fixture() def camera(): if not TEST_CAMERA_EXISTS: return None return read_camera_json_file() @pytest.fixture() def aiport(): if not TEST_CAMERA_EXISTS: return None return read_aiport_json_file() @pytest.fixture() def sensor(): if not TEST_SENSOR_EXISTS: return None return read_json_file("sample_sensor") @pytest.fixture() def chime(): if not TEST_CHIME_EXISTS: return None return read_json_file("sample_chime") @pytest.fixture() def bridge(): if not TEST_BRIDGE_EXISTS: return None return read_json_file("sample_bridge") @pytest.fixture() def liveviews(): if not TEST_LIVEVIEW_EXISTS: return [] return [read_json_file("sample_liveview")] @pytest.fixture() def viewports(): if not TEST_VIEWPORT_EXISTS: return [] return [read_json_file("sample_viewport")] @pytest.fixture() def aiports(): if not TEST_AIPORT_EXISTS: return [] return [read_json_file("sample_aiport")] @pytest.fixture() def lights(): if not TEST_LIGHT_EXISTS: return [] return [read_json_file("sample_light")] @pytest.fixture() def cameras(): if not TEST_CAMERA_EXISTS: return [] return [read_camera_json_file()] @pytest.fixture() def sensors(): if not TEST_SENSOR_EXISTS: return [] return [read_json_file("sample_sensor")] @pytest.fixture() def chimes(): if not TEST_CHIME_EXISTS: return [] return [read_json_file("sample_chime")] @pytest.fixture() def bridges(): if not TEST_BRIDGE_EXISTS: return [] return [read_json_file("sample_bridge")] @pytest.fixture() def ws_messages(): return read_json_file("sample_ws_messages") @pytest.fixture(name="raw_events") def raw_events_fixture(): return read_json_file("sample_raw_events") @pytest.fixture() def bootstrap(): return read_bootstrap_json_file() @pytest.fixture() def nvr(): return read_bootstrap_json_file()["nvr"] @pytest.fixture() def smart_track(): if not TEST_SMART_TRACK_EXISTS: return None return read_json_file("sample_event_smart_track") @pytest.fixture() def now(): return get_now().replace(tzinfo=UTC) @pytest.fixture() def tmp_binary_file(): with NamedTemporaryFile(mode="wb", delete=False) as tmp_file: yield tmp_file Path(tmp_file.name).unlink() # new values added for newer versions of UFP (for backwards compat tests) NEW_FIELDS = { # 1.20.1 "voltage", # 1.21.0-beta1 "timestamp", "isWirelessUplinkEnabled", "marketName", # 1.21.0-beta3 "isPoorNetwork", # 2.0-beta2 "scopes", "streamSharingAvailable", "isDbAvailable", "isRecordingDisabled", "isRecordingMotionOnly", # 2.1.1-beta3 "anonymousDeviceId", # added to viewport "isStacked", "isPrimary", "lastDriveSlowEvent", "isUCoreSetup", # 2.2.1-beta2 "isInsightsEnabled", # 2.2.2 "isDownloadingFW", # 2.6.13 "vaultCameras", "homekitSettings", # 2.6.17 "apMgmtIp", # 2.7.5 "fwUpdateState", "isWaterproofCaseAttached", "deletedAt", "deletionType", "lastDisconnect", # 2.7.15 "featureFlags", # added to chime # 2.8.14+ "nvrMac", "useGlobal", "is2K", "is4K", "ulpVersion", "wanIp", "publicIp", "isVaultRegistered", "hasGateway", "corruptionState", "countryCode", # 2.8.22+ "guid", "userConfiguredAp", # 2.9.20+ "isRestoring", "hasRecordings", "hardDriveState", "isNetworkInstalled", "isProtectUpdatable", "isUcoreUpdatable", # 2.10.10+ "isPtz", # 2.11.13+ "lastDeviceFWUpdatesCheckedAt", "audioSettings", # 3.0.22+ "smartDetection", "platform", "repeatTimes", "ringSettings", "speakerTrackList", "hasHttpsClientOTA", "isUCoreStacked", # 5.0.33+ "isThirdPartyCamera", # 6.0.0+ "isFavorite", "favoriteObjectIds", } NEW_CAMERA_FEATURE_FLAGS = { "audio", "audioCodecs", "hasInfrared", "hotplug", "smartDetectAudioTypes", "lensType", # 2.7.18+ "isDoorbell", # 2.8.22+ "lensModel", # 2.9.20+ "hasColorLcdScreen", "hasLineCrossing", "hasLineCrossingCounting", "hasLiveviewTracking", # 2.10.10+ "hasFlash", "isPtz", # 2.11.13+ "audioStyle", "hasVerticalFlip", # 3.0.22+ "flashRange", # 4.73.71+ "supportNfc", "hasFingerprintSensor", # 6.0.0+ "supportFullHdSnapshot", } NEW_ISP_SETTINGS = { # 3.0.22+ "hdrMode", "icrCustomValue", "icrSwitchMode", "spotlightDuration", } NEW_NVR_FEATURE_FLAGS = { # 2.8.14+ "ulpRoleManagement", } OLD_FIELDS = { # remove in 2.7.12 "avgMotions", # removed in 2.10.11 "eventStats", # removed in 3.0.22 "pirSettings", } pytest.register_assert_rewrite("tests.common") def compare_objs(obj_type, expected, actual): expected = deepcopy(expected) actual = deepcopy(actual) if obj_type in (ModelType.CAMERA.value, ModelType.AIPORT.value): # fields does not always exist (G4 Instant) expected.pop("apMac", None) # field no longer exists on newer cameras expected.pop("elementInfo", None) del expected["apRssi"] del expected["lastPrivacyZonePositionId"] expected.pop("recordingSchedules", None) del expected["smartDetectLines"] expected.pop("streamSharing", None) expected.pop("stopStreamLevel", None) expected.pop("uplinkDevice", None) expected.pop("recordingSchedulesV2", None) expected["stats"].pop("battery", None) expected["recordingSettings"].pop("enablePirTimelapse", None) expected["featureFlags"].pop("hasBattery", None) # do not compare detect zones because float math sucks assert len(expected["motionZones"]) == len(actual["motionZones"]) assert len(expected["privacyZones"]) == len(actual["privacyZones"]) assert len(expected["smartDetectZones"]) == len(actual["smartDetectZones"]) expected["motionZones"] = actual["motionZones"] = [] expected["privacyZones"] = actual["privacyZones"] = [] expected["smartDetectZones"] = actual["smartDetectZones"] = [] if "isColorNightVisionEnabled" not in expected["ispSettings"]: actual["ispSettings"].pop("isColorNightVisionEnabled", None) if ( "audioTypes" in actual["smartDetectSettings"] and "audioTypes" not in expected["smartDetectSettings"] ): del actual["smartDetectSettings"]["audioTypes"] if ( "autoTrackingObjectTypes" in actual["smartDetectSettings"] and "autoTrackingObjectTypes" not in expected["smartDetectSettings"] ): del actual["smartDetectSettings"]["autoTrackingObjectTypes"] exp_settings = expected["recordingSettings"] act_settings = actual["recordingSettings"] exp_settings["enableMotionDetection"] = exp_settings.get( "enableMotionDetection", ) if act_settings and "inScheduleMode" not in exp_settings: del act_settings["inScheduleMode"] if "outScheduleMode" in act_settings and "outScheduleMode" not in exp_settings: del act_settings["outScheduleMode"] if "retentionDurationMs" not in exp_settings: act_settings.pop("retentionDurationMs", None) if "smartDetectPostPadding" not in exp_settings: act_settings.pop("smartDetectPostPadding", None) if "smartDetectPrePadding" not in exp_settings: act_settings.pop("smartDetectPrePadding", None) if ( "talkbackSettings" in expected and expected["talkbackSettings"].get("bindAddr") == "" ): actual["talkbackSettings"]["bindAddr"] = "" if "createAccessEvent" not in expected["recordingSettings"]: actual["recordingSettings"].pop("createAccessEvent", None) for flag in NEW_CAMERA_FEATURE_FLAGS: if flag not in expected["featureFlags"]: del actual["featureFlags"][flag] for setting in NEW_ISP_SETTINGS: if setting not in expected["ispSettings"]: del actual["ispSettings"][setting] # ignore changes to motion for live tests assert isinstance(actual["isMotionDetected"], bool) expected["isMotionDetected"] = actual["isMotionDetected"] if "isAdoptedByAccessApp" not in expected: actual.pop("isAdoptedByAccessApp", None) for index, channel in enumerate(expected["channels"]): if "bitrate" not in channel: actual["channels"][index].pop("bitrate", None) if "minBitrate" not in channel: actual["channels"][index].pop("minBitrate", None) if "maxBitrate" not in channel: actual["channels"][index].pop("maxBitrate", None) if "autoBitrate" not in channel: actual["channels"][index].pop("autoBitrate", None) if "autoFps" not in channel: actual["channels"][index].pop("autoFps", None) elif obj_type == ModelType.USER.value: expected.pop("settings", None) expected.pop("cloudProviders", None) del expected["alertRules"] del expected["notificationsV2"] expected.pop("notifications", None) # lastLoginIp/lastLoginTime is not always present if "lastLoginIp" not in expected: actual.pop("lastLoginIp", None) if "lastLoginTime" not in expected: actual.pop("lastLoginTime", None) if "email" not in expected and "email" in actual and actual["email"] is None: actual.pop("email", None) elif obj_type == ModelType.EVENT.value: expected.pop("partition", None) expected.pop("deletionType", None) expected.pop("description", None) if "category" in expected and expected["category"] is None: expected.pop("category", None) exp_thumbnails = expected.get("metadata", {}).pop("detectedThumbnails", []) act_thumbnails = actual.get("metadata", {}).pop("detectedThumbnails", []) for index, exp_thumb in enumerate(exp_thumbnails): if "attributes" not in exp_thumb: del act_thumbnails[index]["attributes"] if "clockBestWall" not in exp_thumb: del act_thumbnails[index]["clockBestWall"] assert exp_thumbnails == act_thumbnails expected_keys = (expected.get("metadata") or {}).keys() actual_keys = (actual.get("metadata") or {}).keys() # delete all extra metadata keys, many of which are not modeled for key in set(expected_keys).difference(actual_keys): del expected["metadata"][key] elif obj_type == ModelType.SENSOR.value: del expected["bridgeCandidates"] actual.pop("host", None) expected.pop("host", None) elif obj_type == ModelType.CHIME.value: del expected["apMac"] del expected["apRssi"] del expected["elementInfo"] elif obj_type == ModelType.NVR.value: del expected["errorCode"] del expected["wifiSettings"] del expected["smartDetectAgreement"] expected.pop("dbRecoveryOptions", None) expected.pop("portStatus", None) expected.pop("cameraCapacity", None) expected.pop("deviceFirmwareSettings", None) # removed fields expected["ports"].pop("cameraTcp", None) expected["ports"]["piongw"] = expected["ports"].get("piongw") expected["ports"]["stacking"] = expected["ports"].get("stacking") expected["ports"]["emsJsonCLI"] = expected["ports"].get("emsJsonCLI") expected["ports"]["aiFeatureConsole"] = expected["ports"].get( "aiFeatureConsole", ) expected["globalCameraSettings"] = expected.get("globalCameraSettings") if expected["globalCameraSettings"]: settings = expected["globalCameraSettings"]["recordingSettings"] settings["retentionDurationMs"] = settings.get( "retentionDurationMs", ) expected["globalCameraSettings"].pop("recordingSchedulesV2", None) if ( "homekitPaired" in actual["featureFlags"] and "homekitPaired" not in expected["featureFlags"] ): del actual["featureFlags"]["homekitPaired"] if ( "detectionLabels" in actual["featureFlags"] and "detectionLabels" not in expected["featureFlags"] ): del actual["featureFlags"]["detectionLabels"] if ( "hasTwoWayAudioMediaStreams" in actual["featureFlags"] and "hasTwoWayAudioMediaStreams" not in expected["featureFlags"] ): del actual["featureFlags"]["hasTwoWayAudioMediaStreams"] if "capability" not in expected["systemInfo"]["storage"]: actual["systemInfo"]["storage"].pop("capability", None) # float math... cpu_fields = ["averageLoad", "temperature"] for key in cpu_fields: if math.isclose( expected["systemInfo"]["cpu"][key], actual["systemInfo"]["cpu"][key], rel_tol=0.01, ): expected["systemInfo"]["cpu"][key] = actual["systemInfo"]["cpu"][key] if expected["systemInfo"].get("ustorage") is not None: actual_ustor = actual["systemInfo"]["ustorage"] expected_ustor = expected["systemInfo"]["ustorage"] expected_ustor.pop("sdcards", None) for index, disk in enumerate(expected_ustor["disks"]): actual_disk = actual_ustor["disks"][index] estimate = disk.get("estimate") actual_estimate = actual_disk.get("estimate") if ( estimate is not None and actual_estimate is not None and math.isclose(estimate, actual_estimate, rel_tol=0.01) ): actual_ustor["disks"][index]["estimate"] = estimate for index, device in enumerate(expected_ustor["space"]): actual_device = actual_ustor["space"][index] estimate = device.get("estimate") actual_estimate = actual_device.get("estimate") if ( estimate is not None and actual_estimate is not None and math.isclose(estimate, actual_estimate, rel_tol=0.01) ): actual_ustor["space"][index]["estimate"] = estimate if "space_type" not in device: del actual_device["space_type"] if "size" in device: actual_device["size"] = actual_device.pop("size", None) if "reasons" in device: del device["reasons"] for flag in NEW_NVR_FEATURE_FLAGS: if flag not in expected["featureFlags"]: del actual["featureFlags"][flag] if "bridge" not in expected and "bridge" in actual and actual["bridge"] is None: actual.pop("bridge", None) if "bluetoothConnectionState" in expected: expected["bluetoothConnectionState"]["experienceScore"] = expected[ "bluetoothConnectionState" ].get( "experienceScore", ) if "wifiConnectionState" in expected: expected["wifiConnectionState"]["bssid"] = expected["wifiConnectionState"].get( "bssid", ) expected["wifiConnectionState"]["txRate"] = expected["wifiConnectionState"].get( "txRate", ) expected["wifiConnectionState"]["experience"] = expected[ "wifiConnectionState" ].get("experience") expected["wifiConnectionState"]["apName"] = expected["wifiConnectionState"].get( "apName", ) expected["wifiConnectionState"]["connectivity"] = expected[ "wifiConnectionState" ].get("connectivity") # sometimes uptime comes back as a str... if "uptime" in expected and expected["uptime"] is not None: expected["uptime"] = int(expected["uptime"]) # force hardware revision to str to make sure types line up if "hardwareRevision" in expected and expected["hardwareRevision"] is not None: expected["hardwareRevision"] = str(expected["hardwareRevision"]) actual["hardwareRevision"] = str(actual["hardwareRevision"]) # edge case with broken UUID from Protect if ( "guid" in expected and expected["guid"] == _BAD_UUID and actual["guid"] == "00000000-0000-0000-0000-000000000000" ): actual["guid"] = expected["guid"] for key in NEW_FIELDS.intersection(actual.keys()): if key not in expected: del actual[key] for key in OLD_FIELDS.intersection(expected.keys()): del expected[key] if "anonymousDeviceId" in expected and not expected["anonymousDeviceId"]: expected["anonymousDeviceId"] = None assert expected == actual @pytest.fixture() def _disable_camera_validation(): Camera.model_config["validate_assignment"] = False yield Camera.model_config["validate_assignment"] = True @pytest.fixture() def _disable_nvr_validation(): original_validate_assignment = NVR.model_config.get("validate_assignment", True) NVR.model_config["validate_assignment"] = False yield NVR.model_config["validate_assignment"] = original_validate_assignment class MockTalkback: def __init__(self) -> None: self.start = AsyncMock() self.stop = AsyncMock() self.run_until_complete = AsyncMock() uilibs-uiprotect-317a52c/tests/data/000077500000000000000000000000001524637705700174375ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/data/__init__.py000066400000000000000000000000001524637705700215360ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/data/test_camera.py000066400000000000000000001564621524637705700223160ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, arg-type" from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from pydantic import ValidationError from tests.conftest import TEST_CAMERA_EXISTS from uiprotect import ProtectApiClient from uiprotect.data import ( CHANNEL_ID_BY_RTSPS_QUALITY, RTSPS_QUALITY_BY_CHANNEL_ID, Camera, ChannelQuality, ChimeType, DoorbellMessageType, HDRMode, IRLEDMode, LCDMessage, PTZPatrol, RecordingMode, SmartDetectAudioType, channel_id_for_quality, quality_for_channel_id, ) from uiprotect.data.devices import ( CameraChannel, CameraZone, Hotplug, HotplugExtender, WifiStats, ) from uiprotect.data.types import DEFAULT, PermissionNode, SmartDetectObjectType from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.exceptions import BadRequest, NotAuthorized from uiprotect.utils import to_js_time @pytest.mark.parametrize( ("channel_id", "fps", "expected"), [ (3, 2, True), # Package channel with low fps (legacy behavior) (3, 15, True), # Package channel with higher fps (G6 Entry) (3, None, False), # Package channel with no fps (0, 2, False), # Non-package channel with low fps (1, 15, False), # Non-package channel (2, None, False), # Non-package channel with no fps ], ) def test_camera_channel_is_package(channel_id: int, fps: int | None, expected: bool): """Test CameraChannel.is_package uses channel id == 3 for detection.""" channel = CameraChannel.from_unifi_dict( id=channel_id, videoId="test", name="test", enabled=True, isRtspEnabled=False, width=1920, height=1080, fps=fps, bitrate=1000, fpsValues=[], idrInterval=1, ) assert channel.is_package is expected @pytest.mark.parametrize( ("channel_id", "expected"), [ (0, ChannelQuality.HIGH), (1, ChannelQuality.MEDIUM), (2, ChannelQuality.LOW), (3, ChannelQuality.PACKAGE), (4, None), ], ) def test_camera_channel_rtsps_quality(channel_id: int, expected: ChannelQuality | None): """Test CameraChannel.rtsps_quality maps channel id to RTSPS quality tier.""" channel = CameraChannel.from_unifi_dict( id=channel_id, videoId="test", name="test", enabled=True, isRtspEnabled=False, width=1920, height=1080, fps=15, bitrate=1000, fpsValues=[], idrInterval=1, ) assert channel.rtsps_quality is expected @pytest.mark.parametrize( ("channel_id", "expected"), [ (0, ChannelQuality.HIGH), (1, ChannelQuality.MEDIUM), (2, ChannelQuality.LOW), (3, ChannelQuality.PACKAGE), (4, None), ], ) def test_quality_for_channel_id(channel_id: int, expected: ChannelQuality | None): """Test quality_for_channel_id maps channel id to RTSPS quality tier.""" assert quality_for_channel_id(channel_id) is expected @pytest.mark.parametrize( ("quality", "expected"), [ (ChannelQuality.HIGH, 0), (ChannelQuality.MEDIUM, 1), (ChannelQuality.LOW, 2), (ChannelQuality.PACKAGE, 3), (ChannelQuality.UNKNOWN, None), ], ) def test_channel_id_for_quality(quality: ChannelQuality, expected: int | None): """Test channel_id_for_quality inverts the RTSPS quality mapping.""" assert channel_id_for_quality(quality) == expected def test_rtsps_quality_mappings_are_inverses(): """Test the forward and inverse RTSPS quality mappings agree.""" assert { quality: channel_id for channel_id, quality in RTSPS_QUALITY_BY_CHANNEL_ID.items() } == dict(CHANNEL_ID_BY_RTSPS_QUALITY) @pytest.mark.parametrize( ("link_speed", "expected_type"), [ (300, int), # new Protect behavior ("300 Mbps", str), # legacy behavior (None, type(None)), ], ) def test_wifi_stats_link_speed_mbps(link_speed: int | str | None, expected_type: type): """Test WifiStats accepts int, str, and None for link_speed_mbps (Protect 6.x+).""" stats = WifiStats.from_unifi_dict( linkSpeedMbps=link_speed, signalQuality=50, signalStrength=0, ) assert stats.link_speed_mbps == link_speed assert isinstance(stats.link_speed_mbps, expected_type) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_set_motion_detection(camera_obj: Camera | None, status: bool): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.recording_settings.enable_motion_detection = not status await camera_obj.set_motion_detection(status) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"recordingSettings": {"enableMotionDetection": status}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("mode", [RecordingMode.ALWAYS, RecordingMode.DETECTIONS]) @pytest.mark.asyncio() async def test_camera_set_recording_mode( camera_obj: Camera | None, mode: RecordingMode, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.recording_settings.mode = RecordingMode.NEVER await camera_obj.set_recording_mode(mode) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"recordingSettings": {"mode": mode.value}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_ir_led_model_no_ir(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_led_ir = False with pytest.raises(BadRequest): await camera_obj.set_ir_led_model(IRLEDMode.AUTO) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("mode", [IRLEDMode.AUTO, IRLEDMode.ON]) @pytest.mark.asyncio() async def test_camera_set_ir_led_model(camera_obj: Camera | None, mode: IRLEDMode): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_led_ir = True camera_obj.isp_settings.ir_led_mode = IRLEDMode.OFF await camera_obj.set_ir_led_model(mode) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"ispSettings": {"irLedMode": mode.value}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_status_light_no_status(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_led_status = False with pytest.raises(BadRequest): await camera_obj.set_status_light(True) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_set_status_light(camera_obj: Camera | None, status: bool): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_led_status = True camera_obj.led_settings.is_enabled = not status camera_obj.led_settings.blink_rate = 10 await camera_obj.set_status_light(status) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"ledSettings": {"isEnabled": status, "blinkRate": 0}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ("state", "expected"), [ ((True, HDRMode.NORMAL), "auto"), ((False, HDRMode.NORMAL), "off"), ((False, HDRMode.ALWAYS_ON), "off"), ((True, HDRMode.ALWAYS_ON), "always"), ], ) def test_camera_hdr_mode_display( camera_obj: Camera | None, state: tuple[bool, HDRMode], expected: str, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.hdr_mode, camera_obj.isp_settings.hdr_mode = state assert camera_obj.hdr_mode_display == expected @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_set_color_night_vision( camera_obj: Camera | None, status: bool, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.hotplug = Hotplug() camera_obj.feature_flags.hotplug.extender = HotplugExtender() camera_obj.feature_flags.hotplug.extender.is_attached = True camera_obj.isp_settings.is_color_night_vision_enabled = not status await camera_obj.set_color_night_vision(status) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"ispSettings": {"isColorNightVisionEnabled": status}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_color_night_vision_no_color_night_vision( camera_obj: Camera | None, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() with pytest.raises(BadRequest): await camera_obj.set_color_night_vision(True) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_set_ssh(camera_obj: Camera | None, status: bool): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.is_ssh_enabled = not status await camera_obj.set_ssh(status) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"isSshEnabled": status}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_camera_zoom_no_zoom(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.can_optical_zoom = False with pytest.raises(BadRequest): await camera_obj.set_camera_zoom(True) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [-1, 0, 100, 200]) @pytest.mark.asyncio() async def test_camera_set_camera_zoom(camera_obj: Camera | None, level: int): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.can_optical_zoom = True camera_obj.isp_settings.zoom_position = 10 if level in {-1, 200}: with pytest.raises(ValidationError): await camera_obj.set_camera_zoom(level) assert not camera_obj.api.api_request.called else: await camera_obj.set_camera_zoom(level) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"ispSettings": {"zoomPosition": level}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [-1, 0, 3, 5]) @pytest.mark.asyncio() async def test_camera_set_wdr_level(camera_obj: Camera | None, level: int): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_hdr = False camera_obj.isp_settings.wdr = 2 if level in {-1, 5}: with pytest.raises(ValidationError): await camera_obj.set_wdr_level(level) assert not camera_obj.api.api_request.called else: await camera_obj.set_wdr_level(level) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"ispSettings": {"wdr": level}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_wdr_level_hdr(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_hdr = True with pytest.raises(BadRequest): await camera_obj.set_wdr_level(1) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_speaker_volume_no_speaker(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_speaker = False with pytest.raises(BadRequest): await camera_obj.set_speaker_volume(True) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [-1, 0, 100, 200]) @pytest.mark.asyncio() async def test_camera_set_speaker_volume(camera_obj: Camera | None, level: int): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_speaker = True camera_obj.speaker_settings.speaker_volume = 10 if level in {-1, 200}: with pytest.raises(ValidationError): await camera_obj.set_speaker_volume(level) assert not camera_obj.api.api_request.called else: await camera_obj.set_speaker_volume(level) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"speakerSettings": {"speakerVolume": level}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_volume_no_speaker(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_speaker = False with pytest.raises(BadRequest): await camera_obj.set_volume(50) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [-1, 0, 100, 200]) @pytest.mark.asyncio() async def test_camera_set_volume(camera_obj: Camera | None, level: int): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_speaker = True camera_obj.speaker_settings.volume = 10 if level in {-1, 200}: with pytest.raises(ValidationError): await camera_obj.set_volume(level) assert not camera_obj.api.api_request.called else: await camera_obj.set_volume(level) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"speakerSettings": {"volume": level}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_ring_volume_no_doorbell(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_doorbell = False with pytest.raises(BadRequest): await camera_obj.set_ring_volume(50) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [-1, 0, 100, 200]) @pytest.mark.asyncio() async def test_camera_set_ring_volume(camera_obj: Camera | None, level: int): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_doorbell = True camera_obj.speaker_settings.ring_volume = 10 if level in {-1, 200}: with pytest.raises(ValidationError): await camera_obj.set_ring_volume(level) assert not camera_obj.api.api_request.called else: await camera_obj.set_ring_volume(level) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"speakerSettings": {"ringVolume": level}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_chime_duration_no_chime(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_chime = False with pytest.raises(BadRequest): await camera_obj.set_chime_duration(1000) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_mechanical_chime( camera_obj: Camera | None, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.has_chime = True camera_obj.chime_duration = timedelta(seconds=0.3) assert camera_obj.chime_duration_seconds == 0.3 assert camera_obj.chime_type is ChimeType.MECHANICAL @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_no_chime( camera_obj: Camera | None, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.has_chime = True camera_obj.chime_duration = timedelta(seconds=0) assert camera_obj.chime_duration_seconds == 0 assert camera_obj.chime_type is ChimeType.NONE @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("duration", [-1, 0, 0.5, 1, 20]) @pytest.mark.asyncio() async def test_camera_set_chime_duration_duration( camera_obj: Camera | None, duration: int, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_chime = True camera_obj.chime_duration = timedelta(seconds=300) assert camera_obj.chime_duration_seconds == 300 assert camera_obj.chime_type is ChimeType.DIGITAL camera_obj.mic_volume = 10 if duration in {-1, 20}: with pytest.raises(BadRequest): await camera_obj.set_chime_duration(duration) assert not camera_obj.api.api_request.called else: await camera_obj.set_chime_duration(duration) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"chimeDuration": duration * 1000}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_system_sounds_no_speaker(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_speaker = False with pytest.raises(BadRequest): await camera_obj.set_system_sounds(True) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_set_system_sounds(camera_obj: Camera | None, status: bool): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_speaker = True camera_obj.speaker_settings.are_system_sounds_enabled = not status await camera_obj.set_system_sounds(status) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"speakerSettings": {"areSystemSoundsEnabled": status}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_set_osd_bitrate(camera_obj: Camera | None, status: bool): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.osd_settings.is_debug_enabled = not status await camera_obj.set_osd_bitrate(status) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"osdSettings": {"isDebugEnabled": status}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_smart_detect_types_no_smart(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_smart_detect = False with pytest.raises(BadRequest): await camera_obj.set_smart_detect_types([]) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_smart_detect_types(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_smart_detect = True camera_obj.smart_detect_settings.object_types = [] await camera_obj.set_smart_detect_types([SmartDetectObjectType.PERSON]) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"smartDetectSettings": {"objectTypes": ["person"]}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_lcd_text_no_lcd(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_lcd_screen = False with pytest.raises(BadRequest): await camera_obj.set_lcd_text(DoorbellMessageType.DO_NOT_DISTURB) assert not camera_obj.api.api_request.called def test_lcd_message_missing_text_defaults_to_empty(): msg = LCDMessage.model_construct(type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR) assert msg.text == "" def test_lcd_message_from_unifi_dict_missing_text(): msg = LCDMessage.from_unifi_dict( type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value ) assert msg.text == "" @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_lcd_text_custom(camera_obj: Camera | None): camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_lcd_screen = True camera_obj.lcd_message = LCDMessage( type=DoorbellMessageType.DO_NOT_DISTURB, text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), reset_at=None, ) now = datetime.now(tz=UTC) await camera_obj.set_lcd_text(DoorbellMessageType.CUSTOM_MESSAGE, "Test", now) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={ "lcdMessage": { "type": DoorbellMessageType.CUSTOM_MESSAGE.value, "text": "Test", "resetAt": to_js_time(now), }, }, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_lcd_text_custom_to_custom(camera_obj: Camera | None): camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_lcd_screen = True camera_obj.lcd_message = LCDMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text="Welcome", reset_at=None, ) now = datetime.now(tz=UTC) await camera_obj.set_lcd_text(DoorbellMessageType.CUSTOM_MESSAGE, "Test", now) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={ "lcdMessage": { "type": DoorbellMessageType.CUSTOM_MESSAGE.value, "text": "Test", "resetAt": to_js_time(now), }, }, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_lcd_text_invalid_text(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_lcd_screen = True with pytest.raises(BadRequest): await camera_obj.set_lcd_text(DoorbellMessageType.DO_NOT_DISTURB, "Test") assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_lcd_text(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_lcd_screen = True camera_obj.lcd_message = LCDMessage( type=DoorbellMessageType.DO_NOT_DISTURB, text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), reset_at=None, ) await camera_obj.set_lcd_text(DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={ "lcdMessage": { "type": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value, "text": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace( "_", " ", ), "resetAt": None, }, }, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() @patch("uiprotect.data.devices.utc_now") async def test_camera_set_lcd_text_none( mock_now, camera_obj: Camera | None, now: datetime, ): mock_now.return_value = now if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.emit_message = Mock() camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_lcd_screen = True camera_obj.lcd_message = LCDMessage( type=DoorbellMessageType.DO_NOT_DISTURB, text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), reset_at=None, ) await camera_obj.set_lcd_text(None) expected_dt = now - timedelta(seconds=10) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={ "lcdMessage": { "resetAt": to_js_time(expected_dt), }, }, ) # old/new is actually the same here since the client # generating the message is the one that changed it camera_obj.api.emit_message.assert_called_with( WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=camera_obj.api.bootstrap.last_update_id, changed_data={"lcd_message": {"reset_at": expected_dt}}, old_obj=camera_obj, new_obj=camera_obj, ), ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() @patch("uiprotect.data.devices.utc_now") async def test_camera_set_lcd_text_default( mock_now, camera_obj: Camera | None, now: datetime, ): mock_now.return_value = now if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_lcd_screen = True camera_obj.lcd_message = LCDMessage( type=DoorbellMessageType.DO_NOT_DISTURB, text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), reset_at=None, ) await camera_obj.set_lcd_text( DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR, reset_at=DEFAULT, ) expected_dt = ( now + camera_obj.api.bootstrap.nvr.doorbell_settings.default_message_reset_timeout ) camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={ "lcdMessage": { "type": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value, "text": DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace( "_", " ", ), "resetAt": to_js_time(expected_dt), }, }, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_privacy_no_privacy(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_privacy_mask = False with pytest.raises(BadRequest): await camera_obj.set_privacy(True) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("actual_enabled", [True, False]) @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.parametrize("level", [None, -1, 0, 100, 200]) @pytest.mark.parametrize("mode", [None, RecordingMode.ALWAYS]) @pytest.mark.asyncio() async def test_camera_set_privacy( camera_obj: Camera | None, actual_enabled: bool, enabled: bool, level: int | None, mode: RecordingMode | None, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.has_privacy_mask = True camera_obj.privacy_zones = [] if actual_enabled: camera_obj.add_privacy_zone() camera_obj.mic_volume = 10 camera_obj.recording_settings.mode = RecordingMode.NEVER if level in {-1, 200}: with pytest.raises(ValidationError): await camera_obj.set_privacy(enabled, level, mode) assert not camera_obj.api.api_request.called else: expected = {} if level is not None: expected.update({"micVolume": level}) if mode is not None: expected.update( { "recordingSettings": { "mode": mode.value, }, }, ) if actual_enabled != enabled: if enabled: expected.update( {"privacyZones": [CameraZone.create_privacy_zone(0).unifi_dict()]}, ) else: expected.update({"privacyZones": []}) await camera_obj.set_privacy(enabled, level, mode) if not expected: assert not camera_obj.api.api_request.called else: camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json=expected, ) if enabled: assert camera_obj.is_privacy_on else: assert not camera_obj.is_privacy_on @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_set_person_track_no_ptz(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_ptz = False with pytest.raises(BadRequest): await camera_obj.set_person_track(True) assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_set_person_track(camera_obj: Camera | None, status: bool): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.is_ptz = True camera_obj.recording_settings.mode = RecordingMode.ALWAYS if status: camera_obj.smart_detect_settings.auto_tracking_object_types = [] else: camera_obj.smart_detect_settings.auto_tracking_object_types = [ SmartDetectObjectType.PERSON, ] camera_obj.api.api_request.reset_mock() await camera_obj.set_person_track(status) assert camera_obj.is_person_tracking_enabled is status camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json=( {"smartDetectSettings": {"autoTrackingObjectTypes": ["person"]}} if status else {"smartDetectSettings": {"autoTrackingObjectTypes": []}} ), ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_camera_disable_co(camera_obj: Camera | None, status: bool): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.is_ptz = True camera_obj.recording_settings.mode = RecordingMode.ALWAYS if status: camera_obj.smart_detect_settings.audio_types = [] else: camera_obj.smart_detect_settings.audio_types = [ SmartDetectAudioType.SMOKE, SmartDetectAudioType.CMONX, SmartDetectAudioType.SMOKE_CMONX, ] camera_obj.api.api_request.reset_mock() await camera_obj.set_smart_audio_detect_types( [SmartDetectAudioType.SMOKE, SmartDetectAudioType.SMOKE_CMONX] ) assert camera_obj.smart_detect_settings.audio_types == [ SmartDetectAudioType.SMOKE, SmartDetectAudioType.SMOKE_CMONX, ] camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json={"smartDetectSettings": {"audioTypes": ["alrmSmoke"]}}, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ("value", "lux"), [ (0, 0), (1, 1), (2, 3), (3, 5), (4, 7), (5, 10), (6, 12), (7, 15), (8, 20), (9, 25), (10, 30), ], ) @pytest.mark.asyncio() async def test_camera_set_icr_custom_lux( camera_obj: Camera | None, value: int, lux: int, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.has_led_ir = True if ( value == 0 ): # without this there is no change that gets send if the test value is 0 camera_obj.isp_settings.icr_custom_value = 1 else: camera_obj.isp_settings.icr_custom_value = 0 camera_obj.api.api_request.reset_mock() await camera_obj.set_icr_custom_lux(lux) assert camera_obj.isp_settings.icr_custom_value == value assert camera_obj.icr_lux_display == lux camera_obj.api.api_request.assert_called_with( f"cameras/{camera_obj.id}", method="patch", json=({"ispSettings": {"icrCustomValue": value}}), ) @pytest.mark.parametrize( ("mode", "expected"), [ (IRLEDMode.CUSTOM, True), (IRLEDMode.CUSTOM_FILTER_ONLY, True), (IRLEDMode.AUTO, False), (IRLEDMode.AUTO_NO_LED, False), (IRLEDMode.MANUAL, False), (IRLEDMode.ON, False), (IRLEDMode.OFF, False), ], ) def test_camera_is_ir_led_slider_enabled( camera_obj: Camera | None, mode: IRLEDMode, expected: bool, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.has_led_ir = True camera_obj.isp_settings.ir_led_mode = mode assert camera_obj.is_ir_led_slider_enabled is expected def test_camera_is_ir_led_slider_enabled_no_led_ir(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.has_led_ir = False camera_obj.isp_settings.ir_led_mode = IRLEDMode.CUSTOM assert camera_obj.is_ir_led_slider_enabled is False @pytest.mark.asyncio async def test_get_snapshot_read_live_granted(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data") auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return permission == PermissionNode.READ_LIVE with patch.object(auth_user, "can", side_effect=mock_can): snapshot = await camera_obj.get_snapshot() assert snapshot == b"snapshot_data" @pytest.mark.asyncio async def test_get_snapshot_read_media_granted(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data") auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return permission == PermissionNode.READ_MEDIA with patch.object(auth_user, "can", side_effect=mock_can): snapshot = await camera_obj.get_snapshot() assert snapshot == b"snapshot_data" @pytest.mark.asyncio async def test_get_snapshot_no_permissions(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data") auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return False with ( patch.object(auth_user, "can", side_effect=mock_can), pytest.raises( NotAuthorized, match=f"Do not have permission to read live or media for camera: {camera_obj.id}", ), ): await camera_obj.get_snapshot() @pytest.mark.asyncio async def test_get_public_api_snapshot_no_api_key(camera_obj: Camera): """Test that get_public_api_snapshot fails with NotAuthorized if no API key is set in the API client.""" camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api._api_key = None # get_public_api_snapshot should raise NotAuthorized if api_key is missing with pytest.raises( NotAuthorized, match=r"Cannot get public API snapshot without an API key\." ): await camera_obj.get_public_api_snapshot() @pytest.mark.asyncio @pytest.mark.parametrize("high_quality", [False, True]) async def test_get_public_api_snapshot_hq(camera_obj: Camera, high_quality: bool): """Test that get_public_api_snapshot returns the correct snapshot quality.""" camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_public_api_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) snapshot = await camera_obj.get_public_api_snapshot(high_quality=high_quality) assert snapshot == b"snapshot_data" camera_obj._api.get_public_api_camera_snapshot.assert_called_once_with( camera_id=camera_obj.id, high_quality=high_quality, package=False ) @pytest.mark.parametrize("high_quality", [False, True]) @pytest.mark.asyncio async def test_get_public_api_snapshot_default_quality( camera_obj: Camera, high_quality: bool ): """Test get_public_api_snapshot without high_quality argument uses feature flag.""" camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj.feature_flags.support_full_hd_snapshot = high_quality camera_obj._api.get_public_api_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) snapshot = await camera_obj.get_public_api_snapshot() assert snapshot == b"snapshot_data" camera_obj._api.get_public_api_camera_snapshot.assert_called_once_with( camera_id=camera_obj.id, high_quality=high_quality, package=False ) @pytest.mark.parametrize("package", [False, True]) @pytest.mark.asyncio async def test_get_public_api_snapshot_package(camera_obj: Camera, package: bool): """Test get_public_api_snapshot forwards the package flag to the api layer.""" camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj.feature_flags.support_full_hd_snapshot = False camera_obj._api.get_public_api_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) snapshot = await camera_obj.get_public_api_snapshot(package=package) assert snapshot == b"snapshot_data" camera_obj._api.get_public_api_camera_snapshot.assert_called_once_with( camera_id=camera_obj.id, high_quality=False, package=package ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing test data") @pytest.mark.asyncio async def test_get_snapshot_with_dt(camera_obj: Camera): camera_obj.api.api_request.reset_mock() camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data") now = datetime.now(tz=UTC) snapshot = await camera_obj.get_snapshot(dt=now) assert snapshot == b"snapshot_data" camera_obj._api.get_camera_snapshot.assert_called_once_with( camera_obj.id, None, camera_obj.high_camera_channel.height, dt=now ) @pytest.mark.asyncio async def test_get_snapshot_with_dt_no_read_media(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_camera_snapshot = AsyncMock(return_value=b"snapshot_data") auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return permission != PermissionNode.READ_MEDIA with ( patch.object(auth_user, "can", side_effect=mock_can), pytest.raises( NotAuthorized, match=f"Do not have permission to read media for camera: {camera_obj.id}", ), ): await camera_obj.get_snapshot(dt=datetime.now()) @pytest.mark.asyncio async def test_get_package_snapshot_read_live_granted(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_package_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) camera_obj.feature_flags.has_package_camera = True auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return permission == PermissionNode.READ_LIVE with patch.object(auth_user, "can", side_effect=mock_can): snapshot = await camera_obj.get_package_snapshot() assert snapshot == b"snapshot_data" @pytest.mark.asyncio async def test_get_package_snapshot_read_media_granted(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_package_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) camera_obj.feature_flags.has_package_camera = True auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return permission == PermissionNode.READ_MEDIA with patch.object(auth_user, "can", side_effect=mock_can): snapshot = await camera_obj.get_package_snapshot() assert snapshot == b"snapshot_data" @pytest.mark.asyncio async def test_get_package_snapshot_no_permissions(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_package_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) camera_obj.feature_flags.has_package_camera = True auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return False with ( patch.object(auth_user, "can", side_effect=mock_can), pytest.raises( NotAuthorized, match=f"Do not have permission to read live or media for camera: {camera_obj.id}", ), ): await camera_obj.get_package_snapshot() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing test data") @pytest.mark.asyncio async def test_get_package_snapshot_with_dt(camera_obj: Camera): camera_obj.api.api_request.reset_mock() camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_package_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) camera_obj.feature_flags.has_package_camera = True now = datetime.now(tz=UTC) snapshot = await camera_obj.get_package_snapshot(dt=now) assert snapshot == b"snapshot_data" camera_obj._api.get_package_camera_snapshot.assert_called_once_with( camera_obj.id, None, None, dt=now ) @pytest.mark.asyncio async def test_get_package_snapshot_no_package_camera(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_package_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) # Simulate a device without a package camera camera_obj.feature_flags.has_package_camera = False with pytest.raises( BadRequest, match="Device does not have package camera", ): await camera_obj.get_package_snapshot() @pytest.mark.asyncio async def test_get_package_snapshot_dt_no_read_media(camera_obj: Camera | None): camera_obj._api = MagicMock(spec=ProtectApiClient) camera_obj._api.get_package_camera_snapshot = AsyncMock( return_value=b"snapshot_data" ) camera_obj.feature_flags.has_package_camera = True auth_user = camera_obj._api.bootstrap.auth_user def mock_can(model_type: str, permission: PermissionNode, camera: Camera) -> bool: return ( permission != PermissionNode.READ_MEDIA ) # Simulate missing READ_MEDIA permission with ( patch.object(auth_user, "can", side_effect=mock_can), pytest.raises( NotAuthorized, match=f"Do not have permission to read media for camera: {camera_obj.id}", ), ): await camera_obj.get_package_snapshot(dt=datetime.now()) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_camera_can_detect_face(camera_obj: Camera) -> None: # Test when face detection is supported camera_obj.feature_flags.smart_detect_types = [SmartDetectObjectType.FACE] assert camera_obj.can_detect_face is True # Test when face detection is not supported camera_obj.feature_flags.smart_detect_types = [SmartDetectObjectType.PERSON] assert camera_obj.can_detect_face is False @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_camera_is_face_detection_on(camera_obj: Camera) -> None: # Test when face detection is enabled camera_obj.feature_flags.smart_detect_types = [SmartDetectObjectType.FACE] camera_obj.smart_detect_settings.object_types = [SmartDetectObjectType.FACE] assert camera_obj.is_face_detection_on is True # Test when face detection is disabled camera_obj.smart_detect_settings.object_types = [] assert camera_obj.is_face_detection_on is False # Test when face detection is not supported camera_obj.feature_flags.smart_detect_types = [] assert camera_obj.is_face_detection_on is False @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_camera_is_face_currently_detected(camera_obj: Camera) -> None: # Set up camera to support face detection camera_obj.feature_flags.can_optical_zoom = True camera_obj.smart_detect_settings.object_types = [SmartDetectObjectType.FACE] # Test when face is currently detected camera_obj.is_smart_detected = True camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.FACE] = "test_event_id" # Create mock event that's ongoing (end=None) with face detection mock_event = Mock() mock_event.end = None mock_event.smart_detect_types = [SmartDetectObjectType.FACE] with patch.object(camera_obj.api.bootstrap.events, "get", return_value=mock_event): assert camera_obj.is_face_currently_detected is True # Test when face is not currently detected (no event) camera_obj.last_smart_detect_event_ids.pop(SmartDetectObjectType.FACE, None) assert camera_obj.is_face_currently_detected is False # Test when face is not currently detected (event ended) camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.FACE] = "test_event_id" mock_event.end = datetime.now() with patch.object(camera_obj.api.bootstrap.events, "get", return_value=mock_event): assert camera_obj.is_face_currently_detected is False def test_camera_zone_color_serialization() -> None: """Test that CameraZone color is serialized as hex string, not dict with RGB values.""" zone = CameraZone.create_privacy_zone(0) zone_dict = zone.unifi_dict() # Verify color is serialized as string (hex format) assert "color" in zone_dict assert isinstance(zone_dict["color"], str) assert zone_dict["color"] == "#85BCEC" # Verify it's not a dict with RGB values (which would cause Pydantic warnings) assert not isinstance(zone_dict["color"], dict) def test_camera_zone_color_serialization_from_dict() -> None: """ Test that CameraZone.unifi_dict() converts color dict to hex string. This simulates the case where Pydantic's serializer returns a dict with RGB values instead of a hex string, which would cause warnings. """ zone = CameraZone.create_privacy_zone(0) # Create a dict with color as a dict (simulating Pydantic serialization issue) zone_dict_with_color_dict: dict[str, Any] = { "id": 0, "name": "pyufp_privacy_zone", "color": {"r": 133, "g": 188, "b": 236, "a": 1.0}, # Color as dict "points": [[0, 0], [1, 0], [1, 1], [0, 1]], } # Call unifi_dict with this data - it should convert color dict to hex string result = zone.unifi_dict(data=zone_dict_with_color_dict) # Verify the color was converted from dict to hex string assert isinstance(result["color"], str) assert result["color"] == "#85BCEC" # PTZ Public API Tests @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ("method", "args"), [ ("ptz_goto_preset_public", {"slot": 0}), ("ptz_patrol_start_public", {"slot": 0}), ("ptz_patrol_stop_public", {}), ], ) @pytest.mark.asyncio() async def test_camera_ptz_public_api_no_ptz( camera_obj: Camera | None, method: str, args: dict, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request_raw.reset_mock() camera_obj.feature_flags.is_ptz = False with pytest.raises(BadRequest, match="Camera does not support PTZ features"): await getattr(camera_obj, method)(**args) assert not camera_obj.api.api_request_raw.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ("active_patrol_slot", "expected"), [ (None, False), (0, True), (1, True), ], ) def test_camera_is_ptz_patrolling( camera_obj: Camera | None, active_patrol_slot: int | None, expected: bool, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.active_patrol_slot = active_patrol_slot assert camera_obj.is_ptz_patrolling is expected @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_get_ptz_patrols(ptz_camera: Camera | None): if ptz_camera is None: pytest.skip("No ptz_camera obj found") ptz_camera.api.api_request = AsyncMock( return_value=[ { "id": "patrol1", "name": "Patrol 1", "slot": 0, "presets": [0, 1], "presetMovementSpeed": 100, "presetDurationSeconds": 10, "camera": ptz_camera.id, "modelKey": "ptzPatrol", }, ] ) patrols = await ptz_camera.get_ptz_patrols() assert len(patrols) == 1 assert isinstance(patrols[0], PTZPatrol) assert patrols[0].name == "Patrol 1" assert patrols[0].slot == 0 assert patrols[0].presets == [0, 1] ptz_camera.api.api_request.assert_called_with( f"cameras/{ptz_camera.id}/ptz/patrol", ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_camera_get_ptz_patrols_no_ptz(camera_obj: Camera | None): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_ptz = False with pytest.raises(BadRequest, match="Camera does not support PTZ features"): await camera_obj.get_ptz_patrols() assert not camera_obj.api.api_request.called @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ("method", "args", "expected_path"), [ ("ptz_goto_preset_public", {"slot": 2}, "/ptz/goto/2"), ("ptz_patrol_start_public", {"slot": 1}, "/ptz/patrol/start/1"), ("ptz_patrol_stop_public", {}, "/ptz/patrol/stop"), ], ) @pytest.mark.asyncio() async def test_camera_ptz_public_api( ptz_camera: Camera | None, method: str, args: dict, expected_path: str, ): if ptz_camera is None: pytest.skip("No ptz_camera obj found") ptz_camera.api.api_request_raw = AsyncMock(return_value=b"") await getattr(ptz_camera, method)(**args) ptz_camera.api.api_request_raw.assert_called_with( url=f"/v1/cameras/{ptz_camera.id}{expected_path}", method="post", public_api=True, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ("smart_detect_types", "smart_detect_audio_types", "smart_type", "expected"), [ ([SmartDetectObjectType.PERSON], [], SmartDetectObjectType.PERSON, True), ([SmartDetectObjectType.PERSON], [], SmartDetectObjectType.VEHICLE, False), ([], [SmartDetectAudioType.CMONX], SmartDetectObjectType.CMONX, True), ([], [SmartDetectAudioType.SMOKE], SmartDetectObjectType.CMONX, False), ([], [SmartDetectAudioType.BURGLAR], SmartDetectObjectType.BURGLAR, True), ([], [SmartDetectAudioType.SMOKE], SmartDetectObjectType.BURGLAR, False), ([], [SmartDetectAudioType.SMOKE], SmartDetectObjectType.PERSON, False), ([SmartDetectObjectType.SMOKE], [], SmartDetectObjectType.SMOKE, False), ([], None, SmartDetectObjectType.SMOKE, False), ], ) @pytest.mark.asyncio() async def test_camera_can_detect( camera_obj: Camera | None, smart_detect_types: list[SmartDetectObjectType], smart_detect_audio_types: list[SmartDetectAudioType] | None, smart_type: SmartDetectObjectType, expected: bool, ): if camera_obj is None: pytest.skip("No camera_obj obj found") camera_obj.feature_flags.smart_detect_types = smart_detect_types camera_obj.feature_flags.smart_detect_audio_types = smart_detect_audio_types assert camera_obj.can_detect(smart_type) is expected uilibs-uiprotect-317a52c/tests/data/test_chime.py000066400000000000000000000231711524637705700221410ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr" from __future__ import annotations from typing import TYPE_CHECKING import pytest from pydantic import ValidationError from tests.conftest import TEST_CAMERA_EXISTS, TEST_CHIME_EXISTS from uiprotect.data import RingSetting from uiprotect.exceptions import BadRequest if TYPE_CHECKING: from uiprotect.data import Camera, Chime @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.parametrize("level", [-1, 0, 100, 200]) @pytest.mark.asyncio() async def test_chime_set_volume( chime_obj: Chime | None, camera_obj: Camera | None, level: int, ): if chime_obj is None: pytest.skip("No chime_obj obj found") chime_obj.api.api_request.reset_mock() chime_obj.volume = 20 chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=1, # type: ignore[arg-type] ringtone_id="test-ringtone-id", volume=20, ), ] if level in {-1, 200}: with pytest.raises(ValidationError): await chime_obj.set_volume(level) assert not chime_obj.api.api_request.called else: await chime_obj.set_volume(level) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}", method="patch", json={ "volume": level, "ringSettings": [ { "camera": camera_obj.id, "repeatTimes": 1, "ringtoneId": "test-ringtone-id", "trackNo": None, "volume": level, }, ], }, ) @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_set_volume_with_existing_custom( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.camera_ids = [camera_obj.id] chime_obj.volume = 100 chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=1, # type: ignore[arg-type] ringtone_id="test-ringtone-id", volume=20, ), ] camera_obj.api.api_request.reset_mock() await chime_obj.set_volume(50) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}", method="patch", json={"volume": 50}, ) @pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_chime_play(chime_obj: Chime | None): if chime_obj is None: pytest.skip("No chime_obj obj found") await chime_obj.play() chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}/play-speaker", method="post", json=None, ) @pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_chime_play_with_options(chime_obj: Chime | None): if chime_obj is None: pytest.skip("No chime_obj obj found") chime_obj.volume = 100 chime_obj.repeat_times = 1 chime_obj.api.api_request.reset_mock() await chime_obj.play(volume=50) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}/play-speaker", method="post", json={ "volume": 50, "repeatTimes": 1, }, ) @pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_chime_play_buzzer(chime_obj: Chime | None): if chime_obj is None: pytest.skip("No chime_obj obj found") await chime_obj.play_buzzer() chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}/play-buzzer", method="post", ) @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_add_camera( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.api.api_request.reset_mock() chime_obj.camera_ids = [] camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_doorbell = True await chime_obj.add_camera(camera_obj) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}", method="patch", json={"cameraIds": [camera_obj.id]}, ) @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_add_camera_not_doorbell( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.api.api_request.reset_mock() chime_obj.camera_ids = [] camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_doorbell = False with pytest.raises(BadRequest): await chime_obj.add_camera(camera_obj) assert not chime_obj.api.api_request.called @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_add_camera_exists( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.api.api_request.reset_mock() chime_obj.camera_ids = [camera_obj.id] camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_doorbell = True with pytest.raises(BadRequest): await chime_obj.add_camera(camera_obj) assert not chime_obj.api.api_request.called @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_remove_camera( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.api.api_request.reset_mock() chime_obj.camera_ids = [camera_obj.id] camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_doorbell = True await chime_obj.remove_camera(camera_obj) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}", method="patch", json={"cameraIds": []}, ) @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_remove_camera_not_exists( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.api.api_request.reset_mock() chime_obj.camera_ids = [] camera_obj.api.api_request.reset_mock() camera_obj.feature_flags.is_doorbell = True with pytest.raises(BadRequest): await chime_obj.remove_camera(camera_obj) assert not chime_obj.api.api_request.called @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_set_repeat_times( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.camera_ids = [camera_obj.id] chime_obj.repeat_times = 1 chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=1, # type: ignore[arg-type] ringtone_id="test-ringtone-id", volume=100, ), ] camera_obj.api.api_request.reset_mock() await chime_obj.set_repeat_times(2) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}", method="patch", json={ "repeatTimes": 2, "ringSettings": [ { "camera": camera_obj.id, "repeatTimes": 2, "ringtoneId": "test-ringtone-id", "trackNo": None, "volume": 100, }, ], }, ) @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_set_repeat_times_with_existing_custom( chime_obj: Chime | None, camera_obj: Camera | None, ): if chime_obj is None: pytest.skip("No chime_obj obj found") if camera_obj is None: pytest.skip("No camera_obj obj found") chime_obj.camera_ids = [camera_obj.id] chime_obj.repeat_times = 1 chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=3, # type: ignore[arg-type] ringtone_id="test-ringtone-id", volume=100, ), ] camera_obj.api.api_request.reset_mock() await chime_obj.set_repeat_times(2) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}", method="patch", json={"repeatTimes": 2}, ) uilibs-uiprotect-317a52c/tests/data/test_common.py000066400000000000000000002042171524637705700223460ustar00rootroot00000000000000"""Tests for uiprotect.data""" from __future__ import annotations import asyncio import base64 import logging from copy import deepcopy from datetime import timedelta from ipaddress import IPv4Address from pathlib import Path from typing import TYPE_CHECKING, Any, cast from unittest.mock import AsyncMock, Mock, patch import pytest from tests.conftest import ( TEST_BRIDGE_EXISTS, TEST_CAMERA_EXISTS, TEST_LIGHT_EXISTS, TEST_SENSOR_EXISTS, TEST_VIEWPORT_EXISTS, MockTalkback, compare_objs, ) from tests.sample_data.constants import CONSTANTS from uiprotect.data import ( Bootstrap, Camera, DoorbellMessageType, Event, EventType, FixSizeOrderedDict, ModelType, Permission, RecordingMode, SmartDetectAudioType, SmartDetectObjectType, StorageType, User, VideoMode, WSPacket, create_from_unifi_dict, ) from uiprotect.data.bootstrap import MAX_EVENT_HISTORY_IN_STATE_MACHINE from uiprotect.data.devices import LCDMessage from uiprotect.data.nvr import EventMetadata from uiprotect.data.types import RecordingType, ResolutionStorageType from uiprotect.data.user import CloudAccount from uiprotect.exceptions import BadRequest, NotAuthorized, StreamError from uiprotect.utils import set_debug, set_no_debug, utc_now from ..common import assert_equal_dump if TYPE_CHECKING: from pytest_benchmark.fixture import BenchmarkFixture from uiprotect import ProtectApiClient PACKET_B64 = "AQEBAAAAAHR4nB2MQQrCMBBFr1JmbSDNpJnRG4hrDzBNZqCgqUiriHh3SZb/Pd7/guRtWSucBtgfRTaFwwBV39c+zqUJskQW1DufUVwkJsfFxDGLyRFj0dSz+1r0dtFPa+rr2dDSD8YsyceUpskQxzjjHIIQMvz+hMoj/AIBAQAAAAA1eJyrViotKMnMTVWyUjA0MjawMLQ0MDDQUVDKSSwuCU5NzQOJmxkbACUszE0sLQ1rAVU/DPU=" PACKET_ACTION = { "action": "update", "newUpdateId": "7f67f2e0-0c3a-4787-8dfa-88afa934de6e", "modelKey": "nvr", "id": "1ca6046655f3314b3b22a738", } PACKET_DATA = {"uptime": 1230819000, "lastSeen": 1630081874991} PACKET2_B64 = "AQEBAAAAAHZ4nB2MQQrDMAwEvxJ0rqGxFNnuD0rOfYBsyRBoklISSij9e3GOO8PsF6Rs07rArYP9pbIZXDpY7PM4x12bSNdMGhkdZZ8dBakusanLHHmoojwQt2xe1Z6jHa0pMttbGp3OD0JDid7YY/VYrPfWhxQEfn/qpCUVAgEBAAAAATl4nHWQzU7DMBCEXwX5jJD/1nE5grjBoeoTuMk2tTBOsB2gqvLuOA5NaRE369vZ8cweSUwmRXJ/cyTh6+GQcHqDWEkQWt3ekLRALkAJpfhKZvxpd7Ys1XvjPbr89oNzebIL+D6grw9n5Kx/3fSIzcu2j2ccbeuNWw/G2TSpgS5wkwL6Nu0zpWOmW5MShkP5scdQo0+mxbOVjY97E1rr28x2xkWcrBxiv8n1JiFpbKy7HLVO2JDJ88M22M3Fse5Ck5ezOKSMWG4JTIKirLRdBE++KWPBGVWgKg4X47L/vD450KyoKIMrhx/B7KE5V+XO9g2d6SP+l2ERXGTgigum/+yfMlRAtZJUU3rl8CuDkBqUVNNJYurCfNcjGSJO//A8ZCA4MF2qztcUtLrjq4prClBVQo7j+A3Be62W" PACKET2_ACTION = { "action": "update", "newUpdateId": "90b4d863-4b2b-47af-96ed-b6865fad6546", "modelKey": "camera", "id": "43e3a82e623f23ce12e1797a", } PACKET2_DATA = { "stats": { "rxBytes": 53945386, "txBytes": 2356366294, "wifi": { "channel": None, "frequency": None, "linkSpeedMbps": None, "signalQuality": 50, "signalStrength": 0, }, "battery": { "percentage": None, "isCharging": False, "sleepState": "disconnected", }, "video": { "recordingStart": 1629514560194, "recordingEnd": 1632106567254, "recordingStartLQ": 1629505677015, "recordingEndLQ": 1632106582266, "timelapseStart": 1629514560194, "timelapseEnd": 1632106262318, "timelapseStartLQ": 1627508640800, "timelapseEndLQ": 1632103485646, }, "storage": {"used": 285615325184, "rate": 307.297280557734}, }, } def test_packet_decode(): packet_raw = base64.b64decode(PACKET_B64) packet = WSPacket(packet_raw) assert packet.raw == packet_raw assert packet.raw_base64 == PACKET_B64 assert packet.action_frame.data == PACKET_ACTION assert packet.data_frame.data == PACKET_DATA def test_packet_raw_setter(): packet_raw = base64.b64decode(PACKET_B64) packet2_raw = base64.b64decode(PACKET2_B64) packet = WSPacket(packet_raw) packet.raw = packet2_raw assert packet.raw == packet2_raw assert packet.raw_base64 == PACKET2_B64 assert packet.action_frame.data == PACKET2_ACTION assert packet.data_frame.data == PACKET2_DATA def compare_devices(data): obj = create_from_unifi_dict(deepcopy(data)) obj_dict = obj.unifi_dict() compare_objs(obj.model.value, data, obj_dict) set_no_debug() obj_construct = create_from_unifi_dict(deepcopy(data)) assert_equal_dump(obj, obj_construct) set_debug() @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") def test_viewport(viewport): compare_devices(viewport) @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") def test_light(light): compare_devices(light) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_camera(camera): compare_devices(camera) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") def test_sensor(sensor): compare_devices(sensor) @pytest.mark.skipif(not TEST_BRIDGE_EXISTS, reason="Missing testdata") def test_bridge(bridge): compare_devices(bridge) @pytest.mark.timeout(CONSTANTS["event_count"] * 0.1) def test_events(raw_events): for event in raw_events: compare_devices(event) @pytest.fixture def reset_smart_detect(camera_obj: Camera): """Reset smart detection state on camera and clear bootstrap events for test isolation.""" camera_obj.last_smart_detect_event_id = None camera_obj.last_smart_detect = None camera_obj.last_smart_detect_event_ids = {} camera_obj.last_smart_detects = {} camera_obj._active_smart_detect_events = {} camera_obj.is_smart_detected = True camera_obj.api.bootstrap.events.clear() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_camera_smart_events(camera_obj: Camera, reset_smart_detect: None): now = utc_now() events = [ Event( # type: ignore[call-arg] api=camera_obj.api, id="test_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ), Event( # type: ignore[call-arg] api=camera_obj.api, id="test_event_2", camera_id=camera_obj.id, start=now - timedelta(seconds=15), end=now - timedelta(seconds=8), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PACKAGE], smart_detect_event_ids=[], ), Event( # type: ignore[call-arg] api=camera_obj.api, id="test_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), end=now - timedelta(seconds=7), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[ SmartDetectObjectType.PERSON, SmartDetectObjectType.FACE, SmartDetectObjectType.VEHICLE, ], smart_detect_event_ids=[], ), Event( # type: ignore[call-arg] api=camera_obj.api, id="test_event_3", camera_id=camera_obj.id, start=now - timedelta(seconds=5), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], smart_detect_event_ids=[], ), ] for event in events: camera_obj.api.bootstrap.process_event(event) assert camera_obj.last_smart_detect == now - timedelta(seconds=5) assert camera_obj.last_person_detect == now - timedelta(seconds=10) assert camera_obj.last_face_detect == now - timedelta(seconds=10) assert camera_obj.last_vehicle_detect == now - timedelta(seconds=10) assert camera_obj.last_package_detect == now - timedelta(seconds=15) assert camera_obj.last_license_plate_detect == now - timedelta(seconds=5) assert camera_obj.last_smart_detect_event is not None assert camera_obj.last_smart_detect_event.id == "test_event_3" assert camera_obj.last_person_detect_event is not None assert camera_obj.last_person_detect_event.id == "test_event_1" assert camera_obj.last_face_detect_event is not None assert camera_obj.last_face_detect_event.id == "test_event_1" assert camera_obj.last_vehicle_detect_event is not None assert camera_obj.last_vehicle_detect_event.id == "test_event_1" assert camera_obj.last_package_detect_event is not None assert camera_obj.last_package_detect_event.id == "test_event_2" assert camera_obj.last_license_plate_detect_event is not None assert camera_obj.last_license_plate_detect_event.id == "test_event_3" @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_concurrent_smart_detect_zone_and_line( camera_obj: Camera, reset_smart_detect: None ): """Test sensor stays ON when zone event ends but line event is still active.""" now = utc_now() bootstrap = camera_obj.api.bootstrap # Both events start (active, end=None) zone_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) line_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="line_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone_event) bootstrap.process_event(line_event) # Line event should be tracked (last processed active event) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "line_event_1" ) # Both top-level and per-type sensor state should report ON. assert camera_obj.is_smart_currently_detected is True assert camera_obj.is_person_currently_detected is True # Zone event ends zone_event_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), end=now - timedelta(seconds=5), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone_event_ended) # Line event is still active and tracked — sensor should stay ON assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "line_event_1" ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is None # Sensor state must stay ON during the overlap — this is the HA regression. assert camera_obj.is_smart_currently_detected is True assert camera_obj.is_person_currently_detected is True # Line event ends line_event_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="line_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), end=now - timedelta(seconds=3), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(line_event_ended) # No active event left — tracked event is now ended and sensor flips OFF. event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is not None assert camera_obj.is_smart_currently_detected is False assert camera_obj.is_person_currently_detected is False @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_singular_last_smart_detect_event_id_not_overwritten_by_ended( camera_obj: Camera, reset_smart_detect: None ): """ Ended smart event must not overwrite the singular last_smart_detect_event_id. Protect v7 emits a SMART_DETECT and SMART_DETECT_LINE simultaneously. When one ends while the other is still active, the singular ``last_smart_detect_event_id`` (used by ``Camera.is_smart_currently_detected`` and ``last_smart_detect_event``) must not be overwritten with the ended event's id — otherwise the sensor flips OFF for the overlap window. """ now = utc_now() bootstrap = camera_obj.api.bootstrap zone_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) line_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="line_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone_event) bootstrap.process_event(line_event) assert camera_obj.last_smart_detect_event_id == "line_event_1" last = camera_obj.last_smart_detect_event assert last is not None assert last.end is None # Zone event ends — line event is still active. zone_event_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), end=now - timedelta(seconds=5), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone_event_ended) # The singular field must still reference an active event, not the ended one. assert camera_obj.last_smart_detect_event_id == "line_event_1" last = camera_obj.last_smart_detect_event assert last is not None assert last.id == "line_event_1" assert last.end is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_tracked_event_ends_replaced_by_active( camera_obj: Camera, reset_smart_detect: None ): """Test that when the tracked event ends, it's replaced by another active event.""" now = utc_now() bootstrap = camera_obj.api.bootstrap # Zone event starts first and is tracked zone_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone_event) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "zone_event_1" ) # Line event starts — overwrites tracking line_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="line_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=9), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(line_event) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "line_event_1" ) # Line event (tracked) ends — should be replaced by zone_event_1 line_event_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="line_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=9), end=now - timedelta(seconds=5), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(line_event_ended) # Should now track zone_event_1 (still active) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "zone_event_1" ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_concurrent_different_smart_types(camera_obj: Camera, reset_smart_detect: None): """Test that different smart types are tracked independently.""" now = utc_now() bootstrap = camera_obj.api.bootstrap person_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="person_event", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) vehicle_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="vehicle_event", camera_id=camera_obj.id, start=now - timedelta(seconds=8), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.VEHICLE], smart_detect_event_ids=[], ) bootstrap.process_event(person_event) bootstrap.process_event(vehicle_event) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "person_event" ) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] == "vehicle_event" ) # Person event ends — should not affect vehicle tracking person_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="person_event", camera_id=camera_obj.id, start=now - timedelta(seconds=10), end=now - timedelta(seconds=5), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(person_ended) # Person event ended, no replacement event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is not None # Vehicle still active and tracked assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] == "vehicle_event" ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.VEHICLE) assert event is not None assert event.end is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_multiple_zones_same_smart_type(camera_obj: Camera, reset_smart_detect: None): """Test two smartDetectZone events for the same type (e.g. person in two zones).""" now = utc_now() bootstrap = camera_obj.api.bootstrap zone1_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone1_event", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) zone2_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone2_event", camera_id=camera_obj.id, start=now - timedelta(seconds=9), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone1_event) bootstrap.process_event(zone2_event) # zone2 is tracked (last active event processed) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "zone2_event" ) # zone2 (tracked) ends — should be replaced by zone1 (still active) zone2_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone2_event", camera_id=camera_obj.id, start=now - timedelta(seconds=9), end=now - timedelta(seconds=5), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone2_ended) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "zone1_event" ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is None # zone1 ends too — no replacement zone1_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone1_event", camera_id=camera_obj.id, start=now - timedelta(seconds=10), end=now - timedelta(seconds=3), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone1_ended) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is not None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_ended_event_does_not_overwrite_active( camera_obj: Camera, reset_smart_detect: None ): """Test that an ended event for a non-tracked ID does not overwrite an active one.""" now = utc_now() bootstrap = camera_obj.api.bootstrap # Active event is tracked active_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="active_event", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(active_event) # A different ended event arrives — should NOT overwrite the active tracking ended_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="ended_event", camera_id=camera_obj.id, start=now - timedelta(seconds=15), end=now - timedelta(seconds=12), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(ended_event) # Active event should still be tracked assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "active_event" ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_no_prior_tracking_stores_ended_event_as_last_known( camera_obj: Camera, reset_smart_detect: None ): """When no event was ever tracked and an ended event arrives first, store it as last known.""" now = utc_now() bootstrap = camera_obj.api.bootstrap # Verify truly no prior state assert ( camera_obj.last_smart_detect_event_ids.get(SmartDetectObjectType.PERSON) is None ) assert ( camera_obj._active_smart_detect_events.get(SmartDetectObjectType.PERSON) is None ) ended_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="ended_event", camera_id=camera_obj.id, start=now - timedelta(seconds=15), end=now - timedelta(seconds=12), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(ended_event) # Ended event is stored as last known; _is_smart_detected returns False because end is set assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "ended_event" ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.end is not None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_no_prior_tracking_prefers_active_index( camera_obj: Camera, reset_smart_detect: None ): """When ended event arrives and an active event exists in the per-camera index, prefer it.""" now = utc_now() bootstrap = camera_obj.api.bootstrap # Process active event first (populates the per-camera index) active_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="active_event", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(active_event) # Clear the tracking dict but keep the active index intact del camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] del camera_obj.last_smart_detects[SmartDetectObjectType.PERSON] # Ended event arrives with no prior tracking (current_id is None) ended_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="ended_event", camera_id=camera_obj.id, start=now - timedelta(seconds=15), end=now - timedelta(seconds=12), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(ended_event) # Should recover the active event from the per-camera index assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "active_event" ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.id == "active_event" assert event.end is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_replacement_prefers_first_processed_active_event( camera_obj: Camera, reset_smart_detect: None ): """Replacement picks the first-processed still-active event (processing order, not timestamp).""" now = utc_now() bootstrap = camera_obj.api.bootstrap # Intentionally set start times so chronological order does NOT match # processing order: zone1 is processed first but has the newest start, # zone2 is processed second but has the oldest start. This guarantees # the assertion below truly validates processing-order precedence — an # implementation that picked "oldest by start time" would fall back to # zone2 instead of zone1 and fail the test. zone1_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone1_event", camera_id=camera_obj.id, start=now - timedelta(seconds=8), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) zone2_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone2_event", camera_id=camera_obj.id, start=now - timedelta(seconds=11), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) zone3_event = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone3_event", camera_id=camera_obj.id, start=now - timedelta(seconds=9), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone1_event) bootstrap.process_event(zone2_event) bootstrap.process_event(zone3_event) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "zone3_event" ) zone3_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone3_event", camera_id=camera_obj.id, start=now - timedelta(seconds=9), end=now - timedelta(seconds=4), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone3_ended) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "zone1_event" ) assert ( camera_obj.last_smart_detects[SmartDetectObjectType.PERSON] == zone1_event.start ) event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.id == "zone1_event" assert event.end is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_active_event_survives_bootstrap_events_eviction( camera_obj: Camera, reset_smart_detect: None ): """Tracked event must remain accessible even if evicted from bootstrap.events.""" now = utc_now() bootstrap = camera_obj.api.bootstrap # Create 3 parallel events on the same camera zone1 = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) zone2 = Event( # type: ignore[call-arg] api=camera_obj.api, id="zone2", camera_id=camera_obj.id, start=now - timedelta(seconds=9), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) line1 = Event( # type: ignore[call-arg] api=camera_obj.api, id="line1", camera_id=camera_obj.id, start=now - timedelta(seconds=8), type=EventType.SMART_DETECT_LINE, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(zone1) bootstrap.process_event(zone2) bootstrap.process_event(line1) # All 3 active, tracked is line1 (last received) assert ( camera_obj.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] == "line1" ) assert camera_obj._is_smart_detected(SmartDetectObjectType.PERSON) is True # Simulate eviction: remove tracked event from bootstrap.events del bootstrap.events["line1"] # Event must still be accessible via the per-camera active index event = camera_obj.get_last_smart_detect_event(SmartDetectObjectType.PERSON) assert event is not None assert event.id == "line1" assert event.end is None assert camera_obj.last_smart_detect_event is not None assert camera_obj.last_smart_detect_event.id == "line1" assert camera_obj.is_smart_currently_detected is True assert camera_obj._is_smart_detected(SmartDetectObjectType.PERSON) is True @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_find_active_cleans_stale_in_place_mutations( camera_obj: Camera, reset_smart_detect: None, ): """Stale events (end set via in-place mutation) are cleaned from the active index.""" now = utc_now() bootstrap = camera_obj.api.bootstrap event_a = Event( # type: ignore[call-arg] api=camera_obj.api, id="event_a", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) event_b = Event( # type: ignore[call-arg] api=camera_obj.api, id="event_b", camera_id=camera_obj.id, start=now - timedelta(seconds=9), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(event_a) bootstrap.process_event(event_b) # Both in active index active = camera_obj._active_smart_detect_events[SmartDetectObjectType.PERSON] assert len(active) == 2 # Simulate in-place mutation (as update_from_dict does via WS update) event_a.end = now - timedelta(seconds=5) # Trigger _find_active_smart_event via an ended event for event_b event_b_ended = Event( # type: ignore[call-arg] api=camera_obj.api, id="event_b", camera_id=camera_obj.id, start=now - timedelta(seconds=9), end=now - timedelta(seconds=3), type=EventType.SMART_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], ) bootstrap.process_event(event_b_ended) # event_a was stale (end set in-place) — should be cleaned from active index assert SmartDetectObjectType.PERSON not in camera_obj._active_smart_detect_events @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_camera_smart_audio_events(camera_obj: Camera): now = utc_now() camera_obj.last_smart_audio_detect_event_id = None camera_obj.last_smart_audio_detect = None camera_obj.last_smart_audio_detect_event_ids = {} camera_obj.last_smart_audio_detects = {} events = [ Event( # type: ignore[call-arg] api=camera_obj.api, id="test_event_1", camera_id=camera_obj.id, start=now - timedelta(seconds=10), type=EventType.SMART_AUDIO_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.SMOKE], smart_detect_event_ids=[], ), Event( # type: ignore[call-arg] api=camera_obj.api, id="test_event_2", camera_id=camera_obj.id, start=now - timedelta(seconds=5), type=EventType.SMART_AUDIO_DETECT, score=100, smart_detect_types=[SmartDetectObjectType.CMONX], smart_detect_event_ids=[], ), ] for event in events: camera_obj.api.bootstrap.process_event(event) assert camera_obj.last_smart_audio_detect == now - timedelta(seconds=5) assert camera_obj.last_smoke_detect == now - timedelta(seconds=10) assert camera_obj.last_cmonx_detect == now - timedelta(seconds=5) assert camera_obj.last_smart_audio_detect_event is not None assert camera_obj.last_smart_audio_detect_event.id == "test_event_2" assert camera_obj.last_smoke_detect_event is not None assert camera_obj.last_smoke_detect_event.id == "test_event_1" assert camera_obj.last_cmonx_detect_event is not None assert camera_obj.last_cmonx_detect_event.id == "test_event_2" @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_bootstrap_events_are_bounded(camera_obj: Camera): """ Bootstrap.events must evict old entries so the in-memory cache is bounded. Previously this dict was an unbounded ``FixSizeOrderedDict()`` — every incoming event accumulated forever. The cap is ``MAX_EVENT_HISTORY_IN_STATE_MACHINE`` and is enforced by ``FixSizeOrderedDict.__setitem__``. """ bootstrap = camera_obj.api.bootstrap bootstrap.events.clear() now = utc_now() # Push one more event than the cap — the oldest should be evicted. overfill = MAX_EVENT_HISTORY_IN_STATE_MACHINE + 10 for i in range(overfill): event = Event( # type: ignore[call-arg] api=camera_obj.api, id=f"bounded_event_{i}", camera_id=camera_obj.id, start=now - timedelta(seconds=overfill - i), type=EventType.MOTION, score=0, smart_detect_types=[], smart_detect_event_ids=[], ) bootstrap.process_event(event) assert len(bootstrap.events) == MAX_EVENT_HISTORY_IN_STATE_MACHINE # Oldest insertions evicted, newest retained. assert "bounded_event_0" not in bootstrap.events assert f"bounded_event_{overfill - 1}" in bootstrap.events def test_bootstrap(bootstrap: dict[str, Any]): obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap)) set_no_debug() obj_construct = Bootstrap.from_unifi_dict(**deepcopy(bootstrap)) set_debug() obj_dict = obj.unifi_dict() bootstrap.pop("schedules", None) bootstrap.pop("agreements", None) bootstrap.pop("deviceGroups", None) # Remove additional keys from obj_dict obj_dict.pop("keyrings", None) obj_dict.pop("ulpUsers", None) for model_type in ModelType.bootstrap_models: key = model_type + "s" expected_data = bootstrap.pop(key) actual_data = obj_dict.pop(key) assert len(expected_data) == len(actual_data) for index, expected in enumerate(expected_data): actual = actual_data[index] compare_objs(expected["modelKey"], expected, actual) compare_objs(ModelType.NVR.value, bootstrap.pop("nvr"), obj_dict.pop("nvr")) assert bootstrap == obj_dict assert_equal_dump(obj, obj_construct) def test_bootstrap_aiports_missing(bootstrap: dict[str, Any]): deepcopied_bootstrap_1 = deepcopy(bootstrap) deepcopied_bootstrap_1.pop("aiports", None) logger = logging.getLogger("uiprotect.data.bootstrap") with patch.object(logger, "error") as mock_log_error: obj = Bootstrap.from_unifi_dict(**deepcopied_bootstrap_1) mock_log_error.assert_called_once_with( "Missing key in bootstrap: %s. This may be fixed by updating Protect.", "aiports", ) assert obj.aiports == {} def test_doorlock_modelkey_resolves_unknown(): """An abandoned doorlock modelKey degrades to UNKNOWN instead of raising.""" assert ModelType.from_string("doorlock") is ModelType.UNKNOWN assert "doorlock" not in ModelType.bootstrap_models_set def test_unifi_dict_exclude(bootstrap: dict[str, Any]): obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap)) obj_dict = obj.unifi_dict(exclude=set()) assert "authUserId" in obj_dict obj_dict = obj.unifi_dict() assert "authUserId" in obj_dict obj_dict = obj.unifi_dict(exclude={"auth_user_id"}) assert "authUserId" not in obj_dict obj_dict = obj.unifi_dict() assert "authUserId" in obj_dict obj_dict = obj.unifi_dict(exclude=set()) assert "authUserId" in obj_dict @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_bootstrap_device_not_adopted(bootstrap, protect_client: ProtectApiClient): bootstrap["cameras"][0]["isAdopted"] = False obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client) set_no_debug() obj_construct = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client) set_debug() expected_count = sum(1 if c["isAdopted"] else 0 for c in bootstrap["cameras"]) assert len(obj.cameras) == expected_count assert_equal_dump(obj.cameras, obj_construct.cameras) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_bootstrap_device_not_adopted_no_api(bootstrap): bootstrap["cameras"][0]["isAdopted"] = False obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap)) set_no_debug() obj_construct = cast("Bootstrap", Bootstrap.from_unifi_dict(**deepcopy(bootstrap))) set_debug() assert len(obj.cameras) == len(bootstrap["cameras"]) assert_equal_dump(obj.cameras, obj_construct.cameras) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_bootstrap_device_not_adopted_enabled( bootstrap: dict[str, Any], protect_client: ProtectApiClient, ): bootstrap["cameras"][0]["isAdopted"] = False protect_client.ignore_unadopted = False obj = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client) set_no_debug() obj_construct = Bootstrap.from_unifi_dict(**deepcopy(bootstrap), api=protect_client) set_debug() assert len(obj.cameras) == len(bootstrap["cameras"]) assert_equal_dump(obj.cameras, obj_construct.cameras) @pytest.mark.benchmark(group="construct") @pytest.mark.timeout(0) def test_bootstrap_benchmark(bootstrap: dict[str, Any], benchmark: BenchmarkFixture): def create(): Bootstrap.from_unifi_dict(**deepcopy(bootstrap)) benchmark.pedantic(create, rounds=50, iterations=5) @pytest.mark.benchmark(group="construct") @pytest.mark.timeout(0) def test_bootstrap_benchmark_construct( bootstrap: dict[str, Any], benchmark: BenchmarkFixture, ): set_no_debug() def create(): Bootstrap.from_unifi_dict(**deepcopy(bootstrap)) benchmark.pedantic(create, rounds=50, iterations=5) set_debug() def test_fix_order_size_dict_no_max(): d = FixSizeOrderedDict() d["test"] = 1 d["test2"] = 2 d["test3"] = 3 del d["test2"] assert d == {"test": 1, "test3": 3} def test_fix_order_size_dict_max(): d = FixSizeOrderedDict(max_size=1) d["test"] = 1 d["test2"] = 2 d["test3"] = 3 with pytest.raises(KeyError): del d["test2"] assert d == {"test3": 3} def test_fix_order_size_dict_negative_max(): d = FixSizeOrderedDict(max_size=-1) d["test"] = 1 d["test2"] = 2 d["test3"] = 3 del d["test2"] assert d == {"test": 1, "test3": 3} def test_case_str_enum(): assert RecordingMode("always") == RecordingMode.ALWAYS assert ResolutionStorageType("4K") == ResolutionStorageType.UHD assert VideoMode("highFps") == VideoMode.HIGH_FPS assert RecordingType("roTating") == RecordingType.CONTINUOUS @pytest.mark.asyncio() async def test_play_audio_no_speaker(camera_obj: Camera): camera_obj.feature_flags.has_speaker = False with pytest.raises(BadRequest): await camera_obj.play_audio("test") @pytest.mark.asyncio() @pytest.mark.usefixtures("_disable_camera_validation") async def test_play_audio_already_playing(camera_obj: Camera): camera_obj.feature_flags.has_speaker = True camera_obj.talkback_stream = Mock() camera_obj.talkback_stream.is_running = True with pytest.raises(BadRequest): await camera_obj.play_audio("test") @pytest.mark.asyncio() @pytest.mark.usefixtures("_disable_camera_validation") @patch("uiprotect.data.devices.TalkbackStream") async def test_play_audio(mock_talkback, camera_obj: Camera): camera_obj.feature_flags.has_speaker = True mock_instance = MockTalkback() mock_talkback.return_value = mock_instance # Mock the API key and call for public talkback session camera_obj._api._api_key = "test-api-key" camera_obj._api.create_talkback_session_public = AsyncMock( return_value=Mock( url="rtp://192.168.1.100:7004", codec="opus", sampling_rate=24000, host="192.168.1.100", port=7004, ) ) await camera_obj.play_audio("test") assert mock_talkback.called assert mock_instance.start.called assert mock_instance.run_until_complete.called @pytest.mark.asyncio() @pytest.mark.usefixtures("_disable_camera_validation") @patch("uiprotect.data.devices.TalkbackStream") async def test_play_audio_ffmpeg_path_deprecated( mock_talkback, camera_obj: Camera, caplog: pytest.LogCaptureFixture ): """Test that ffmpeg_path parameter triggers deprecation warning.""" camera_obj.feature_flags.has_speaker = True mock_talkback.return_value = MockTalkback() camera_obj._api._api_key = "test-api-key" camera_obj._api.create_talkback_session_public = AsyncMock(return_value=Mock()) await camera_obj.play_audio("test", ffmpeg_path=Path("/usr/bin/ffmpeg")) assert "ffmpeg_path is deprecated" in caplog.text @pytest.mark.asyncio() @pytest.mark.usefixtures("_disable_camera_validation") @patch("uiprotect.data.devices.TalkbackStream") async def test_play_audio_no_blocking(mock_talkback, camera_obj: Camera): camera_obj.feature_flags.has_speaker = True mock_instance = MockTalkback() mock_talkback.return_value = mock_instance # Mock the API key and call for public talkback session camera_obj._api._api_key = "test-api-key" camera_obj._api.create_talkback_session_public = AsyncMock( return_value=Mock( url="rtp://192.168.1.100:7004", codec="opus", sampling_rate=24000, host="192.168.1.100", port=7004, ) ) await camera_obj.play_audio("test", blocking=False) assert mock_talkback.called assert mock_instance.start.called assert not mock_instance.run_until_complete.called await camera_obj.wait_until_audio_completes() assert mock_instance.run_until_complete.called @pytest.mark.asyncio() @pytest.mark.usefixtures("_disable_camera_validation") @patch("uiprotect.data.devices.TalkbackStream") async def test_play_audio_stop(mock_talkback, camera_obj: Camera): camera_obj.feature_flags.has_speaker = True mock_instance = MockTalkback() mock_talkback.return_value = mock_instance # Mock the API key and call for public talkback session camera_obj._api._api_key = "test-api-key" camera_obj._api.create_talkback_session_public = AsyncMock( return_value=Mock( url="rtp://192.168.1.100:7004", codec="opus", sampling_rate=24000, host="192.168.1.100", port=7004, ) ) await camera_obj.play_audio("test", blocking=False) assert mock_talkback.called assert mock_instance.start.called assert not mock_instance.run_until_complete.called await camera_obj.stop_audio() assert mock_instance.stop.called @pytest.mark.asyncio() @pytest.mark.usefixtures("_disable_camera_validation") @patch("uiprotect.data.devices.TalkbackStream") async def test_play_audio_error(mock_talkback, camera_obj: Camera): camera_obj.feature_flags.has_speaker = True mock_instance = MockTalkback() mock_instance.run_until_complete = AsyncMock( side_effect=StreamError("Audio streaming failed") ) mock_talkback.return_value = mock_instance # Mock the API key and call for public talkback session camera_obj._api._api_key = "test-api-key" camera_obj._api.create_talkback_session_public = AsyncMock( return_value=Mock( url="rtp://192.168.1.100:7004", codec="opus", sampling_rate=24000, host="192.168.1.100", port=7004, ) ) with pytest.raises(StreamError): await camera_obj.play_audio("test") assert mock_talkback.called assert mock_instance.run_until_complete.called @pytest.mark.asyncio() async def test_get_smart_detect_track_bad_type(smart_dectect_obj: Event | None): if smart_dectect_obj is None: pytest.skip("No smart detection object found") smart_dectect_obj.type = EventType.MOTION with pytest.raises(BadRequest): await smart_dectect_obj.get_smart_detect_track() @pytest.mark.asyncio() async def test_get_smart_detect_track(smart_dectect_obj: Event | None): if smart_dectect_obj is None: pytest.skip("No smart detection object found") track = await smart_dectect_obj.get_smart_detect_track() assert track.camera @pytest.mark.asyncio() async def test_get_smart_detect_zones(smart_dectect_obj: Event | None): if smart_dectect_obj is None: pytest.skip("No smart detection object found") camera = smart_dectect_obj.camera if camera is None: pytest.skip("Camera not found for smart detection") track = await smart_dectect_obj.get_smart_detect_track() zone_ids: set[int] = set() for item in track.payload: zone_ids |= set(item.zone_ids) zones = await smart_dectect_obj.get_smart_detect_zones() for zone_id, zone in zones.items(): assert zone_id in zone_ids assert zone_id == zone.id assert zone in camera.smart_detect_zones def test_doorbell_bad_state(): message = LCDMessage.from_unifi_dict(text="Test") assert message.text == "Test" assert message.type == DoorbellMessageType.CUSTOM_MESSAGE def test_camera_ip_host(camera): camera["host"] = "1.1.1.1" camera["connectionHost"] = "1.1.1.1" camera_obj = Camera.from_unifi_dict(**camera) assert camera_obj.host == IPv4Address("1.1.1.1") assert camera_obj.connection_host == IPv4Address("1.1.1.1") def test_camera_dns_host(camera): camera["host"] = "se-gw.local" camera["connectionHost"] = "se-gw.local" camera_obj = Camera.from_unifi_dict(**camera) assert camera_obj.host == "se-gw.local" assert camera_obj.connection_host == "se-gw.local" def test_bootstrap_ip_host(bootstrap): bootstrap["nvr"]["hosts"] = ["1.1.1.1"] bootstrap_obj = Bootstrap.from_unifi_dict(**bootstrap) assert bootstrap_obj.nvr.hosts == [IPv4Address("1.1.1.1")] def test_bootstrap_dns_host(bootstrap): bootstrap["nvr"]["hosts"] = ["se-gw.local"] bootstrap_obj = Bootstrap.from_unifi_dict(**bootstrap) assert bootstrap_obj.nvr.hosts == ["se-gw.local"] @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_save_device_no_changes(camera_obj: Camera): camera_obj.api.api_request.reset_mock() # type: ignore[attr-defined] data_before_changes = camera_obj.dict_with_excludes() await camera_obj.save_device(data_before_changes) assert not camera_obj.api.api_request.called # type: ignore[attr-defined] @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_device_reboot(camera_obj: Camera): camera_obj.api.api_request.reset_mock() # type: ignore[attr-defined] await camera_obj.reboot() camera_obj.api.api_request.assert_called_with( # type: ignore[attr-defined] f"cameras/{camera_obj.id}/reboot", method="post", ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ( "permissions", "can_create", "can_read", "can_write", "can_delete", "can_read_media", "can_delete_media", ), [ (["camera:*:*"], True, True, True, True, True, True), ( ["camera:create,read,write,delete,readmedia,deletemedia:*"], True, True, True, True, True, True, ), ( ["camera:create,read,write,readmedia:*"], True, True, True, False, True, False, ), (["camera:read,readmedia:*"], False, True, False, False, True, False), ( ["camera:read,readmedia:test_id_1,test_id_2"], False, True, False, False, True, False, ), ( ["camera:read,readmedia:test_id_2,test_id_1"], False, True, False, False, True, False, ), ( ["camera:delete:test_id_1", "camera:read,readmedia:*"], False, True, False, True, True, False, ), ( ["camera:delete:test_id_2", "camera:read,readmedia:*"], False, True, False, False, True, False, ), ( ["camera:read,readmedia:*", "camera:delete:test_id_1"], False, True, False, True, True, False, ), ( ["camera:read,readmedia:*", "camera:delete:test_id_2"], False, True, False, False, True, False, ), ], ) @pytest.mark.asyncio() async def test_permissions( user_obj: User, camera_obj: Camera, permissions: list[str], can_create: bool, can_read: bool, can_write: bool, can_delete: bool, can_read_media: bool, can_delete_media: bool, ): if camera_obj is None: pytest.skip("No camera_obj obj found") api = user_obj.api user_obj.all_permissions = [ Permission.from_unifi_dict(rawPermission=p, api=api) for p in permissions ] camera_obj.id = "test_id_1" api.bootstrap.cameras[camera_obj.id] = camera_obj assert camera_obj.can_create(user_obj) is can_create assert camera_obj.can_read(user_obj) is can_read assert camera_obj.can_write(user_obj) is can_write assert camera_obj.can_delete(user_obj) is can_delete assert camera_obj.can_read_media(user_obj) is can_read_media assert camera_obj.can_delete_media(user_obj) is can_delete_media @pytest.mark.parametrize( ("permissions", "can_create", "can_read", "can_write", "can_delete"), [ (["user:*:*"], True, True, True, True), (["user:create,read,write,delete:*"], True, True, True, True), (["user:create,read,write,delete:$"], True, True, True, True), ( ["user:read,write:$", "user:create,read,write,delete:*"], True, True, True, True, ), (["user:read,write:*"], False, True, True, False), (["user:read,write:$"], False, True, True, False), ( ["user:read,write:$", "user:create,read,write,delete:test_id_2"], False, True, True, False, ), (["user:create,delete:$", "user:read,write:*"], True, True, True, True), ], ) @pytest.mark.asyncio() async def test_permissions_user( user_obj: User, permissions: list[str], can_create: bool, can_read: bool, can_write: bool, can_delete: bool, ): api = user_obj.api user1 = user_obj.model_copy() user1.id = "test_id_1" user1.all_permissions = [ Permission.from_unifi_dict(rawPermission=p, api=api) for p in permissions ] api.bootstrap.auth_user_id = user1.id api.bootstrap.users = {user1.id: user1} assert user1.can_create(user1) is can_create assert user1.can_read(user1) is can_read assert user1.can_write(user1) is can_write assert user1.can_delete(user1) is can_delete @pytest.mark.parametrize( ("permissions", "can_create", "can_read", "can_write", "can_delete"), [ (["user:*:*"], True, True, True, True), (["user:create,read,write,delete:*"], True, True, True, True), (["user:create,read,write,delete:$"], False, False, False, False), ( ["user:read,write:$", "user:create,read,write,delete:*"], True, True, True, True, ), (["user:read,write:*"], False, True, True, False), (["user:read,write:$"], False, False, False, False), ( ["user:read,write:$", "user:create,read,write,delete:test_id_2"], True, True, True, True, ), (["user:create,delete:$", "user:read,write:*"], False, True, True, False), ], ) @pytest.mark.asyncio() async def test_permissions_self_with_other( user_obj: User, permissions: list[str], can_create: bool, can_read: bool, can_write: bool, can_delete: bool, ): api = user_obj.api user1 = user_obj.model_copy() user1.id = "test_id_1" user1.all_permissions = [ Permission.from_unifi_dict(rawPermission=p, api=api) for p in permissions ] user2 = user_obj.model_copy() user2.id = "test_id_2" api.bootstrap.auth_user_id = user1.id api.bootstrap.users = {user1.id: user1, user2.id: user2} assert user2.can_create(user1) is can_create assert user2.can_read(user1) is can_read assert user2.can_write(user1) is can_write assert user2.can_delete(user1) is can_delete @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_revert(user_obj: User, camera_obj: Camera): if camera_obj is None: pytest.skip("No camera_obj obj found") api = user_obj.api camera_obj.id = "test_id_1" camera_obj.add_privacy_zone() camera_obj.recording_settings.mode = RecordingMode.NEVER api.bootstrap.cameras[camera_obj.id] = camera_obj user_obj.all_permissions = [ Permission.from_unifi_dict(rawPermission="camera:read:*", api=api), ] camera_before = camera_obj.model_dump() camera_obj.remove_privacy_zone() camera_obj.recording_settings.mode = RecordingMode.ALWAYS with pytest.raises(NotAuthorized): await camera_obj.save_device(camera_before) assert camera_before == camera_obj.model_dump() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_multiple_updates(user_obj: User, camera_obj: Camera): if camera_obj is None: pytest.skip("No camera_obj obj found") api = user_obj.api camera_obj.id = "test_id_1" camera_obj.recording_settings.enable_motion_detection = False camera_obj.recording_settings.mode = RecordingMode.NEVER camera_obj.smart_detect_settings.object_types = [] camera_obj.feature_flags.has_smart_detect = True camera_obj.use_global = False api.bootstrap.cameras[camera_obj.id] = camera_obj await asyncio.gather( camera_obj.set_motion_detection(True), camera_obj.set_recording_mode(RecordingMode.ALWAYS), camera_obj.set_smart_detect_types( [ SmartDetectObjectType.FACE, SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, ] ), ) camera_obj.api.api_request.assert_called_with( # type: ignore[attr-defined] f"cameras/{camera_obj.id}", method="patch", json={ "recordingSettings": { "enableMotionDetection": True, "mode": RecordingMode.ALWAYS.value, }, "smartDetectSettings": {"objectTypes": ["face", "person", "vehicle"]}, }, ) @pytest.mark.asyncio() async def test_user_becomes_cloud_account_and_then_removed(user_obj: User): assert not user_obj.cloud_account model = user_obj._get_protect_model() assert "cloud_account" in model.objs user_obj.update_from_dict( { "id": "test_id_1", "name": "Test", "cloud_account": { "first_name": "Qpvfly", "last_name": "Ikjzilt", "email": "QhoFvCv@example.com", "profile_img": None, "user_id": "fe4c12ae2c1348edb7854e2f", "id": "9efc4511-4539-4402-9581-51cee8b65cf5", "cloud_id": "9efc4511-4539-4402-9581-51cee8b65cf5", "name": "Qpvfly Ikjzilt", "model_key": "cloudIdentity", }, } ) assert user_obj.name == "Test" assert user_obj.cloud_account assert isinstance(user_obj.cloud_account, CloudAccount) assert user_obj.cloud_account.first_name == "Qpvfly" user_obj.update_from_dict( { "id": "test_id_1", "name": "Test", "cloud_account": None, } ) assert user_obj.name == "Test" assert user_obj.cloud_account is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_smart_detect_settings_becomes_none(camera_obj: Camera): camera_obj.smart_detect_settings.update_from_dict( { "audio_types": None, } ) assert camera_obj.smart_detect_settings.audio_types is None camera_obj.smart_detect_settings.update_from_dict( { "audio_types": ["alrmSmoke"], } ) assert camera_obj.smart_detect_settings.audio_types == [SmartDetectAudioType.SMOKE] camera_obj.smart_detect_settings.update_from_dict( { "audio_types": None, } ) assert camera_obj.smart_detect_settings.audio_types is None @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_unknown_smart( camera: dict[str, Any] | None, bootstrap: dict[str, Any], protect_client: ProtectApiClient, ): if camera is None: pytest.skip("No camera obj found") camera["featureFlags"]["smartDetectTypes"] = ["alrmSmoke3"] camera["smartDetectZones"][0]["objectTypes"] = ["alrmSmoke3"] camera["smartDetectSettings"]["objectTypes"] = ["alrmSmoke3"] bootstrap["cameras"] = [camera] obj: Bootstrap = Bootstrap.from_unifi_dict( **deepcopy(bootstrap), api=protect_client, ) camera_obj = next(iter(obj.cameras.values())) assert camera_obj.feature_flags.smart_detect_types == [] assert camera_obj.smart_detect_zones[0].object_types == [] assert camera_obj.smart_detect_settings.object_types == [] set_no_debug() obj: Bootstrap = Bootstrap.from_unifi_dict( **deepcopy(bootstrap), api=protect_client, ) camera_obj = next(iter(obj.cameras.values())) assert camera_obj.feature_flags.smart_detect_types == [] assert camera_obj.smart_detect_zones[0].object_types == [] assert camera_obj.smart_detect_settings.object_types == [] set_debug() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_unknown_video( camera: dict[str, Any] | None, bootstrap: dict[str, Any], protect_client: ProtectApiClient, ): if camera is None: pytest.skip("No camera obj found") camera["featureFlags"]["videoModes"] = ["stuff"] bootstrap["cameras"] = [camera] obj: Bootstrap = Bootstrap.from_unifi_dict( **deepcopy(bootstrap), api=protect_client, ) camera_obj = next(iter(obj.cameras.values())) assert camera_obj.feature_flags.video_modes == [] set_no_debug() obj: Bootstrap = Bootstrap.from_unifi_dict( **deepcopy(bootstrap), api=protect_client, ) camera_obj = next(iter(obj.cameras.values())) assert camera_obj.feature_flags.video_modes == [] set_debug() def test_unknown_storage_type( bootstrap: dict[str, Any], protect_client: ProtectApiClient, ): bootstrap["nvr"]["systemInfo"]["storage"]["type"] = "test" obj: Bootstrap = Bootstrap.from_unifi_dict( **deepcopy(bootstrap), api=protect_client, ) assert obj.nvr.system_info.storage.type == StorageType.UNKNOWN set_no_debug() obj: Bootstrap = Bootstrap.from_unifi_dict( **deepcopy(bootstrap), api=protect_client, ) assert obj.nvr.system_info.storage.type == StorageType.UNKNOWN set_debug() def test_protect7_duplicate_snake_and_camel_case_keys(): """ Protect 7.x firmware sends both camelCase and snake_case variants of the same key in event metadata (e.g. ``hallwayMode`` *and* ``hallway_mode``). ``unifi_dict_to_dict`` iterates over a snapshot of the keys and pops each one after converting it to snake_case. When two keys map to the same snake_case result the second pop would raise a ``KeyError``. Regression test for https://github.com/home-assistant/core/issues/164109 """ # Real payload fragment from a Protect 7.x controller websocket message. # Both "hallwayMode" (camelCase) and "hallway_mode" (already snake_case) # are present in the same dict. data: dict[str, Any] = { "sensorId": "abc123", "hallwayMode": None, "hallway_mode": "disabled", "type": "motion", } # Must not raise KeyError result = EventMetadata.unifi_dict_to_dict(data) assert result["sensor_id"] == "abc123" assert result["type"] == "motion" def test_event_metadata_collapse_keys_text_format(): """Collapse keys with standard {"text": "value"} format are correctly unwrapped.""" data: dict[str, Any] = { "sensorType": {"text": "temperature"}, "mountType": {"text": "door"}, "status": {"text": "safe"}, } result = EventMetadata.unifi_dict_to_dict(data) assert result["sensor_type"] == "temperature" assert result["mount_type"] == "door" assert result["status"] == "safe" def test_event_metadata_collapse_keys_unexpected_format(): """Collapse keys with unexpected dict format (no 'text' key) are dropped gracefully.""" data: dict[str, Any] = { "sensorType": {"newFormat": "temperature", "extra": 123}, "sensorId": "abc123", } result = EventMetadata.unifi_dict_to_dict(data) # Malformed key should be dropped assert "sensor_type" not in result # Other keys should still be processed assert result["sensor_id"] == "abc123" def test_event_metadata_collapse_keys_non_dict_passthrough(): """Collapse keys that are plain strings (not dicts) pass through unchanged.""" data: dict[str, Any] = { "sensorType": "temperature", "type": "motion", } result = EventMetadata.unifi_dict_to_dict(data) assert result["sensor_type"] == "temperature" assert result["type"] == "motion" def test_handle_ws_error_event_model( protect_client: ProtectApiClient, ): """_handle_ws_error logs event errors without triggering a device refresh.""" bootstrap = protect_client.bootstrap action = {"action": "update", "id": "event123", "modelKey": "event"} err = ValueError("bad event data") # Should not raise, should not create refresh tasks bootstrap._handle_ws_error("update", ModelType.EVENT, action, err) assert len(bootstrap._refresh_tasks) == 0 @pytest.mark.asyncio() async def test_handle_ws_error_device_with_id( protect_client: ProtectApiClient, ): """_handle_ws_error triggers a device refresh when device_id is present.""" bootstrap = protect_client.bootstrap action = {"action": "update", "id": "some_device_id", "modelKey": "camera"} err = ValueError("bad device data") bootstrap._handle_ws_error("update", ModelType.CAMERA, action, err) # A refresh task should have been created. The done-callback discards the # task from the set once it completes, so check for at least one pending task # rather than an exact count to avoid flakiness on fast event loops. assert len(bootstrap._refresh_tasks) >= 1 assert any(not t.done() for t in bootstrap._refresh_tasks) def test_handle_ws_error_no_device_id( protect_client: ProtectApiClient, ): """_handle_ws_error handles missing device ID gracefully without crashing.""" bootstrap = protect_client.bootstrap action = {"action": "update", "modelKey": "camera"} # no "id" key err = KeyError("id") # Must not raise — this was a bug before the fix bootstrap._handle_ws_error("update", ModelType.CAMERA, action, err) assert len(bootstrap._refresh_tasks) == 0 uilibs-uiprotect-317a52c/tests/data/test_convert.py000066400000000000000000000100671524637705700225340ustar00rootroot00000000000000"""Tests for uiprotect.data.convert - 100% coverage.""" from __future__ import annotations from copy import deepcopy from typing import TYPE_CHECKING, Any import pytest from tests.conftest import TEST_CAMERA_EXISTS from uiprotect.data.convert import ( create_from_unifi_dict, get_klass_from_dict, list_from_unifi_list, ) from uiprotect.data.devices import Camera from uiprotect.data.types import ModelType from uiprotect.exceptions import DataDecodeError if TYPE_CHECKING: from uiprotect import ProtectApiClient _skip_no_camera = pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") class TestGetKlassFromDict: """Tests for get_klass_from_dict.""" def test_no_model_key_raises(self) -> None: with pytest.raises(DataDecodeError, match="No modelKey"): get_klass_from_dict({}) def test_unknown_model_key_raises(self) -> None: with pytest.raises(DataDecodeError, match="Unknown modelKey"): get_klass_from_dict({"modelKey": "totallyFakeModel"}) def test_known_model_key_returns_class(self) -> None: assert get_klass_from_dict({"modelKey": "camera"}) is Camera class TestCreateFromUnifiDict: """Tests for create_from_unifi_dict - covers the Protect 7 modelKey injection.""" def test_no_model_key_no_model_type_raises(self) -> None: """No modelKey in data and no model_type provided -> DataDecodeError.""" with pytest.raises(DataDecodeError, match="No modelKey"): create_from_unifi_dict({"id": "test123"}) @_skip_no_camera def test_model_type_with_missing_model_key(self, camera: dict[str, Any]) -> None: """Protect 7+: modelKey missing from data, model_type provided -> inject modelKey and succeed.""" data = deepcopy(camera) data.pop("modelKey", None) obj = create_from_unifi_dict(data, model_type=ModelType.CAMERA) assert isinstance(obj, Camera) assert data["modelKey"] == "camera" @_skip_no_camera def test_model_type_preserves_existing_model_key( self, camera: dict[str, Any] ) -> None: """Protect 6: modelKey already present -> don't overwrite, resolve klass from model_type.""" data = deepcopy(camera) obj = create_from_unifi_dict(data, model_type=ModelType.CAMERA) assert isinstance(obj, Camera) assert data["modelKey"] == "camera" @_skip_no_camera def test_explicit_klass_skips_resolution(self, camera: dict[str, Any]) -> None: """When klass is explicitly provided, model_type klass resolution is skipped.""" data = deepcopy(camera) obj = create_from_unifi_dict(data, klass=Camera) assert isinstance(obj, Camera) @_skip_no_camera def test_model_type_with_explicit_klass(self, camera: dict[str, Any]) -> None: """model_type + klass both provided -> injects modelKey and succeeds.""" data = deepcopy(camera) data.pop("modelKey", None) obj = create_from_unifi_dict(data, klass=Camera, model_type=ModelType.CAMERA) assert isinstance(obj, Camera) assert data["modelKey"] == "camera" def test_unsupported_model_type_raises(self) -> None: """model_type not in MODEL_TO_CLASS -> DataDecodeError with 'Unknown modelKey'.""" with pytest.raises(DataDecodeError, match="Unknown modelKey"): create_from_unifi_dict({"id": "test123"}, model_type=ModelType.SCHEDULE) @_skip_no_camera def test_fallback_to_get_klass_from_dict(self, camera: dict[str, Any]) -> None: """No model_type, no klass -> fall back to get_klass_from_dict.""" obj = create_from_unifi_dict(deepcopy(camera)) assert isinstance(obj, Camera) class TestListFromUnifiList: """Tests for list_from_unifi_list.""" @_skip_no_camera def test_converts_list( self, protect_client: ProtectApiClient, camera: dict[str, Any] ) -> None: result = list_from_unifi_list( protect_client, [deepcopy(camera), deepcopy(camera)] ) assert len(result) == 2 assert all(isinstance(obj, Camera) for obj in result) uilibs-uiprotect-317a52c/tests/data/test_event_detected_thumbnail.py000066400000000000000000000204621524637705700261010ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, arg-type" from __future__ import annotations from datetime import UTC, datetime from typing import TYPE_CHECKING from uiprotect.data.nvr import ( Event, EventDetectedThumbnail, EventMetadata, EventThumbnailGroup, ) from uiprotect.data.types import SmartDetectObjectType if TYPE_CHECKING: from uiprotect import ProtectApiClient def test_get_detected_thumbnail_with_clock_best_wall(): """Test that get_detected_thumbnail returns the thumbnail with clockBestWall.""" event = Event.model_construct( id="test_event", type="smartDetect", smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], metadata=EventMetadata.model_construct( detected_thumbnails=[ EventDetectedThumbnail.model_construct( type="vehicle", cropped_id="thumb_1", clock_best_wall=None, group=EventThumbnailGroup.model_construct( id="group_1", matched_name="ABC123", confidence=75, ), ), EventDetectedThumbnail.model_construct( type="vehicle", cropped_id="thumb_2", clock_best_wall=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), group=EventThumbnailGroup.model_construct( id="group_2", matched_name="XYZ789", confidence=92, ), ), EventDetectedThumbnail.model_construct( type="vehicle", cropped_id="thumb_3", clock_best_wall=None, group=EventThumbnailGroup.model_construct( id="group_3", matched_name="DEF456", confidence=85, ), ), ] ), ) thumbnail = event.get_detected_thumbnail() assert thumbnail is not None assert thumbnail.cropped_id == "thumb_2" assert thumbnail.group is not None assert thumbnail.group.matched_name == "XYZ789" def test_get_detected_thumbnail_no_metadata(): """Test that get_detected_thumbnail returns None when no metadata.""" event = Event.model_construct( id="test_event", type="smartDetect", smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], metadata=None, ) assert event.get_detected_thumbnail() is None def test_get_detected_thumbnail_no_detected_thumbnails(): """Test that get_detected_thumbnail returns None when no detected thumbnails.""" event = Event.model_construct( id="test_event", type="smartDetect", smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], metadata=EventMetadata.model_construct( detected_thumbnails=None, ), ) assert event.get_detected_thumbnail() is None def test_get_detected_thumbnail_empty_list(): """Test that get_detected_thumbnail returns None when detected thumbnails is empty.""" event = Event.model_construct( id="test_event", type="smartDetect", smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], metadata=EventMetadata.model_construct( detected_thumbnails=[], ), ) assert event.get_detected_thumbnail() is None def test_get_detected_thumbnail_no_clock_best_wall(): """Test that get_detected_thumbnail returns None when no thumbnail has clockBestWall.""" event = Event.model_construct( id="test_event", type="smartDetect", smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], metadata=EventMetadata.model_construct( detected_thumbnails=[ EventDetectedThumbnail.model_construct( type="vehicle", cropped_id="thumb_1", clock_best_wall=None, ), EventDetectedThumbnail.model_construct( type="vehicle", cropped_id="thumb_2", clock_best_wall=None, ), ] ), ) assert event.get_detected_thumbnail() is None def test_get_detected_thumbnail_first_with_clock_best_wall(): """Test that get_detected_thumbnail returns first thumbnail with clockBestWall.""" event = Event.model_construct( id="test_event", type="smartDetect", smart_detect_types=[SmartDetectObjectType.FACE], metadata=EventMetadata.model_construct( detected_thumbnails=[ EventDetectedThumbnail.model_construct( type="face", cropped_id="thumb_1", clock_best_wall=None, ), EventDetectedThumbnail.model_construct( type="face", cropped_id="thumb_2", clock_best_wall=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), group=EventThumbnailGroup.model_construct( id="group_1", matched_name="Alice", confidence=90, ), ), EventDetectedThumbnail.model_construct( type="face", cropped_id="thumb_3", clock_best_wall=datetime(2024, 1, 1, 12, 1, 0, tzinfo=UTC), group=EventThumbnailGroup.model_construct( id="group_2", matched_name="Bob", confidence=95, ), ), ] ), ) thumbnail = event.get_detected_thumbnail() assert thumbnail is not None assert thumbnail.cropped_id == "thumb_2" assert thumbnail.group is not None assert thumbnail.group.matched_name == "Alice" def test_get_detected_thumbnail_from_real_data( raw_events: list[dict], protect_client: ProtectApiClient ): """Test get_detected_thumbnail with real event data.""" events_with_thumbs = [ e for e in raw_events if e.get("metadata", {}).get("detectedThumbnails") ] if not events_with_thumbs: return # Skip if no events with thumbnails # Parse first event with thumbnails event = Event.from_unifi_dict(**events_with_thumbs[0], api=protect_client) # Should return the thumbnail with clockBestWall thumbnail = event.get_detected_thumbnail() assert thumbnail is not None assert thumbnail.clock_best_wall is not None def test_event_thumbnail_attributes_get_value( raw_events: list[dict], protect_client: ProtectApiClient ): """Test EventThumbnailAttributes.get_value() helper method.""" events_with_attrs = [ e for e in raw_events if any( t.get("attributes") for t in e.get("metadata", {}).get("detectedThumbnails", []) ) ] if not events_with_attrs: return # Skip if no events with attributes event = Event.from_unifi_dict(**events_with_attrs[0], api=protect_client) thumbnail = event.get_detected_thumbnail() assert thumbnail is not None assert thumbnail.attributes is not None # Test get_value with EventThumbnailAttribute objects # Check if color exists (common for LPR events) if hasattr(thumbnail.attributes, "color"): color = thumbnail.attributes.get_value("color") assert color is not None assert isinstance(color, str) # Check if vehicleType exists (common for LPR events) if hasattr(thumbnail.attributes, "vehicleType"): vehicle_type = thumbnail.attributes.get_value("vehicleType") assert vehicle_type is not None assert isinstance(vehicle_type, str) # Test get_value with non-EventThumbnailAttribute fields # zone is list[int], not EventThumbnailAttribute if hasattr(thumbnail.attributes, "zone"): zone_value = thumbnail.attributes.get_value("zone") assert ( zone_value is None ) # Should be None because it's not EventThumbnailAttribute # Test get_value with non-existent field nonexistent = thumbnail.attributes.get_value("nonexistent_field_12345") assert nonexistent is None uilibs-uiprotect-317a52c/tests/data/test_light.py000066400000000000000000000370741524637705700221720ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr" from __future__ import annotations from datetime import timedelta from typing import TYPE_CHECKING from unittest.mock import AsyncMock import pytest from pydantic import ValidationError from tests.conftest import TEST_CAMERA_EXISTS, TEST_LIGHT_EXISTS from uiprotect.data.public_devices import ( PublicLight, PublicLightDeviceSettings, PublicLightModeSettings, ) from uiprotect.data.types import LightModeEnableType, LightModeType from uiprotect.exceptions import BadRequest from uiprotect.utils import to_ms if TYPE_CHECKING: from uiprotect.data import Camera, Light def _public_light_response( *, is_light_force_enabled: bool = False, light_device_settings: PublicLightDeviceSettings | None = None, light_mode_settings: PublicLightModeSettings | None = None, name: str | None = None, ) -> PublicLight: """Build a minimal ``PublicLight`` for mocking ``update_light_public`` returns.""" return PublicLight.model_construct( is_light_force_enabled=is_light_force_enabled, light_device_settings=light_device_settings, light_mode_settings=light_mode_settings, name=name, ) @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_light_set_paired_camera_none(light_obj: Light): light_obj.api.api_request.reset_mock() light_obj.camera_id = "bad_id" await light_obj.set_paired_camera(None) light_obj.api.api_request.assert_called_with( f"lights/{light_obj.id}", method="patch", json={"camera": None}, ) @pytest.mark.skipif( not TEST_LIGHT_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_light_set_paired_camera(light_obj: Light, camera_obj: Camera): light_obj.api.api_request.reset_mock() light_obj.camera_id = None await light_obj.set_paired_camera(camera_obj) light_obj.api.api_request.assert_called_with( f"lights/{light_obj.id}", method="patch", json={"camera": camera_obj.id}, ) @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_light_set_status_light(light_obj: Light, status: bool) -> None: light_obj.api.api_request.reset_mock() light_obj.light_device_settings.is_indicator_enabled = not status await light_obj.set_status_light(status) light_obj.api.api_request.assert_called_with( f"lights/{light_obj.id}", method="patch", json={"lightDeviceSettings": {"isIndicatorEnabled": status}}, ) @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_light_set_name_public(light_obj: Light) -> None: light_obj.api.update_light_public = AsyncMock( return_value=_public_light_response(name="Renamed"), ) await light_obj.set_name_public("Renamed") light_obj.api.update_light_public.assert_called_once_with( light_obj.id, name="Renamed", ) assert light_obj.name == "Renamed" @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_light_set_flood_light_public(light_obj: Light, status: bool) -> None: light_obj.api.update_light_public = AsyncMock( return_value=_public_light_response(is_light_force_enabled=status), ) await light_obj.set_flood_light_public(status) light_obj.api.update_light_public.assert_called_once_with( light_obj.id, is_light_force_enabled=status, ) assert light_obj.light_on_settings.is_led_force_on is status @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_light_set_status_light_public(light_obj: Light, status: bool) -> None: light_obj.api.update_light_public = AsyncMock( return_value=_public_light_response( light_device_settings=PublicLightDeviceSettings( is_indicator_enabled=status ), ), ) await light_obj.set_status_light_public(status) call = light_obj.api.update_light_public.call_args assert call.args == (light_obj.id,) sent = call.kwargs["light_device_settings"] assert sent.is_indicator_enabled is status assert light_obj.light_device_settings.is_indicator_enabled is status @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [-1, 0, 1, 6, 7]) @pytest.mark.asyncio() async def test_light_set_led_level_public(light_obj: Light, level: int) -> None: light_obj.api.update_light_public = AsyncMock() if level in {-1, 0, 7}: with pytest.raises(BadRequest, match="led_level must be between 1 and 6"): await light_obj.set_led_level_public(level) assert not light_obj.api.update_light_public.called return light_obj.api.update_light_public.return_value = _public_light_response( light_device_settings=PublicLightDeviceSettings( is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled, led_level=level, ), ) await light_obj.set_led_level_public(level) sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"] assert sent.led_level == level assert light_obj.light_device_settings.led_level == level @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_light_set_led_level_public_coerces_float(light_obj: Light) -> None: light_obj.api.update_light_public = AsyncMock() light_obj.api.update_light_public.return_value = _public_light_response( light_device_settings=PublicLightDeviceSettings( is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled, led_level=3, ), ) await light_obj.set_led_level_public(3.0) sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"] assert type(sent.led_level) is int assert sent.led_level == 3 @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [float("nan"), float("inf"), float("-inf")]) @pytest.mark.asyncio() async def test_light_set_led_level_public_rejects_non_finite( light_obj: Light, level: float ) -> None: light_obj.api.update_light_public = AsyncMock() with pytest.raises(BadRequest, match="led_level must be a finite number"): await light_obj.set_led_level_public(level) assert not light_obj.api.update_light_public.called @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_light_set_light_public_no_level(light_obj: Light, status: bool) -> None: light_obj.api.update_light_public = AsyncMock( return_value=_public_light_response(is_light_force_enabled=status), ) await light_obj.set_light_public(status) light_obj.api.update_light_public.assert_called_once_with( light_obj.id, is_light_force_enabled=status, ) assert light_obj.light_on_settings.is_led_force_on is status @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_light_set_light_public_with_level( light_obj: Light, status: bool ) -> None: light_obj.api.update_light_public = AsyncMock( return_value=_public_light_response( is_light_force_enabled=status, light_device_settings=PublicLightDeviceSettings(led_level=5), ), ) await light_obj.set_light_public(status, 5) call = light_obj.api.update_light_public.call_args assert call.args == (light_obj.id,) assert call.kwargs["is_light_force_enabled"] is status assert call.kwargs["light_device_settings"].led_level == 5 assert light_obj.light_on_settings.is_led_force_on is status assert light_obj.light_device_settings.led_level == 5 @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("level", [-1, 0, 7]) @pytest.mark.asyncio() async def test_light_set_light_public_bad_level(light_obj: Light, level: int) -> None: light_obj.api.update_light_public = AsyncMock() with pytest.raises(BadRequest, match="led_level must be between 1 and 6"): await light_obj.set_light_public(True, level) assert not light_obj.api.update_light_public.called @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_light_set_light_public_coerces_float(light_obj: Light) -> None: light_obj.api.update_light_public = AsyncMock( return_value=_public_light_response( is_light_force_enabled=True, light_device_settings=PublicLightDeviceSettings(led_level=3), ), ) await light_obj.set_light_public(True, 3.0) sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"] assert type(sent.led_level) is int assert sent.led_level == 3 @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("sensitivity", [1, 100, -10]) @pytest.mark.asyncio() async def test_light_set_sensitivity_public(light_obj: Light, sensitivity: int) -> None: light_obj.api.update_light_public = AsyncMock() if sensitivity == -10: with pytest.raises(ValidationError): await light_obj.set_sensitivity_public(sensitivity) assert not light_obj.api.update_light_public.called return light_obj.api.update_light_public.return_value = _public_light_response( light_device_settings=PublicLightDeviceSettings( is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled, pir_sensitivity=sensitivity, ), ) await light_obj.set_sensitivity_public(sensitivity) sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"] assert sent.pir_sensitivity == sensitivity assert light_obj.light_device_settings.pir_sensitivity == sensitivity @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( "duration", [ timedelta(seconds=1), timedelta(seconds=15), timedelta(seconds=900), timedelta(seconds=1000), ], ) @pytest.mark.asyncio() async def test_light_set_duration_public(light_obj: Light, duration: timedelta) -> None: light_obj.api.update_light_public = AsyncMock() if int(duration.total_seconds()) in {1, 1000}: with pytest.raises(BadRequest): await light_obj.set_duration_public(duration) assert not light_obj.api.update_light_public.called return light_obj.api.update_light_public.return_value = _public_light_response( light_device_settings=PublicLightDeviceSettings( is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled, pir_duration=to_ms(duration), ), ) await light_obj.set_duration_public(duration) sent = light_obj.api.update_light_public.call_args.kwargs["light_device_settings"] assert sent.pir_duration == duration assert light_obj.light_device_settings.pir_duration == duration @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("mode", [LightModeType.MANUAL, LightModeType.WHEN_DARK]) @pytest.mark.parametrize("enable_at", [None, LightModeEnableType.ALWAYS]) @pytest.mark.asyncio() async def test_light_set_light_mode_public( light_obj: Light, mode: LightModeType, enable_at: LightModeEnableType | None, ) -> None: response_enable_at = ( enable_at if enable_at is not None else light_obj.light_mode_settings.enable_at ) light_obj.api.update_light_public = AsyncMock( return_value=_public_light_response( light_mode_settings=PublicLightModeSettings( mode=mode, enable_at=response_enable_at ), ), ) original_enable_at = light_obj.light_mode_settings.enable_at await light_obj.set_light_mode_public(mode, enable_at=enable_at) sent = light_obj.api.update_light_public.call_args.kwargs["light_mode_settings"] assert sent.mode == mode if enable_at is not None: assert sent.enable_at == enable_at else: assert sent.enable_at == original_enable_at assert light_obj.light_mode_settings.mode == mode @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( "duration", [ None, timedelta(seconds=1), timedelta(seconds=15), timedelta(seconds=60), timedelta(seconds=900), timedelta(seconds=1000), ], ) @pytest.mark.parametrize("sensitivity", [None, 1, 100, -10]) @pytest.mark.parametrize("enable_at", [None, LightModeEnableType.ALWAYS]) @pytest.mark.asyncio() async def test_light_set_light_settings_public( light_obj: Light, duration: timedelta | None, sensitivity: int | None, enable_at: LightModeEnableType | None, ) -> None: light_obj.api.update_light_public = AsyncMock() duration_invalid = duration is not None and int(duration.total_seconds()) in { 1, 1000, } if duration_invalid: with pytest.raises(BadRequest): await light_obj.set_light_settings_public( LightModeType.MOTION, enable_at=enable_at, duration=duration, sensitivity=sensitivity, ) assert not light_obj.api.update_light_public.called return if sensitivity == -10: with pytest.raises(ValidationError): await light_obj.set_light_settings_public( LightModeType.MOTION, enable_at=enable_at, duration=duration, sensitivity=sensitivity, ) assert not light_obj.api.update_light_public.called return response_enable_at = ( enable_at if enable_at is not None else light_obj.light_mode_settings.enable_at ) updated_mode = PublicLightModeSettings( mode=LightModeType.MOTION, enable_at=response_enable_at ) has_device_update = duration is not None or sensitivity is not None updated_device = ( PublicLightDeviceSettings( is_indicator_enabled=light_obj.light_device_settings.is_indicator_enabled, pir_duration=to_ms(duration) if duration is not None else None, pir_sensitivity=sensitivity, ) if has_device_update else None ) light_obj.api.update_light_public.return_value = _public_light_response( light_mode_settings=updated_mode, light_device_settings=updated_device, ) await light_obj.set_light_settings_public( LightModeType.MOTION, enable_at=enable_at, duration=duration, sensitivity=sensitivity, ) kwargs = light_obj.api.update_light_public.call_args.kwargs assert kwargs["light_mode_settings"].mode == LightModeType.MOTION if enable_at is not None: assert kwargs["light_mode_settings"].enable_at == enable_at if has_device_update: assert kwargs["light_device_settings"] is not None if duration is not None: assert kwargs["light_device_settings"].pir_duration == duration if sensitivity is not None: assert kwargs["light_device_settings"].pir_sensitivity == sensitivity else: assert kwargs["light_device_settings"] is None uilibs-uiprotect-317a52c/tests/data/test_nvr.py000066400000000000000000000246011524637705700216600ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, arg-type, list-item" from __future__ import annotations from datetime import timedelta from ipaddress import IPv4Address, IPv6Address from unittest.mock import Mock import pytest from pydantic import ValidationError from uiprotect.data import ( NVR, AnalyticsOption, DoorbellMessage, DoorbellMessageType, Event, EventType, RecordingMode, ) from uiprotect.data.nvr import NVRSmartDetection, StorageDevice from uiprotect.data.types import SmartDetectObjectType from uiprotect.exceptions import BadRequest from uiprotect.utils import to_ms @pytest.mark.parametrize( ("data", "expected"), [ ({}, {"model": None, "size": None, "healthy": None}), # empty slot ( {"model": "ST4000VN008", "size": 4000787030016, "healthy": True}, {"model": "ST4000VN008", "size": 4000787030016, "healthy": True}, ), ({"healthy": "OK"}, {"healthy": "OK"}), # string healthy value ({"model": "TestModel"}, {"model": "TestModel", "size": None}), # partial ], ) def test_storage_device(data: dict, expected: dict): """Test StorageDevice handles empty slots and various field types (Protect 6.x+).""" device = StorageDevice.from_unifi_dict(**data) for key, value in expected.items(): assert getattr(device, key) == value def test_event_unknown_smart_detect_type_dropped() -> None: """An unknown ``smartDetectTypes`` entry is dropped rather than aborting parsing.""" event = Event.from_unifi_dict( api=Mock(), id="evt-1", modelKey="event", type=EventType.SMART_DETECT.value, start=1735689600000, score=0, smartDetectTypes=["person", "linecrossing_basic"], ) assert event.smart_detect_types == [SmartDetectObjectType.PERSON] @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_nvr_set_insights(nvr_obj: NVR, status: bool): nvr_obj.api.api_request.reset_mock() nvr_obj.is_insights_enabled = not status await nvr_obj.set_insights(status) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"isInsightsEnabled": status}, ) @pytest.mark.asyncio() async def test_nvr_set_anonymous_analytics(nvr_obj: NVR): nvr_obj.api.api_request.reset_mock() nvr_obj.analytics_data = AnalyticsOption.ANONYMOUS await nvr_obj.set_anonymous_analytics(False) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"analyticsData": "none"}, ) @pytest.mark.asyncio() async def test_nvr_set_default_reset_timeout(nvr_obj: NVR): nvr_obj.api.api_request.reset_mock() duration = timedelta(seconds=10) await nvr_obj.set_default_reset_timeout(duration) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"doorbellSettings": {"defaultMessageResetTimeoutMs": to_ms(duration)}}, ) @pytest.mark.parametrize("message", ["Test", "fqthpqBgVMKXp9jXX2VeuGeXYfx2mMjB"]) @pytest.mark.asyncio() async def test_nvr_set_default_doorbell_message(nvr_obj: NVR, message: str): nvr_obj.api.api_request.reset_mock() if len(message) > 30: with pytest.raises(ValidationError): await nvr_obj.set_default_doorbell_message(message) assert not nvr_obj.api.api_request.called else: await nvr_obj.set_default_doorbell_message(message) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"doorbellSettings": {"defaultMessageText": message}}, ) @pytest.mark.parametrize( "message", ["Welcome", "Test", "fqthpqBgVMKXp9jXX2VeuGeXYfx2mMjB"], ) @pytest.mark.asyncio() async def test_nvr_add_custom_doorbell_message(nvr_obj: NVR, message: str): nvr_obj.api.api_request.reset_mock() nvr_obj.doorbell_settings.custom_messages = ["Welcome"] if message != "Test": with pytest.raises(BadRequest): await nvr_obj.add_custom_doorbell_message(message) assert not nvr_obj.api.api_request.called else: await nvr_obj.add_custom_doorbell_message(message) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"doorbellSettings": {"customMessages": ["Welcome", "Test"]}}, ) assert nvr_obj.doorbell_settings.all_messages == [ DoorbellMessage( type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR, text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "), ), DoorbellMessage( type=DoorbellMessageType.DO_NOT_DISTURB, text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), ), DoorbellMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text="Welcome", ), DoorbellMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text="Test", ), ] @pytest.mark.parametrize("message", ["Welcome", "Test"]) @pytest.mark.asyncio() async def test_nvr_remove_custom_doorbell_message(nvr_obj: NVR, message: str): nvr_obj.api.api_request.reset_mock() nvr_obj.doorbell_settings.custom_messages = ["Welcome"] if message == "Test": with pytest.raises(BadRequest): await nvr_obj.remove_custom_doorbell_message(message) assert not nvr_obj.api.api_request.called else: await nvr_obj.remove_custom_doorbell_message(message) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"doorbellSettings": {"customMessages": []}}, ) assert nvr_obj.doorbell_settings.all_messages == [ DoorbellMessage( type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR, text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "), ), DoorbellMessage( type=DoorbellMessageType.DO_NOT_DISTURB, text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), ), ] @pytest.mark.parametrize( ("ip", "expected"), [ ("192.168.1.1", IPv4Address("192.168.1.1")), ("fe80::1ff:fe23:4567:890a", IPv6Address("fe80::1ff:fe23:4567:890a")), ], ) @pytest.mark.asyncio() async def test_nvr_wan_ip(nvr_obj: NVR, ip: str, expected: IPv4Address | IPv6Address): nvr_dict = nvr_obj.unifi_dict() nvr_dict["wanIp"] = ip nvr = NVR.from_unifi_dict(**nvr_dict) assert nvr.wan_ip == expected assert nvr.unifi_dict()["wanIp"] == ip @pytest.mark.asyncio() async def test_nvr_set_smart_detections(nvr_obj: NVR): nvr_obj.smart_detection = NVRSmartDetection( enable=False, face_recognition=False, license_plate_recognition=False, ) nvr_obj.api.api_request.reset_mock() await nvr_obj.set_smart_detections(True) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"smartDetection": {"enable": True}}, ) @pytest.mark.asyncio() async def test_nvr_set_face_recognition(nvr_obj: NVR): nvr_obj.smart_detection = NVRSmartDetection( enable=True, face_recognition=False, license_plate_recognition=False, ) nvr_obj.api.api_request.reset_mock() await nvr_obj.set_face_recognition(True) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"smartDetection": {"faceRecognition": True}}, ) @pytest.mark.asyncio() async def test_nvr_set_face_recognition_no_smart(nvr_obj: NVR): nvr_obj.smart_detection = NVRSmartDetection( enable=False, face_recognition=False, license_plate_recognition=False, ) nvr_obj.api.api_request.reset_mock() with pytest.raises(BadRequest): await nvr_obj.set_face_recognition(True) assert not nvr_obj.api.api_request.called @pytest.mark.asyncio() async def test_nvr_set_license_plate_recognition(nvr_obj: NVR): nvr_obj.smart_detection = NVRSmartDetection( enable=True, face_recognition=False, license_plate_recognition=False, ) nvr_obj.api.api_request.reset_mock() await nvr_obj.set_license_plate_recognition(True) nvr_obj.api.api_request.assert_called_with( "nvr", method="patch", json={"smartDetection": {"licensePlateRecognition": True}}, ) @pytest.mark.asyncio() async def test_nvr_set_license_plate_recognition_no_smart(nvr_obj: NVR): nvr_obj.smart_detection = NVRSmartDetection( enable=False, face_recognition=False, license_plate_recognition=False, ) nvr_obj.api.api_request.reset_mock() with pytest.raises(BadRequest): await nvr_obj.set_license_plate_recognition(True) assert not nvr_obj.api.api_request.called @pytest.mark.usefixtures("_disable_nvr_validation") def test_nvr_is_global_face_detection_on(nvr_obj: NVR) -> None: # Create mock global camera settings if they don't exist if nvr_obj.global_camera_settings is None: nvr_obj.global_camera_settings = Mock() nvr_obj.global_camera_settings.recording_settings = Mock() nvr_obj.global_camera_settings.smart_detect_settings = Mock() # Test when global face detection is enabled nvr_obj.global_camera_settings.recording_settings.mode = RecordingMode.ALWAYS nvr_obj.global_camera_settings.smart_detect_settings.object_types = [ SmartDetectObjectType.FACE ] assert nvr_obj.is_global_face_detection_on is True # Test when global face detection is disabled nvr_obj.global_camera_settings.smart_detect_settings.object_types = [] assert nvr_obj.is_global_face_detection_on is False # Test when global recording is disabled nvr_obj.global_camera_settings.recording_settings.mode = RecordingMode.NEVER nvr_obj.global_camera_settings.smart_detect_settings.object_types = [ SmartDetectObjectType.FACE ] assert nvr_obj.is_global_face_detection_on is False # Test with mixed object types nvr_obj.global_camera_settings.recording_settings.mode = RecordingMode.ALWAYS nvr_obj.global_camera_settings.smart_detect_settings.object_types = [ SmartDetectObjectType.PERSON, SmartDetectObjectType.FACE, SmartDetectObjectType.VEHICLE, ] assert nvr_obj.is_global_face_detection_on is True uilibs-uiprotect-317a52c/tests/data/test_sensor.py000066400000000000000000000521211524637705700223620ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr" from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import AsyncMock, Mock import pytest from tests.conftest import TEST_CAMERA_EXISTS, TEST_SENSOR_EXISTS from uiprotect.data.types import MountType, SensorScheduleMode from uiprotect.exceptions import BadRequest if TYPE_CHECKING: from uiprotect.data import Camera, Light, Sensor @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_status_light(sensor_obj: Sensor, status: bool): sensor_obj.api.api_request.reset_mock() sensor_obj.led_settings.is_enabled = not status await sensor_obj.set_status_light(status) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"ledSettings": {"isEnabled": status}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("mount_type", [MountType.DOOR, MountType.NONE]) @pytest.mark.asyncio() async def test_sensor_set_mount_type(sensor_obj: Sensor, mount_type: MountType): sensor_obj.api.api_request.reset_mock() sensor_obj.mount_type = MountType.LEAK await sensor_obj.set_mount_type(mount_type) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"mountType": mount_type.value}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_motion_status(sensor_obj: Sensor, status: bool): sensor_obj.api.api_request.reset_mock() sensor_obj.motion_settings.is_enabled = not status await sensor_obj.set_motion_status(status) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"motionSettings": {"isEnabled": status}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_temperature_status(sensor_obj: Sensor, status: bool): sensor_obj.api.api_request.reset_mock() sensor_obj.temperature_settings.is_enabled = not status await sensor_obj.set_temperature_status(status) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"temperatureSettings": {"isEnabled": status}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_humidity_status(sensor_obj: Sensor, status: bool): sensor_obj.api.api_request.reset_mock() sensor_obj.humidity_settings.is_enabled = not status await sensor_obj.set_humidity_status(status) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"humiditySettings": {"isEnabled": status}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_light_status(sensor_obj: Sensor, status: bool): sensor_obj.api.api_request.reset_mock() sensor_obj.light_settings.is_enabled = not status await sensor_obj.set_light_status(status) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"lightSettings": {"isEnabled": status}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("status", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_alarm_status(sensor_obj: Sensor, status: bool): sensor_obj.api.api_request.reset_mock() sensor_obj.alarm_settings.is_enabled = not status await sensor_obj.set_alarm_status(status) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"alarmSettings": {"isEnabled": status}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("low", [-1.0, 0.0, 25.0]) @pytest.mark.parametrize("high", [20.0, 45.0, 50.0]) @pytest.mark.asyncio() async def test_sensor_set_temperature_safe_range( sensor_obj: Sensor, low: float, high: float, ): sensor_obj.api.api_request.reset_mock() sensor_obj.temperature_settings.low_threshold = None sensor_obj.temperature_settings.high_threshold = None if low == -1.0 or high == 50.0 or low > high: with pytest.raises(BadRequest): await sensor_obj.set_temperature_safe_range(low, high) assert not sensor_obj.api.api_request.called else: await sensor_obj.set_temperature_safe_range(low, high) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"temperatureSettings": {"lowThreshold": low, "highThreshold": high}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("low", [0.0, 1.0, 50.0]) @pytest.mark.parametrize("high", [45.0, 99.0, 100.0]) @pytest.mark.asyncio() async def test_sensor_set_humidity_safe_range( sensor_obj: Sensor, low: float, high: float, ): sensor_obj.api.api_request.reset_mock() sensor_obj.humidity_settings.low_threshold = None sensor_obj.humidity_settings.high_threshold = None if low == 0.0 or high == 100.0 or low > high: with pytest.raises(BadRequest): await sensor_obj.set_humidity_safe_range(low, high) assert not sensor_obj.api.api_request.called else: await sensor_obj.set_humidity_safe_range(low, high) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"humiditySettings": {"lowThreshold": low, "highThreshold": high}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("low", [0.0, 1.0, 500.0]) @pytest.mark.parametrize("high", [400.0, 1000.0, 1001.0]) @pytest.mark.asyncio() async def test_sensor_set_light_safe_range(sensor_obj: Sensor, low: float, high: float): sensor_obj.api.api_request.reset_mock() sensor_obj.light_settings.low_threshold = None sensor_obj.light_settings.high_threshold = None if low == 0.0 or high == 1001.0 or low > high: with pytest.raises(BadRequest): await sensor_obj.set_light_safe_range(low, high) assert not sensor_obj.api.api_request.called else: await sensor_obj.set_light_safe_range(low, high) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"lightSettings": {"lowThreshold": low, "highThreshold": high}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_remove_temperature_safe_range(sensor_obj: Sensor): sensor_obj.api.api_request.reset_mock() sensor_obj.temperature_settings.low_threshold = 10 sensor_obj.temperature_settings.high_threshold = 20 await sensor_obj.remove_temperature_safe_range() sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"temperatureSettings": {"lowThreshold": None, "highThreshold": None}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_remove_humidity_safe_range(sensor_obj: Sensor): sensor_obj.api.api_request.reset_mock() sensor_obj.humidity_settings.low_threshold = 10 sensor_obj.humidity_settings.high_threshold = 20 await sensor_obj.remove_humidity_safe_range() sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"humiditySettings": {"lowThreshold": None, "highThreshold": None}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_remove_light_safe_range(sensor_obj: Sensor): sensor_obj.api.api_request.reset_mock() sensor_obj.light_settings.low_threshold = 10 sensor_obj.light_settings.high_threshold = 20 await sensor_obj.remove_light_safe_range() sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"lightSettings": {"lowThreshold": None, "highThreshold": None}}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_paired_camera_none(sensor_obj: Sensor): sensor_obj.api.api_request.reset_mock() sensor_obj.camera_id = "bad_id" await sensor_obj.set_paired_camera(None) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"camera": None}, ) @pytest.mark.skipif( not TEST_SENSOR_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_sensor_set_paired_camera(sensor_obj: Light, camera_obj: Camera): sensor_obj.api.api_request.reset_mock() sensor_obj.camera_id = None await sensor_obj.set_paired_camera(camera_obj) sensor_obj.api.api_request.assert_called_with( f"sensors/{sensor_obj.id}", method="patch", json={"camera": camera_obj.id}, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_name_public(sensor_obj: Sensor): updated = Mock() updated.name = "Renamed" sensor_obj.api.update_sensor_public = AsyncMock(return_value=updated) await sensor_obj.set_name_public("Renamed") sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, name="Renamed" ) assert sensor_obj.name == "Renamed" @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( "attr", ["temperature", "humidity", "light"], ) @pytest.mark.asyncio() async def test_sensor_set_threshold_settings_public(sensor_obj: Sensor, attr: str): sensor_obj.api.update_sensor_public = AsyncMock() setter = getattr(sensor_obj, f"set_{attr}_settings_public") await setter(is_enabled=True, low_threshold=5, high_threshold=20, margin=2) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, **{ f"{attr}_settings": { "isEnabled": True, "lowThreshold": 5, "highThreshold": 20, "margin": 2, } }, ) local = getattr(sensor_obj, f"{attr}_settings") assert local.is_enabled is True assert local.low_threshold == 5 assert local.high_threshold == 20 assert local.margin == 2 @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( "attr", ["temperature", "humidity", "light"], ) @pytest.mark.asyncio() async def test_sensor_set_threshold_settings_public_requires_arg( sensor_obj: Sensor, attr: str ): sensor_obj.api.update_sensor_public = AsyncMock() setter = getattr(sensor_obj, f"set_{attr}_settings_public") with pytest.raises(BadRequest): await setter() assert not sensor_obj.api.update_sensor_public.called @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_motion_settings_public(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_motion_settings_public( is_enabled=True, sensitivity=70, sensitivity_when_armed=90 ) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, motion_settings={ "isEnabled": True, "sensitivity": 70, "sensitivityWhenArmed": 90, }, ) assert sensor_obj.motion_settings.is_enabled is True assert sensor_obj.motion_settings.sensitivity == 70 assert sensor_obj.motion_settings.sensitivity_when_armed == 90 @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_motion_settings_public_requires_arg(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() with pytest.raises(BadRequest): await sensor_obj.set_motion_settings_public() assert not sensor_obj.api.update_sensor_public.called @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_glass_break_settings_public(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_glass_break_settings_public( is_enabled=True, sensitivity=55, sensitivity_when_armed=65 ) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, glass_break_settings={ "isEnabled": True, "sensitivity": 55, "sensitivityWhenArmed": 65, }, ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_glass_break_settings_public_requires_arg(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() with pytest.raises(BadRequest): await sensor_obj.set_glass_break_settings_public() assert not sensor_obj.api.update_sensor_public.called @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("field", ["sensitivity", "sensitivity_when_armed"]) @pytest.mark.parametrize("value", [-1, 101, 150]) @pytest.mark.asyncio() async def test_sensor_set_motion_settings_public_rejects_out_of_range( sensor_obj: Sensor, field: str, value: int ): sensor_obj.api.update_sensor_public = AsyncMock() with pytest.raises(BadRequest): await sensor_obj.set_motion_settings_public(**{field: value}) assert not sensor_obj.api.update_sensor_public.called @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_motion_settings_public_coerces_float(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_motion_settings_public( sensitivity=70.0, sensitivity_when_armed=90.0 ) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, motion_settings={"sensitivity": 70, "sensitivityWhenArmed": 90}, ) sent = sensor_obj.api.update_sensor_public.call_args.kwargs["motion_settings"] assert isinstance(sent["sensitivity"], int) assert isinstance(sent["sensitivityWhenArmed"], int) assert sensor_obj.motion_settings.sensitivity == 70 @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("value", [-1, 101]) @pytest.mark.asyncio() async def test_sensor_set_glass_break_settings_public_rejects_out_of_range( sensor_obj: Sensor, value: int ): sensor_obj.api.update_sensor_public = AsyncMock() with pytest.raises(BadRequest): await sensor_obj.set_glass_break_settings_public(sensitivity=value) assert not sensor_obj.api.update_sensor_public.called @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize( ("attr", "low_bad", "low_ok"), [ ("temperature", -40, -39), ("temperature", 125, 124), ("humidity", 0, 1), ("humidity", 100, 99), ("light", 0, 1), ("light", 503193, 503192), ], ) @pytest.mark.asyncio() async def test_sensor_set_threshold_settings_public_rejects_out_of_range( sensor_obj: Sensor, attr: str, low_bad: float, low_ok: float, ): sensor_obj.api.update_sensor_public = AsyncMock() setter = getattr(sensor_obj, f"set_{attr}_settings_public") with pytest.raises(BadRequest): await setter(low_threshold=low_bad) assert not sensor_obj.api.update_sensor_public.called # A boundary low value passes; highThreshold is spec-unbounded, so even a # value below the low minimum is accepted for it. await setter(low_threshold=low_ok, high_threshold=low_bad) assert sensor_obj.api.update_sensor_public.called @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("attr", ["humidity", "light"]) @pytest.mark.asyncio() async def test_sensor_set_threshold_settings_public_passes_float_through( sensor_obj: Sensor, attr: str ): sensor_obj.api.update_sensor_public = AsyncMock() setter = getattr(sensor_obj, f"set_{attr}_settings_public") await setter(low_threshold=30.7, high_threshold=80.9) sent = sensor_obj.api.update_sensor_public.call_args.kwargs[f"{attr}_settings"] assert sent == {"lowThreshold": 30.7, "highThreshold": 80.9} # Spec types these as ``number`` — the caller's float is sent verbatim, not # silently truncated to int. local = getattr(sensor_obj, f"{attr}_settings") assert local.low_threshold == 30.7 assert local.high_threshold == 80.9 @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_alarm_public(sensor_obj: Sensor, enabled: bool): sensor_obj.api.update_sensor_public = AsyncMock() sensor_obj.alarm_settings.is_enabled = not enabled await sensor_obj.set_alarm_public(enabled) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, alarm_settings={"isEnabled": enabled} ) assert sensor_obj.alarm_settings.is_enabled is enabled @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_schedule_mode_public(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_schedule_mode_public(SensorScheduleMode.WHEN_ARMED) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, schedule_mode=SensorScheduleMode.WHEN_ARMED ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_arm_profile_ids_public(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_arm_profile_ids_public(["a", "b"]) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, arm_profile_ids=["a", "b"] ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_custom_sensitivity_when_armed_public( sensor_obj: Sensor, enabled: bool ): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_custom_sensitivity_when_armed_public(enabled) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, has_custom_sensitivity_when_armed=enabled ) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_motion_status_public(sensor_obj: Sensor, enabled: bool): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_motion_status_public(enabled) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, motion_settings={"isEnabled": enabled} ) assert sensor_obj.motion_settings.is_enabled is enabled @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_sensor_set_motion_sensitivity_public(sensor_obj: Sensor): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_motion_sensitivity_public(42) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, motion_settings={"sensitivity": 42} ) assert sensor_obj.motion_settings.sensitivity == 42 @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("attr", ["temperature", "humidity", "light"]) @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_threshold_status_public( sensor_obj: Sensor, attr: str, enabled: bool ): sensor_obj.api.update_sensor_public = AsyncMock() await getattr(sensor_obj, f"set_{attr}_status_public")(enabled) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, **{f"{attr}_settings": {"isEnabled": enabled}} ) assert getattr(sensor_obj, f"{attr}_settings").is_enabled is enabled @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.asyncio() async def test_sensor_set_glass_break_status_public(sensor_obj: Sensor, enabled: bool): sensor_obj.api.update_sensor_public = AsyncMock() await sensor_obj.set_glass_break_status_public(enabled) sensor_obj.api.update_sensor_public.assert_called_once_with( sensor_obj.id, glass_break_settings={"isEnabled": enabled} ) uilibs-uiprotect-317a52c/tests/data/test_types.py000066400000000000000000000061751524637705700222250ustar00rootroot00000000000000from __future__ import annotations import typing import pytest from uiprotect.data.types import ( AudioStyle, HDRMode, IRLEDMode, ModelType, MountPosition, MountType, OsdOverlayLocation, PermissionNode, SensorStatusType, SensorType, StorageType, VideoMode, get_field_type, ) @pytest.mark.asyncio() async def test_model_type_from_string(): assert ModelType.from_string("camera") is ModelType.CAMERA assert ModelType.from_string("invalid") is ModelType.UNKNOWN @pytest.mark.parametrize( ("enum_cls", "known_value", "known_member"), [ (AudioStyle, "nature", AudioStyle.NATURE), (HDRMode, "normal", HDRMode.NORMAL), (IRLEDMode, "auto", IRLEDMode.AUTO), (IRLEDMode, "customFilterOnly", IRLEDMode.CUSTOM_FILTER_ONLY), (MountType, "door", MountType.DOOR), (SensorType, "temperature", SensorType.TEMPERATURE), (SensorStatusType, "safe", SensorStatusType.SAFE), (MountPosition, "ceiling", MountPosition.CEILING), (StorageType, "hdd", StorageType.DISK), (PermissionNode, "read", PermissionNode.READ), (OsdOverlayLocation, "topLeft", OsdOverlayLocation.TOP_LEFT), (OsdOverlayLocation, "bottomRight", OsdOverlayLocation.BOTTOM_RIGHT), (VideoMode, "default", VideoMode.DEFAULT), (VideoMode, "lprReflex", VideoMode.LPR_REFLEX), ], ) def test_unknown_values_enum_known_value(enum_cls, known_value, known_member): """Known values resolve to their correct enum member.""" assert enum_cls(known_value) is known_member @pytest.mark.parametrize( "enum_cls", [ AudioStyle, HDRMode, IRLEDMode, MountType, SensorType, SensorStatusType, MountPosition, StorageType, PermissionNode, OsdOverlayLocation, VideoMode, ], ) def test_unknown_values_enum_falls_back_to_unknown(enum_cls): """Unknown values fall back to UNKNOWN instead of raising ValueError.""" result = enum_cls("completely_new_firmware_value") assert result.name == "UNKNOWN" @pytest.mark.parametrize( ("annotation", "origin", "type_"), [ (bytearray, None, bytearray), (typing.get_origin(dict[str, int]), None, dict), (dict, None, dict), # Extract value type from list, set, dict (list[int], list, int), (set[int], set, int), (dict[str, int], dict, int), # Extract type from Annotated (typing.Annotated[int, "Hello World"], None, int), # Remove '| None' from Union and extract remaining value type (int | None, None, int), (list[int] | None, list, int), (typing.Annotated[int, "Hello World"] | None, None, int), # Leave 'normal' unions as is (int | str, None, int | str), (int | str | bytes, None, int | str | bytes), ], ) def test_get_field_type(annotation, origin, type_): res = get_field_type(annotation) assert origin == res[0] assert type_ == res[1] def test_get_field_type_error(): with pytest.raises(ValueError, match="Type annotation cannot be None"): get_field_type(None) uilibs-uiprotect-317a52c/tests/data/test_user.py000066400000000000000000000146041524637705700220330ustar00rootroot00000000000000from datetime import datetime from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers def test_ulp_user_creation(): user = UlpUser( id="1", ulp_id="ulp1", first_name="John", last_name="Doe", full_name="John Doe", avatar="avatar_url", status="active", ) assert user.id == "1" assert user.ulp_id == "ulp1" assert user.first_name == "John" assert user.last_name == "Doe" assert user.full_name == "John Doe" assert user.avatar == "avatar_url" assert user.status == "active" def test_ulp_users_add_and_remove(): users = UlpUsers() user = UlpUser( id="1", ulp_id="ulp1", first_name="John", last_name="Doe", full_name="John Doe", avatar="avatar_url", status="active", ) users.add(user) assert users.by_id("1") == user assert users.by_ulp_id("ulp1") == user users.remove(user) assert users.by_id("1") is None assert users.by_ulp_id("ulp1") is None def test_keyring_creation(): keyring = Keyring( id="1", device_type="type1", device_id="device1", registry_type="reg_type1", registry_id="reg_id1", last_activity=datetime.now(), ulp_user="ulp1", ) assert keyring.id == "1" assert keyring.device_type == "type1" assert keyring.device_id == "device1" assert keyring.registry_type == "reg_type1" assert keyring.registry_id == "reg_id1" assert keyring.ulp_user == "ulp1" def test_keyrings_add_and_remove(): keyrings = Keyrings() keyring = Keyring( id="1", device_type="type1", device_id="device1", registry_type="reg_type1", registry_id="reg_id1", last_activity=datetime.now(), ulp_user="ulp1", ) keyrings.add(keyring) assert keyrings.by_id("1") == keyring assert keyrings.by_registry_id("reg_id1") == keyring assert keyrings.by_ulp_id("ulp1") == keyring keyrings.remove(keyring) assert keyrings.by_id("1") is None assert keyrings.by_registry_id("reg_id1") is None assert keyrings.by_ulp_id("ulp1") is None def test_keyrings_equality(): keyrings1 = Keyrings() keyring1 = Keyring( id="1", device_type="type1", device_id="device1", registry_type="reg_type1", registry_id="reg_id1", last_activity=datetime.now(), ulp_user="ulp1", ) keyring2 = Keyring( id="2", device_type="type2", device_id="device2", registry_type="reg_type2", registry_id="reg_id2", last_activity=datetime.now(), ulp_user="ulp2", ) keyrings1.add(keyring1) keyrings1.add(keyring2) keyrings2 = Keyrings() keyrings2.add(keyring1) keyrings2.add(keyring2) assert keyrings1 == keyrings2 keyrings3 = Keyrings() keyring3 = Keyring( id="3", device_type="type3", device_id="device3", registry_type="reg_type3", registry_id="reg_id3", last_activity=datetime.now(), ulp_user="ulp3", ) keyrings3.add(keyring3) assert keyrings1 != keyrings3 def test_keyrings_eq_not_implemented(): keyrings = Keyrings() assert keyrings.__eq__(object()) == NotImplemented def test_ulp_users_eq_not_implemented(): users = UlpUsers() assert users.__eq__(object()) == NotImplemented def test_ulp_users_equality(): users1 = UlpUsers() user1 = UlpUser( id="1", ulp_id="ulp1", first_name="John", last_name="Doe", full_name="John Doe", avatar="avatar_url", status="active", ) user2 = UlpUser( id="2", ulp_id="ulp2", first_name="Jane", last_name="Doe", full_name="Jane Doe", avatar="avatar_url", status="inactive", ) users1.add(user1) users1.add(user2) users2 = UlpUsers() users2.add(user1) users2.add(user2) assert users1 == users2 users3 = UlpUsers() user3 = UlpUser( id="3", ulp_id="ulp3", first_name="Jim", last_name="Beam", full_name="Jim Beam", avatar="avatar_url", status="active", ) users3.add(user3) assert users1 != users3 def test_ulp_users_as_list(): users = UlpUsers() user1 = UlpUser( id="1", ulp_id="ulp1", first_name="John", last_name="Doe", full_name="John Doe", avatar="avatar_url", status="active", ) user2 = UlpUser( id="2", ulp_id="ulp2", first_name="Jane", last_name="Doe", full_name="Jane Doe", avatar="avatar_url", status="inactive", ) users.add(user1) users.add(user2) user_list = users.as_list() assert len(user_list) == 2 assert user1 in user_list assert user2 in user_list def test_keyrings_from_list(): keyring1 = Keyring( id="1", device_type="type1", device_id="device1", registry_type="reg_type1", registry_id="reg_id1", last_activity=datetime.now(), ulp_user="ulp1", ) keyring2 = Keyring( id="2", device_type="type2", device_id="device2", registry_type="reg_type2", registry_id="reg_id2", last_activity=datetime.now(), ulp_user="ulp2", ) keyrings_list = [keyring1, keyring2] keyrings = Keyrings.from_list(keyrings_list) assert keyrings.by_id("1") == keyring1 assert keyrings.by_id("2") == keyring2 assert keyrings.by_registry_id("reg_id1") == keyring1 assert keyrings.by_registry_id("reg_id2") == keyring2 assert keyrings.by_ulp_id("ulp1") == keyring1 assert keyrings.by_ulp_id("ulp2") == keyring2 def test_ulp_users_from_list(): user1 = UlpUser( id="1", ulp_id="ulp1", first_name="John", last_name="Doe", full_name="John Doe", avatar="avatar_url", status="active", ) user2 = UlpUser( id="2", ulp_id="ulp2", first_name="Jane", last_name="Doe", full_name="Jane Doe", avatar="avatar_url", status="inactive", ) users_list = [user1, user2] users = UlpUsers.from_list(users_list) assert users.by_id("1") == user1 assert users.by_id("2") == user2 assert users.by_ulp_id("ulp1") == user1 assert users.by_ulp_id("ulp2") == user2 uilibs-uiprotect-317a52c/tests/data/test_viewer.py000066400000000000000000000072541524637705700223610ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr" from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import AsyncMock, Mock import pytest from tests.conftest import TEST_VIEWPORT_EXISTS from uiprotect.data.public_devices import PublicViewer from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.exceptions import BadRequest if TYPE_CHECKING: from uiprotect.data import Liveview, Viewer def _public_viewer_response(**overrides: object) -> PublicViewer: raw: dict[str, object] = { "id": "viewer-1", "modelKey": "viewer", "state": "CONNECTED", "name": "Viewer 1", "mac": "AABBCCDDEE01", "liveview": "lv-1", "streamLimit": 16, } raw.update(overrides) return PublicViewer.from_unifi_dict(**raw) @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_viewer_set_liveview_invalid(viewer_obj: Viewer, liveview_obj: Liveview): viewer_obj.api.api_request.reset_mock() liveview = liveview_obj.update_from_dict({"id": "bad_id"}) with pytest.raises(BadRequest): await viewer_obj.set_liveview(liveview) assert not viewer_obj.api.api_request.called @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_viewer_set_liveview_valid(viewer_obj: Viewer, liveview_obj: Liveview): viewer_obj.api.api_request.reset_mock() viewer_obj.api.emit_message = Mock() viewer_obj.liveview_id = "bad_id" await viewer_obj.set_liveview(liveview_obj) viewer_obj.api.api_request.assert_called_with( f"viewers/{viewer_obj.id}", method="patch", json={"liveview": liveview_obj.id}, ) # old/new is actually the same here since the client # generating the message is the one that changed it viewer_obj.api.emit_message.assert_called_with( WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=viewer_obj.api.bootstrap.last_update_id, changed_data={"liveview_id": liveview_obj.id}, old_obj=viewer_obj, new_obj=viewer_obj, ), ) @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_viewer_set_name_public(viewer_obj: Viewer) -> None: viewer_obj.api.update_viewer_public = AsyncMock( return_value=_public_viewer_response(name="Renamed"), ) await viewer_obj.set_name_public("Renamed") viewer_obj.api.update_viewer_public.assert_awaited_once_with( viewer_obj.id, name="Renamed", ) assert viewer_obj.name == "Renamed" @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_viewer_set_liveview_public(viewer_obj: Viewer) -> None: viewer_obj.api.update_viewer_public = AsyncMock( return_value=_public_viewer_response(liveview="lv-9"), ) await viewer_obj.set_liveview_public("lv-9") viewer_obj.api.update_viewer_public.assert_awaited_once_with( viewer_obj.id, liveview="lv-9", ) assert viewer_obj.liveview_id == "lv-9" @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_viewer_set_liveview_public_clear(viewer_obj: Viewer) -> None: viewer_obj.api.update_viewer_public = AsyncMock( return_value=_public_viewer_response(liveview=None), ) await viewer_obj.set_liveview_public(None) viewer_obj.api.update_viewer_public.assert_awaited_once_with( viewer_obj.id, liveview=None, ) assert viewer_obj.liveview_id == "" uilibs-uiprotect-317a52c/tests/sample_data/000077500000000000000000000000001524637705700210005ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/sample_data/__init__.py000066400000000000000000000000001524637705700230770ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/sample_data/constants.py000066400000000000000000000014601524637705700233670ustar00rootroot00000000000000from __future__ import annotations import json import os from pathlib import Path from typing import Any UFP_SAMPLE_DIR = os.environ.get("UFP_SAMPLE_DIR") if UFP_SAMPLE_DIR: DATA_FILE = Path(UFP_SAMPLE_DIR) / "sample_constants.json" else: DATA_FILE = Path(__file__).parent / "sample_constants.json" class ConstantData: _data: dict[str, Any] | None = None def __getitem__(self, key): return self.data().__getitem__(key) def __contains__(self, key): return self.data().__contains__(key) def get(self, key, default=None): return self.data().get(key, default) def data(self): if self._data is None: with DATA_FILE.open(encoding="utf-8") as f: self._data = json.load(f) return self._data CONSTANTS = ConstantData() uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/000077500000000000000000000000001524637705700243535ustar00rootroot00000000000000uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/born_closed_update_bad_start.json000066400000000000000000000003321524637705700331220ustar00rootroot00000000000000{ "type": "update", "item": { "id": "bad-event", "modelKey": "event", "type": "smartDetectZone", "start": "not-a-timestamp", "end": 1735690006000, "device": "aabbccddeeff00112233aabb" } } uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/fingerprint_add_ulpid_null.json000066400000000000000000000005021524637705700326310ustar00rootroot00000000000000{ "type": "add", "item": { "id": "11111111-1111-1111-1111-111111111113", "modelKey": "event", "type": "fingerprintIdentified", "start": 1735689800000, "end": 1735689800500, "device": "aabbccddeeff00112233aabb", "metadata": { "fingerprint": { "ulpId": null } } } } uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/fingerprint_add_with_end.json000066400000000000000000000005441524637705700322710ustar00rootroot00000000000000{ "type": "add", "item": { "id": "11111111-1111-1111-1111-111111111112", "modelKey": "event", "type": "fingerprintIdentified", "start": 1735689700000, "end": 1735689700500, "device": "aabbccddeeff00112233aabb", "metadata": { "fingerprint": { "ulpId": "22222222-2222-2222-2222-222222222222" } } } } uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/light_motion_add.json000066400000000000000000000003201524637705700305450ustar00rootroot00000000000000{ "type": "add", "item": { "id": "11111111-1111-1111-1111-111111111114", "modelKey": "event", "type": "lightMotion", "start": 1735689900000, "device": "aabbccddeeff00112233aabb" } } uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/nfc_add_with_end.json000066400000000000000000000005251524637705700305070ustar00rootroot00000000000000{ "type": "add", "item": { "id": "11111111-1111-1111-1111-111111111111", "modelKey": "event", "type": "nfcCardScanned", "start": 1735689600000, "end": 1735689601000, "device": "aabbccddeeff00112233aabb", "metadata": { "nfc": { "ulpId": "22222222-2222-2222-2222-222222222222" } } } } uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/open_unknown_update.json000066400000000000000000000003431524637705700313300ustar00rootroot00000000000000{ "type": "update", "item": { "id": "unseen-open", "modelKey": "event", "type": "smartDetectZone", "start": 1735690000000, "device": "aabbccddeeff00112233aabb", "smartDetectTypes": ["package"] } } uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/package_born_closed_update.json000066400000000000000000000004261524637705700325560ustar00rootroot00000000000000{ "type": "update", "item": { "id": "11111111-1111-1111-1111-111111111116", "modelKey": "event", "type": "smartDetectZone", "start": 1735690000000, "end": 1735690006000, "device": "aabbccddeeff00112233aabb", "smartDetectTypes": ["package"] } } uilibs-uiprotect-317a52c/tests/sample_data/events_ws_public/smartdetect_lifecycle.json000066400000000000000000000016331524637705700316070ustar00rootroot00000000000000[ { "type": "add", "item": { "id": "11111111-1111-1111-1111-111111111115", "modelKey": "event", "type": "smartDetectZone", "start": 1735690000000, "device": "aabbccddeeff00112233aabb", "smartDetectTypes": ["person"] } }, { "type": "update", "item": { "id": "11111111-1111-1111-1111-111111111115", "modelKey": "event", "smartDetectTypes": ["person", "vehicle"] } }, { "type": "update", "item": { "id": "11111111-1111-1111-1111-111111111115", "modelKey": "event", "end": 1735690010000 } }, { "type": "update", "item": { "id": "11111111-1111-1111-1111-111111111115", "modelKey": "event", "end": 1735690010000 } }, { "type": "update", "item": { "id": "11111111-1111-1111-1111-111111111115", "modelKey": "event", "end": 1735690010000 } } ] uilibs-uiprotect-317a52c/tests/sample_data/sample_aiport.json000066400000000000000000000416361524637705700245440ustar00rootroot00000000000000{ "isDeleting": false, "mac": "942A6EGCA31F", "host": "192.168.7.217", "connectionHost": "192.168.7.8", "type": "UVC AI Port", "sysid": "0xa5f1", "name": "AI Port", "upSince": 1734402814930, "uptime": 66493, "lastSeen": 1734469307930, "connectedSince": 1734429720103, "state": "CONNECTED", "lastDisconnect": 1734429714493, "hardwareRevision": "9", "firmwareVersion": "4.73.8", "latestFirmwareVersion": null, "firmwareBuild": "bc916ee.241211.844", "isUpdating": false, "isDownloadingFW": false, "fwUpdateState": "upToDate", "isAdopting": false, "isRestoring": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "uplinkDevice": null, "guid": "f3d3739b-743d-46df-9209-a1d31595a893", "anonymousDeviceId": "4a547994-8d51-4a57-9e69-880c5f699c04", "lastMotion": null, "micVolume": 100, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": null, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "apMgmtIp": null, "elementInfo": null, "chimeDuration": 0, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "eventStats": { "motion": { "today": 0, "average": 0, "lastDays": [], "recentHours": [] }, "smart": { "today": 0, "average": 0, "lastDays": [] } }, "videoReconfigurationInProgress": false, "voltage": null, "activePatrolSlot": null, "useGlobal": false, "hubMac": null, "isPoorNetwork": false, "stopStreamLevel": null, "downScaleMode": 0, "isExtenderInstalledEver": false, "isWaterproofCaseAttached": false, "isMissingRecordingDetected": false, "userConfiguredAp": false, "hasRecordings": false, "videoCodec": "h264", "videoCodecState": 0, "videoCodecSwitchingSince": null, "enableNfc": false, "isThirdPartyCamera": false, "isPairedWithAiPort": false, "streamingChannels": [], "ptzControlEnabled": true, "isAudioIncluded": true, "isStreaming": true, "wiredConnectionState": { "phyRate": null }, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "txRate": null, "signalQuality": null, "ssid": null, "bssid": null, "apName": null, "experience": null, "signalStrength": null, "connectivity": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "High", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 3840, "height": 2160, "fps": 24, "bitrate": 8000000, "minBitrate": 2000000, "maxBitrate": 8000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24], "idrInterval": 5, "autoFps": true, "autoBitrate": true }, { "id": 1, "videoId": "video2", "name": "Medium", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 750000, "maxBitrate": 8000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24], "idrInterval": 5, "autoFps": true, "autoBitrate": true }, { "id": 2, "videoId": "video3", "name": "Low", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 900000, "minBitrate": 300000, "maxBitrate": 8000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24], "idrInterval": 5, "autoFps": true, "autoBitrate": true } ], "ispSettings": { "aeMode": "none", "irLedMode": "auto", "irLedLevel": 256, "wdr": 4, "icrSensitivity": 4, "icrSwitchMode": "none", "icrCustomValue": 11, "brightness": 101, "contrast": 101, "hue": 101, "saturation": 101, "sharpness": 101, "denoise": 101, "isColorNightVisionEnabled": true, "spotlightDuration": 901, "isFlippedVertical": true, "isFlippedHorizontal": true, "isAutoRotateEnabled": true, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": true, "isAggressiveAntiFlickerEnabled": true, "isPauseMotionEnabled": true, "dZoomCenterX": 101, "dZoomCenterY": 101, "dZoomScale": 101, "dZoomStreamId": 5, "focusMode": "none", "focusPosition": 101, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 101, "mountPosition": "none", "hdrMode": "none", "sceneMode": "none" }, "audioSettings": { "style": ["nature"] }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": false, "isDateEnabled": false, "isLogoEnabled": true, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80, "ringVolume": 80 }, "recordingSettings": { "prePaddingSecs": 2, "postPaddingSecs": 2, "smartDetectPrePaddingSecs": 2, "smartDetectPostPaddingSecs": 2, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "inScheduleMode": "always", "outScheduleMode": "never", "geofencing": "off", "retentionDurationMs": null, "retentionDurationLQMs": null, "motionAlgorithm": "enhanced", "enableMotionDetection": true, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle", "animal", "package", "face", "licensePlate" ], "autoTrackingObjectTypes": [], "autoTrackingWithZoom": true, "audioTypes": [], "detectionRange": { "max": null, "min": null } }, "recordingSchedulesV2": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [0.204, 0], [0.546, 0], [0.525, 0.631], [0.625, 0.63], [1, 0.704], [1, 1], [0.257, 1] ], "sensitivity": 50, "isTriggerLightEnabled": true } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [0.204, 0], [0.533, 0], [0.528, 0.626], [0.696, 0.613], [1, 0.711], [1, 1], [0.251, 1] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle", "animal", "face", "licensePlate", "package" ], "isTriggerLightEnabled": true, "source": "unifi-protect", "triggerAccessTypes": [], "enableAccessLPOnlyMode": false } ], "smartDetectLines": [ { "id": 3, "name": "New Crossing Line", "color": "#46C7FD", "points": [ [0.69, 0.609], [0.53, 0.609] ], "sensitivity": 50, "objectTypes": ["person", "vehicle", "animal"], "isTriggerLightEnabled": false, "direction": "BA", "isTargetCounting": false, "plan": { "autoReset": "none", "time": null, "weekDay": null, "date": null } }, { "id": 4, "name": "New Crossing Line", "color": "#61E066", "points": [ [1, 0.685], [0.69, 0.609] ], "sensitivity": 50, "objectTypes": ["person", "vehicle"], "isTriggerLightEnabled": false, "direction": "BA", "isTargetCounting": false, "plan": { "autoReset": "none", "time": null, "weekDay": null, "date": null } } ], "smartDetectLoiterZones": [], "stats": { "rxBytes": 0, "txBytes": 0, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "video": { "recordingStart": null, "recordingEnd": null, "recordingStartLQ": null, "recordingEndLQ": null, "timelapseStart": null, "timelapseEnd": null, "timelapseStartLQ": null, "timelapseEndLQ": null }, "storage": { "used": null, "rate": null, "channelStorage": {} }, "sdCard": { "state": "unmounted", "health": null, "mounts": [], "serial": null, "size": null, "healthStatus": "insufficient_size", "usedSize": 0 }, "edgeRecording": { "recordStreamNumber": null, "recordMode": "smartDetect" }, "wifiQuality": 50, "wifiStrength": 0, "sdCardStorageCapacityMs": null }, "featureFlags": { "canAdjustIrLedLevel": false, "maxScaleDownLevel": 0, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasVerticalFlip": true, "hasAec": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasInfrared": true, "hasLdc": false, "hasLedIr": false, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": false, "hasAutoICROnly": false, "videoModes": ["default"], "videoModeMaxFps": [30], "hasMotionZones": true, "hasLcdScreen": false, "hasFingerprintSensor": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle", "animal", "face", "licensePlate", "package" ], "smartDetectAudioTypes": [ "smoke_cmonx", "alrmSmoke", "alrmCmonx", "alrmSiren", "alrmBabyCry", "alrmSpeak", "alrmBurglar", "alrmCarHorn", "alrmBark", "alrmGlassBreak" ], "supportDoorAccessConfig": false, "supportNfc": false, "supportLpDetectionWithoutVehicle": false, "lensType": null, "lensModel": null, "motionAlgorithms": ["enhanced"], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "audio": [], "audioCodecs": ["aac"], "videoCodecs": ["h264", "h265", "mjpg"], "audioStyle": [], "isDoorbell": false, "isPtz": false, "hasColorLcdScreen": false, "hasLiveviewTracking": true, "hasLineCrossing": true, "hasLineCrossingCounting": true, "hasFlash": false, "flashRange": null, "hasLuxCheck": false, "presetTour": false, "hasEdgeRecording": false, "hasLprReflex": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 1, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hotplug": { "audio": null, "video": null, "standaloneAdoption": false, "extender": { "isAttached": false, "hasFlash": null, "flashRange": null, "hasIR": null, "hasRadar": false, "radarRangeMax": null, "radarRangeMin": null } }, "hasSmartDetect": true }, "tiltLimitsOfPrivacyZones": { "side": "bottom", "limit": 0 }, "lcdMessage": {}, "lenses": [], "streamSharing": { "enabled": false, "token": null, "shareLink": null, "expires": null, "sharedByUserId": null, "sharedByUser": null, "maxStreams": null }, "homekitSettings": { "talkbackSettingsActive": false, "streamInProgress": false, "microphoneMuted": false, "speakerMuted": false }, "shortcuts": [], "alarms": { "lensThermal": 0, "tiltThermal": 0, "panTiltMotorFaults": [], "autoTrackingThermalThresholdReached": false, "lensThermalThresholdReached": false, "motorOverheated": false }, "extendedAiFeatures": { "smartDetectTypes": [] }, "thirdPartyCameraInfo": { "port": null, "rtspUrl": null, "rtspUrlLQ": null, "snapshotUrl": null }, "fingerprintSettings": { "enable": false, "enablePrintLatency": false, "mode": "identify", "reportFingerTouch": false, "reportCaptureComplete": false }, "fingerprintState": { "fingerprintId": null, "status": null, "progress": null, "total": 0, "free": 0 }, "nfcSettings": { "enableNfc": false, "supportThirdPartyCard": false }, "nfcState": { "lastSeen": null, "mode": "disabled", "cardId": null, "isUACard": false }, "pairedCameras": ["663d0aa400918803e4396735"], "id": "6760947e02b4830659365930", "nvrMac": "F9FC431B07EC", "displayName": "AI Port", "isConnected": true, "platform": "all", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "AI Port", "is4K": false, "is2K": false, "currentResolution": "HD", "supportedScalingResolutions": ["HD"], "modelKey": "aiport" } uilibs-uiprotect-317a52c/tests/sample_data/sample_bootstrap.json000066400000000000000000010372021524637705700252560ustar00rootroot00000000000000{ "authUserId": "4c5f03a8c8bd48ad8e066285", "accessKey": "8528571101220:340ff666bffb58bc404b859a:8f3f41a7b180b1ff7463fe4f7f13b528ac3d28668f25d0ecaa30c8e7888559e782b38d4335b40861030b75126eb7cea8385f3f9ab59dfa9a993e50757c277053", "cameras": [ { "isDeleting": false, "mac": "7CD8B328641B", "host": "192.168.164.242", "connectionHost": "192.168.102.63", "type": "UVC G4 Dome", "name": "Tfzotee Autbv", "upSince": 1641665036921, "uptime": null, "lastSeen": 1641666397032, "connectedSince": null, "state": "DISCONNECTED", "hardwareRevision": "10", "firmwareVersion": "4.47.15", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "524d13e.211221.1032", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1641666350274, "micVolume": 100, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 1000, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 0, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "355958af-1a08-4fb4-8619-d81fb88b5488", "eventStats": { "motion": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ], "recentHours": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "Jsqti Dnyvdi", "enabled": true, "isRtspEnabled": true, "rtspAlias": "BViVMrQWSv8mS5M8", "width": 2688, "height": 1512, "fps": 24, "bitrate": 10000000, "minBitrate": 32000, "maxBitrate": 10000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Xlddbfz Idwiu", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 32000, "maxBitrate": 3000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Vpae Hsr", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 300000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": true, "isFlippedHorizontal": true, "isAutoRotateEnabled": true, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 50 }, "recordingSettings": { "prePaddingSecs": 2, "postPaddingSecs": 2, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "person" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 33888051, "txBytes": 1445351903, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639849398628, "recordingEnd": 1641666364031, "recordingStartLQ": 1639849398580, "recordingEndLQ": 1641666369049, "timelapseStart": 1639849398590, "timelapseEnd": 1641666309048, "timelapseStartLQ": 1639849398590, "timelapseEndLQ": 1641666294067 }, "storage": {}, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": false, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": true, "hasAec": true, "hasBattery": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": true, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": true, "hasWifi": false, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 48 ], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "76227b20c37b2ff0abdc9d4d", "isConnected": false, "platform": "s5l", "hasSpeaker": true, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Dome", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "4B8290F6D7A9", "host": "10.10.0.12", "connectionHost": null, "type": "UA Intercom", "name": "UI Intercom", "upSince": 1737389973000, "uptime": 135027140, "lastSeen": 1737525000140, "connectedSince": 1737390022000, "state": "DISCONNECTED", "lastDisconnect": 1737390740043, "hardwareRevision": null, "firmwareVersion": "1.5.30", "latestFirmwareVersion": null, "firmwareBuild": null, "isUpdating": false, "isDownloadingFW": false, "fwUpdateState": "upToDate", "isAdopting": false, "isRestoring": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "uplinkDevice": null, "guid": "a3cf3555-9a4d-487e-938e-0d2f2797909c", "anonymousDeviceId": null, "lastMotion": 1737373936750, "micVolume": 100, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": null, "hdrMode": false, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "apMgmtIp": null, "elementInfo": null, "chimeDuration": 0, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "eventStats": { "motion": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ], "recentHours": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "useGlobal": false, "isPoorNetwork": false, "stopStreamLevel": null, "isWaterproofCaseAttached": false, "userConfiguredAp": false, "hasRecordings": false, "isThirdPartyCamera": false, "isPairedWithAiPort": false, "isAdoptedByAccessApp": true, "wiredConnectionState": { "phyRate": null }, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "txRate": null, "signalQuality": null, "ssid": null, "bssid": null, "apName": null, "experience": null, "signalStrength": null, "connectivity": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "High", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 1200, "height": 1600, "fps": 30, "bitrate": 6000000, "minBitrate": null, "maxBitrate": null, "minClientAdaptiveBitRate": null, "minMotionAdaptiveBitRate": null, "fpsValues": [ 30 ], "idrInterval": 5, "autoFps": false, "autoBitrate": false }, { "id": 1, "videoId": "video3", "name": "Medium", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 960, "height": 1280, "fps": 30, "bitrate": 1200000, "minBitrate": null, "maxBitrate": null, "minClientAdaptiveBitRate": null, "minMotionAdaptiveBitRate": null, "fpsValues": [ 30 ], "idrInterval": 5, "autoFps": false, "autoBitrate": false }, { "id": 2, "videoId": "video2", "name": "Low", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 360, "height": 480, "fps": 15, "bitrate": 200000, "minBitrate": null, "maxBitrate": null, "minClientAdaptiveBitRate": null, "minMotionAdaptiveBitRate": null, "fpsValues": [ 15 ], "idrInterval": 5, "autoFps": false, "autoBitrate": false } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "icrSwitchMode": "sensitivity", "icrCustomValue": 2, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isColorNightVisionEnabled": false, "spotlightDuration": 15, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusPosition": 0, "touchFocusX": null, "touchFocusY": null, "zoomPosition": 0, "mountPosition": null, "hdrMode": "normal" }, "audioSettings": { "style": [ "nature" ] }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "", "bindAddr": "", "bindPort": 8182, "filterAddr": "", "filterPort": 0, "channels": 1, "samplingRate": 48000, "bitsPerSample": 16, "quality": 0 }, "osdSettings": { "isNameEnabled": false, "isDateEnabled": false, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": true, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 5, "postPaddingSecs": 5, "smartDetectPrePaddingSecs": 2, "smartDetectPostPaddingSecs": 2, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "never", "inScheduleMode": "always", "outScheduleMode": "never", "geofencing": "off", "retentionDurationMs": null, "motionAlgorithm": "enhanced", "enableMotionDetection": false, "createAccessEvent": true, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [], "autoTrackingObjectTypes": [], "audioTypes": [] }, "recordingSchedulesV2": [], "motionZones": [], "privacyZones": [], "smartDetectZones": [], "smartDetectLines": [], "stats": { "rxBytes": 0, "txBytes": 0, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "video": { "recordingStart": null, "recordingEnd": null, "recordingStartLQ": null, "recordingEndLQ": null, "timelapseStart": null, "timelapseEnd": null, "timelapseStartLQ": null, "timelapseEndLQ": null }, "storage": { "used": null }, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasVerticalFlip": true, "hasAec": true, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasInfrared": true, "hasLdc": false, "hasLedIr": false, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": true, "hasWifi": false, "hasHdr": false, "hasAutoICROnly": false, "videoModes": [], "videoModeMaxFps": [], "hasMotionZones": true, "hasLcdScreen": false, "hasFingerprintSensor": false, "mountPositions": [], "smartDetectTypes": [], "smartDetectAudioTypes": [], "supportNfc": false, "lensType": null, "lensModel": null, "motionAlgorithms": [], "hasSquareEventThumbnail": false, "hasPackageCamera": false, "audio": [], "audioCodecs": [ "aac", "opus" ], "audioStyle": [], "isDoorbell": false, "isPtz": false, "hasColorLcdScreen": false, "hasLiveviewTracking": false, "hasLineCrossing": false, "hasLineCrossingCounting": false, "hasFlash": false, "flashRange": null, "privacyMaskCapability": { "maxMasks": null, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 22, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hotplug": { "audio": null, "video": null, "standaloneAdoption": false, "extender": { "isAttached": null, "hasFlash": null, "flashRange": null, "hasIR": null, "hasRadar": null } }, "hasSmartDetect": false }, "lcdMessage": {}, "lenses": [], "streamSharing": { "enabled": false, "token": null, "shareLink": null, "expires": null, "sharedByUserId": null, "sharedByUser": null, "maxStreams": null }, "homekitSettings": { "talkbackSettingsActive": false, "streamInProgress": false, "microphoneMuted": false, "speakerMuted": false }, "id": "45235b20c37b2ff0abdc9d4d", "nvrMac": "4B8290F6D7A3", "isConnected": false, "platform": null, "hasSpeaker": true, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "UA Intercom", "is4K": false, "is2K": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "CE668E1CDADB", "host": "192.168.11.58", "connectionHost": "192.168.102.63", "type": "UVC G4 Doorbell", "name": "Xfqf Fydhbh", "upSince": 1642022726827, "uptime": null, "lastSeen": 1642023134515, "connectedSince": null, "state": "DISCONNECTED", "hardwareRevision": "21", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1642023134518, "micVolume": 100, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 135, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": 1642022756933, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "97323b0e-c723-4688-b382-177c83ab6d82", "eventStats": { "motion": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ], "recentHours": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "Oktg Brcmgt", "enabled": true, "isRtspEnabled": true, "rtspAlias": "d42VD60PnFGSWKAI", "width": 1600, "height": 1200, "fps": 30, "bitrate": 6000000, "minBitrate": 32000, "maxBitrate": 6000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Tgveb Xvipv", "enabled": true, "isRtspEnabled": true, "rtspAlias": "zJtlHLIysUUtrDFz", "width": 960, "height": 720, "fps": 30, "bitrate": 1200000, "minBitrate": 32000, "maxBitrate": 2000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Zqpexd Eryy", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 480, "height": 360, "fps": 30, "bitrate": 200000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 200000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 0, "icrSensitivity": 0, "brightness": 51, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": "", "filterPort": 0, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 100 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 0.639, 0 ], [ 0.689, 0.554 ], [ 0.854, 0.575 ], [ 1, 0.601 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "person" ] }, { "id": 2, "name": "Ihb Ses", "color": "#FF0075", "points": [ [ 0, 0 ], [ 0.389, 0 ], [ 0.356, 0.533 ], [ 0.648, 0.554 ], [ 0.841, 0.578 ], [ 1, 0.601 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 3607428, "txBytes": 131235466, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 81, "signalStrength": -71 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183850698, "recordingEnd": 1642023074549, "recordingStartLQ": 1639183850689, "recordingEndLQ": 1642023074607, "timelapseStart": 1639183850646, "timelapseEnd": 1642022576106, "timelapseStartLQ": 1639183850646, "timelapseEndLQ": 1642022576106 }, "storage": {}, "wifiQuality": 81, "wifiStrength": -71 }, "featureFlags": { "supportFullHdSnapshot": false, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasAec": true, "hasBattery": false, "hasBluetooth": true, "hasChime": true, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": true, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": true, "hasWifi": true, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 45 ], "hasMotionZones": true, "hasLcdScreen": true, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": { "type": "CUSTOM_MESSAGE", "text": "Welcome | 01:57 PM | 25\u00b0F", "resetAt": null }, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "0777b5d342302079dc6b793d", "isConnected": false, "platform": "s5l", "hasSpeaker": true, "hasWifi": true, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Doorbell", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "A3ABB5ABE298", "host": "192.168.23.22", "connectionHost": "192.168.102.63", "type": "UVC G4 Bullet", "name": "Ddxoxwb Ned", "upSince": 1640292872771, "uptime": null, "lastSeen": 1640293400431, "connectedSince": null, "state": "DISCONNECTED", "hardwareRevision": "5", "firmwareVersion": "4.47.13", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "0a55423.211124.717", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1640290945306, "micVolume": 100, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 1000, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "e12a9b6d-8a3b-411a-8ab8-34931517ed9d", "eventStats": { "motion": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ], "recentHours": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "Wrdvta Imi", "enabled": true, "isRtspEnabled": true, "rtspAlias": "GNYYX1qS1sEQMErd", "width": 2688, "height": 1512, "fps": 24, "bitrate": 10000000, "minBitrate": 32000, "maxBitrate": 10000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Ijje Uqsh", "enabled": true, "isRtspEnabled": true, "rtspAlias": "CGIMLNSphQQKZT4Z", "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 32000, "maxBitrate": 3000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Tqc Jlwq", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 300000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 9642720, "txBytes": 394685474, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183865263, "recordingEnd": 1640293360356, "recordingStartLQ": 1639183865313, "recordingEndLQ": 1640293375316, "timelapseStart": 1639183865236, "timelapseEnd": 1640293205314, "timelapseStartLQ": 1639183865236, "timelapseEndLQ": 1640290918216 }, "storage": {}, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": false, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasAec": false, "hasBattery": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": true, "hasIcrSensitivity": true, "hasLdc": true, "hasLedIr": true, "hasLedStatus": false, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 48 ], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "56401f80d300adad7123c864", "isConnected": false, "platform": "s5l", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Bullet", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "331AFA68BE00", "host": "192.168.10.22", "connectionHost": "192.168.102.63", "type": "UVC G4 Doorbell Pro", "name": "Mxxoxar Gyd", "upSince": 1642431757906, "uptime": 624002, "lastSeen": 1643055759906, "connectedSince": 1642431796280, "state": "CONNECTED", "hardwareRevision": "20", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": true, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643055545876, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 200, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": 1643055447318, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "0cc40649-3501-4755-a144-14cfd449efe5", "eventStats": { "motion": { "today": 43, "average": 69, "lastDays": [ 57, 59, 126, 59, 65, 64, 53 ], "recentHours": [ 4, 2, 6, 5, 1, 0, 6, 4, 1, 5, 5, 0, 3 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": 27, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "Evv Acjhsdh", "enabled": true, "isRtspEnabled": true, "rtspAlias": "OfJowIFFUBaYAtah", "width": 1600, "height": 1200, "fps": 30, "bitrate": 6000000, "minBitrate": 32000, "maxBitrate": 6000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Frjg Ciu", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 960, "height": 720, "fps": 30, "bitrate": 1200000, "minBitrate": 32000, "maxBitrate": 2000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Huvqtqm Tbpibvh", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 480, "height": 360, "fps": 30, "bitrate": 200000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 200000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 3, "videoId": "video4", "name": "Ldmm Hnqdcg", "enabled": true, "isRtspEnabled": true, "rtspAlias": "VWJGXSBiRS7WbQGL", "width": 1600, "height": 1200, "fps": 2, "bitrate": 1000000, "minBitrate": 32000, "maxBitrate": 2000000, "minClientAdaptiveBitRate": null, "minMotionAdaptiveBitRate": null, "fpsValues": [ 1, 2 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": "", "filterPort": 0, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Plbxuwl Xlypwre", "color": "#AB46BC", "points": [ [ 0.569, 0 ], [ 0.604, 0.537 ], [ 1, 0.565 ], [ 1, 1 ], [ 0, 1 ], [ 0, 0 ], [ 0.276, 0 ] ], "sensitivity": 50, "objectTypes": [ "person" ] }, { "id": 2, "name": "Oazunxs Ibxw", "color": "#586CED", "points": [ [ 0, 0 ], [ 0.369, 0 ], [ 0.358, 0.543 ], [ 1, 0.531 ], [ 1, 1 ], [ 0, 0.978 ] ], "sensitivity": 50, "objectTypes": [ "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 7916684198, "txBytes": 270419791080, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -52 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1641921049599, "recordingEnd": 1643055770699, "recordingStartLQ": 1641921049574, "recordingEndLQ": 1643055785757, "timelapseStart": 1641921049594, "timelapseEnd": 1643055460715, "timelapseStartLQ": 1641921049594, "timelapseEndLQ": 1643055520725 }, "storage": { "used": 427349245952, "rate": 642.30288184191 }, "wifiQuality": 100, "wifiStrength": -52 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasAec": true, "hasBattery": false, "hasBluetooth": true, "hasChime": true, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": true, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": true, "hasWifi": true, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default" ], "videoModeMaxFps": [], "hasMotionZones": true, "hasLcdScreen": true, "hasFingerprintSensor": true, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "supportNfc": true, "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": true, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": { "type": "CUSTOM_MESSAGE", "text": "Welcome | 03:23 PM | 25\u00b0F", "resetAt": null }, "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -52, "ssid": "Mortis Camera" }, "lenses": [ { "id": 2, "video": { "recordingStart": 1642357077629, "recordingEnd": 1643055747396, "recordingStartLQ": null, "recordingEndLQ": null, "timelapseStart": 1641921049317, "timelapseEnd": 1643055438229, "timelapseStartLQ": null, "timelapseEndLQ": null } } ], "id": "1c9a2db4df6efda47a3509be", "isConnected": true, "platform": "s5l", "hasSpeaker": true, "hasWifi": true, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Doorbell Pro", "isPairedWithAiPort": true, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "C1E5752D2D9B", "host": "192.168.16.186", "connectionHost": "192.168.102.63", "type": "UVC AI Bullet", "name": "Dheril Fsx", "upSince": 1641929381901, "uptime": 1126378, "lastSeen": 1643055759901, "connectedSince": 1642896021779, "state": "CONNECTED", "hardwareRevision": "3", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1820", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643055575971, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 1000, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 0, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "dbb18c8b-a7f1-4759-905f-e5b52e9a614d", "eventStats": { "motion": { "today": 22, "average": 18, "lastDays": [ 36, 6, 0, 3, 3, 36, 44 ], "recentHours": [ 3, 1, 12, 0, 1, 0, 5, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": 1000 }, "channels": [ { "id": 0, "videoId": "video1", "name": "Wlixnbo Nykk", "enabled": true, "isRtspEnabled": true, "rtspAlias": "AZWxWsX2PSEMI6d2", "width": 2688, "height": 1512, "fps": 30, "bitrate": 10000000, "minBitrate": 32000, "maxBitrate": 10000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Jilbyey Dtdgebd", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 1280, "height": 720, "fps": 30, "bitrate": 2000000, "minBitrate": 32000, "maxBitrate": 3000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Hevuxf Sre", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 30, "bitrate": 300000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": true, "isFlippedHorizontal": true, "isAutoRotateEnabled": true, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": "wall" }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 21294840858, "txBytes": 1002784215600, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640288743861, "recordingEnd": 1643055770802, "recordingStartLQ": 1640288744338, "recordingEndLQ": 1643055785778, "timelapseStart": 1640288743858, "timelapseEnd": 1643055480800, "timelapseStartLQ": 1640288743858, "timelapseEndLQ": 1643055095797 }, "storage": { "used": 1987496116224, "rate": 708.231920183599 }, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": true, "hasAec": false, "hasBattery": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": true, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 30, 50 ], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "586ab7c2bb6423c3fdd47e95", "isConnected": true, "platform": "cv2x", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "AI Bullet", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "49512F7958AE", "host": "192.168.127.173", "connectionHost": "192.168.102.63", "type": "UVC G4 Doorbell", "name": "Ftjj Bjbx", "upSince": 1642431757906, "uptime": 624002, "lastSeen": 1643055759906, "connectedSince": 1642431800250, "state": "CONNECTED", "hardwareRevision": "22", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643055542532, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 43, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": 1641571914529, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "3e29e07a-13c2-4514-b610-8376df41c2ad", "eventStats": { "motion": { "today": 166, "average": 363, "lastDays": [ 272, 397, 479, 350, 326, 415, 307 ], "recentHours": [ 13, 32, 14, 18, 15, 7, 27, 17, 6, 4, 6, 2, 3 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": 25.9, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "Hvx Zle", "enabled": true, "isRtspEnabled": true, "rtspAlias": "CLsJTPVhWOq4OZQa", "width": 1600, "height": 1200, "fps": 30, "bitrate": 3000000, "minBitrate": 32000, "maxBitrate": 6000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Nckpktj Mvqte", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 960, "height": 720, "fps": 30, "bitrate": 1200000, "minBitrate": 32000, "maxBitrate": 2000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Gubbdb Tvvy", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 480, "height": 360, "fps": 30, "bitrate": 200000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 200000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": "", "filterPort": 0, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0.575, 0.451 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ], [ 0, 0.53 ], [ 0.487, 0.535 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 4499323888, "txBytes": 145588953564, "wifi": { "channel": 6, "frequency": 2437, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -62 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640308840567, "recordingEnd": 1643055771020, "recordingStartLQ": 1640308840563, "recordingEndLQ": 1643055786085, "timelapseStart": 1640308840533, "timelapseEnd": 1643055521005, "timelapseStartLQ": 1640308840533, "timelapseEndLQ": 1643055146038 }, "storage": { "used": 747324309504, "rate": 342.122831820542 }, "wifiQuality": 100, "wifiStrength": -62 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasAec": true, "hasBattery": false, "hasBluetooth": true, "hasChime": true, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": true, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": true, "hasWifi": true, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 45 ], "hasMotionZones": true, "hasLcdScreen": true, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": { "type": "CUSTOM_MESSAGE", "text": "Use Other Door | 25\u00b0F", "resetAt": null }, "wifiConnectionState": { "channel": 6, "frequency": 2437, "phyRate": 43, "signalQuality": 100, "signalStrength": -62, "ssid": "Mortis Camera" }, "lenses": [], "id": "e2ff0ade6be0f2a2beb61869", "isConnected": true, "platform": "s5l", "hasSpeaker": true, "hasWifi": true, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Doorbell", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "CF0C20B53D5E", "host": "192.168.222.251", "connectionHost": "192.168.102.63", "type": "UVC G4 Pro", "name": "Yzlrc Duo", "upSince": 1641929375904, "uptime": 1126384, "lastSeen": 1643055759904, "connectedSince": 1643053361454, "state": "CONNECTED", "hardwareRevision": "7", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643038873769, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 1000, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "", "eventStats": { "motion": { "today": 1, "average": 6, "lastDays": [ 7, 5, 5, 4, 11, 2, 8 ], "recentHours": [ 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": 1000 }, "channels": [ { "id": 0, "videoId": "video1", "name": "Byrhmtr Oklylbv", "enabled": true, "isRtspEnabled": true, "rtspAlias": "NSRPJJY4ePQUVZfZ", "width": 3840, "height": 2160, "fps": 24, "bitrate": 16000000, "minBitrate": 32000, "maxBitrate": 16000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Abxb Nmhhzd", "enabled": true, "isRtspEnabled": true, "rtspAlias": "VEFUSSOvpL1KVRSj", "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 32000, "maxBitrate": 4000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Mkoo Jbzf", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 300000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 2, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": false, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0.138 ], [ 0.466, 0.07 ], [ 0.601, 0.148 ], [ 1, 0.456 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 18763321757, "txBytes": 700072880391, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852977, "recordingEnd": 1643055770002, "recordingStartLQ": 1639183852992, "recordingEndLQ": 1643055785014, "timelapseStart": 1639183852941, "timelapseEnd": 1643055599988, "timelapseStartLQ": 1639183852941, "timelapseEndLQ": 1643055105008 }, "storage": { "used": 1603096543232, "rate": 415.108794037914 }, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": true, "canMagicZoom": false, "canOpticalZoom": true, "canTouchFocus": true, "hasAccelerometer": false, "hasAec": false, "hasBattery": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": false, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 50 ], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "f0cd15b8bed9e38899286a8c", "isConnected": true, "platform": "s5l", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Pro", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "D3BA2C5D0032", "host": "192.168.6.142", "connectionHost": "192.168.102.63", "type": "UVC G4 Pro", "name": "Nvs Bwd", "upSince": 1641929376903, "uptime": 1126383, "lastSeen": 1643055759903, "connectedSince": 1643053372503, "state": "CONNECTED", "hardwareRevision": "7", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1642803682789, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 1000, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "36ad9efc-8b8b-4f47-815e-14f65a46a6e3", "eventStats": { "motion": { "today": 0, "average": 1, "lastDays": [ 0, 0, 2, 0, 0, 0, 11 ], "recentHours": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": 1000 }, "channels": [ { "id": 0, "videoId": "video1", "name": "Ncfv Xvxtzgd", "enabled": true, "isRtspEnabled": true, "rtspAlias": "lKaCXTMDTCJQQKum", "width": 3840, "height": 2160, "fps": 24, "bitrate": 16000000, "minBitrate": 32000, "maxBitrate": 16000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Jbsr Fbqsp", "enabled": true, "isRtspEnabled": true, "rtspAlias": "OpbJXC6TNvQNIXYN", "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 32000, "maxBitrate": 4000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Ske Yea", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 300000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 2, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": false, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0.453 ], [ 0.682, 0 ], [ 0.844, 0 ], [ 0.708, 0.431 ], [ 0.468, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 17442683098, "txBytes": 653254998457, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183853280, "recordingEnd": 1643055771104, "recordingStartLQ": 1639183853258, "recordingEndLQ": 1643055786019, "timelapseStart": 1639183853279, "timelapseEnd": 1643055611101, "timelapseStartLQ": 1639183853279, "timelapseEndLQ": 1643055116058 }, "storage": { "used": 1471026298880, "rate": 335.261879469184 }, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": true, "canMagicZoom": false, "canOpticalZoom": true, "canTouchFocus": true, "hasAccelerometer": false, "hasAec": false, "hasBattery": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": false, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 50 ], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "ab3e27f2d55fad817dac7bb9", "isConnected": true, "platform": "s5l", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Pro", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "25546150ED3E", "host": "192.168.3.158", "connectionHost": "192.168.102.63", "type": "UVC G4 Pro", "name": "Zycyj Vxvp", "upSince": 1641929377903, "uptime": 1126382, "lastSeen": 1643055759903, "connectedSince": 1643053361126, "state": "CONNECTED", "hardwareRevision": "7", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": true, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643008086650, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 1000, "hdrMode": false, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "c370d035-8003-4b70-85e9-ba5f460cac09", "eventStats": { "motion": { "today": 1, "average": 5, "lastDays": [ 0, 6, 2, 1, 1, 6, 20 ], "recentHours": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": 1000 }, "channels": [ { "id": 0, "videoId": "video1", "name": "Txsfih Lvjpxxn", "enabled": true, "isRtspEnabled": true, "rtspAlias": "gXCHWI1VITVZ9PwY", "width": 3840, "height": 2160, "fps": 24, "bitrate": 16000000, "minBitrate": 32000, "maxBitrate": 16000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Rcidfx Iedpmai", "enabled": true, "isRtspEnabled": true, "rtspAlias": "PWK9O5JVX0VnVGBg", "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 32000, "maxBitrate": 4000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Jtrh Mij", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 300000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 3, "icrSensitivity": 1, "brightness": 50, "contrast": 54, "hue": 52, "saturation": 51, "sharpness": 50, "denoise": 54, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "touch", "focusPosition": 0, "touchFocusX": 865, "touchFocusY": 213, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": false, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "adaptive", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0.201, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0.414, 1 ], [ 0.293, 0.573 ], [ 0.444, 0.478 ], [ 0.432, 0.363 ], [ 0.277, 0.456 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 27521967465, "txBytes": 1497713862841, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852998, "recordingEnd": 1643055750205, "recordingStartLQ": 1639183853004, "recordingEndLQ": 1643055780152, "timelapseStart": 1639183852995, "timelapseEnd": 1643055600214, "timelapseStartLQ": 1639183852995, "timelapseEndLQ": 1643055100356 }, "storage": { "used": 3005403365376, "rate": 832.407819806798 }, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": true, "canMagicZoom": false, "canOpticalZoom": true, "canTouchFocus": true, "hasAccelerometer": false, "hasAec": false, "hasBattery": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": false, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 50 ], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "4a333d993fe8e2e8472bc901", "isConnected": true, "platform": "s5l", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Pro", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "3F9EDAD1F6C4", "host": "192.168.29.88", "connectionHost": "192.168.102.63", "type": "UVC G4 Pro", "name": "Fxdmvtc Gdo", "upSince": 1641929376904, "uptime": 1126383, "lastSeen": 1643055759904, "connectedSince": 1642537084909, "state": "CONNECTED", "hardwareRevision": "10", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643055447656, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 1000, "hdrMode": false, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "242b87f0-f0e1-402e-a48a-2a0b5a16d850", "eventStats": { "motion": { "today": 88, "average": 160, "lastDays": [ 156, 105, 202, 171, 131, 173, 187 ], "recentHours": [ 3, 10, 14, 6, 9, 0, 9, 6, 9, 9, 6, 2, 2 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": 1000 }, "channels": [ { "id": 0, "videoId": "video1", "name": "Lwp Issyqxp", "enabled": true, "isRtspEnabled": true, "rtspAlias": "ZAOBJFOXCUNAPQEz", "width": 3840, "height": 2160, "fps": 24, "bitrate": 16000000, "minBitrate": 32000, "maxBitrate": 16000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Xedhihw Ryokoqa", "enabled": true, "isRtspEnabled": true, "rtspAlias": "honlCJQSGucYfRfX", "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 32000, "maxBitrate": 4000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Ldgqgxy Ouduc", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 300000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "autoFilterOnly", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 52, "sharpness": 51, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "touch", "focusPosition": 0, "touchFocusX": 437, "touchFocusY": 0, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": false, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0.318 ], [ 0.21, 0.158 ], [ 0.435, 0.055 ], [ 0.638, 0.07 ], [ 1, 0.203 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 28446700467, "txBytes": 1583875810916, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852906, "recordingEnd": 1643055755100, "recordingStartLQ": 1639183852911, "recordingEndLQ": 1643055784968, "timelapseStart": 1639183852888, "timelapseEnd": 1643055575165, "timelapseStartLQ": 1639183852888, "timelapseEndLQ": 1643055285277 }, "storage": { "used": 5012226834432, "rate": 1753.16626544334 }, "wifiQuality": 50, "wifiStrength": 0 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": true, "canMagicZoom": false, "canOpticalZoom": true, "canTouchFocus": true, "hasAccelerometer": false, "hasAec": false, "hasBattery": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": false, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default", "highFps" ], "videoModeMaxFps": [ 24, 50 ], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": null, "signalStrength": null, "ssid": null }, "lenses": [], "id": "c462c07dbd63ad805a7318c7", "isConnected": true, "platform": "s5l", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Pro", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null }, { "isDeleting": false, "mac": "2BE358A7CE14", "host": "192.168.186.25", "connectionHost": "192.168.102.63", "type": "UVC G4 Instant", "name": "Eohx Mrkciz", "upSince": 1642456952904, "uptime": 598807, "lastSeen": 1643055759904, "connectedSince": 1642456986161, "state": "CONNECTED", "hardwareRevision": "11", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1757", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643055573501, "micVolume": 100, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 200, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 0, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "857fa0fd-4a74-4212-8b75-f9ce40589250", "eventStats": { "motion": { "today": 43, "average": 51, "lastDays": [ 75, 39, 36, 43, 43, 90, 35 ], "recentHours": [ 9, 0, 8, 2, 8, 1, 6, 1, 0, 0, 1, 0, 0 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": null, "isPoorNetwork": false, "wiredConnectionState": { "phyRate": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "Ajkq Ygq", "enabled": true, "isRtspEnabled": true, "rtspAlias": "ybEZtAOEnPBFDHBN", "width": 2688, "height": 1512, "fps": 30, "bitrate": 10000000, "minBitrate": 32000, "maxBitrate": 10000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 1, "videoId": "video2", "name": "Etjo Bfa", "enabled": true, "isRtspEnabled": true, "rtspAlias": "66XxksASK5XGRKEM", "width": 1280, "height": 720, "fps": 30, "bitrate": 1500000, "minBitrate": 32000, "maxBitrate": 2000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 2, "videoId": "video3", "name": "Frmeln Grau", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 30, "bitrate": 200000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 200000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": true, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": "wall" }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": "", "filterPort": 0, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": false, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 100 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "detections", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50, "objectTypes": [] } ], "smartDetectLines": [], "stats": { "rxBytes": 8155758338, "txBytes": 346703002345, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -45 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639219284079, "recordingEnd": 1643055573528, "recordingStartLQ": 1639219283987, "recordingEndLQ": 1643055574508, "timelapseStart": 1639219284030, "timelapseEnd": 1643055513560, "timelapseStartLQ": 1639219284030, "timelapseEndLQ": 1643055533528 }, "storage": { "used": 39728447488, "rate": 13.3032332410829 }, "wifiQuality": 100, "wifiStrength": -45 }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": true, "hasAec": true, "hasBattery": false, "hasBluetooth": true, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": false, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": true, "hasWifi": true, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default" ], "videoModeMaxFps": [], "hasMotionZones": true, "hasLcdScreen": false, "mountPositions": [], "smartDetectTypes": [], "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "privacyMaskCapability": { "maxMasks": 4, "rectangleOnly": true }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": false }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": {}, "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -45, "ssid": "Mortis Camera" }, "lenses": [], "id": "1ee3895eb4ef2170046f9f2c", "isConnected": true, "platform": "sav530q", "hasSpeaker": true, "hasWifi": true, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Instant", "isPairedWithAiPort": false, "modelKey": "camera", "activePatrolSlot": null } ], "users": [ { "permissions": [], "lastLoginIp": null, "lastLoginTime": null, "isOwner": true, "enableNotifications": false, "settings": { "flags": {} }, "groups": [ "b061186823695fb901973177" ], "alertRules": [], "notificationsV2": { "state": "custom", "motionNotifications": { "trigger": { "when": "inherit", "location": "away", "schedules": [] }, "cameras": [ { "inheritFromParent": true, "motion": [], "person": [], "vehicle": [], "camera": "61b3f5c7033ea703e7000424", "trigger": { "when": "always", "location": "away", "schedules": [] } } ], "doorbells": [], "lights": [], "doorlocks": [], "sensors": [] }, "systemNotifications": {} }, "featureFlags": { "notificationsV2": true }, "id": "fe4c12ae2c1348edb7854e2f", "hasAcceptedInvite": true, "allPermissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "nvr:write,delete:*", "group:create,read,write,delete:*", "user:create,read,write,delete:*", "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", "viewer:create,read,write,delete:*", "display:create,read,write,delete:*", "chime:create,read,write,delete:*" ], "cloudAccount": { "firstName": "Qpvfly", "lastName": "Ikjzilt", "email": "QhoFvCv@example.com", "profileImg": null, "user": "fe4c12ae2c1348edb7854e2f", "id": "9efc4511-4539-4402-9581-51cee8b65cf5", "cloudId": "9efc4511-4539-4402-9581-51cee8b65cf5", "name": "Qpvfly Ikjzilt", "modelKey": "cloudIdentity" }, "name": "Qpvfly Ikjzilt", "firstName": "Qpvfly", "lastName": "Ikjzilt", "email": "QhoFvCv@example.com", "localUsername": "QhoFvCv", "modelKey": "user" }, { "permissions": [], "lastLoginIp": null, "lastLoginTime": null, "isOwner": false, "enableNotifications": false, "settings": null, "groups": [ "a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177" ], "alertRules": [], "notificationsV2": { "state": "auto", "motionNotifications": { "trigger": { "when": "inherit", "location": "away", "schedules": [] }, "cameras": [], "doorbells": [], "lights": [], "doorlocks": [], "sensors": [] }, "systemNotifications": {} }, "featureFlags": { "notificationsV2": true }, "id": "dcaef9cb8aed05c7db658a46", "hasAcceptedInvite": false, "allPermissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "bridge:read:*", "camera:read,readmedia,readlive:*", "doorlock:read:*", "light:read:*", "sensor:read:*", "viewer:read:*", "display:read:*", "chime:read:*", "nvr:read:*", "liveview:create", "user:read,write,delete:$", "nvr:write,delete:*", "group:create,read,write,delete:*", "user:create,read,write,delete:*", "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", "viewer:create,read,write,delete:*", "display:create,read,write,delete:*", "chime:create,read,write,delete:*" ], "cloudAccount": null, "name": "Uxqg Wcbz", "firstName": "Uxqg", "lastName": "Wcbz", "email": "epHDEhE@example.com", "localUsername": "epHDEhE", "modelKey": "user" }, { "permissions": [ "liveview:*:d65bb41c14d6aa92bfa4a6d1", "liveview:*:49bbb5005424a0d35152671a", "liveview:*:b28c38f1220f6b43f3930dff", "liveview:*:b9861b533a87ea639fa4d438" ], "lastLoginIp": null, "lastLoginTime": null, "isOwner": false, "enableNotifications": false, "settings": { "flags": {}, "web": { "dewarp": { "61ddb66b018e2703e7008c19": { "dewarp": false, "state": { "pan": 0, "tilt": -1.5707963267948966, "zoom": 1.5707963267948966, "panning": 0, "tilting": 0 } } }, "liveview.includeGlobal": true, "elements.events_viewmode": "grid", "elements.viewmode": "list" } }, "groups": [ "b061186823695fb901973177" ], "location": { "isAway": true, "latitude": null, "longitude": null }, "alertRules": [], "notificationsV2": { "state": "custom", "motionNotifications": { "trigger": { "when": "inherit", "location": "away", "schedules": [] }, "cameras": [ { "inheritFromParent": true, "motion": [], "camera": "61b3f5c703d2a703e7000427", "trigger": { "when": "always", "location": "away", "schedules": [] } }, { "inheritFromParent": true, "motion": [], "person": [], "vehicle": [], "camera": "61b3f5c7033ea703e7000424", "trigger": { "when": "always", "location": "away", "schedules": [] } } ], "doorbells": [], "lights": [], "doorlocks": [], "sensors": [] }, "systemNotifications": {} }, "featureFlags": { "notificationsV2": true }, "id": "4c5f03a8c8bd48ad8e066285", "hasAcceptedInvite": false, "allPermissions": [ "liveview:*:d65bb41c14d6aa92bfa4a6d1", "liveview:*:49bbb5005424a0d35152671a", "liveview:*:b28c38f1220f6b43f3930dff", "liveview:*:b9861b533a87ea639fa4d438", "nvr:read:*", "liveview:create", "user:read,write,delete:$", "nvr:write,delete:*", "group:create,read,write,delete:*", "user:create,read,write,delete:*", "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", "viewer:create,read,write,delete:*", "display:create,read,write,delete:*", "chime:create,read,write,delete:*" ], "cloudAccount": null, "name": "Ptcmsdo Tfiyoep", "firstName": "Ptcmsdo", "lastName": "Tfiyoep", "email": "EQAoXL@example.com", "localUsername": "EQAoXL", "modelKey": "user" }, { "permissions": [], "lastLoginIp": null, "lastLoginTime": null, "isOwner": false, "enableNotifications": false, "settings": { "flags": {}, "web": { "dewarp": { "61c4d1db02c82a03e700429c": { "dewarp": false, "state": { "pan": 0, "tilt": 0, "zoom": 1.5707963267948966, "panning": 0, "tilting": 0 } } }, "liveview.includeGlobal": true } }, "groups": [ "a7f3b2eb71b4c4e56f1f45ac" ], "alertRules": [], "notificationsV2": { "state": "auto", "motionNotifications": { "trigger": { "when": "inherit", "location": "away", "schedules": [] }, "cameras": [], "doorbells": [], "lights": [], "doorlocks": [], "sensors": [] }, "systemNotifications": {} }, "featureFlags": { "notificationsV2": true }, "id": "bc3dd633553907952a6fe20d", "hasAcceptedInvite": false, "allPermissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "bridge:read:*", "camera:read,readmedia:*", "doorlock:read:*", "light:read:*", "sensor:read:*", "viewer:read:*", "display:read:*", "chime:read:*" ], "cloudAccount": null, "name": "Evdxou Zgyv", "firstName": "Evdxou", "lastName": "Zgyv", "email": "FMZuD@example.com", "localUsername": "FMZuD", "modelKey": "user" }, { "permissions": [], "lastLoginIp": null, "lastLoginTime": null, "isOwner": false, "enableNotifications": false, "settings": null, "groups": [ "a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177" ], "alertRules": [], "notificationsV2": { "state": "auto", "motionNotifications": { "trigger": { "when": "inherit", "location": "away", "schedules": [] }, "cameras": [], "doorbells": [], "lights": [], "doorlocks": [], "sensors": [] }, "systemNotifications": {} }, "featureFlags": { "notificationsV2": true }, "id": "adec5334b69f56f6a6c47520", "hasAcceptedInvite": false, "allPermissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "bridge:read:*", "camera:read,readmedia:*", "doorlock:read:*", "light:read:*", "sensor:read:*", "viewer:read:*", "display:read:*", "chime:read:*", "nvr:read:*", "liveview:create", "user:read,write,delete:$", "nvr:write,delete:*", "group:create,read,write,delete:*", "user:create,read,write,delete:*", "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", "camera:create,read,write,delete,readmedia,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", "viewer:create,read,write,delete:*", "display:create,read,write,delete:*", "chime:create,read,write,delete:*" ], "cloudAccount": null, "name": "Qpv Elqfgq", "firstName": "Qpv", "lastName": "Elqfgq", "email": "xdr@example.com", "localUsername": "xdr", "modelKey": "user" }, { "permissions": [], "lastLoginIp": null, "lastLoginTime": null, "isOwner": false, "enableNotifications": false, "settings": null, "groups": [ "a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177" ], "alertRules": [], "notificationsV2": { "state": "auto", "motionNotifications": { "trigger": { "when": "inherit", "location": "away", "schedules": [] }, "cameras": [], "doorbells": [], "lights": [], "doorlocks": [], "sensors": [] }, "systemNotifications": {} }, "featureFlags": { "notificationsV2": true }, "id": "8593657a25b7826a4288b6af", "hasAcceptedInvite": false, "allPermissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "bridge:read:*", "camera:read,readmedia:*", "doorlock:read:*", "light:read:*", "sensor:read:*", "viewer:read:*", "display:read:*", "chime:read:*", "nvr:read:*", "liveview:create", "user:read,write,delete:$", "nvr:write,delete:*", "group:create,read,write,delete:*", "user:create,read,write,delete:*", "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", "camera:create,read,write,delete,readmedia,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", "viewer:create,read,write,delete:*", "display:create,read,write,delete:*", "chime:create,read,write,delete:*" ], "cloudAccount": null, "name": "Sgpy Ooevsme", "firstName": "Sgpy", "lastName": "Ooevsme", "email": "WQJNT@example.com", "localUsername": "WQJNT", "modelKey": "user" }, { "permissions": [], "isOwner": false, "enableNotifications": false, "groups": [ "a7f3b2eb71b4c4e56f1f45ac" ], "alertRules": [], "notificationsV2": { "state": "off", "motionNotifications": { "trigger": { "when": "inherit", "location": "away", "schedules": [] }, "cameras": [], "doorbells": [], "lights": [], "doorlocks": [], "sensors": [] }, "systemNotifications": {} }, "featureFlags": { "notificationsV2": true }, "id": "abf647aed3650a781ceba13f", "hasAcceptedInvite": false, "allPermissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "bridge:read:*", "camera:read,readmedia:*", "doorlock:read:*", "light:read:*", "sensor:read:*", "viewer:read:*", "display:read:*", "chime:read:*" ], "cloudAccount": null, "name": "Yiiyq Glx", "firstName": "Yiiyq", "lastName": "Glx", "email": "fBjmm@example.com", "localUsername": "fBjmm", "modelKey": "user" } ], "groups": [ { "name": "Kubw Xnbb", "permissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "nvr:write,delete:*", "group:create,read,write,delete:*", "user:create,read,write,delete:*", "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", "camera:create,read,write,delete,readmedia,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", "viewer:create,read,write,delete:*", "display:create,read,write,delete:*", "chime:create,read,write,delete:*" ], "type": "preset", "isDefault": true, "id": "b061186823695fb901973177", "modelKey": "group" }, { "name": "Pmbrvp Wyzqs", "permissions": [ "nvr:read:*", "liveview:create", "user:read,write,delete:$", "bridge:read:*", "camera:read,readmedia:*", "doorlock:read:*", "light:read:*", "sensor:read:*", "viewer:read:*", "display:read:*", "chime:read:*" ], "type": "preset", "isDefault": false, "id": "a7f3b2eb71b4c4e56f1f45ac", "modelKey": "group" } ], "liveviews": [ { "name": "Default", "isDefault": true, "isGlobal": true, "layout": 12, "slots": [ { "cameras": [ "4a333d993fe8e2e8472bc901" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "1ee3895eb4ef2170046f9f2c" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "0777b5d342302079dc6b793d" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "c462c07dbd63ad805a7318c7" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "56401f80d300adad7123c864" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "76227b20c37b2ff0abdc9d4d" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "1c9a2db4df6efda47a3509be" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "586ab7c2bb6423c3fdd47e95" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "e2ff0ade6be0f2a2beb61869" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "ab3e27f2d55fad817dac7bb9" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "f0cd15b8bed9e38899286a8c" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [], "cycleMode": "time", "cycleInterval": 10 } ], "owner": "fe4c12ae2c1348edb7854e2f", "id": "bf41f6b5ba0ddd046eeb1c98", "modelKey": "liveview" }, { "name": "Yisz Ovrhoka", "isDefault": false, "isGlobal": true, "layout": 1, "slots": [ { "cameras": [ "1c9a2db4df6efda47a3509be", "e2ff0ade6be0f2a2beb61869", "c462c07dbd63ad805a7318c7", "4a333d993fe8e2e8472bc901", "ab3e27f2d55fad817dac7bb9", "f0cd15b8bed9e38899286a8c" ], "cycleMode": "motion", "cycleInterval": 10 } ], "owner": "4c5f03a8c8bd48ad8e066285", "id": "d65bb41c14d6aa92bfa4a6d1", "modelKey": "liveview" }, { "name": "Ozbz Qle", "isDefault": false, "isGlobal": true, "layout": 1, "slots": [ { "cameras": [ "1c9a2db4df6efda47a3509be" ], "cycleMode": "time", "cycleInterval": 10 } ], "owner": "4c5f03a8c8bd48ad8e066285", "id": "b9861b533a87ea639fa4d438", "modelKey": "liveview" }, { "name": "Axehzr Dhxebqg", "isDefault": false, "isGlobal": true, "layout": 4, "slots": [ { "cameras": [ "1c9a2db4df6efda47a3509be" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "e2ff0ade6be0f2a2beb61869" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "c462c07dbd63ad805a7318c7" ], "cycleMode": "time", "cycleInterval": 10 }, { "cameras": [ "586ab7c2bb6423c3fdd47e95" ], "cycleMode": "time", "cycleInterval": 10 } ], "owner": "4c5f03a8c8bd48ad8e066285", "id": "49bbb5005424a0d35152671a", "modelKey": "liveview" }, { "name": "Jbrkb Urqq", "isDefault": false, "isGlobal": true, "layout": 1, "slots": [ { "cameras": [ "1ee3895eb4ef2170046f9f2c" ], "cycleMode": "time", "cycleInterval": 10 } ], "owner": "4c5f03a8c8bd48ad8e066285", "id": "b28c38f1220f6b43f3930dff", "modelKey": "liveview" } ], "schedules": [], "nvr": { "mac": "4B8290F6D7A3", "host": "192.168.102.63", "name": "Uiiji Ryoyo", "canAutoUpdate": true, "isStatsGatheringEnabled": true, "timezone": "America/New_York", "version": "1.21.0-beta.3", "ucoreVersion": "2.3.26", "firmwareVersion": "2.3.10", "uiVersion": null, "hardwarePlatform": "al324", "ports": { "ump": 7449, "http": 7080, "https": 7443, "rtsp": 7447, "rtsps": 7441, "rtmp": 1935, "devicesWss": 7442, "cameraHttps": 7444, "cameraTcp": 7877, "liveWs": 7445, "liveWss": 7446, "tcpStreams": 7448, "playback": 7450, "emsCLI": 7440, "emsLiveFLV": 7550, "cameraEvents": 7551, "tcpBridge": 7888, "ucore": 11081, "discoveryClient": 0 }, "uptime": 681778000, "lastSeen": 1643055784651, "isUpdating": false, "lastUpdateAt": null, "isStation": false, "enableAutomaticBackups": true, "enableStatsReporting": false, "isSshEnabled": false, "errorCode": null, "releaseChannel": "beta", "ssoChannel": null, "hosts": [ "192.168.102.63" ], "enableBridgeAutoAdoption": true, "hardwareId": "4133b915-976c-4945-9da1-85a8297dc2e9", "hardwareRevision": "113-03137-22", "hostType": 59936, "hostShortname": "UNVRPRO", "isHardware": true, "isWirelessUplinkEnabled": false, "timeFormat": "24h", "temperatureUnit": "C", "recordingRetentionDurationMs": null, "enableCrashReporting": true, "disableAudio": false, "analyticsData": "anonymous", "anonymousDeviceId": "f7babe76-b29f-4786-ac5f-c4df3943287c", "cameraUtilization": 30, "isRecycling": false, "avgMotions": [ 11.14, 7.29, 6, 6.14, 5.71, 8.14, 17.29, 26.14, 19.86, 35, 33.43, 52.71, 46.71, 41.29, 28.14, 31.43, 33.71, 43.29, 58, 61.43, 39.29, 33.14, 16, 15 ], "disableAutoLink": false, "skipFirmwareUpdate": false, "wifiSettings": { "useThirdPartyWifi": false, "ssid": null, "password": null }, "locationSettings": { "isAway": true, "isGeofencingEnabled": false, "latitude": 41.4519, "longitude": -81.921, "radius": 200 }, "featureFlags": { "beta": false, "dev": false, "notificationsV2": true }, "systemInfo": { "cpu": { "averageLoad": 6, "temperature": 68 }, "memory": { "available": 6388164, "free": 102208, "total": 8163024 }, "storage": { "available": 13846957756416, "isRecycling": false, "size": 31855989432320, "type": "raid", "used": 16409797353472, "devices": [ { "model": "ST16000VE000-2L2103", "size": 16000900661248, "healthy": true }, { "model": "ST16000VE000-2L2103", "size": 16000900661248, "healthy": true }, { "model": "ST16000VE000-2L2103", "size": 16000900661248, "healthy": true } ] }, "tmpfs": { "available": 960028, "total": 1048576, "used": 88548, "path": "/var/opt/unifi-protect/tmp" } }, "doorbellSettings": { "defaultMessageText": "Welcome", "defaultMessageResetTimeoutMs": 60000, "customMessages": [ "Come In!", "Use Other Door" ], "allMessages": [ { "type": "LEAVE_PACKAGE_AT_DOOR", "text": "LEAVE PACKAGE AT DOOR" }, { "type": "DO_NOT_DISTURB", "text": "DO NOT DISTURB" }, { "type": "CUSTOM_MESSAGE", "text": "Come In!" }, { "type": "CUSTOM_MESSAGE", "text": "Use Other Door" } ] }, "smartDetectAgreement": { "status": "agreed", "lastUpdateAt": 1606964227734 }, "storageStats": { "utilization": 51.623801166344975, "capacity": 6304614230, "remainingCapacity": 3049932715, "recordingSpace": { "total": 31787269955584, "used": 16409797038080, "available": 15377472917504 }, "storageDistribution": { "recordingTypeDistributions": [ { "recordingType": "rotating", "size": 15332774950544, "percentage": 93.45392305589237 }, { "recordingType": "timelapse", "size": 24696061952, "percentage": 0.15052356020942406 }, { "recordingType": "detections", "size": 1049304058224, "percentage": 6.395553383898204 } ], "resolutionDistributions": [ { "resolution": "4K", "size": 11108932911104, "percentage": 34.94774142801942 }, { "resolution": "HD", "size": 5297842159616, "percentage": 16.666552890571023 }, { "resolution": "free", "size": 15380494884864, "percentage": 48.385705681409554 } ] } }, "id": "2435ff5ab300d119d704bbe3", "isAway": true, "isSetup": true, "network": "Ethernet", "type": "UNVR-PRO", "upSince": 1642374007037, "isRecordingDisabled": false, "isRecordingMotionOnly": false, "maxCameraCapacity": { "4K": 20, "2K": 30, "HD": 60 }, "modelKey": "nvr" }, "lastUpdateId": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", "viewers": [ { "mac": "85EEA7DF1601", "host": "192.168.27.69", "connectionHost": "192.168.102.63", "type": "UP Viewport", "name": "Aiyhwrk Fyoqval", "upSince": 1642661973905, "uptime": 393786, "lastSeen": 1643055759905, "connectedSince": 1642672730441, "state": "CONNECTED", "hardwareRevision": null, "firmwareVersion": "1.2.54", "latestFirmwareVersion": "1.2.54", "firmwareBuild": "dcfb16f3.210907.625", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "streamLimit": 16, "softwareVersion": "1.2.54", "wiredConnectionState": { "phyRate": 1000 }, "liveview": "bf41f6b5ba0ddd046eeb1c98", "id": "081c58d13ad7e198d3dddffa", "isConnected": true, "marketName": "UP ViewPort", "modelKey": "viewer" } ], "lights": [ { "mac": "534D8B001B14", "host": "192.168.234.163", "connectionHost": "192.168.102.63", "type": "UP FloodLight", "name": "Icxei Irdq", "upSince": 1638128967900, "uptime": 4926792, "lastSeen": 1643055759900, "connectedSince": 1642902624923, "state": "CONNECTED", "hardwareRevision": null, "firmwareVersion": "1.9.3", "latestFirmwareVersion": "1.9.3", "firmwareBuild": "g990c553.211105.251", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": true, "canAdopt": false, "isAttemptingToConnect": false, "isPirMotionDetected": false, "lastMotion": 1643055491157, "isDark": false, "isLightOn": false, "isLocating": false, "wiredConnectionState": { "phyRate": 100 }, "lightDeviceSettings": { "isIndicatorEnabled": false, "ledLevel": 6, "luxSensitivity": "medium", "pirDuration": 120000, "pirSensitivity": 46 }, "lightOnSettings": { "isLedForceOn": false }, "lightModeSettings": { "mode": "off", "enableAt": "fulltime" }, "camera": "1c9a2db4df6efda47a3509be", "id": "3ada785d6626c88d7d52446a", "isConnected": true, "isCameraPaired": true, "marketName": "UP FloodLight", "modelKey": "light" } ], "bridges": [ { "mac": "A28D0DB15AE1", "host": "192.168.231.68", "connectionHost": "192.168.102.63", "type": "UFP-UAP-B", "name": "Sffde Gxcaqe", "upSince": 1639807977891, "uptime": 3247782, "lastSeen": 1643055759891, "connectedSince": 1642374159304, "state": "CONNECTED", "hardwareRevision": 19, "firmwareVersion": "0.3.1", "latestFirmwareVersion": null, "firmwareBuild": null, "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "wiredConnectionState": { "phyRate": null }, "id": "1f5a055254fb9169d7536fb9", "isConnected": true, "platform": "mt7621", "modelKey": "bridge" }, { "mac": "C65C557CCA95", "host": "192.168.87.68", "connectionHost": "192.168.102.63", "type": "UFP-UAP-B", "name": "Axiwj Bbd", "upSince": 1641257260772, "uptime": null, "lastSeen": 1643052750862, "connectedSince": 1643052754695, "state": "CONNECTED", "hardwareRevision": 19, "firmwareVersion": "0.3.1", "latestFirmwareVersion": null, "firmwareBuild": null, "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "wiredConnectionState": { "phyRate": null }, "id": "e6901e3665a4c0eab0d9c1a5", "isConnected": true, "platform": "mt7621", "modelKey": "bridge" } ], "sensors": [ { "mac": "4191A8E35F39", "host": null, "connectionHost": "192.168.102.63", "type": "UFP-SENSE", "name": "Yeahe Anfa", "upSince": 1642991171327, "uptime": null, "lastSeen": 1643054753862, "connectedSince": 1643054778327, "state": "CONNECTED", "hardwareRevision": 6, "firmwareVersion": "1.0.2", "latestFirmwareVersion": "1.0.2", "firmwareBuild": null, "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "isMotionDetected": false, "mountType": "door", "leakDetectedAt": null, "tamperingDetectedAt": null, "isOpened": false, "openStatusChangedAt": 1643055549536, "alarmTriggeredAt": null, "motionDetectedAt": 1643055565002, "wiredConnectionState": { "phyRate": null }, "stats": { "light": { "value": 20, "status": "neutral" }, "humidity": { "value": 29, "status": "neutral" }, "temperature": { "value": 16.95, "status": "neutral" } }, "bluetoothConnectionState": { "signalQuality": 37, "signalStrength": -75 }, "batteryStatus": { "percentage": 80, "isLow": false }, "alarmSettings": { "isEnabled": false }, "lightSettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 10 }, "motionSettings": { "isEnabled": true, "sensitivity": 100 }, "temperatureSettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 0.1 }, "humiditySettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 1 }, "ledSettings": { "isEnabled": true }, "bridge": "61b3f5c90054a703e700042b", "camera": null, "bridgeCandidates": [], "id": "02ee9b99f17d69346e0c8c00", "isConnected": true, "marketName": "UP Sense", "modelKey": "sensor" }, { "mac": "3CDB35674BB5", "host": null, "connectionHost": "192.168.102.63", "type": "UFP-SENSE", "name": "Xvub Rzo", "upSince": 1641931605865, "uptime": null, "lastSeen": 1643052750836, "connectedSince": 1643052765865, "state": "CONNECTED", "hardwareRevision": 6, "firmwareVersion": "1.0.2", "latestFirmwareVersion": "1.0.2", "firmwareBuild": null, "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "isMotionDetected": false, "mountType": "none", "leakDetectedAt": null, "tamperingDetectedAt": null, "isOpened": false, "openStatusChangedAt": null, "alarmTriggeredAt": 1641931691625, "motionDetectedAt": 1642978103125, "wiredConnectionState": { "phyRate": null }, "stats": { "light": { "value": null, "status": "unknown" }, "humidity": { "value": 14, "status": "neutral" }, "temperature": { "value": 28.58, "status": "neutral" } }, "bluetoothConnectionState": { "signalQuality": 100, "signalStrength": -42 }, "batteryStatus": { "percentage": 73, "isLow": false }, "alarmSettings": { "isEnabled": false }, "lightSettings": { "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 10 }, "motionSettings": { "isEnabled": true, "sensitivity": 100 }, "temperatureSettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 0.1 }, "humiditySettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 1 }, "ledSettings": { "isEnabled": true }, "bridge": "61b3f5c90050a703e700042a", "camera": null, "bridgeCandidates": [], "id": "d3cdb13d7b66b7d67763cd2e", "isConnected": true, "marketName": "UP Sense", "modelKey": "sensor" }, { "mac": "CB9494FA353D", "host": null, "connectionHost": "192.168.102.63", "type": "UFP-SENSE", "name": "Vmhrp Zdebrsu", "upSince": 1642635613872, "uptime": null, "lastSeen": 1643052750853, "connectedSince": 1643052765872, "state": "CONNECTED", "hardwareRevision": 6, "firmwareVersion": "1.0.2", "latestFirmwareVersion": "1.0.2", "firmwareBuild": null, "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "isMotionDetected": false, "mountType": "window", "leakDetectedAt": null, "tamperingDetectedAt": null, "isOpened": false, "openStatusChangedAt": 1643055451596, "alarmTriggeredAt": null, "motionDetectedAt": null, "wiredConnectionState": { "phyRate": null }, "stats": { "light": { "value": null, "status": "unknown" }, "humidity": { "value": null, "status": "unknown" }, "temperature": { "value": 15.3, "status": "neutral" } }, "bluetoothConnectionState": { "signalQuality": 47, "signalStrength": -71 }, "batteryStatus": { "percentage": 86, "isLow": false }, "alarmSettings": { "isEnabled": false }, "lightSettings": { "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 10 }, "motionSettings": { "isEnabled": false, "sensitivity": 100 }, "temperatureSettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 0.1 }, "humiditySettings": { "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 1 }, "ledSettings": { "isEnabled": true }, "bridge": "61b3f5c90050a703e700042a", "camera": null, "bridgeCandidates": [], "id": "20b1e28b9a5fbdf1060bf7d8", "isConnected": true, "marketName": "UP Sense", "modelKey": "sensor" }, { "mac": "0063ADA22061", "host": null, "connectionHost": "192.168.102.63", "type": "UFP-SENSE", "name": "Mhfu Txvn", "upSince": 1642704127861, "uptime": null, "lastSeen": 1643052750856, "connectedSince": 1643052765861, "state": "CONNECTED", "hardwareRevision": 6, "firmwareVersion": "1.0.2", "latestFirmwareVersion": "1.0.2", "firmwareBuild": null, "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "isMotionDetected": false, "mountType": "garage", "leakDetectedAt": null, "tamperingDetectedAt": null, "isOpened": false, "openStatusChangedAt": 1643055553059, "alarmTriggeredAt": null, "motionDetectedAt": 1643055561549, "wiredConnectionState": { "phyRate": null }, "stats": { "light": { "value": 13, "status": "neutral" }, "humidity": { "value": 60, "status": "neutral" }, "temperature": { "value": -0.04, "status": "neutral" } }, "bluetoothConnectionState": { "signalQuality": 70, "signalStrength": -62 }, "batteryStatus": { "percentage": 74, "isLow": false }, "alarmSettings": { "isEnabled": false }, "lightSettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 10 }, "motionSettings": { "isEnabled": true, "sensitivity": 100 }, "temperatureSettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 0.1 }, "humiditySettings": { "isEnabled": true, "lowThreshold": null, "highThreshold": null, "margin": 1 }, "ledSettings": { "isEnabled": true }, "bridge": "61b3f5c90050a703e700042a", "camera": "586ab7c2bb6423c3fdd47e95", "bridgeCandidates": [], "id": "5c1b116c29e4e55a70f39736", "isConnected": true, "marketName": "UP Sense", "modelKey": "sensor" } ], "chimes": [ { "mac": "BEEEE2FBE413", "host": "192.168.144.146", "connectionHost": "192.168.234.27", "type": "UP Chime", "name": "Xaorvu Tvsv", "upSince": 1651882870009, "uptime": 567870, "lastSeen": 1652450740009, "connectedSince": 1652448904587, "state": "CONNECTED", "hardwareRevision": null, "firmwareVersion": "1.3.4", "latestFirmwareVersion": "1.3.4", "firmwareBuild": "58bd350.220401.1859", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": true, "canAdopt": false, "isAttemptingToConnect": false, "volume": 100, "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "lastRing": 1652116059940, "isWirelessUplinkEnabled": true, "wiredConnectionState": { "phyRate": null }, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "signalQuality": 100, "signalStrength": -44, "ssid": null }, "cameraIds": [], "id": "cf1a330397c08f919d02bd7c", "isConnected": true, "marketName": "UP Chime", "modelKey": "chime" } ], "ringtones": [ { "id": "66a14fa502d44203e40003eb", "name": "Default", "size": 208, "isDefault": true, "nvrMac": "4B8290F6D7A3", "modelKey": "ringtone" }, { "id": "66a14fa502da4203e40003ec", "name": "custometest", "size": 180, "isDefault": false, "nvrMac": "4B8290F6D7A3", "modelKey": "ringtone" } ], "aiports": [ { "isDeleting": false, "mac": "942A6FERF45E", "host": "192.168.144.142", "connectionHost": "192.168.144.141", "type": "UVC AI Port", "name": "AI Port", "upSince": 1734358286292, "uptime": 5711, "lastSeen": 1734363997292, "connectedSince": 1734358304453, "state": "CONNECTED", "lastDisconnect": 1734358270257, "hardwareRevision": "9", "firmwareVersion": "4.73.7", "latestFirmwareVersion": null, "firmwareBuild": "02c7590.241202.29", "isUpdating": false, "isDownloadingFW": false, "fwUpdateState": "upToDate", "isAdopting": false, "isRestoring": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "uplinkDevice": null, "guid": "d856a941-980d-4002-822c-6cc419266f9a", "anonymousDeviceId": "08752e43-0d7f-4340-b2fe-9d2a5e89ba2b", "lastMotion": null, "micVolume": 100, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": null, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "apMgmtIp": null, "elementInfo": null, "chimeDuration": 0, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": null, "isLiveHeatmapEnabled": false, "eventStats": { "motion": { "today": 2, "average": 0, "lastDays": [], "recentHours": [ null ] }, "smart": { "today": 0, "average": 0, "lastDays": [] } }, "videoReconfigurationInProgress": false, "voltage": null, "useGlobal": false, "isPoorNetwork": false, "stopStreamLevel": null, "isWaterproofCaseAttached": false, "userConfiguredAp": false, "hasRecordings": false, "isThirdPartyCamera": false, "isPairedWithAiPort": false, "wiredConnectionState": { "phyRate": null }, "wifiConnectionState": { "channel": null, "frequency": null, "phyRate": null, "txRate": null, "signalQuality": null, "ssid": null, "bssid": null, "apName": null, "experience": null, "signalStrength": null, "connectivity": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "High", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 3840, "height": 2160, "fps": 24, "bitrate": 8000000, "minBitrate": 2000000, "maxBitrate": 8000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 2000000, "fpsValues": [ 1, 3, 5, 8, 10, 12, 15, 16, 18, 20, 22, 24 ], "idrInterval": 5, "autoFps": true, "autoBitrate": true }, { "id": 1, "videoId": "video2", "name": "Medium", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 1280, "height": 720, "fps": 24, "bitrate": 2000000, "minBitrate": 750000, "maxBitrate": 8000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 3, 5, 8, 10, 12, 15, 16, 18, 20, 22, 24 ], "idrInterval": 5, "autoFps": true, "autoBitrate": true }, { "id": 2, "videoId": "video3", "name": "Low", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 640, "height": 360, "fps": 24, "bitrate": 900000, "minBitrate": 300000, "maxBitrate": 8000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 300000, "fpsValues": [ 1, 3, 5, 8, 10, 12, 15, 16, 18, 20, 22, 24 ], "idrInterval": 5, "autoFps": true, "autoBitrate": true } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "icrSwitchMode": "sensitivity", "icrCustomValue": 2, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isColorNightVisionEnabled": false, "spotlightDuration": 15, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null, "hdrMode": "normal" }, "audioSettings": { "style": [ "nature" ] }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": null, "filterPort": null, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": false, "isDateEnabled": false, "isLogoEnabled": true, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 2, "postPaddingSecs": 2, "smartDetectPrePaddingSecs": 2, "smartDetectPostPaddingSecs": 2, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "inScheduleMode": "always", "outScheduleMode": "never", "geofencing": "off", "retentionDurationMs": null, "motionAlgorithm": "enhanced", "enableMotionDetection": true, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle", "package", "animal", "face", "licensePlate" ], "autoTrackingObjectTypes": [], "audioTypes": [ "alrmSmoke", "alrmCmonx", "alrmSiren", "alrmBabyCry", "alrmSpeak", "alrmBurglar", "alrmCarHorn", "alrmBark", "alrmGlassBreak" ] }, "recordingSchedulesV2": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0.249, 0 ], [ 0.517, 0 ], [ 0.525, 0.62 ], [ 0.7, 0.622 ], [ 1, 0.689 ], [ 1, 1 ], [ 0, 1 ], [ 0.205, 0.611 ] ], "sensitivity": 50, "isTriggerLightEnabled": true } ], "privacyZones": [], "smartDetectZones": [ { "id": 2, "name": "Default", "color": "#AB46BC", "points": [ [ 0.229, 0 ], [ 0.524, 0 ], [ 0.526, 0.602 ], [ 0.692, 0.6 ], [ 1, 0.667 ], [ 1, 1 ], [ 0, 1 ], [ 0.201, 0.62 ] ], "sensitivity": 50, "objectTypes": [ "animal", "face", "vehicle", "person" ], "isTriggerLightEnabled": true, "source": "unifi-protect", "triggerAccessTypes": [], "enableAccessLPOnlyMode": false } ], "smartDetectLines": [ { "id": 3, "name": "New Crossing Line", "color": "#46C7FD", "points": [ [ 0.69, 0.609 ], [ 0.53, 0.609 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle", "animal" ], "isTriggerLightEnabled": false, "direction": "BA", "isTargetCounting": false, "plan": { "autoReset": "none", "time": null, "weekDay": null, "date": null } }, { "id": 4, "name": "New Crossing Line", "color": "#61E066", "points": [ [ 1, 0.685 ], [ 0.69, 0.609 ] ], "sensitivity": 50, "objectTypes": [ "person", "vehicle" ], "isTriggerLightEnabled": false, "direction": "BA", "isTargetCounting": false, "plan": { "autoReset": "none", "time": null, "weekDay": null, "date": null } } ], "stats": { "rxBytes": 0, "txBytes": 0, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "video": { "recordingStart": null, "recordingEnd": null, "recordingStartLQ": null, "recordingEndLQ": null, "timelapseStart": null, "timelapseEnd": null, "timelapseStartLQ": null, "timelapseEndLQ": null }, "wifiQuality": 50, "wifiStrength": 0, "storage": {} }, "featureFlags": { "supportFullHdSnapshot": true, "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasVerticalFlip": true, "hasAec": false, "hasBluetooth": false, "hasChime": false, "hasExternalIr": false, "hasIcrSensitivity": true, "hasInfrared": true, "hasLdc": false, "hasLedIr": false, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": false, "hasWifi": false, "hasHdr": false, "hasAutoICROnly": false, "videoModes": [ "default" ], "videoModeMaxFps": [ 30 ], "hasMotionZones": true, "hasLcdScreen": false, "hasFingerprintSensor": false, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle", "animal", "face", "licensePlate", "package" ], "smartDetectAudioTypes": [ "smoke_cmonx", "alrmSmoke", "alrmCmonx", "alrmSiren", "alrmBabyCry", "alrmSpeak", "alrmBurglar", "alrmCarHorn", "alrmBark", "alrmGlassBreak" ], "supportNfc": false, "lensType": null, "lensModel": null, "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": false, "audio": [], "audioCodecs": [ "aac" ], "audioStyle": [], "isDoorbell": false, "isPtz": false, "hasColorLcdScreen": false, "hasLiveviewTracking": true, "hasLineCrossing": true, "hasLineCrossingCounting": true, "hasFlash": false, "flashRange": null, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 1, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hotplug": { "audio": null, "video": null, "standaloneAdoption": false, "extender": { "isAttached": false, "hasFlash": null, "flashRange": null, "hasIR": null, "hasRadar": false } }, "hasSmartDetect": true }, "lcdMessage": {}, "lenses": [], "streamSharing": { "enabled": false, "token": null, "shareLink": null, "expires": null, "sharedByUserId": null, "sharedByUser": null, "maxStreams": null }, "homekitSettings": { "talkbackSettingsActive": false, "streamInProgress": false, "microphoneMuted": false, "speakerMuted": false }, "pairedCameras": [ "0777b5d342302079dc6b793d" ], "id": "675erf3d000c9787e7415re2", "nvrMac": "4B8290F6D7A3", "isConnected": true, "platform": "all", "hasSpeaker": false, "hasWifi": false, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "AI Port", "is4K": false, "is2K": false, "modelKey": "aiport", "activePatrolSlot": null } ] } uilibs-uiprotect-317a52c/tests/sample_data/sample_bridge.json000066400000000000000000000015051524637705700244710ustar00rootroot00000000000000{ "mac": "A28D0DB15AE1", "host": "192.168.231.68", "connectionHost": "192.168.102.63", "type": "UFP-UAP-B", "name": "Vdr Fzr", "upSince": 1639807977891, "uptime": 3247782, "lastSeen": 1643055759891, "connectedSince": 1642374159304, "state": "CONNECTED", "hardwareRevision": 19, "firmwareVersion": "0.3.1", "latestFirmwareVersion": null, "firmwareBuild": null, "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "wiredConnectionState": { "phyRate": null }, "id": "1f5a055254fb9169d7536fb9", "isConnected": true, "platform": "mt7621", "modelKey": "bridge" } uilibs-uiprotect-317a52c/tests/sample_data/sample_camera.json000066400000000000000000000346121524637705700244720ustar00rootroot00000000000000{ "isDeleting": false, "mac": "331AFA68BE00", "host": "192.168.10.22", "connectionHost": "192.168.102.63", "type": "UVC G4 Doorbell Pro", "name": "Oxnjk Ajfq", "upSince": 1642431757906, "uptime": 624002, "lastSeen": 1643055759906, "connectedSince": 1642431796280, "state": "CONNECTED", "hardwareRevision": "20", "firmwareVersion": "4.48.16", "latestFirmwareVersion": "4.48.16", "firmwareBuild": "dd71d61.211230.1810", "isUpdating": false, "isAdopting": false, "isAdopted": true, "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, "isSshEnabled": true, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1643055816121, "micVolume": 100, "isMicEnabled": true, "isRecording": true, "isWirelessUplinkEnabled": true, "isMotionDetected": true, "isSmartDetected": true, "phyRate": 200, "hdrMode": true, "videoMode": "default", "isProbingForWifi": false, "apMac": null, "apRssi": null, "elementInfo": null, "chimeDuration": 300, "isDark": false, "lastPrivacyZonePositionId": null, "lastRing": 1643055803577, "isLiveHeatmapEnabled": false, "useGlobal": false, "anonymousDeviceId": "783670a8-fd5d-45f7-9e4a-5af42ba89c25", "lastDisconnect": 1669917368811, "eventStats": { "motion": { "today": 44, "average": 69, "lastDays": [ 57, 59, 126, 59, 65, 64, 53 ], "recentHours": [ 5, 2, 6, 5, 1, 0, 6, 4, 1, 5, 5, 0, 3 ] }, "smart": { "today": 0, "average": 0, "lastDays": [ 0, 0, 0, 0, 0, 0, 0 ] } }, "videoReconfigurationInProgress": false, "voltage": 27.3, "isPoorNetwork": false, "isThirdPartyCamera": false, "wiredConnectionState": { "phyRate": null }, "channels": [ { "id": 0, "videoId": "video1", "name": "Ppkqf Wamffz", "enabled": true, "isRtspEnabled": true, "rtspAlias": "bVFsZBWTKrCRgZKO", "width": 1600, "height": 1200, "fps": 30, "bitrate": 6000000, "minBitrate": 32000, "maxBitrate": 6000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 1, "videoId": "video3", "name": "Woi Uoyb", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 960, "height": 720, "fps": 30, "bitrate": 1200000, "minBitrate": 32000, "maxBitrate": 2000000, "minClientAdaptiveBitRate": 150000, "minMotionAdaptiveBitRate": 750000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 2, "videoId": "video2", "name": "Rtogc Kztj", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, "width": 480, "height": 360, "fps": 30, "bitrate": 200000, "minBitrate": 32000, "maxBitrate": 1000000, "minClientAdaptiveBitRate": 0, "minMotionAdaptiveBitRate": 200000, "fpsValues": [ 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30 ], "idrInterval": 5 }, { "id": 3, "videoId": "video4", "name": "Ohxs Zvtmc", "enabled": true, "isRtspEnabled": true, "rtspAlias": "d8KCmJNaI1NTIpGJ", "width": 1600, "height": 1200, "fps": 2, "bitrate": 1000000, "minBitrate": 32000, "maxBitrate": 2000000, "minClientAdaptiveBitRate": null, "minMotionAdaptiveBitRate": null, "fpsValues": [ 1, 2 ], "idrInterval": 5 } ], "ispSettings": { "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, "wdr": 1, "icrSensitivity": 0, "brightness": 50, "contrast": 50, "hue": 50, "saturation": 50, "sharpness": 50, "denoise": 50, "isFlippedVertical": false, "isFlippedHorizontal": false, "isAutoRotateEnabled": false, "isLdcEnabled": true, "is3dnrEnabled": true, "isExternalIrEnabled": false, "isAggressiveAntiFlickerEnabled": false, "isPauseMotionEnabled": false, "dZoomCenterX": 50, "dZoomCenterY": 50, "dZoomScale": 0, "dZoomStreamId": 4, "focusMode": "ztrig", "focusPosition": 0, "touchFocusX": 1001, "touchFocusY": 1001, "zoomPosition": 0, "mountPosition": null }, "talkbackSettings": { "typeFmt": "aac", "typeIn": "serverudp", "bindAddr": "0.0.0.0", "bindPort": 7004, "filterAddr": "", "filterPort": 0, "channels": 1, "samplingRate": 22050, "bitsPerSample": 16, "quality": 100 }, "osdSettings": { "isNameEnabled": true, "isDateEnabled": true, "isLogoEnabled": false, "isDebugEnabled": false }, "ledSettings": { "isEnabled": true, "blinkRate": 0 }, "speakerSettings": { "isEnabled": true, "areSystemSoundsEnabled": false, "volume": 80 }, "recordingSettings": { "prePaddingSecs": 10, "postPaddingSecs": 10, "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, "useNewMotionAlgorithm": true }, "smartDetectSettings": { "objectTypes": [ "person", "vehicle" ] }, "recordingSchedules": [], "motionZones": [ { "id": 1, "name": "Default", "color": "#AB46BC", "points": [ [ 0, 0 ], [ 1, 0 ], [ 1, 1 ], [ 0, 1 ] ], "sensitivity": 50 } ], "privacyZones": [], "smartDetectZones": [ { "id": 1, "name": "Txeb Hlswjhf", "color": "#AB46BC", "points": [ [ 0.569, 0 ], [ 0.604, 0.537 ], [ 1, 0.565 ], [ 1, 1 ], [ 0, 1 ], [ 0, 0 ], [ 0.276, 0 ] ], "sensitivity": 50, "objectTypes": [ "person" ] }, { "id": 2, "name": "Krc Zhcvlco", "color": "#586CED", "points": [ [ 0, 0 ], [ 0.369, 0 ], [ 0.358, 0.543 ], [ 1, 0.531 ], [ 1, 1 ], [ 0, 0.978 ] ], "sensitivity": 50, "objectTypes": [ "vehicle" ] } ], "smartDetectLines": [], "stats": { "rxBytes": 7917059346, "txBytes": 270432644401, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -52 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1641921049599, "recordingEnd": 1643055815739, "recordingStartLQ": 1641921049574, "recordingEndLQ": 1643055815722, "timelapseStart": 1641921049594, "timelapseEnd": 1643055730717, "timelapseStartLQ": 1641921049594, "timelapseEndLQ": 1643055520725 }, "storage": { "used": 427349245952, "rate": 642.30288184191 }, "wifiQuality": 100, "wifiStrength": -52 }, "featureFlags": { "canAdjustIrLedLevel": false, "canMagicZoom": false, "canOpticalZoom": false, "canTouchFocus": false, "hasAccelerometer": false, "hasAec": true, "hasBattery": false, "hasBluetooth": true, "hasChime": true, "hasExternalIr": false, "hasIcrSensitivity": true, "hasLdc": true, "hasLedIr": true, "hasLedStatus": true, "hasLineIn": false, "hasMic": true, "hasPrivacyMask": true, "hasRtc": false, "hasSdCard": false, "hasSpeaker": true, "hasWifi": true, "hasHdr": true, "hasAutoICROnly": true, "videoModes": [ "default" ], "videoModeMaxFps": [], "hasMotionZones": true, "hasLcdScreen": true, "hasFingerprintSensor": true, "mountPositions": [], "smartDetectTypes": [ "person", "vehicle" ], "supportNfc": true, "motionAlgorithms": [ "enhanced" ], "hasSquareEventThumbnail": true, "hasPackageCamera": true, "privacyMaskCapability": { "maxMasks": 16, "rectangleOnly": false }, "focus": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "pan": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "tilt": { "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "zoom": { "ratio": 3, "steps": { "max": null, "min": null, "step": null }, "degrees": { "max": null, "min": null, "step": null } }, "hasSmartDetect": true, "supportFullHdSnapshot": false }, "pirSettings": { "pirSensitivity": 100, "pirMotionClipLength": 15, "timelapseFrameInterval": 15, "timelapseTransferInterval": 600 }, "lcdMessage": { "type": "CUSTOM_MESSAGE", "text": "Welcome | 03:23 PM | 25\u00b0F", "resetAt": null }, "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -52, "ssid": "Mortis Camera" }, "lenses": [ { "id": 2, "video": { "recordingStart": 1642357077629, "recordingEnd": 1643055806877, "recordingStartLQ": null, "recordingEndLQ": null, "timelapseStart": 1641921049317, "timelapseEnd": 1643055667971, "timelapseStartLQ": null, "timelapseEndLQ": null } } ], "id": "1c9a2db4df6efda47a3509be", "isConnected": true, "platform": "s5l", "hasSpeaker": true, "hasWifi": true, "audioBitrate": 64000, "canManage": false, "isManaged": true, "marketName": "G4 Doorbell Pro", "modelKey": "camera", "isPairedWithAiPort": false, "guid": "00000000-0000-00 0- 000-000000000000", "activePatrolSlot": null } uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_heatmap.png000066400000000000000000000027111524637705700257770ustar00rootroot00000000000000‰PNG  IHDR€hHPÝ�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¸�yIEND®B`‚uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_snapshot.png000066400000000000000000000204461524637705700262240ustar00rootroot00000000000000‰PNG  IHDR@°,cÀ íIDATxœíØ1 À0@ù¤ã€—‰‚ÞÝ3³ êü€ €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €´ Œ¹ àt·¨IEND®B`‚uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_thumbnail.png000066400000000000000000000027111524637705700263430ustar00rootroot00000000000000‰PNG  IHDR€hHPÝ�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¸�yIEND®B`‚uilibs-uiprotect-317a52c/tests/sample_data/sample_camera_video.mp4000066400000000000000000001431101524637705700254010ustar00rootroot00000000000000 ftypisomisomiso2avc1mp41freeu‹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ôɽ4uU&Ô @2@âø †€ì ëg$Aš$lC¿þ©–0ÞLavc58.91.100B Á8!AžBx…ÿq!`Œ!`Œ!žatB¿&à!`Œ!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Ah™Lwÿþ©–1!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&á!`Œ!`Œ!ž§jB¿&à!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&à!`Œ!`Œ!žëjB¿&à!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿq!`Œ!`Œ!Ÿ-tB¿&á!`Œ!`Œ!Ÿ/jB¿&à!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿq!`Œ!`Œ!ŸqtB¿&à!`Œ!`Œ!ŸsjB¿&à!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&á!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&à!`Œ!ŸûjB¿&á!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿp!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!žatB¿&à!`Œ!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Al™Lwÿþ©–1!`Œ!`Œ#Až†E,/ÿq!`Œ!ž¥tB¿&á!`Œ!`Œ!ž§jB¿&à!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&à!`Œ!`Œ!žëjB¿&à!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ#AŸE,/ÿq!`Œ!`Œ!Ÿ-tB¿&á!`Œ!`Œ!Ÿ/jB¿&à!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿq!`Œ!`Œ!ŸqtB¿&à!`Œ!`Œ!ŸsjB¿&à!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&á!`Œ!`Œ!Ÿ·jB¿&á!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&à!`Œ!ŸûjB¿&á!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿp!`Œ!`Œ!ž=tB¿&à!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!žatB¿&à!`Œ!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Al™Lwÿþ©–1!`Œ!`Œ#Až†E,/ÿq!`Œ!ž¥tB¿&á!`Œ!`Œ!ž§jB¿&à!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!žétB¿&à!`Œ!`Œ!žëjB¿&à!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ#AŸE,/ÿq!`Œ!`Œ!Ÿ-tB¿&á!`Œ!`Œ!Ÿ/jB¿&à!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ#AŸRE,/ÿq!`Œ!`Œ!ŸqtB¿&à!`Œ!`Œ!ŸsjB¿&à!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&á!`Œ!`Œ!Ÿ·jB¿&á!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&à!`Œ!`Œ!ŸûjB¿&á!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿp!`Œ!`Œ!ž=tB¿&à!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&à!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Al™Lwÿþ©–1!`Œ!`Œ#Až†E,/ÿq!`Œ!ž¥tB¿&á!`Œ!`Œ!ž§jB¿&à!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!žétB¿&à!`Œ!`Œ!žëjB¿&à!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿq!`Œ!Ÿ-tB¿&á!`Œ!`Œ!Ÿ/jB¿&à!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ#AŸRE,/ÿq!`Œ!`Œ!ŸqtB¿&à!`Œ!`Œ!ŸsjB¿&à!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&á!`Œ!`Œ!Ÿ·jB¿&á!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&à!`Œ!`Œ!ŸûjB¿&á!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿp!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&à!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Al™Lwÿþ©–1!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&á!`Œ!ž§jB¿&à!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&à!`Œ!žëjB¿&à!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿq!`Œ!Ÿ-tB¿&á!`Œ!`Œ!Ÿ/jB¿&à!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿq!`Œ!ŸqtB¿&à!`Œ!`Œ!ŸsjB¿&à!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ#AŸ–E,/ÿpÞLavc58.91.100B Á8!`Œ!ŸµtB¿&á!`Œ!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&à!`Œ!`Œ!ŸûjB¿&á!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ#AžE,/ÿp!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&à!`Œ!`Œ!žcjB¿&á!`Œ*AšhI¨Al™Lwÿþ©–1!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&á!`Œ!ž§jB¿&à!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&à!`Œ!žëjB¿&à!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿq!`Œ!`Œ!Ÿ-tB¿&á!`Œ!Ÿ/jB¿&à!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿq!`Œ!ŸqtB¿&à!`Œ!`Œ!ŸsjB¿&à!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!ŸµtB¿&á!`Œ!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&à!`Œ!`Œ!ŸûjB¿&á!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ#AžE,/ÿp!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&à!`Œ!`Œ!žcjB¿&á!`Œ*AšhI¨Al™Lwÿþ©–1!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&á!`Œ!`Œ!ž§jB¿&à!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&à!`Œ!žëjB¿&à!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿq!`Œ!`Œ!Ÿ-tB¿&á!`Œ!Ÿ/jB¿&à!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿq!`Œ!`Œ!ŸqtB¿&à!`Œ!ŸsjB¿&à!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!ŸµtB¿&á!`Œ!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿq!`Œ!ŸùtB¿&à!`Œ!`Œ!ŸûjB¿&á!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ#AžE,/ÿp!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&à!`Œ!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Al™Lwÿþ©–1!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&á!`Œ!`Œ!ž§jB¿&à!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&à!`Œ!`Œ!žëjB¿&à!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿq!`Œ!`Œ!Ÿ-tB¿&á!`Œ!Ÿ/jB¿&à!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿq!`Œ!`Œ!ŸqtB¿&à!`Œ!ŸsjB¿&à!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&á!`Œ!Ÿ·jB¿&á!`Œ!`Œ)A›¹I¨Al™LWÿþ8@Ú!`Œ!`ŒÓeˆ‚þ÷ˆ2Ëoœj×rç­.­PîFq^¤½þ U¶nÃ::(  ¨ -à}’À™4`ÅX1!`Œ$Aš$lC¿þ©–0!`Œ!`Œ!AžBx…ÿq!`Œ!`Œ!žatB¿&á!`Œ!`Œ!žcjB¿&à!`Œ*AšhI¨Ah™Lwÿþ©–0!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&á!`Œ!žëjB¿&á!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿp!`Œ!`Œ!Ÿ-tB¿&à!`Œ!Ÿ/jB¿&á!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿp!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&á!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿp!`Œ!ŸùtB¿&á!`Œ!`Œ!ŸûjB¿&à!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ#AžE,/ÿq!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&á!`Œ!`Œ!žcjB¿&à!`Œ*AšhI¨Al™Lwÿþ©–0!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!`Œ!ž§jB¿&á!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&á!`Œ!`Œ!žëjB¿&á!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿp!`Œ!`Œ!Ÿ-tB¿&à!`Œ!Ÿ/jB¿&á!`ŒÞLavc58.91.100B Á8*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿp!`Œ!`Œ!ŸqtB¿&á!`Œ!ŸsjB¿&á!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿp!`Œ!ŸùtB¿&á!`Œ!`Œ!ŸûjB¿&à!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿq!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&á!`Œ!`Œ!žcjB¿&à!`Œ!`Œ*AšhI¨Al™Lwÿþ©–0!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!`Œ!ž§jB¿&á!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&á!`Œ!`Œ!žëjB¿&á!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿp!`Œ!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿp!`Œ!`Œ!ŸqtB¿&á!`Œ!ŸsjB¿&á!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&à!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿp!`Œ!ŸùtB¿&á!`Œ!`Œ!ŸûjB¿&à!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿq!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!žatB¿&á!`Œ!`Œ!žcjB¿&à!`Œ!`Œ*AšhI¨Al™Lwÿþ©–0!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&á!`Œ!`Œ!žëjB¿&á!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿp!`Œ!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿp!`Œ!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&á!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&à!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿp!`Œ!`Œ!ŸùtB¿&á!`Œ!ŸûjB¿&à!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿq!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!žatB¿&á!`Œ!`Œ!žcjB¿&à!`Œ!`Œ*AšhI¨Al™Lwÿþ©–0!`Œ!`Œ#Až†E,/ÿq!`Œ!ž¥tB¿&à!`Œ!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&á!`Œ!`Œ!žëjB¿&á!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ#AŸE,/ÿp!`Œ!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ#AŸRE,/ÿp!`Œ!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&á!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&á!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿp!`Œ!`Œ!ŸùtB¿&á!`Œ!ŸûjB¿&à!`Œ!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿq!`Œ!`Œ!ž=tB¿&à!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&á!`Œ!žcjB¿&à!`Œ!`Œ*AšhI¨Al™Lwÿþ©–0!`Œ!`Œ#Až†E,/ÿq!`Œ!ž¥tB¿&à!`Œ!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!žétB¿&á!`Œ!`Œ!žëjB¿&á!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ#AŸE,/ÿp!`Œ!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ#AŸRE,/ÿp!`Œ!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&á!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&á!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸÚE,/ÿp!`Œ!`Œ!ŸùtB¿&á!`Œ!`Œ!ŸûjB¿&à!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿq!`Œ!`Œ!ž=tB¿&à!`Œ!ž?jB¿&á!`Œ!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&á!`Œ!žcjB¿&à!`Œ!`Œ*AšhI¨Al™Lwÿþ©–0ÞLavc58.91.100B Á8!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!žétB¿&á!`Œ!`Œ!žëjB¿&á!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿp!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ#AŸRE,/ÿp!`Œ!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&á!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&á!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–0!`Œ#AŸÚE,/ÿp!`Œ!`Œ!ŸùtB¿&á!`Œ!`Œ!ŸûjB¿&à!`Œ*A›àI¨Al™Lwÿþ©–1!`Œ!`Œ#AžE,/ÿq!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&á!`Œ*Aš$I¨Al™Lwÿþ©–0!`Œ!`Œ#AžBE,/ÿq!`Œ!`Œ!žatB¿&á!`Œ!žcjB¿&à!`Œ!`Œ*AšhI¨Al™Lwÿþ©–0!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–0!`Œ!`Œ#AžÊE,/ÿq!`Œ!`Œ!žétB¿&á!`Œ!žëjB¿&á!`Œ!`Œ*AšðI¨Al™Lwÿþ©–1!`Œ!`Œ#AŸE,/ÿp!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ!`Œ*A›4I¨Al™Lwÿþ©–0!`Œ!`Œ#AŸRE,/ÿp!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&á!`Œ!`Œ*A›xI¨Al™Lwÿþ©–1!`Œ#AŸ–E,/ÿp!`Œ!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&á!`Œ!`Œ)A›¹I¨Al™LWÿþ8@Ú!`ŒÓeˆ„ÿþ÷ˆ2Ëoœj×rç­.­PîFq^¤½þ U¶nÃ::(  ¨ -à}’À™4`ÅX0!`Œ!`Œ$Aš$lC¿þ©–1!`Œ!`Œ!AžBx…ÿp!`Œ!`Œ!žatB¿&á!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Ah™Lwÿþ©–0!`Œ!`Œ#Až†E,/ÿq!`Œ!ž¥tB¿&à!`Œ!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–1!`Œ!`Œ#AžÊE,/ÿp!`Œ!žétB¿&à!`Œ!`Œ!žëjB¿&á!`Œ!`Œ*AšðI¨Al™Lwÿþ©–0!`Œ#AŸE,/ÿq!`Œ!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ!`Œ*A›4I¨Al™Lwÿþ©–1!`Œ#AŸRE,/ÿq!`Œ!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&à!`Œ!`Œ*A›xI¨Al™Lwÿþ©–0!`Œ#AŸ–E,/ÿq!`Œ!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&à!`Œ*A›¼I¨Al™Lwÿþ©–1!`Œ!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&á!`Œ!`Œ!ŸûjB¿&à!`Œ*A›àI¨Al™Lwÿþ©–0!`Œ!`Œ#AžE,/ÿq!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&à!`Œ*Aš$I¨Al™Lwÿþ©–1!`Œ!`Œ#AžBE,/ÿp!`Œ!`Œ!žatB¿&á!`Œ!žcjB¿&á!`Œ!`Œ*AšhI¨Al™Lwÿþ©–0!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!ž§jB¿&á!`Œ!`Œ*Aš¬I¨Al™Lwÿþ©–1!`Œ!`Œ#AžÊE,/ÿp!`Œ!žétB¿&à!`Œ!`Œ!žëjB¿&á!`Œ!`Œ*AšðI¨Al™Lwÿþ©–0!`Œ!`Œ#AŸE,/ÿq!`Œ!Ÿ-tB¿&à!`Œ!`Œ!Ÿ/jB¿&á!`Œ!`Œ*A›4I¨Al™Lwÿþ©–1!`Œ!`Œ#AŸRE,/ÿq!`Œ!ŸqtB¿&á!`Œ!`Œ!ŸsjB¿&à!`Œ!`Œ*A›xI¨Al™Lwÿþ©–0!`Œ#AŸ–E,/ÿq!`Œ!`Œ!ŸµtB¿&à!`Œ!`Œ!Ÿ·jB¿&à!`Œ!`Œ*A›¼I¨Al™Lwÿþ©–1!`Œ#AŸÚE,/ÿq!`Œ!`Œ!ŸùtB¿&á!`Œ!`Œ!ŸûjB¿&à!`Œ*A›àI¨Al™Loÿþ§„0 !`Œ!`Œ#AžE,/ÿq!`Œ!`Œ!ž=tB¿&à!`Œ!`Œ!ž?jB¿&à!`Œ*Aš$I¨Al™Loÿþ§„0!!`Œ!`Œ#AžBE,/ÿp!`Œ!`Œ!žatB¿&á!`Œ!`Œ!žcjB¿&á!`Œ*AšhI¨Al™Lgÿþž»€!`Œ!`Œ#Až†E,/ÿq!`Œ!`Œ!ž¥tB¿&à!`Œ!ž§jB¿&á!`Œ!`Œ*AšªI¨Al™LL+ÿþ8@Û!`Œ!`Œ!žÉjB¿&à!`Œ!`Œ!`Œ!`Œ!`ŒP•moovlmvhdèYð@&>trak\tkhdYØ@Ð$edtselstYØ%¶mdia mdhd2~UÄ-hdlrvideVideoHandler%aminfvmhd$dinfdref url %!stbl­stsd�avc1ÐHHÿÿ7avcCdÿágd¬Ù@P» ñƒ`hëãË"Àýøøpaspstts?stssûõøctts=                                                                                                                                              (stsc stsz?Š(%%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%-×(%%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%-×(%%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.'%%.% stco>0ù*[ŒÀó$U‰¼íR…¶áNªäBs­à < v £ Ô  ? l � Î  5 f — Ë þ / ` ” Ç ø # ] � Á ì&Yеï"M~¸ëG�®ßJw¨Ù@q¢Ö :kŸÒ.h›Ì÷1d•Àú-X‰Ãö!RŒ¹êU‚³äK|­áEvªÝ9s¦×<o Ë8c”Î,]—Êõ&`�¾ï ) V ‡ ¸ ì!0!a!’!Æ!ù"*"["�"Â"ó##X#‹#¼#ç$!$T$$°$ê%%H%y%³%æ&&B&|&©&Ú' 'E'r'£'Ô((;(l(�(Ñ))5)f)š)Í)þ*)*c*–*Ç*ò+,+_+Š+»+õ,(,S,„,¾,ñ--M-‡-´-å..P.}.®.ß//F/w/¨/Ü00@0q0¥0Ø1 141n1¡1Ò1ý272j2•2Æ3333^3�3É3ü4'4X4‘5n5¢5Ó66/6i6œ6Ç6ø727e7�7Á7û8.8Y8Š8Ä8ñ9"9S9�9º9ë::V:ƒ:´:å;;L;};®;â<>=>p>›>Ý??J?u?¦?à@ @>@o@©@ÖAA8ArAŸAÐBB5BhB™BÊBþC1CbC�CÇCúD+DVD�DÃDôEEYEŒE·EèF"FUF€F±FëGGIGzG´GáHHCH}HªHÛI I@IsI¤IÕJ J<JmJ˜JÒKK6KaK›KÎKÿL*LdL—LÂLóM-M`M‹M¼MöN#NTN…N¿NìOONOˆOµOæPPKP~P¯PàQQGQxQ©QÝRRARlR¦RÙS S5SoS¢SÍSþT8TkT–TÇUU4U_U�UÊU÷V(VYV“VÀVñW"WVW‰WºWëXXRXƒX´XèYYLYwY±YäZZ@ZzZ­ZØ[ [C[v[¡[Ò\\P\{\¬\æ]]D]u]¯]Ü^ ^>^r^¥^Ö__;_n_Ÿ_Ð``7`h`“`Íaa1a\a–aÉaôb%b_b’b½bîc(c[c†c·cñddOd€dºdçeeIe}e°eáffEg(g\g�g¸géh#hPh�h²hìiiJi{i¯iâjjDjxj«jÜk kAktk¥kÐl l=lnl™lÓmm7mbmœmÏmún+nen˜nÃnôo.o[oŒo½o÷p$pUp†pÀpíqqOqƒq¶qçrrLrr°rÛssHsys¤sÞttBtmt§tÚuu6up)�trak\tkhdYð@$edtselstYØ(ùmdia mdhd¬D~UÄ-hdlrsounSoundHandler(¤minfsmhd$dinfdref url (hstbljstsdZmp4a¬D6esds€€€%€€€@ô+€€€V倀€ sttsßstsc?    "#&')*-.014589;<?@BCFGJKMNQRTUXY\]_`cdfgjknoqruvxy|}€�ƒ„‡ˆ‹ŒŽ�’“•–™š�ž ¡¤¥§¨«¬¯°²³¶·¹º½¾ÁÂÄÅÈÉËÌÏÐÓÔÖ×ÚÛÝÞáâåæèéìíïðóô÷øúûþÿ    "#&')*-.014589;<?@BCFGJKMNQRTUXY\]_`cdfgjknoqruvxy|}€�ƒ„‡ˆ‹ŒŽ�’“•–™š�ž ¡¤¥§¨«¬¯°²³¶·¹º½¾ÁÂÄÅÈÉËÌÏÐÓÔÖ×ÚÛÝÞáâåæèéìíïðóô÷øúûþÿ    "#&')*-.014589;<>”stszà stco>âO€ºçIƒ°áFyªÛBs¤Ø <g¡Ô  0 j � È ù 3 f ‘  ü / Z ‹ Å ò # T Ž » ì  Q „ µ æM~¯ãGr¬ß;u¨Ó>qœÍ:e–Ðý._™Æ÷(\�Àñ%X‰ºî!R}·êF€³ÞI|§ØEp¡Û9j¤Ñ3gšËü0c”Åù,]ˆÂõ&Q‹¾ïT‡²ã  P { ¬ æ!!U!†!À!í""O"‰"¶"ç##L##°#á$$H$y$¤$Þ%%B%m%§%Ú& &6&p&£&Î&ÿ'9'l'—'È((/(`(‘(Ë(ø)))Z)”)Á)ò*#*W*Š*»*ì+ +S+„+¯+é,,M,x,²,å--A-{-®-Ù. .D.w.¢.Ó/ /:/k/œ/Ö0040e0Ÿ0Ì0ý1.1b1•1Æ1÷2+2^2�2º2ô3'3X3ƒ3½3ð4!4L4…5h5–5Ç5ø6)6]6�6Á6ì7&7Y7Š7µ7ï8"8S8~8¸8ë99G9�9´9ß::J:}:¨:Ù;;@;q;¢;Ü< <:1>d>•>À? ?>?o?š?Ô@@2@c@�@Ð@ûA,AfA™AÄAõB/B\B�B¾BøC%CVC‡C»CîDDPD„D·DèEEME€E±EÜFFIFzF¥FßGG=GnG¨GÛHH7HqH¤HÏII:IgI˜IÉJJ0JaJ’JÆJùK*K[K�KÂKóL$LXL‹L¼LçM!MTM…M°MêNNHNyN³NæOOBO|O¯OÚP PEPrP£PÔQQ;QlQ�Q×RR5RfRšRÍRþS/ScS–SÇSòT,T_T�T»TõU(UYU„U¾UñVVMV‡VºVåWWPW}W®WßXXFXwX¨XâYY@YqY¥YØZ Z:ZnZ¡ZÒZý[7[j[›[Æ\\D\u\ \Ú] ]8]i]£]Ö^^2^l^™^Ê^û_5_b_“_Ä_þ`+`\`�`Á`ôa%aVaŠa½aîbbSb†b·bâccOc€c«cåddCdtd®dáe e=ewe¤eÕff?ggPg�g²gÝhhJhuh¦hàii>ioi©iÖjj8jrjŸjÐkk;khk™kÊkþl1lbl“lÇlúm+m\m�mÃmônnYnŒn½nèo"oUo€o±oëppIpzp´pçqqCq}qªqÛr rFrsr¤rÕs s<smsžsÒtt6tgt›tÎtÿu*udu•sgpdrollÿÿsbgprollàbudtaZmeta!hdlrmdirappl-ilst%©toodataLavf58.45.100uilibs-uiprotect-317a52c/tests/sample_data/sample_chime.json000066400000000000000000000023561524637705700243270ustar00rootroot00000000000000{ "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" } uilibs-uiprotect-317a52c/tests/sample_data/sample_constants.json000066400000000000000000000014041524637705700252470ustar00rootroot00000000000000{ "server_name": "Uiiji Ryoyo", "server_id": "4B8290F6D7A3", "server_version": "1.21.0-beta.3", "server_ip": "192.168.102.63", "server_model": "UNVR-PRO", "last_update_id": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", "user_id": "4c5f03a8c8bd48ad8e066285", "counts": { "camera": 11, "user": 7, "group": 2, "liveview": 5, "viewer": 1, "display": 0, "light": 1, "bridge": 2, "sensor": 4, "chime": 0, "schedule": 0 }, "time": "2022-01-24T20:23:32.433278+00:00", "event_count": 1351, "camera_thumbnail": "e-90f051ebf085214d331644a5", "camera_heatmap": "e-90f051ebf085214d331644a5", "camera_video_length": 23, "camera_online": true } uilibs-uiprotect-317a52c/tests/sample_data/sample_event_smart_track.json000066400000000000000000001734211524637705700267570ustar00rootroot00000000000000{ "id": "011ff3a70d34c3ac6aaa3be3", "payload": [ { "coord": [ 916, 540, 83, 253 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817387975, "confidence": 86, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 690, "stationary": false, "attributes": null, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 906, 540, 92, 251 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388076, "confidence": 85, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 699, "stationary": false, "attributes": null, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 896, 536, 102, 256 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388175, "confidence": 86, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 706, "stationary": false, "attributes": null, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 887, 535, 111, 247 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388276, "confidence": 86, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 713, "stationary": false, "attributes": null, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 878, 534, 121, 250 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388375, "confidence": 87, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 718, "stationary": false, "attributes": null, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 871, 531, 128, 251 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388476, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 722, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 861, 530, 137, 249 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388575, "confidence": 88, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 727, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 852, 527, 146, 246 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388675, "confidence": 89, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 732, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 845, 528, 154, 239 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388776, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 735, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 838, 526, 161, 241 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388875, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 738, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 828, 525, 170, 239 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817388975, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 740, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 822, 525, 175, 240 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389075, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 743, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 816, 527, 182, 240 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389176, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 745, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 809, 525, 189, 240 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389275, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 747, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 802, 525, 196, 240 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389375, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 749, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 795, 524, 203, 243 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389475, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 751, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 790, 525, 208, 238 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389576, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 754, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 785, 525, 213, 240 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389675, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 756, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 780, 523, 218, 242 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389779, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 758, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 775, 525, 222, 237 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389875, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 760, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 770, 525, 229, 242 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817389976, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 763, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 764, 527, 234, 236 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390075, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 765, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 760, 531, 238, 232 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390176, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 767, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 752, 531, 247, 238 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390275, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 769, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 746, 533, 253, 239 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390376, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 771, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 743, 534, 256, 240 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390476, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 772, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 738, 535, 260, 235 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390575, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 772, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 734, 535, 265, 236 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390675, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 772, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 730, 535, 269, 234 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390775, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 774, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 725, 536, 274, 238 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390875, "confidence": 90, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 775, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 721, 539, 277, 231 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817390976, "confidence": 89, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 775, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 721, 538, 277, 229 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391075, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 775, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 716, 539, 280, 231 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391176, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 775, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 711, 539, 285, 225 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391275, "confidence": 94, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 776, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 708, 538, 290, 231 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391376, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 778, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 705, 537, 294, 234 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391479, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 779, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 701, 536, 298, 238 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391576, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 779, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 698, 539, 301, 238 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391675, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 780, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 695, 538, 304, 239 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391775, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 781, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 688, 539, 310, 237 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391875, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 781, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 688, 539, 310, 237 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817391983, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 780, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 682, 540, 316, 222 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392075, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 778, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 678, 540, 319, 232 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392175, "confidence": 91, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 777, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 674, 540, 324, 230 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392275, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 777, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 675, 541, 324, 223 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392375, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 778, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 672, 540, 326, 223 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392475, "confidence": 92, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 780, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 668, 540, 329, 222 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392575, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 780, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 667, 541, 332, 215 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392675, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 781, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 665, 541, 334, 212 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392775, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 783, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 661, 541, 337, 208 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392875, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 785, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 658, 541, 340, 202 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817392976, "confidence": 94, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 787, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 657, 542, 341, 196 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393075, "confidence": 94, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 788, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 655, 543, 343, 193 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393176, "confidence": 93, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 789, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 651, 543, 338, 190 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393275, "confidence": 94, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 789, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 649, 543, 333, 188 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393375, "confidence": 94, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 789, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 648, 544, 319, 184 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393475, "confidence": 94, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 790, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 646, 544, 314, 180 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393575, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 790, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 645, 545, 307, 177 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393676, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 789, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 645, 544, 299, 175 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393775, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 789, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 644, 545, 291, 171 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393876, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 789, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 81, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 643, 544, 285, 170 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817393976, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 790, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 81, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 640, 544, 281, 169 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394075, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 790, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 85, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 638, 545, 276, 164 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394175, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 792, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 85, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 637, 546, 271, 158 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394276, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 792, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 85, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 636, 544, 263, 157 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394375, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 793, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 85, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 635, 545, 259, 154 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394476, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 795, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 85, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 635, 545, 254, 153 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394576, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 802, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 85, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 632, 544, 249, 152 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394676, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 809, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 88, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 629, 547, 244, 149 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394775, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 817, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 88, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 628, 545, 243, 146 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817394876, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 824, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 626, 546, 234, 145 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395076, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 831, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 625, 546, 234, 142 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395176, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 828, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 625, 548, 228, 138 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395275, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 825, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 623, 548, 225, 138 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395376, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 821, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 625, 548, 220, 135 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395475, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 818, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 621, 548, 219, 134 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395575, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 815, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 621, 549, 215, 134 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395675, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 814, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 619, 548, 212, 133 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395776, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 812, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 619, 549, 210, 132 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395876, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 811, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 618, 549, 207, 131 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817395975, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 808, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 617, 548, 204, 130 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396075, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 807, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 617, 548, 201, 127 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396175, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 805, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 615, 549, 200, 125 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396275, "confidence": 97, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 803, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 615, 549, 196, 125 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396375, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 802, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 614, 550, 193, 122 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396475, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 800, "stationary": false, "attributes": { "color": { "confidence": 79, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 612, 550, 189, 122 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396575, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 799, "stationary": false, "attributes": { "color": { "confidence": 84, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 613, 549, 186, 120 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396675, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 797, "stationary": false, "attributes": { "color": { "confidence": 84, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 612, 550, 183, 118 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396775, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 796, "stationary": false, "attributes": { "color": { "confidence": 84, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 611, 550, 181, 117 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396875, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 796, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 610, 550, 176, 115 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817396976, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 795, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 609, 550, 174, 112 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397075, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 794, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 608, 550, 171, 111 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397175, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 793, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 605, 552, 169, 108 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397275, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 792, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 604, 552, 166, 107 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397377, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 791, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 604, 551, 166, 106 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397475, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 792, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 604, 551, 161, 105 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397575, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 792, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 601, 552, 158, 104 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397676, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 792, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 601, 551, 156, 103 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397775, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 790, "stationary": false, "attributes": { "color": { "confidence": 85, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 600, 553, 153, 102 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397882, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 790, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 598, 553, 150, 101 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817397975, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 788, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 598, 554, 150, 98 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817398075, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 788, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 598, 554, 147, 98 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817398175, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 788, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 598, 553, 142, 96 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817398276, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 788, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 595, 555, 142, 94 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817398375, "confidence": 95, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 788, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 595, 555, 139, 95 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817398476, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 787, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 }, { "coord": [ 594, 555, 134, 91 ], "depth": null, "speed": null, "id": "15915", "timestamp": 1763817398675, "confidence": 96, "licensePlate": "WWH336E", "objectType": "vehicle", "zones": [ 2 ], "lines": [], "duration": 785, "stationary": false, "attributes": { "color": { "confidence": 86, "val": "white" }, "vehicleType": { "confidence": 92, "val": "car" } }, "firstShownTimeMs": 1763817387575, "idleSinceTimeMs": 0 } ], "camera": "1c9a2db4df6efda47a3509be", "event": "f1d59512b03f596d87db22c7" } uilibs-uiprotect-317a52c/tests/sample_data/sample_keyrings.json000066400000000000000000000012231524637705700250650ustar00rootroot00000000000000[ { "deviceType": "camera", "deviceId": "1c9a2db4df6efda47a3509be", "registryType": "nfc", "registryId": "64B2A621", "lastActivity": 1732904108638, "ulpUser": "73791632-9805-419c-8351-f3afaab8f064", "id": "672b573764f79603e400031d", "modelKey": "keyring" }, { "deviceType": "camera", "deviceId": "1c9a2db4df6efda47a3509be", "registryType": "fingerprint", "registryId": "1", "lastActivity": 1732904119477, "ulpUser": "0ef32f28-f654-404d-ab34-30e373e66436", "id": "672b573764f79603e44871d", "modelKey": "keyring" } ] uilibs-uiprotect-317a52c/tests/sample_data/sample_light.json000066400000000000000000000026161524637705700243500ustar00rootroot00000000000000{ "mac": "534D8B001B14", "host": "192.168.234.163", "connectionHost": "192.168.102.63", "type": "UP FloodLight", "name": "Pjq Osgvztd", "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": true, "lastMotion": 1643055813957, "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" } uilibs-uiprotect-317a52c/tests/sample_data/sample_link_station_alarm_hub.json000066400000000000000000000055431524637705700277530ustar00rootroot00000000000000{ "id": "alarmhub01alarmhub01alarmhub", "modelKey": "linkstation", "state": "CONNECTED", "name": "Hallway Alarm Hub", "mac": "AABBCCDDEE02", "isAlarmHub": true, "ledSettings": {"isEnabled": true}, "lastEvent": 1700000000000, "alarmHub": { "armed": "on", "buckboost": "off", "battery": { "charging": "off", "connection": "connected", "voltage": 12.4, "batteryStatus": "ok" }, "cover": { "status": "close", "distance": 3 }, "input": { "0": { "enable": "on", "type": "no", "eolr": 0, "resistor": 0, "status": "normal", "trigger": 0, "name": "Front Door", "inputType": "ENTRY", "lastTriggeredAt": 1700000010000, "cameraId": "cam01cam01cam01cam01cam01" }, "1": { "enable": "on", "type": "nc", "eolr": 1, "resistor": 5, "status": "normal", "trigger": 0, "name": "Smoke Hallway", "inputType": "SMOKE", "lastTriggeredAt": null, "cameraId": null }, "2": { "enable": "off", "type": "no", "eolr": 0, "resistor": 0, "status": "normal", "trigger": 0 }, "3": { "enable": "on", "type": "no", "eolr": 0, "resistor": 0, "status": "normal", "trigger": 0, "name": "Future Panel Button", "inputType": "SOMETHING_NEW" } }, "output": { "0": { "enable": "on", "active": "off", "status": "dry", "name": "Siren", "delay": 0, "duration": 30 }, "1": { "enable": "off", "active": "off", "status": "wet" }, "stringly-keyed": { "enable": "on", "active": "off", "status": "dry" } }, "connector": { "emergency": {"+": "connected", "-": "connected"}, "12v": {"ch0": {"+": "connected", "-": "connected"}} }, "currentMeterStatus": { "ch0": { "critical": "normal", "efuse": "normal", "enable": "on", "powerValid": "normal", "timingControl": "normal", "warning": "normal" } } } } uilibs-uiprotect-317a52c/tests/sample_data/sample_liveview.json000066400000000000000000000011431524637705700250650ustar00rootroot00000000000000{ "name": "Txvocs Vwn", "isDefault": false, "isGlobal": true, "layout": 1, "slots": [ { "cameras": [ "1c9a2db4df6efda47a3509be", "e2ff0ade6be0f2a2beb61869", "c462c07dbd63ad805a7318c7", "4a333d993fe8e2e8472bc901", "ab3e27f2d55fad817dac7bb9", "f0cd15b8bed9e38899286a8c" ], "cycleMode": "motion", "cycleInterval": 10 } ], "owner": "4c5f03a8c8bd48ad8e066285", "id": "d65bb41c14d6aa92bfa4a6d1", "modelKey": "liveview" } uilibs-uiprotect-317a52c/tests/sample_data/sample_public_api_camera_snapshot.png000066400000000000000000000204461524637705700304130ustar00rootroot00000000000000‰PNG  IHDR@°,cÀ íIDATxœíØ1 À0@ù¤ã€—‰‚ÞÝ3³ êü€ €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €4 €´ Œ¹ àt·¨IEND®B`‚uilibs-uiprotect-317a52c/tests/sample_data/sample_raw_events.json000066400000000000000000031706571524637705700254340ustar00rootroot00000000000000[ { "id": "a4547d7347c9f2f7c901988d", "type": "disconnect", "start": 1640293400462, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "56401f80d300adad7123c864", "partition": null, "user": null, "metadata": { "reason": "CONNECTION_LOSS" }, "thumbnail": "e-a4547d7347c9f2f7c901988d", "heatmap": "e-a4547d7347c9f2f7c901988d", "modelKey": "event", "timestamp": null }, { "id": "cea535e766680356430f25b4", "type": "disconnect", "start": 1641666397056, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "76227b20c37b2ff0abdc9d4d", "partition": null, "user": null, "metadata": { "reason": "CONNECTION_LOSS" }, "thumbnail": "e-cea535e766680356430f25b4", "heatmap": "e-cea535e766680356430f25b4", "modelKey": "event", "timestamp": null }, { "id": "aa2f815bf67a423d4c2023b3", "type": "disconnect", "start": 1642023134544, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "0777b5d342302079dc6b793d", "partition": null, "user": null, "metadata": { "reason": "CONNECTION_LOSS" }, "thumbnail": "e-aa2f815bf67a423d4c2023b3", "heatmap": "e-aa2f815bf67a423d4c2023b3", "modelKey": "event", "timestamp": null }, { "id": "f2c0797cef2295cf94d7258d", "type": "access", "start": 1642968399418, "end": 1643038112717, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "8593657a25b7826a4288b6af", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-f2c0797cef2295cf94d7258d", "heatmap": "e-f2c0797cef2295cf94d7258d", "modelKey": "event", "timestamp": null }, { "id": "a6ec856c4b526f1ef75581e0", "type": "sensorMotion", "start": 1642969428282, "end": 1642969428282, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Vgsyvl Cmhwjm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a6ec856c4b526f1ef75581e0", "heatmap": "e-a6ec856c4b526f1ef75581e0", "modelKey": "event", "timestamp": null }, { "id": "76a8fcddad901f3a47f6a491", "type": "motion", "start": 1642969868951, "end": 1642969890117, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-76a8fcddad901f3a47f6a491", "heatmap": "e-76a8fcddad901f3a47f6a491", "modelKey": "event", "timestamp": 1642968718587 }, { "id": "b6c283115a45b90e01328374", "type": "motion", "start": 1642969874623, "end": 1642969896127, "score": 14, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b6c283115a45b90e01328374", "heatmap": "e-b6c283115a45b90e01328374", "modelKey": "event", "timestamp": 1642968718587 }, { "id": "bfebf7927fa4a3b687de2527", "type": "motion", "start": 1642969903129, "end": 1642969924659, "score": 19, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bfebf7927fa4a3b687de2527", "heatmap": "e-bfebf7927fa4a3b687de2527", "modelKey": "event", "timestamp": 1642968718587 }, { "id": "59a11e87ae1bc1eb8ca1dbc1", "type": "motion", "start": 1642970259299, "end": 1642970280812, "score": 15, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-59a11e87ae1bc1eb8ca1dbc1", "heatmap": "e-59a11e87ae1bc1eb8ca1dbc1", "modelKey": "event", "timestamp": 1642968718587 }, { "id": "147c9b78f6d9a811f2d48dfd", "type": "motion", "start": 1642970489439, "end": 1642970513605, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-147c9b78f6d9a811f2d48dfd", "heatmap": "e-147c9b78f6d9a811f2d48dfd", "modelKey": "event", "timestamp": 1642970500627 }, { "id": "90b5f83de271cf5b34506da6", "type": "smartDetectZone", "start": 1642970928482, "end": 1642970963990, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-90b5f83de271cf5b34506da6", "heatmap": "e-90b5f83de271cf5b34506da6", "modelKey": "event", "timestamp": 1642970944768 }, { "id": "99230d645167538098e365eb", "type": "motion", "start": 1642970931827, "end": 1642971007950, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "90b5f83de271cf5b34506da6", "36b65fab5f89cc9652a0f6b0" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-99230d645167538098e365eb", "heatmap": "e-99230d645167538098e365eb", "modelKey": "event", "timestamp": 1642970952728 }, { "id": "639099f2212638678bb27231", "type": "sensorMotion", "start": 1642970939192, "end": 1642970939192, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fditv Oxbauug" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-639099f2212638678bb27231", "heatmap": "e-639099f2212638678bb27231", "modelKey": "event", "timestamp": null }, { "id": "ccd6073e052e881e963884c3", "type": "sensorMotion", "start": 1642970943587, "end": 1642970943587, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mhero Taqxk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ccd6073e052e881e963884c3", "heatmap": "e-ccd6073e052e881e963884c3", "modelKey": "event", "timestamp": null }, { "id": "2b8cb85a7b74da054bd1bca9", "type": "sensorMotion", "start": 1642970948381, "end": 1642970948381, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Crjk Ejgvfx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2b8cb85a7b74da054bd1bca9", "heatmap": "e-2b8cb85a7b74da054bd1bca9", "modelKey": "event", "timestamp": null }, { "id": "e62363b2fd25cf4d1ad9ca6b", "type": "sensorOpened", "start": 1642970949997, "end": 1642970949997, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zmyjfgs Vbyymv" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-e62363b2fd25cf4d1ad9ca6b", "heatmap": "e-e62363b2fd25cf4d1ad9ca6b", "modelKey": "event", "timestamp": null }, { "id": "d5e5d32a31234eb7753cbb15", "type": "sensorClosed", "start": 1642970950021, "end": 1642970950021, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vsgsn Ckw" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-d5e5d32a31234eb7753cbb15", "heatmap": "e-d5e5d32a31234eb7753cbb15", "modelKey": "event", "timestamp": null }, { "id": "9f3d21186207f627581dbf4c", "type": "sensorOpened", "start": 1642970950402, "end": 1642970950402, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ogljqg Ontles" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-9f3d21186207f627581dbf4c", "heatmap": "e-9f3d21186207f627581dbf4c", "modelKey": "event", "timestamp": null }, { "id": "36b65fab5f89cc9652a0f6b0", "type": "smartDetectZone", "start": 1642970950948, "end": 1642971005543, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-36b65fab5f89cc9652a0f6b0", "heatmap": "e-36b65fab5f89cc9652a0f6b0", "modelKey": "event", "timestamp": 1642970981322 }, { "id": "094944af2a857993e8140e93", "type": "motion", "start": 1642970978656, "end": 1642971021242, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "5dc2b46d9f249f0bd3e93de7" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-094944af2a857993e8140e93", "heatmap": "e-094944af2a857993e8140e93", "modelKey": "event", "timestamp": 1642971007728 }, { "id": "c180db39a745d0bf3d250f2f", "type": "motion", "start": 1642970979366, "end": 1642971044266, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "2c5b2080101e04b3394740e7", "fbe1f10258c159e7773de819" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c180db39a745d0bf3d250f2f", "heatmap": "e-c180db39a745d0bf3d250f2f", "modelKey": "event", "timestamp": 1642971031743 }, { "id": "5dc2b46d9f249f0bd3e93de7", "type": "smartDetectZone", "start": 1642970980151, "end": 1642971020687, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5dc2b46d9f249f0bd3e93de7", "heatmap": "e-5dc2b46d9f249f0bd3e93de7", "modelKey": "event", "timestamp": 1642970991930 }, { "id": "2c5b2080101e04b3394740e7", "type": "smartDetectZone", "start": 1642970980367, "end": 1642971016216, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2c5b2080101e04b3394740e7", "heatmap": "e-2c5b2080101e04b3394740e7", "modelKey": "event", "timestamp": 1642970996542 }, { "id": "b5453036147383d640bc831c", "type": "sensorMotion", "start": 1642970982969, "end": 1642970982969, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yyfnr Zbdnoy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b5453036147383d640bc831c", "heatmap": "e-b5453036147383d640bc831c", "modelKey": "event", "timestamp": null }, { "id": "8a535a04d0f9cb1ee12f9c86", "type": "sensorMotion", "start": 1642970985931, "end": 1642970985931, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dkslbh Qugymxx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8a535a04d0f9cb1ee12f9c86", "heatmap": "e-8a535a04d0f9cb1ee12f9c86", "modelKey": "event", "timestamp": null }, { "id": "680b24361a238bdceee31657", "type": "lightMotion", "start": 1642970990146, "end": 1642970990146, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-680b24361a238bdceee31657", "heatmap": "e-680b24361a238bdceee31657", "modelKey": "event", "timestamp": null }, { "id": "22189a674143cca569b373e2", "type": "sensorMotion", "start": 1642970990180, "end": 1642970990180, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dbajl Fbmomnj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-22189a674143cca569b373e2", "heatmap": "e-22189a674143cca569b373e2", "modelKey": "event", "timestamp": null }, { "id": "5c222d0ac5376329261573e5", "type": "smartDetectZone", "start": 1642971010015, "end": 1642971042632, "score": 86, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5c222d0ac5376329261573e5", "heatmap": "e-5c222d0ac5376329261573e5", "modelKey": "event", "timestamp": 1642971021015 }, { "id": "90f494c398886c01afdbbcfc", "type": "motion", "start": 1642971010439, "end": 1642971044933, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "5c222d0ac5376329261573e5" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-90f494c398886c01afdbbcfc", "heatmap": "e-90f494c398886c01afdbbcfc", "modelKey": "event", "timestamp": 1642971027770 }, { "id": "f7ac41cb3d37bb083bb52663", "type": "motion", "start": 1642971013105, "end": 1642971053157, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f7ac41cb3d37bb083bb52663", "heatmap": "e-f7ac41cb3d37bb083bb52663", "modelKey": "event", "timestamp": 1642971024297 }, { "id": "fbe1f10258c159e7773de819", "type": "smartDetectZone", "start": 1642971019743, "end": 1642971042412, "score": 46, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fbe1f10258c159e7773de819", "heatmap": "e-fbe1f10258c159e7773de819", "modelKey": "event", "timestamp": 1642971031743 }, { "id": "37801af120fc618260e9a14f", "type": "motion", "start": 1642971022158, "end": 1642971051470, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-37801af120fc618260e9a14f", "heatmap": "e-37801af120fc618260e9a14f", "modelKey": "event", "timestamp": 1642971041136 }, { "id": "8a6c94a2f536e639e67bc613", "type": "motion", "start": 1642971159137, "end": 1642971189464, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8a6c94a2f536e639e67bc613", "heatmap": "e-8a6c94a2f536e639e67bc613", "modelKey": "event", "timestamp": 1642971170303 }, { "id": "0261b5d0a3c5aa89fff90da1", "type": "motion", "start": 1642971206968, "end": 1642971230968, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0261b5d0a3c5aa89fff90da1", "heatmap": "e-0261b5d0a3c5aa89fff90da1", "modelKey": "event", "timestamp": 1642971219985 }, { "id": "cf460d8e1dfc1825515ff249", "type": "motion", "start": 1642971279557, "end": 1642971307396, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cf460d8e1dfc1825515ff249", "heatmap": "e-cf460d8e1dfc1825515ff249", "modelKey": "event", "timestamp": 1642971292390 }, { "id": "1dc21a9e6c6f594cbb6aa2af", "type": "motion", "start": 1642971281949, "end": 1642971305450, "score": 63, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1dc21a9e6c6f594cbb6aa2af", "heatmap": "e-1dc21a9e6c6f594cbb6aa2af", "modelKey": "event", "timestamp": 1642971293116 }, { "id": "ffdc15c15414a055583b1fef", "type": "motion", "start": 1642971283365, "end": 1642971335906, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "48d274fa97f10ba71fd7eeb2" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ffdc15c15414a055583b1fef", "heatmap": "e-ffdc15c15414a055583b1fef", "modelKey": "event", "timestamp": 1642971306931 }, { "id": "b3d5719b23f3effb1b0cc7f6", "type": "motion", "start": 1642971283855, "end": 1642971316515, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "8cf9c508f775aae4b972f70f" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b3d5719b23f3effb1b0cc7f6", "heatmap": "e-b3d5719b23f3effb1b0cc7f6", "modelKey": "event", "timestamp": 1642971299353 }, { "id": "7b30f823378cbb6f724a7198", "type": "sensorMotion", "start": 1642971285451, "end": 1642971285451, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Fhnfl Ihedgt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7b30f823378cbb6f724a7198", "heatmap": "e-7b30f823378cbb6f724a7198", "modelKey": "event", "timestamp": null }, { "id": "8cf9c508f775aae4b972f70f", "type": "smartDetectZone", "start": 1642971286843, "end": 1642971316520, "score": 86, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8cf9c508f775aae4b972f70f", "heatmap": "e-8cf9c508f775aae4b972f70f", "modelKey": "event", "timestamp": 1642971297843 }, { "id": "30b12a8b3ecf98170e0c0498", "type": "sensorMotion", "start": 1642971290708, "end": 1642971290708, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Thihyq Qwwlx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-30b12a8b3ecf98170e0c0498", "heatmap": "e-30b12a8b3ecf98170e0c0498", "modelKey": "event", "timestamp": null }, { "id": "bd3a7b062a7e8a0ab641dc6b", "type": "motion", "start": 1642971293932, "end": 1642971341141, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "3e6e8f89dd8c461ea162046b" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bd3a7b062a7e8a0ab641dc6b", "heatmap": "e-bd3a7b062a7e8a0ab641dc6b", "modelKey": "event", "timestamp": 1642971318776 }, { "id": "d9a193b82b0e411f0721a58b", "type": "sensorMotion", "start": 1642971295096, "end": 1642971295096, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Kseka Gkquuj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d9a193b82b0e411f0721a58b", "heatmap": "e-d9a193b82b0e411f0721a58b", "modelKey": "event", "timestamp": null }, { "id": "48d274fa97f10ba71fd7eeb2", "type": "smartDetectZone", "start": 1642971299816, "end": 1642971335976, "score": 81, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-48d274fa97f10ba71fd7eeb2", "heatmap": "e-48d274fa97f10ba71fd7eeb2", "modelKey": "event", "timestamp": 1642971319511 }, { "id": "3e6e8f89dd8c461ea162046b", "type": "smartDetectZone", "start": 1642971301111, "end": 1642971333123, "score": 80, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3e6e8f89dd8c461ea162046b", "heatmap": "e-3e6e8f89dd8c461ea162046b", "modelKey": "event", "timestamp": 1642971318776 }, { "id": "0232f3174c88afa5d93119c9", "type": "smartDetectZone", "start": 1642971302380, "end": 1642971364595, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0232f3174c88afa5d93119c9", "heatmap": "e-0232f3174c88afa5d93119c9", "modelKey": "event", "timestamp": 1642971321933 }, { "id": "973c3f681014430600fc60c8", "type": "motion", "start": 1642971304683, "end": 1642971379018, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "0232f3174c88afa5d93119c9", "772c86a44847b4a080733ef9" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-973c3f681014430600fc60c8", "heatmap": "e-973c3f681014430600fc60c8", "modelKey": "event", "timestamp": 1642971343688 }, { "id": "d6e00a52e1bde37828d2d056", "type": "motion", "start": 1642971306287, "end": 1642971327456, "score": 38, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d6e00a52e1bde37828d2d056", "heatmap": "e-d6e00a52e1bde37828d2d056", "modelKey": "event", "timestamp": 1642971292390 }, { "id": "a6d3ef50843f0e8cbd9a060b", "type": "sensorMotion", "start": 1642971326104, "end": 1642971326104, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jspaad Zagme" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a6d3ef50843f0e8cbd9a060b", "heatmap": "e-a6d3ef50843f0e8cbd9a060b", "modelKey": "event", "timestamp": null }, { "id": "85da046c40551f9fd47a3fb8", "type": "smartDetectZone", "start": 1642971328388, "end": 1642971350942, "score": 64, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-85da046c40551f9fd47a3fb8", "heatmap": "e-85da046c40551f9fd47a3fb8", "modelKey": "event", "timestamp": 1642971339388 }, { "id": "edd6bb1d8c561ec90adf30a1", "type": "lightMotion", "start": 1642971330286, "end": 1642971330286, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-edd6bb1d8c561ec90adf30a1", "heatmap": "e-edd6bb1d8c561ec90adf30a1", "modelKey": "event", "timestamp": null }, { "id": "489078a3602cb1757cb4ba81", "type": "sensorMotion", "start": 1642971330865, "end": 1642971330865, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dgyyxll Itzsx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-489078a3602cb1757cb4ba81", "heatmap": "e-489078a3602cb1757cb4ba81", "modelKey": "event", "timestamp": null }, { "id": "735ba7aad60320bf3fff23d9", "type": "sensorMotion", "start": 1642971334129, "end": 1642971334129, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cgcwks Vvj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-735ba7aad60320bf3fff23d9", "heatmap": "e-735ba7aad60320bf3fff23d9", "modelKey": "event", "timestamp": null }, { "id": "ee6c19df4ba6b1240bb2225b", "type": "lightMotion", "start": 1642971335326, "end": 1642971335326, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-ee6c19df4ba6b1240bb2225b", "heatmap": "e-ee6c19df4ba6b1240bb2225b", "modelKey": "event", "timestamp": null }, { "id": "a5c1b4d467bbddb8d8c7bb95", "type": "sensorMotion", "start": 1642971338091, "end": 1642971338091, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wbil Gpz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a5c1b4d467bbddb8d8c7bb95", "heatmap": "e-a5c1b4d467bbddb8d8c7bb95", "modelKey": "event", "timestamp": null }, { "id": "00ae57975d7e0eb1cadf053c", "type": "smartDetectZone", "start": 1642971338473, "end": 1642971365819, "score": 77, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-00ae57975d7e0eb1cadf053c", "heatmap": "e-00ae57975d7e0eb1cadf053c", "modelKey": "event", "timestamp": 1642971352813 }, { "id": "cf595972044315730f993db9", "type": "motion", "start": 1642971338806, "end": 1642971379843, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "00ae57975d7e0eb1cadf053c", "af5049cfd3c9bd4b70ba302c" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cf595972044315730f993db9", "heatmap": "e-cf595972044315730f993db9", "modelKey": "event", "timestamp": 1642971361995 }, { "id": "2ad44df8ad24d31a4bc3920f", "type": "smartDetectZone", "start": 1642971338907, "end": 1642971368428, "score": 82, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2ad44df8ad24d31a4bc3920f", "heatmap": "e-2ad44df8ad24d31a4bc3920f", "modelKey": "event", "timestamp": 1642971350594 }, { "id": "919024d3dd8ce164e018d9ae", "type": "motion", "start": 1642971338983, "end": 1642971381696, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "2ad44df8ad24d31a4bc3920f", "e1ab7b742518bf833d9b4b1b" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-919024d3dd8ce164e018d9ae", "heatmap": "e-919024d3dd8ce164e018d9ae", "modelKey": "event", "timestamp": 1642971356847 }, { "id": "22b4716513f08bedc66e8a8e", "type": "sensorMotion", "start": 1642971343370, "end": 1642971343370, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jtyut Grtw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-22b4716513f08bedc66e8a8e", "heatmap": "e-22b4716513f08bedc66e8a8e", "modelKey": "event", "timestamp": null }, { "id": "772c86a44847b4a080733ef9", "type": "smartDetectZone", "start": 1642971348293, "end": 1642971377131, "score": 86, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-772c86a44847b4a080733ef9", "heatmap": "e-772c86a44847b4a080733ef9", "modelKey": "event", "timestamp": 1642971359293 }, { "id": "4778bbb4c2dfd9f93fa89565", "type": "lightMotion", "start": 1642971348466, "end": 1642971348466, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-4778bbb4c2dfd9f93fa89565", "heatmap": "e-4778bbb4c2dfd9f93fa89565", "modelKey": "event", "timestamp": null }, { "id": "85827cdbcc1d7a60cc560809", "type": "sensorMotion", "start": 1642971349035, "end": 1642971349035, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kstnf Xmqh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-85827cdbcc1d7a60cc560809", "heatmap": "e-85827cdbcc1d7a60cc560809", "modelKey": "event", "timestamp": null }, { "id": "af5049cfd3c9bd4b70ba302c", "type": "smartDetectZone", "start": 1642971350495, "end": 1642971379172, "score": 74, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-af5049cfd3c9bd4b70ba302c", "heatmap": "e-af5049cfd3c9bd4b70ba302c", "modelKey": "event", "timestamp": 1642971361995 }, { "id": "fc414d661e7b3e453c0172fb", "type": "motion", "start": 1642971352362, "end": 1642971376033, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fc414d661e7b3e453c0172fb", "heatmap": "e-fc414d661e7b3e453c0172fb", "modelKey": "event", "timestamp": 1642971365364 }, { "id": "e1ab7b742518bf833d9b4b1b", "type": "smartDetectZone", "start": 1642971355661, "end": 1642971378446, "score": 67, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e1ab7b742518bf833d9b4b1b", "heatmap": "e-e1ab7b742518bf833d9b4b1b", "modelKey": "event", "timestamp": 1642971367371 }, { "id": "2656e4a4b308306e6c1983b0", "type": "motion", "start": 1642971357435, "end": 1642971385111, "score": 99, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2656e4a4b308306e6c1983b0", "heatmap": "e-2656e4a4b308306e6c1983b0", "modelKey": "event", "timestamp": 1642971374777 }, { "id": "f8956de35442322b06a516f2", "type": "motion", "start": 1642971368123, "end": 1642971392127, "score": 61, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f8956de35442322b06a516f2", "heatmap": "e-f8956de35442322b06a516f2", "modelKey": "event", "timestamp": 1642971381960 }, { "id": "a61abdf353802201690d2088", "type": "motion", "start": 1642971376188, "end": 1642971409359, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a61abdf353802201690d2088", "heatmap": "e-a61abdf353802201690d2088", "modelKey": "event", "timestamp": 1642971388025 }, { "id": "da617885fad3f1099c33efb2", "type": "sensorMotion", "start": 1642971380311, "end": 1642971380311, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zjnyd Hzlpkpe" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-da617885fad3f1099c33efb2", "heatmap": "e-da617885fad3f1099c33efb2", "modelKey": "event", "timestamp": null }, { "id": "504042c8f77d425b0286ddce", "type": "sensorMotion", "start": 1642971387785, "end": 1642971387785, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Vgsi Hroyg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-504042c8f77d425b0286ddce", "heatmap": "e-504042c8f77d425b0286ddce", "modelKey": "event", "timestamp": null }, { "id": "42f561171e59c9df83bb16f4", "type": "motion", "start": 1642971392254, "end": 1642971414757, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-42f561171e59c9df83bb16f4", "heatmap": "e-42f561171e59c9df83bb16f4", "modelKey": "event", "timestamp": 1642971403420 }, { "id": "df10f9ad7531be18eec24cb6", "type": "motion", "start": 1642971393499, "end": 1642971468001, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "358bba7a750fad9eb4da5eff" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-df10f9ad7531be18eec24cb6", "heatmap": "e-df10f9ad7531be18eec24cb6", "modelKey": "event", "timestamp": 1642971434496 }, { "id": "89e0ad7acfe18a68b7c3eca5", "type": "motion", "start": 1642971394225, "end": 1642971470287, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "e4c46240db245e60c0e1bc57", "0bdd51c0ea591a7b708e8efc" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-89e0ad7acfe18a68b7c3eca5", "heatmap": "e-89e0ad7acfe18a68b7c3eca5", "modelKey": "event", "timestamp": 1642971447604 }, { "id": "6ce97e116393595fdfe38d21", "type": "sensorMotion", "start": 1642971394852, "end": 1642971394852, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Mqktc Jafqdg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6ce97e116393595fdfe38d21", "heatmap": "e-6ce97e116393595fdfe38d21", "modelKey": "event", "timestamp": null }, { "id": "358bba7a750fad9eb4da5eff", "type": "smartDetectZone", "start": 1642971395500, "end": 1642971466134, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-358bba7a750fad9eb4da5eff", "heatmap": "e-358bba7a750fad9eb4da5eff", "modelKey": "event", "timestamp": 1642971424713 }, { "id": "e979d87856bebc4857311125", "type": "motion", "start": 1642971398569, "end": 1642971464451, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "1f6af0ad14994903afc1ac38" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e979d87856bebc4857311125", "heatmap": "e-e979d87856bebc4857311125", "modelKey": "event", "timestamp": 1642971441581 }, { "id": "e4c46240db245e60c0e1bc57", "type": "smartDetectZone", "start": 1642971403402, "end": 1642971442982, "score": 85, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e4c46240db245e60c0e1bc57", "heatmap": "e-e4c46240db245e60c0e1bc57", "modelKey": "event", "timestamp": 1642971422474 }, { "id": "1f6af0ad14994903afc1ac38", "type": "smartDetectZone", "start": 1642971404572, "end": 1642971463768, "score": 79, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1f6af0ad14994903afc1ac38", "heatmap": "e-1f6af0ad14994903afc1ac38", "modelKey": "event", "timestamp": 1642971441581 }, { "id": "0bdd51c0ea591a7b708e8efc", "type": "smartDetectZone", "start": 1642971428338, "end": 1642971467642, "score": 85, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0bdd51c0ea591a7b708e8efc", "heatmap": "e-0bdd51c0ea591a7b708e8efc", "modelKey": "event", "timestamp": 1642971439674 }, { "id": "73aa187fd17daeb27bf8dda7", "type": "sensorMotion", "start": 1642971433226, "end": 1642971433226, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fmw Nbtfx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-73aa187fd17daeb27bf8dda7", "heatmap": "e-73aa187fd17daeb27bf8dda7", "modelKey": "event", "timestamp": null }, { "id": "73d051569fda04094e99083a", "type": "lightMotion", "start": 1642971433306, "end": 1642971433306, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-73d051569fda04094e99083a", "heatmap": "e-73d051569fda04094e99083a", "modelKey": "event", "timestamp": null }, { "id": "1944f3437979e475b44dc263", "type": "motion", "start": 1642971446628, "end": 1642971471442, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1944f3437979e475b44dc263", "heatmap": "e-1944f3437979e475b44dc263", "modelKey": "event", "timestamp": 1642971460942 }, { "id": "705c65e29ea344a7d078817d", "type": "motion", "start": 1642971477439, "end": 1642971505103, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-705c65e29ea344a7d078817d", "heatmap": "e-705c65e29ea344a7d078817d", "modelKey": "event", "timestamp": 1642971488606 }, { "id": "5b07b673cf2e503de43186f8", "type": "motion", "start": 1642971482149, "end": 1642971546473, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "9f589b7f58e04b579cad760a" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5b07b673cf2e503de43186f8", "heatmap": "e-5b07b673cf2e503de43186f8", "modelKey": "event", "timestamp": 1642971511641 }, { "id": "d81bb5bab1dd58db4fc63790", "type": "motion", "start": 1642971483670, "end": 1642971522243, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "74dd5e3a52fd4f86f07ef89f" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d81bb5bab1dd58db4fc63790", "heatmap": "e-d81bb5bab1dd58db4fc63790", "modelKey": "event", "timestamp": 1642971499515 }, { "id": "9f589b7f58e04b579cad760a", "type": "smartDetectZone", "start": 1642971483767, "end": 1642971544474, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9f589b7f58e04b579cad760a", "heatmap": "e-9f589b7f58e04b579cad760a", "modelKey": "event", "timestamp": 1642971507283 }, { "id": "c219a5a517e650fef87feba1", "type": "smartDetectZone", "start": 1642971487148, "end": 1642971622065, "score": 82, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c219a5a517e650fef87feba1", "heatmap": "e-c219a5a517e650fef87feba1", "modelKey": "event", "timestamp": 1642971608049 }, { "id": "bb88cfefa65b82744cd32789", "type": "motion", "start": 1642971487649, "end": 1642971627570, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "c219a5a517e650fef87feba1" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bb88cfefa65b82744cd32789", "heatmap": "e-bb88cfefa65b82744cd32789", "modelKey": "event", "timestamp": 1642971608049 }, { "id": "74dd5e3a52fd4f86f07ef89f", "type": "smartDetectZone", "start": 1642971490967, "end": 1642971521107, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-74dd5e3a52fd4f86f07ef89f", "heatmap": "e-74dd5e3a52fd4f86f07ef89f", "modelKey": "event", "timestamp": 1642971507995 }, { "id": "7599dea2c7cc88d4e3224a94", "type": "motion", "start": 1642971508597, "end": 1642971624217, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "4f50b25130a6a955b0aba44d" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7599dea2c7cc88d4e3224a94", "heatmap": "e-7599dea2c7cc88d4e3224a94", "modelKey": "event", "timestamp": 1642971536177 }, { "id": "4f50b25130a6a955b0aba44d", "type": "smartDetectZone", "start": 1642971509088, "end": 1642971622840, "score": 88, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4f50b25130a6a955b0aba44d", "heatmap": "e-4f50b25130a6a955b0aba44d", "modelKey": "event", "timestamp": 1642971569002 }, { "id": "c2ca10ce6d9ecaa67f44f26d", "type": "lightMotion", "start": 1642971511366, "end": 1642971511366, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-c2ca10ce6d9ecaa67f44f26d", "heatmap": "e-c2ca10ce6d9ecaa67f44f26d", "modelKey": "event", "timestamp": null }, { "id": "6b3425e97c545540f52ccc9b", "type": "sensorMotion", "start": 1642971512297, "end": 1642971512297, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xflyjre Nqved" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6b3425e97c545540f52ccc9b", "heatmap": "e-6b3425e97c545540f52ccc9b", "modelKey": "event", "timestamp": null }, { "id": "d9346d97231453f1c07b4c6e", "type": "lightMotion", "start": 1642971519046, "end": 1642971519046, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-d9346d97231453f1c07b4c6e", "heatmap": "e-d9346d97231453f1c07b4c6e", "modelKey": "event", "timestamp": null }, { "id": "ddb8496120736cb79bf291c3", "type": "smartDetectZone", "start": 1642971530090, "end": 1642971559122, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ddb8496120736cb79bf291c3", "heatmap": "e-ddb8496120736cb79bf291c3", "modelKey": "event", "timestamp": 1642971547473 }, { "id": "9aa4cdea1c95f21701403a9a", "type": "motion", "start": 1642971530976, "end": 1642971561303, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [ "ddb8496120736cb79bf291c3" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9aa4cdea1c95f21701403a9a", "heatmap": "e-9aa4cdea1c95f21701403a9a", "modelKey": "event", "timestamp": 1642971542811 }, { "id": "b9e85db3d3c9461ab357308b", "type": "smartDetectZone", "start": 1642971545284, "end": 1642971571310, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b9e85db3d3c9461ab357308b", "heatmap": "e-b9e85db3d3c9461ab357308b", "modelKey": "event", "timestamp": 1642971556284 }, { "id": "1f521c055c026fe29d6f340d", "type": "motion", "start": 1642971545971, "end": 1642971573133, "score": 76, "smartDetectTypes": [], "smartDetectEvents": [ "b9e85db3d3c9461ab357308b" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1f521c055c026fe29d6f340d", "heatmap": "e-1f521c055c026fe29d6f340d", "modelKey": "event", "timestamp": 1642971558805 }, { "id": "46e3fcc73ad55b67903a31f6", "type": "smartDetectZone", "start": 1642971559423, "end": 1642971586580, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-46e3fcc73ad55b67903a31f6", "heatmap": "e-46e3fcc73ad55b67903a31f6", "modelKey": "event", "timestamp": 1642971572184 }, { "id": "2ae7c65f9476ff5ec4af19fa", "type": "motion", "start": 1642971560630, "end": 1642971588458, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "46e3fcc73ad55b67903a31f6" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2ae7c65f9476ff5ec4af19fa", "heatmap": "e-2ae7c65f9476ff5ec4af19fa", "modelKey": "event", "timestamp": 1642971573796 }, { "id": "423f0f8c9334da581225fdb1", "type": "smartDetectZone", "start": 1642971570883, "end": 1642971595469, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-423f0f8c9334da581225fdb1", "heatmap": "e-423f0f8c9334da581225fdb1", "modelKey": "event", "timestamp": 1642971582179 }, { "id": "d7c58fab68268f828279b6bb", "type": "motion", "start": 1642971572125, "end": 1642971596788, "score": 59, "smartDetectTypes": [], "smartDetectEvents": [ "423f0f8c9334da581225fdb1" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d7c58fab68268f828279b6bb", "heatmap": "e-d7c58fab68268f828279b6bb", "modelKey": "event", "timestamp": 1642971583458 }, { "id": "d5c569eca01774a7ff4a451c", "type": "smartDetectZone", "start": 1642971588627, "end": 1642971612363, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d5c569eca01774a7ff4a451c", "heatmap": "e-d5c569eca01774a7ff4a451c", "modelKey": "event", "timestamp": 1642971599779 }, { "id": "82f329e0d7db1bc02a939c9f", "type": "smartDetectZone", "start": 1642971599689, "end": 1642971694082, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-82f329e0d7db1bc02a939c9f", "heatmap": "e-82f329e0d7db1bc02a939c9f", "modelKey": "event", "timestamp": 1642971636718 }, { "id": "43e1cc4bae9d5e46dd1c3a76", "type": "motion", "start": 1642971601623, "end": 1642971694619, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "82f329e0d7db1bc02a939c9f" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-43e1cc4bae9d5e46dd1c3a76", "heatmap": "e-43e1cc4bae9d5e46dd1c3a76", "modelKey": "event", "timestamp": 1642971622293 }, { "id": "e3501358f03519d4c2330d41", "type": "lightMotion", "start": 1642971612406, "end": 1642971612406, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-e3501358f03519d4c2330d41", "heatmap": "e-e3501358f03519d4c2330d41", "modelKey": "event", "timestamp": null }, { "id": "b9f693e3a6ba8aa038901ffe", "type": "sensorMotion", "start": 1642971614051, "end": 1642971614051, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jyxb Bmhpnx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b9f693e3a6ba8aa038901ffe", "heatmap": "e-b9f693e3a6ba8aa038901ffe", "modelKey": "event", "timestamp": null }, { "id": "2ad8629c899c112993fa3f9f", "type": "sensorMotion", "start": 1642971619938, "end": 1642971619938, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ewfhpui Nihhi" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2ad8629c899c112993fa3f9f", "heatmap": "e-2ad8629c899c112993fa3f9f", "modelKey": "event", "timestamp": null }, { "id": "c5e892ecc19c30bcccaf0e25", "type": "sensorMotion", "start": 1642971627277, "end": 1642971627277, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vysgadq Pwp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c5e892ecc19c30bcccaf0e25", "heatmap": "e-c5e892ecc19c30bcccaf0e25", "modelKey": "event", "timestamp": null }, { "id": "41eb5e620d7175c3e0c8cf86", "type": "motion", "start": 1642971654238, "end": 1642971676249, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-41eb5e620d7175c3e0c8cf86", "heatmap": "e-41eb5e620d7175c3e0c8cf86", "modelKey": "event", "timestamp": 1642971666246 }, { "id": "958a30aee213970a9c1b8cbf", "type": "smartDetectZone", "start": 1642971662873, "end": 1642971698280, "score": 80, "smartDetectTypes": [ "vehicle" ], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-958a30aee213970a9c1b8cbf", "heatmap": "e-958a30aee213970a9c1b8cbf", "modelKey": "event", "timestamp": 1642971673873 }, { "id": "92e8869ef3150fea5f116457", "type": "sensorClosed", "start": 1642971671562, "end": 1642971671562, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nvhhq Ibcpte" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-92e8869ef3150fea5f116457", "heatmap": "e-92e8869ef3150fea5f116457", "modelKey": "event", "timestamp": null }, { "id": "3abbf1bd927ed480d92a1843", "type": "sensorMotion", "start": 1642971672971, "end": 1642971672971, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ylkli Fpdfkhb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3abbf1bd927ed480d92a1843", "heatmap": "e-3abbf1bd927ed480d92a1843", "modelKey": "event", "timestamp": null }, { "id": "7c75fe660a8b513e8244a45a", "type": "sensorMotion", "start": 1642971676963, "end": 1642971676963, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Tam Nuof" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7c75fe660a8b513e8244a45a", "heatmap": "e-7c75fe660a8b513e8244a45a", "modelKey": "event", "timestamp": null }, { "id": "f2e3d4b62b5877d814ea7bce", "type": "motion", "start": 1642971744786, "end": 1642971797868, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "f6de81ce1ae82c60fda29301" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f2e3d4b62b5877d814ea7bce", "heatmap": "e-f2e3d4b62b5877d814ea7bce", "modelKey": "event", "timestamp": 1642971779982 }, { "id": "e693749aeeb2791336ecbbb0", "type": "motion", "start": 1642971746414, "end": 1642971773415, "score": 42, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e693749aeeb2791336ecbbb0", "heatmap": "e-e693749aeeb2791336ecbbb0", "modelKey": "event", "timestamp": 1642971762248 }, { "id": "f6de81ce1ae82c60fda29301", "type": "smartDetectZone", "start": 1642971746817, "end": 1642971815800, "score": 82, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f6de81ce1ae82c60fda29301", "heatmap": "e-f6de81ce1ae82c60fda29301", "modelKey": "event", "timestamp": 1642971798667 }, { "id": "3d434e907ce11c149a74a612", "type": "smartDetectZone", "start": 1642971748179, "end": 1642971798204, "score": 85, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3d434e907ce11c149a74a612", "heatmap": "e-3d434e907ce11c149a74a612", "modelKey": "event", "timestamp": 1642971765882 }, { "id": "098f498460712660b0d17753", "type": "motion", "start": 1642971749012, "end": 1642971817518, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "3d434e907ce11c149a74a612", "d358505fa19cd9f488a53c5a" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-098f498460712660b0d17753", "heatmap": "e-098f498460712660b0d17753", "modelKey": "event", "timestamp": 1642971799169 }, { "id": "a0b545733d56a61edbbdc69d", "type": "sensorMotion", "start": 1642971754583, "end": 1642971754583, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Vohd Jatyu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a0b545733d56a61edbbdc69d", "heatmap": "e-a0b545733d56a61edbbdc69d", "modelKey": "event", "timestamp": null }, { "id": "3df5b08e64bff93d8ad41603", "type": "motion", "start": 1642971760412, "end": 1642971782410, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3df5b08e64bff93d8ad41603", "heatmap": "e-3df5b08e64bff93d8ad41603", "modelKey": "event", "timestamp": 1642971771588 }, { "id": "b37bf2886aa7e039eb899d47", "type": "ring", "start": 1642971766763, "end": 1642971767763, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b37bf2886aa7e039eb899d47", "heatmap": "e-b37bf2886aa7e039eb899d47", "modelKey": "event", "timestamp": null }, { "id": "d358505fa19cd9f488a53c5a", "type": "smartDetectZone", "start": 1642971784271, "end": 1642971816848, "score": 82, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d358505fa19cd9f488a53c5a", "heatmap": "e-d358505fa19cd9f488a53c5a", "modelKey": "event", "timestamp": 1642971799169 }, { "id": "c5a1efa20961104f0d0eddb2", "type": "motion", "start": 1642971786376, "end": 1642971828066, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c5a1efa20961104f0d0eddb2", "heatmap": "e-c5a1efa20961104f0d0eddb2", "modelKey": "event", "timestamp": 1642971812391 }, { "id": "308eee69243d9577cd609b61", "type": "motion", "start": 1642971787213, "end": 1642971820737, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "f6de81ce1ae82c60fda29301" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-308eee69243d9577cd609b61", "heatmap": "e-308eee69243d9577cd609b61", "modelKey": "event", "timestamp": 1642971798379 }, { "id": "025e9fcdbf09e65056be5c41", "type": "sensorMotion", "start": 1642971795268, "end": 1642971795268, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Owyvc Khzcd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-025e9fcdbf09e65056be5c41", "heatmap": "e-025e9fcdbf09e65056be5c41", "modelKey": "event", "timestamp": null }, { "id": "c655c88f1655d94aa9c34686", "type": "motion", "start": 1642971797061, "end": 1642971823894, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c655c88f1655d94aa9c34686", "heatmap": "e-c655c88f1655d94aa9c34686", "modelKey": "event", "timestamp": 1642971809893 }, { "id": "4af02328e5872315f2220781", "type": "sensorMotion", "start": 1642971800418, "end": 1642971800418, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Oho Miwn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4af02328e5872315f2220781", "heatmap": "e-4af02328e5872315f2220781", "modelKey": "event", "timestamp": null }, { "id": "9952a2d2c1bc8b1e65499afb", "type": "sensorMotion", "start": 1642971811104, "end": 1642971811104, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Jco Jqlqunm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9952a2d2c1bc8b1e65499afb", "heatmap": "e-9952a2d2c1bc8b1e65499afb", "modelKey": "event", "timestamp": null }, { "id": "40030811ec656ce6d307087a", "type": "motion", "start": 1642971813063, "end": 1642971842744, "score": 75, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-40030811ec656ce6d307087a", "heatmap": "e-40030811ec656ce6d307087a", "modelKey": "event", "timestamp": 1642971827897 }, { "id": "e0a1d458532fd231cc67a086", "type": "motion", "start": 1642971813327, "end": 1642971840499, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e0a1d458532fd231cc67a086", "heatmap": "e-e0a1d458532fd231cc67a086", "modelKey": "event", "timestamp": 1642971824993 }, { "id": "5590bcf47cdd896b555a48d0", "type": "sensorMotion", "start": 1642971813551, "end": 1642971813551, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xkwblrl Eagkxz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5590bcf47cdd896b555a48d0", "heatmap": "e-5590bcf47cdd896b555a48d0", "modelKey": "event", "timestamp": null }, { "id": "b67e44cd17ffc192312d2732", "type": "motion", "start": 1642971819103, "end": 1642971863972, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "6d5117e79f85a1695e90dcb1" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b67e44cd17ffc192312d2732", "heatmap": "e-b67e44cd17ffc192312d2732", "modelKey": "event", "timestamp": 1642971837106 }, { "id": "b0cd4ed8cf63e76650a341eb", "type": "sensorMotion", "start": 1642971819731, "end": 1642971819731, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ibe Cnmey" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b0cd4ed8cf63e76650a341eb", "heatmap": "e-b0cd4ed8cf63e76650a341eb", "modelKey": "event", "timestamp": null }, { "id": "6d5117e79f85a1695e90dcb1", "type": "smartDetectZone", "start": 1642971822705, "end": 1642971862287, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6d5117e79f85a1695e90dcb1", "heatmap": "e-6d5117e79f85a1695e90dcb1", "modelKey": "event", "timestamp": 1642971844117 }, { "id": "3d7c307796c9d8bfbbbc1407", "type": "smartDetectZone", "start": 1642971825631, "end": 1642971859994, "score": 80, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3d7c307796c9d8bfbbbc1407", "heatmap": "e-3d7c307796c9d8bfbbbc1407", "modelKey": "event", "timestamp": 1642971846149 }, { "id": "7db2150e16b8e85860f056c4", "type": "motion", "start": 1642971826465, "end": 1642971863553, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "3d7c307796c9d8bfbbbc1407" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7db2150e16b8e85860f056c4", "heatmap": "e-7db2150e16b8e85860f056c4", "modelKey": "event", "timestamp": 1642971846149 }, { "id": "3c57c30c50663ba089f3286f", "type": "motion", "start": 1642971827212, "end": 1642971850213, "score": 25, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3c57c30c50663ba089f3286f", "heatmap": "e-3c57c30c50663ba089f3286f", "modelKey": "event", "timestamp": 1642971824993 }, { "id": "24f81c0bf29a752a1173eccf", "type": "motion", "start": 1642971836278, "end": 1642971880232, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-24f81c0bf29a752a1173eccf", "heatmap": "e-24f81c0bf29a752a1173eccf", "modelKey": "event", "timestamp": 1642971860399 }, { "id": "9ffe994f37196ae790999efd", "type": "sensorMotion", "start": 1642971838658, "end": 1642971838658, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zvfeyv Ppp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9ffe994f37196ae790999efd", "heatmap": "e-9ffe994f37196ae790999efd", "modelKey": "event", "timestamp": null }, { "id": "9cd2bbfcdbe592c4d20bea28", "type": "motion", "start": 1642971840404, "end": 1642971863752, "score": 96, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9cd2bbfcdbe592c4d20bea28", "heatmap": "e-9cd2bbfcdbe592c4d20bea28", "modelKey": "event", "timestamp": 1642971852905 }, { "id": "a97d2482e33eafb2b116a73a", "type": "motion", "start": 1642971847574, "end": 1642971890796, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a97d2482e33eafb2b116a73a", "heatmap": "e-a97d2482e33eafb2b116a73a", "modelKey": "event", "timestamp": 1642971874117 }, { "id": "06b9ab8e26a5b0555fde368a", "type": "sensorOpened", "start": 1642971853465, "end": 1642971853465, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qef Nqm" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-06b9ab8e26a5b0555fde368a", "heatmap": "e-06b9ab8e26a5b0555fde368a", "modelKey": "event", "timestamp": null }, { "id": "18a6d846c2536581f99ac713", "type": "sensorMotion", "start": 1642971854506, "end": 1642971854506, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qel Znsvg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-18a6d846c2536581f99ac713", "heatmap": "e-18a6d846c2536581f99ac713", "modelKey": "event", "timestamp": null }, { "id": "8583927a2a4441adf06aeb2b", "type": "motion", "start": 1642971860079, "end": 1642971885914, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8583927a2a4441adf06aeb2b", "heatmap": "e-8583927a2a4441adf06aeb2b", "modelKey": "event", "timestamp": 1642971875082 }, { "id": "a49c6f262405a8d3b6e22330", "type": "motion", "start": 1642971870995, "end": 1642971902499, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a49c6f262405a8d3b6e22330", "heatmap": "e-a49c6f262405a8d3b6e22330", "modelKey": "event", "timestamp": 1642971887335 }, { "id": "ab0c153766e793e1052486f2", "type": "motion", "start": 1642971902735, "end": 1642971931573, "score": 53, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ab0c153766e793e1052486f2", "heatmap": "e-ab0c153766e793e1052486f2", "modelKey": "event", "timestamp": 1642971917234 }, { "id": "862e6305b6c5b3032e1fc3ae", "type": "motion", "start": 1642971905378, "end": 1642971948714, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-862e6305b6c5b3032e1fc3ae", "heatmap": "e-862e6305b6c5b3032e1fc3ae", "modelKey": "event", "timestamp": 1642971927881 }, { "id": "ba0b198f5cbbdbc3886764c6", "type": "motion", "start": 1642971924164, "end": 1642971947166, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ba0b198f5cbbdbc3886764c6", "heatmap": "e-ba0b198f5cbbdbc3886764c6", "modelKey": "event", "timestamp": 1642971935330 }, { "id": "4791adbe02fedfdbe110e85d", "type": "motion", "start": 1642971932925, "end": 1642971961098, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4791adbe02fedfdbe110e85d", "heatmap": "e-4791adbe02fedfdbe110e85d", "modelKey": "event", "timestamp": 1642971944088 }, { "id": "422d426b68d105657b76195e", "type": "motion", "start": 1642971967666, "end": 1642971995674, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-422d426b68d105657b76195e", "heatmap": "e-422d426b68d105657b76195e", "modelKey": "event", "timestamp": 1642971979168 }, { "id": "4215d96713147364e5c461dd", "type": "motion", "start": 1642971971931, "end": 1642972007999, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "92b59fcd91f519f35ab23a8e" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4215d96713147364e5c461dd", "heatmap": "e-4215d96713147364e5c461dd", "modelKey": "event", "timestamp": 1642971987600 }, { "id": "92b59fcd91f519f35ab23a8e", "type": "smartDetectZone", "start": 1642971974099, "end": 1642972005798, "score": 85, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-92b59fcd91f519f35ab23a8e", "heatmap": "e-92b59fcd91f519f35ab23a8e", "modelKey": "event", "timestamp": 1642971987600 }, { "id": "ba1dd4ed9a1fe2279e788f81", "type": "motion", "start": 1642971983894, "end": 1642972008565, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ba1dd4ed9a1fe2279e788f81", "heatmap": "e-ba1dd4ed9a1fe2279e788f81", "modelKey": "event", "timestamp": 1642971995395 }, { "id": "f9882d9bbdeaf925bc6702eb", "type": "motion", "start": 1642971994293, "end": 1642972017298, "score": 22, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f9882d9bbdeaf925bc6702eb", "heatmap": "e-f9882d9bbdeaf925bc6702eb", "modelKey": "event", "timestamp": 1642971995395 }, { "id": "aa3100e1aa3785a1bdfa6dec", "type": "sensorMotion", "start": 1642972003771, "end": 1642972003771, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Avbznt Tfa" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-aa3100e1aa3785a1bdfa6dec", "heatmap": "e-aa3100e1aa3785a1bdfa6dec", "modelKey": "event", "timestamp": null }, { "id": "52f1a2060791a3ea0ae8eb3a", "type": "motion", "start": 1642972008370, "end": 1642972032035, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-52f1a2060791a3ea0ae8eb3a", "heatmap": "e-52f1a2060791a3ea0ae8eb3a", "modelKey": "event", "timestamp": 1642972020702 }, { "id": "47c81874aac04233eced56b3", "type": "sensorMotion", "start": 1642972008744, "end": 1642972008744, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ott Qxmrnh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-47c81874aac04233eced56b3", "heatmap": "e-47c81874aac04233eced56b3", "modelKey": "event", "timestamp": null }, { "id": "c494aa647385d8e10439f467", "type": "sensorClosed", "start": 1642972008873, "end": 1642972008873, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Dxvwv Nwdqxre" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-c494aa647385d8e10439f467", "heatmap": "e-c494aa647385d8e10439f467", "modelKey": "event", "timestamp": null }, { "id": "032b72a3322494fb450a7868", "type": "motion", "start": 1642972023429, "end": 1642972049443, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-032b72a3322494fb450a7868", "heatmap": "e-032b72a3322494fb450a7868", "modelKey": "event", "timestamp": 1642972035596 }, { "id": "b4b5c913135f16f107552c93", "type": "sensorMotion", "start": 1642972038358, "end": 1642972038358, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Sztgtvs Blsfywn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b4b5c913135f16f107552c93", "heatmap": "e-b4b5c913135f16f107552c93", "modelKey": "event", "timestamp": null }, { "id": "6dfcc9accdefdf9e761b8526", "type": "sensorMotion", "start": 1642972045826, "end": 1642972045826, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qdcjgsj Tkaae" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6dfcc9accdefdf9e761b8526", "heatmap": "e-6dfcc9accdefdf9e761b8526", "modelKey": "event", "timestamp": null }, { "id": "3bab8e4d54b1be5a79a480b0", "type": "motion", "start": 1642972053027, "end": 1642972076192, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3bab8e4d54b1be5a79a480b0", "heatmap": "e-3bab8e4d54b1be5a79a480b0", "modelKey": "event", "timestamp": 1642972035596 }, { "id": "ace89d1a9693ca0d8beb15a2", "type": "sensorMotion", "start": 1642972059860, "end": 1642972059860, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qgrhh Jqifq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ace89d1a9693ca0d8beb15a2", "heatmap": "e-ace89d1a9693ca0d8beb15a2", "modelKey": "event", "timestamp": null }, { "id": "8a0b26d47559ff0283e29ce8", "type": "sensorOpened", "start": 1642972064242, "end": 1642972064242, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Jfa Zqdho" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-8a0b26d47559ff0283e29ce8", "heatmap": "e-8a0b26d47559ff0283e29ce8", "modelKey": "event", "timestamp": null }, { "id": "aaf5117407ae60074b0b4e92", "type": "sensorMotion", "start": 1642972065268, "end": 1642972065268, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Omc Chv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-aaf5117407ae60074b0b4e92", "heatmap": "e-aaf5117407ae60074b0b4e92", "modelKey": "event", "timestamp": null }, { "id": "97689912929cd8c6c9ad0597", "type": "motion", "start": 1642972106913, "end": 1642972179348, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "71cc58639f74df66134f1ef5", "604a0a7c8c001f6514a45cf0" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-97689912929cd8c6c9ad0597", "heatmap": "e-97689912929cd8c6c9ad0597", "modelKey": "event", "timestamp": 1642972160653 }, { "id": "71cc58639f74df66134f1ef5", "type": "smartDetectZone", "start": 1642972110751, "end": 1642972165798, "score": 87, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-71cc58639f74df66134f1ef5", "heatmap": "e-71cc58639f74df66134f1ef5", "modelKey": "event", "timestamp": 1642972144106 }, { "id": "602f8b9a1e04a5ac2ed923a7", "type": "smartDetectZone", "start": 1642972110962, "end": 1642972182619, "score": 86, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-602f8b9a1e04a5ac2ed923a7", "heatmap": "e-602f8b9a1e04a5ac2ed923a7", "modelKey": "event", "timestamp": 1642972130149 }, { "id": "10077ed621c7bc61a63b74b1", "type": "motion", "start": 1642972112973, "end": 1642972136966, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-10077ed621c7bc61a63b74b1", "heatmap": "e-10077ed621c7bc61a63b74b1", "modelKey": "event", "timestamp": 1642972124136 }, { "id": "ae38346fa0bbcc7c192c1557", "type": "motion", "start": 1642972113211, "end": 1642972183293, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "602f8b9a1e04a5ac2ed923a7" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ae38346fa0bbcc7c192c1557", "heatmap": "e-ae38346fa0bbcc7c192c1557", "modelKey": "event", "timestamp": 1642972125388 }, { "id": "78a7566590f46e98d14a4d23", "type": "motion", "start": 1642972131860, "end": 1642972154696, "score": 35, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-78a7566590f46e98d14a4d23", "heatmap": "e-78a7566590f46e98d14a4d23", "modelKey": "event", "timestamp": 1642972144027 }, { "id": "d9ba954a7f645c9d38a87bfc", "type": "lightMotion", "start": 1642972131886, "end": 1642972131886, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-d9ba954a7f645c9d38a87bfc", "heatmap": "e-d9ba954a7f645c9d38a87bfc", "modelKey": "event", "timestamp": null }, { "id": "604a0a7c8c001f6514a45cf0", "type": "smartDetectZone", "start": 1642972149653, "end": 1642972178681, "score": 77, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-604a0a7c8c001f6514a45cf0", "heatmap": "e-604a0a7c8c001f6514a45cf0", "modelKey": "event", "timestamp": 1642972160653 }, { "id": "266771d1e0dcf2f086e86898", "type": "motion", "start": 1642972160669, "end": 1642972185696, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-266771d1e0dcf2f086e86898", "heatmap": "e-266771d1e0dcf2f086e86898", "modelKey": "event", "timestamp": 1642972174334 }, { "id": "fd888edb4c70986cd9dff6f3", "type": "motion", "start": 1642972252481, "end": 1642972282478, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fd888edb4c70986cd9dff6f3", "heatmap": "e-fd888edb4c70986cd9dff6f3", "modelKey": "event", "timestamp": 1642972263813 }, { "id": "b4d5244a2a288cd8a0a557b5", "type": "motion", "start": 1642972254058, "end": 1642972306765, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "605fd60ec7b7a6d6d4cee3af" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b4d5244a2a288cd8a0a557b5", "heatmap": "e-b4d5244a2a288cd8a0a557b5", "modelKey": "event", "timestamp": 1642972289760 }, { "id": "605fd60ec7b7a6d6d4cee3af", "type": "smartDetectZone", "start": 1642972263116, "end": 1642972305294, "score": 80, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-605fd60ec7b7a6d6d4cee3af", "heatmap": "e-605fd60ec7b7a6d6d4cee3af", "modelKey": "event", "timestamp": 1642972288392 }, { "id": "1054af780ebff0661ac18117", "type": "smartDetectZone", "start": 1642972266502, "end": 1642972306077, "score": 87, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1054af780ebff0661ac18117", "heatmap": "e-1054af780ebff0661ac18117", "modelKey": "event", "timestamp": 1642972290184 }, { "id": "1e610ab6e19c33e22cbccd90", "type": "motion", "start": 1642972270504, "end": 1642972309247, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "1054af780ebff0661ac18117" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1e610ab6e19c33e22cbccd90", "heatmap": "e-1e610ab6e19c33e22cbccd90", "modelKey": "event", "timestamp": 1642972290184 }, { "id": "3f777cac6969041926e5f498", "type": "motion", "start": 1642972279127, "end": 1642972322799, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3f777cac6969041926e5f498", "heatmap": "e-3f777cac6969041926e5f498", "modelKey": "event", "timestamp": 1642972300464 }, { "id": "6b11c326cacfa3304992cb63", "type": "motion", "start": 1642972320399, "end": 1642972344904, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6b11c326cacfa3304992cb63", "heatmap": "e-6b11c326cacfa3304992cb63", "modelKey": "event", "timestamp": 1642972333735 }, { "id": "2aff2758c898f84a00ea02fc", "type": "sensorClosed", "start": 1642972387613, "end": 1642972387613, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Uto Lijw" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-2aff2758c898f84a00ea02fc", "heatmap": "e-2aff2758c898f84a00ea02fc", "modelKey": "event", "timestamp": null }, { "id": "9f6c6e754933297d6f3ebec4", "type": "motion", "start": 1642972498593, "end": 1642972519758, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9f6c6e754933297d6f3ebec4", "heatmap": "e-9f6c6e754933297d6f3ebec4", "modelKey": "event", "timestamp": 1642972263813 }, { "id": "cad31ca0af0d903bc9440966", "type": "motion", "start": 1642972872880, "end": 1642972893881, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cad31ca0af0d903bc9440966", "heatmap": "e-cad31ca0af0d903bc9440966", "modelKey": "event", "timestamp": 1642972289760 }, { "id": "db73a2d33821a7707928bd0e", "type": "sensorMotion", "start": 1642973081739, "end": 1642973081739, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Wdvztl Vae" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-db73a2d33821a7707928bd0e", "heatmap": "e-db73a2d33821a7707928bd0e", "modelKey": "event", "timestamp": null }, { "id": "1eb8bfc7ac4d1c92246dde87", "type": "access", "start": 1642973181923, "end": 1642989919913, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "bc3dd633553907952a6fe20d", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-1eb8bfc7ac4d1c92246dde87", "heatmap": "e-1eb8bfc7ac4d1c92246dde87", "modelKey": "event", "timestamp": null }, { "id": "be28bd2a91dfcebd856f4984", "type": "motion", "start": 1642974054187, "end": 1642974075688, "score": 14, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-be28bd2a91dfcebd856f4984", "heatmap": "e-be28bd2a91dfcebd856f4984", "modelKey": "event", "timestamp": 1642972263813 }, { "id": "c1c31ce4872288f538436402", "type": "motion", "start": 1642974675138, "end": 1642974696806, "score": 87, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c1c31ce4872288f538436402", "heatmap": "e-c1c31ce4872288f538436402", "modelKey": "event", "timestamp": 1642974686637 }, { "id": "03ead0390d5c67737cf6fde9", "type": "motion", "start": 1642974784706, "end": 1642974806551, "score": 97, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-03ead0390d5c67737cf6fde9", "heatmap": "e-03ead0390d5c67737cf6fde9", "modelKey": "event", "timestamp": 1642974796546 }, { "id": "ae2ff99503565dca3be1fdfb", "type": "motion", "start": 1642974889280, "end": 1642974910763, "score": 3, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ae2ff99503565dca3be1fdfb", "heatmap": "e-ae2ff99503565dca3be1fdfb", "modelKey": "event", "timestamp": 1642972263813 }, { "id": "8f4765f3db75891b7234fedf", "type": "motion", "start": 1642975139690, "end": 1642975161854, "score": 91, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8f4765f3db75891b7234fedf", "heatmap": "e-8f4765f3db75891b7234fedf", "modelKey": "event", "timestamp": 1642975150855 }, { "id": "ca3ded0a55309cd814240315", "type": "motion", "start": 1642975258054, "end": 1642975279886, "score": 91, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ca3ded0a55309cd814240315", "heatmap": "e-ca3ded0a55309cd814240315", "modelKey": "event", "timestamp": 1642975269880 }, { "id": "9dd3092889c42647616d40d3", "type": "motion", "start": 1642975348736, "end": 1642975374906, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9dd3092889c42647616d40d3", "heatmap": "e-9dd3092889c42647616d40d3", "modelKey": "event", "timestamp": 1642975362902 }, { "id": "88649f087a86105299e774cc", "type": "motion", "start": 1642975353402, "end": 1642975380398, "score": 83, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-88649f087a86105299e774cc", "heatmap": "e-88649f087a86105299e774cc", "modelKey": "event", "timestamp": 1642975368402 }, { "id": "5f0e8c51fe97e27e3e0ffb4d", "type": "motion", "start": 1642975362488, "end": 1642975389675, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5f0e8c51fe97e27e3e0ffb4d", "heatmap": "e-5f0e8c51fe97e27e3e0ffb4d", "modelKey": "event", "timestamp": 1642975379502 }, { "id": "ce758dcc73234fb72ff48c64", "type": "motion", "start": 1642975492774, "end": 1642975514969, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ce758dcc73234fb72ff48c64", "heatmap": "e-ce758dcc73234fb72ff48c64", "modelKey": "event", "timestamp": 1642975504946 }, { "id": "1a0aec27076467e2b40f9fae", "type": "motion", "start": 1642975629971, "end": 1642975660683, "score": 95, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1a0aec27076467e2b40f9fae", "heatmap": "e-1a0aec27076467e2b40f9fae", "modelKey": "event", "timestamp": 1642975648667 }, { "id": "ac667d1f0c5df682863c5d53", "type": "motion", "start": 1642975639795, "end": 1642975669482, "score": 93, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ac667d1f0c5df682863c5d53", "heatmap": "e-ac667d1f0c5df682863c5d53", "modelKey": "event", "timestamp": 1642975650961 }, { "id": "8458d14868730f2b30f88902", "type": "motion", "start": 1642975678103, "end": 1642975705810, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8458d14868730f2b30f88902", "heatmap": "e-8458d14868730f2b30f88902", "modelKey": "event", "timestamp": 1642975694967 }, { "id": "fad5bd9e8e2e07460a7af921", "type": "motion", "start": 1642975732438, "end": 1642975758616, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fad5bd9e8e2e07460a7af921", "heatmap": "e-fad5bd9e8e2e07460a7af921", "modelKey": "event", "timestamp": 1642975744105 }, { "id": "eeb6414d0bef8771517dcf3f", "type": "motion", "start": 1642976145764, "end": 1642976167767, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-eeb6414d0bef8771517dcf3f", "heatmap": "e-eeb6414d0bef8771517dcf3f", "modelKey": "event", "timestamp": 1642976157766 }, { "id": "b7d221f5f2cbae2567266011", "type": "motion", "start": 1642976245984, "end": 1642976267652, "score": 61, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b7d221f5f2cbae2567266011", "heatmap": "e-b7d221f5f2cbae2567266011", "modelKey": "event", "timestamp": 1642976257487 }, { "id": "5d67e0b49f3bfcc4a3d6c337", "type": "motion", "start": 1642976314112, "end": 1642976335614, "score": 77, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5d67e0b49f3bfcc4a3d6c337", "heatmap": "e-5d67e0b49f3bfcc4a3d6c337", "modelKey": "event", "timestamp": 1642976325279 }, { "id": "f075714a4f011c36a97e905d", "type": "motion", "start": 1642976317278, "end": 1642976338609, "score": 19, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f075714a4f011c36a97e905d", "heatmap": "e-f075714a4f011c36a97e905d", "modelKey": "event", "timestamp": 1642975368402 }, { "id": "b3229ad94ab275344a0aa07e", "type": "motion", "start": 1642976405778, "end": 1642976427280, "score": 15, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b3229ad94ab275344a0aa07e", "heatmap": "e-b3229ad94ab275344a0aa07e", "modelKey": "event", "timestamp": 1642975368402 }, { "id": "3587b69833fea7e765d2e7f4", "type": "motion", "start": 1642976854052, "end": 1642976875718, "score": 3, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3587b69833fea7e765d2e7f4", "heatmap": "e-3587b69833fea7e765d2e7f4", "modelKey": "event", "timestamp": 1642975368402 }, { "id": "1fd3e7683a1a8e40ab3f905b", "type": "motion", "start": 1642976915894, "end": 1642976936899, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1fd3e7683a1a8e40ab3f905b", "heatmap": "e-1fd3e7683a1a8e40ab3f905b", "modelKey": "event", "timestamp": 1642975368402 }, { "id": "27fb0746f7a2a4c90ba49a56", "type": "motion", "start": 1642977000383, "end": 1642977024243, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-27fb0746f7a2a4c90ba49a56", "heatmap": "e-27fb0746f7a2a4c90ba49a56", "modelKey": "event", "timestamp": 1642977013392 }, { "id": "dd65b60d3b1f338f8b549321", "type": "motion", "start": 1642977034265, "end": 1642977055433, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-dd65b60d3b1f338f8b549321", "heatmap": "e-dd65b60d3b1f338f8b549321", "modelKey": "event", "timestamp": 1642977045431 }, { "id": "c122006a53b258edfa93d494", "type": "motion", "start": 1642977071631, "end": 1642977093134, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c122006a53b258edfa93d494", "heatmap": "e-c122006a53b258edfa93d494", "modelKey": "event", "timestamp": 1642977083132 }, { "id": "a2b14c9c3d393da58a2a0398", "type": "motion", "start": 1642977130901, "end": 1642977154249, "score": 50, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a2b14c9c3d393da58a2a0398", "heatmap": "e-a2b14c9c3d393da58a2a0398", "modelKey": "event", "timestamp": 1642977144236 }, { "id": "32841ab7ec7134d14b55c917", "type": "motion", "start": 1642977242055, "end": 1642977264721, "score": 77, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-32841ab7ec7134d14b55c917", "heatmap": "e-32841ab7ec7134d14b55c917", "modelKey": "event", "timestamp": 1642977254556 }, { "id": "de23b0e92145f04c791711f8", "type": "motion", "start": 1642977436447, "end": 1642977458778, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-de23b0e92145f04c791711f8", "heatmap": "e-de23b0e92145f04c791711f8", "modelKey": "event", "timestamp": 1642977447613 }, { "id": "17106adce9147df87b035cd2", "type": "motion", "start": 1642977610587, "end": 1642977639095, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-17106adce9147df87b035cd2", "heatmap": "e-17106adce9147df87b035cd2", "modelKey": "event", "timestamp": 1642977621921 }, { "id": "c8a57e19237f018fb20d694b", "type": "motion", "start": 1642977615207, "end": 1642977640883, "score": 57, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c8a57e19237f018fb20d694b", "heatmap": "e-c8a57e19237f018fb20d694b", "modelKey": "event", "timestamp": 1642977626374 }, { "id": "b0800e87be918f9cf7353ea5", "type": "motion", "start": 1642977629634, "end": 1642977651142, "score": 15, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b0800e87be918f9cf7353ea5", "heatmap": "e-b0800e87be918f9cf7353ea5", "modelKey": "event", "timestamp": 1642977447613 }, { "id": "33a335643e48585e0a3a9260", "type": "motion", "start": 1642977829040, "end": 1642977851217, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-33a335643e48585e0a3a9260", "heatmap": "e-33a335643e48585e0a3a9260", "modelKey": "event", "timestamp": 1642977841207 }, { "id": "6f5d96c3a515e310b4dbe486", "type": "motion", "start": 1642977838400, "end": 1642977860752, "score": 77, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6f5d96c3a515e310b4dbe486", "heatmap": "e-6f5d96c3a515e310b4dbe486", "modelKey": "event", "timestamp": 1642977850734 }, { "id": "85c9621a7e08eb240e68d4f6", "type": "access", "start": 1642977896829, "end": 1642977897647, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "bc3dd633553907952a6fe20d", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-85c9621a7e08eb240e68d4f6", "heatmap": "e-85c9621a7e08eb240e68d4f6", "modelKey": "event", "timestamp": null }, { "id": "6a9e8a746b3362055d2d4f8b", "type": "motion", "start": 1642978058262, "end": 1642978079767, "score": 97, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6a9e8a746b3362055d2d4f8b", "heatmap": "e-6a9e8a746b3362055d2d4f8b", "modelKey": "event", "timestamp": 1642978069763 }, { "id": "4e0948c189f36df1e865a45c", "type": "sensorMotion", "start": 1642978102224, "end": 1642978102224, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "d3cdb13d7b66b7d67763cd2e" }, "sensorName": { "text": "Rbezboy Hjrgz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4e0948c189f36df1e865a45c", "heatmap": "e-4e0948c189f36df1e865a45c", "modelKey": "event", "timestamp": null }, { "id": "0a0073b1e7e8750dfc7b3e1a", "type": "motion", "start": 1642978317996, "end": 1642978339163, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0a0073b1e7e8750dfc7b3e1a", "heatmap": "e-0a0073b1e7e8750dfc7b3e1a", "modelKey": "event", "timestamp": 1642978069763 }, { "id": "808029f1340fcdd3dee49582", "type": "motion", "start": 1642978352564, "end": 1642978373704, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-808029f1340fcdd3dee49582", "heatmap": "e-808029f1340fcdd3dee49582", "modelKey": "event", "timestamp": 1642978069763 }, { "id": "7e59c8685c430b8f7dbb463e", "type": "smartDetectZone", "start": 1642978465859, "end": 1642978532379, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7e59c8685c430b8f7dbb463e", "heatmap": "e-7e59c8685c430b8f7dbb463e", "modelKey": "event", "timestamp": 1642978507025 }, { "id": "398dcd21eedb6a5681177f0a", "type": "motion", "start": 1642978468754, "end": 1642978490300, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "7e59c8685c430b8f7dbb463e" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-398dcd21eedb6a5681177f0a", "heatmap": "e-398dcd21eedb6a5681177f0a", "modelKey": "event", "timestamp": 1642978481087 }, { "id": "f9201b227bdfa0fa99b687f2", "type": "sensorMotion", "start": 1642978477311, "end": 1642978477311, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Crjerhp Jnkrfmn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f9201b227bdfa0fa99b687f2", "heatmap": "e-f9201b227bdfa0fa99b687f2", "modelKey": "event", "timestamp": null }, { "id": "6209a271636149b4b4560f34", "type": "sensorMotion", "start": 1642978481302, "end": 1642978481302, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gee Goaod" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6209a271636149b4b4560f34", "heatmap": "e-6209a271636149b4b4560f34", "modelKey": "event", "timestamp": null }, { "id": "2694ba9e96dc9002051644e1", "type": "sensorMotion", "start": 1642978485036, "end": 1642978485036, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pcde Dpqoid" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2694ba9e96dc9002051644e1", "heatmap": "e-2694ba9e96dc9002051644e1", "modelKey": "event", "timestamp": null }, { "id": "436c3de821f2b03a1179ff31", "type": "motion", "start": 1642978495301, "end": 1642978533449, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "7e59c8685c430b8f7dbb463e" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-436c3de821f2b03a1179ff31", "heatmap": "e-436c3de821f2b03a1179ff31", "modelKey": "event", "timestamp": 1642978507300 }, { "id": "30ca50d4daa9a2834dfdc4bd", "type": "sensorMotion", "start": 1642978506303, "end": 1642978506303, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yqiz Kcwdtvq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-30ca50d4daa9a2834dfdc4bd", "heatmap": "e-30ca50d4daa9a2834dfdc4bd", "modelKey": "event", "timestamp": null }, { "id": "087f4e80f84c5de8c1e46aa9", "type": "sensorMotion", "start": 1642978511302, "end": 1642978511302, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mrorsm Inps" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-087f4e80f84c5de8c1e46aa9", "heatmap": "e-087f4e80f84c5de8c1e46aa9", "modelKey": "event", "timestamp": null }, { "id": "876800d5a96efa74c1c7da9e", "type": "sensorMotion", "start": 1642978515293, "end": 1642978515293, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pmexc Ggna" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-876800d5a96efa74c1c7da9e", "heatmap": "e-876800d5a96efa74c1c7da9e", "modelKey": "event", "timestamp": null }, { "id": "8bfa6615112c158aa6a7a44d", "type": "sensorMotion", "start": 1642978519285, "end": 1642978519285, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wpxkcco Oyt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8bfa6615112c158aa6a7a44d", "heatmap": "e-8bfa6615112c158aa6a7a44d", "modelKey": "event", "timestamp": null }, { "id": "fb8bb0b9baddcde8d5dbcadf", "type": "sensorMotion", "start": 1642978523276, "end": 1642978523276, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ztk Rpiek" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-fb8bb0b9baddcde8d5dbcadf", "heatmap": "e-fb8bb0b9baddcde8d5dbcadf", "modelKey": "event", "timestamp": null }, { "id": "06811d49b3b67d73737ee249", "type": "motion", "start": 1642978595601, "end": 1642978616779, "score": 93, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-06811d49b3b67d73737ee249", "heatmap": "e-06811d49b3b67d73737ee249", "modelKey": "event", "timestamp": 1642978606767 }, { "id": "4c526e8ae35f2f77fb55495c", "type": "motion", "start": 1642978681543, "end": 1642978705213, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4c526e8ae35f2f77fb55495c", "heatmap": "e-4c526e8ae35f2f77fb55495c", "modelKey": "event", "timestamp": 1642978692709 }, { "id": "b0edd28c08a2e5a35c368aec", "type": "motion", "start": 1642978933757, "end": 1642978955088, "score": 73, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b0edd28c08a2e5a35c368aec", "heatmap": "e-b0edd28c08a2e5a35c368aec", "modelKey": "event", "timestamp": 1642978944938 }, { "id": "a2893ac92d081e3ae4643765", "type": "motion", "start": 1642979292234, "end": 1642979313905, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a2893ac92d081e3ae4643765", "heatmap": "e-a2893ac92d081e3ae4643765", "modelKey": "event", "timestamp": 1642979303400 }, { "id": "9df31e89eb39db23fe76942c", "type": "motion", "start": 1642979345888, "end": 1642979368916, "score": 35, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9df31e89eb39db23fe76942c", "heatmap": "e-9df31e89eb39db23fe76942c", "modelKey": "event", "timestamp": 1642979359722 }, { "id": "df57d0675d4b097a36412a00", "type": "motion", "start": 1642979348133, "end": 1642979379675, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-df57d0675d4b097a36412a00", "heatmap": "e-df57d0675d4b097a36412a00", "modelKey": "event", "timestamp": 1642979367671 }, { "id": "8a36358b15dbb2111748f733", "type": "motion", "start": 1642979353892, "end": 1642979375396, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8a36358b15dbb2111748f733", "heatmap": "e-8a36358b15dbb2111748f733", "modelKey": "event", "timestamp": 1642979365393 }, { "id": "2e69c58e38d886753b7c9226", "type": "motion", "start": 1642979447431, "end": 1642979468766, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2e69c58e38d886753b7c9226", "heatmap": "e-2e69c58e38d886753b7c9226", "modelKey": "event", "timestamp": 1642979458741 }, { "id": "b543c8e41379324564ca0aad", "type": "motion", "start": 1642979450451, "end": 1642979471646, "score": 77, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b543c8e41379324564ca0aad", "heatmap": "e-b543c8e41379324564ca0aad", "modelKey": "event", "timestamp": 1642979461618 }, { "id": "59df2295b8f602c8ed0ec82a", "type": "smartDetectZone", "start": 1642979519273, "end": 1642979703149, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-59df2295b8f602c8ed0ec82a", "heatmap": "e-59df2295b8f602c8ed0ec82a", "modelKey": "event", "timestamp": 1642979530739 }, { "id": "10e5bef8bebbfb943adf76e5", "type": "motion", "start": 1642979519547, "end": 1642979541725, "score": 81, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-10e5bef8bebbfb943adf76e5", "heatmap": "e-10e5bef8bebbfb943adf76e5", "modelKey": "event", "timestamp": 1642979530714 }, { "id": "09251c45f7e53b74055ea0d3", "type": "motion", "start": 1642979520998, "end": 1642979543335, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "59df2295b8f602c8ed0ec82a" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-09251c45f7e53b74055ea0d3", "heatmap": "e-09251c45f7e53b74055ea0d3", "modelKey": "event", "timestamp": 1642979534168 }, { "id": "863f269c4a9b5d5e782b32dc", "type": "sensorMotion", "start": 1642979530451, "end": 1642979530451, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Asyxgo Xwjal" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-863f269c4a9b5d5e782b32dc", "heatmap": "e-863f269c4a9b5d5e782b32dc", "modelKey": "event", "timestamp": null }, { "id": "0ecec233d8b9414256bddea0", "type": "sensorMotion", "start": 1642979534908, "end": 1642979534908, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cryie Cnsvwgt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0ecec233d8b9414256bddea0", "heatmap": "e-0ecec233d8b9414256bddea0", "modelKey": "event", "timestamp": null }, { "id": "2b96889acb024024e495c47c", "type": "sensorMotion", "start": 1642979540025, "end": 1642979540025, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mknjlb Gacpq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2b96889acb024024e495c47c", "heatmap": "e-2b96889acb024024e495c47c", "modelKey": "event", "timestamp": null }, { "id": "0149db821144673168e2fc24", "type": "motion", "start": 1642979540063, "end": 1642979561754, "score": 55, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0149db821144673168e2fc24", "heatmap": "e-0149db821144673168e2fc24", "modelKey": "event", "timestamp": 1642979551232 }, { "id": "989f17a114109d278e3b6882", "type": "sensorMotion", "start": 1642979543549, "end": 1642979543549, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nufcrk Icbrdco" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-989f17a114109d278e3b6882", "heatmap": "e-989f17a114109d278e3b6882", "modelKey": "event", "timestamp": null }, { "id": "383c38bff5e78d279adade9f", "type": "sensorMotion", "start": 1642979545996, "end": 1642979545996, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gsil Whuw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-383c38bff5e78d279adade9f", "heatmap": "e-383c38bff5e78d279adade9f", "modelKey": "event", "timestamp": null }, { "id": "3b1d85e0b1a40459b6672446", "type": "sensorMotion", "start": 1642979549991, "end": 1642979549991, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Swstbp Cdjri" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3b1d85e0b1a40459b6672446", "heatmap": "e-3b1d85e0b1a40459b6672446", "modelKey": "event", "timestamp": null }, { "id": "cd41002cd98136f8a7cc7eb9", "type": "sensorMotion", "start": 1642979555896, "end": 1642979555896, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pfq Pqrz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cd41002cd98136f8a7cc7eb9", "heatmap": "e-cd41002cd98136f8a7cc7eb9", "modelKey": "event", "timestamp": null }, { "id": "4ba067855995b9c8ec94c083", "type": "sensorMotion", "start": 1642979559372, "end": 1642979559372, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iwsduo Dbrq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4ba067855995b9c8ec94c083", "heatmap": "e-4ba067855995b9c8ec94c083", "modelKey": "event", "timestamp": null }, { "id": "891f2f98359abadefab305c0", "type": "sensorMotion", "start": 1642979563121, "end": 1642979563121, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nbrhneo Yvsubr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-891f2f98359abadefab305c0", "heatmap": "e-891f2f98359abadefab305c0", "modelKey": "event", "timestamp": null }, { "id": "d9d95d0ca78f95608ebb8574", "type": "sensorMotion", "start": 1642979567237, "end": 1642979567237, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wfucpum Fyhekko" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d9d95d0ca78f95608ebb8574", "heatmap": "e-d9d95d0ca78f95608ebb8574", "modelKey": "event", "timestamp": null }, { "id": "c81cbc14dceaef98d6f2cf5e", "type": "sensorMotion", "start": 1642979571990, "end": 1642979571990, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rgmwf Cxfj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c81cbc14dceaef98d6f2cf5e", "heatmap": "e-c81cbc14dceaef98d6f2cf5e", "modelKey": "event", "timestamp": null }, { "id": "b8fee3cd536842130f75af92", "type": "sensorMotion", "start": 1642979575724, "end": 1642979575724, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Bnqdj Pwpk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b8fee3cd536842130f75af92", "heatmap": "e-b8fee3cd536842130f75af92", "modelKey": "event", "timestamp": null }, { "id": "d34702e5598d6bba0caab1f9", "type": "sensorMotion", "start": 1642979585123, "end": 1642979585123, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xhmyuej Xkfou" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d34702e5598d6bba0caab1f9", "heatmap": "e-d34702e5598d6bba0caab1f9", "modelKey": "event", "timestamp": null }, { "id": "bbf0a77e7d5d571285c0881b", "type": "sensorMotion", "start": 1642979589115, "end": 1642979589115, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zsol Qwdmxl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bbf0a77e7d5d571285c0881b", "heatmap": "e-bbf0a77e7d5d571285c0881b", "modelKey": "event", "timestamp": null }, { "id": "a0351b58da162badb91ddab9", "type": "sensorMotion", "start": 1642979593234, "end": 1642979593234, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Baeztl Sdwwo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a0351b58da162badb91ddab9", "heatmap": "e-a0351b58da162badb91ddab9", "modelKey": "event", "timestamp": null }, { "id": "5dee731f98d4d1254294c5de", "type": "sensorMotion", "start": 1642979597548, "end": 1642979597548, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yvoeu Gywcdhu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5dee731f98d4d1254294c5de", "heatmap": "e-5dee731f98d4d1254294c5de", "modelKey": "event", "timestamp": null }, { "id": "787844f67e6d44c4db3b84ac", "type": "sensorMotion", "start": 1642979601475, "end": 1642979601475, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Glppy Mjgg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-787844f67e6d44c4db3b84ac", "heatmap": "e-787844f67e6d44c4db3b84ac", "modelKey": "event", "timestamp": null }, { "id": "39a0673d57228d9e5a671078", "type": "sensorMotion", "start": 1642979605466, "end": 1642979605466, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fsz Gryfx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-39a0673d57228d9e5a671078", "heatmap": "e-39a0673d57228d9e5a671078", "modelKey": "event", "timestamp": null }, { "id": "fa436575786083c52c93c9c8", "type": "sensorMotion", "start": 1642979611518, "end": 1642979611518, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xuet Zguq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-fa436575786083c52c93c9c8", "heatmap": "e-fa436575786083c52c93c9c8", "modelKey": "event", "timestamp": null }, { "id": "7657e49101da91cee440716d", "type": "sensorMotion", "start": 1642979617440, "end": 1642979617440, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Aja Lak" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7657e49101da91cee440716d", "heatmap": "e-7657e49101da91cee440716d", "modelKey": "event", "timestamp": null }, { "id": "0f8c08c7ebfce25ee3bfd35b", "type": "sensorMotion", "start": 1642979626196, "end": 1642979626196, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Risuq Ckw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0f8c08c7ebfce25ee3bfd35b", "heatmap": "e-0f8c08c7ebfce25ee3bfd35b", "modelKey": "event", "timestamp": null }, { "id": "a7104fc66503e4bf3d2f05e2", "type": "sensorMotion", "start": 1642979630961, "end": 1642979630961, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gjtdwe Drm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a7104fc66503e4bf3d2f05e2", "heatmap": "e-a7104fc66503e4bf3d2f05e2", "modelKey": "event", "timestamp": null }, { "id": "899dccb50862e258deec7040", "type": "motion", "start": 1642979635036, "end": 1642979656369, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-899dccb50862e258deec7040", "heatmap": "e-899dccb50862e258deec7040", "modelKey": "event", "timestamp": 1642979646369 }, { "id": "d528eeafefe698ac2a27bf7d", "type": "motion", "start": 1642979637995, "end": 1642979661492, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "59df2295b8f602c8ed0ec82a" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d528eeafefe698ac2a27bf7d", "heatmap": "e-d528eeafefe698ac2a27bf7d", "modelKey": "event", "timestamp": 1642979651159 }, { "id": "5523fb7b25679097b77d576f", "type": "sensorMotion", "start": 1642979638428, "end": 1642979638428, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mfjvf Lubmu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5523fb7b25679097b77d576f", "heatmap": "e-5523fb7b25679097b77d576f", "modelKey": "event", "timestamp": null }, { "id": "e915a0896f233b0af3d1a522", "type": "sensorMotion", "start": 1642979643450, "end": 1642979643450, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kqk Gcfdw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e915a0896f233b0af3d1a522", "heatmap": "e-e915a0896f233b0af3d1a522", "modelKey": "event", "timestamp": null }, { "id": "8994aef61f77c99c48fac147", "type": "sensorMotion", "start": 1642979647955, "end": 1642979647955, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ewblhdq Wrwkg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8994aef61f77c99c48fac147", "heatmap": "e-8994aef61f77c99c48fac147", "modelKey": "event", "timestamp": null }, { "id": "78243d5dbf5e6ab9830a0ebd", "type": "sensorMotion", "start": 1642979650533, "end": 1642979650533, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Askfh Tvje" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-78243d5dbf5e6ab9830a0ebd", "heatmap": "e-78243d5dbf5e6ab9830a0ebd", "modelKey": "event", "timestamp": null }, { "id": "0df41198323f929f8dc94ae1", "type": "sensorMotion", "start": 1642979658643, "end": 1642979658643, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ppqpspe Kodsvut" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0df41198323f929f8dc94ae1", "heatmap": "e-0df41198323f929f8dc94ae1", "modelKey": "event", "timestamp": null }, { "id": "62de790fe8f456613006605c", "type": "sensorMotion", "start": 1642979663149, "end": 1642979663149, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qsvp Dccc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-62de790fe8f456613006605c", "heatmap": "e-62de790fe8f456613006605c", "modelKey": "event", "timestamp": null }, { "id": "b1c1e8b5b7b8f4ede9b54720", "type": "sensorMotion", "start": 1642979667011, "end": 1642979667011, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Eugnluq Ymrrsqu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b1c1e8b5b7b8f4ede9b54720", "heatmap": "e-b1c1e8b5b7b8f4ede9b54720", "modelKey": "event", "timestamp": null }, { "id": "97aff8d980ffa82ba6fdfe4c", "type": "sensorMotion", "start": 1642979671518, "end": 1642979671518, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cpdfxed Ulcgjlq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-97aff8d980ffa82ba6fdfe4c", "heatmap": "e-97aff8d980ffa82ba6fdfe4c", "modelKey": "event", "timestamp": null }, { "id": "aae4a7f10fedfac3eae036b4", "type": "sensorMotion", "start": 1642979674994, "end": 1642979674994, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nwk Sybcwv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-aae4a7f10fedfac3eae036b4", "heatmap": "e-aae4a7f10fedfac3eae036b4", "modelKey": "event", "timestamp": null }, { "id": "17066c1d89dc60fc4c50e450", "type": "sensorMotion", "start": 1642979680284, "end": 1642979680284, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Phn Yvcvgmp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-17066c1d89dc60fc4c50e450", "heatmap": "e-17066c1d89dc60fc4c50e450", "modelKey": "event", "timestamp": null }, { "id": "79851ffab5d129ea5afdf57b", "type": "sensorMotion", "start": 1642979682978, "end": 1642979682978, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Uaoqz Wvgeis" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-79851ffab5d129ea5afdf57b", "heatmap": "e-79851ffab5d129ea5afdf57b", "modelKey": "event", "timestamp": null }, { "id": "1e35567d2c6afe74f7419a8c", "type": "sensorMotion", "start": 1642979687273, "end": 1642979687273, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hbyt Yewwid" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1e35567d2c6afe74f7419a8c", "heatmap": "e-1e35567d2c6afe74f7419a8c", "modelKey": "event", "timestamp": null }, { "id": "e307a4ba2cb2650b088db105", "type": "sensorMotion", "start": 1642979695209, "end": 1642979695209, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cjbiws Senki" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e307a4ba2cb2650b088db105", "heatmap": "e-e307a4ba2cb2650b088db105", "modelKey": "event", "timestamp": null }, { "id": "33aa2cc2ba2d70ae4bf84575", "type": "sensorMotion", "start": 1642979700617, "end": 1642979700617, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sfo Cdqd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-33aa2cc2ba2d70ae4bf84575", "heatmap": "e-33aa2cc2ba2d70ae4bf84575", "modelKey": "event", "timestamp": null }, { "id": "0070932c00112a891cd7d84a", "type": "sensorMotion", "start": 1642979704994, "end": 1642979704994, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ncaykcn Lnly" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0070932c00112a891cd7d84a", "heatmap": "e-0070932c00112a891cd7d84a", "modelKey": "event", "timestamp": null }, { "id": "012f74810039927bf4d4aae3", "type": "motion", "start": 1642979780217, "end": 1642979803232, "score": 6, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-012f74810039927bf4d4aae3", "heatmap": "e-012f74810039927bf4d4aae3", "modelKey": "event", "timestamp": 1642979551232 }, { "id": "efd419551a60f2352c587716", "type": "motion", "start": 1642979901163, "end": 1642979922329, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-efd419551a60f2352c587716", "heatmap": "e-efd419551a60f2352c587716", "modelKey": "event", "timestamp": 1642979646369 }, { "id": "46ee2925d684c7181def7671", "type": "motion", "start": 1642979903355, "end": 1642979926388, "score": 14, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-46ee2925d684c7181def7671", "heatmap": "e-46ee2925d684c7181def7671", "modelKey": "event", "timestamp": 1642979551232 }, { "id": "ef3816c94c5a3b3f0bc8bfc9", "type": "motion", "start": 1642980041292, "end": 1642980069728, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ef3816c94c5a3b3f0bc8bfc9", "heatmap": "e-ef3816c94c5a3b3f0bc8bfc9", "modelKey": "event", "timestamp": 1642980055410 }, { "id": "72dd8f7f55b9ec55d921a998", "type": "motion", "start": 1642980045300, "end": 1642980067632, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-72dd8f7f55b9ec55d921a998", "heatmap": "e-72dd8f7f55b9ec55d921a998", "modelKey": "event", "timestamp": 1642980057466 }, { "id": "804ae3f4ac357f690be7edc5", "type": "motion", "start": 1642980053163, "end": 1642980075162, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-804ae3f4ac357f690be7edc5", "heatmap": "e-804ae3f4ac357f690be7edc5", "modelKey": "event", "timestamp": 1642979551232 }, { "id": "027d78b9ee38e776b6caee91", "type": "sensorMotion", "start": 1642980053586, "end": 1642980053586, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Prb Rycs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-027d78b9ee38e776b6caee91", "heatmap": "e-027d78b9ee38e776b6caee91", "modelKey": "event", "timestamp": null }, { "id": "ddaec34412b6f9972b83fe30", "type": "sensorMotion", "start": 1642980057597, "end": 1642980057597, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Rncjw Ore" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ddaec34412b6f9972b83fe30", "heatmap": "e-ddaec34412b6f9972b83fe30", "modelKey": "event", "timestamp": null }, { "id": "3997a67b585b2b1573f1740e", "type": "sensorMotion", "start": 1642980062504, "end": 1642980062504, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Udnjvx Ewgl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3997a67b585b2b1573f1740e", "heatmap": "e-3997a67b585b2b1573f1740e", "modelKey": "event", "timestamp": null }, { "id": "5f23dad0a12dd83a778a8aa9", "type": "sensorMotion", "start": 1642980068249, "end": 1642980068249, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hxmwmaf Lzsj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5f23dad0a12dd83a778a8aa9", "heatmap": "e-5f23dad0a12dd83a778a8aa9", "modelKey": "event", "timestamp": null }, { "id": "d08390d5f920b1b1ef59151b", "type": "motion", "start": 1642980071754, "end": 1642980093560, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d08390d5f920b1b1ef59151b", "heatmap": "e-d08390d5f920b1b1ef59151b", "modelKey": "event", "timestamp": 1642979551232 }, { "id": "bab0fedeb65e4ca2f7d8bc1e", "type": "smartDetectZone", "start": 1642980079109, "end": 1642980281265, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bab0fedeb65e4ca2f7d8bc1e", "heatmap": "e-bab0fedeb65e4ca2f7d8bc1e", "modelKey": "event", "timestamp": 1642980113071 }, { "id": "32193c85c1f69df89c7f68d1", "type": "motion", "start": 1642980081980, "end": 1642980103396, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "bab0fedeb65e4ca2f7d8bc1e" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-32193c85c1f69df89c7f68d1", "heatmap": "e-32193c85c1f69df89c7f68d1", "modelKey": "event", "timestamp": 1642980094479 }, { "id": "c2e8544dd0d5e59889369115", "type": "sensorMotion", "start": 1642980089716, "end": 1642980089716, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qbdkh Gvteug" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c2e8544dd0d5e59889369115", "heatmap": "e-c2e8544dd0d5e59889369115", "modelKey": "event", "timestamp": null }, { "id": "7399cfd7a5c33672ce1fa268", "type": "motion", "start": 1642980092802, "end": 1642980114460, "score": 99, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7399cfd7a5c33672ce1fa268", "heatmap": "e-7399cfd7a5c33672ce1fa268", "modelKey": "event", "timestamp": 1642980104128 }, { "id": "9509c5a2c26a6368fcbdc2c9", "type": "motion", "start": 1642980093226, "end": 1642980115321, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9509c5a2c26a6368fcbdc2c9", "heatmap": "e-9509c5a2c26a6368fcbdc2c9", "modelKey": "event", "timestamp": 1642980106394 }, { "id": "1f56103deb53f188130b1c41", "type": "sensorMotion", "start": 1642980095381, "end": 1642980095381, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Durjnea Alh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1f56103deb53f188130b1c41", "heatmap": "e-1f56103deb53f188130b1c41", "modelKey": "event", "timestamp": null }, { "id": "d7510485cc0f912117514a0b", "type": "sensorMotion", "start": 1642980098987, "end": 1642980098987, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dpcf Gru" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d7510485cc0f912117514a0b", "heatmap": "e-d7510485cc0f912117514a0b", "modelKey": "event", "timestamp": null }, { "id": "ba92048cba21931f5f4e2239", "type": "sensorMotion", "start": 1642980104786, "end": 1642980104786, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gtw Wfxtj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ba92048cba21931f5f4e2239", "heatmap": "e-ba92048cba21931f5f4e2239", "modelKey": "event", "timestamp": null }, { "id": "e3db9867110c2df2a792dbb1", "type": "sensorMotion", "start": 1642980108658, "end": 1642980108658, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ttjv Eooqbrh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e3db9867110c2df2a792dbb1", "heatmap": "e-e3db9867110c2df2a792dbb1", "modelKey": "event", "timestamp": null }, { "id": "9f55ff4c4c76927c3790e83b", "type": "sensorMotion", "start": 1642980111748, "end": 1642980111748, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Adzgi Kag" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9f55ff4c4c76927c3790e83b", "heatmap": "e-9f55ff4c4c76927c3790e83b", "modelKey": "event", "timestamp": null }, { "id": "6f84b35eb6816bfe4731238f", "type": "sensorMotion", "start": 1642980116255, "end": 1642980116255, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zoalcpm Wxivqw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6f84b35eb6816bfe4731238f", "heatmap": "e-6f84b35eb6816bfe4731238f", "modelKey": "event", "timestamp": null }, { "id": "5d5de998abb19d9c05f06b11", "type": "sensorMotion", "start": 1642980120747, "end": 1642980120747, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vve Hjgnogu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5d5de998abb19d9c05f06b11", "heatmap": "e-5d5de998abb19d9c05f06b11", "modelKey": "event", "timestamp": null }, { "id": "224dac5d37aa03b649683242", "type": "sensorMotion", "start": 1642980123851, "end": 1642980123851, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Etq Sqny" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-224dac5d37aa03b649683242", "heatmap": "e-224dac5d37aa03b649683242", "modelKey": "event", "timestamp": null }, { "id": "673432c66ef08cfbccd87fdf", "type": "sensorMotion", "start": 1642980131818, "end": 1642980131818, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wvsiz Eixs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-673432c66ef08cfbccd87fdf", "heatmap": "e-673432c66ef08cfbccd87fdf", "modelKey": "event", "timestamp": null }, { "id": "613a3eceee5443e2b05c2589", "type": "sensorMotion", "start": 1642980135939, "end": 1642980135939, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dcw Cwob" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-613a3eceee5443e2b05c2589", "heatmap": "e-613a3eceee5443e2b05c2589", "modelKey": "event", "timestamp": null }, { "id": "58fb6ce5f8a46ef713144da2", "type": "sensorMotion", "start": 1642980140026, "end": 1642980140026, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zwwppl Kznbxoo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-58fb6ce5f8a46ef713144da2", "heatmap": "e-58fb6ce5f8a46ef713144da2", "modelKey": "event", "timestamp": null }, { "id": "692ed11c9f6b5545761c8a01", "type": "sensorMotion", "start": 1642980147701, "end": 1642980147701, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ydxcr Vubjepl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-692ed11c9f6b5545761c8a01", "heatmap": "e-692ed11c9f6b5545761c8a01", "modelKey": "event", "timestamp": null }, { "id": "2ae0464f74b21be5ae619104", "type": "sensorMotion", "start": 1642980152520, "end": 1642980152520, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dpkja Grtga" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2ae0464f74b21be5ae619104", "heatmap": "e-2ae0464f74b21be5ae619104", "modelKey": "event", "timestamp": null }, { "id": "6a3a3532ef1a84d610fb1ce4", "type": "motion", "start": 1642980153636, "end": 1642980175304, "score": 91, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6a3a3532ef1a84d610fb1ce4", "heatmap": "e-6a3a3532ef1a84d610fb1ce4", "modelKey": "event", "timestamp": 1642980165137 }, { "id": "52620998ba9314f34415a19e", "type": "sensorMotion", "start": 1642980156894, "end": 1642980156894, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cchgrq Mylsqyp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-52620998ba9314f34415a19e", "heatmap": "e-52620998ba9314f34415a19e", "modelKey": "event", "timestamp": null }, { "id": "b271eedcbfe979c918a0188c", "type": "sensorMotion", "start": 1642980161530, "end": 1642980161530, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nvu Lrpuk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b271eedcbfe979c918a0188c", "heatmap": "e-b271eedcbfe979c918a0188c", "modelKey": "event", "timestamp": null }, { "id": "29856a237202fff20cdac328", "type": "sensorMotion", "start": 1642980165907, "end": 1642980165907, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ozvjz Wsala" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-29856a237202fff20cdac328", "heatmap": "e-29856a237202fff20cdac328", "modelKey": "event", "timestamp": null }, { "id": "e310528236a9745d32128da6", "type": "sensorMotion", "start": 1642980170451, "end": 1642980170451, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gnc Ubsg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e310528236a9745d32128da6", "heatmap": "e-e310528236a9745d32128da6", "modelKey": "event", "timestamp": null }, { "id": "170bb5ba868e3ba0604a0f53", "type": "sensorMotion", "start": 1642980173504, "end": 1642980173504, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wokipn Xzqy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-170bb5ba868e3ba0604a0f53", "heatmap": "e-170bb5ba868e3ba0604a0f53", "modelKey": "event", "timestamp": null }, { "id": "4044b2272420da05a9b27bf0", "type": "sensorMotion", "start": 1642980190931, "end": 1642980190931, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dqy Agdmwm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4044b2272420da05a9b27bf0", "heatmap": "e-4044b2272420da05a9b27bf0", "modelKey": "event", "timestamp": null }, { "id": "0de3fa15d0c1dd9e2c124ea9", "type": "sensorMotion", "start": 1642980195906, "end": 1642980195906, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Anw Rwpejhk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0de3fa15d0c1dd9e2c124ea9", "heatmap": "e-0de3fa15d0c1dd9e2c124ea9", "modelKey": "event", "timestamp": null }, { "id": "32cee6034cee45a5c077fc8b", "type": "sensorMotion", "start": 1642980203117, "end": 1642980203117, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Etvjjmq Ggz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-32cee6034cee45a5c077fc8b", "heatmap": "e-32cee6034cee45a5c077fc8b", "modelKey": "event", "timestamp": null }, { "id": "6da684e6813e32cdc3ab3c60", "type": "sensorMotion", "start": 1642980207113, "end": 1642980207113, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dnu Kifvr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6da684e6813e32cdc3ab3c60", "heatmap": "e-6da684e6813e32cdc3ab3c60", "modelKey": "event", "timestamp": null }, { "id": "1e5e7221280bc21ad147de02", "type": "sensorMotion", "start": 1642980213418, "end": 1642980213418, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ssevukv Ocxfh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1e5e7221280bc21ad147de02", "heatmap": "e-1e5e7221280bc21ad147de02", "modelKey": "event", "timestamp": null }, { "id": "aeeb5be2ef200f7d12c6dc5f", "type": "sensorMotion", "start": 1642980217411, "end": 1642980217411, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vjcredc Ims" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-aeeb5be2ef200f7d12c6dc5f", "heatmap": "e-aeeb5be2ef200f7d12c6dc5f", "modelKey": "event", "timestamp": null }, { "id": "abfa4a1b7b220b3b5bd05e9b", "type": "sensorMotion", "start": 1642980223460, "end": 1642980223460, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wojqdb Mbizzwt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-abfa4a1b7b220b3b5bd05e9b", "heatmap": "e-abfa4a1b7b220b3b5bd05e9b", "modelKey": "event", "timestamp": null }, { "id": "e6572172ffc753871770489f", "type": "sensorMotion", "start": 1642980230542, "end": 1642980230542, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Tpgpn Vxiyj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e6572172ffc753871770489f", "heatmap": "e-e6572172ffc753871770489f", "modelKey": "event", "timestamp": null }, { "id": "c004d26f89049017a82644e4", "type": "sensorMotion", "start": 1642980234533, "end": 1642980234533, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fus Pdmhb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c004d26f89049017a82644e4", "heatmap": "e-c004d26f89049017a82644e4", "modelKey": "event", "timestamp": null }, { "id": "beb5235526975c27cc8b5e01", "type": "sensorMotion", "start": 1642980238654, "end": 1642980238654, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Oewjezs Atlvqns" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-beb5235526975c27cc8b5e01", "heatmap": "e-beb5235526975c27cc8b5e01", "modelKey": "event", "timestamp": null }, { "id": "536f58e951f77fb327180949", "type": "sensorMotion", "start": 1642980242645, "end": 1642980242645, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cjhz Coksvoa" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-536f58e951f77fb327180949", "heatmap": "e-536f58e951f77fb327180949", "modelKey": "event", "timestamp": null }, { "id": "abe7805281caaf79eae92dd9", "type": "motion", "start": 1642980243637, "end": 1642980268306, "score": 75, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-abe7805281caaf79eae92dd9", "heatmap": "e-abe7805281caaf79eae92dd9", "modelKey": "event", "timestamp": 1642980256304 }, { "id": "df23e3b0136ebaa195295595", "type": "motion", "start": 1642980247233, "end": 1642980276293, "score": 83, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-df23e3b0136ebaa195295595", "heatmap": "e-df23e3b0136ebaa195295595", "modelKey": "event", "timestamp": 1642980265120 }, { "id": "b84f6cad517fac4d3a0cdc77", "type": "sensorMotion", "start": 1642980247932, "end": 1642980247932, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Anbcs Hygd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b84f6cad517fac4d3a0cdc77", "heatmap": "e-b84f6cad517fac4d3a0cdc77", "modelKey": "event", "timestamp": null }, { "id": "2a17cd129d52642572680eac", "type": "sensorMotion", "start": 1642980252173, "end": 1642980252173, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Prte Admwnhj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2a17cd129d52642572680eac", "heatmap": "e-2a17cd129d52642572680eac", "modelKey": "event", "timestamp": null }, { "id": "9fb6e60b33c888935a1740fe", "type": "sensorMotion", "start": 1642980256036, "end": 1642980256036, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qcudy Osylzp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9fb6e60b33c888935a1740fe", "heatmap": "e-9fb6e60b33c888935a1740fe", "modelKey": "event", "timestamp": null }, { "id": "e792a74724e965a90613ff29", "type": "motion", "start": 1642980256733, "end": 1642980280901, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "bab0fedeb65e4ca2f7d8bc1e" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e792a74724e965a90613ff29", "heatmap": "e-e792a74724e965a90613ff29", "modelKey": "event", "timestamp": 1642980267900 }, { "id": "daf40e5546f21769f8e0f0de", "type": "sensorMotion", "start": 1642980260156, "end": 1642980260156, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Khfg Ewsouz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-daf40e5546f21769f8e0f0de", "heatmap": "e-daf40e5546f21769f8e0f0de", "modelKey": "event", "timestamp": null }, { "id": "287b5b1ac3dccf9f49ea3ebc", "type": "sensorMotion", "start": 1642980264147, "end": 1642980264147, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Niaenc Gpno" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-287b5b1ac3dccf9f49ea3ebc", "heatmap": "e-287b5b1ac3dccf9f49ea3ebc", "modelKey": "event", "timestamp": null }, { "id": "b9400469d12a3ed367bcf110", "type": "sensorMotion", "start": 1642980268181, "end": 1642980268181, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Lvuahx Cfhpuo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b9400469d12a3ed367bcf110", "heatmap": "e-b9400469d12a3ed367bcf110", "modelKey": "event", "timestamp": null }, { "id": "c3af9d3bddd1fab315d6e2d5", "type": "sensorMotion", "start": 1642980272774, "end": 1642980272774, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Uufuy Qpbnscb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c3af9d3bddd1fab315d6e2d5", "heatmap": "e-c3af9d3bddd1fab315d6e2d5", "modelKey": "event", "timestamp": null }, { "id": "86e3fdb96b1a5af5f687dedd", "type": "sensorMotion", "start": 1642980275993, "end": 1642980275993, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yihsei Qgo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-86e3fdb96b1a5af5f687dedd", "heatmap": "e-86e3fdb96b1a5af5f687dedd", "modelKey": "event", "timestamp": null }, { "id": "d1a404c444315cad5ecb2252", "type": "sensorMotion", "start": 1642980279727, "end": 1642980279727, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ksx Ntav" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d1a404c444315cad5ecb2252", "heatmap": "e-d1a404c444315cad5ecb2252", "modelKey": "event", "timestamp": null }, { "id": "b9b8dfb4e28cebde4a317e92", "type": "sensorMotion", "start": 1642980285263, "end": 1642980285263, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wlskxq Fzhcmh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b9b8dfb4e28cebde4a317e92", "heatmap": "e-b9b8dfb4e28cebde4a317e92", "modelKey": "event", "timestamp": null }, { "id": "9670d8e96d745c4cc0204f61", "type": "smartDetectZone", "start": 1642980287051, "end": 1642980324622, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9670d8e96d745c4cc0204f61", "heatmap": "e-9670d8e96d745c4cc0204f61", "modelKey": "event", "timestamp": 1642980298051 }, { "id": "af5b6785aa4e7e7710c18e3f", "type": "sensorMotion", "start": 1642980292216, "end": 1642980292216, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rduk Bzmzh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-af5b6785aa4e7e7710c18e3f", "heatmap": "e-af5b6785aa4e7e7710c18e3f", "modelKey": "event", "timestamp": null }, { "id": "1c9521222088145b10e361b5", "type": "sensorMotion", "start": 1642980296207, "end": 1642980296207, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kjsdko Gex" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1c9521222088145b10e361b5", "heatmap": "e-1c9521222088145b10e361b5", "modelKey": "event", "timestamp": null }, { "id": "1a22d566430aa2e12f7ec641", "type": "motion", "start": 1642980297760, "end": 1642980336380, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "9670d8e96d745c4cc0204f61", "19ea4764cc26fda4b87f01db" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1a22d566430aa2e12f7ec641", "heatmap": "e-1a22d566430aa2e12f7ec641", "modelKey": "event", "timestamp": 1642980309758 }, { "id": "0c414f26ffb5c3888be64a66", "type": "sensorMotion", "start": 1642980302001, "end": 1642980302001, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vkvxbxe Dfayzr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0c414f26ffb5c3888be64a66", "heatmap": "e-0c414f26ffb5c3888be64a66", "modelKey": "event", "timestamp": null }, { "id": "19673d8f416b4ed8eafec3a2", "type": "sensorMotion", "start": 1642980305994, "end": 1642980305994, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Tfhht Hrgrumu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-19673d8f416b4ed8eafec3a2", "heatmap": "e-19673d8f416b4ed8eafec3a2", "modelKey": "event", "timestamp": null }, { "id": "19ea4764cc26fda4b87f01db", "type": "smartDetectZone", "start": 1642980309065, "end": 1642980336652, "score": 86, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-19ea4764cc26fda4b87f01db", "heatmap": "e-19ea4764cc26fda4b87f01db", "modelKey": "event", "timestamp": 1642980320374 }, { "id": "070a287f7af0b1494785facf", "type": "sensorMotion", "start": 1642980309984, "end": 1642980309984, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Alprs Kotu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-070a287f7af0b1494785facf", "heatmap": "e-070a287f7af0b1494785facf", "modelKey": "event", "timestamp": null }, { "id": "71a928bf5b3db2d034e5b7ae", "type": "sensorMotion", "start": 1642980314104, "end": 1642980314104, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ibglt Zkwl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-71a928bf5b3db2d034e5b7ae", "heatmap": "e-71a928bf5b3db2d034e5b7ae", "modelKey": "event", "timestamp": null }, { "id": "90781c6cc79618bd779c0272", "type": "sensorMotion", "start": 1642980321315, "end": 1642980321315, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Aoi Kxrech" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-90781c6cc79618bd779c0272", "heatmap": "e-90781c6cc79618bd779c0272", "modelKey": "event", "timestamp": null }, { "id": "3e52845e6e40c69490d40230", "type": "sensorMotion", "start": 1642980325950, "end": 1642980325950, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fpj Uzsd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3e52845e6e40c69490d40230", "heatmap": "e-3e52845e6e40c69490d40230", "modelKey": "event", "timestamp": null }, { "id": "8150c7143469c1c121c77df5", "type": "motion", "start": 1642980431941, "end": 1642980452943, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8150c7143469c1c121c77df5", "heatmap": "e-8150c7143469c1c121c77df5", "modelKey": "event", "timestamp": 1642980165137 }, { "id": "6f0b3b1c4ddbcc681a54df2b", "type": "motion", "start": 1642980509738, "end": 1642980530905, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6f0b3b1c4ddbcc681a54df2b", "heatmap": "e-6f0b3b1c4ddbcc681a54df2b", "modelKey": "event", "timestamp": 1642980520905 }, { "id": "f1b5ae2f1ca54abc5f341ac9", "type": "motion", "start": 1642980514042, "end": 1642980535376, "score": 83, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f1b5ae2f1ca54abc5f341ac9", "heatmap": "e-f1b5ae2f1ca54abc5f341ac9", "modelKey": "event", "timestamp": 1642980525375 }, { "id": "0d8275f8c19fa12ab785171d", "type": "motion", "start": 1642980635259, "end": 1642980656263, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0d8275f8c19fa12ab785171d", "heatmap": "e-0d8275f8c19fa12ab785171d", "modelKey": "event", "timestamp": 1642980525375 }, { "id": "166f1c76c4cd0635b3813ec1", "type": "motion", "start": 1642980825897, "end": 1642980847241, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-166f1c76c4cd0635b3813ec1", "heatmap": "e-166f1c76c4cd0635b3813ec1", "modelKey": "event", "timestamp": 1642980837063 }, { "id": "5b10fb2707ae67ec9406972a", "type": "motion", "start": 1642980899280, "end": 1642980925445, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5b10fb2707ae67ec9406972a", "heatmap": "e-5b10fb2707ae67ec9406972a", "modelKey": "event", "timestamp": 1642980910447 }, { "id": "98798623f1bf984ee021cad8", "type": "motion", "start": 1642980900551, "end": 1642980925229, "score": 62, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-98798623f1bf984ee021cad8", "heatmap": "e-98798623f1bf984ee021cad8", "modelKey": "event", "timestamp": 1642980915221 }, { "id": "14bf578052f06a797b5be779", "type": "motion", "start": 1642981026841, "end": 1642981048003, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-14bf578052f06a797b5be779", "heatmap": "e-14bf578052f06a797b5be779", "modelKey": "event", "timestamp": 1642980915221 }, { "id": "c9f295c7f1d13648f8435d97", "type": "motion", "start": 1642981032345, "end": 1642981055349, "score": 17, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c9f295c7f1d13648f8435d97", "heatmap": "e-c9f295c7f1d13648f8435d97", "modelKey": "event", "timestamp": 1642980915221 }, { "id": "5adc6ae2565bfc450c9215e4", "type": "motion", "start": 1642981254777, "end": 1642981276775, "score": 19, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5adc6ae2565bfc450c9215e4", "heatmap": "e-5adc6ae2565bfc450c9215e4", "modelKey": "event", "timestamp": 1642981266611 }, { "id": "21f98ef036f1693a968026e6", "type": "motion", "start": 1642981257883, "end": 1642981280893, "score": 26, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-21f98ef036f1693a968026e6", "heatmap": "e-21f98ef036f1693a968026e6", "modelKey": "event", "timestamp": 1642980915221 }, { "id": "d70cae6008807ab3d2ee82a2", "type": "motion", "start": 1642981270912, "end": 1642981293246, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d70cae6008807ab3d2ee82a2", "heatmap": "e-d70cae6008807ab3d2ee82a2", "modelKey": "event", "timestamp": 1642981282079 }, { "id": "df6855995b8808c3dd9b011a", "type": "motion", "start": 1642981279106, "end": 1642981304103, "score": 59, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-df6855995b8808c3dd9b011a", "heatmap": "e-df6855995b8808c3dd9b011a", "modelKey": "event", "timestamp": 1642981291789 }, { "id": "73c23fca3795c231337a69a7", "type": "motion", "start": 1642981385090, "end": 1642981408101, "score": 19, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-73c23fca3795c231337a69a7", "heatmap": "e-73c23fca3795c231337a69a7", "modelKey": "event", "timestamp": 1642981282079 }, { "id": "2f14cdec98df8d44efe4b203", "type": "motion", "start": 1642981481705, "end": 1642981504706, "score": 22, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2f14cdec98df8d44efe4b203", "heatmap": "e-2f14cdec98df8d44efe4b203", "modelKey": "event", "timestamp": 1642980106394 }, { "id": "2658cb19358043d508e2c03d", "type": "motion", "start": 1642981499119, "end": 1642981533697, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2658cb19358043d508e2c03d", "heatmap": "e-2658cb19358043d508e2c03d", "modelKey": "event", "timestamp": 1642981520474 }, { "id": "6e56bf7601cc699fe4a02023", "type": "motion", "start": 1642981500325, "end": 1642981524004, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6e56bf7601cc699fe4a02023", "heatmap": "e-6e56bf7601cc699fe4a02023", "modelKey": "event", "timestamp": 1642981511492 }, { "id": "396bbb455a8272a4bed796cc", "type": "motion", "start": 1642981593969, "end": 1642981615309, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-396bbb455a8272a4bed796cc", "heatmap": "e-396bbb455a8272a4bed796cc", "modelKey": "event", "timestamp": 1642981605136 }, { "id": "338940810c2cf850e466d172", "type": "sensorMotion", "start": 1642981645292, "end": 1642981645292, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qfkpck Vwobrlv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-338940810c2cf850e466d172", "heatmap": "e-338940810c2cf850e466d172", "modelKey": "event", "timestamp": null }, { "id": "77565f29622fe31b9ec5ae81", "type": "motion", "start": 1642981842676, "end": 1642981864014, "score": 82, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-77565f29622fe31b9ec5ae81", "heatmap": "e-77565f29622fe31b9ec5ae81", "modelKey": "event", "timestamp": 1642981853842 }, { "id": "97238e951e7fcd04fd715f7b", "type": "motion", "start": 1642981929145, "end": 1642981950811, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-97238e951e7fcd04fd715f7b", "heatmap": "e-97238e951e7fcd04fd715f7b", "modelKey": "event", "timestamp": 1642981520474 }, { "id": "157527ac0a0cb729b1b7a3fb", "type": "motion", "start": 1642981935050, "end": 1642981959900, "score": 65, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-157527ac0a0cb729b1b7a3fb", "heatmap": "e-157527ac0a0cb729b1b7a3fb", "modelKey": "event", "timestamp": 1642981947884 }, { "id": "1b26dc0a8d5c3e7049df2924", "type": "motion", "start": 1642981977983, "end": 1642982011359, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1b26dc0a8d5c3e7049df2924", "heatmap": "e-1b26dc0a8d5c3e7049df2924", "modelKey": "event", "timestamp": 1642981989651 }, { "id": "4881660fc5c799f9a529c9f1", "type": "sensorMotion", "start": 1642981981952, "end": 1642981981952, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Pyqxiy Vayq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4881660fc5c799f9a529c9f1", "heatmap": "e-4881660fc5c799f9a529c9f1", "modelKey": "event", "timestamp": null }, { "id": "c024bf026307942aaa96d8f2", "type": "motion", "start": 1642981985632, "end": 1642982009626, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c024bf026307942aaa96d8f2", "heatmap": "e-c024bf026307942aaa96d8f2", "modelKey": "event", "timestamp": 1642981998964 }, { "id": "722a50aac30b732bb4dada38", "type": "sensorMotion", "start": 1642981992148, "end": 1642981992148, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ldfj Gsffkqr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-722a50aac30b732bb4dada38", "heatmap": "e-722a50aac30b732bb4dada38", "modelKey": "event", "timestamp": null }, { "id": "4f731a4e0ff3dd1b568fe378", "type": "motion", "start": 1642981999643, "end": 1642982023292, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4f731a4e0ff3dd1b568fe378", "heatmap": "e-4f731a4e0ff3dd1b568fe378", "modelKey": "event", "timestamp": 1642982012458 }, { "id": "6a63b13a26a8901173dc0f5e", "type": "motion", "start": 1642982015598, "end": 1642982038272, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6a63b13a26a8901173dc0f5e", "heatmap": "e-6a63b13a26a8901173dc0f5e", "modelKey": "event", "timestamp": 1642982026765 }, { "id": "f93510309c6fe98ba19fde92", "type": "motion", "start": 1642982119526, "end": 1642982141691, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "f0cd15b8bed9e38899286a8c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f93510309c6fe98ba19fde92", "heatmap": "e-f93510309c6fe98ba19fde92", "modelKey": "event", "timestamp": 1642982131359 }, { "id": "e089fdba33554bb14400c6a2", "type": "motion", "start": 1642982125007, "end": 1642982148175, "score": 60, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e089fdba33554bb14400c6a2", "heatmap": "e-e089fdba33554bb14400c6a2", "modelKey": "event", "timestamp": 1642982136174 }, { "id": "685375fbcd98b281b35ff2ab", "type": "motion", "start": 1642982159385, "end": 1642982184552, "score": 65, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-685375fbcd98b281b35ff2ab", "heatmap": "e-685375fbcd98b281b35ff2ab", "modelKey": "event", "timestamp": 1642982170873 }, { "id": "594fae76fd3fcac08da9b15d", "type": "motion", "start": 1642982183763, "end": 1642982206954, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-594fae76fd3fcac08da9b15d", "heatmap": "e-594fae76fd3fcac08da9b15d", "modelKey": "event", "timestamp": 1642982194930 }, { "id": "d4ee04ddf038bddd26b0fe3c", "type": "motion", "start": 1642982216030, "end": 1642982238027, "score": 99, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "f0cd15b8bed9e38899286a8c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d4ee04ddf038bddd26b0fe3c", "heatmap": "e-d4ee04ddf038bddd26b0fe3c", "modelKey": "event", "timestamp": 1642982227196 }, { "id": "705a25209a7461e974c55d08", "type": "motion", "start": 1642982257395, "end": 1642982281890, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "f0cd15b8bed9e38899286a8c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-705a25209a7461e974c55d08", "heatmap": "e-705a25209a7461e974c55d08", "modelKey": "event", "timestamp": 1642982268555 }, { "id": "606a1813d54bcbaa4199a9f5", "type": "sensorMotion", "start": 1642982260061, "end": 1642982260061, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cbjgil Pzbe" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-606a1813d54bcbaa4199a9f5", "heatmap": "e-606a1813d54bcbaa4199a9f5", "modelKey": "event", "timestamp": null }, { "id": "783fd42848c83e3bbb02cbcf", "type": "motion", "start": 1642982262451, "end": 1642982288996, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-783fd42848c83e3bbb02cbcf", "heatmap": "e-783fd42848c83e3bbb02cbcf", "modelKey": "event", "timestamp": 1642982274119 }, { "id": "a59b9fe2c6a699221e95d97d", "type": "motion", "start": 1642982264675, "end": 1642982287179, "score": 45, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a59b9fe2c6a699221e95d97d", "heatmap": "e-a59b9fe2c6a699221e95d97d", "modelKey": "event", "timestamp": 1642982277176 }, { "id": "bf085ff0f0924a4531050d54", "type": "motion", "start": 1642982264933, "end": 1642982297101, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bf085ff0f0924a4531050d54", "heatmap": "e-bf085ff0f0924a4531050d54", "modelKey": "event", "timestamp": 1642982278433 }, { "id": "29aba140021d330c03e3bd4e", "type": "motion", "start": 1642982282586, "end": 1642982303924, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-29aba140021d330c03e3bd4e", "heatmap": "e-29aba140021d330c03e3bd4e", "modelKey": "event", "timestamp": 1642982293757 }, { "id": "b1b949177b3ff37b97884569", "type": "sensorMotion", "start": 1642982291864, "end": 1642982291864, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Eqedyrh Oakrojx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b1b949177b3ff37b97884569", "heatmap": "e-b1b949177b3ff37b97884569", "modelKey": "event", "timestamp": null }, { "id": "cf040d8bf08d41da2fb51985", "type": "sensorMotion", "start": 1642982303111, "end": 1642982303111, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Avb Xmc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cf040d8bf08d41da2fb51985", "heatmap": "e-cf040d8bf08d41da2fb51985", "modelKey": "event", "timestamp": null }, { "id": "965f824ddc7e089aa7582271", "type": "motion", "start": 1642982417061, "end": 1642982438226, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-965f824ddc7e089aa7582271", "heatmap": "e-965f824ddc7e089aa7582271", "modelKey": "event", "timestamp": 1642982278433 }, { "id": "1f8265b1f42a1eeb09cb83fc", "type": "motion", "start": 1642982539108, "end": 1642982567446, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1f8265b1f42a1eeb09cb83fc", "heatmap": "e-1f8265b1f42a1eeb09cb83fc", "modelKey": "event", "timestamp": 1642982554296 }, { "id": "def81c71bc3afd50524a8efa", "type": "motion", "start": 1642982850463, "end": 1642982872467, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-def81c71bc3afd50524a8efa", "heatmap": "e-def81c71bc3afd50524a8efa", "modelKey": "event", "timestamp": 1642982274119 }, { "id": "823ff4b6194cbbc9e0d7d573", "type": "motion", "start": 1642982988832, "end": 1642983011171, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-823ff4b6194cbbc9e0d7d573", "heatmap": "e-823ff4b6194cbbc9e0d7d573", "modelKey": "event", "timestamp": 1642982999998 }, { "id": "05e74d0906958befe61d9241", "type": "motion", "start": 1642983114674, "end": 1642983136853, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-05e74d0906958befe61d9241", "heatmap": "e-05e74d0906958befe61d9241", "modelKey": "event", "timestamp": 1642982999998 }, { "id": "3eb11b86e61a2dd1d7fd8253", "type": "motion", "start": 1642983699438, "end": 1642983720451, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3eb11b86e61a2dd1d7fd8253", "heatmap": "e-3eb11b86e61a2dd1d7fd8253", "modelKey": "event", "timestamp": 1642982554296 }, { "id": "1343fd48b7221d66fc230d2d", "type": "motion", "start": 1642983711807, "end": 1642983734836, "score": 10, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1343fd48b7221d66fc230d2d", "heatmap": "e-1343fd48b7221d66fc230d2d", "modelKey": "event", "timestamp": 1642982999998 }, { "id": "71b69378760b0a5a8831e229", "type": "motion", "start": 1642983814944, "end": 1642983835945, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-71b69378760b0a5a8831e229", "heatmap": "e-71b69378760b0a5a8831e229", "modelKey": "event", "timestamp": 1642982554296 }, { "id": "b17827872a73c8088dadd8c4", "type": "motion", "start": 1642983817014, "end": 1642983840015, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b17827872a73c8088dadd8c4", "heatmap": "e-b17827872a73c8088dadd8c4", "modelKey": "event", "timestamp": 1642982999998 }, { "id": "5f119008d9ea5e1297a1ee7d", "type": "motion", "start": 1642984565894, "end": 1642984587396, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5f119008d9ea5e1297a1ee7d", "heatmap": "e-5f119008d9ea5e1297a1ee7d", "modelKey": "event", "timestamp": 1642984577061 }, { "id": "09b20ec083856e76d9eb9a68", "type": "motion", "start": 1642984710334, "end": 1642984731337, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-09b20ec083856e76d9eb9a68", "heatmap": "e-09b20ec083856e76d9eb9a68", "modelKey": "event", "timestamp": 1642982999998 }, { "id": "1e7bc754027dab321db5149e", "type": "motion", "start": 1642984756783, "end": 1642984779119, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1e7bc754027dab321db5149e", "heatmap": "e-1e7bc754027dab321db5149e", "modelKey": "event", "timestamp": 1642984767950 }, { "id": "971b583f180105390e54093a", "type": "sensorMotion", "start": 1642984861839, "end": 1642984861839, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Yhzqai Jkjs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-971b583f180105390e54093a", "heatmap": "e-971b583f180105390e54093a", "modelKey": "event", "timestamp": null }, { "id": "28eca793d4fda155d349d174", "type": "motion", "start": 1642984930210, "end": 1642984959045, "score": 75, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-28eca793d4fda155d349d174", "heatmap": "e-28eca793d4fda155d349d174", "modelKey": "event", "timestamp": 1642984946712 }, { "id": "6863bea0c634fb1de3751b72", "type": "sensorMotion", "start": 1642984939161, "end": 1642984939161, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hlh Vtfoe" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6863bea0c634fb1de3751b72", "heatmap": "e-6863bea0c634fb1de3751b72", "modelKey": "event", "timestamp": null }, { "id": "c61d1de7284b362d2410cd9e", "type": "sensorMotion", "start": 1642984946931, "end": 1642984946931, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Vqbarl Sniu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c61d1de7284b362d2410cd9e", "heatmap": "e-c61d1de7284b362d2410cd9e", "modelKey": "event", "timestamp": null }, { "id": "0c10f186c8f8c9f985d1fdbf", "type": "motion", "start": 1642985297940, "end": 1642985320940, "score": 3, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0c10f186c8f8c9f985d1fdbf", "heatmap": "e-0c10f186c8f8c9f985d1fdbf", "modelKey": "event", "timestamp": 1642982999998 }, { "id": "cb32f49c542aa7f0ab6a21c1", "type": "motion", "start": 1642985815351, "end": 1642985839379, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cb32f49c542aa7f0ab6a21c1", "heatmap": "e-cb32f49c542aa7f0ab6a21c1", "modelKey": "event", "timestamp": 1642985827184 }, { "id": "05bbaf7a4e20c633ed41bea6", "type": "motion", "start": 1642985816148, "end": 1642985841151, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-05bbaf7a4e20c633ed41bea6", "heatmap": "e-05bbaf7a4e20c633ed41bea6", "modelKey": "event", "timestamp": 1642985827480 }, { "id": "626bab23b500dbd3d6784651", "type": "motion", "start": 1642985966228, "end": 1642985991395, "score": 61, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-626bab23b500dbd3d6784651", "heatmap": "e-626bab23b500dbd3d6784651", "modelKey": "event", "timestamp": 1642985978061 }, { "id": "07e29f5bb93f799de79f3e83", "type": "motion", "start": 1642985969384, "end": 1642985995903, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-07e29f5bb93f799de79f3e83", "heatmap": "e-07e29f5bb93f799de79f3e83", "modelKey": "event", "timestamp": 1642985980551 }, { "id": "54248175f82e8161c8be29f9", "type": "motion", "start": 1642985970916, "end": 1642985995100, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-54248175f82e8161c8be29f9", "heatmap": "e-54248175f82e8161c8be29f9", "modelKey": "event", "timestamp": 1642985982082 }, { "id": "51ad137854ea015e404ea532", "type": "motion", "start": 1642986106412, "end": 1642986137941, "score": 92, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-51ad137854ea015e404ea532", "heatmap": "e-51ad137854ea015e404ea532", "modelKey": "event", "timestamp": 1642986121247 }, { "id": "3543790e952c01ca244ae773", "type": "motion", "start": 1642986288723, "end": 1642986314222, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3543790e952c01ca244ae773", "heatmap": "e-3543790e952c01ca244ae773", "modelKey": "event", "timestamp": 1642986302389 }, { "id": "7a3cbbda8feba32d65189f00", "type": "motion", "start": 1642986290243, "end": 1642986311913, "score": 55, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7a3cbbda8feba32d65189f00", "heatmap": "e-7a3cbbda8feba32d65189f00", "modelKey": "event", "timestamp": 1642986121247 }, { "id": "ddc4f02ad4f664ee29b147a0", "type": "motion", "start": 1642986386777, "end": 1642986408451, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ddc4f02ad4f664ee29b147a0", "heatmap": "e-ddc4f02ad4f664ee29b147a0", "modelKey": "event", "timestamp": 1642986121247 }, { "id": "5b8145d61cf676d9132596e2", "type": "motion", "start": 1642986493459, "end": 1642986514624, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5b8145d61cf676d9132596e2", "heatmap": "e-5b8145d61cf676d9132596e2", "modelKey": "event", "timestamp": 1642986121247 }, { "id": "f25e3d3396ab6ded8f3ece27", "type": "motion", "start": 1642986684541, "end": 1642986706540, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f25e3d3396ab6ded8f3ece27", "heatmap": "e-f25e3d3396ab6ded8f3ece27", "modelKey": "event", "timestamp": 1642986696225 }, { "id": "b3a7a2cb57daae850923e91d", "type": "motion", "start": 1642986926123, "end": 1642986947292, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b3a7a2cb57daae850923e91d", "heatmap": "e-b3a7a2cb57daae850923e91d", "modelKey": "event", "timestamp": 1642986937290 }, { "id": "c44e8af978f568335ae8fadd", "type": "motion", "start": 1642986977664, "end": 1642986999329, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c44e8af978f568335ae8fadd", "heatmap": "e-c44e8af978f568335ae8fadd", "modelKey": "event", "timestamp": 1642986988831 }, { "id": "477b1b2203e09b24d660b968", "type": "motion", "start": 1642987131125, "end": 1642987153294, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-477b1b2203e09b24d660b968", "heatmap": "e-477b1b2203e09b24d660b968", "modelKey": "event", "timestamp": 1642987142292 }, { "id": "f0887d644a754cc76e8c3236", "type": "motion", "start": 1642987374087, "end": 1642987395919, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f0887d644a754cc76e8c3236", "heatmap": "e-f0887d644a754cc76e8c3236", "modelKey": "event", "timestamp": 1642987385754 }, { "id": "97495d6526e3c7cca7c0dd6b", "type": "motion", "start": 1642987661997, "end": 1642987683672, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-97495d6526e3c7cca7c0dd6b", "heatmap": "e-97495d6526e3c7cca7c0dd6b", "modelKey": "event", "timestamp": 1642986121247 }, { "id": "2925d7ed4af26f388d985673", "type": "motion", "start": 1642987663577, "end": 1642987685923, "score": 63, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2925d7ed4af26f388d985673", "heatmap": "e-2925d7ed4af26f388d985673", "modelKey": "event", "timestamp": 1642987674743 }, { "id": "2e8c2c758dc12aa55672adc7", "type": "motion", "start": 1642987795674, "end": 1642987817508, "score": 25, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2e8c2c758dc12aa55672adc7", "heatmap": "e-2e8c2c758dc12aa55672adc7", "modelKey": "event", "timestamp": 1642986121247 }, { "id": "e4f8ff70ab3292b5832ba56d", "type": "motion", "start": 1642987876405, "end": 1642987897737, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e4f8ff70ab3292b5832ba56d", "heatmap": "e-e4f8ff70ab3292b5832ba56d", "modelKey": "event", "timestamp": 1642987887572 }, { "id": "937d37ea0ce05557f9ea4b55", "type": "motion", "start": 1642988787437, "end": 1642988810450, "score": 7, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-937d37ea0ce05557f9ea4b55", "heatmap": "e-937d37ea0ce05557f9ea4b55", "modelKey": "event", "timestamp": 1642986121247 }, { "id": "cd8a4b760b76d5a4e4cac7d2", "type": "motion", "start": 1642988807164, "end": 1642988828844, "score": 73, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cd8a4b760b76d5a4e4cac7d2", "heatmap": "e-cd8a4b760b76d5a4e4cac7d2", "modelKey": "event", "timestamp": 1642988818332 }, { "id": "4aff5f3f58d0b7dd77c63a38", "type": "motion", "start": 1642989033282, "end": 1642989055102, "score": 3, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4aff5f3f58d0b7dd77c63a38", "heatmap": "e-4aff5f3f58d0b7dd77c63a38", "modelKey": "event", "timestamp": 1642987674743 }, { "id": "ba859fe2f33022cac0fafcaa", "type": "motion", "start": 1642989684099, "end": 1642989709598, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ba859fe2f33022cac0fafcaa", "heatmap": "e-ba859fe2f33022cac0fafcaa", "modelKey": "event", "timestamp": 1642989698766 }, { "id": "010c6df0fb4e10885c705ae0", "type": "motion", "start": 1642989956563, "end": 1642989978396, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-010c6df0fb4e10885c705ae0", "heatmap": "e-010c6df0fb4e10885c705ae0", "modelKey": "event", "timestamp": 1642989967730 }, { "id": "e9c92094888c3cb9e7d091b0", "type": "motion", "start": 1642989963060, "end": 1642989989738, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e9c92094888c3cb9e7d091b0", "heatmap": "e-e9c92094888c3cb9e7d091b0", "modelKey": "event", "timestamp": 1642989974226 }, { "id": "e4f6505889319cf9dfc369f1", "type": "motion", "start": 1642989971727, "end": 1642989995561, "score": 91, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e4f6505889319cf9dfc369f1", "heatmap": "e-e4f6505889319cf9dfc369f1", "modelKey": "event", "timestamp": 1642989985394 }, { "id": "59303cab874fd7b7ac9557c6", "type": "motion", "start": 1642990071356, "end": 1642990092867, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-59303cab874fd7b7ac9557c6", "heatmap": "e-59303cab874fd7b7ac9557c6", "modelKey": "event", "timestamp": 1642988818332 }, { "id": "14d5c76bc5c26ab11e298391", "type": "motion", "start": 1642990098360, "end": 1642990119544, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-14d5c76bc5c26ab11e298391", "heatmap": "e-14d5c76bc5c26ab11e298391", "modelKey": "event", "timestamp": 1642989985394 }, { "id": "2bf353750e888039105a2962", "type": "motion", "start": 1642990638754, "end": 1642990660419, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2bf353750e888039105a2962", "heatmap": "e-2bf353750e888039105a2962", "modelKey": "event", "timestamp": 1642990650254 }, { "id": "b2715fffa15b84578d1bc6ca", "type": "motion", "start": 1642990640789, "end": 1642990663799, "score": 13, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b2715fffa15b84578d1bc6ca", "heatmap": "e-b2715fffa15b84578d1bc6ca", "modelKey": "event", "timestamp": 1642988818332 }, { "id": "c85fb3660dd72626440ee820", "type": "motion", "start": 1642991365990, "end": 1642991387322, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c85fb3660dd72626440ee820", "heatmap": "e-c85fb3660dd72626440ee820", "modelKey": "event", "timestamp": 1642990650254 }, { "id": "3d1f1c0c06af22705c38c78c", "type": "motion", "start": 1642991661449, "end": 1642991682615, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3d1f1c0c06af22705c38c78c", "heatmap": "e-3d1f1c0c06af22705c38c78c", "modelKey": "event", "timestamp": 1642990650254 }, { "id": "64b0d74996601b9938ee9d04", "type": "motion", "start": 1642991666729, "end": 1642991689729, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-64b0d74996601b9938ee9d04", "heatmap": "e-64b0d74996601b9938ee9d04", "modelKey": "event", "timestamp": 1642991677895 }, { "id": "71b6485c5b27d91ee58b4e53", "type": "motion", "start": 1642991784620, "end": 1642991806454, "score": 14, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-71b6485c5b27d91ee58b4e53", "heatmap": "e-71b6485c5b27d91ee58b4e53", "modelKey": "event", "timestamp": 1642990650254 }, { "id": "6de9e4ba570b12d96d05076a", "type": "motion", "start": 1642992048883, "end": 1642992069883, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6de9e4ba570b12d96d05076a", "heatmap": "e-6de9e4ba570b12d96d05076a", "modelKey": "event", "timestamp": 1642990650254 }, { "id": "04c9883fd8000ffa24b1fcec", "type": "motion", "start": 1642992503198, "end": 1642992524873, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-04c9883fd8000ffa24b1fcec", "heatmap": "e-04c9883fd8000ffa24b1fcec", "modelKey": "event", "timestamp": 1642988818332 }, { "id": "a16b0181776518463f0c4c80", "type": "motion", "start": 1642993027479, "end": 1642993051146, "score": 99, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a16b0181776518463f0c4c80", "heatmap": "e-a16b0181776518463f0c4c80", "modelKey": "event", "timestamp": 1642993038644 }, { "id": "a6d8b4043244526e8f97a0f1", "type": "sensorMotion", "start": 1642993035845, "end": 1642993035845, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Mhprgo Drrcma" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a6d8b4043244526e8f97a0f1", "heatmap": "e-a6d8b4043244526e8f97a0f1", "modelKey": "event", "timestamp": null }, { "id": "abf951246af4242311a5d73d", "type": "motion", "start": 1642993065683, "end": 1642993088521, "score": 50, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-abf951246af4242311a5d73d", "heatmap": "e-abf951246af4242311a5d73d", "modelKey": "event", "timestamp": 1642993038644 }, { "id": "26b867a2db0eba6d2c63b7a9", "type": "motion", "start": 1642993121455, "end": 1642993146617, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-26b867a2db0eba6d2c63b7a9", "heatmap": "e-26b867a2db0eba6d2c63b7a9", "modelKey": "event", "timestamp": 1642993134452 }, { "id": "0c7e615733ea56b10cd4ee99", "type": "motion", "start": 1642993126445, "end": 1642993147947, "score": 63, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0c7e615733ea56b10cd4ee99", "heatmap": "e-0c7e615733ea56b10cd4ee99", "modelKey": "event", "timestamp": 1642993137946 }, { "id": "701ee12baac34fdfde6103ab", "type": "motion", "start": 1642993130332, "end": 1642993152333, "score": 61, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-701ee12baac34fdfde6103ab", "heatmap": "e-701ee12baac34fdfde6103ab", "modelKey": "event", "timestamp": 1642993141504 }, { "id": "945fc8cc02df3b8ab47b8714", "type": "motion", "start": 1642993132907, "end": 1642993154905, "score": 58, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-945fc8cc02df3b8ab47b8714", "heatmap": "e-945fc8cc02df3b8ab47b8714", "modelKey": "event", "timestamp": 1642993144074 }, { "id": "d436f7241ca003450d8f28f2", "type": "sensorMotion", "start": 1642993139247, "end": 1642993139247, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xrnoxf Ckf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d436f7241ca003450d8f28f2", "heatmap": "e-d436f7241ca003450d8f28f2", "modelKey": "event", "timestamp": null }, { "id": "99ac512db462febf9c6b1b19", "type": "motion", "start": 1642993817185, "end": 1642993838517, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-99ac512db462febf9c6b1b19", "heatmap": "e-99ac512db462febf9c6b1b19", "modelKey": "event", "timestamp": 1642993144074 }, { "id": "faa6f6fb3499607193b7212e", "type": "motion", "start": 1642994062660, "end": 1642994102658, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "307184b29ddaa51fd0a4061a" ], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-faa6f6fb3499607193b7212e", "heatmap": "e-faa6f6fb3499607193b7212e", "modelKey": "event", "timestamp": 1642994081992 }, { "id": "82f45a1831cfe67b4d0aacff", "type": "motion", "start": 1642994066247, "end": 1642994109851, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-82f45a1831cfe67b4d0aacff", "heatmap": "e-82f45a1831cfe67b4d0aacff", "modelKey": "event", "timestamp": 1642994081582 }, { "id": "d5c7e68cf13686cf822b63d7", "type": "motion", "start": 1642994066475, "end": 1642994101820, "score": 73, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "f0cd15b8bed9e38899286a8c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d5c7e68cf13686cf822b63d7", "heatmap": "e-d5c7e68cf13686cf822b63d7", "modelKey": "event", "timestamp": 1642994081310 }, { "id": "5844a2ac28d9d0266c6ca377", "type": "motion", "start": 1642994071913, "end": 1642994116719, "score": 79, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5844a2ac28d9d0266c6ca377", "heatmap": "e-5844a2ac28d9d0266c6ca377", "modelKey": "event", "timestamp": 1642994083580 }, { "id": "307184b29ddaa51fd0a4061a", "type": "smartDetectZone", "start": 1642994075970, "end": 1642994098206, "score": 69, "smartDetectTypes": [ "vehicle" ], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-307184b29ddaa51fd0a4061a", "heatmap": "e-307184b29ddaa51fd0a4061a", "modelKey": "event", "timestamp": 1642994086970 }, { "id": "c5f9ef0838df1c77913743b7", "type": "smartDetectZone", "start": 1642994108580, "end": 1642994135643, "score": 87, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c5f9ef0838df1c77913743b7", "heatmap": "e-c5f9ef0838df1c77913743b7", "modelKey": "event", "timestamp": 1642994124803 }, { "id": "0306b37fa3dc62e82050a006", "type": "motion", "start": 1642994110419, "end": 1642994169539, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "c5f9ef0838df1c77913743b7" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0306b37fa3dc62e82050a006", "heatmap": "e-0306b37fa3dc62e82050a006", "modelKey": "event", "timestamp": 1642994124803 }, { "id": "130d832e7922dc9df2681016", "type": "motion", "start": 1642994110746, "end": 1642994155921, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-130d832e7922dc9df2681016", "heatmap": "e-130d832e7922dc9df2681016", "modelKey": "event", "timestamp": 1642994121915 }, { "id": "0b100cd1f2dfaef6693665e9", "type": "motion", "start": 1642994113488, "end": 1642994159500, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "ff89a74900f3fc17ead52908" ], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0b100cd1f2dfaef6693665e9", "heatmap": "e-0b100cd1f2dfaef6693665e9", "modelKey": "event", "timestamp": 1642994138991 }, { "id": "ff89a74900f3fc17ead52908", "type": "smartDetectZone", "start": 1642994114899, "end": 1642994136426, "score": 42, "smartDetectTypes": [ "vehicle" ], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ff89a74900f3fc17ead52908", "heatmap": "e-ff89a74900f3fc17ead52908", "modelKey": "event", "timestamp": 1642994126167 }, { "id": "d8c3a07f0e98ce8790cec883", "type": "motion", "start": 1642994126043, "end": 1642994148148, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d8c3a07f0e98ce8790cec883", "heatmap": "e-d8c3a07f0e98ce8790cec883", "modelKey": "event", "timestamp": 1642993134452 }, { "id": "9e6affe579c8108de5c8b4b6", "type": "sensorOpened", "start": 1642994129455, "end": 1642994129455, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Tiptoi Mzpt" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-9e6affe579c8108de5c8b4b6", "heatmap": "e-9e6affe579c8108de5c8b4b6", "modelKey": "event", "timestamp": null }, { "id": "81dfac8e28b4393408b89454", "type": "sensorMotion", "start": 1642994130987, "end": 1642994130987, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zsyjxm Eujh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-81dfac8e28b4393408b89454", "heatmap": "e-81dfac8e28b4393408b89454", "modelKey": "event", "timestamp": null }, { "id": "c4e1bf269fdea0ab4b76fac5", "type": "sensorMotion", "start": 1642994136135, "end": 1642994136135, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Calbkqz Zmpo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c4e1bf269fdea0ab4b76fac5", "heatmap": "e-c4e1bf269fdea0ab4b76fac5", "modelKey": "event", "timestamp": null }, { "id": "b83fc69604ec20d9925a669d", "type": "sensorMotion", "start": 1642994140657, "end": 1642994140657, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Spbtc Ynnem" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b83fc69604ec20d9925a669d", "heatmap": "e-b83fc69604ec20d9925a669d", "modelKey": "event", "timestamp": null }, { "id": "aac3bbdd7d679e7256bf95e1", "type": "sensorClosed", "start": 1642994140674, "end": 1642994140674, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Wnzenvc Ykfejhn" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-aac3bbdd7d679e7256bf95e1", "heatmap": "e-aac3bbdd7d679e7256bf95e1", "modelKey": "event", "timestamp": null }, { "id": "b4d00aef13c9315f4a469873", "type": "sensorMotion", "start": 1642994147466, "end": 1642994147466, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Yribd Almq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b4d00aef13c9315f4a469873", "heatmap": "e-b4d00aef13c9315f4a469873", "modelKey": "event", "timestamp": null }, { "id": "e027cfc4a27f50412b5968ea", "type": "motion", "start": 1642994466390, "end": 1642994491484, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e027cfc4a27f50412b5968ea", "heatmap": "e-e027cfc4a27f50412b5968ea", "modelKey": "event", "timestamp": 1642994477889 }, { "id": "c84b747af672fa0925b0ea46", "type": "sensorMotion", "start": 1642994467161, "end": 1642994467161, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ipdetv Iolk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c84b747af672fa0925b0ea46", "heatmap": "e-c84b747af672fa0925b0ea46", "modelKey": "event", "timestamp": null }, { "id": "8853b32897778e28b11db628", "type": "sensorMotion", "start": 1642994471925, "end": 1642994471925, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Clmfuvh Nmwwp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8853b32897778e28b11db628", "heatmap": "e-8853b32897778e28b11db628", "modelKey": "event", "timestamp": null }, { "id": "c9f4dc47b8e8364c500cddb0", "type": "sensorMotion", "start": 1642994475659, "end": 1642994475659, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Bmxggsy Ogzftt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c9f4dc47b8e8364c500cddb0", "heatmap": "e-c9f4dc47b8e8364c500cddb0", "modelKey": "event", "timestamp": null }, { "id": "0770bd45b5f90b1d068d58bd", "type": "sensorMotion", "start": 1642994480294, "end": 1642994480294, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xnzbfdf Nlt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0770bd45b5f90b1d068d58bd", "heatmap": "e-0770bd45b5f90b1d068d58bd", "modelKey": "event", "timestamp": null }, { "id": "4a869d32e04831cb46f2f8c6", "type": "sensorMotion", "start": 1642994485959, "end": 1642994485959, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Uurhe Uqs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4a869d32e04831cb46f2f8c6", "heatmap": "e-4a869d32e04831cb46f2f8c6", "modelKey": "event", "timestamp": null }, { "id": "41c2399b4009375df964f0b0", "type": "sensorMotion", "start": 1642994489565, "end": 1642994489565, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ywbawb Jrgz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-41c2399b4009375df964f0b0", "heatmap": "e-41c2399b4009375df964f0b0", "modelKey": "event", "timestamp": null }, { "id": "eec74db5c99f55786db47959", "type": "sensorMotion", "start": 1642994493427, "end": 1642994493427, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qgl Pgqscp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-eec74db5c99f55786db47959", "heatmap": "e-eec74db5c99f55786db47959", "modelKey": "event", "timestamp": null }, { "id": "812c3f73b35a6701340d522e", "type": "sensorMotion", "start": 1642994498320, "end": 1642994498320, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Voqph Snk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-812c3f73b35a6701340d522e", "heatmap": "e-812c3f73b35a6701340d522e", "modelKey": "event", "timestamp": null }, { "id": "2ced0f1aa7f9bcb3ff59f32c", "type": "sensorMotion", "start": 1642994669562, "end": 1642994669562, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Vbx Goonzg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2ced0f1aa7f9bcb3ff59f32c", "heatmap": "e-2ced0f1aa7f9bcb3ff59f32c", "modelKey": "event", "timestamp": null }, { "id": "cfba804d544753d5bcbf25a2", "type": "motion", "start": 1642994827294, "end": 1642994850980, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cfba804d544753d5bcbf25a2", "heatmap": "e-cfba804d544753d5bcbf25a2", "modelKey": "event", "timestamp": 1642994838972 }, { "id": "931fa73e04e05570abdcbf2b", "type": "motion", "start": 1642994834387, "end": 1642994866267, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-931fa73e04e05570abdcbf2b", "heatmap": "e-931fa73e04e05570abdcbf2b", "modelKey": "event", "timestamp": 1642994849836 }, { "id": "4baabcef90df5120926be9c2", "type": "sensorMotion", "start": 1642994836340, "end": 1642994836340, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Pmrmo Ogwiv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4baabcef90df5120926be9c2", "heatmap": "e-4baabcef90df5120926be9c2", "modelKey": "event", "timestamp": null }, { "id": "befa578cd0b4771a3f929667", "type": "sensorMotion", "start": 1642994839830, "end": 1642994839830, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cebcoae Juj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-befa578cd0b4771a3f929667", "heatmap": "e-befa578cd0b4771a3f929667", "modelKey": "event", "timestamp": null }, { "id": "5ec7fdaac7382a5783dd2745", "type": "sensorMotion", "start": 1642994845867, "end": 1642994845867, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cyug Qxgo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5ec7fdaac7382a5783dd2745", "heatmap": "e-5ec7fdaac7382a5783dd2745", "modelKey": "event", "timestamp": null }, { "id": "dfd8d85056ddf65177dc444a", "type": "sensorMotion", "start": 1642994849988, "end": 1642994849988, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Gqwx Dvvnqx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-dfd8d85056ddf65177dc444a", "heatmap": "e-dfd8d85056ddf65177dc444a", "modelKey": "event", "timestamp": null }, { "id": "e47c18ce34aa5abb29df58b6", "type": "sensorMotion", "start": 1642994873420, "end": 1642994873420, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Bakye Xhlfhb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e47c18ce34aa5abb29df58b6", "heatmap": "e-e47c18ce34aa5abb29df58b6", "modelKey": "event", "timestamp": null }, { "id": "5965b13dea09f9ac0a2a9107", "type": "sensorMotion", "start": 1642994951461, "end": 1642994951461, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Myis Iar" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5965b13dea09f9ac0a2a9107", "heatmap": "e-5965b13dea09f9ac0a2a9107", "modelKey": "event", "timestamp": null }, { "id": "9aeb184b57b9704c7a295029", "type": "motion", "start": 1642995267219, "end": 1642995289716, "score": 44, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9aeb184b57b9704c7a295029", "heatmap": "e-9aeb184b57b9704c7a295029", "modelKey": "event", "timestamp": 1642995279551 }, { "id": "11ca83891271c45d6e006152", "type": "motion", "start": 1642995321773, "end": 1642995343444, "score": 16, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-11ca83891271c45d6e006152", "heatmap": "e-11ca83891271c45d6e006152", "modelKey": "event", "timestamp": 1642994121915 }, { "id": "74c99708b75af1ae200855dc", "type": "sensorMotion", "start": 1642995369914, "end": 1642995369914, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Nbkmf Vxd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-74c99708b75af1ae200855dc", "heatmap": "e-74c99708b75af1ae200855dc", "modelKey": "event", "timestamp": null }, { "id": "1b5fcd17e0ad70ad9db0f2a9", "type": "sensorMotion", "start": 1642995968608, "end": 1642995968608, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Mlmncsw Skldql" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1b5fcd17e0ad70ad9db0f2a9", "heatmap": "e-1b5fcd17e0ad70ad9db0f2a9", "modelKey": "event", "timestamp": null }, { "id": "7bb3cf02bf038bb713f1af47", "type": "sensorMotion", "start": 1642995972114, "end": 1642995972114, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Kjvdqkp Tvbae" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7bb3cf02bf038bb713f1af47", "heatmap": "e-7bb3cf02bf038bb713f1af47", "modelKey": "event", "timestamp": null }, { "id": "ef1ff6f8af9ed7e122dc122d", "type": "sensorMotion", "start": 1642995980196, "end": 1642995980196, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Oqcolys Gwctu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ef1ff6f8af9ed7e122dc122d", "heatmap": "e-ef1ff6f8af9ed7e122dc122d", "modelKey": "event", "timestamp": null }, { "id": "908436460d5bfa73a01761dc", "type": "sensorMotion", "start": 1642995989080, "end": 1642995989080, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zynkqa Pvg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-908436460d5bfa73a01761dc", "heatmap": "e-908436460d5bfa73a01761dc", "modelKey": "event", "timestamp": null }, { "id": "24a7df91db6204403051919b", "type": "access", "start": 1642996600044, "end": 1643038112723, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "dcaef9cb8aed05c7db658a46", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-24a7df91db6204403051919b", "heatmap": "e-24a7df91db6204403051919b", "modelKey": "event", "timestamp": null }, { "id": "fe67f8aea2088ea5145f46b4", "type": "motion", "start": 1642997046034, "end": 1642997073699, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fe67f8aea2088ea5145f46b4", "heatmap": "e-fe67f8aea2088ea5145f46b4", "modelKey": "event", "timestamp": 1642997057700 }, { "id": "a3a33ffabb046faaacf77193", "type": "sensorMotion", "start": 1642997051820, "end": 1642997051820, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ldkqybx Dsuz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a3a33ffabb046faaacf77193", "heatmap": "e-a3a33ffabb046faaacf77193", "modelKey": "event", "timestamp": null }, { "id": "4c4d3d07e849da3250c174ce", "type": "sensorMotion", "start": 1642997058000, "end": 1642997058000, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qkn Ebhjvi" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4c4d3d07e849da3250c174ce", "heatmap": "e-4c4d3d07e849da3250c174ce", "modelKey": "event", "timestamp": null }, { "id": "177e3b85b57c126b9389247e", "type": "motion", "start": 1642997082438, "end": 1642997109417, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-177e3b85b57c126b9389247e", "heatmap": "e-177e3b85b57c126b9389247e", "modelKey": "event", "timestamp": 1642997094938 }, { "id": "b9a3d737bc6777dc058dba6c", "type": "sensorMotion", "start": 1642997088831, "end": 1642997088831, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zbocirl Hafw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b9a3d737bc6777dc058dba6c", "heatmap": "e-b9a3d737bc6777dc058dba6c", "modelKey": "event", "timestamp": null }, { "id": "6590e6bb21930a4825effc3b", "type": "sensorMotion", "start": 1642997094953, "end": 1642997094953, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Upusd Bgjexlp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6590e6bb21930a4825effc3b", "heatmap": "e-6590e6bb21930a4825effc3b", "modelKey": "event", "timestamp": null }, { "id": "5bf008b5572454f554b0f2e8", "type": "sensorMotion", "start": 1642997099459, "end": 1642997099459, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Rryhn Ebcuwjp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5bf008b5572454f554b0f2e8", "heatmap": "e-5bf008b5572454f554b0f2e8", "modelKey": "event", "timestamp": null }, { "id": "2411d11320c4fa6bf97c189f", "type": "motion", "start": 1642997173710, "end": 1642997240234, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2411d11320c4fa6bf97c189f", "heatmap": "e-2411d11320c4fa6bf97c189f", "modelKey": "event", "timestamp": 1642997201565 }, { "id": "5d4bc3794a5490b248947bc4", "type": "sensorMotion", "start": 1642997187528, "end": 1642997187528, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Setr Xjmrne" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5d4bc3794a5490b248947bc4", "heatmap": "e-5d4bc3794a5490b248947bc4", "modelKey": "event", "timestamp": null }, { "id": "f1ef1c7ab87ba35357e15c15", "type": "sensorMotion", "start": 1642997198086, "end": 1642997198086, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Fwef Hzqqdl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f1ef1c7ab87ba35357e15c15", "heatmap": "e-f1ef1c7ab87ba35357e15c15", "modelKey": "event", "timestamp": null }, { "id": "47a5a261fcad5799dfc8c59d", "type": "sensorMotion", "start": 1642997202493, "end": 1642997202493, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Jivwyw Smgboxv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-47a5a261fcad5799dfc8c59d", "heatmap": "e-47a5a261fcad5799dfc8c59d", "modelKey": "event", "timestamp": null }, { "id": "e7848170e5f9afea8f972bde", "type": "sensorMotion", "start": 1642997206970, "end": 1642997206970, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Goaybq Zaejd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e7848170e5f9afea8f972bde", "heatmap": "e-e7848170e5f9afea8f972bde", "modelKey": "event", "timestamp": null }, { "id": "707f523643d835c05a1c197f", "type": "sensorMotion", "start": 1642997210590, "end": 1642997210590, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xqjudn Pii" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-707f523643d835c05a1c197f", "heatmap": "e-707f523643d835c05a1c197f", "modelKey": "event", "timestamp": null }, { "id": "fb3241969987de8cf83b00ca", "type": "motion", "start": 1642997224315, "end": 1642997250327, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fb3241969987de8cf83b00ca", "heatmap": "e-fb3241969987de8cf83b00ca", "modelKey": "event", "timestamp": 1642997235482 }, { "id": "c275d62dfe2f966e6ca1768d", "type": "sensorMotion", "start": 1642997231563, "end": 1642997231563, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Lljni Kmifm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c275d62dfe2f966e6ca1768d", "heatmap": "e-c275d62dfe2f966e6ca1768d", "modelKey": "event", "timestamp": null }, { "id": "c175229287d87839e7a97e73", "type": "sensorMotion", "start": 1642997658901, "end": 1642997658901, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Oqiax Pjby" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c175229287d87839e7a97e73", "heatmap": "e-c175229287d87839e7a97e73", "modelKey": "event", "timestamp": null }, { "id": "efb2ad8525781bd557054efe", "type": "motion", "start": 1642997746733, "end": 1642997772405, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-efb2ad8525781bd557054efe", "heatmap": "e-efb2ad8525781bd557054efe", "modelKey": "event", "timestamp": 1642997757895 }, { "id": "de8613a3211f835e3e205f96", "type": "sensorMotion", "start": 1642997754955, "end": 1642997754955, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Uhxqik Gytv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-de8613a3211f835e3e205f96", "heatmap": "e-de8613a3211f835e3e205f96", "modelKey": "event", "timestamp": null }, { "id": "50d3af30f0b848543ac19606", "type": "motion", "start": 1642997782513, "end": 1642997804848, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-50d3af30f0b848543ac19606", "heatmap": "e-50d3af30f0b848543ac19606", "modelKey": "event", "timestamp": 1642997794349 }, { "id": "0d8ca1835184b119c7015d78", "type": "sensorMotion", "start": 1643001163454, "end": 1643001163454, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xepef Kehbpgm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0d8ca1835184b119c7015d78", "heatmap": "e-0d8ca1835184b119c7015d78", "modelKey": "event", "timestamp": null }, { "id": "798376b6f31393a3f68cfb77", "type": "sensorMotion", "start": 1643001166315, "end": 1643001166315, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Smn Etp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-798376b6f31393a3f68cfb77", "heatmap": "e-798376b6f31393a3f68cfb77", "modelKey": "event", "timestamp": null }, { "id": "589c767035fbb9bb1aafd71d", "type": "sensorMotion", "start": 1643001198346, "end": 1643001198346, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xellh Kapat" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-589c767035fbb9bb1aafd71d", "heatmap": "e-589c767035fbb9bb1aafd71d", "modelKey": "event", "timestamp": null }, { "id": "2bf2c48a207756b40bda877e", "type": "sensorMotion", "start": 1643001201822, "end": 1643001201822, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Nxjr Cwoez" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2bf2c48a207756b40bda877e", "heatmap": "e-2bf2c48a207756b40bda877e", "modelKey": "event", "timestamp": null }, { "id": "fce6c1753d2197663a1b9daa", "type": "motion", "start": 1643001229218, "end": 1643001252720, "score": 54, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fce6c1753d2197663a1b9daa", "heatmap": "e-fce6c1753d2197663a1b9daa", "modelKey": "event", "timestamp": 1643001240552 }, { "id": "707787e90b39c7f049c572b6", "type": "motion", "start": 1643001371915, "end": 1643001393917, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-707787e90b39c7f049c572b6", "heatmap": "e-707787e90b39c7f049c572b6", "modelKey": "event", "timestamp": 1643001383915 }, { "id": "7b5b9a512949060828803ac8", "type": "motion", "start": 1643005296896, "end": 1643005318068, "score": 4, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7b5b9a512949060828803ac8", "heatmap": "e-7b5b9a512949060828803ac8", "modelKey": "event", "timestamp": 1643001383915 }, { "id": "a639ba30ca5a6b26048444d6", "type": "motion", "start": 1643006365832, "end": 1643006388000, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a639ba30ca5a6b26048444d6", "heatmap": "e-a639ba30ca5a6b26048444d6", "modelKey": "event", "timestamp": 1643006376998 }, { "id": "21ec6b1ce60a02741c89cdb3", "type": "motion", "start": 1643006426490, "end": 1643006452464, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-21ec6b1ce60a02741c89cdb3", "heatmap": "e-21ec6b1ce60a02741c89cdb3", "modelKey": "event", "timestamp": 1643006437659 }, { "id": "b6a95a52395ed5294d8d175b", "type": "motion", "start": 1643007389211, "end": 1643007418374, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b6a95a52395ed5294d8d175b", "heatmap": "e-b6a95a52395ed5294d8d175b", "modelKey": "event", "timestamp": 1643007400378 }, { "id": "7f0f76aa5d7dcefbb634d684", "type": "motion", "start": 1643007408535, "end": 1643007429702, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7f0f76aa5d7dcefbb634d684", "heatmap": "e-7f0f76aa5d7dcefbb634d684", "modelKey": "event", "timestamp": 1643007419701 }, { "id": "1578edbcd12b5d2d3ab8b84b", "type": "motion", "start": 1643007410802, "end": 1643007433841, "score": 5, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1578edbcd12b5d2d3ab8b84b", "heatmap": "e-1578edbcd12b5d2d3ab8b84b", "modelKey": "event", "timestamp": 1642994121915 }, { "id": "c8ef3d4b96ce9e157302effa", "type": "motion", "start": 1643007412428, "end": 1643007433930, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c8ef3d4b96ce9e157302effa", "heatmap": "e-c8ef3d4b96ce9e157302effa", "modelKey": "event", "timestamp": 1643007423596 }, { "id": "d5298d08aa83dc096045c4a7", "type": "motion", "start": 1643008062482, "end": 1643008086650, "score": 34, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "4a333d993fe8e2e8472bc901", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d5298d08aa83dc096045c4a7", "heatmap": "e-d5298d08aa83dc096045c4a7", "modelKey": "event", "timestamp": 1643008073982 }, { "id": "01fdbf75ad7c63a5f34f1f26", "type": "motion", "start": 1643008232203, "end": 1643008255365, "score": 22, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-01fdbf75ad7c63a5f34f1f26", "heatmap": "e-01fdbf75ad7c63a5f34f1f26", "modelKey": "event", "timestamp": 1643006437659 }, { "id": "3d66b5a6df01a0e018a9aba0", "type": "motion", "start": 1643008448394, "end": 1643008470900, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3d66b5a6df01a0e018a9aba0", "heatmap": "e-3d66b5a6df01a0e018a9aba0", "modelKey": "event", "timestamp": 1643008459561 }, { "id": "fc05bcb972300bb6e98ee669", "type": "motion", "start": 1643009641858, "end": 1643009664029, "score": 81, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fc05bcb972300bb6e98ee669", "heatmap": "e-fc05bcb972300bb6e98ee669", "modelKey": "event", "timestamp": 1643009653525 }, { "id": "763c24be4506ec329bcf603b", "type": "sensorMotion", "start": 1643010456660, "end": 1643010456660, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qiyhkhh Fiovv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-763c24be4506ec329bcf603b", "heatmap": "e-763c24be4506ec329bcf603b", "modelKey": "event", "timestamp": null }, { "id": "05c3d80d19bff600c0bd68b7", "type": "motion", "start": 1643010493170, "end": 1643010514348, "score": 38, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-05c3d80d19bff600c0bd68b7", "heatmap": "e-05c3d80d19bff600c0bd68b7", "modelKey": "event", "timestamp": 1643006437659 }, { "id": "e2834ca1fa886ecba1a202b3", "type": "sensorMotion", "start": 1643010501452, "end": 1643010501452, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Newj Ryyutiw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e2834ca1fa886ecba1a202b3", "heatmap": "e-e2834ca1fa886ecba1a202b3", "modelKey": "event", "timestamp": null }, { "id": "a13eb1bc1a4ec395fc92c4a6", "type": "motion", "start": 1643011681159, "end": 1643011702986, "score": 40, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a13eb1bc1a4ec395fc92c4a6", "heatmap": "e-a13eb1bc1a4ec395fc92c4a6", "modelKey": "event", "timestamp": 1643011692649 }, { "id": "6fee13e39a572c68c00381d3", "type": "motion", "start": 1643012537532, "end": 1643012563363, "score": 55, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6fee13e39a572c68c00381d3", "heatmap": "e-6fee13e39a572c68c00381d3", "modelKey": "event", "timestamp": 1643012549534 }, { "id": "5f7eb895dc1bdb504814602e", "type": "motion", "start": 1643012539207, "end": 1643012560891, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5f7eb895dc1bdb504814602e", "heatmap": "e-5f7eb895dc1bdb504814602e", "modelKey": "event", "timestamp": 1643011692649 }, { "id": "ac3619a594fdafb41d24fa98", "type": "motion", "start": 1643014450108, "end": 1643014477613, "score": 82, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ac3619a594fdafb41d24fa98", "heatmap": "e-ac3619a594fdafb41d24fa98", "modelKey": "event", "timestamp": 1643014461442 }, { "id": "c46c01012c680f317ca56ef3", "type": "motion", "start": 1643014478977, "end": 1643014505485, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c46c01012c680f317ca56ef3", "heatmap": "e-c46c01012c680f317ca56ef3", "modelKey": "event", "timestamp": 1643014492816 }, { "id": "743fd83d4f5b67a620f786c5", "type": "motion", "start": 1643014481892, "end": 1643014504061, "score": 2, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-743fd83d4f5b67a620f786c5", "heatmap": "e-743fd83d4f5b67a620f786c5", "modelKey": "event", "timestamp": 1643011692649 }, { "id": "28650e8e2f0b67d70b8af67d", "type": "motion", "start": 1643014483740, "end": 1643014506578, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-28650e8e2f0b67d70b8af67d", "heatmap": "e-28650e8e2f0b67d70b8af67d", "modelKey": "event", "timestamp": 1643014495073 }, { "id": "d3cc5e7e3ad4b1b1126bd461", "type": "motion", "start": 1643014566937, "end": 1643014589604, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d3cc5e7e3ad4b1b1126bd461", "heatmap": "e-d3cc5e7e3ad4b1b1126bd461", "modelKey": "event", "timestamp": 1643014578104 }, { "id": "c253cd9503167ba8e76d5e3b", "type": "motion", "start": 1643015412882, "end": 1643015437724, "score": 77, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c253cd9503167ba8e76d5e3b", "heatmap": "e-c253cd9503167ba8e76d5e3b", "modelKey": "event", "timestamp": 1643015425215 }, { "id": "ed8bb79d67ef26d5509a5a7e", "type": "motion", "start": 1643015413891, "end": 1643015435727, "score": 6, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ed8bb79d67ef26d5509a5a7e", "heatmap": "e-ed8bb79d67ef26d5509a5a7e", "modelKey": "event", "timestamp": 1643011692649 }, { "id": "8b07009fee6e93c723515249", "type": "motion", "start": 1643016520939, "end": 1643016542941, "score": 58, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8b07009fee6e93c723515249", "heatmap": "e-8b07009fee6e93c723515249", "modelKey": "event", "timestamp": 1643016532106 }, { "id": "57f9da605dd445edd59ba73f", "type": "motion", "start": 1643016560076, "end": 1643016591903, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-57f9da605dd445edd59ba73f", "heatmap": "e-57f9da605dd445edd59ba73f", "modelKey": "event", "timestamp": 1643016581736 }, { "id": "7723485677eef23fee5c1c93", "type": "motion", "start": 1643018880494, "end": 1643018904009, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7723485677eef23fee5c1c93", "heatmap": "e-7723485677eef23fee5c1c93", "modelKey": "event", "timestamp": 1643018891994 }, { "id": "caba849e487e5142d92fb628", "type": "motion", "start": 1643018884512, "end": 1643018906011, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-caba849e487e5142d92fb628", "heatmap": "e-caba849e487e5142d92fb628", "modelKey": "event", "timestamp": 1643016581736 }, { "id": "af887e4f69645274149af5ef", "type": "sensorMotion", "start": 1643018898259, "end": 1643018898259, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Kqgehnm Zmvikh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-af887e4f69645274149af5ef", "heatmap": "e-af887e4f69645274149af5ef", "modelKey": "event", "timestamp": null }, { "id": "7dc4a84c4330a10763d099ae", "type": "sensorMotion", "start": 1643018902253, "end": 1643018902253, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Smrscv Jcjbvu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7dc4a84c4330a10763d099ae", "heatmap": "e-7dc4a84c4330a10763d099ae", "modelKey": "event", "timestamp": null }, { "id": "05a76f1fa860478e2a8600d9", "type": "sensorMotion", "start": 1643018917701, "end": 1643018917701, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Rwud Juze" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-05a76f1fa860478e2a8600d9", "heatmap": "e-05a76f1fa860478e2a8600d9", "modelKey": "event", "timestamp": null }, { "id": "ff143d25f979badbcea918af", "type": "sensorMotion", "start": 1643018923639, "end": 1643018923639, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ixqeeaw Nzibndy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ff143d25f979badbcea918af", "heatmap": "e-ff143d25f979badbcea918af", "modelKey": "event", "timestamp": null }, { "id": "bcec598772a30cdc475ef6e5", "type": "motion", "start": 1643018971751, "end": 1643018997583, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bcec598772a30cdc475ef6e5", "heatmap": "e-bcec598772a30cdc475ef6e5", "modelKey": "event", "timestamp": 1643018984232 }, { "id": "1009d233056d6b9cc303064d", "type": "sensorMotion", "start": 1643018980276, "end": 1643018980276, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cerusr Bgidwn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1009d233056d6b9cc303064d", "heatmap": "e-1009d233056d6b9cc303064d", "modelKey": "event", "timestamp": null }, { "id": "f29eb275a70516bb064fbba2", "type": "sensorMotion", "start": 1643018985055, "end": 1643018985055, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Fgthco Rmxo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f29eb275a70516bb064fbba2", "heatmap": "e-f29eb275a70516bb064fbba2", "modelKey": "event", "timestamp": null }, { "id": "3a25f568c802d8a4143ea877", "type": "motion", "start": 1643019110973, "end": 1643019146333, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3a25f568c802d8a4143ea877", "heatmap": "e-3a25f568c802d8a4143ea877", "modelKey": "event", "timestamp": 1643019123128 }, { "id": "938c78abee36b25e13cdc253", "type": "motion", "start": 1643019123996, "end": 1643019153664, "score": 92, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-938c78abee36b25e13cdc253", "heatmap": "e-938c78abee36b25e13cdc253", "modelKey": "event", "timestamp": 1643019135329 }, { "id": "32805898c08279a702b9b9ff", "type": "motion", "start": 1643019127223, "end": 1643019150910, "score": 79, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-32805898c08279a702b9b9ff", "heatmap": "e-32805898c08279a702b9b9ff", "modelKey": "event", "timestamp": 1643019138396 }, { "id": "45bcd2910f012e86feade4d5", "type": "motion", "start": 1643019219467, "end": 1643019240469, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-45bcd2910f012e86feade4d5", "heatmap": "e-45bcd2910f012e86feade4d5", "modelKey": "event", "timestamp": 1643019123128 }, { "id": "c7345d40f982e803c0069735", "type": "motion", "start": 1643019226290, "end": 1643019249316, "score": 65, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c7345d40f982e803c0069735", "heatmap": "e-c7345d40f982e803c0069735", "modelKey": "event", "timestamp": 1643019237457 }, { "id": "1cdd5b9dce8696d326968ab1", "type": "motion", "start": 1643020263002, "end": 1643020286678, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1cdd5b9dce8696d326968ab1", "heatmap": "e-1cdd5b9dce8696d326968ab1", "modelKey": "event", "timestamp": 1643020274667 }, { "id": "67b9c97ad1f96e5930903a1b", "type": "motion", "start": 1643020264105, "end": 1643020287120, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-67b9c97ad1f96e5930903a1b", "heatmap": "e-67b9c97ad1f96e5930903a1b", "modelKey": "event", "timestamp": 1643020275271 }, { "id": "8dbe7e2f8d2dff25e7e829b6", "type": "motion", "start": 1643020623105, "end": 1643020647449, "score": 24, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8dbe7e2f8d2dff25e7e829b6", "heatmap": "e-8dbe7e2f8d2dff25e7e829b6", "modelKey": "event", "timestamp": 1643020635109 }, { "id": "cadb11f4d963e2553b798456", "type": "motion", "start": 1643020626205, "end": 1643020649542, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cadb11f4d963e2553b798456", "heatmap": "e-cadb11f4d963e2553b798456", "modelKey": "event", "timestamp": 1643020637371 }, { "id": "069398be4df97e45160facba", "type": "motion", "start": 1643021273918, "end": 1643021295561, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-069398be4df97e45160facba", "heatmap": "e-069398be4df97e45160facba", "modelKey": "event", "timestamp": 1643021285061 }, { "id": "68688f33edfd4110dc1bf356", "type": "motion", "start": 1643021571111, "end": 1643021594960, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-68688f33edfd4110dc1bf356", "heatmap": "e-68688f33edfd4110dc1bf356", "modelKey": "event", "timestamp": 1643021582278 }, { "id": "74959da68761f09a025a2026", "type": "motion", "start": 1643021577898, "end": 1643021600246, "score": 23, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-74959da68761f09a025a2026", "heatmap": "e-74959da68761f09a025a2026", "modelKey": "event", "timestamp": 1643021589565 }, { "id": "1c4572f19f445776bca3378f", "type": "motion", "start": 1643021884484, "end": 1643021909319, "score": 94, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1c4572f19f445776bca3378f", "heatmap": "e-1c4572f19f445776bca3378f", "modelKey": "event", "timestamp": 1643021897650 }, { "id": "b3a20498a09994fc2b8978a6", "type": "motion", "start": 1643021884916, "end": 1643021906588, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b3a20498a09994fc2b8978a6", "heatmap": "e-b3a20498a09994fc2b8978a6", "modelKey": "event", "timestamp": 1643021285061 }, { "id": "dc857b7559ba32e8e4567a85", "type": "motion", "start": 1643021945465, "end": 1643021967299, "score": 16, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-dc857b7559ba32e8e4567a85", "heatmap": "e-dc857b7559ba32e8e4567a85", "modelKey": "event", "timestamp": 1643021897650 }, { "id": "8dc1234f46bd59177dc5a5c1", "type": "motion", "start": 1643022055181, "end": 1643022077356, "score": 73, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8dc1234f46bd59177dc5a5c1", "heatmap": "e-8dc1234f46bd59177dc5a5c1", "modelKey": "event", "timestamp": 1643022066515 }, { "id": "7965278aa93d664c550a5f49", "type": "motion", "start": 1643022058488, "end": 1643022080159, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7965278aa93d664c550a5f49", "heatmap": "e-7965278aa93d664c550a5f49", "modelKey": "event", "timestamp": 1643021285061 }, { "id": "80eb0b9267b0f7f5c41b4e3a", "type": "motion", "start": 1643022060626, "end": 1643022082605, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-80eb0b9267b0f7f5c41b4e3a", "heatmap": "e-80eb0b9267b0f7f5c41b4e3a", "modelKey": "event", "timestamp": 1643022071939 }, { "id": "8ac15b29547dd5d6f5f281c8", "type": "motion", "start": 1643023418261, "end": 1643023439926, "score": 96, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8ac15b29547dd5d6f5f281c8", "heatmap": "e-8ac15b29547dd5d6f5f281c8", "modelKey": "event", "timestamp": 1643023429761 }, { "id": "aa5c106a83a9754ba13cf207", "type": "motion", "start": 1643023504233, "end": 1643023527399, "score": 60, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-aa5c106a83a9754ba13cf207", "heatmap": "e-aa5c106a83a9754ba13cf207", "modelKey": "event", "timestamp": 1643023429761 }, { "id": "80749db534c119b158cb244e", "type": "motion", "start": 1643023919063, "end": 1643023941396, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-80749db534c119b158cb244e", "heatmap": "e-80749db534c119b158cb244e", "modelKey": "event", "timestamp": 1643023930230 }, { "id": "13e460054cd3b176f7f65ef1", "type": "motion", "start": 1643024126932, "end": 1643024149431, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-13e460054cd3b176f7f65ef1", "heatmap": "e-13e460054cd3b176f7f65ef1", "modelKey": "event", "timestamp": 1643024138120 }, { "id": "2ca5797d591d12c597178f61", "type": "motion", "start": 1643024473328, "end": 1643024496816, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2ca5797d591d12c597178f61", "heatmap": "e-2ca5797d591d12c597178f61", "modelKey": "event", "timestamp": 1643024485485 }, { "id": "6f465ef86b69e1a51dc395b7", "type": "motion", "start": 1643024485814, "end": 1643024508646, "score": 76, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6f465ef86b69e1a51dc395b7", "heatmap": "e-6f465ef86b69e1a51dc395b7", "modelKey": "event", "timestamp": 1643024498480 }, { "id": "18849868b6f5ed3c0d510c72", "type": "motion", "start": 1643024576619, "end": 1643024598119, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-18849868b6f5ed3c0d510c72", "heatmap": "e-18849868b6f5ed3c0d510c72", "modelKey": "event", "timestamp": 1643024587969 }, { "id": "915720d06e00ca831d4e20d7", "type": "motion", "start": 1643024890107, "end": 1643024912774, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-915720d06e00ca831d4e20d7", "heatmap": "e-915720d06e00ca831d4e20d7", "modelKey": "event", "timestamp": 1643023930230 }, { "id": "a1d6a0fc781f540e9a90d9e4", "type": "motion", "start": 1643024956510, "end": 1643024978180, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a1d6a0fc781f540e9a90d9e4", "heatmap": "e-a1d6a0fc781f540e9a90d9e4", "modelKey": "event", "timestamp": 1643024968010 }, { "id": "f40c66145cbc171ccd49aa2b", "type": "motion", "start": 1643024961303, "end": 1643024983467, "score": 37, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f40c66145cbc171ccd49aa2b", "heatmap": "e-f40c66145cbc171ccd49aa2b", "modelKey": "event", "timestamp": 1643024587969 }, { "id": "938aa1a8ffe29797856721d0", "type": "motion", "start": 1643024973636, "end": 1643024995135, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-938aa1a8ffe29797856721d0", "heatmap": "e-938aa1a8ffe29797856721d0", "modelKey": "event", "timestamp": 1643024984802 }, { "id": "b398cf999f9aecff761c5d26", "type": "motion", "start": 1643025294301, "end": 1643025316155, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b398cf999f9aecff761c5d26", "heatmap": "e-b398cf999f9aecff761c5d26", "modelKey": "event", "timestamp": 1643024138120 }, { "id": "e8781cd372cac079d8cf4d33", "type": "motion", "start": 1643025363354, "end": 1643025384374, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e8781cd372cac079d8cf4d33", "heatmap": "e-e8781cd372cac079d8cf4d33", "modelKey": "event", "timestamp": 1643024968010 }, { "id": "f9e395f3c78c514df4a13683", "type": "motion", "start": 1643025365448, "end": 1643025390613, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f9e395f3c78c514df4a13683", "heatmap": "e-f9e395f3c78c514df4a13683", "modelKey": "event", "timestamp": 1643025378448 }, { "id": "abe8b878f0ffb1c763d711d4", "type": "motion", "start": 1643025365813, "end": 1643025387992, "score": 58, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-abe8b878f0ffb1c763d711d4", "heatmap": "e-abe8b878f0ffb1c763d711d4", "modelKey": "event", "timestamp": 1643025377313 }, { "id": "0a502694d911a60ce91e6208", "type": "motion", "start": 1643025769427, "end": 1643025790926, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0a502694d911a60ce91e6208", "heatmap": "e-0a502694d911a60ce91e6208", "modelKey": "event", "timestamp": 1643025780782 }, { "id": "3bb1024a36a8647f95a93242", "type": "motion", "start": 1643025828614, "end": 1643025851962, "score": 76, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3bb1024a36a8647f95a93242", "heatmap": "e-3bb1024a36a8647f95a93242", "modelKey": "event", "timestamp": 1643025841954 }, { "id": "29f3899ed794731554ec1563", "type": "motion", "start": 1643026097386, "end": 1643026122414, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-29f3899ed794731554ec1563", "heatmap": "e-29f3899ed794731554ec1563", "modelKey": "event", "timestamp": 1643026110398 }, { "id": "7d910dc93447dd13a93ebd48", "type": "motion", "start": 1643026100242, "end": 1643026125430, "score": 92, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7d910dc93447dd13a93ebd48", "heatmap": "e-7d910dc93447dd13a93ebd48", "modelKey": "event", "timestamp": 1643026111417 }, { "id": "e561cee0a258d2fdbaddc984", "type": "motion", "start": 1643026246758, "end": 1643026268091, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e561cee0a258d2fdbaddc984", "heatmap": "e-e561cee0a258d2fdbaddc984", "modelKey": "event", "timestamp": 1643026257925 }, { "id": "19d7471e7f27b5cc691730b6", "type": "motion", "start": 1643026249579, "end": 1643026272746, "score": 44, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-19d7471e7f27b5cc691730b6", "heatmap": "e-19d7471e7f27b5cc691730b6", "modelKey": "event", "timestamp": 1643026110398 }, { "id": "0834d4884f7215dbb1066cc9", "type": "motion", "start": 1643026319929, "end": 1643026346601, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0834d4884f7215dbb1066cc9", "heatmap": "e-0834d4884f7215dbb1066cc9", "modelKey": "event", "timestamp": 1643026331096 }, { "id": "21be71658c55852e659f7f89", "type": "motion", "start": 1643026653612, "end": 1643026675613, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-21be71658c55852e659f7f89", "heatmap": "e-21be71658c55852e659f7f89", "modelKey": "event", "timestamp": 1643026664779 }, { "id": "d910e127f3c05ac61ba69c64", "type": "motion", "start": 1643026669803, "end": 1643026691287, "score": 94, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d910e127f3c05ac61ba69c64", "heatmap": "e-d910e127f3c05ac61ba69c64", "modelKey": "event", "timestamp": 1643026681280 }, { "id": "5a1fc76e6934260cf0b567d6", "type": "motion", "start": 1643026673022, "end": 1643026694523, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5a1fc76e6934260cf0b567d6", "heatmap": "e-5a1fc76e6934260cf0b567d6", "modelKey": "event", "timestamp": 1643026684522 }, { "id": "9e8d740fc77ba557003f9547", "type": "motion", "start": 1643026890926, "end": 1643026912597, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9e8d740fc77ba557003f9547", "heatmap": "e-9e8d740fc77ba557003f9547", "modelKey": "event", "timestamp": 1643026684522 }, { "id": "1dfb99491602ddac5ea6dbe4", "type": "motion", "start": 1643027925877, "end": 1643027948878, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1dfb99491602ddac5ea6dbe4", "heatmap": "e-1dfb99491602ddac5ea6dbe4", "modelKey": "event", "timestamp": 1643026684522 }, { "id": "568194dcce8ebb898f206004", "type": "motion", "start": 1643028009466, "end": 1643028035131, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-568194dcce8ebb898f206004", "heatmap": "e-568194dcce8ebb898f206004", "modelKey": "event", "timestamp": 1643028024799 }, { "id": "0c9ef5f85f77519807c51196", "type": "motion", "start": 1643028041377, "end": 1643028063075, "score": 7, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0c9ef5f85f77519807c51196", "heatmap": "e-0c9ef5f85f77519807c51196", "modelKey": "event", "timestamp": 1643028053044 }, { "id": "340ef7d5c97aa8c9365f02cd", "type": "motion", "start": 1643028155769, "end": 1643028177269, "score": 19, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-340ef7d5c97aa8c9365f02cd", "heatmap": "e-340ef7d5c97aa8c9365f02cd", "modelKey": "event", "timestamp": 1643028024799 }, { "id": "ecb6203a3952abcb458f0dfc", "type": "motion", "start": 1643028196590, "end": 1643028222756, "score": 95, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ecb6203a3952abcb458f0dfc", "heatmap": "e-ecb6203a3952abcb458f0dfc", "modelKey": "event", "timestamp": 1643028211423 }, { "id": "a54236ce506f19ce8ec38a01", "type": "motion", "start": 1643029253211, "end": 1643029279209, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a54236ce506f19ce8ec38a01", "heatmap": "e-a54236ce506f19ce8ec38a01", "modelKey": "event", "timestamp": 1643029267711 }, { "id": "b77b342399723f7125f8d6cd", "type": "motion", "start": 1643029255075, "end": 1643029277086, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b77b342399723f7125f8d6cd", "heatmap": "e-b77b342399723f7125f8d6cd", "modelKey": "event", "timestamp": 1643029266243 }, { "id": "2a45b4862dd308bd43181cc7", "type": "motion", "start": 1643029257403, "end": 1643029279068, "score": 16, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2a45b4862dd308bd43181cc7", "heatmap": "e-2a45b4862dd308bd43181cc7", "modelKey": "event", "timestamp": 1643028211423 }, { "id": "143a9692f442e3724cee7e64", "type": "motion", "start": 1643029481466, "end": 1643029507809, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-143a9692f442e3724cee7e64", "heatmap": "e-143a9692f442e3724cee7e64", "modelKey": "event", "timestamp": 1643029492800 }, { "id": "dfc8249c592e8bc6bd03e897", "type": "sensorMotion", "start": 1643029491996, "end": 1643029491996, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ffkxsru Lqyvl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-dfc8249c592e8bc6bd03e897", "heatmap": "e-dfc8249c592e8bc6bd03e897", "modelKey": "event", "timestamp": null }, { "id": "134eee3f7e0c2bf8eae39de6", "type": "sensorMotion", "start": 1643029495472, "end": 1643029495472, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hajc Ykubo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-134eee3f7e0c2bf8eae39de6", "heatmap": "e-134eee3f7e0c2bf8eae39de6", "modelKey": "event", "timestamp": null }, { "id": "2146d82e2fc305411613b651", "type": "motion", "start": 1643029734116, "end": 1643029755130, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2146d82e2fc305411613b651", "heatmap": "e-2146d82e2fc305411613b651", "modelKey": "event", "timestamp": 1643028211423 }, { "id": "58fe20f374709a3121a694f4", "type": "motion", "start": 1643029736331, "end": 1643029758666, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-58fe20f374709a3121a694f4", "heatmap": "e-58fe20f374709a3121a694f4", "modelKey": "event", "timestamp": 1643029747998 }, { "id": "0fb57201b4dc62e841aee675", "type": "motion", "start": 1643029738612, "end": 1643029761957, "score": 83, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0fb57201b4dc62e841aee675", "heatmap": "e-0fb57201b4dc62e841aee675", "modelKey": "event", "timestamp": 1643029749778 }, { "id": "647ad5d7ef311325114ad636", "type": "motion", "start": 1643030119149, "end": 1643030146321, "score": 62, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-647ad5d7ef311325114ad636", "heatmap": "e-647ad5d7ef311325114ad636", "modelKey": "event", "timestamp": 1643030134488 }, { "id": "186cb722e46f1779527161b0", "type": "motion", "start": 1643030121296, "end": 1643030145301, "score": 65, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-186cb722e46f1779527161b0", "heatmap": "e-186cb722e46f1779527161b0", "modelKey": "event", "timestamp": 1643030132463 }, { "id": "e1c2e26c7a950ba761d10ee3", "type": "motion", "start": 1643030123928, "end": 1643030148094, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e1c2e26c7a950ba761d10ee3", "heatmap": "e-e1c2e26c7a950ba761d10ee3", "modelKey": "event", "timestamp": 1643030135094 }, { "id": "41f2e78dc438643eeac7c781", "type": "motion", "start": 1643030486204, "end": 1643030508043, "score": 65, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-41f2e78dc438643eeac7c781", "heatmap": "e-41f2e78dc438643eeac7c781", "modelKey": "event", "timestamp": 1643030497371 }, { "id": "c5a3cceafb37b84b55ad9ca4", "type": "motion", "start": 1643030705062, "end": 1643030726229, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c5a3cceafb37b84b55ad9ca4", "heatmap": "e-c5a3cceafb37b84b55ad9ca4", "modelKey": "event", "timestamp": 1643030716229 }, { "id": "981d38669fbbfd1b3149d231", "type": "motion", "start": 1643031171943, "end": 1643031193448, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-981d38669fbbfd1b3149d231", "heatmap": "e-981d38669fbbfd1b3149d231", "modelKey": "event", "timestamp": 1643030497371 }, { "id": "a207ccc7036ae31769a50528", "type": "motion", "start": 1643031324907, "end": 1643031345920, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a207ccc7036ae31769a50528", "heatmap": "e-a207ccc7036ae31769a50528", "modelKey": "event", "timestamp": 1643030497371 }, { "id": "6e51c85e0fc6610703d93fad", "type": "motion", "start": 1643031351403, "end": 1643031373752, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6e51c85e0fc6610703d93fad", "heatmap": "e-6e51c85e0fc6610703d93fad", "modelKey": "event", "timestamp": 1643031362570 }, { "id": "3751e9bd97680365609f07d5", "type": "motion", "start": 1643031604081, "end": 1643031625588, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3751e9bd97680365609f07d5", "heatmap": "e-3751e9bd97680365609f07d5", "modelKey": "event", "timestamp": 1643031615581 }, { "id": "06f5c13cff4c9d368679d72a", "type": "motion", "start": 1643031635291, "end": 1643031656469, "score": 94, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-06f5c13cff4c9d368679d72a", "heatmap": "e-06f5c13cff4c9d368679d72a", "modelKey": "event", "timestamp": 1643031646458 }, { "id": "a42e08821b8b7daba434dc39", "type": "motion", "start": 1643031689563, "end": 1643031710591, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a42e08821b8b7daba434dc39", "heatmap": "e-a42e08821b8b7daba434dc39", "modelKey": "event", "timestamp": 1643031646458 }, { "id": "d525093c9f35e8d8f6c99834", "type": "motion", "start": 1643031831386, "end": 1643031853222, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d525093c9f35e8d8f6c99834", "heatmap": "e-d525093c9f35e8d8f6c99834", "modelKey": "event", "timestamp": 1643031842540 }, { "id": "929c8e8fa80989b5b8900d3e", "type": "motion", "start": 1643031837228, "end": 1643031859228, "score": 97, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-929c8e8fa80989b5b8900d3e", "heatmap": "e-929c8e8fa80989b5b8900d3e", "modelKey": "event", "timestamp": 1643031848395 }, { "id": "29a4bfac383ed0d5ee0c5b64", "type": "motion", "start": 1643032196000, "end": 1643032217670, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-29a4bfac383ed0d5ee0c5b64", "heatmap": "e-29a4bfac383ed0d5ee0c5b64", "modelKey": "event", "timestamp": 1643031848395 }, { "id": "860c39b8f2d23248b22f73d1", "type": "motion", "start": 1643032222220, "end": 1643032244387, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-860c39b8f2d23248b22f73d1", "heatmap": "e-860c39b8f2d23248b22f73d1", "modelKey": "event", "timestamp": 1643032233386 }, { "id": "72b34820449f34d651921f05", "type": "motion", "start": 1643032224232, "end": 1643032246237, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-72b34820449f34d651921f05", "heatmap": "e-72b34820449f34d651921f05", "modelKey": "event", "timestamp": 1643032235399 }, { "id": "2412b843afcf242fa4978872", "type": "motion", "start": 1643032224765, "end": 1643032246264, "score": 6, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2412b843afcf242fa4978872", "heatmap": "e-2412b843afcf242fa4978872", "modelKey": "event", "timestamp": 1643031362570 }, { "id": "f39232651905d46129578ec0", "type": "motion", "start": 1643032275486, "end": 1643032296489, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f39232651905d46129578ec0", "heatmap": "e-f39232651905d46129578ec0", "modelKey": "event", "timestamp": 1643032233386 }, { "id": "c0f39c18c1f509b720135edf", "type": "motion", "start": 1643032455067, "end": 1643032476232, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c0f39c18c1f509b720135edf", "heatmap": "e-c0f39c18c1f509b720135edf", "modelKey": "event", "timestamp": 1643032233386 }, { "id": "6d0ac7415f8f140589c7e413", "type": "motion", "start": 1643032494453, "end": 1643032515968, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6d0ac7415f8f140589c7e413", "heatmap": "e-6d0ac7415f8f140589c7e413", "modelKey": "event", "timestamp": 1643032233386 }, { "id": "ee06497462565b5ca859fecd", "type": "motion", "start": 1643032681305, "end": 1643032702641, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ee06497462565b5ca859fecd", "heatmap": "e-ee06497462565b5ca859fecd", "modelKey": "event", "timestamp": 1643032692638 }, { "id": "1da29e98b2c366aa0b9e0cbd", "type": "motion", "start": 1643032868542, "end": 1643032889707, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1da29e98b2c366aa0b9e0cbd", "heatmap": "e-1da29e98b2c366aa0b9e0cbd", "modelKey": "event", "timestamp": 1643032692638 }, { "id": "0f10b68b6dd840c25c2ea218", "type": "motion", "start": 1643032881593, "end": 1643032902593, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0f10b68b6dd840c25c2ea218", "heatmap": "e-0f10b68b6dd840c25c2ea218", "modelKey": "event", "timestamp": 1643032692638 }, { "id": "92f374446316b31e4002dd23", "type": "motion", "start": 1643032895617, "end": 1643032918285, "score": 76, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-92f374446316b31e4002dd23", "heatmap": "e-92f374446316b31e4002dd23", "modelKey": "event", "timestamp": 1643032906784 }, { "id": "f550c3599bb8f157bd01d366", "type": "motion", "start": 1643032970483, "end": 1643032991985, "score": 95, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f550c3599bb8f157bd01d366", "heatmap": "e-f550c3599bb8f157bd01d366", "modelKey": "event", "timestamp": 1643032981983 }, { "id": "89277cc7eee6bb274a670bce", "type": "motion", "start": 1643033076130, "end": 1643033097634, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-89277cc7eee6bb274a670bce", "heatmap": "e-89277cc7eee6bb274a670bce", "modelKey": "event", "timestamp": 1643032981983 }, { "id": "fdf04353176575711ebd6f0d", "type": "motion", "start": 1643033126529, "end": 1643033147697, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fdf04353176575711ebd6f0d", "heatmap": "e-fdf04353176575711ebd6f0d", "modelKey": "event", "timestamp": 1643033137696 }, { "id": "0cf2d9d4827f9f340f98a530", "type": "motion", "start": 1643033190244, "end": 1643033211746, "score": 81, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0cf2d9d4827f9f340f98a530", "heatmap": "e-0cf2d9d4827f9f340f98a530", "modelKey": "event", "timestamp": 1643033201411 }, { "id": "ab2e21305f7c8cec9d18e91a", "type": "motion", "start": 1643033514082, "end": 1643033536103, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ab2e21305f7c8cec9d18e91a", "heatmap": "e-ab2e21305f7c8cec9d18e91a", "modelKey": "event", "timestamp": 1643033525249 }, { "id": "847cab13d1d2381059a25c5d", "type": "motion", "start": 1643033687810, "end": 1643033711981, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-847cab13d1d2381059a25c5d", "heatmap": "e-847cab13d1d2381059a25c5d", "modelKey": "event", "timestamp": 1643033698976 }, { "id": "bf82ebd69f7198988f899813", "type": "sensorMotion", "start": 1643033701310, "end": 1643033701310, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cflvghf Yrgh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bf82ebd69f7198988f899813", "heatmap": "e-bf82ebd69f7198988f899813", "modelKey": "event", "timestamp": null }, { "id": "8faff5c361580abe696c7175", "type": "motion", "start": 1643033726548, "end": 1643033747881, "score": 73, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8faff5c361580abe696c7175", "heatmap": "e-8faff5c361580abe696c7175", "modelKey": "event", "timestamp": 1643033737714 }, { "id": "3939f399baafb1120b91c54e", "type": "sensorMotion", "start": 1643033735186, "end": 1643033735186, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Oriaxs Vubg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3939f399baafb1120b91c54e", "heatmap": "e-3939f399baafb1120b91c54e", "modelKey": "event", "timestamp": null }, { "id": "dba914ff1f093bc03456938f", "type": "sensorMotion", "start": 1643033739743, "end": 1643033739743, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zdewi Eetkell" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-dba914ff1f093bc03456938f", "heatmap": "e-dba914ff1f093bc03456938f", "modelKey": "event", "timestamp": null }, { "id": "e0c2654b3b681f4b73d1f974", "type": "motion", "start": 1643033818082, "end": 1643033839943, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e0c2654b3b681f4b73d1f974", "heatmap": "e-e0c2654b3b681f4b73d1f974", "modelKey": "event", "timestamp": 1643033829249 }, { "id": "45cee07fc50632c93876db44", "type": "smartDetectZone", "start": 1643033852026, "end": 1643033892130, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-45cee07fc50632c93876db44", "heatmap": "e-45cee07fc50632c93876db44", "modelKey": "event", "timestamp": 1643033874960 }, { "id": "21180d58d2a96a760d9c9d38", "type": "motion", "start": 1643033852836, "end": 1643033892235, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "45cee07fc50632c93876db44" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-21180d58d2a96a760d9c9d38", "heatmap": "e-21180d58d2a96a760d9c9d38", "modelKey": "event", "timestamp": 1643033877402 }, { "id": "8aec51731fc04583d67d2702", "type": "sensorMotion", "start": 1643033863540, "end": 1643033863540, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Oslftrc Lzok" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8aec51731fc04583d67d2702", "heatmap": "e-8aec51731fc04583d67d2702", "modelKey": "event", "timestamp": null }, { "id": "3fc3e994c431b451f9cd093f", "type": "sensorMotion", "start": 1643033866902, "end": 1643033866902, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hsnlnz Hmxkk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3fc3e994c431b451f9cd093f", "heatmap": "e-3fc3e994c431b451f9cd093f", "modelKey": "event", "timestamp": null }, { "id": "baa6b464779b1f2b2f118f98", "type": "sensorMotion", "start": 1643033871909, "end": 1643033871909, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jgzjgna Ueek" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-baa6b464779b1f2b2f118f98", "heatmap": "e-baa6b464779b1f2b2f118f98", "modelKey": "event", "timestamp": null }, { "id": "59d0b44991c733a5f21ee9ab", "type": "sensorMotion", "start": 1643033875514, "end": 1643033875514, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rvgjz Pqm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-59d0b44991c733a5f21ee9ab", "heatmap": "e-59d0b44991c733a5f21ee9ab", "modelKey": "event", "timestamp": null }, { "id": "fda5c28f94c00301b9afb496", "type": "sensorMotion", "start": 1643033878891, "end": 1643033878891, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xdwami Unxntaq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-fda5c28f94c00301b9afb496", "heatmap": "e-fda5c28f94c00301b9afb496", "modelKey": "event", "timestamp": null }, { "id": "dd488fa4d39aef142ecee7c8", "type": "sensorMotion", "start": 1643033883755, "end": 1643033883755, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Awdfcs Dohtckv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-dd488fa4d39aef142ecee7c8", "heatmap": "e-dd488fa4d39aef142ecee7c8", "modelKey": "event", "timestamp": null }, { "id": "03b8ba7d3784827d3b16cd4d", "type": "motion", "start": 1643033887045, "end": 1643033915416, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-03b8ba7d3784827d3b16cd4d", "heatmap": "e-03b8ba7d3784827d3b16cd4d", "modelKey": "event", "timestamp": 1643033900580 }, { "id": "891e72365b48c6db5d3c0483", "type": "sensorMotion", "start": 1643033899218, "end": 1643033899218, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Lpdm Rlks" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-891e72365b48c6db5d3c0483", "heatmap": "e-891e72365b48c6db5d3c0483", "modelKey": "event", "timestamp": null }, { "id": "809ab96de9f09ac17037b650", "type": "sensorMotion", "start": 1643033903324, "end": 1643033903324, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Oliavn Nrho" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-809ab96de9f09ac17037b650", "heatmap": "e-809ab96de9f09ac17037b650", "modelKey": "event", "timestamp": null }, { "id": "d390e0125e4e7d42237f6150", "type": "motion", "start": 1643033965476, "end": 1643033991845, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d390e0125e4e7d42237f6150", "heatmap": "e-d390e0125e4e7d42237f6150", "modelKey": "event", "timestamp": 1643033979312 }, { "id": "6b03ec8f7b45a65b426db5c7", "type": "motion", "start": 1643034077118, "end": 1643034100459, "score": 82, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6b03ec8f7b45a65b426db5c7", "heatmap": "e-6b03ec8f7b45a65b426db5c7", "modelKey": "event", "timestamp": 1643034088452 }, { "id": "c8cdcb657f26c7dc94ae68d7", "type": "motion", "start": 1643034098609, "end": 1643034138356, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c8cdcb657f26c7dc94ae68d7", "heatmap": "e-c8cdcb657f26c7dc94ae68d7", "modelKey": "event", "timestamp": 1643034122183 }, { "id": "0cc717c27849fd217ec0f0cd", "type": "smartDetectZone", "start": 1643034106100, "end": 1643034130317, "score": 78, "smartDetectTypes": [ "vehicle" ], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0cc717c27849fd217ec0f0cd", "heatmap": "e-0cc717c27849fd217ec0f0cd", "modelKey": "event", "timestamp": 1643034118926 }, { "id": "30aafe16438a6ad1b8a08d8c", "type": "motion", "start": 1643034107122, "end": 1643034134122, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "86a54e56ad0cff6d21456bfd" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-30aafe16438a6ad1b8a08d8c", "heatmap": "e-30aafe16438a6ad1b8a08d8c", "modelKey": "event", "timestamp": 1643034123788 }, { "id": "cbf1d93f35a9ea7aaeb8704b", "type": "motion", "start": 1643034108435, "end": 1643034131435, "score": 24, "smartDetectTypes": [], "smartDetectEvents": [ "0cc717c27849fd217ec0f0cd" ], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cbf1d93f35a9ea7aaeb8704b", "heatmap": "e-cbf1d93f35a9ea7aaeb8704b", "modelKey": "event", "timestamp": 1643034088452 }, { "id": "86a54e56ad0cff6d21456bfd", "type": "smartDetectZone", "start": 1643034109250, "end": 1643034174195, "score": 86, "smartDetectTypes": [ "vehicle" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-86a54e56ad0cff6d21456bfd", "heatmap": "e-86a54e56ad0cff6d21456bfd", "modelKey": "event", "timestamp": 1643034142295 }, { "id": "6a07abd05bc50d48c2459c35", "type": "motion", "start": 1643034110328, "end": 1643034134864, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "bd039053c283bc5e52ef56ec" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6a07abd05bc50d48c2459c35", "heatmap": "e-6a07abd05bc50d48c2459c35", "modelKey": "event", "timestamp": 1643034124164 }, { "id": "bd039053c283bc5e52ef56ec", "type": "smartDetectZone", "start": 1643034111495, "end": 1643034134166, "score": 74, "smartDetectTypes": [ "vehicle" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bd039053c283bc5e52ef56ec", "heatmap": "e-bd039053c283bc5e52ef56ec", "modelKey": "event", "timestamp": 1643034124164 }, { "id": "f8486ad6cf5e4f7009ef00e7", "type": "sensorMotion", "start": 1643034112181, "end": 1643034112181, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Emz Rgejv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f8486ad6cf5e4f7009ef00e7", "heatmap": "e-f8486ad6cf5e4f7009ef00e7", "modelKey": "event", "timestamp": null }, { "id": "b3b85a9d8680c187fde04313", "type": "sensorMotion", "start": 1643034115106, "end": 1643034115106, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ivi Adoxj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b3b85a9d8680c187fde04313", "heatmap": "e-b3b85a9d8680c187fde04313", "modelKey": "event", "timestamp": null }, { "id": "85807c1988e3812b4a856939", "type": "sensorMotion", "start": 1643034119736, "end": 1643034119736, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Gbkykkm Tdjzgua" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-85807c1988e3812b4a856939", "heatmap": "e-85807c1988e3812b4a856939", "modelKey": "event", "timestamp": null }, { "id": "e5dd98980376dfbd3a2ac42d", "type": "sensorMotion", "start": 1643034124243, "end": 1643034124243, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Agpknne Omvc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e5dd98980376dfbd3a2ac42d", "heatmap": "e-e5dd98980376dfbd3a2ac42d", "modelKey": "event", "timestamp": null }, { "id": "b9ebb1d9a6469c23b95190cc", "type": "smartDetectZone", "start": 1643034124578, "end": 1643034179294, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b9ebb1d9a6469c23b95190cc", "heatmap": "e-b9ebb1d9a6469c23b95190cc", "modelKey": "event", "timestamp": 1643034142258 }, { "id": "5a5467476bee50071c40fb60", "type": "motion", "start": 1643034125457, "end": 1643034153983, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "86a54e56ad0cff6d21456bfd" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5a5467476bee50071c40fb60", "heatmap": "e-5a5467476bee50071c40fb60", "modelKey": "event", "timestamp": 1643034142965 }, { "id": "07e2a6d5ff5e1f43d3345738", "type": "sensorMotion", "start": 1643034127720, "end": 1643034127720, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Inf Kdhj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-07e2a6d5ff5e1f43d3345738", "heatmap": "e-07e2a6d5ff5e1f43d3345738", "modelKey": "event", "timestamp": null }, { "id": "e8f38bb78f605824dc1d0bf0", "type": "motion", "start": 1643034128925, "end": 1643034180310, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "b9ebb1d9a6469c23b95190cc" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e8f38bb78f605824dc1d0bf0", "heatmap": "e-e8f38bb78f605824dc1d0bf0", "modelKey": "event", "timestamp": 1643034142258 }, { "id": "9741fe944c9df1fed9177a9a", "type": "smartDetectZone", "start": 1643034141746, "end": 1643034209062, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9741fe944c9df1fed9177a9a", "heatmap": "e-9741fe944c9df1fed9177a9a", "modelKey": "event", "timestamp": 1643034163010 }, { "id": "87c8669a40b01193b67289f8", "type": "motion", "start": 1643034142686, "end": 1643034210463, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "9741fe944c9df1fed9177a9a" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-87c8669a40b01193b67289f8", "heatmap": "e-87c8669a40b01193b67289f8", "modelKey": "event", "timestamp": 1643034155685 }, { "id": "0ba7eb3323160b8090dd2425", "type": "motion", "start": 1643034148023, "end": 1643034173867, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "86a54e56ad0cff6d21456bfd" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0ba7eb3323160b8090dd2425", "heatmap": "e-0ba7eb3323160b8090dd2425", "modelKey": "event", "timestamp": 1643034159190 }, { "id": "3316c7d2cc43578f09b84a18", "type": "sensorMotion", "start": 1643034153908, "end": 1643034153908, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kxjmg Eostbf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3316c7d2cc43578f09b84a18", "heatmap": "e-3316c7d2cc43578f09b84a18", "modelKey": "event", "timestamp": null }, { "id": "152d23f2c318810f7dfea61d", "type": "sensorMotion", "start": 1643034157100, "end": 1643034157100, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xrarpt Zbspzhc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-152d23f2c318810f7dfea61d", "heatmap": "e-152d23f2c318810f7dfea61d", "modelKey": "event", "timestamp": null }, { "id": "1e1ce63795411aca543bf8b3", "type": "sensorOpened", "start": 1643034160051, "end": 1643034160051, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wxb Yckpr" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-1e1ce63795411aca543bf8b3", "heatmap": "e-1e1ce63795411aca543bf8b3", "modelKey": "event", "timestamp": null }, { "id": "0e2cb7fc08eca54782480a41", "type": "lightMotion", "start": 1643034164127, "end": 1643034164127, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-0e2cb7fc08eca54782480a41", "heatmap": "e-0e2cb7fc08eca54782480a41", "modelKey": "event", "timestamp": null }, { "id": "81dc3eb205d3872ffab10223", "type": "sensorMotion", "start": 1643034164954, "end": 1643034164954, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Arcatf Pfonr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-81dc3eb205d3872ffab10223", "heatmap": "e-81dc3eb205d3872ffab10223", "modelKey": "event", "timestamp": null }, { "id": "2cb88c644af5e6f594b8405e", "type": "sensorMotion", "start": 1643034168688, "end": 1643034168688, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Lbcmeq Por" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2cb88c644af5e6f594b8405e", "heatmap": "e-2cb88c644af5e6f594b8405e", "modelKey": "event", "timestamp": null }, { "id": "78642d47d67c8059f726b53a", "type": "motion", "start": 1643034169816, "end": 1643034214815, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-78642d47d67c8059f726b53a", "heatmap": "e-78642d47d67c8059f726b53a", "modelKey": "event", "timestamp": 1643034184314 }, { "id": "f139f8d88f6403dbc7175d63", "type": "sensorMotion", "start": 1643034171625, "end": 1643034171625, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ssir Pvuemwg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f139f8d88f6403dbc7175d63", "heatmap": "e-f139f8d88f6403dbc7175d63", "modelKey": "event", "timestamp": null }, { "id": "27bcd86abc6291f32aaa2f60", "type": "sensorMotion", "start": 1643034171922, "end": 1643034171922, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Voxzwog Xrxsl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-27bcd86abc6291f32aaa2f60", "heatmap": "e-27bcd86abc6291f32aaa2f60", "modelKey": "event", "timestamp": null }, { "id": "240301c1951062c734fa27c6", "type": "motion", "start": 1643034173695, "end": 1643034245269, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "51e5bc44be6e4094b9fac289", "e37ba8bf3f0c7a9affbeed0d" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-240301c1951062c734fa27c6", "heatmap": "e-240301c1951062c734fa27c6", "modelKey": "event", "timestamp": 1643034231092 }, { "id": "51e5bc44be6e4094b9fac289", "type": "smartDetectZone", "start": 1643034174527, "end": 1643034225060, "score": 79, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-51e5bc44be6e4094b9fac289", "heatmap": "e-51e5bc44be6e4094b9fac289", "modelKey": "event", "timestamp": 1643034196867 }, { "id": "63614bad49ba229fbccc18e5", "type": "motion", "start": 1643034175454, "end": 1643034212649, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "66ea30438debb8943163d24c" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-63614bad49ba229fbccc18e5", "heatmap": "e-63614bad49ba229fbccc18e5", "modelKey": "event", "timestamp": 1643034191622 }, { "id": "66ea30438debb8943163d24c", "type": "smartDetectZone", "start": 1643034175486, "end": 1643034242415, "score": 84, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-66ea30438debb8943163d24c", "heatmap": "e-66ea30438debb8943163d24c", "modelKey": "event", "timestamp": 1643034227590 }, { "id": "21987b70dffbdad398da2710", "type": "sensorMotion", "start": 1643034176689, "end": 1643034176689, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mlg Kaeai" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-21987b70dffbdad398da2710", "heatmap": "e-21987b70dffbdad398da2710", "modelKey": "event", "timestamp": null }, { "id": "98dd21f591734590696386d9", "type": "sensorMotion", "start": 1643034177032, "end": 1643034177032, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Eysfyii Qxgb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-98dd21f591734590696386d9", "heatmap": "e-98dd21f591734590696386d9", "modelKey": "event", "timestamp": null }, { "id": "586a727dcfe1ed52b83b54e0", "type": "sensorMotion", "start": 1643034181895, "end": 1643034181895, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ywuaaj Cstft" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-586a727dcfe1ed52b83b54e0", "heatmap": "e-586a727dcfe1ed52b83b54e0", "modelKey": "event", "timestamp": null }, { "id": "8e4cb37918d1a4503ee7b410", "type": "sensorMotion", "start": 1643034183985, "end": 1643034183985, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Psfltms Uqf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8e4cb37918d1a4503ee7b410", "heatmap": "e-8e4cb37918d1a4503ee7b410", "modelKey": "event", "timestamp": null }, { "id": "f3252e0d59b35df3709bafb0", "type": "lightMotion", "start": 1643034185067, "end": 1643034185067, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-f3252e0d59b35df3709bafb0", "heatmap": "e-f3252e0d59b35df3709bafb0", "modelKey": "event", "timestamp": null }, { "id": "565f4261f2c7dff49b9226ba", "type": "sensorMotion", "start": 1643034185684, "end": 1643034185684, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Veptfg Noyyfqd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-565f4261f2c7dff49b9226ba", "heatmap": "e-565f4261f2c7dff49b9226ba", "modelKey": "event", "timestamp": null }, { "id": "f6bbcea80cd163670c29326d", "type": "sensorMotion", "start": 1643034189807, "end": 1643034189807, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Refhi Mfklien" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f6bbcea80cd163670c29326d", "heatmap": "e-f6bbcea80cd163670c29326d", "modelKey": "event", "timestamp": null }, { "id": "be4465c6857195708bf346af", "type": "sensorMotion", "start": 1643034190165, "end": 1643034190165, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Llp Jzklyd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-be4465c6857195708bf346af", "heatmap": "e-be4465c6857195708bf346af", "modelKey": "event", "timestamp": null }, { "id": "38b6ad02dc08bd5b87c3db04", "type": "lightMotion", "start": 1643034191787, "end": 1643034191787, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-38b6ad02dc08bd5b87c3db04", "heatmap": "e-38b6ad02dc08bd5b87c3db04", "modelKey": "event", "timestamp": null }, { "id": "1c6a751bd4526fe1247302a6", "type": "sensorMotion", "start": 1643034194028, "end": 1643034194028, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Fozaine Tiw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1c6a751bd4526fe1247302a6", "heatmap": "e-1c6a751bd4526fe1247302a6", "modelKey": "event", "timestamp": null }, { "id": "37a2d26f6d7d2155f54e69de", "type": "sensorMotion", "start": 1643034195598, "end": 1643034195598, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Krjwwbe Zcl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-37a2d26f6d7d2155f54e69de", "heatmap": "e-37a2d26f6d7d2155f54e69de", "modelKey": "event", "timestamp": null }, { "id": "9d6be81af13bab06dde81347", "type": "sensorMotion", "start": 1643034197504, "end": 1643034197504, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Kfatshw Ntym" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9d6be81af13bab06dde81347", "heatmap": "e-9d6be81af13bab06dde81347", "modelKey": "event", "timestamp": null }, { "id": "2152b8d701b31b4249224518", "type": "sensorMotion", "start": 1643034201367, "end": 1643034201367, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hqzwlkv Tqlx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2152b8d701b31b4249224518", "heatmap": "e-2152b8d701b31b4249224518", "modelKey": "event", "timestamp": null }, { "id": "e37ba8bf3f0c7a9affbeed0d", "type": "smartDetectZone", "start": 1643034214254, "end": 1643034244596, "score": 79, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e37ba8bf3f0c7a9affbeed0d", "heatmap": "e-e37ba8bf3f0c7a9affbeed0d", "modelKey": "event", "timestamp": 1643034231092 }, { "id": "46847c426ae880c6f67f1cc8", "type": "smartDetectZone", "start": 1643034214691, "end": 1643034321096, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-46847c426ae880c6f67f1cc8", "heatmap": "e-46847c426ae880c6f67f1cc8", "modelKey": "event", "timestamp": 1643034237732 }, { "id": "40b94b67c4f3b293952b8ddf", "type": "motion", "start": 1643034215956, "end": 1643034282001, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "46847c426ae880c6f67f1cc8" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-40b94b67c4f3b293952b8ddf", "heatmap": "e-40b94b67c4f3b293952b8ddf", "modelKey": "event", "timestamp": 1643034243006 }, { "id": "abd2f032eb0720328d48088f", "type": "motion", "start": 1643034216013, "end": 1643034242206, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "66ea30438debb8943163d24c" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-abd2f032eb0720328d48088f", "heatmap": "e-abd2f032eb0720328d48088f", "modelKey": "event", "timestamp": 1643034230515 }, { "id": "306fafeb26009d23a5b2e4fa", "type": "sensorMotion", "start": 1643034224439, "end": 1643034224439, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sajow Ryrwtvj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-306fafeb26009d23a5b2e4fa", "heatmap": "e-306fafeb26009d23a5b2e4fa", "modelKey": "event", "timestamp": null }, { "id": "151ee6311d83e32aaf790418", "type": "lightMotion", "start": 1643034229347, "end": 1643034229347, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-151ee6311d83e32aaf790418", "heatmap": "e-151ee6311d83e32aaf790418", "modelKey": "event", "timestamp": null }, { "id": "1a06ea7c2ea6743e1c83ff39", "type": "sensorMotion", "start": 1643034230619, "end": 1643034230619, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Njie Tnw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1a06ea7c2ea6743e1c83ff39", "heatmap": "e-1a06ea7c2ea6743e1c83ff39", "modelKey": "event", "timestamp": null }, { "id": "82f97bd199f90a5bbec4651a", "type": "sensorMotion", "start": 1643034233466, "end": 1643034233466, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rdhjv Xiqgcd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-82f97bd199f90a5bbec4651a", "heatmap": "e-82f97bd199f90a5bbec4651a", "modelKey": "event", "timestamp": null }, { "id": "5458eb82161fd350b7a5017b", "type": "motion", "start": 1643034235829, "end": 1643034274201, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "09d790c39c9bb29861f0a0b5" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5458eb82161fd350b7a5017b", "heatmap": "e-5458eb82161fd350b7a5017b", "modelKey": "event", "timestamp": 1643034258684 }, { "id": "09d790c39c9bb29861f0a0b5", "type": "smartDetectZone", "start": 1643034236162, "end": 1643034271689, "score": 79, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-09d790c39c9bb29861f0a0b5", "heatmap": "e-09d790c39c9bb29861f0a0b5", "modelKey": "event", "timestamp": 1643034258684 }, { "id": "1653f7a8268f75bd07331e58", "type": "smartDetectZone", "start": 1643034237050, "end": 1643034275519, "score": 84, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1653f7a8268f75bd07331e58", "heatmap": "e-1653f7a8268f75bd07331e58", "modelKey": "event", "timestamp": 1643034255311 }, { "id": "92e51b29fda9f0971a677439", "type": "motion", "start": 1643034237079, "end": 1643034268934, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "1653f7a8268f75bd07331e58" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-92e51b29fda9f0971a677439", "heatmap": "e-92e51b29fda9f0971a677439", "modelKey": "event", "timestamp": 1643034258084 }, { "id": "70f9b930386ee8239bf59402", "type": "sensorMotion", "start": 1643034237459, "end": 1643034237459, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mdwvs Kigqvy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-70f9b930386ee8239bf59402", "heatmap": "e-70f9b930386ee8239bf59402", "modelKey": "event", "timestamp": null }, { "id": "df4358235d5c33e62cd2beca", "type": "sensorMotion", "start": 1643034241707, "end": 1643034241707, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nitb Tlvd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-df4358235d5c33e62cd2beca", "heatmap": "e-df4358235d5c33e62cd2beca", "modelKey": "event", "timestamp": null }, { "id": "a3223b11965b5fcba1d20a16", "type": "sensorMotion", "start": 1643034245570, "end": 1643034245570, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Blramsn Ymawl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a3223b11965b5fcba1d20a16", "heatmap": "e-a3223b11965b5fcba1d20a16", "modelKey": "event", "timestamp": null }, { "id": "5017b196c4855a19ff072781", "type": "lightMotion", "start": 1643034247057, "end": 1643034247057, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-5017b196c4855a19ff072781", "heatmap": "e-5017b196c4855a19ff072781", "modelKey": "event", "timestamp": null }, { "id": "aba0eae7854a9825b3626655", "type": "sensorMotion", "start": 1643034249697, "end": 1643034249697, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kgffofj Qisiqw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-aba0eae7854a9825b3626655", "heatmap": "e-aba0eae7854a9825b3626655", "modelKey": "event", "timestamp": null }, { "id": "ac9fb7c03c661956d1967e5e", "type": "lightMotion", "start": 1643034252217, "end": 1643034252217, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-ac9fb7c03c661956d1967e5e", "heatmap": "e-ac9fb7c03c661956d1967e5e", "modelKey": "event", "timestamp": null }, { "id": "ce6232d5cb8e1066483da558", "type": "sensorMotion", "start": 1643034254698, "end": 1643034254698, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yvpk Csn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ce6232d5cb8e1066483da558", "heatmap": "e-ce6232d5cb8e1066483da558", "modelKey": "event", "timestamp": null }, { "id": "89b2b8af9734f042e1c11789", "type": "sensorMotion", "start": 1643034263451, "end": 1643034263451, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Scxquy Kvaqnw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-89b2b8af9734f042e1c11789", "heatmap": "e-89b2b8af9734f042e1c11789", "modelKey": "event", "timestamp": null }, { "id": "209466155a92260257ea1c8c", "type": "smartDetectZone", "start": 1643034264214, "end": 1643034473688, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-209466155a92260257ea1c8c", "heatmap": "e-209466155a92260257ea1c8c", "modelKey": "event", "timestamp": 1643034460522 }, { "id": "3fa8c60def39861c184a3bea", "type": "motion", "start": 1643034266222, "end": 1643034476529, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "209466155a92260257ea1c8c" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3fa8c60def39861c184a3bea", "heatmap": "e-3fa8c60def39861c184a3bea", "modelKey": "event", "timestamp": 1643034460522 }, { "id": "56c7d026c78b6a609082dc42", "type": "motion", "start": 1643034268334, "end": 1643034323169, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "46847c426ae880c6f67f1cc8" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-56c7d026c78b6a609082dc42", "heatmap": "e-56c7d026c78b6a609082dc42", "modelKey": "event", "timestamp": 1643034288840 }, { "id": "f587c17eb9423b0d5f90e02d", "type": "sensorMotion", "start": 1643034272981, "end": 1643034272981, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ode Tlhmd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f587c17eb9423b0d5f90e02d", "heatmap": "e-f587c17eb9423b0d5f90e02d", "modelKey": "event", "timestamp": null }, { "id": "90d74c51a8520aadca948cf3", "type": "motion", "start": 1643034273269, "end": 1643034295268, "score": 85, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-90d74c51a8520aadca948cf3", "heatmap": "e-90d74c51a8520aadca948cf3", "modelKey": "event", "timestamp": 1643034285103 }, { "id": "7dec1c6d22a8d13e24f412ed", "type": "smartDetectZone", "start": 1643034273545, "end": 1643034473800, "score": 85, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7dec1c6d22a8d13e24f412ed", "heatmap": "e-7dec1c6d22a8d13e24f412ed", "modelKey": "event", "timestamp": 1643034286975 }, { "id": "ee700e8225243ff0f60f1378", "type": "sensorMotion", "start": 1643034278001, "end": 1643034278001, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qhnpv Fokab" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ee700e8225243ff0f60f1378", "heatmap": "e-ee700e8225243ff0f60f1378", "modelKey": "event", "timestamp": null }, { "id": "6ec98d3280b12d822fc298c5", "type": "motion", "start": 1643034278029, "end": 1643034301375, "score": 76, "smartDetectTypes": [], "smartDetectEvents": [ "7dec1c6d22a8d13e24f412ed" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6ec98d3280b12d822fc298c5", "heatmap": "e-6ec98d3280b12d822fc298c5", "modelKey": "event", "timestamp": 1643034289195 }, { "id": "9382cc9045d59b1e0a671038", "type": "motion", "start": 1643034295383, "end": 1643034318415, "score": 36, "smartDetectTypes": [], "smartDetectEvents": [ "7dec1c6d22a8d13e24f412ed" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9382cc9045d59b1e0a671038", "heatmap": "e-9382cc9045d59b1e0a671038", "modelKey": "event", "timestamp": 1643034289195 }, { "id": "4d33dcff80d9d4f5139b9774", "type": "motion", "start": 1643034304267, "end": 1643034326270, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4d33dcff80d9d4f5139b9774", "heatmap": "e-4d33dcff80d9d4f5139b9774", "modelKey": "event", "timestamp": 1643034315434 }, { "id": "a1b88d4b26e1da2a75b7ecd8", "type": "smartDetectZone", "start": 1643034310700, "end": 1643034533166, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a1b88d4b26e1da2a75b7ecd8", "heatmap": "e-a1b88d4b26e1da2a75b7ecd8", "modelKey": "event", "timestamp": 1643034504481 }, { "id": "8fe3efc643d4cbb15bf38bc5", "type": "sensorMotion", "start": 1643034335787, "end": 1643034335787, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Yskd Mxf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8fe3efc643d4cbb15bf38bc5", "heatmap": "e-8fe3efc643d4cbb15bf38bc5", "modelKey": "event", "timestamp": null }, { "id": "a3ad114bd008c28650a56f13", "type": "motion", "start": 1643034340011, "end": 1643034364186, "score": 42, "smartDetectTypes": [], "smartDetectEvents": [ "7dec1c6d22a8d13e24f412ed" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a3ad114bd008c28650a56f13", "heatmap": "e-a3ad114bd008c28650a56f13", "modelKey": "event", "timestamp": 1643034289195 }, { "id": "3caadaf5c08c776d2537e196", "type": "motion", "start": 1643034350192, "end": 1643034376552, "score": 85, "smartDetectTypes": [], "smartDetectEvents": [ "7dec1c6d22a8d13e24f412ed" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3caadaf5c08c776d2537e196", "heatmap": "e-3caadaf5c08c776d2537e196", "modelKey": "event", "timestamp": 1643034362861 }, { "id": "7763476eaefed8866393cab6", "type": "motion", "start": 1643034403200, "end": 1643034430056, "score": 26, "smartDetectTypes": [], "smartDetectEvents": [ "7dec1c6d22a8d13e24f412ed" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7763476eaefed8866393cab6", "heatmap": "e-7763476eaefed8866393cab6", "modelKey": "event", "timestamp": 1643034362861 }, { "id": "fb8e264cb3040b9324a4a086", "type": "motion", "start": 1643034455122, "end": 1643034478140, "score": 65, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fb8e264cb3040b9324a4a086", "heatmap": "e-fb8e264cb3040b9324a4a086", "modelKey": "event", "timestamp": 1643034467124 }, { "id": "7bf5078bbf0f90ad9c3c7086", "type": "smartDetectZone", "start": 1643034492395, "end": 1643034515877, "score": 76, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7bf5078bbf0f90ad9c3c7086", "heatmap": "e-7bf5078bbf0f90ad9c3c7086", "modelKey": "event", "timestamp": 1643034505821 }, { "id": "9c9f85c23e24465ebd47359d", "type": "motion", "start": 1643034492421, "end": 1643034516269, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "7bf5078bbf0f90ad9c3c7086" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9c9f85c23e24465ebd47359d", "heatmap": "e-9c9f85c23e24465ebd47359d", "modelKey": "event", "timestamp": 1643034503587 }, { "id": "040e5519c2752459a3256f2c", "type": "smartDetectZone", "start": 1643034492633, "end": 1643034518826, "score": 78, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-040e5519c2752459a3256f2c", "heatmap": "e-040e5519c2752459a3256f2c", "modelKey": "event", "timestamp": 1643034506143 }, { "id": "b38afdd13745aebecca45c76", "type": "motion", "start": 1643034492799, "end": 1643034525329, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "040e5519c2752459a3256f2c" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b38afdd13745aebecca45c76", "heatmap": "e-b38afdd13745aebecca45c76", "modelKey": "event", "timestamp": 1643034506143 }, { "id": "20e0a77e3d59fc594c72b4b5", "type": "motion", "start": 1643034494305, "end": 1643034544356, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "a1b88d4b26e1da2a75b7ecd8" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-20e0a77e3d59fc594c72b4b5", "heatmap": "e-20e0a77e3d59fc594c72b4b5", "modelKey": "event", "timestamp": 1643034507638 }, { "id": "3d44489f1d418a0b549bbfb7", "type": "lightMotion", "start": 1643034504337, "end": 1643034504337, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-3d44489f1d418a0b549bbfb7", "heatmap": "e-3d44489f1d418a0b549bbfb7", "modelKey": "event", "timestamp": null }, { "id": "3dc1a02ec2a032c1b37fc652", "type": "sensorMotion", "start": 1643034505783, "end": 1643034505783, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Enmboru Angh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3dc1a02ec2a032c1b37fc652", "heatmap": "e-3dc1a02ec2a032c1b37fc652", "modelKey": "event", "timestamp": null }, { "id": "c245296944dc152bd5e48981", "type": "motion", "start": 1643034512488, "end": 1643034536498, "score": 58, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c245296944dc152bd5e48981", "heatmap": "e-c245296944dc152bd5e48981", "modelKey": "event", "timestamp": 1643034523655 }, { "id": "92ffcc521268b11cba706f42", "type": "motion", "start": 1643034512932, "end": 1643034539248, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "a1561674672c0b261ccddf68" ], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-92ffcc521268b11cba706f42", "heatmap": "e-92ffcc521268b11cba706f42", "modelKey": "event", "timestamp": 1643034527413 }, { "id": "a1561674672c0b261ccddf68", "type": "smartDetectZone", "start": 1643034514462, "end": 1643034535865, "score": 79, "smartDetectTypes": [ "vehicle" ], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a1561674672c0b261ccddf68", "heatmap": "e-a1561674672c0b261ccddf68", "modelKey": "event", "timestamp": 1643034525462 }, { "id": "d4375332199ac173b163eb25", "type": "sensorClosed", "start": 1643034521741, "end": 1643034521741, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Allfkcn Ljqjtmf" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-d4375332199ac173b163eb25", "heatmap": "e-d4375332199ac173b163eb25", "modelKey": "event", "timestamp": null }, { "id": "b3e0e0bc55af5839453a9fff", "type": "sensorMotion", "start": 1643034522650, "end": 1643034522650, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Amdieiv Dkj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b3e0e0bc55af5839453a9fff", "heatmap": "e-b3e0e0bc55af5839453a9fff", "modelKey": "event", "timestamp": null }, { "id": "396bba8dab73f7ece6065dc7", "type": "motion", "start": 1643034523081, "end": 1643034545924, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-396bba8dab73f7ece6065dc7", "heatmap": "e-396bba8dab73f7ece6065dc7", "modelKey": "event", "timestamp": 1643034534248 }, { "id": "72d0a74c193d2729208e52ff", "type": "sensorMotion", "start": 1643034651880, "end": 1643034651880, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ndfxolu Ylkh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-72d0a74c193d2729208e52ff", "heatmap": "e-72d0a74c193d2729208e52ff", "modelKey": "event", "timestamp": null }, { "id": "68507083223c1faff68c5331", "type": "motion", "start": 1643034745568, "end": 1643034766910, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-68507083223c1faff68c5331", "heatmap": "e-68507083223c1faff68c5331", "modelKey": "event", "timestamp": 1643034756901 }, { "id": "347d3ea944e3a2715f4ace55", "type": "motion", "start": 1643035340318, "end": 1643035362827, "score": 82, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-347d3ea944e3a2715f4ace55", "heatmap": "e-347d3ea944e3a2715f4ace55", "modelKey": "event", "timestamp": 1643035351486 }, { "id": "1147e5e521c5fec8969bd3cb", "type": "motion", "start": 1643035343244, "end": 1643035364743, "score": 9, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1147e5e521c5fec8969bd3cb", "heatmap": "e-1147e5e521c5fec8969bd3cb", "modelKey": "event", "timestamp": 1643034534248 }, { "id": "74363b23a1e9e6de27950b86", "type": "motion", "start": 1643035359897, "end": 1643035381724, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-74363b23a1e9e6de27950b86", "heatmap": "e-74363b23a1e9e6de27950b86", "modelKey": "event", "timestamp": 1643035351486 }, { "id": "57131678d0ae3f61acd2a52b", "type": "motion", "start": 1643035611387, "end": 1643035633899, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-57131678d0ae3f61acd2a52b", "heatmap": "e-57131678d0ae3f61acd2a52b", "modelKey": "event", "timestamp": 1643035622563 }, { "id": "a363cf26ad14ee10a9973337", "type": "motion", "start": 1643036049491, "end": 1643036072162, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a363cf26ad14ee10a9973337", "heatmap": "e-a363cf26ad14ee10a9973337", "modelKey": "event", "timestamp": 1643036060658 }, { "id": "8731495d403021bbc2412bad", "type": "motion", "start": 1643036204181, "end": 1643036226015, "score": 94, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8731495d403021bbc2412bad", "heatmap": "e-8731495d403021bbc2412bad", "modelKey": "event", "timestamp": 1643036215347 }, { "id": "d7f91a1249291d4ce0970395", "type": "motion", "start": 1643036437273, "end": 1643036458943, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d7f91a1249291d4ce0970395", "heatmap": "e-d7f91a1249291d4ce0970395", "modelKey": "event", "timestamp": 1643036215347 }, { "id": "378a20292406ee2d20a56f27", "type": "motion", "start": 1643037018033, "end": 1643037039527, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-378a20292406ee2d20a56f27", "heatmap": "e-378a20292406ee2d20a56f27", "modelKey": "event", "timestamp": 1643037029194 }, { "id": "cd3a4d0443e0c393706ee70f", "type": "sensorMotion", "start": 1643037030880, "end": 1643037030880, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hxme Pjvd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cd3a4d0443e0c393706ee70f", "heatmap": "e-cd3a4d0443e0c393706ee70f", "modelKey": "event", "timestamp": null }, { "id": "99d62ec53eef45464d11b8e4", "type": "sensorMotion", "start": 1643037036416, "end": 1643037036416, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xthbn Gplbc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-99d62ec53eef45464d11b8e4", "heatmap": "e-99d62ec53eef45464d11b8e4", "modelKey": "event", "timestamp": null }, { "id": "1d50b893fb0fa21b6156ab9f", "type": "motion", "start": 1643037233934, "end": 1643037255433, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1d50b893fb0fa21b6156ab9f", "heatmap": "e-1d50b893fb0fa21b6156ab9f", "modelKey": "event", "timestamp": 1643037245267 }, { "id": "42ae4ac889974c03d795e670", "type": "motion", "start": 1643037763765, "end": 1643037784767, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-42ae4ac889974c03d795e670", "heatmap": "e-42ae4ac889974c03d795e670", "modelKey": "event", "timestamp": 1643037245267 }, { "id": "b64f526dee4974658a6aa80f", "type": "motion", "start": 1643037962976, "end": 1643037985144, "score": 81, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b64f526dee4974658a6aa80f", "heatmap": "e-b64f526dee4974658a6aa80f", "modelKey": "event", "timestamp": 1643037975144 }, { "id": "be264e27e6b256bcf2f9381b", "type": "motion", "start": 1643038079173, "end": 1643038100677, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-be264e27e6b256bcf2f9381b", "heatmap": "e-be264e27e6b256bcf2f9381b", "modelKey": "event", "timestamp": 1643037975144 }, { "id": "3a2a9a47705436037cbd3b7a", "type": "access", "start": 1643038136835, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "8593657a25b7826a4288b6af", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-3a2a9a47705436037cbd3b7a", "heatmap": "e-3a2a9a47705436037cbd3b7a", "modelKey": "event", "timestamp": null }, { "id": "32ba93a82da7e973aa99b01d", "type": "access", "start": 1643038145947, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "dcaef9cb8aed05c7db658a46", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-32ba93a82da7e973aa99b01d", "heatmap": "e-32ba93a82da7e973aa99b01d", "modelKey": "event", "timestamp": null }, { "id": "9fb10aee5cb8c3fac2cb3e4e", "type": "motion", "start": 1643038443103, "end": 1643038464939, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9fb10aee5cb8c3fac2cb3e4e", "heatmap": "e-9fb10aee5cb8c3fac2cb3e4e", "modelKey": "event", "timestamp": 1643038454769 }, { "id": "9bcc0355bf06dfd40960cc32", "type": "smartDetectZone", "start": 1643038851191, "end": 1643038872941, "score": 31, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "f0cd15b8bed9e38899286a8c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9bcc0355bf06dfd40960cc32", "heatmap": "e-9bcc0355bf06dfd40960cc32", "modelKey": "event", "timestamp": 1643038862483 }, { "id": "34730af354700c6729f331de", "type": "motion", "start": 1643038851268, "end": 1643038873769, "score": 39, "smartDetectTypes": [], "smartDetectEvents": [ "9bcc0355bf06dfd40960cc32" ], "camera": "f0cd15b8bed9e38899286a8c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-34730af354700c6729f331de", "heatmap": "e-34730af354700c6729f331de", "modelKey": "event", "timestamp": 1642994081310 }, { "id": "18c81712f6616b9e5461ff94", "type": "motion", "start": 1643038858374, "end": 1643038880200, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-18c81712f6616b9e5461ff94", "heatmap": "e-18c81712f6616b9e5461ff94", "modelKey": "event", "timestamp": 1643038869530 }, { "id": "fdb40a9eeba05ac5355d6d19", "type": "motion", "start": 1643040017026, "end": 1643040038197, "score": 87, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fdb40a9eeba05ac5355d6d19", "heatmap": "e-fdb40a9eeba05ac5355d6d19", "modelKey": "event", "timestamp": 1643040028193 }, { "id": "50949a93485e2efd056881cd", "type": "motion", "start": 1643040326743, "end": 1643040355322, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-50949a93485e2efd056881cd", "heatmap": "e-50949a93485e2efd056881cd", "modelKey": "event", "timestamp": 1643040340478 }, { "id": "daef7eaaa4f5105d4c3120ef", "type": "sensorMotion", "start": 1643040338973, "end": 1643040338973, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qnvss Zun" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-daef7eaaa4f5105d4c3120ef", "heatmap": "e-daef7eaaa4f5105d4c3120ef", "modelKey": "event", "timestamp": null }, { "id": "a01760bea04587eec85b459a", "type": "motion", "start": 1643040368090, "end": 1643040395009, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a01760bea04587eec85b459a", "heatmap": "e-a01760bea04587eec85b459a", "modelKey": "event", "timestamp": 1643040380455 }, { "id": "62bb9e3f214093567445abcd", "type": "sensorMotion", "start": 1643040379916, "end": 1643040379916, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Nsj Yedwcgz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-62bb9e3f214093567445abcd", "heatmap": "e-62bb9e3f214093567445abcd", "modelKey": "event", "timestamp": null }, { "id": "ad3a3af0e57df6c179e1824f", "type": "sensorMotion", "start": 1643040383779, "end": 1643040383779, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hqlmg Ues" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ad3a3af0e57df6c179e1824f", "heatmap": "e-ad3a3af0e57df6c179e1824f", "modelKey": "event", "timestamp": null }, { "id": "4f842f170531d4b724b1dafc", "type": "motion", "start": 1643040531297, "end": 1643040552464, "score": 82, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4f842f170531d4b724b1dafc", "heatmap": "e-4f842f170531d4b724b1dafc", "modelKey": "event", "timestamp": 1643040542464 }, { "id": "b39227de7be73afeb756cc5a", "type": "motion", "start": 1643040915109, "end": 1643040937120, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b39227de7be73afeb756cc5a", "heatmap": "e-b39227de7be73afeb756cc5a", "modelKey": "event", "timestamp": 1643040926277 }, { "id": "527d526e5c1e57363981c23f", "type": "motion", "start": 1643040920136, "end": 1643040943310, "score": 5, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-527d526e5c1e57363981c23f", "heatmap": "e-527d526e5c1e57363981c23f", "modelKey": "event", "timestamp": 1643035622563 }, { "id": "c50f4e55db5f9419882b26ed", "type": "access", "start": 1643041036221, "end": 1643041334668, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "adec5334b69f56f6a6c47520", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-c50f4e55db5f9419882b26ed", "heatmap": "e-c50f4e55db5f9419882b26ed", "modelKey": "event", "timestamp": null }, { "id": "24e70a137cbc481fe514f1e4", "type": "motion", "start": 1643041118901, "end": 1643041140401, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-24e70a137cbc481fe514f1e4", "heatmap": "e-24e70a137cbc481fe514f1e4", "modelKey": "event", "timestamp": 1643041130235 }, { "id": "ce54930b351bb34b8eebe01a", "type": "motion", "start": 1643041487524, "end": 1643041508704, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ce54930b351bb34b8eebe01a", "heatmap": "e-ce54930b351bb34b8eebe01a", "modelKey": "event", "timestamp": 1643041130235 }, { "id": "8795e3fc9fcdbceaa26def5a", "type": "motion", "start": 1643041564516, "end": 1643041586705, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8795e3fc9fcdbceaa26def5a", "heatmap": "e-8795e3fc9fcdbceaa26def5a", "modelKey": "event", "timestamp": 1643041575682 }, { "id": "315d10575a7d928f5a017792", "type": "motion", "start": 1643041631463, "end": 1643041652629, "score": 85, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-315d10575a7d928f5a017792", "heatmap": "e-315d10575a7d928f5a017792", "modelKey": "event", "timestamp": 1643041642630 }, { "id": "7efd506681db68df7c517373", "type": "motion", "start": 1643041638348, "end": 1643041665212, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7efd506681db68df7c517373", "heatmap": "e-7efd506681db68df7c517373", "modelKey": "event", "timestamp": 1643041650710 }, { "id": "b26556fbb76e1794c308f2a3", "type": "sensorMotion", "start": 1643041649309, "end": 1643041649309, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Dtjjwt Weeqs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b26556fbb76e1794c308f2a3", "heatmap": "e-b26556fbb76e1794c308f2a3", "modelKey": "event", "timestamp": null }, { "id": "75dc4cb27a11b5ee774f33b7", "type": "sensorMotion", "start": 1643041652915, "end": 1643041652915, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Usyi Bjxle" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-75dc4cb27a11b5ee774f33b7", "heatmap": "e-75dc4cb27a11b5ee774f33b7", "modelKey": "event", "timestamp": null }, { "id": "f3d18cd9f3cfedffef1c4664", "type": "motion", "start": 1643041865628, "end": 1643041894513, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f3d18cd9f3cfedffef1c4664", "heatmap": "e-f3d18cd9f3cfedffef1c4664", "modelKey": "event", "timestamp": 1643041879293 }, { "id": "cbf039a94065de2e3c9d373d", "type": "sensorMotion", "start": 1643041877978, "end": 1643041877978, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Abcmyne Jsvcpiy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cbf039a94065de2e3c9d373d", "heatmap": "e-cbf039a94065de2e3c9d373d", "modelKey": "event", "timestamp": null }, { "id": "64f8024715e2db112c79a548", "type": "sensorMotion", "start": 1643041882870, "end": 1643041882870, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Rdwo Usljlq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-64f8024715e2db112c79a548", "heatmap": "e-64f8024715e2db112c79a548", "modelKey": "event", "timestamp": null }, { "id": "09a6a79146b3e97fc71fb20a", "type": "motion", "start": 1643042042177, "end": 1643042064350, "score": 80, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-09a6a79146b3e97fc71fb20a", "heatmap": "e-09a6a79146b3e97fc71fb20a", "modelKey": "event", "timestamp": 1643042053511 }, { "id": "1a6d457296f65658459b9731", "type": "motion", "start": 1643042088441, "end": 1643042109621, "score": 92, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1a6d457296f65658459b9731", "heatmap": "e-1a6d457296f65658459b9731", "modelKey": "event", "timestamp": 1643042099621 }, { "id": "092e7e2c439f37c4481d6b6f", "type": "motion", "start": 1643042327401, "end": 1643042350403, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-092e7e2c439f37c4481d6b6f", "heatmap": "e-092e7e2c439f37c4481d6b6f", "modelKey": "event", "timestamp": 1643042340259 }, { "id": "2e7a29d1d2ee9c6b4e959283", "type": "motion", "start": 1643042662604, "end": 1643042683940, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2e7a29d1d2ee9c6b4e959283", "heatmap": "e-2e7a29d1d2ee9c6b4e959283", "modelKey": "event", "timestamp": 1643042673937 }, { "id": "013230719a2f622d6dd62f2e", "type": "motion", "start": 1643042663853, "end": 1643042689689, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-013230719a2f622d6dd62f2e", "heatmap": "e-013230719a2f622d6dd62f2e", "modelKey": "event", "timestamp": 1643042679353 }, { "id": "c654d9582e7c5aabcbf0acb9", "type": "motion", "start": 1643042668943, "end": 1643042690778, "score": 7, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c654d9582e7c5aabcbf0acb9", "heatmap": "e-c654d9582e7c5aabcbf0acb9", "modelKey": "event", "timestamp": 1643042673937 }, { "id": "894395a64c4bdef76bb6d00c", "type": "motion", "start": 1643042698732, "end": 1643042721568, "score": 72, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-894395a64c4bdef76bb6d00c", "heatmap": "e-894395a64c4bdef76bb6d00c", "modelKey": "event", "timestamp": 1643042709900 }, { "id": "f1cea2328fdc8794dfd51acb", "type": "motion", "start": 1643042750613, "end": 1643042773452, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f1cea2328fdc8794dfd51acb", "heatmap": "e-f1cea2328fdc8794dfd51acb", "modelKey": "event", "timestamp": 1643042761779 }, { "id": "42126853c5e30601a8db577f", "type": "motion", "start": 1643042754022, "end": 1643042775525, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-42126853c5e30601a8db577f", "heatmap": "e-42126853c5e30601a8db577f", "modelKey": "event", "timestamp": 1643042765522 }, { "id": "c05aa053c6f719f718e008e4", "type": "motion", "start": 1643042764871, "end": 1643042787210, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c05aa053c6f719f718e008e4", "heatmap": "e-c05aa053c6f719f718e008e4", "modelKey": "event", "timestamp": 1643042776038 }, { "id": "b5713b2abad42a3b22938d15", "type": "motion", "start": 1643042827488, "end": 1643042848659, "score": 75, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b5713b2abad42a3b22938d15", "heatmap": "e-b5713b2abad42a3b22938d15", "modelKey": "event", "timestamp": 1643042838655 }, { "id": "67c87489edcd1bd6ce54253e", "type": "motion", "start": 1643042933418, "end": 1643042954787, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-67c87489edcd1bd6ce54253e", "heatmap": "e-67c87489edcd1bd6ce54253e", "modelKey": "event", "timestamp": 1643042944585 }, { "id": "ec925b4a1b8ff94e5aa60bc7", "type": "motion", "start": 1643042945769, "end": 1643042969942, "score": 20, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ec925b4a1b8ff94e5aa60bc7", "heatmap": "e-ec925b4a1b8ff94e5aa60bc7", "modelKey": "event", "timestamp": 1643042959774 }, { "id": "1c9bbfaa903b0c37cbd7f83c", "type": "motion", "start": 1643043243382, "end": 1643043270552, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1c9bbfaa903b0c37cbd7f83c", "heatmap": "e-1c9bbfaa903b0c37cbd7f83c", "modelKey": "event", "timestamp": 1643043258223 }, { "id": "09721a527552f14fa01c9d5c", "type": "motion", "start": 1643043245340, "end": 1643043266528, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-09721a527552f14fa01c9d5c", "heatmap": "e-09721a527552f14fa01c9d5c", "modelKey": "event", "timestamp": 1643043256507 }, { "id": "c388a5ed945ea3854b228cf9", "type": "sensorMotion", "start": 1643043252689, "end": 1643043252689, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qcmzii Iea" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c388a5ed945ea3854b228cf9", "heatmap": "e-c388a5ed945ea3854b228cf9", "modelKey": "event", "timestamp": null }, { "id": "b0da8a423ebd70bf5fe1d9e5", "type": "sensorMotion", "start": 1643043259255, "end": 1643043259255, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Kbqyz Dlg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b0da8a423ebd70bf5fe1d9e5", "heatmap": "e-b0da8a423ebd70bf5fe1d9e5", "modelKey": "event", "timestamp": null }, { "id": "e4c06ef076ea15a8a3de3f29", "type": "motion", "start": 1643043264465, "end": 1643043292139, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e4c06ef076ea15a8a3de3f29", "heatmap": "e-e4c06ef076ea15a8a3de3f29", "modelKey": "event", "timestamp": 1643043275633 }, { "id": "081bc51b8b883756d51a9a14", "type": "sensorMotion", "start": 1643043274963, "end": 1643043274963, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Dcexjo Rnrysve" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-081bc51b8b883756d51a9a14", "heatmap": "e-081bc51b8b883756d51a9a14", "modelKey": "event", "timestamp": null }, { "id": "caa11c323792f753b5ccd267", "type": "motion", "start": 1643043279050, "end": 1643043306390, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-caa11c323792f753b5ccd267", "heatmap": "e-caa11c323792f753b5ccd267", "modelKey": "event", "timestamp": 1643043290382 }, { "id": "0ac3945755441c01792a9275", "type": "sensorMotion", "start": 1643043279470, "end": 1643043279470, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Wrg Zwlw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0ac3945755441c01792a9275", "heatmap": "e-0ac3945755441c01792a9275", "modelKey": "event", "timestamp": null }, { "id": "46a3db31e1d81f20fdae7e9a", "type": "motion", "start": 1643043285782, "end": 1643043308925, "score": 19, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-46a3db31e1d81f20fdae7e9a", "heatmap": "e-46a3db31e1d81f20fdae7e9a", "modelKey": "event", "timestamp": 1643042959774 }, { "id": "585cab5d98964d5c2320b58b", "type": "motion", "start": 1643043305153, "end": 1643043332999, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-585cab5d98964d5c2320b58b", "heatmap": "e-585cab5d98964d5c2320b58b", "modelKey": "event", "timestamp": 1643043319320 }, { "id": "4520caf27925a5190a2f0a96", "type": "sensorMotion", "start": 1643043356351, "end": 1643043356351, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Vykvcf Iippvl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4520caf27925a5190a2f0a96", "heatmap": "e-4520caf27925a5190a2f0a96", "modelKey": "event", "timestamp": null }, { "id": "84f89d11b3e7da4ef003edc5", "type": "sensorMotion", "start": 1643043501416, "end": 1643043501416, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Snflcgv Vvux" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-84f89d11b3e7da4ef003edc5", "heatmap": "e-84f89d11b3e7da4ef003edc5", "modelKey": "event", "timestamp": null }, { "id": "0c6b1ce8cb22b13baa77ae80", "type": "sensorMotion", "start": 1643043526413, "end": 1643043526413, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Oks Yfryvop" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0c6b1ce8cb22b13baa77ae80", "heatmap": "e-0c6b1ce8cb22b13baa77ae80", "modelKey": "event", "timestamp": null }, { "id": "cb6f6f9d0291ea10aba8ba37", "type": "smartDetectZone", "start": 1643043533442, "end": 1643043584506, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cb6f6f9d0291ea10aba8ba37", "heatmap": "e-cb6f6f9d0291ea10aba8ba37", "modelKey": "event", "timestamp": 1643043558380 }, { "id": "47c1d3918dabe8153e68405b", "type": "motion", "start": 1643043537466, "end": 1643043584403, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "cb6f6f9d0291ea10aba8ba37" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-47c1d3918dabe8153e68405b", "heatmap": "e-47c1d3918dabe8153e68405b", "modelKey": "event", "timestamp": 1643043569184 }, { "id": "adb70d6c5346a5297543febf", "type": "motion", "start": 1643043537562, "end": 1643043558909, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-adb70d6c5346a5297543febf", "heatmap": "e-adb70d6c5346a5297543febf", "modelKey": "event", "timestamp": 1643043548907 }, { "id": "2f0a20c1b346f32d75487ceb", "type": "sensorMotion", "start": 1643043544616, "end": 1643043544616, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dccr Etrlgcy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2f0a20c1b346f32d75487ceb", "heatmap": "e-2f0a20c1b346f32d75487ceb", "modelKey": "event", "timestamp": null }, { "id": "ba5c700c719332ac4e15815f", "type": "sensorMotion", "start": 1643043547434, "end": 1643043547434, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ooddau Lvpxif" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ba5c700c719332ac4e15815f", "heatmap": "e-ba5c700c719332ac4e15815f", "modelKey": "event", "timestamp": null }, { "id": "778d7845364cac274ea23fc8", "type": "sensorMotion", "start": 1643043551615, "end": 1643043551615, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dntl Cjcl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-778d7845364cac274ea23fc8", "heatmap": "e-778d7845364cac274ea23fc8", "modelKey": "event", "timestamp": null }, { "id": "1b94907f506c6dfcec6e5b95", "type": "sensorMotion", "start": 1643043556847, "end": 1643043556847, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ykxz Fpued" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1b94907f506c6dfcec6e5b95", "heatmap": "e-1b94907f506c6dfcec6e5b95", "modelKey": "event", "timestamp": null }, { "id": "2ea2a757e2d9f0b1eb76c6b5", "type": "sensorMotion", "start": 1643043563785, "end": 1643043563785, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Uehrmcl Ifcn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2ea2a757e2d9f0b1eb76c6b5", "heatmap": "e-2ea2a757e2d9f0b1eb76c6b5", "modelKey": "event", "timestamp": null }, { "id": "cc51886812b5e17bd63c0d9d", "type": "sensorMotion", "start": 1643043568806, "end": 1643043568806, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pntwatr Mer" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cc51886812b5e17bd63c0d9d", "heatmap": "e-cc51886812b5e17bd63c0d9d", "modelKey": "event", "timestamp": null }, { "id": "d812cd195204db7c8660f139", "type": "sensorMotion", "start": 1643043572928, "end": 1643043572928, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Alnqfki Zxwowmg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d812cd195204db7c8660f139", "heatmap": "e-d812cd195204db7c8660f139", "modelKey": "event", "timestamp": null }, { "id": "9187fe8b6ca7c94637c749d2", "type": "motion", "start": 1643043582174, "end": 1643043603344, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9187fe8b6ca7c94637c749d2", "heatmap": "e-9187fe8b6ca7c94637c749d2", "modelKey": "event", "timestamp": 1643043593341 }, { "id": "9310fae8e786cec462ee350b", "type": "sensorMotion", "start": 1643043610470, "end": 1643043610470, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Iulxm Ajatut" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9310fae8e786cec462ee350b", "heatmap": "e-9310fae8e786cec462ee350b", "modelKey": "event", "timestamp": null }, { "id": "32dee6c21329ef16d61158e8", "type": "sensorMotion", "start": 1643043640084, "end": 1643043640084, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Nglp Pteqae" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-32dee6c21329ef16d61158e8", "heatmap": "e-32dee6c21329ef16d61158e8", "modelKey": "event", "timestamp": null }, { "id": "2f70df8ab84f077a0665c1c4", "type": "sensorMotion", "start": 1643043766650, "end": 1643043766650, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Fmqvl Ojf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2f70df8ab84f077a0665c1c4", "heatmap": "e-2f70df8ab84f077a0665c1c4", "modelKey": "event", "timestamp": null }, { "id": "87d90402a0dd7c60da1eb842", "type": "motion", "start": 1643043781871, "end": 1643043803206, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-87d90402a0dd7c60da1eb842", "heatmap": "e-87d90402a0dd7c60da1eb842", "modelKey": "event", "timestamp": 1643043793206 }, { "id": "81f79c2901e5544b07bdf154", "type": "motion", "start": 1643043789742, "end": 1643043810744, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-81f79c2901e5544b07bdf154", "heatmap": "e-81f79c2901e5544b07bdf154", "modelKey": "event", "timestamp": 1643043793206 }, { "id": "a6137bb8a65cce32f43d1b3b", "type": "motion", "start": 1643043906128, "end": 1643043934299, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a6137bb8a65cce32f43d1b3b", "heatmap": "e-a6137bb8a65cce32f43d1b3b", "modelKey": "event", "timestamp": 1643043918462 }, { "id": "45626b14429c717df0e6ea4a", "type": "sensorMotion", "start": 1643043917164, "end": 1643043917164, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Rpc Wxzgbp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-45626b14429c717df0e6ea4a", "heatmap": "e-45626b14429c717df0e6ea4a", "modelKey": "event", "timestamp": null }, { "id": "cce220edaf841d8a4c7faa6c", "type": "sensorMotion", "start": 1643043921568, "end": 1643043921568, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Vhuk Hybx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cce220edaf841d8a4c7faa6c", "heatmap": "e-cce220edaf841d8a4c7faa6c", "modelKey": "event", "timestamp": null }, { "id": "f018b446975b212e806b469e", "type": "motion", "start": 1643044066134, "end": 1643044092987, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f018b446975b212e806b469e", "heatmap": "e-f018b446975b212e806b469e", "modelKey": "event", "timestamp": 1643044080316 }, { "id": "914017a15d80941d577a08a7", "type": "sensorMotion", "start": 1643044078921, "end": 1643044078921, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "His Fqdo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-914017a15d80941d577a08a7", "heatmap": "e-914017a15d80941d577a08a7", "modelKey": "event", "timestamp": null }, { "id": "226b267c7a62d4b466a8c1e6", "type": "sensorMotion", "start": 1643044083773, "end": 1643044083773, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Toqd Aldgn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-226b267c7a62d4b466a8c1e6", "heatmap": "e-226b267c7a62d4b466a8c1e6", "modelKey": "event", "timestamp": null }, { "id": "a94051c9a170dbec7728c5e6", "type": "motion", "start": 1643044220323, "end": 1643044241323, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a94051c9a170dbec7728c5e6", "heatmap": "e-a94051c9a170dbec7728c5e6", "modelKey": "event", "timestamp": 1643043793206 }, { "id": "e9763dee00986e40dc0d3b29", "type": "motion", "start": 1643044381550, "end": 1643044407218, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e9763dee00986e40dc0d3b29", "heatmap": "e-e9763dee00986e40dc0d3b29", "modelKey": "event", "timestamp": 1643044397053 }, { "id": "c8ee9cb66e1930bfe980e344", "type": "motion", "start": 1643044381893, "end": 1643044403058, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c8ee9cb66e1930bfe980e344", "heatmap": "e-c8ee9cb66e1930bfe980e344", "modelKey": "event", "timestamp": 1643042959774 }, { "id": "d2095c91472f83246d16a3b9", "type": "motion", "start": 1643044566653, "end": 1643044588337, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d2095c91472f83246d16a3b9", "heatmap": "e-d2095c91472f83246d16a3b9", "modelKey": "event", "timestamp": 1643044578328 }, { "id": "2d69383264993c13751076d0", "type": "motion", "start": 1643044781316, "end": 1643044803141, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2d69383264993c13751076d0", "heatmap": "e-2d69383264993c13751076d0", "modelKey": "event", "timestamp": 1643044792467 }, { "id": "743a8b495bceaa3438d4fffd", "type": "motion", "start": 1643044929719, "end": 1643044951057, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-743a8b495bceaa3438d4fffd", "heatmap": "e-743a8b495bceaa3438d4fffd", "modelKey": "event", "timestamp": 1643044941053 }, { "id": "54baab571604547c39f650e6", "type": "motion", "start": 1643045044370, "end": 1643045065709, "score": 97, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-54baab571604547c39f650e6", "heatmap": "e-54baab571604547c39f650e6", "modelKey": "event", "timestamp": 1643045055703 }, { "id": "2cca5fbae2b03099df7f26f8", "type": "motion", "start": 1643045268635, "end": 1643045293488, "score": 94, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2cca5fbae2b03099df7f26f8", "heatmap": "e-2cca5fbae2b03099df7f26f8", "modelKey": "event", "timestamp": 1643045283478 }, { "id": "99577e3e0fceecb89af6e401", "type": "motion", "start": 1643045337297, "end": 1643045358298, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-99577e3e0fceecb89af6e401", "heatmap": "e-99577e3e0fceecb89af6e401", "modelKey": "event", "timestamp": 1643045055703 }, { "id": "613250db7fa351d66eeb4106", "type": "motion", "start": 1643045373155, "end": 1643045394485, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-613250db7fa351d66eeb4106", "heatmap": "e-613250db7fa351d66eeb4106", "modelKey": "event", "timestamp": 1643045384303 }, { "id": "8fc03d02b66e891979910823", "type": "motion", "start": 1643045377283, "end": 1643045399783, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8fc03d02b66e891979910823", "heatmap": "e-8fc03d02b66e891979910823", "modelKey": "event", "timestamp": 1643045388449 }, { "id": "6650cff19be71c53f4e3f8b7", "type": "motion", "start": 1643045433502, "end": 1643045455181, "score": 95, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6650cff19be71c53f4e3f8b7", "heatmap": "e-6650cff19be71c53f4e3f8b7", "modelKey": "event", "timestamp": 1643045445173 }, { "id": "51f6ec394067bb1cf3aad739", "type": "motion", "start": 1643045581981, "end": 1643045603829, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-51f6ec394067bb1cf3aad739", "heatmap": "e-51f6ec394067bb1cf3aad739", "modelKey": "event", "timestamp": 1643045593819 }, { "id": "baf8f53a54c3004c9a120224", "type": "motion", "start": 1643045631897, "end": 1643045653897, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-baf8f53a54c3004c9a120224", "heatmap": "e-baf8f53a54c3004c9a120224", "modelKey": "event", "timestamp": 1643045643897 }, { "id": "085f6a7fbf57ba1c816485ba", "type": "motion", "start": 1643045633986, "end": 1643045655322, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-085f6a7fbf57ba1c816485ba", "heatmap": "e-085f6a7fbf57ba1c816485ba", "modelKey": "event", "timestamp": 1643045645152 }, { "id": "b1d520cb773fb9b165ec0d7e", "type": "motion", "start": 1643045706464, "end": 1643045729132, "score": 89, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b1d520cb773fb9b165ec0d7e", "heatmap": "e-b1d520cb773fb9b165ec0d7e", "modelKey": "event", "timestamp": 1643045718981 }, { "id": "1a903b2bc17ae48388b000f4", "type": "motion", "start": 1643045838063, "end": 1643045865576, "score": 75, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1a903b2bc17ae48388b000f4", "heatmap": "e-1a903b2bc17ae48388b000f4", "modelKey": "event", "timestamp": 1643045849565 }, { "id": "ff1b0ff69def8985f105b298", "type": "motion", "start": 1643045845573, "end": 1643045867752, "score": 76, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ff1b0ff69def8985f105b298", "heatmap": "e-ff1b0ff69def8985f105b298", "modelKey": "event", "timestamp": 1643045856741 }, { "id": "70dbeb8d86a2b287e5aca4c4", "type": "motion", "start": 1643045846115, "end": 1643045867943, "score": 16, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-70dbeb8d86a2b287e5aca4c4", "heatmap": "e-70dbeb8d86a2b287e5aca4c4", "modelKey": "event", "timestamp": 1643045718981 }, { "id": "fd24864609166e8a2734925f", "type": "motion", "start": 1643046055795, "end": 1643046077489, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fd24864609166e8a2734925f", "heatmap": "e-fd24864609166e8a2734925f", "modelKey": "event", "timestamp": 1643046067462 }, { "id": "56c57cc137bd9d46b3c274b2", "type": "motion", "start": 1643046349411, "end": 1643046370912, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-56c57cc137bd9d46b3c274b2", "heatmap": "e-56c57cc137bd9d46b3c274b2", "modelKey": "event", "timestamp": 1643046067462 }, { "id": "ad94271589103861abae6483", "type": "motion", "start": 1643046430549, "end": 1643046452049, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ad94271589103861abae6483", "heatmap": "e-ad94271589103861abae6483", "modelKey": "event", "timestamp": 1643046441715 }, { "id": "1680f2f5037262fdfd3815e3", "type": "motion", "start": 1643046501009, "end": 1643046522180, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1680f2f5037262fdfd3815e3", "heatmap": "e-1680f2f5037262fdfd3815e3", "modelKey": "event", "timestamp": 1643046512176 }, { "id": "eb46c5bff33b0da40194fd6b", "type": "motion", "start": 1643046635460, "end": 1643046656626, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-eb46c5bff33b0da40194fd6b", "heatmap": "e-eb46c5bff33b0da40194fd6b", "modelKey": "event", "timestamp": 1643046646626 }, { "id": "26499ac13aef98f0bf013e2e", "type": "motion", "start": 1643047088734, "end": 1643047111397, "score": 71, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-26499ac13aef98f0bf013e2e", "heatmap": "e-26499ac13aef98f0bf013e2e", "modelKey": "event", "timestamp": 1643047101233 }, { "id": "fd7461809a38fe0748cd549d", "type": "motion", "start": 1643047125717, "end": 1643047156894, "score": 61, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fd7461809a38fe0748cd549d", "heatmap": "e-fd7461809a38fe0748cd549d", "modelKey": "event", "timestamp": 1643047144721 }, { "id": "86688e418f645ad384617d1a", "type": "motion", "start": 1643047141449, "end": 1643047164453, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-86688e418f645ad384617d1a", "heatmap": "e-86688e418f645ad384617d1a", "modelKey": "event", "timestamp": 1643045856741 }, { "id": "4f7ca6875a14891db8ebdb5a", "type": "motion", "start": 1643047273197, "end": 1643047294536, "score": 83, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4f7ca6875a14891db8ebdb5a", "heatmap": "e-4f7ca6875a14891db8ebdb5a", "modelKey": "event", "timestamp": 1643047284524 }, { "id": "4e367a5dce9c82bbddf7fd3b", "type": "motion", "start": 1643047365566, "end": 1643047387899, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4e367a5dce9c82bbddf7fd3b", "heatmap": "e-4e367a5dce9c82bbddf7fd3b", "modelKey": "event", "timestamp": 1643047376733 }, { "id": "28ebab635aaa5723851e349e", "type": "motion", "start": 1643047369153, "end": 1643047390819, "score": 9, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-28ebab635aaa5723851e349e", "heatmap": "e-28ebab635aaa5723851e349e", "modelKey": "event", "timestamp": 1643047284524 }, { "id": "5b2ff0ec2a6fc2d5914714cb", "type": "motion", "start": 1643047541993, "end": 1643047564164, "score": 80, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5b2ff0ec2a6fc2d5914714cb", "heatmap": "e-5b2ff0ec2a6fc2d5914714cb", "modelKey": "event", "timestamp": 1643047553160 }, { "id": "4cc2fb95cbab184b4b7341f0", "type": "motion", "start": 1643047575139, "end": 1643047596997, "score": 87, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4cc2fb95cbab184b4b7341f0", "heatmap": "e-4cc2fb95cbab184b4b7341f0", "modelKey": "event", "timestamp": 1643047586806 }, { "id": "bafb3f1ad9f1f9b85dbae28d", "type": "motion", "start": 1643047578422, "end": 1643047601112, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bafb3f1ad9f1f9b85dbae28d", "heatmap": "e-bafb3f1ad9f1f9b85dbae28d", "modelKey": "event", "timestamp": 1643047591090 }, { "id": "748202b40359a6b5613e9f10", "type": "motion", "start": 1643047581064, "end": 1643047602905, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-748202b40359a6b5613e9f10", "heatmap": "e-748202b40359a6b5613e9f10", "modelKey": "event", "timestamp": 1643047592231 }, { "id": "0b15336632a01d38ece6e246", "type": "motion", "start": 1643047940008, "end": 1643047961672, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0b15336632a01d38ece6e246", "heatmap": "e-0b15336632a01d38ece6e246", "modelKey": "event", "timestamp": 1643047951177 }, { "id": "cc4c6e783944ce40cce154f5", "type": "sensorMotion", "start": 1643047951314, "end": 1643047951314, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ezcftgx Dzget" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cc4c6e783944ce40cce154f5", "heatmap": "e-cc4c6e783944ce40cce154f5", "modelKey": "event", "timestamp": null }, { "id": "8ffcd0fcbd895b9d45220f9e", "type": "motion", "start": 1643048001106, "end": 1643048023456, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8ffcd0fcbd895b9d45220f9e", "heatmap": "e-8ffcd0fcbd895b9d45220f9e", "modelKey": "event", "timestamp": 1643048012273 }, { "id": "a8f0121953212fa938c05fa9", "type": "motion", "start": 1643048003973, "end": 1643048025305, "score": 10, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a8f0121953212fa938c05fa9", "heatmap": "e-a8f0121953212fa938c05fa9", "modelKey": "event", "timestamp": 1643047586806 }, { "id": "026b578aca8fab9e10dac514", "type": "sensorMotion", "start": 1643048095261, "end": 1643048095261, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Kbf Gdap" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-026b578aca8fab9e10dac514", "heatmap": "e-026b578aca8fab9e10dac514", "modelKey": "event", "timestamp": null }, { "id": "ffdc941680621e430f0b4423", "type": "sensorMotion", "start": 1643048098866, "end": 1643048098866, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cxssct Qdrknq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ffdc941680621e430f0b4423", "heatmap": "e-ffdc941680621e430f0b4423", "modelKey": "event", "timestamp": null }, { "id": "10c8c022f4106338b0d9b139", "type": "motion", "start": 1643048288593, "end": 1643048309753, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-10c8c022f4106338b0d9b139", "heatmap": "e-10c8c022f4106338b0d9b139", "modelKey": "event", "timestamp": 1643047586806 }, { "id": "4f80c1bd8605f4a6a744da8e", "type": "motion", "start": 1643048291550, "end": 1643048313383, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4f80c1bd8605f4a6a744da8e", "heatmap": "e-4f80c1bd8605f4a6a744da8e", "modelKey": "event", "timestamp": 1643048303217 }, { "id": "41ca76cef16a79c411f9956b", "type": "motion", "start": 1643048293104, "end": 1643048314771, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-41ca76cef16a79c411f9956b", "heatmap": "e-41ca76cef16a79c411f9956b", "modelKey": "event", "timestamp": 1643048304438 }, { "id": "eda6d68885051a32bb3bdfb5", "type": "motion", "start": 1643048325406, "end": 1643048347071, "score": 11, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-eda6d68885051a32bb3bdfb5", "heatmap": "e-eda6d68885051a32bb3bdfb5", "modelKey": "event", "timestamp": 1643047586806 }, { "id": "743b98708c0bda74c3e6e745", "type": "motion", "start": 1643048338569, "end": 1643048363237, "score": 67, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-743b98708c0bda74c3e6e745", "heatmap": "e-743b98708c0bda74c3e6e745", "modelKey": "event", "timestamp": 1643048349735 }, { "id": "0076fbd58b598c65e588736d", "type": "motion", "start": 1643048339643, "end": 1643048384185, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "cfa08131ebe90be900a4e15a" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0076fbd58b598c65e588736d", "heatmap": "e-0076fbd58b598c65e588736d", "modelKey": "event", "timestamp": 1643048362986 }, { "id": "cfa08131ebe90be900a4e15a", "type": "smartDetectZone", "start": 1643048339913, "end": 1643048377745, "score": 82, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cfa08131ebe90be900a4e15a", "heatmap": "e-cfa08131ebe90be900a4e15a", "modelKey": "event", "timestamp": 1643048357566 }, { "id": "428a4ca8bc4a87b23572aed8", "type": "smartDetectZone", "start": 1643048341509, "end": 1643048380132, "score": 86, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-428a4ca8bc4a87b23572aed8", "heatmap": "e-428a4ca8bc4a87b23572aed8", "modelKey": "event", "timestamp": 1643048359360 }, { "id": "014e9beebbaa097448839968", "type": "motion", "start": 1643048344186, "end": 1643048382316, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "428a4ca8bc4a87b23572aed8" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-014e9beebbaa097448839968", "heatmap": "e-014e9beebbaa097448839968", "modelKey": "event", "timestamp": 1643048359360 }, { "id": "62858f0ec4bef764e921bbd7", "type": "motion", "start": 1643048360234, "end": 1643048383397, "score": 79, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-62858f0ec4bef764e921bbd7", "heatmap": "e-62858f0ec4bef764e921bbd7", "modelKey": "event", "timestamp": 1643048373233 }, { "id": "4fe527c017a047b196ac231a", "type": "motion", "start": 1643048370842, "end": 1643048433554, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4fe527c017a047b196ac231a", "heatmap": "e-4fe527c017a047b196ac231a", "modelKey": "event", "timestamp": 1643048384697 }, { "id": "520627843e21cc6f837ea43e", "type": "sensorMotion", "start": 1643048380968, "end": 1643048380968, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Mus Vpv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-520627843e21cc6f837ea43e", "heatmap": "e-520627843e21cc6f837ea43e", "modelKey": "event", "timestamp": null }, { "id": "0a0635fa2ecfe75b318fcb1c", "type": "motion", "start": 1643048384068, "end": 1643048406741, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0a0635fa2ecfe75b318fcb1c", "heatmap": "e-0a0635fa2ecfe75b318fcb1c", "modelKey": "event", "timestamp": 1643048396736 }, { "id": "76c80e7528199b092cccff18", "type": "sensorMotion", "start": 1643048385989, "end": 1643048385989, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cppvuqo Jqjqwxp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-76c80e7528199b092cccff18", "heatmap": "e-76c80e7528199b092cccff18", "modelKey": "event", "timestamp": null }, { "id": "f1d28a3cd982b0417437699a", "type": "motion", "start": 1643048386031, "end": 1643048407367, "score": 66, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f1d28a3cd982b0417437699a", "heatmap": "e-f1d28a3cd982b0417437699a", "modelKey": "event", "timestamp": 1643048397364 }, { "id": "8a0d7e827f9352464ee0b941", "type": "sensorMotion", "start": 1643048392312, "end": 1643048392312, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hhuigey Obcjmn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8a0d7e827f9352464ee0b941", "heatmap": "e-8a0d7e827f9352464ee0b941", "modelKey": "event", "timestamp": null }, { "id": "1be0fd2931567d970bcefdc5", "type": "motion", "start": 1643048392751, "end": 1643048415594, "score": 80, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1be0fd2931567d970bcefdc5", "heatmap": "e-1be0fd2931567d970bcefdc5", "modelKey": "event", "timestamp": 1643048404418 }, { "id": "43aa2d1dc6ff81f1834bc73d", "type": "sensorOpened", "start": 1643048397319, "end": 1643048397319, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ifibm Lkidw" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-43aa2d1dc6ff81f1834bc73d", "heatmap": "e-43aa2d1dc6ff81f1834bc73d", "modelKey": "event", "timestamp": null }, { "id": "73e05af6bbd3de919dd625d6", "type": "sensorMotion", "start": 1643048398349, "end": 1643048398349, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Uvenapn Fawa" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-73e05af6bbd3de919dd625d6", "heatmap": "e-73e05af6bbd3de919dd625d6", "modelKey": "event", "timestamp": null }, { "id": "092af620938fd03b6ba747ed", "type": "sensorMotion", "start": 1643048408393, "end": 1643048408393, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hscfefl Wkfqpj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-092af620938fd03b6ba747ed", "heatmap": "e-092af620938fd03b6ba747ed", "modelKey": "event", "timestamp": null }, { "id": "0bb4ecc16bbd72a9385182a1", "type": "sensorClosed", "start": 1643048411096, "end": 1643048411096, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zhboojs Azo" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-0bb4ecc16bbd72a9385182a1", "heatmap": "e-0bb4ecc16bbd72a9385182a1", "modelKey": "event", "timestamp": null }, { "id": "7ac514da03f1939ede1b4fc2", "type": "sensorMotion", "start": 1643048412383, "end": 1643048412383, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Zppjnix Oszo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7ac514da03f1939ede1b4fc2", "heatmap": "e-7ac514da03f1939ede1b4fc2", "modelKey": "event", "timestamp": null }, { "id": "8355c6972c4ac1cc4d0f80bf", "type": "sensorMotion", "start": 1643048414959, "end": 1643048414959, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qgs Ulh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8355c6972c4ac1cc4d0f80bf", "heatmap": "e-8355c6972c4ac1cc4d0f80bf", "modelKey": "event", "timestamp": null }, { "id": "87c3164ac978e2f0d848ac4c", "type": "sensorMotion", "start": 1643048418950, "end": 1643048418950, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hbto Xphpn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-87c3164ac978e2f0d848ac4c", "heatmap": "e-87c3164ac978e2f0d848ac4c", "modelKey": "event", "timestamp": null }, { "id": "c0eddebfffa8dae821ef7656", "type": "sensorMotion", "start": 1643048423843, "end": 1643048423843, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Srzc Bdp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c0eddebfffa8dae821ef7656", "heatmap": "e-c0eddebfffa8dae821ef7656", "modelKey": "event", "timestamp": null }, { "id": "aababda1b1001fcb24123937", "type": "motion", "start": 1643048424644, "end": 1643048446315, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-aababda1b1001fcb24123937", "heatmap": "e-aababda1b1001fcb24123937", "modelKey": "event", "timestamp": 1643048435818 }, { "id": "0b3751504a2d25a0c0c969af", "type": "sensorMotion", "start": 1643048435450, "end": 1643048435450, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Gvugwrq Wikbp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0b3751504a2d25a0c0c969af", "heatmap": "e-0b3751504a2d25a0c0c969af", "modelKey": "event", "timestamp": null }, { "id": "647dca54f3be0efdc57c20b0", "type": "access", "start": 1643048554291, "end": 1643048808612, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "bc3dd633553907952a6fe20d", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-647dca54f3be0efdc57c20b0", "heatmap": "e-647dca54f3be0efdc57c20b0", "modelKey": "event", "timestamp": null }, { "id": "6a01a2513b9941149fc476ab", "type": "motion", "start": 1643048577222, "end": 1643048601721, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6a01a2513b9941149fc476ab", "heatmap": "e-6a01a2513b9941149fc476ab", "modelKey": "event", "timestamp": 1643048591556 }, { "id": "f534ebbe5c23743b2b078f2f", "type": "motion", "start": 1643048741866, "end": 1643048763705, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f534ebbe5c23743b2b078f2f", "heatmap": "e-f534ebbe5c23743b2b078f2f", "modelKey": "event", "timestamp": 1643048753534 }, { "id": "a2da1425168ce09b451bd2ac", "type": "sensorMotion", "start": 1643048754485, "end": 1643048754485, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ssaems Idtoq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a2da1425168ce09b451bd2ac", "heatmap": "e-a2da1425168ce09b451bd2ac", "modelKey": "event", "timestamp": null }, { "id": "2ed6a93b60d62805fab92fd9", "type": "sensorMotion", "start": 1643048758352, "end": 1643048758352, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Piy Bfpx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2ed6a93b60d62805fab92fd9", "heatmap": "e-2ed6a93b60d62805fab92fd9", "modelKey": "event", "timestamp": null }, { "id": "c17351e903f4d2801b68950d", "type": "motion", "start": 1643048837006, "end": 1643048916525, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c17351e903f4d2801b68950d", "heatmap": "e-c17351e903f4d2801b68950d", "modelKey": "event", "timestamp": 1643048854175 }, { "id": "f2e0bc91908514354d4c25cb", "type": "access", "start": 1643048849515, "end": 1643049055412, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "android" }, "thumbnail": "e-f2e0bc91908514354d4c25cb", "heatmap": "e-f2e0bc91908514354d4c25cb", "modelKey": "event", "timestamp": null }, { "id": "e5ae42d7225f658667eba4dc", "type": "sensorMotion", "start": 1643048851051, "end": 1643048851051, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Sladu Ouvtdl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e5ae42d7225f658667eba4dc", "heatmap": "e-e5ae42d7225f658667eba4dc", "modelKey": "event", "timestamp": null }, { "id": "10e41d54819f61be82fe67e1", "type": "sensorMotion", "start": 1643048855560, "end": 1643048855560, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ujdnjwk Jwjopbu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-10e41d54819f61be82fe67e1", "heatmap": "e-10e41d54819f61be82fe67e1", "modelKey": "event", "timestamp": null }, { "id": "eecf0e2dc2f5a6024ca4f11c", "type": "sensorMotion", "start": 1643048860579, "end": 1643048860579, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Szcq Paohybo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-eecf0e2dc2f5a6024ca4f11c", "heatmap": "e-eecf0e2dc2f5a6024ca4f11c", "modelKey": "event", "timestamp": null }, { "id": "1eafa4f0edf1daff35cae332", "type": "deviceAdopted", "start": 1643048867031, "end": 1643048867031, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "deviceId": { "text": "61eeeeef03de9d03e700c532" }, "mac": { "text": "74ACB94616F5" }, "host": { "text": null }, "type": { "text": "UFP-LOCK-R" }, "name": { "text": "UP DoorLock" }, "firmwareVersion": { "text": "unknown" }, "anonymousDeviceId": { "text": "unknown" }, "nvrId": { "text": "573efde7-efb9-41c2-b715-bd8f53892cbe" }, "nvrName": { "text": "Mortis NVR" }, "adoptedBy": { "text": "Christopher Bailey" }, "clickUrl": { "url": "protect/devices" } }, "thumbnail": "e-1eafa4f0edf1daff35cae332", "heatmap": "e-1eafa4f0edf1daff35cae332", "modelKey": "event", "timestamp": null }, { "id": "625003efe5a7569715643941", "type": "access", "start": 1643048886218, "end": 1643048981573, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-625003efe5a7569715643941", "heatmap": "e-625003efe5a7569715643941", "modelKey": "event", "timestamp": null }, { "id": "25d7a06faa04feaf55e1a2e6", "type": "sensorMotion", "start": 1643048897451, "end": 1643048897451, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Jzkyxis Wzegsq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-25d7a06faa04feaf55e1a2e6", "heatmap": "e-25d7a06faa04feaf55e1a2e6", "modelKey": "event", "timestamp": null }, { "id": "b30fc9147a900415a381c360", "type": "motion", "start": 1643048916309, "end": 1643048943908, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b30fc9147a900415a381c360", "heatmap": "e-b30fc9147a900415a381c360", "modelKey": "event", "timestamp": 1643048928070 }, { "id": "0bdfba4fbe102173d8c5f9b7", "type": "sensorMotion", "start": 1643048917232, "end": 1643048917232, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Icnbohg Chuhx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0bdfba4fbe102173d8c5f9b7", "heatmap": "e-0bdfba4fbe102173d8c5f9b7", "modelKey": "event", "timestamp": null }, { "id": "f22258ef072ca8fa8f79cc11", "type": "motion", "start": 1643048919750, "end": 1643048957388, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "2026ced9ce8af63e34d927cf" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f22258ef072ca8fa8f79cc11", "heatmap": "e-f22258ef072ca8fa8f79cc11", "modelKey": "event", "timestamp": 1643048935358 }, { "id": "1df205b3715ca42479776a7a", "type": "smartDetectZone", "start": 1643048921740, "end": 1643048954339, "score": 82, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1df205b3715ca42479776a7a", "heatmap": "e-1df205b3715ca42479776a7a", "modelKey": "event", "timestamp": 1643048934978 }, { "id": "2026ced9ce8af63e34d927cf", "type": "smartDetectZone", "start": 1643048921932, "end": 1643048956871, "score": 82, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2026ced9ce8af63e34d927cf", "heatmap": "e-2026ced9ce8af63e34d927cf", "modelKey": "event", "timestamp": 1643048935358 }, { "id": "c99f938f1d3a5cfb8aefc047", "type": "motion", "start": 1643048922255, "end": 1643048957128, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "1df205b3715ca42479776a7a" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c99f938f1d3a5cfb8aefc047", "heatmap": "e-c99f938f1d3a5cfb8aefc047", "modelKey": "event", "timestamp": 1643048933921 }, { "id": "19b46ab54fff82dfd60038e4", "type": "sensorMotion", "start": 1643048925345, "end": 1643048925345, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Gexvd Kppcj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-19b46ab54fff82dfd60038e4", "heatmap": "e-19b46ab54fff82dfd60038e4", "modelKey": "event", "timestamp": null }, { "id": "2714130bddc1b99dab98f965", "type": "sensorOpened", "start": 1643048928433, "end": 1643048928433, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Omnod Obn" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-2714130bddc1b99dab98f965", "heatmap": "e-2714130bddc1b99dab98f965", "modelKey": "event", "timestamp": null }, { "id": "821b017bda764d645891954c", "type": "sensorMotion", "start": 1643048929592, "end": 1643048929592, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Iueux Ohz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-821b017bda764d645891954c", "heatmap": "e-821b017bda764d645891954c", "modelKey": "event", "timestamp": null }, { "id": "c10ee5cb00906814a13bd4dc", "type": "sensorClosed", "start": 1643048931689, "end": 1643048931689, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Pew Sxq" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-c10ee5cb00906814a13bd4dc", "heatmap": "e-c10ee5cb00906814a13bd4dc", "modelKey": "event", "timestamp": null }, { "id": "9e8eaae86ecd3ff79499ee07", "type": "motion", "start": 1643048933304, "end": 1643048961148, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9e8eaae86ecd3ff79499ee07", "heatmap": "e-9e8eaae86ecd3ff79499ee07", "modelKey": "event", "timestamp": 1643048946970 }, { "id": "1565855930169cc19511ebe8", "type": "motion", "start": 1643048950629, "end": 1643048974962, "score": 69, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1565855930169cc19511ebe8", "heatmap": "e-1565855930169cc19511ebe8", "modelKey": "event", "timestamp": 1643048961796 }, { "id": "cb63480f78dd68856b914550", "type": "access", "start": 1643048984443, "end": 1643055110923, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-cb63480f78dd68856b914550", "heatmap": "e-cb63480f78dd68856b914550", "modelKey": "event", "timestamp": null }, { "id": "7be00752498a8bee26b5e21f", "type": "motion", "start": 1643049033627, "end": 1643049054771, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7be00752498a8bee26b5e21f", "heatmap": "e-7be00752498a8bee26b5e21f", "modelKey": "event", "timestamp": 1643049044771 }, { "id": "9e1cf7a2dc52be25363f9076", "type": "motion", "start": 1643049074998, "end": 1643049095999, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9e1cf7a2dc52be25363f9076", "heatmap": "e-9e1cf7a2dc52be25363f9076", "modelKey": "event", "timestamp": 1643048933921 }, { "id": "7eb0ba38d860a37b874fb3d6", "type": "access", "start": 1643049228238, "end": 1643049464896, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "bc3dd633553907952a6fe20d", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-7eb0ba38d860a37b874fb3d6", "heatmap": "e-7eb0ba38d860a37b874fb3d6", "modelKey": "event", "timestamp": null }, { "id": "1b25c133a67eb8a5d6c0c7d2", "type": "motion", "start": 1643049261918, "end": 1643049284086, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1b25c133a67eb8a5d6c0c7d2", "heatmap": "e-1b25c133a67eb8a5d6c0c7d2", "modelKey": "event", "timestamp": 1643048933921 }, { "id": "d860737f7241e63b34dfe1e4", "type": "motion", "start": 1643049265736, "end": 1643049287565, "score": 7, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d860737f7241e63b34dfe1e4", "heatmap": "e-d860737f7241e63b34dfe1e4", "modelKey": "event", "timestamp": 1643049044771 }, { "id": "78ea48f6a4aacc6c9d1fd162", "type": "smartDetectZone", "start": 1643049359846, "end": 1643049560224, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-78ea48f6a4aacc6c9d1fd162", "heatmap": "e-78ea48f6a4aacc6c9d1fd162", "modelKey": "event", "timestamp": 1643049371146 }, { "id": "55fb6d97956a04e4061c1905", "type": "motion", "start": 1643049364621, "end": 1643049415174, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "78ea48f6a4aacc6c9d1fd162" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-55fb6d97956a04e4061c1905", "heatmap": "e-55fb6d97956a04e4061c1905", "modelKey": "event", "timestamp": 1643049380002 }, { "id": "9f59279c69dd9588476ba07d", "type": "sensorMotion", "start": 1643049371803, "end": 1643049371803, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fboxzup Tfbn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9f59279c69dd9588476ba07d", "heatmap": "e-9f59279c69dd9588476ba07d", "modelKey": "event", "timestamp": null }, { "id": "4249db1ab2ddd683a0fcae43", "type": "sensorMotion", "start": 1643049374749, "end": 1643049374749, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mlzftun Kzrr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4249db1ab2ddd683a0fcae43", "heatmap": "e-4249db1ab2ddd683a0fcae43", "modelKey": "event", "timestamp": null }, { "id": "95f386de7c18dfccb34a6f4c", "type": "sensorMotion", "start": 1643049378869, "end": 1643049378869, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Inouzly Visryg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-95f386de7c18dfccb34a6f4c", "heatmap": "e-95f386de7c18dfccb34a6f4c", "modelKey": "event", "timestamp": null }, { "id": "1c114f40179581ddc533a055", "type": "sensorMotion", "start": 1643049384024, "end": 1643049384024, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Uut Icosq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1c114f40179581ddc533a055", "heatmap": "e-1c114f40179581ddc533a055", "modelKey": "event", "timestamp": null }, { "id": "6835c80114734fb24fe91e89", "type": "sensorMotion", "start": 1643049388011, "end": 1643049388011, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nysxro Ghsgcb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6835c80114734fb24fe91e89", "heatmap": "e-6835c80114734fb24fe91e89", "modelKey": "event", "timestamp": null }, { "id": "8c479993adb022079f02e93b", "type": "sensorMotion", "start": 1643049392146, "end": 1643049392146, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zhbecty Wrcv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8c479993adb022079f02e93b", "heatmap": "e-8c479993adb022079f02e93b", "modelKey": "event", "timestamp": null }, { "id": "1b9d6dc68606edf48eae675d", "type": "sensorMotion", "start": 1643049396123, "end": 1643049396123, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Joqpzo Uvki" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1b9d6dc68606edf48eae675d", "heatmap": "e-1b9d6dc68606edf48eae675d", "modelKey": "event", "timestamp": null }, { "id": "f3a9c47131f8872db0b8e9d4", "type": "sensorMotion", "start": 1643049401674, "end": 1643049401674, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yzeonk Mujyz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f3a9c47131f8872db0b8e9d4", "heatmap": "e-f3a9c47131f8872db0b8e9d4", "modelKey": "event", "timestamp": null }, { "id": "f54315a164f806e5e26e541f", "type": "sensorMotion", "start": 1643049405794, "end": 1643049405794, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xnsrx Gnp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f54315a164f806e5e26e541f", "heatmap": "e-f54315a164f806e5e26e541f", "modelKey": "event", "timestamp": null }, { "id": "1ff8b8277812ed0576d4b9b3", "type": "sensorMotion", "start": 1643049409899, "end": 1643049409899, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Avlypbh Nqsfbx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1ff8b8277812ed0576d4b9b3", "heatmap": "e-1ff8b8277812ed0576d4b9b3", "modelKey": "event", "timestamp": null }, { "id": "c1ab25732e98e110b9a91e6c", "type": "motion", "start": 1643049413997, "end": 1643049439997, "score": 48, "smartDetectTypes": [], "smartDetectEvents": [ "78ea48f6a4aacc6c9d1fd162" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c1ab25732e98e110b9a91e6c", "heatmap": "e-c1ab25732e98e110b9a91e6c", "modelKey": "event", "timestamp": 1643049425830 }, { "id": "7e4131d660d70acfe66ef623", "type": "sensorMotion", "start": 1643049416347, "end": 1643049416347, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hppe Shjdi" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7e4131d660d70acfe66ef623", "heatmap": "e-7e4131d660d70acfe66ef623", "modelKey": "event", "timestamp": null }, { "id": "3186573fd28371837c56abf7", "type": "sensorMotion", "start": 1643049420199, "end": 1643049420199, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vvh Zbyhuc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3186573fd28371837c56abf7", "heatmap": "e-3186573fd28371837c56abf7", "modelKey": "event", "timestamp": null }, { "id": "7bca591460599f2b03c9ee21", "type": "sensorMotion", "start": 1643049424206, "end": 1643049424206, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xjp Lahc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7bca591460599f2b03c9ee21", "heatmap": "e-7bca591460599f2b03c9ee21", "modelKey": "event", "timestamp": null }, { "id": "aab18472a217ebae7233fb69", "type": "sensorMotion", "start": 1643049428197, "end": 1643049428197, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dlwmovy Efpd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-aab18472a217ebae7233fb69", "heatmap": "e-aab18472a217ebae7233fb69", "modelKey": "event", "timestamp": null }, { "id": "7c7eee71c99dddf04c133aa8", "type": "sensorMotion", "start": 1643049431288, "end": 1643049431288, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nvp Pxcvdv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7c7eee71c99dddf04c133aa8", "heatmap": "e-7c7eee71c99dddf04c133aa8", "modelKey": "event", "timestamp": null }, { "id": "f7548d8fd5c0bddb569f46a4", "type": "sensorMotion", "start": 1643049435311, "end": 1643049435311, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pxrut Hej" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f7548d8fd5c0bddb569f46a4", "heatmap": "e-f7548d8fd5c0bddb569f46a4", "modelKey": "event", "timestamp": null }, { "id": "e4d68c46d88073437abb8330", "type": "sensorMotion", "start": 1643049439400, "end": 1643049439400, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Olmnl Rzmzhcg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e4d68c46d88073437abb8330", "heatmap": "e-e4d68c46d88073437abb8330", "modelKey": "event", "timestamp": null }, { "id": "cfe45b986b91f1fd70f78207", "type": "motion", "start": 1643049443008, "end": 1643049464672, "score": 12, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cfe45b986b91f1fd70f78207", "heatmap": "e-cfe45b986b91f1fd70f78207", "modelKey": "event", "timestamp": 1643049044771 }, { "id": "c235ab4811d4d9214a97697d", "type": "sensorMotion", "start": 1643049444092, "end": 1643049444092, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ndlhy Qcsfkf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c235ab4811d4d9214a97697d", "heatmap": "e-c235ab4811d4d9214a97697d", "modelKey": "event", "timestamp": null }, { "id": "c2908f3f1463a4373c2b9e96", "type": "sensorMotion", "start": 1643049448169, "end": 1643049448169, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hzm Aqt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c2908f3f1463a4373c2b9e96", "heatmap": "e-c2908f3f1463a4373c2b9e96", "modelKey": "event", "timestamp": null }, { "id": "c6edf33f59b1d011c03c0799", "type": "sensorMotion", "start": 1643049452002, "end": 1643049452002, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kijyqi Pcmxqd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c6edf33f59b1d011c03c0799", "heatmap": "e-c6edf33f59b1d011c03c0799", "modelKey": "event", "timestamp": null }, { "id": "2cc623c94f7ed1e414c1a4ce", "type": "motion", "start": 1643049455556, "end": 1643049477066, "score": 91, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2cc623c94f7ed1e414c1a4ce", "heatmap": "e-2cc623c94f7ed1e414c1a4ce", "modelKey": "event", "timestamp": 1643049467065 }, { "id": "26fab3d7bd5577e69564b878", "type": "sensorMotion", "start": 1643049464764, "end": 1643049464764, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pcjqwv Mgv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-26fab3d7bd5577e69564b878", "heatmap": "e-26fab3d7bd5577e69564b878", "modelKey": "event", "timestamp": null }, { "id": "22dc1d06c1fb9cabacf44e02", "type": "sensorMotion", "start": 1643049467711, "end": 1643049467711, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zpv Qud" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-22dc1d06c1fb9cabacf44e02", "heatmap": "e-22dc1d06c1fb9cabacf44e02", "modelKey": "event", "timestamp": null }, { "id": "9f50b74c7d99f71053cb4b99", "type": "sensorMotion", "start": 1643049472216, "end": 1643049472216, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qrjuo Pluo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9f50b74c7d99f71053cb4b99", "heatmap": "e-9f50b74c7d99f71053cb4b99", "modelKey": "event", "timestamp": null }, { "id": "cc0f7dc383f7eb70d2df70bc", "type": "sensorMotion", "start": 1643049479555, "end": 1643049479555, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qysajb Cvqntqa" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cc0f7dc383f7eb70d2df70bc", "heatmap": "e-cc0f7dc383f7eb70d2df70bc", "modelKey": "event", "timestamp": null }, { "id": "ffe3b8a3f89c41225bbed6e7", "type": "sensorMotion", "start": 1643049485366, "end": 1643049485366, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dax Gnwxjcx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ffe3b8a3f89c41225bbed6e7", "heatmap": "e-ffe3b8a3f89c41225bbed6e7", "modelKey": "event", "timestamp": null }, { "id": "92375fd4935d4edecbde05f8", "type": "sensorMotion", "start": 1643049495907, "end": 1643049495907, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kwb Cknuh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-92375fd4935d4edecbde05f8", "heatmap": "e-92375fd4935d4edecbde05f8", "modelKey": "event", "timestamp": null }, { "id": "95124b836dcea004f3e91ee0", "type": "sensorMotion", "start": 1643049504037, "end": 1643049504037, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hpioq Mvqwh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-95124b836dcea004f3e91ee0", "heatmap": "e-95124b836dcea004f3e91ee0", "modelKey": "event", "timestamp": null }, { "id": "a05fca21f0c03ea381eb78ba", "type": "sensorMotion", "start": 1643049517409, "end": 1643049517409, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sle Ilpvd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a05fca21f0c03ea381eb78ba", "heatmap": "e-a05fca21f0c03ea381eb78ba", "modelKey": "event", "timestamp": null }, { "id": "e9568db2904b45986a214c6e", "type": "sensorMotion", "start": 1643049522302, "end": 1643049522302, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Lqn Ajhotd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e9568db2904b45986a214c6e", "heatmap": "e-e9568db2904b45986a214c6e", "modelKey": "event", "timestamp": null }, { "id": "6c6436b885db6eaf627222ee", "type": "sensorMotion", "start": 1643049531958, "end": 1643049531958, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Aktfamn Okvs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6c6436b885db6eaf627222ee", "heatmap": "e-6c6436b885db6eaf627222ee", "modelKey": "event", "timestamp": null }, { "id": "7e9256d4dcb7ee73196bc01e", "type": "sensorMotion", "start": 1643049534935, "end": 1643049534935, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ozax Bbr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7e9256d4dcb7ee73196bc01e", "heatmap": "e-7e9256d4dcb7ee73196bc01e", "modelKey": "event", "timestamp": null }, { "id": "a2c6ca86f180898dbfd081b2", "type": "sensorMotion", "start": 1643049540070, "end": 1643049540070, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Bvofskh Ymic" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a2c6ca86f180898dbfd081b2", "heatmap": "e-a2c6ca86f180898dbfd081b2", "modelKey": "event", "timestamp": null }, { "id": "69c897fd9662a786f08b54ad", "type": "sensorMotion", "start": 1643049557881, "end": 1643049557881, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ajs Lhfn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-69c897fd9662a786f08b54ad", "heatmap": "e-69c897fd9662a786f08b54ad", "modelKey": "event", "timestamp": null }, { "id": "50f78c58330963829f50f961", "type": "smartDetectZone", "start": 1643049557936, "end": 1643049615082, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-50f78c58330963829f50f961", "heatmap": "e-50f78c58330963829f50f961", "modelKey": "event", "timestamp": 1643049594374 }, { "id": "ddb6a2982b7f988ab24ce449", "type": "motion", "start": 1643049560474, "end": 1643049589307, "score": 40, "smartDetectTypes": [], "smartDetectEvents": [ "50f78c58330963829f50f961" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ddb6a2982b7f988ab24ce449", "heatmap": "e-ddb6a2982b7f988ab24ce449", "modelKey": "event", "timestamp": 1643049425830 }, { "id": "e5e84ff450d183479d70a81a", "type": "sensorMotion", "start": 1643049560815, "end": 1643049560815, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gyxopx Ibzapc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e5e84ff450d183479d70a81a", "heatmap": "e-e5e84ff450d183479d70a81a", "modelKey": "event", "timestamp": null }, { "id": "20cba43b4162a01fbcea8dbe", "type": "sensorMotion", "start": 1643049564809, "end": 1643049564809, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zhy Bzlgyqf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-20cba43b4162a01fbcea8dbe", "heatmap": "e-20cba43b4162a01fbcea8dbe", "modelKey": "event", "timestamp": null }, { "id": "fcd38f91506364c60c7a912e", "type": "sensorMotion", "start": 1643049569941, "end": 1643049569941, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sezeupw Tagmnh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-fcd38f91506364c60c7a912e", "heatmap": "e-fcd38f91506364c60c7a912e", "modelKey": "event", "timestamp": null }, { "id": "360121dc28a21f30b8ed6aa8", "type": "sensorMotion", "start": 1643049572903, "end": 1643049572903, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mxbysf Wyqaa" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-360121dc28a21f30b8ed6aa8", "heatmap": "e-360121dc28a21f30b8ed6aa8", "modelKey": "event", "timestamp": null }, { "id": "512f567e181470443f7f7c2b", "type": "motion", "start": 1643049581135, "end": 1643049614967, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "50f78c58330963829f50f961" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-512f567e181470443f7f7c2b", "heatmap": "e-512f567e181470443f7f7c2b", "modelKey": "event", "timestamp": 1643049597303 }, { "id": "f41e9a3d0c7e96f854e63fa3", "type": "sensorMotion", "start": 1643049586808, "end": 1643049586808, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Bexvnzm Iwozf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f41e9a3d0c7e96f854e63fa3", "heatmap": "e-f41e9a3d0c7e96f854e63fa3", "modelKey": "event", "timestamp": null }, { "id": "962c67fc84d31396195d8e9c", "type": "sensorMotion", "start": 1643049590928, "end": 1643049590928, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ncswptc Bce" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-962c67fc84d31396195d8e9c", "heatmap": "e-962c67fc84d31396195d8e9c", "modelKey": "event", "timestamp": null }, { "id": "63a89b4ac70152afd3867ec3", "type": "sensorMotion", "start": 1643049594934, "end": 1643049594934, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iprpcz Tvnx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-63a89b4ac70152afd3867ec3", "heatmap": "e-63a89b4ac70152afd3867ec3", "modelKey": "event", "timestamp": null }, { "id": "eb816b811366b46d877a4429", "type": "sensorMotion", "start": 1643049595828, "end": 1643049595828, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Srxpu Zefd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-eb816b811366b46d877a4429", "heatmap": "e-eb816b811366b46d877a4429", "modelKey": "event", "timestamp": null }, { "id": "4d6cfccdc5fd14891ac528da", "type": "sensorMotion", "start": 1643049598404, "end": 1643049598404, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xamd Ijalr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4d6cfccdc5fd14891ac528da", "heatmap": "e-4d6cfccdc5fd14891ac528da", "modelKey": "event", "timestamp": null }, { "id": "b0d957e984dee31b170b0eba", "type": "sensorMotion", "start": 1643049598926, "end": 1643049598926, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Poxfvgm Gavqcj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b0d957e984dee31b170b0eba", "heatmap": "e-b0d957e984dee31b170b0eba", "modelKey": "event", "timestamp": null }, { "id": "ddc22e01a46d71cec64d5411", "type": "sensorMotion", "start": 1643049604447, "end": 1643049604447, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dgyoyt Sat" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ddc22e01a46d71cec64d5411", "heatmap": "e-ddc22e01a46d71cec64d5411", "modelKey": "event", "timestamp": null }, { "id": "483d47f8812cc5ffb92c44ac", "type": "motion", "start": 1643049608427, "end": 1643049637626, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-483d47f8812cc5ffb92c44ac", "heatmap": "e-483d47f8812cc5ffb92c44ac", "modelKey": "event", "timestamp": 1643049621125 }, { "id": "a5a065748670b92e8562cf5c", "type": "sensorMotion", "start": 1643049616429, "end": 1643049616429, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Lhd Ycmwd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a5a065748670b92e8562cf5c", "heatmap": "e-a5a065748670b92e8562cf5c", "modelKey": "event", "timestamp": null }, { "id": "10942d825c9a7bf61424366e", "type": "sensorMotion", "start": 1643049620306, "end": 1643049620306, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qka Sfalsd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-10942d825c9a7bf61424366e", "heatmap": "e-10942d825c9a7bf61424366e", "modelKey": "event", "timestamp": null }, { "id": "e2ac44be35667c163e9227a3", "type": "access", "start": 1643049916630, "end": 1643050366598, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "android" }, "thumbnail": "e-e2ac44be35667c163e9227a3", "heatmap": "e-e2ac44be35667c163e9227a3", "modelKey": "event", "timestamp": null }, { "id": "73ae4f31d7df35867e73903b", "type": "smartDetectZone", "start": 1643049935729, "end": 1643050052325, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-73ae4f31d7df35867e73903b", "heatmap": "e-73ae4f31d7df35867e73903b", "modelKey": "event", "timestamp": 1643050032301 }, { "id": "687a7bf9a4f3539a202c7a7d", "type": "motion", "start": 1643049937902, "end": 1643049982911, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "73ae4f31d7df35867e73903b" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-687a7bf9a4f3539a202c7a7d", "heatmap": "e-687a7bf9a4f3539a202c7a7d", "modelKey": "event", "timestamp": 1643049953903 }, { "id": "f9558325b960881a8439048d", "type": "sensorMotion", "start": 1643049948867, "end": 1643049948867, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xrcooyt Tfgegbh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f9558325b960881a8439048d", "heatmap": "e-f9558325b960881a8439048d", "modelKey": "event", "timestamp": null }, { "id": "105e5188929e1eb4e41a6645", "type": "sensorMotion", "start": 1643049952858, "end": 1643049952858, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Crb Ogwym" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-105e5188929e1eb4e41a6645", "heatmap": "e-105e5188929e1eb4e41a6645", "modelKey": "event", "timestamp": null }, { "id": "eec4ccd210a96c4d50add14f", "type": "sensorMotion", "start": 1643049956850, "end": 1643049956850, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Oye Kxna" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-eec4ccd210a96c4d50add14f", "heatmap": "e-eec4ccd210a96c4d50add14f", "modelKey": "event", "timestamp": null }, { "id": "98a32c95ada534dc1955243f", "type": "motion", "start": 1643049959181, "end": 1643049985190, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-98a32c95ada534dc1955243f", "heatmap": "e-98a32c95ada534dc1955243f", "modelKey": "event", "timestamp": 1643049973514 }, { "id": "ff60f5f609e340f0db262217", "type": "sensorMotion", "start": 1643049960068, "end": 1643049960068, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iwftef Sploj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ff60f5f609e340f0db262217", "heatmap": "e-ff60f5f609e340f0db262217", "modelKey": "event", "timestamp": null }, { "id": "22ed37a605e9920cf1d502eb", "type": "sensorMotion", "start": 1643049964976, "end": 1643049964976, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vtx Wazv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-22ed37a605e9920cf1d502eb", "heatmap": "e-22ed37a605e9920cf1d502eb", "modelKey": "event", "timestamp": null }, { "id": "0445bfa9c0213342428d4724", "type": "sensorMotion", "start": 1643049969081, "end": 1643049969081, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Msvb Zeng" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0445bfa9c0213342428d4724", "heatmap": "e-0445bfa9c0213342428d4724", "modelKey": "event", "timestamp": null }, { "id": "e861a69e3258b5989fd1e5b1", "type": "sensorMotion", "start": 1643049971663, "end": 1643049971663, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Likghe Tzfaucg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e861a69e3258b5989fd1e5b1", "heatmap": "e-e861a69e3258b5989fd1e5b1", "modelKey": "event", "timestamp": null }, { "id": "ae77dd2f7d59d3f1e27d34e0", "type": "sensorMotion", "start": 1643049974231, "end": 1643049974231, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jjzdu Dfxdrf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ae77dd2f7d59d3f1e27d34e0", "heatmap": "e-ae77dd2f7d59d3f1e27d34e0", "modelKey": "event", "timestamp": null }, { "id": "358119aed7b73498a955f41b", "type": "sensorMotion", "start": 1643049976571, "end": 1643049976571, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Wdh Vprgc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-358119aed7b73498a955f41b", "heatmap": "e-358119aed7b73498a955f41b", "modelKey": "event", "timestamp": null }, { "id": "d146491b507d425024da0782", "type": "sensorMotion", "start": 1643049977079, "end": 1643049977079, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qimexk Jbgmfji" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d146491b507d425024da0782", "heatmap": "e-d146491b507d425024da0782", "modelKey": "event", "timestamp": null }, { "id": "d0b789cebf77818067e21764", "type": "sensorMotion", "start": 1643049983244, "end": 1643049983244, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kobx Jgdhmk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d0b789cebf77818067e21764", "heatmap": "e-d0b789cebf77818067e21764", "modelKey": "event", "timestamp": null }, { "id": "0afe89beb7727ac53c6705f8", "type": "sensorMotion", "start": 1643049989682, "end": 1643049989682, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ukyqmir Ofiqq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0afe89beb7727ac53c6705f8", "heatmap": "e-0afe89beb7727ac53c6705f8", "modelKey": "event", "timestamp": null }, { "id": "97d8d8063de25dc45aa2f37b", "type": "sensorMotion", "start": 1643049993551, "end": 1643049993551, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xjwn Cohmdcl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-97d8d8063de25dc45aa2f37b", "heatmap": "e-97d8d8063de25dc45aa2f37b", "modelKey": "event", "timestamp": null }, { "id": "de6aad9c35ebbd909b2b64ee", "type": "motion", "start": 1643049994930, "end": 1643050019430, "score": 34, "smartDetectTypes": [], "smartDetectEvents": [ "73ae4f31d7df35867e73903b" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-de6aad9c35ebbd909b2b64ee", "heatmap": "e-de6aad9c35ebbd909b2b64ee", "modelKey": "event", "timestamp": 1643049953903 }, { "id": "6aef7e6b3ebefcc091186817", "type": "sensorMotion", "start": 1643049997793, "end": 1643049997793, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iuetq Ktplq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6aef7e6b3ebefcc091186817", "heatmap": "e-6aef7e6b3ebefcc091186817", "modelKey": "event", "timestamp": null }, { "id": "5eba5bca1023fe1835112ecd", "type": "motion", "start": 1643049998829, "end": 1643050020497, "score": 75, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5eba5bca1023fe1835112ecd", "heatmap": "e-5eba5bca1023fe1835112ecd", "modelKey": "event", "timestamp": 1643050009993 }, { "id": "c299aff7f4ca4a8533583ca3", "type": "sensorMotion", "start": 1643050002686, "end": 1643050002686, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Oujppj Yehq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c299aff7f4ca4a8533583ca3", "heatmap": "e-c299aff7f4ca4a8533583ca3", "modelKey": "event", "timestamp": null }, { "id": "9930c48e44632c39c56952a3", "type": "sensorMotion", "start": 1643050006806, "end": 1643050006806, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jujvxny Uqn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9930c48e44632c39c56952a3", "heatmap": "e-9930c48e44632c39c56952a3", "modelKey": "event", "timestamp": null }, { "id": "5d33b8f061b23569ee73b258", "type": "sensorMotion", "start": 1643050009896, "end": 1643050009896, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iciqtfs Dhnl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5d33b8f061b23569ee73b258", "heatmap": "e-5d33b8f061b23569ee73b258", "modelKey": "event", "timestamp": null }, { "id": "3e24246f3e58485f71bfc2cb", "type": "sensorMotion", "start": 1643050015046, "end": 1643050015046, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mlyutb Hzzlnpg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3e24246f3e58485f71bfc2cb", "heatmap": "e-3e24246f3e58485f71bfc2cb", "modelKey": "event", "timestamp": null }, { "id": "4ffff1947bbee65fb5b36a73", "type": "motion", "start": 1643050021263, "end": 1643050054932, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "73ae4f31d7df35867e73903b" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4ffff1947bbee65fb5b36a73", "heatmap": "e-4ffff1947bbee65fb5b36a73", "modelKey": "event", "timestamp": 1643050036436 }, { "id": "23a7b598124e9bc59ad0fe7f", "type": "sensorMotion", "start": 1643050022772, "end": 1643050022772, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Luafovc Fhx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-23a7b598124e9bc59ad0fe7f", "heatmap": "e-23a7b598124e9bc59ad0fe7f", "modelKey": "event", "timestamp": null }, { "id": "ad0cf4786e053ec5626d9a6f", "type": "sensorMotion", "start": 1643050027793, "end": 1643050027793, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Blaiv Upkhgdb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ad0cf4786e053ec5626d9a6f", "heatmap": "e-ad0cf4786e053ec5626d9a6f", "modelKey": "event", "timestamp": null }, { "id": "d160756d60de7daf017b998d", "type": "sensorMotion", "start": 1643050030755, "end": 1643050030755, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fabik Yqyes" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d160756d60de7daf017b998d", "heatmap": "e-d160756d60de7daf017b998d", "modelKey": "event", "timestamp": null }, { "id": "26d7535732794cacbfa18be3", "type": "sensorMotion", "start": 1643050035790, "end": 1643050035790, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Whztfn Hpg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-26d7535732794cacbfa18be3", "heatmap": "e-26d7535732794cacbfa18be3", "modelKey": "event", "timestamp": null }, { "id": "0476583f47bae21e0b6c088c", "type": "sensorMotion", "start": 1643050038866, "end": 1643050038866, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Bwvx Rtrknv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0476583f47bae21e0b6c088c", "heatmap": "e-0476583f47bae21e0b6c088c", "modelKey": "event", "timestamp": null }, { "id": "88cfc451716bdc38f7eae4a8", "type": "smartDetectZone", "start": 1643050065508, "end": 1643050265938, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-88cfc451716bdc38f7eae4a8", "heatmap": "e-88cfc451716bdc38f7eae4a8", "modelKey": "event", "timestamp": 1643050076508 }, { "id": "b2fb7ec2ae74f80547810444", "type": "motion", "start": 1643050066754, "end": 1643050110755, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "88cfc451716bdc38f7eae4a8" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b2fb7ec2ae74f80547810444", "heatmap": "e-b2fb7ec2ae74f80547810444", "modelKey": "event", "timestamp": 1643050082423 }, { "id": "1e302e724c888ac094888e7c", "type": "sensorMotion", "start": 1643050075947, "end": 1643050075947, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jluc Jjeu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1e302e724c888ac094888e7c", "heatmap": "e-1e302e724c888ac094888e7c", "modelKey": "event", "timestamp": null }, { "id": "efbbee2f6d0ef05545e663d4", "type": "sensorMotion", "start": 1643050081742, "end": 1643050081742, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Btlu Eupky" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-efbbee2f6d0ef05545e663d4", "heatmap": "e-efbbee2f6d0ef05545e663d4", "modelKey": "event", "timestamp": null }, { "id": "bf18a67a505675b30a45168f", "type": "sensorMotion", "start": 1643050085346, "end": 1643050085346, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ujltrr Hlyic" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bf18a67a505675b30a45168f", "heatmap": "e-bf18a67a505675b30a45168f", "modelKey": "event", "timestamp": null }, { "id": "f76edcf2ae31eb026c6fef3e", "type": "sensorMotion", "start": 1643050089339, "end": 1643050089339, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sbk Bsgo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f76edcf2ae31eb026c6fef3e", "heatmap": "e-f76edcf2ae31eb026c6fef3e", "modelKey": "event", "timestamp": null }, { "id": "c35189b61298e2697d14c0b2", "type": "sensorMotion", "start": 1643050093458, "end": 1643050093458, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ubuv Wgds" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c35189b61298e2697d14c0b2", "heatmap": "e-c35189b61298e2697d14c0b2", "modelKey": "event", "timestamp": null }, { "id": "bb6ec45db73e8ce4156e54e3", "type": "sensorMotion", "start": 1643050097593, "end": 1643050097593, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Armm Ixffdyt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bb6ec45db73e8ce4156e54e3", "heatmap": "e-bb6ec45db73e8ce4156e54e3", "modelKey": "event", "timestamp": null }, { "id": "a17e96572835c3b2b9080612", "type": "sensorMotion", "start": 1643050101585, "end": 1643050101585, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zljih Xvkbgok" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a17e96572835c3b2b9080612", "heatmap": "e-a17e96572835c3b2b9080612", "modelKey": "event", "timestamp": null }, { "id": "11d83d0e8ce509e46aa45b19", "type": "sensorMotion", "start": 1643050111355, "end": 1643050111355, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zsorlt Hpabi" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-11d83d0e8ce509e46aa45b19", "heatmap": "e-11d83d0e8ce509e46aa45b19", "modelKey": "event", "timestamp": null }, { "id": "c39b526441cf92fdf9ec1b19", "type": "sensorMotion", "start": 1643050115475, "end": 1643050115475, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Eivziua Enccfan" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c39b526441cf92fdf9ec1b19", "heatmap": "e-c39b526441cf92fdf9ec1b19", "modelKey": "event", "timestamp": null }, { "id": "21d7126a0a6157a2d987f1a9", "type": "sensorMotion", "start": 1643050120624, "end": 1643050120624, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jyxtlia Iuilp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-21d7126a0a6157a2d987f1a9", "heatmap": "e-21d7126a0a6157a2d987f1a9", "modelKey": "event", "timestamp": null }, { "id": "a16d55ce9211c04f4a3909a0", "type": "sensorMotion", "start": 1643050132857, "end": 1643050132857, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ywt Wwun" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a16d55ce9211c04f4a3909a0", "heatmap": "e-a16d55ce9211c04f4a3909a0", "modelKey": "event", "timestamp": null }, { "id": "8ee33b9b106bd5f7f44e4c1a", "type": "sensorMotion", "start": 1643050135991, "end": 1643050135991, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mkgehu Giaf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8ee33b9b106bd5f7f44e4c1a", "heatmap": "e-8ee33b9b106bd5f7f44e4c1a", "modelKey": "event", "timestamp": null }, { "id": "b301eef6d0beefe5138317e5", "type": "sensorMotion", "start": 1643050140859, "end": 1643050140859, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ycbbv Janrdl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b301eef6d0beefe5138317e5", "heatmap": "e-b301eef6d0beefe5138317e5", "modelKey": "event", "timestamp": null }, { "id": "71fbce0cd5c10f8bdf6092cb", "type": "sensorMotion", "start": 1643050145346, "end": 1643050145346, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gqcnhny Xsrm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-71fbce0cd5c10f8bdf6092cb", "heatmap": "e-71fbce0cd5c10f8bdf6092cb", "modelKey": "event", "timestamp": null }, { "id": "871b72df703079f4f44edfe8", "type": "sensorMotion", "start": 1643050153241, "end": 1643050153241, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Okbn Edgd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-871b72df703079f4f44edfe8", "heatmap": "e-871b72df703079f4f44edfe8", "modelKey": "event", "timestamp": null }, { "id": "b16b8475e6c66724f9c2b5f0", "type": "sensorMotion", "start": 1643050157835, "end": 1643050157835, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Djetyl Kzskynb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b16b8475e6c66724f9c2b5f0", "heatmap": "e-b16b8475e6c66724f9c2b5f0", "modelKey": "event", "timestamp": null }, { "id": "bbef379af7b402ea6f580dda", "type": "sensorMotion", "start": 1643050163372, "end": 1643050163372, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Uuucc Dgukgrf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bbef379af7b402ea6f580dda", "heatmap": "e-bbef379af7b402ea6f580dda", "modelKey": "event", "timestamp": null }, { "id": "a76a976fe2df881a26d4d537", "type": "sensorMotion", "start": 1643050166334, "end": 1643050166334, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mma Flfkmte" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a76a976fe2df881a26d4d537", "heatmap": "e-a76a976fe2df881a26d4d537", "modelKey": "event", "timestamp": null }, { "id": "a308b0d8525aee74d05d1b2c", "type": "sensorMotion", "start": 1643050171869, "end": 1643050171869, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Tgtnva Ndtj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a308b0d8525aee74d05d1b2c", "heatmap": "e-a308b0d8525aee74d05d1b2c", "modelKey": "event", "timestamp": null }, { "id": "9e0796133cd8bc6a65e05b30", "type": "sensorMotion", "start": 1643050176648, "end": 1643050176648, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Upuxuqy Hbkh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9e0796133cd8bc6a65e05b30", "heatmap": "e-9e0796133cd8bc6a65e05b30", "modelKey": "event", "timestamp": null }, { "id": "cc4bcfa13108326a1df0aa6b", "type": "sensorMotion", "start": 1643050187449, "end": 1643050187449, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Bjvkzep Lfwxbht" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cc4bcfa13108326a1df0aa6b", "heatmap": "e-cc4bcfa13108326a1df0aa6b", "modelKey": "event", "timestamp": null }, { "id": "1d51c4080682635bceff714f", "type": "sensorMotion", "start": 1643050200324, "end": 1643050200324, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iiwzfth Oarl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1d51c4080682635bceff714f", "heatmap": "e-1d51c4080682635bceff714f", "modelKey": "event", "timestamp": null }, { "id": "b582567e17f4a3dca8714d26", "type": "sensorMotion", "start": 1643050205346, "end": 1643050205346, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hekwj Obklrnw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b582567e17f4a3dca8714d26", "heatmap": "e-b582567e17f4a3dca8714d26", "modelKey": "event", "timestamp": null }, { "id": "fcca69ab8bc9c007e3e110ee", "type": "sensorMotion", "start": 1643050211912, "end": 1643050211912, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qrv Bfh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-fcca69ab8bc9c007e3e110ee", "heatmap": "e-fcca69ab8bc9c007e3e110ee", "modelKey": "event", "timestamp": null }, { "id": "269e99e7fc3e0abbef8152b2", "type": "sensorMotion", "start": 1643050216331, "end": 1643050216331, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Geohwzf Vem" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-269e99e7fc3e0abbef8152b2", "heatmap": "e-269e99e7fc3e0abbef8152b2", "modelKey": "event", "timestamp": null }, { "id": "42f54dc1c5218562df5f81ec", "type": "sensorMotion", "start": 1643050221698, "end": 1643050221698, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qkbqjx Vuijm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-42f54dc1c5218562df5f81ec", "heatmap": "e-42f54dc1c5218562df5f81ec", "modelKey": "event", "timestamp": null }, { "id": "19c85a34298f7c306d9da2fb", "type": "sensorMotion", "start": 1643050226204, "end": 1643050226204, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xch Rfelb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-19c85a34298f7c306d9da2fb", "heatmap": "e-19c85a34298f7c306d9da2fb", "modelKey": "event", "timestamp": null }, { "id": "caa4c362b48517432c52789c", "type": "access", "start": 1643050229984, "end": 1643050604060, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "bc3dd633553907952a6fe20d", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-caa4c362b48517432c52789c", "heatmap": "e-caa4c362b48517432c52789c", "modelKey": "event", "timestamp": null }, { "id": "e2589f883d6320859a20f006", "type": "sensorMotion", "start": 1643050230710, "end": 1643050230710, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dssv Esqdte" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e2589f883d6320859a20f006", "heatmap": "e-e2589f883d6320859a20f006", "modelKey": "event", "timestamp": null }, { "id": "3adca0e42e748045ebf103d3", "type": "sensorMotion", "start": 1643050233815, "end": 1643050233815, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Osdibh Wogov" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3adca0e42e748045ebf103d3", "heatmap": "e-3adca0e42e748045ebf103d3", "modelKey": "event", "timestamp": null }, { "id": "a3f221f78cff201dd1e20185", "type": "sensorMotion", "start": 1643050239884, "end": 1643050239884, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hqagra Pymwq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a3f221f78cff201dd1e20185", "heatmap": "e-a3f221f78cff201dd1e20185", "modelKey": "event", "timestamp": null }, { "id": "782b2255145b002a53da469f", "type": "sensorMotion", "start": 1643050256204, "end": 1643050256204, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Krjk Skrcc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-782b2255145b002a53da469f", "heatmap": "e-782b2255145b002a53da469f", "modelKey": "event", "timestamp": null }, { "id": "75d703a32bc35af3246bea11", "type": "sensorMotion", "start": 1643050260338, "end": 1643050260338, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fuj Matgbls" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-75d703a32bc35af3246bea11", "heatmap": "e-75d703a32bc35af3246bea11", "modelKey": "event", "timestamp": null }, { "id": "7c916a87e9654f3f1b8fe894", "type": "sensorMotion", "start": 1643050264846, "end": 1643050264846, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ldtl Zao" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7c916a87e9654f3f1b8fe894", "heatmap": "e-7c916a87e9654f3f1b8fe894", "modelKey": "event", "timestamp": null }, { "id": "566f02efcb55fe5c66e826c8", "type": "sensorMotion", "start": 1643050268838, "end": 1643050268838, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ljf Wiesf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-566f02efcb55fe5c66e826c8", "heatmap": "e-566f02efcb55fe5c66e826c8", "modelKey": "event", "timestamp": null }, { "id": "5e3f080762b0168b5b32c22a", "type": "sensorMotion", "start": 1643050273717, "end": 1643050273717, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jjdh Rnyd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5e3f080762b0168b5b32c22a", "heatmap": "e-5e3f080762b0168b5b32c22a", "modelKey": "event", "timestamp": null }, { "id": "6e2e7b2c55500408de85adb8", "type": "sensorMotion", "start": 1643050277849, "end": 1643050277849, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ouu Vgtn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6e2e7b2c55500408de85adb8", "heatmap": "e-6e2e7b2c55500408de85adb8", "modelKey": "event", "timestamp": null }, { "id": "7cf439298f29f474f7625815", "type": "motion", "start": 1643050278700, "end": 1643050302034, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7cf439298f29f474f7625815", "heatmap": "e-7cf439298f29f474f7625815", "modelKey": "event", "timestamp": 1643050289867 }, { "id": "95388108ef1c9eaffed515aa", "type": "sensorMotion", "start": 1643050285302, "end": 1643050285302, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qibwk Afcnm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-95388108ef1c9eaffed515aa", "heatmap": "e-95388108ef1c9eaffed515aa", "modelKey": "event", "timestamp": null }, { "id": "fc949c629d91bd3b56571d7b", "type": "sensorMotion", "start": 1643050290326, "end": 1643050290326, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gmyi Rwa" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-fc949c629d91bd3b56571d7b", "heatmap": "e-fc949c629d91bd3b56571d7b", "modelKey": "event", "timestamp": null }, { "id": "2f5f0e895611612659718226", "type": "sensorMotion", "start": 1643050298566, "end": 1643050298566, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Tevue Seb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2f5f0e895611612659718226", "heatmap": "e-2f5f0e895611612659718226", "modelKey": "event", "timestamp": null }, { "id": "39f00bc4b8db931dbb45ead2", "type": "sensorMotion", "start": 1643050302942, "end": 1643050302942, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jjble Ugui" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-39f00bc4b8db931dbb45ead2", "heatmap": "e-39f00bc4b8db931dbb45ead2", "modelKey": "event", "timestamp": null }, { "id": "4e5b8a5f782ab70349254fad", "type": "sensorMotion", "start": 1643050311311, "end": 1643050311311, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gttfy Pze" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4e5b8a5f782ab70349254fad", "heatmap": "e-4e5b8a5f782ab70349254fad", "modelKey": "event", "timestamp": null }, { "id": "6998abd28da1ef3ae51076d9", "type": "sensorMotion", "start": 1643050319293, "end": 1643050319293, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Oais Ctjgqcs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6998abd28da1ef3ae51076d9", "heatmap": "e-6998abd28da1ef3ae51076d9", "modelKey": "event", "timestamp": null }, { "id": "f5e38d96ae7d9ad1b4db6168", "type": "sensorMotion", "start": 1643050323285, "end": 1643050323285, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rdsvxni Kkqv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f5e38d96ae7d9ad1b4db6168", "heatmap": "e-f5e38d96ae7d9ad1b4db6168", "modelKey": "event", "timestamp": null }, { "id": "17fd6b913e8e458f80bbace9", "type": "sensorMotion", "start": 1643050329594, "end": 1643050329594, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Tpoyjr Djrjtw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-17fd6b913e8e458f80bbace9", "heatmap": "e-17fd6b913e8e458f80bbace9", "modelKey": "event", "timestamp": null }, { "id": "e4b710c96afd7afa5e020e40", "type": "sensorMotion", "start": 1643050335902, "end": 1643050335902, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Urfjxcr Blo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e4b710c96afd7afa5e020e40", "heatmap": "e-e4b710c96afd7afa5e020e40", "modelKey": "event", "timestamp": null }, { "id": "3928bcb3d4fa74529885fb44", "type": "sensorMotion", "start": 1643050339856, "end": 1643050339856, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jgyip Ogp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3928bcb3d4fa74529885fb44", "heatmap": "e-3928bcb3d4fa74529885fb44", "modelKey": "event", "timestamp": null }, { "id": "b9cae957c1f122862dfc728f", "type": "sensorMotion", "start": 1643050345442, "end": 1643050345442, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iyhbz Yooxdne" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b9cae957c1f122862dfc728f", "heatmap": "e-b9cae957c1f122862dfc728f", "modelKey": "event", "timestamp": null }, { "id": "d759077dadf40d9eb84c18a9", "type": "sensorMotion", "start": 1643050351143, "end": 1643050351143, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xpag Oweqdnm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d759077dadf40d9eb84c18a9", "heatmap": "e-d759077dadf40d9eb84c18a9", "modelKey": "event", "timestamp": null }, { "id": "afaffa60e221769f473d62a2", "type": "sensorMotion", "start": 1643050362570, "end": 1643050362570, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jfwm Oclnxs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-afaffa60e221769f473d62a2", "heatmap": "e-afaffa60e221769f473d62a2", "modelKey": "event", "timestamp": null }, { "id": "22067c7e8b213b4b4d034036", "type": "sensorMotion", "start": 1643050372340, "end": 1643050372340, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yadliy Oizvcu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-22067c7e8b213b4b4d034036", "heatmap": "e-22067c7e8b213b4b4d034036", "modelKey": "event", "timestamp": null }, { "id": "ef476e535fe6df467231ceb3", "type": "sensorMotion", "start": 1643050379822, "end": 1643050379822, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jquybnn Paumeul" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ef476e535fe6df467231ceb3", "heatmap": "e-ef476e535fe6df467231ceb3", "modelKey": "event", "timestamp": null }, { "id": "52d04eab99c955ebf24a7070", "type": "sensorMotion", "start": 1643050389079, "end": 1643050389079, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Etmnqyj Mle" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-52d04eab99c955ebf24a7070", "heatmap": "e-52d04eab99c955ebf24a7070", "modelKey": "event", "timestamp": null }, { "id": "ef116f734e4f4d80aaad5318", "type": "sensorMotion", "start": 1643050393072, "end": 1643050393072, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wbdodi Ceyq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ef116f734e4f4d80aaad5318", "heatmap": "e-ef116f734e4f4d80aaad5318", "modelKey": "event", "timestamp": null }, { "id": "9b542a17d0a6fa0684bb55f0", "type": "sensorMotion", "start": 1643050399249, "end": 1643050399249, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rpyzzli Dxf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9b542a17d0a6fa0684bb55f0", "heatmap": "e-9b542a17d0a6fa0684bb55f0", "modelKey": "event", "timestamp": null }, { "id": "d26a58d6d83d1cb929a89828", "type": "sensorMotion", "start": 1643050413298, "end": 1643050413298, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jby Ppoj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d26a58d6d83d1cb929a89828", "heatmap": "e-d26a58d6d83d1cb929a89828", "modelKey": "event", "timestamp": null }, { "id": "16fb9a53dc55fa89e4585484", "type": "sensorMotion", "start": 1643050418820, "end": 1643050418820, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zmiy Eyje" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-16fb9a53dc55fa89e4585484", "heatmap": "e-16fb9a53dc55fa89e4585484", "modelKey": "event", "timestamp": null }, { "id": "2e6e0fcd3ac1c1f8925b444c", "type": "sensorMotion", "start": 1643050429121, "end": 1643050429121, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xmgh Rfxczim" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2e6e0fcd3ac1c1f8925b444c", "heatmap": "e-2e6e0fcd3ac1c1f8925b444c", "modelKey": "event", "timestamp": null }, { "id": "536ed217d80ca7b6273ec7de", "type": "smartDetectZone", "start": 1643050430140, "end": 1643050453138, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-536ed217d80ca7b6273ec7de", "heatmap": "e-536ed217d80ca7b6273ec7de", "modelKey": "event", "timestamp": 1643050441741 }, { "id": "5108cbfe4544b822945b4aba", "type": "motion", "start": 1643050431893, "end": 1643050455058, "score": 9, "smartDetectTypes": [], "smartDetectEvents": [ "536ed217d80ca7b6273ec7de" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5108cbfe4544b822945b4aba", "heatmap": "e-5108cbfe4544b822945b4aba", "modelKey": "event", "timestamp": 1643050082423 }, { "id": "5582e6a0c26b37bd534c5c8e", "type": "sensorMotion", "start": 1643050432096, "end": 1643050432096, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Exwmebg Waqn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5582e6a0c26b37bd534c5c8e", "heatmap": "e-5582e6a0c26b37bd534c5c8e", "modelKey": "event", "timestamp": null }, { "id": "d736bb33c08ead050676b9bf", "type": "sensorMotion", "start": 1643050436073, "end": 1643050436073, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yqgkm Wgfpk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d736bb33c08ead050676b9bf", "heatmap": "e-d736bb33c08ead050676b9bf", "modelKey": "event", "timestamp": null }, { "id": "0661f7ab4c004935e2486c1d", "type": "sensorMotion", "start": 1643050441352, "end": 1643050441352, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kvgie Fat" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0661f7ab4c004935e2486c1d", "heatmap": "e-0661f7ab4c004935e2486c1d", "modelKey": "event", "timestamp": null }, { "id": "8df618372d63c175049d18ac", "type": "sensorMotion", "start": 1643050445343, "end": 1643050445343, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yspfa Mittii" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8df618372d63c175049d18ac", "heatmap": "e-8df618372d63c175049d18ac", "modelKey": "event", "timestamp": null }, { "id": "b2c0021d25cc2d9f799b41c6", "type": "smartDetectZone", "start": 1643050445693, "end": 1643050477433, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b2c0021d25cc2d9f799b41c6", "heatmap": "e-b2c0021d25cc2d9f799b41c6", "modelKey": "event", "timestamp": 1643050456693 }, { "id": "532a7e8c90a5e60daecfa782", "type": "sensorMotion", "start": 1643050450107, "end": 1643050450107, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vyvfz Yldxvq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-532a7e8c90a5e60daecfa782", "heatmap": "e-532a7e8c90a5e60daecfa782", "modelKey": "event", "timestamp": null }, { "id": "e87471c5d14c8764bc322779", "type": "sensorMotion", "start": 1643050456674, "end": 1643050456674, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ant Qxy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e87471c5d14c8764bc322779", "heatmap": "e-e87471c5d14c8764bc322779", "modelKey": "event", "timestamp": null }, { "id": "9c9e99168f79da5b4b29c9b5", "type": "sensorMotion", "start": 1643050461051, "end": 1643050461051, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fxub Prik" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9c9e99168f79da5b4b29c9b5", "heatmap": "e-9c9e99168f79da5b4b29c9b5", "modelKey": "event", "timestamp": null }, { "id": "2e5b65115275db6f62c800a8", "type": "sensorMotion", "start": 1643050465686, "end": 1643050465686, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Aquepj Nrg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2e5b65115275db6f62c800a8", "heatmap": "e-2e5b65115275db6f62c800a8", "modelKey": "event", "timestamp": null }, { "id": "abd70e20ddc33f6327d3a8c2", "type": "sensorMotion", "start": 1643050469677, "end": 1643050469677, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dyrhmzn Ioiwrq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-abd70e20ddc33f6327d3a8c2", "heatmap": "e-abd70e20ddc33f6327d3a8c2", "modelKey": "event", "timestamp": null }, { "id": "b71f257e22cabdc48a7426bd", "type": "sensorMotion", "start": 1643050474956, "end": 1643050474956, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ropzmc Uepkn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b71f257e22cabdc48a7426bd", "heatmap": "e-b71f257e22cabdc48a7426bd", "modelKey": "event", "timestamp": null }, { "id": "e5cfe91582ea82c779a6ec5a", "type": "sensorMotion", "start": 1643050483582, "end": 1643050483582, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Obtmmt Hgdbix" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e5cfe91582ea82c779a6ec5a", "heatmap": "e-e5cfe91582ea82c779a6ec5a", "modelKey": "event", "timestamp": null }, { "id": "b11ced642e5243edb5f50d46", "type": "sensorMotion", "start": 1643050487588, "end": 1643050487588, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Freg Rodgtlv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b11ced642e5243edb5f50d46", "heatmap": "e-b11ced642e5243edb5f50d46", "modelKey": "event", "timestamp": null }, { "id": "23f529fefa74f48698703447", "type": "sensorMotion", "start": 1643050491580, "end": 1643050491580, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wqiaxgb Yghrf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-23f529fefa74f48698703447", "heatmap": "e-23f529fefa74f48698703447", "modelKey": "event", "timestamp": null }, { "id": "0aa03234505698a27d8b7618", "type": "sensorMotion", "start": 1643050495589, "end": 1643050495589, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Twysgt Xicxxe" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0aa03234505698a27d8b7618", "heatmap": "e-0aa03234505698a27d8b7618", "modelKey": "event", "timestamp": null }, { "id": "84ac8b5f73f166e8b22e93a8", "type": "smartDetectZone", "start": 1643050496588, "end": 1643050697024, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-84ac8b5f73f166e8b22e93a8", "heatmap": "e-84ac8b5f73f166e8b22e93a8", "modelKey": "event", "timestamp": 1643050507885 }, { "id": "07c30c13550ae5844c64407b", "type": "motion", "start": 1643050499875, "end": 1643050534375, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "84ac8b5f73f166e8b22e93a8" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-07c30c13550ae5844c64407b", "heatmap": "e-07c30c13550ae5844c64407b", "modelKey": "event", "timestamp": 1643050514207 }, { "id": "37657f86e3bdf24c91ab6c8c", "type": "sensorMotion", "start": 1643050501865, "end": 1643050501865, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nsfgd Ztk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-37657f86e3bdf24c91ab6c8c", "heatmap": "e-37657f86e3bdf24c91ab6c8c", "modelKey": "event", "timestamp": null }, { "id": "59180b687842fd4838ef8394", "type": "sensorMotion", "start": 1643050505866, "end": 1643050505866, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Joz Jhuxz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-59180b687842fd4838ef8394", "heatmap": "e-59180b687842fd4838ef8394", "modelKey": "event", "timestamp": null }, { "id": "e40819b946d3c50892559c53", "type": "sensorMotion", "start": 1643050508690, "end": 1643050508690, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xcgdoie Aelrb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e40819b946d3c50892559c53", "heatmap": "e-e40819b946d3c50892559c53", "modelKey": "event", "timestamp": null }, { "id": "d5ff719c83fc4f8effac1014", "type": "sensorMotion", "start": 1643050523509, "end": 1643050523509, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kfkc Rleptaj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d5ff719c83fc4f8effac1014", "heatmap": "e-d5ff719c83fc4f8effac1014", "modelKey": "event", "timestamp": null }, { "id": "9016fae7b9a2dbb2caeaee7d", "type": "sensorMotion", "start": 1643050548132, "end": 1643050548132, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rwawsy Ijml" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9016fae7b9a2dbb2caeaee7d", "heatmap": "e-9016fae7b9a2dbb2caeaee7d", "modelKey": "event", "timestamp": null }, { "id": "d50803bb14ad6ad4c0c07437", "type": "sensorMotion", "start": 1643050556973, "end": 1643050556973, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Grjv Ydhdzrz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d50803bb14ad6ad4c0c07437", "heatmap": "e-d50803bb14ad6ad4c0c07437", "modelKey": "event", "timestamp": null }, { "id": "fc8ca256f8eb34fa4e6b25fe", "type": "sensorMotion", "start": 1643050563152, "end": 1643050563152, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qxk Iiu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-fc8ca256f8eb34fa4e6b25fe", "heatmap": "e-fc8ca256f8eb34fa4e6b25fe", "modelKey": "event", "timestamp": null }, { "id": "3c8d4bcf9d62f1a9a4c8b723", "type": "sensorMotion", "start": 1643050574998, "end": 1643050574998, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sgzcy Cgdwg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3c8d4bcf9d62f1a9a4c8b723", "heatmap": "e-3c8d4bcf9d62f1a9a4c8b723", "modelKey": "event", "timestamp": null }, { "id": "b1400545e15f0c9a10abea7c", "type": "sensorMotion", "start": 1643050580534, "end": 1643050580534, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ytdtu Xzcnhos" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b1400545e15f0c9a10abea7c", "heatmap": "e-b1400545e15f0c9a10abea7c", "modelKey": "event", "timestamp": null }, { "id": "f4f569377b656dd3f0a84a7a", "type": "sensorMotion", "start": 1643050583897, "end": 1643050583897, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fvlhdw Qpvg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f4f569377b656dd3f0a84a7a", "heatmap": "e-f4f569377b656dd3f0a84a7a", "modelKey": "event", "timestamp": null }, { "id": "5829559c8e9f10291b388214", "type": "sensorMotion", "start": 1643050601136, "end": 1643050601136, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yetvzs Tedig" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5829559c8e9f10291b388214", "heatmap": "e-5829559c8e9f10291b388214", "modelKey": "event", "timestamp": null }, { "id": "d1344bdd296e98adc1968d9c", "type": "sensorMotion", "start": 1643050619418, "end": 1643050619418, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Eyr Gbfzx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d1344bdd296e98adc1968d9c", "heatmap": "e-d1344bdd296e98adc1968d9c", "modelKey": "event", "timestamp": null }, { "id": "8119aa04347b63e04e390c89", "type": "sensorMotion", "start": 1643050627530, "end": 1643050627530, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xfsi Lvp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8119aa04347b63e04e390c89", "heatmap": "e-8119aa04347b63e04e390c89", "modelKey": "event", "timestamp": null }, { "id": "c960d62f2aa872b13b09c87c", "type": "sensorMotion", "start": 1643050633195, "end": 1643050633195, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sbzbfcy Xtogd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c960d62f2aa872b13b09c87c", "heatmap": "e-c960d62f2aa872b13b09c87c", "modelKey": "event", "timestamp": null }, { "id": "256aaf4c00928faa1a417c31", "type": "motion", "start": 1643050640039, "end": 1643050664041, "score": 30, "smartDetectTypes": [], "smartDetectEvents": [ "84ac8b5f73f166e8b22e93a8" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-256aaf4c00928faa1a417c31", "heatmap": "e-256aaf4c00928faa1a417c31", "modelKey": "event", "timestamp": 1643050651539 }, { "id": "897672f84ae817e3a7e5a158", "type": "sensorMotion", "start": 1643050647615, "end": 1643050647615, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Btn Qhx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-897672f84ae817e3a7e5a158", "heatmap": "e-897672f84ae817e3a7e5a158", "modelKey": "event", "timestamp": null }, { "id": "5cb4579758e1bf254a8d3393", "type": "sensorMotion", "start": 1643050651993, "end": 1643050651993, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qayfj Mhskdbp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5cb4579758e1bf254a8d3393", "heatmap": "e-5cb4579758e1bf254a8d3393", "modelKey": "event", "timestamp": null }, { "id": "2284602f0bfbf90c4dd36ad7", "type": "sensorMotion", "start": 1643050657014, "end": 1643050657014, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Uqfw Utrduo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2284602f0bfbf90c4dd36ad7", "heatmap": "e-2284602f0bfbf90c4dd36ad7", "modelKey": "event", "timestamp": null }, { "id": "77b71c653a25b7f61a7915a7", "type": "sensorMotion", "start": 1643050668474, "end": 1643050668474, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hkkop Qtj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-77b71c653a25b7f61a7915a7", "heatmap": "e-77b71c653a25b7f61a7915a7", "modelKey": "event", "timestamp": null }, { "id": "1224c78a8569cfeb30c9f1db", "type": "sensorMotion", "start": 1643050679201, "end": 1643050679201, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Sowmd Abpo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1224c78a8569cfeb30c9f1db", "heatmap": "e-1224c78a8569cfeb30c9f1db", "modelKey": "event", "timestamp": null }, { "id": "44d603976b19887675f53a68", "type": "sensorMotion", "start": 1643050687014, "end": 1643050687014, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Obb Mqy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-44d603976b19887675f53a68", "heatmap": "e-44d603976b19887675f53a68", "modelKey": "event", "timestamp": null }, { "id": "0c7b6d27d76deaddd58afa6f", "type": "sensorMotion", "start": 1643050692551, "end": 1643050692551, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ntsjtdc Kzwlxl" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-0c7b6d27d76deaddd58afa6f", "heatmap": "e-0c7b6d27d76deaddd58afa6f", "modelKey": "event", "timestamp": null }, { "id": "69197f96b2d753c63565a769", "type": "sensorMotion", "start": 1643050697572, "end": 1643050697572, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Xzxiks Dsz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-69197f96b2d753c63565a769", "heatmap": "e-69197f96b2d753c63565a769", "modelKey": "event", "timestamp": null }, { "id": "72c91b80ae8c8e7a56a3ccc9", "type": "sensorMotion", "start": 1643050704653, "end": 1643050704653, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jydc Oac" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-72c91b80ae8c8e7a56a3ccc9", "heatmap": "e-72c91b80ae8c8e7a56a3ccc9", "modelKey": "event", "timestamp": null }, { "id": "743d49925dbb7589ae226946", "type": "motion", "start": 1643050707353, "end": 1643050746017, "score": 54, "smartDetectTypes": [], "smartDetectEvents": [ "2d9155f200e0642ca338c8bd" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-743d49925dbb7589ae226946", "heatmap": "e-743d49925dbb7589ae226946", "modelKey": "event", "timestamp": 1643050718519 }, { "id": "83b82459678b3da16fc01385", "type": "sensorMotion", "start": 1643050707486, "end": 1643050707486, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Izso Wnta" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-83b82459678b3da16fc01385", "heatmap": "e-83b82459678b3da16fc01385", "modelKey": "event", "timestamp": null }, { "id": "2d9155f200e0642ca338c8bd", "type": "smartDetectZone", "start": 1643050710888, "end": 1643050911288, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2d9155f200e0642ca338c8bd", "heatmap": "e-2d9155f200e0642ca338c8bd", "modelKey": "event", "timestamp": 1643050722812 }, { "id": "4b20df7ec8ee5e3145057fae", "type": "sensorMotion", "start": 1643050711606, "end": 1643050711606, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gqmarj Fvnlep" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4b20df7ec8ee5e3145057fae", "heatmap": "e-4b20df7ec8ee5e3145057fae", "modelKey": "event", "timestamp": null }, { "id": "928295e3508ba15e15d4c267", "type": "sensorMotion", "start": 1643050715613, "end": 1643050715613, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fzqrlo Edlxbci" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-928295e3508ba15e15d4c267", "heatmap": "e-928295e3508ba15e15d4c267", "modelKey": "event", "timestamp": null }, { "id": "1af20130fc55ccdaacf0c843", "type": "sensorMotion", "start": 1643050720491, "end": 1643050720491, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Iudwcq Chxi" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1af20130fc55ccdaacf0c843", "heatmap": "e-1af20130fc55ccdaacf0c843", "modelKey": "event", "timestamp": null }, { "id": "15c33f694e6222fe9dc0bd02", "type": "sensorMotion", "start": 1643050724496, "end": 1643050724496, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kuaztwb Jltu" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-15c33f694e6222fe9dc0bd02", "heatmap": "e-15c33f694e6222fe9dc0bd02", "modelKey": "event", "timestamp": null }, { "id": "890b2773349cc0753ada20f0", "type": "sensorMotion", "start": 1643050728631, "end": 1643050728631, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Bgwh Fnjhciz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-890b2773349cc0753ada20f0", "heatmap": "e-890b2773349cc0753ada20f0", "modelKey": "event", "timestamp": null }, { "id": "7a239fa995e688c1a12fda31", "type": "access", "start": 1643050729825, "end": 1643052108134, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "android" }, "thumbnail": "e-7a239fa995e688c1a12fda31", "heatmap": "e-7a239fa995e688c1a12fda31", "modelKey": "event", "timestamp": null }, { "id": "38a8b865ddb07e84eaaa8f56", "type": "sensorMotion", "start": 1643050735569, "end": 1643050735569, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Whgystn Shbdq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-38a8b865ddb07e84eaaa8f56", "heatmap": "e-38a8b865ddb07e84eaaa8f56", "modelKey": "event", "timestamp": null }, { "id": "94dd9108f3214cab478abdad", "type": "sensorMotion", "start": 1643050739545, "end": 1643050739545, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Fkfpvyy Aovspyx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-94dd9108f3214cab478abdad", "heatmap": "e-94dd9108f3214cab478abdad", "modelKey": "event", "timestamp": null }, { "id": "0ad117f586b43157d9e6b0da", "type": "motion", "start": 1643050750303, "end": 1643050771302, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0ad117f586b43157d9e6b0da", "heatmap": "e-0ad117f586b43157d9e6b0da", "modelKey": "event", "timestamp": 1643050289867 }, { "id": "92b149f5d57f4e48736d97d2", "type": "sensorMotion", "start": 1643050765682, "end": 1643050765682, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hpqf Hpfw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-92b149f5d57f4e48736d97d2", "heatmap": "e-92b149f5d57f4e48736d97d2", "modelKey": "event", "timestamp": null }, { "id": "a70cfc002997d88aca93fa06", "type": "sensorMotion", "start": 1643050770575, "end": 1643050770575, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hthq Jyovvq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a70cfc002997d88aca93fa06", "heatmap": "e-a70cfc002997d88aca93fa06", "modelKey": "event", "timestamp": null }, { "id": "5e1ce41bdc4b887d50c7b90d", "type": "sensorMotion", "start": 1643050796583, "end": 1643050796583, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Wkfgwac Ahjw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5e1ce41bdc4b887d50c7b90d", "heatmap": "e-5e1ce41bdc4b887d50c7b90d", "modelKey": "event", "timestamp": null }, { "id": "4d54b2597521d93e3f3f96b3", "type": "sensorMotion", "start": 1643050799287, "end": 1643050799287, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Syp Wxhkkv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4d54b2597521d93e3f3f96b3", "heatmap": "e-4d54b2597521d93e3f3f96b3", "modelKey": "event", "timestamp": null }, { "id": "5a02ee3f1a3f7cb9e83e2c06", "type": "sensorMotion", "start": 1643050804052, "end": 1643050804052, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qoivii Xmdm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5a02ee3f1a3f7cb9e83e2c06", "heatmap": "e-5a02ee3f1a3f7cb9e83e2c06", "modelKey": "event", "timestamp": null }, { "id": "059ff047a8934be0ed4a742a", "type": "sensorMotion", "start": 1643050809761, "end": 1643050809761, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Oackqe Xfynr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-059ff047a8934be0ed4a742a", "heatmap": "e-059ff047a8934be0ed4a742a", "modelKey": "event", "timestamp": null }, { "id": "acd872b8a53c4d00e4600c74", "type": "sensorMotion", "start": 1643050814228, "end": 1643050814228, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Uxjc Lcahyfo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-acd872b8a53c4d00e4600c74", "heatmap": "e-acd872b8a53c4d00e4600c74", "modelKey": "event", "timestamp": null }, { "id": "66c1d40fa83219e7c0091743", "type": "sensorMotion", "start": 1643050821562, "end": 1643050821562, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dtickug Rkydt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-66c1d40fa83219e7c0091743", "heatmap": "e-66c1d40fa83219e7c0091743", "modelKey": "event", "timestamp": null }, { "id": "496dca8ef7f1f5b13cf74194", "type": "sensorMotion", "start": 1643050825583, "end": 1643050825583, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Kliekus Tgkas" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-496dca8ef7f1f5b13cf74194", "heatmap": "e-496dca8ef7f1f5b13cf74194", "modelKey": "event", "timestamp": null }, { "id": "4f298de64f9b33207ad292fc", "type": "sensorMotion", "start": 1643050831218, "end": 1643050831218, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Odaoh Ncf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4f298de64f9b33207ad292fc", "heatmap": "e-4f298de64f9b33207ad292fc", "modelKey": "event", "timestamp": null }, { "id": "b52045d8da92f1896ce85e56", "type": "sensorMotion", "start": 1643050834567, "end": 1643050834567, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Nvfvfev Thligci" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b52045d8da92f1896ce85e56", "heatmap": "e-b52045d8da92f1896ce85e56", "modelKey": "event", "timestamp": null }, { "id": "9d912c9a7d111a3639a9f182", "type": "sensorMotion", "start": 1643050839891, "end": 1643050839891, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Qtzczh Pvtjs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-9d912c9a7d111a3639a9f182", "heatmap": "e-9d912c9a7d111a3639a9f182", "modelKey": "event", "timestamp": null }, { "id": "eda6d847a9714a85e811c162", "type": "sensorMotion", "start": 1643050852220, "end": 1643050852220, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ziljw Gnyrdn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-eda6d847a9714a85e811c162", "heatmap": "e-eda6d847a9714a85e811c162", "modelKey": "event", "timestamp": null }, { "id": "4906f6706e5337e32e88c916", "type": "sensorMotion", "start": 1643050856583, "end": 1643050856583, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ihnp Fetsg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4906f6706e5337e32e88c916", "heatmap": "e-4906f6706e5337e32e88c916", "modelKey": "event", "timestamp": null }, { "id": "95672e62180d58fc836ca1b7", "type": "sensorMotion", "start": 1643050879421, "end": 1643050879421, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ajbryen Wgnrju" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-95672e62180d58fc836ca1b7", "heatmap": "e-95672e62180d58fc836ca1b7", "modelKey": "event", "timestamp": null }, { "id": "1c03250f5dc28e42dc5e62b5", "type": "sensorMotion", "start": 1643050884909, "end": 1643050884909, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rznx Xnlc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1c03250f5dc28e42dc5e62b5", "heatmap": "e-1c03250f5dc28e42dc5e62b5", "modelKey": "event", "timestamp": null }, { "id": "cf093ab11848fe601293a99d", "type": "sensorMotion", "start": 1643050887627, "end": 1643050887627, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Hpp Mwkyxfz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-cf093ab11848fe601293a99d", "heatmap": "e-cf093ab11848fe601293a99d", "modelKey": "event", "timestamp": null }, { "id": "88a134d27b5acd4d55218d69", "type": "sensorMotion", "start": 1643050894709, "end": 1643050894709, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Yqevfm Bztfe" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-88a134d27b5acd4d55218d69", "heatmap": "e-88a134d27b5acd4d55218d69", "modelKey": "event", "timestamp": null }, { "id": "e77303a371d3e7a77d313a3a", "type": "sensorMotion", "start": 1643050900102, "end": 1643050900102, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ovv Edlluhq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e77303a371d3e7a77d313a3a", "heatmap": "e-e77303a371d3e7a77d313a3a", "modelKey": "event", "timestamp": null }, { "id": "5e3637b0fd1ae5169898d247", "type": "sensorMotion", "start": 1643050902682, "end": 1643050902682, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mkgq Weyhc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5e3637b0fd1ae5169898d247", "heatmap": "e-5e3637b0fd1ae5169898d247", "modelKey": "event", "timestamp": null }, { "id": "a6f1b5811011ca1da699603f", "type": "sensorMotion", "start": 1643050910788, "end": 1643050910788, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pjcfjpp Iujsh" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a6f1b5811011ca1da699603f", "heatmap": "e-a6f1b5811011ca1da699603f", "modelKey": "event", "timestamp": null }, { "id": "bdab302950429540ec1bd9cd", "type": "sensorMotion", "start": 1643050915424, "end": 1643050915424, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cmbf Wzyr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bdab302950429540ec1bd9cd", "heatmap": "e-bdab302950429540ec1bd9cd", "modelKey": "event", "timestamp": null }, { "id": "bdb141e1a47edceddda271c5", "type": "sensorMotion", "start": 1643050919673, "end": 1643050919673, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Imjy Njb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bdb141e1a47edceddda271c5", "heatmap": "e-bdb141e1a47edceddda271c5", "modelKey": "event", "timestamp": null }, { "id": "e4329cc823c27dc37aa45a0d", "type": "sensorMotion", "start": 1643050923694, "end": 1643050923694, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Vpxkpwb Invk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e4329cc823c27dc37aa45a0d", "heatmap": "e-e4329cc823c27dc37aa45a0d", "modelKey": "event", "timestamp": null }, { "id": "b3100edd145e6d6d13d7c007", "type": "sensorMotion", "start": 1643050928814, "end": 1643050928814, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Tnei Blqn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b3100edd145e6d6d13d7c007", "heatmap": "e-b3100edd145e6d6d13d7c007", "modelKey": "event", "timestamp": null }, { "id": "07aef2985e55dc3fd7a92484", "type": "sensorMotion", "start": 1643050933192, "end": 1643050933192, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Msuu Rjqvfbj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-07aef2985e55dc3fd7a92484", "heatmap": "e-07aef2985e55dc3fd7a92484", "modelKey": "event", "timestamp": null }, { "id": "a8fe97eb5c2d67c713d2b0ee", "type": "sensorMotion", "start": 1643050935896, "end": 1643050935896, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ysnf Azdiml" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a8fe97eb5c2d67c713d2b0ee", "heatmap": "e-a8fe97eb5c2d67c713d2b0ee", "modelKey": "event", "timestamp": null }, { "id": "4fce6344c8d16998803e92db", "type": "sensorMotion", "start": 1643050940289, "end": 1643050940289, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mrgqrm Tgp" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4fce6344c8d16998803e92db", "heatmap": "e-4fce6344c8d16998803e92db", "modelKey": "event", "timestamp": null }, { "id": "4d560226a20cd77bb0b24099", "type": "sensorMotion", "start": 1643050944308, "end": 1643050944308, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gjpl Vxv" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4d560226a20cd77bb0b24099", "heatmap": "e-4d560226a20cd77bb0b24099", "modelKey": "event", "timestamp": null }, { "id": "8ec6c6880ef39712c22dc560", "type": "sensorMotion", "start": 1643050965895, "end": 1643050965895, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Refvbz Czqrd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8ec6c6880ef39712c22dc560", "heatmap": "e-8ec6c6880ef39712c22dc560", "modelKey": "event", "timestamp": null }, { "id": "892abca3b22f28f9fee7f0c9", "type": "sensorMotion", "start": 1643050968728, "end": 1643050968728, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Zpjfc Piq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-892abca3b22f28f9fee7f0c9", "heatmap": "e-892abca3b22f28f9fee7f0c9", "modelKey": "event", "timestamp": null }, { "id": "5b642886f0b8a3f453aeeae6", "type": "sensorMotion", "start": 1643050972719, "end": 1643050972719, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cbhqtvt Datdfm" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5b642886f0b8a3f453aeeae6", "heatmap": "e-5b642886f0b8a3f453aeeae6", "modelKey": "event", "timestamp": null }, { "id": "a279a99a4b77bbc5880bd498", "type": "sensorMotion", "start": 1643050976839, "end": 1643050976839, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Bew Hrt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a279a99a4b77bbc5880bd498", "heatmap": "e-a279a99a4b77bbc5880bd498", "modelKey": "event", "timestamp": null }, { "id": "c5b6dead2f4ffa5877c26184", "type": "sensorMotion", "start": 1643050991648, "end": 1643050991648, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Jvucc Fdhch" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c5b6dead2f4ffa5877c26184", "heatmap": "e-c5b6dead2f4ffa5877c26184", "modelKey": "event", "timestamp": null }, { "id": "0c1b026ef03f0d5b320b7755", "type": "motion", "start": 1643050992642, "end": 1643051014997, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0c1b026ef03f0d5b320b7755", "heatmap": "e-0c1b026ef03f0d5b320b7755", "modelKey": "event", "timestamp": 1643051003809 }, { "id": "dc6718a6dd54639bbd552f76", "type": "sensorMotion", "start": 1643050998599, "end": 1643050998599, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Gbtqw Vxo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-dc6718a6dd54639bbd552f76", "heatmap": "e-dc6718a6dd54639bbd552f76", "modelKey": "event", "timestamp": null }, { "id": "8c23f88862fa14e491888c7d", "type": "smartDetectZone", "start": 1643051003635, "end": 1643051039747, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8c23f88862fa14e491888c7d", "heatmap": "e-8c23f88862fa14e491888c7d", "modelKey": "event", "timestamp": 1643051014635 }, { "id": "d641d2a03babd4ab428294ad", "type": "motion", "start": 1643051006311, "end": 1643051039818, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "8c23f88862fa14e491888c7d" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d641d2a03babd4ab428294ad", "heatmap": "e-d641d2a03babd4ab428294ad", "modelKey": "event", "timestamp": 1643051020812 }, { "id": "91d361a40390415512ca0879", "type": "sensorMotion", "start": 1643051013803, "end": 1643051013803, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Lxc Loakhxn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-91d361a40390415512ca0879", "heatmap": "e-91d361a40390415512ca0879", "modelKey": "event", "timestamp": null }, { "id": "8b7814a057ce666a7d4e1e10", "type": "sensorMotion", "start": 1643051017526, "end": 1643051017526, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Dubj Tgpea" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8b7814a057ce666a7d4e1e10", "heatmap": "e-8b7814a057ce666a7d4e1e10", "modelKey": "event", "timestamp": null }, { "id": "3b13eef2dd1750658cbcbbbe", "type": "sensorMotion", "start": 1643051022676, "end": 1643051022676, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ugeist Skdklct" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3b13eef2dd1750658cbcbbbe", "heatmap": "e-3b13eef2dd1750658cbcbbbe", "modelKey": "event", "timestamp": null }, { "id": "c74cb5fc409e9247a2cb6162", "type": "sensorMotion", "start": 1643051027182, "end": 1643051027182, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Niuqjco Sdgxtr" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c74cb5fc409e9247a2cb6162", "heatmap": "e-c74cb5fc409e9247a2cb6162", "modelKey": "event", "timestamp": null }, { "id": "362e855a9acdd341f7008a01", "type": "motion", "start": 1643051090846, "end": 1643051111845, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-362e855a9acdd341f7008a01", "heatmap": "e-362e855a9acdd341f7008a01", "modelKey": "event", "timestamp": 1643051003809 }, { "id": "c7451e5ced8dd8d511ac6bc5", "type": "access", "start": 1643051220245, "end": 1643051248534, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-c7451e5ced8dd8d511ac6bc5", "heatmap": "e-c7451e5ced8dd8d511ac6bc5", "modelKey": "event", "timestamp": null }, { "id": "bac57e01440d374ed29e6c42", "type": "motion", "start": 1643051224934, "end": 1643051246103, "score": 70, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bac57e01440d374ed29e6c42", "heatmap": "e-bac57e01440d374ed29e6c42", "modelKey": "event", "timestamp": 1643051236101 }, { "id": "62302386a66c10b483664b3c", "type": "motion", "start": 1643051446018, "end": 1643051467018, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-62302386a66c10b483664b3c", "heatmap": "e-62302386a66c10b483664b3c", "modelKey": "event", "timestamp": 1643051236101 }, { "id": "f4c8b762a0050b2c84f4e25d", "type": "access", "start": 1643051726135, "end": 1643051754321, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-f4c8b762a0050b2c84f4e25d", "heatmap": "e-f4c8b762a0050b2c84f4e25d", "modelKey": "event", "timestamp": null }, { "id": "d72d56d2822625546319b811", "type": "access", "start": 1643051780121, "end": 1643051783884, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-d72d56d2822625546319b811", "heatmap": "e-d72d56d2822625546319b811", "modelKey": "event", "timestamp": null }, { "id": "738360365585e537d12f81df", "type": "access", "start": 1643051831465, "end": 1643051859650, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-738360365585e537d12f81df", "heatmap": "e-738360365585e537d12f81df", "modelKey": "event", "timestamp": null }, { "id": "9bdd7e705f8d72c3b7e9ddfa", "type": "motion", "start": 1643051849405, "end": 1643051870571, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9bdd7e705f8d72c3b7e9ddfa", "heatmap": "e-9bdd7e705f8d72c3b7e9ddfa", "modelKey": "event", "timestamp": 1643049044771 }, { "id": "2069cd1e332a0c02d454aaa2", "type": "motion", "start": 1643051852654, "end": 1643051874156, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2069cd1e332a0c02d454aaa2", "heatmap": "e-2069cd1e332a0c02d454aaa2", "modelKey": "event", "timestamp": 1643051863820 }, { "id": "a6de77b02fa60d941d2c8ac8", "type": "motion", "start": 1643051966296, "end": 1643051987966, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a6de77b02fa60d941d2c8ac8", "heatmap": "e-a6de77b02fa60d941d2c8ac8", "modelKey": "event", "timestamp": 1643051977811 }, { "id": "bfc9ecfff91587fc82061689", "type": "motion", "start": 1643052007703, "end": 1643052029375, "score": 68, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bfc9ecfff91587fc82061689", "heatmap": "e-bfc9ecfff91587fc82061689", "modelKey": "event", "timestamp": 1643052018873 }, { "id": "64c493ad9773569f48ea294d", "type": "motion", "start": 1643052019274, "end": 1643052040944, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-64c493ad9773569f48ea294d", "heatmap": "e-64c493ad9773569f48ea294d", "modelKey": "event", "timestamp": 1643052030941 }, { "id": "f58f732e73e8cbefee0f6ed9", "type": "motion", "start": 1643052055517, "end": 1643052079869, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f58f732e73e8cbefee0f6ed9", "heatmap": "e-f58f732e73e8cbefee0f6ed9", "modelKey": "event", "timestamp": 1643052066685 }, { "id": "f4fa9899bb32355821756da5", "type": "motion", "start": 1643052125174, "end": 1643052146492, "score": 77, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f4fa9899bb32355821756da5", "heatmap": "e-f4fa9899bb32355821756da5", "modelKey": "event", "timestamp": 1643052136322 }, { "id": "42d1dad4d44d5320c65d6439", "type": "motion", "start": 1643052128248, "end": 1643052149581, "score": 10, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-42d1dad4d44d5320c65d6439", "heatmap": "e-42d1dad4d44d5320c65d6439", "modelKey": "event", "timestamp": 1643049044771 }, { "id": "b8697707c3ad6b6b5a52470c", "type": "motion", "start": 1643052182963, "end": 1643052206270, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b8697707c3ad6b6b5a52470c", "heatmap": "e-b8697707c3ad6b6b5a52470c", "modelKey": "event", "timestamp": 1643052194104 }, { "id": "820f224d4d3720d9e61972b9", "type": "motion", "start": 1643052367921, "end": 1643052389259, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-820f224d4d3720d9e61972b9", "heatmap": "e-820f224d4d3720d9e61972b9", "modelKey": "event", "timestamp": 1643052379111 }, { "id": "1ea08ccfa9e0d5687cb33404", "type": "motion", "start": 1643052429874, "end": 1643052451560, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1ea08ccfa9e0d5687cb33404", "heatmap": "e-1ea08ccfa9e0d5687cb33404", "modelKey": "event", "timestamp": 1643052441544 }, { "id": "c44f2e8b9cde149d01ee2e42", "type": "motion", "start": 1643052439084, "end": 1643052461252, "score": 75, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c44f2e8b9cde149d01ee2e42", "heatmap": "e-c44f2e8b9cde149d01ee2e42", "modelKey": "event", "timestamp": 1643052451087 }, { "id": "815f5c8c18622d68c4892468", "type": "motion", "start": 1643052581656, "end": 1643052602826, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-815f5c8c18622d68c4892468", "heatmap": "e-815f5c8c18622d68c4892468", "modelKey": "event", "timestamp": 1643052592822 }, { "id": "2d8c0d40412d5d3bc8ce0990", "type": "motion", "start": 1643052607396, "end": 1643052628907, "score": 95, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-2d8c0d40412d5d3bc8ce0990", "heatmap": "e-2d8c0d40412d5d3bc8ce0990", "modelKey": "event", "timestamp": 1643052618897 }, { "id": "93bc0d7c525356dc3b1304af", "type": "motion", "start": 1643052886428, "end": 1643052908266, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-93bc0d7c525356dc3b1304af", "heatmap": "e-93bc0d7c525356dc3b1304af", "modelKey": "event", "timestamp": 1643052897596 }, { "id": "97686c9a8ac9d62b3ca99064", "type": "motion", "start": 1643052900462, "end": 1643052921963, "score": 92, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-97686c9a8ac9d62b3ca99064", "heatmap": "e-97686c9a8ac9d62b3ca99064", "modelKey": "event", "timestamp": 1643052911961 }, { "id": "35201f7b1d057bfde724d955", "type": "sensorMotion", "start": 1643052938897, "end": 1643052938897, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ebhix Yxrs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-35201f7b1d057bfde724d955", "heatmap": "e-35201f7b1d057bfde724d955", "modelKey": "event", "timestamp": null }, { "id": "79a7875bbe152d5723277fdf", "type": "motion", "start": 1643052939578, "end": 1643052960914, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-79a7875bbe152d5723277fdf", "heatmap": "e-79a7875bbe152d5723277fdf", "modelKey": "event", "timestamp": 1643052950912 }, { "id": "a5053650cf38fcc579aab665", "type": "motion", "start": 1643052945052, "end": 1643052967202, "score": 87, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a5053650cf38fcc579aab665", "heatmap": "e-a5053650cf38fcc579aab665", "modelKey": "event", "timestamp": 1643052956200 }, { "id": "08baf052cf4196976cf71132", "type": "motion", "start": 1643052947910, "end": 1643052969603, "score": 3, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-08baf052cf4196976cf71132", "heatmap": "e-08baf052cf4196976cf71132", "modelKey": "event", "timestamp": 1643052950912 }, { "id": "cd62e5d1ee4eb2d4510df9dd", "type": "motion", "start": 1643053070434, "end": 1643053092273, "score": 95, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cd62e5d1ee4eb2d4510df9dd", "heatmap": "e-cd62e5d1ee4eb2d4510df9dd", "modelKey": "event", "timestamp": 1643053082267 }, { "id": "f907cd94a88c081c752ffcad", "type": "motion", "start": 1643053082503, "end": 1643053106521, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-f907cd94a88c081c752ffcad", "heatmap": "e-f907cd94a88c081c752ffcad", "modelKey": "event", "timestamp": 1643053093669 }, { "id": "ff028ccf68dfabcf4b353ae6", "type": "motion", "start": 1643053086850, "end": 1643053108019, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ff028ccf68dfabcf4b353ae6", "heatmap": "e-ff028ccf68dfabcf4b353ae6", "modelKey": "event", "timestamp": 1643053098017 }, { "id": "920abbf0f89eb5ca91ff383e", "type": "motion", "start": 1643053087589, "end": 1643053109107, "score": 10, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-920abbf0f89eb5ca91ff383e", "heatmap": "e-920abbf0f89eb5ca91ff383e", "modelKey": "event", "timestamp": 1643052950912 }, { "id": "9b90fde17804ffe1cf79885e", "type": "motion", "start": 1643053276401, "end": 1643053298404, "score": 91, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9b90fde17804ffe1cf79885e", "heatmap": "e-9b90fde17804ffe1cf79885e", "modelKey": "event", "timestamp": 1643053287569 }, { "id": "1e1e8e1c973d920ee7843a3a", "type": "motion", "start": 1643053294807, "end": 1643053317333, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1e1e8e1c973d920ee7843a3a", "heatmap": "e-1e1e8e1c973d920ee7843a3a", "modelKey": "event", "timestamp": 1643053305975 }, { "id": "76b2b3cc8750a0a129d7819c", "type": "motion", "start": 1643053298450, "end": 1643053319969, "score": 8, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-76b2b3cc8750a0a129d7819c", "heatmap": "e-76b2b3cc8750a0a129d7819c", "modelKey": "event", "timestamp": 1643052950912 }, { "id": "6b798e2f404909399fd1fe9f", "type": "disconnect", "start": 1643053355358, "end": 1643053362720, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "4a333d993fe8e2e8472bc901", "partition": null, "user": null, "metadata": { "reason": "CONNECTION_LOSS" }, "thumbnail": "e-6b798e2f404909399fd1fe9f", "heatmap": "e-6b798e2f404909399fd1fe9f", "modelKey": "event", "timestamp": null }, { "id": "c1bf3ee329dad6aae2887831", "type": "disconnect", "start": 1643053355422, "end": 1643053363029, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "f0cd15b8bed9e38899286a8c", "partition": null, "user": null, "metadata": { "reason": "CONNECTION_LOSS" }, "thumbnail": "e-c1bf3ee329dad6aae2887831", "heatmap": "e-c1bf3ee329dad6aae2887831", "modelKey": "event", "timestamp": null }, { "id": "1a493b780595e6f80ebd1941", "type": "disconnect", "start": 1643053359378, "end": 1643053373345, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "ab3e27f2d55fad817dac7bb9", "partition": null, "user": null, "metadata": { "reason": "CONNECTION_LOSS" }, "thumbnail": "e-1a493b780595e6f80ebd1941", "heatmap": "e-1a493b780595e6f80ebd1941", "modelKey": "event", "timestamp": null }, { "id": "bc2b177ea45d42a0a38e847f", "type": "motion", "start": 1643053507966, "end": 1643053529139, "score": 92, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bc2b177ea45d42a0a38e847f", "heatmap": "e-bc2b177ea45d42a0a38e847f", "modelKey": "event", "timestamp": 1643053519133 }, { "id": "c5ab1bdee3240243d3f16cf4", "type": "motion", "start": 1643053553567, "end": 1643053575399, "score": 90, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c5ab1bdee3240243d3f16cf4", "heatmap": "e-c5ab1bdee3240243d3f16cf4", "modelKey": "event", "timestamp": 1643053565374 }, { "id": "ddd7747c18fa81be4cc632ed", "type": "motion", "start": 1643053563426, "end": 1643053584429, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ddd7747c18fa81be4cc632ed", "heatmap": "e-ddd7747c18fa81be4cc632ed", "modelKey": "event", "timestamp": 1643053565374 }, { "id": "7e36d50ee5c6404464339bf6", "type": "motion", "start": 1643053620678, "end": 1643053642181, "score": 84, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-7e36d50ee5c6404464339bf6", "heatmap": "e-7e36d50ee5c6404464339bf6", "modelKey": "event", "timestamp": 1643053632178 }, { "id": "8e4a7d35cc3f42b0451c6991", "type": "motion", "start": 1643053728845, "end": 1643053749845, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8e4a7d35cc3f42b0451c6991", "heatmap": "e-8e4a7d35cc3f42b0451c6991", "modelKey": "event", "timestamp": 1643053632178 }, { "id": "caec8a02af526ec31aa704a8", "type": "motion", "start": 1643053812976, "end": 1643053834310, "score": 87, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-caec8a02af526ec31aa704a8", "heatmap": "e-caec8a02af526ec31aa704a8", "modelKey": "event", "timestamp": 1643053824143 }, { "id": "079d552683bae554c5d8e6b6", "type": "motion", "start": 1643053879742, "end": 1643053903746, "score": 98, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-079d552683bae554c5d8e6b6", "heatmap": "e-079d552683bae554c5d8e6b6", "modelKey": "event", "timestamp": 1643053892577 }, { "id": "b465384309318dc68d41bb94", "type": "motion", "start": 1643053912880, "end": 1643053934559, "score": 92, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b465384309318dc68d41bb94", "heatmap": "e-b465384309318dc68d41bb94", "modelKey": "event", "timestamp": 1643053924223 }, { "id": "5958e375e9a7943e170bc7d6", "type": "motion", "start": 1643053917647, "end": 1643053940326, "score": 64, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5958e375e9a7943e170bc7d6", "heatmap": "e-5958e375e9a7943e170bc7d6", "modelKey": "event", "timestamp": 1643053929312 }, { "id": "9e7e01b904f1536af43633ab", "type": "motion", "start": 1643053961265, "end": 1643053989271, "score": 94, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9e7e01b904f1536af43633ab", "heatmap": "e-9e7e01b904f1536af43633ab", "modelKey": "event", "timestamp": 1643053975934 }, { "id": "3b46cac1f3afab7c7e5b7d85", "type": "motion", "start": 1643053962702, "end": 1643053985233, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-3b46cac1f3afab7c7e5b7d85", "heatmap": "e-3b46cac1f3afab7c7e5b7d85", "modelKey": "event", "timestamp": 1643053973870 }, { "id": "d7d3e1df973355a573cc5a88", "type": "motion", "start": 1643053965375, "end": 1643053989533, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d7d3e1df973355a573cc5a88", "heatmap": "e-d7d3e1df973355a573cc5a88", "modelKey": "event", "timestamp": 1643053976535 }, { "id": "81c40abf395a6d6008edd44c", "type": "motion", "start": 1643053993113, "end": 1643054014971, "score": 95, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-81c40abf395a6d6008edd44c", "heatmap": "e-81c40abf395a6d6008edd44c", "modelKey": "event", "timestamp": 1643054004301 }, { "id": "a06e149794fbe9a33cf8c3ed", "type": "motion", "start": 1643054293279, "end": 1643054316444, "score": 12, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a06e149794fbe9a33cf8c3ed", "heatmap": "e-a06e149794fbe9a33cf8c3ed", "modelKey": "event", "timestamp": 1643053976535 }, { "id": "bd080016644d6954508952b7", "type": "motion", "start": 1643054602210, "end": 1643054623210, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bd080016644d6954508952b7", "heatmap": "e-bd080016644d6954508952b7", "modelKey": "event", "timestamp": 1643054004301 }, { "id": "e32c7fd06ae4abf236f95ad0", "type": "motion", "start": 1643054612223, "end": 1643054633733, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e32c7fd06ae4abf236f95ad0", "heatmap": "e-e32c7fd06ae4abf236f95ad0", "modelKey": "event", "timestamp": 1643054623723 }, { "id": "5a604ea7861254a944bd63b3", "type": "sensorMotion", "start": 1643054627462, "end": 1643054627462, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qkxnsvp Lrotrn" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-5a604ea7861254a944bd63b3", "heatmap": "e-5a604ea7861254a944bd63b3", "modelKey": "event", "timestamp": null }, { "id": "0ce04347e042445c503e6f14", "type": "motion", "start": 1643054663150, "end": 1643054684320, "score": 91, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0ce04347e042445c503e6f14", "heatmap": "e-0ce04347e042445c503e6f14", "modelKey": "event", "timestamp": 1643054674317 }, { "id": "1bf0b115d5d7c0596601eb23", "type": "motion", "start": 1643054742302, "end": 1643054766147, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1bf0b115d5d7c0596601eb23", "heatmap": "e-1bf0b115d5d7c0596601eb23", "modelKey": "event", "timestamp": 1643054755639 }, { "id": "5088f5afd788114524a81887", "type": "motion", "start": 1643054849864, "end": 1643054870868, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-5088f5afd788114524a81887", "heatmap": "e-5088f5afd788114524a81887", "modelKey": "event", "timestamp": 1643054755639 }, { "id": "a6e02c235d3d47486f583618", "type": "motion", "start": 1643054851265, "end": 1643054880434, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a6e02c235d3d47486f583618", "heatmap": "e-a6e02c235d3d47486f583618", "modelKey": "event", "timestamp": 1643054865768 }, { "id": "141f87a3241c063a599f488e", "type": "sensorMotion", "start": 1643054863294, "end": 1643054863294, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Csa Jeamad" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-141f87a3241c063a599f488e", "heatmap": "e-141f87a3241c063a599f488e", "modelKey": "event", "timestamp": null }, { "id": "eca22cd738a7e97c8aeae8d1", "type": "sensorMotion", "start": 1643054867543, "end": 1643054867543, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Fquw Disnd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-eca22cd738a7e97c8aeae8d1", "heatmap": "e-eca22cd738a7e97c8aeae8d1", "modelKey": "event", "timestamp": null }, { "id": "39a68c8c8c57dbef9560fcd2", "type": "motion", "start": 1643054873017, "end": 1643054896194, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-39a68c8c8c57dbef9560fcd2", "heatmap": "e-39a68c8c8c57dbef9560fcd2", "modelKey": "event", "timestamp": 1643054884185 }, { "id": "b4670d0a0400e717aa7b41ef", "type": "sensorMotion", "start": 1643054881963, "end": 1643054881963, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Jwmrk Idi" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b4670d0a0400e717aa7b41ef", "heatmap": "e-b4670d0a0400e717aa7b41ef", "modelKey": "event", "timestamp": null }, { "id": "6afa39e93c7675715187d3a5", "type": "motion", "start": 1643055002332, "end": 1643055023998, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6afa39e93c7675715187d3a5", "heatmap": "e-6afa39e93c7675715187d3a5", "modelKey": "event", "timestamp": 1643054755639 }, { "id": "436a7bfb6de6a84e75878125", "type": "motion", "start": 1643055051296, "end": 1643055072965, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-436a7bfb6de6a84e75878125", "heatmap": "e-436a7bfb6de6a84e75878125", "modelKey": "event", "timestamp": 1643054755639 }, { "id": "299b8fc031e1f206e8b9c6c8", "type": "motion", "start": 1643055066163, "end": 1643055088511, "score": 78, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-299b8fc031e1f206e8b9c6c8", "heatmap": "e-299b8fc031e1f206e8b9c6c8", "modelKey": "event", "timestamp": 1643055077329 }, { "id": "aecb3f1eb4bb4e9b9dfa9500", "type": "motion", "start": 1643055069365, "end": 1643055091363, "score": 88, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-aecb3f1eb4bb4e9b9dfa9500", "heatmap": "e-aecb3f1eb4bb4e9b9dfa9500", "modelKey": "event", "timestamp": 1643055080532 }, { "id": "988b9f933bb8b0f88613cf78", "type": "access", "start": 1643055115307, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-988b9f933bb8b0f88613cf78", "heatmap": "e-988b9f933bb8b0f88613cf78", "modelKey": "event", "timestamp": null }, { "id": "b094781c1e975be1afaf4f9b", "type": "motion", "start": 1643055132162, "end": 1643055154664, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b094781c1e975be1afaf4f9b", "heatmap": "e-b094781c1e975be1afaf4f9b", "modelKey": "event", "timestamp": 1643055143329 }, { "id": "ca411e12910e5a207b9d25bd", "type": "motion", "start": 1643055205892, "end": 1643055235060, "score": 65, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ca411e12910e5a207b9d25bd", "heatmap": "e-ca411e12910e5a207b9d25bd", "modelKey": "event", "timestamp": 1643055218725 }, { "id": "2904f432b76761dc659024eb", "type": "sensorMotion", "start": 1643055213104, "end": 1643055213104, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Yab Koad" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-2904f432b76761dc659024eb", "heatmap": "e-2904f432b76761dc659024eb", "modelKey": "event", "timestamp": null }, { "id": "d9499661def27e2e22235a4c", "type": "sensorMotion", "start": 1643055218511, "end": 1643055218511, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Mbtl Aozuk" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d9499661def27e2e22235a4c", "heatmap": "e-d9499661def27e2e22235a4c", "modelKey": "event", "timestamp": null }, { "id": "525146b492fcec280faba9f5", "type": "sensorMotion", "start": 1643055223661, "end": 1643055223661, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Mkf Zhuamg" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-525146b492fcec280faba9f5", "heatmap": "e-525146b492fcec280faba9f5", "modelKey": "event", "timestamp": null }, { "id": "96cf40d76e90d0b90b7d5b54", "type": "motion", "start": 1643055233458, "end": 1643055281958, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-96cf40d76e90d0b90b7d5b54", "heatmap": "e-96cf40d76e90d0b90b7d5b54", "modelKey": "event", "timestamp": 1643055253291 }, { "id": "47ca7df750f2db3f6411e8c2", "type": "sensorMotion", "start": 1643055244133, "end": 1643055244133, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Idi Vozqal" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-47ca7df750f2db3f6411e8c2", "heatmap": "e-47ca7df750f2db3f6411e8c2", "modelKey": "event", "timestamp": null }, { "id": "b2d82ccf0091e431544abb2e", "type": "sensorMotion", "start": 1643055249169, "end": 1643055249169, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qxecmf Gmgy" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-b2d82ccf0091e431544abb2e", "heatmap": "e-b2d82ccf0091e431544abb2e", "modelKey": "event", "timestamp": null }, { "id": "64e1016e325ad497ad734ec7", "type": "sensorMotion", "start": 1643055254304, "end": 1643055254304, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qtvm Jnkt" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-64e1016e325ad497ad734ec7", "heatmap": "e-64e1016e325ad497ad734ec7", "modelKey": "event", "timestamp": null }, { "id": "8b9bc5f044c4fa356aa7f36b", "type": "sensorMotion", "start": 1643055259197, "end": 1643055259197, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Cph Zyje" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8b9bc5f044c4fa356aa7f36b", "heatmap": "e-8b9bc5f044c4fa356aa7f36b", "modelKey": "event", "timestamp": null }, { "id": "7d8c66b17e274d199ab2a9b3", "type": "sensorMotion", "start": 1643055266793, "end": 1643055266793, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qjemjj Kvej" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-7d8c66b17e274d199ab2a9b3", "heatmap": "e-7d8c66b17e274d199ab2a9b3", "modelKey": "event", "timestamp": null }, { "id": "607e9ae058d9ca04999216c6", "type": "sensorMotion", "start": 1643055280200, "end": 1643055280200, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hxgrpa Dsddgw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-607e9ae058d9ca04999216c6", "heatmap": "e-607e9ae058d9ca04999216c6", "modelKey": "event", "timestamp": null }, { "id": "e97bb0b257b30babeb8ad025", "type": "sensorMotion", "start": 1643055308124, "end": 1643055308124, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Iwizxxk Oqzyd" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e97bb0b257b30babeb8ad025", "heatmap": "e-e97bb0b257b30babeb8ad025", "modelKey": "event", "timestamp": null }, { "id": "f6bb8676252bae9c818e3356", "type": "sensorMotion", "start": 1643055318168, "end": 1643055318168, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Scmjfji Plovq" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-f6bb8676252bae9c818e3356", "heatmap": "e-f6bb8676252bae9c818e3356", "modelKey": "event", "timestamp": null }, { "id": "dfc2f5934ce355ed2bce67c8", "type": "sensorMotion", "start": 1643055329768, "end": 1643055329768, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Bnqey Bffydzc" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-dfc2f5934ce355ed2bce67c8", "heatmap": "e-dfc2f5934ce355ed2bce67c8", "modelKey": "event", "timestamp": null }, { "id": "bb64b41a56f3576d0c76bbca", "type": "motion", "start": 1643055332076, "end": 1643055362906, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-bb64b41a56f3576d0c76bbca", "heatmap": "e-bb64b41a56f3576d0c76bbca", "modelKey": "event", "timestamp": 1643055351573 }, { "id": "f1e6540f70c74dc2b38ae8cc", "type": "sensorOpened", "start": 1643055342130, "end": 1643055342130, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Yopekmp Wmr" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-f1e6540f70c74dc2b38ae8cc", "heatmap": "e-f1e6540f70c74dc2b38ae8cc", "modelKey": "event", "timestamp": null }, { "id": "1a0ff6b39154c4630db1de1f", "type": "sensorMotion", "start": 1643055343144, "end": 1643055343144, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ldzva Mbbagj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1a0ff6b39154c4630db1de1f", "heatmap": "e-1a0ff6b39154c4630db1de1f", "modelKey": "event", "timestamp": null }, { "id": "ed344a69c0e14cc6b186470d", "type": "motion", "start": 1643055351675, "end": 1643055377338, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ed344a69c0e14cc6b186470d", "heatmap": "e-ed344a69c0e14cc6b186470d", "modelKey": "event", "timestamp": 1643055363008 }, { "id": "4e8aac11f406542b105cfa54", "type": "motion", "start": 1643055384498, "end": 1643055405831, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-4e8aac11f406542b105cfa54", "heatmap": "e-4e8aac11f406542b105cfa54", "modelKey": "event", "timestamp": 1643055395664 }, { "id": "6883f7f7381645ea50d62db8", "type": "sensorMotion", "start": 1643055390397, "end": 1643055390397, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Nbfnis Lrnwhf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6883f7f7381645ea50d62db8", "heatmap": "e-6883f7f7381645ea50d62db8", "modelKey": "event", "timestamp": null }, { "id": "af1beccd369bbcdf85e5ce09", "type": "sensorClosed", "start": 1643055394532, "end": 1643055394532, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Lfy Zemzosr" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-af1beccd369bbcdf85e5ce09", "heatmap": "e-af1beccd369bbcdf85e5ce09", "modelKey": "event", "timestamp": null }, { "id": "155d410caaa8f4a8354b3c7a", "type": "sensorMotion", "start": 1643055395291, "end": 1643055395291, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Gfixk Blj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-155d410caaa8f4a8354b3c7a", "heatmap": "e-155d410caaa8f4a8354b3c7a", "modelKey": "event", "timestamp": null }, { "id": "c409d74eaa81ee320a5d6d7b", "type": "sensorMotion", "start": 1643055399296, "end": 1643055399296, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Amdwoc Ygw" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-c409d74eaa81ee320a5d6d7b", "heatmap": "e-c409d74eaa81ee320a5d6d7b", "modelKey": "event", "timestamp": null }, { "id": "d9728ec0394b2c362bd8d82d", "type": "motion", "start": 1643055422875, "end": 1643055445206, "score": 74, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-d9728ec0394b2c362bd8d82d", "heatmap": "e-d9728ec0394b2c362bd8d82d", "modelKey": "event", "timestamp": 1643055434042 }, { "id": "fd027ebb11edf1a5d4e02917", "type": "motion", "start": 1643055425997, "end": 1643055447656, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "c462c07dbd63ad805a7318c7", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-fd027ebb11edf1a5d4e02917", "heatmap": "e-fd027ebb11edf1a5d4e02917", "modelKey": "event", "timestamp": 1643055437156 }, { "id": "ebe0880538a65ae729465964", "type": "motion", "start": 1643055428814, "end": 1643055481335, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "cae56878dda06c28448931ef" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-ebe0880538a65ae729465964", "heatmap": "e-ebe0880538a65ae729465964", "modelKey": "event", "timestamp": 1643055471170 }, { "id": "c2257bbb62739a5aede83843", "type": "motion", "start": 1643055430117, "end": 1643055463780, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c2257bbb62739a5aede83843", "heatmap": "e-c2257bbb62739a5aede83843", "modelKey": "event", "timestamp": 1643055441623 }, { "id": "83678f9d98618da78f49c14a", "type": "motion", "start": 1643055433452, "end": 1643055473416, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "1f7ba6f81d4e5b3bce4f92ee" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-83678f9d98618da78f49c14a", "heatmap": "e-83678f9d98618da78f49c14a", "modelKey": "event", "timestamp": 1643055452223 }, { "id": "d8da1fbc5f116d25d25474dc", "type": "sensorMotion", "start": 1643055435332, "end": 1643055435332, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Tnotfga Qcdcvo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-d8da1fbc5f116d25d25474dc", "heatmap": "e-d8da1fbc5f116d25d25474dc", "modelKey": "event", "timestamp": null }, { "id": "1f7ba6f81d4e5b3bce4f92ee", "type": "smartDetectZone", "start": 1643055436123, "end": 1643055472745, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-1f7ba6f81d4e5b3bce4f92ee", "heatmap": "e-1f7ba6f81d4e5b3bce4f92ee", "modelKey": "event", "timestamp": 1643055452223 }, { "id": "18316f8b1857cba3bb51f24d", "type": "smartDetectZone", "start": 1643055436342, "end": 1643055469329, "score": 85, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-18316f8b1857cba3bb51f24d", "heatmap": "e-18316f8b1857cba3bb51f24d", "modelKey": "event", "timestamp": 1643055456706 }, { "id": "6a5d5803f2fc20b5c63b3b12", "type": "motion", "start": 1643055439053, "end": 1643055469070, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "18316f8b1857cba3bb51f24d" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6a5d5803f2fc20b5c63b3b12", "heatmap": "e-6a5d5803f2fc20b5c63b3b12", "modelKey": "event", "timestamp": 1643055451891 }, { "id": "2ca51eabafc9285919d3dbfd", "type": "sensorOpened", "start": 1643055439709, "end": 1643055439709, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Fwaa Xquqcs" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-2ca51eabafc9285919d3dbfd", "heatmap": "e-2ca51eabafc9285919d3dbfd", "modelKey": "event", "timestamp": null }, { "id": "e33bbe7801d3fb62b2a5ce87", "type": "sensorMotion", "start": 1643055439725, "end": 1643055439725, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Rrlhrj Mbe" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-e33bbe7801d3fb62b2a5ce87", "heatmap": "e-e33bbe7801d3fb62b2a5ce87", "modelKey": "event", "timestamp": null }, { "id": "1830c4fcfc98cb8679a7f5a7", "type": "sensorMotion", "start": 1643055439875, "end": 1643055439875, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Rochjgn Dbojwpo" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1830c4fcfc98cb8679a7f5a7", "heatmap": "e-1830c4fcfc98cb8679a7f5a7", "modelKey": "event", "timestamp": null }, { "id": "24032a6cd303b586ee2cdcf7", "type": "sensorOpened", "start": 1643055439992, "end": 1643055439992, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pos Kdweeq" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-24032a6cd303b586ee2cdcf7", "heatmap": "e-24032a6cd303b586ee2cdcf7", "modelKey": "event", "timestamp": null }, { "id": "cae56878dda06c28448931ef", "type": "smartDetectZone", "start": 1643055443840, "end": 1643055499503, "score": 95, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-cae56878dda06c28448931ef", "heatmap": "e-cae56878dda06c28448931ef", "modelKey": "event", "timestamp": 1643055455296 }, { "id": "b4db9f07c10c6b10345f9e7f", "type": "ring", "start": 1643055447318, "end": 1643055448318, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b4db9f07c10c6b10345f9e7f", "heatmap": "e-b4db9f07c10c6b10345f9e7f", "modelKey": "event", "timestamp": null }, { "id": "45240b20222327acefef906d", "type": "sensorOpened", "start": 1643055448119, "end": 1643055448119, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "20b1e28b9a5fbdf1060bf7d8" }, "sensorName": { "text": "Bsa Ickshd" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "window" } }, "thumbnail": "e-45240b20222327acefef906d", "heatmap": "e-45240b20222327acefef906d", "modelKey": "event", "timestamp": null }, { "id": "e6f18a416896510c4f6bba81", "type": "sensorClosed", "start": 1643055451596, "end": 1643055451596, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "20b1e28b9a5fbdf1060bf7d8" }, "sensorName": { "text": "Skjw Yurgmz" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "window" } }, "thumbnail": "e-e6f18a416896510c4f6bba81", "heatmap": "e-e6f18a416896510c4f6bba81", "modelKey": "event", "timestamp": null }, { "id": "ca4420000dd74bb22e256436", "type": "sensorMotion", "start": 1643055459060, "end": 1643055459060, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cfzngh Rqqra" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-ca4420000dd74bb22e256436", "heatmap": "e-ca4420000dd74bb22e256436", "modelKey": "event", "timestamp": null }, { "id": "0f55d95abc0a3d18c0851204", "type": "lightMotion", "start": 1643055459357, "end": 1643055459357, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-0f55d95abc0a3d18c0851204", "heatmap": "e-0f55d95abc0a3d18c0851204", "modelKey": "event", "timestamp": null }, { "id": "6ca308338938954b596dec93", "type": "sensorMotion", "start": 1643055462604, "end": 1643055462604, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Soktlm Slxb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-6ca308338938954b596dec93", "heatmap": "e-6ca308338938954b596dec93", "modelKey": "event", "timestamp": null }, { "id": "468a5ac35556ad5f4e81cdda", "type": "motion", "start": 1643055472336, "end": 1643055499336, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "cae56878dda06c28448931ef" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-468a5ac35556ad5f4e81cdda", "heatmap": "e-468a5ac35556ad5f4e81cdda", "modelKey": "event", "timestamp": 1643055483669 }, { "id": "b1f6828f0778ee11793241f9", "type": "smartDetectZone", "start": 1643055475782, "end": 1643055507694, "score": 83, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b1f6828f0778ee11793241f9", "heatmap": "e-b1f6828f0778ee11793241f9", "modelKey": "event", "timestamp": 1643055497338 }, { "id": "6b38b014d17c6ef8bcc68fad", "type": "motion", "start": 1643055475795, "end": 1643055509171, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "b1f6828f0778ee11793241f9" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-6b38b014d17c6ef8bcc68fad", "heatmap": "e-6b38b014d17c6ef8bcc68fad", "modelKey": "event", "timestamp": 1643055491479 }, { "id": "e46822b9b3d12873255725a5", "type": "smartDetectZone", "start": 1643055476431, "end": 1643055508456, "score": 90, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e46822b9b3d12873255725a5", "heatmap": "e-e46822b9b3d12873255725a5", "modelKey": "event", "timestamp": 1643055493110 }, { "id": "c7f20c2b5645a5847a79d0fa", "type": "motion", "start": 1643055478772, "end": 1643055513295, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [ "e46822b9b3d12873255725a5" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-c7f20c2b5645a5847a79d0fa", "heatmap": "e-c7f20c2b5645a5847a79d0fa", "modelKey": "event", "timestamp": 1643055493110 }, { "id": "ba06e097e51a5ce729aa16e3", "type": "lightMotion", "start": 1643055484857, "end": 1643055484857, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-ba06e097e51a5ce729aa16e3", "heatmap": "e-ba06e097e51a5ce729aa16e3", "modelKey": "event", "timestamp": null }, { "id": "3192d319f3d19bf3d4404d23", "type": "sensorMotion", "start": 1643055486355, "end": 1643055486355, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Myo Seggb" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-3192d319f3d19bf3d4404d23", "heatmap": "e-3192d319f3d19bf3d4404d23", "modelKey": "event", "timestamp": null }, { "id": "0002da2371731c382439c36b", "type": "motion", "start": 1643055500866, "end": 1643055533866, "score": 76, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-0002da2371731c382439c36b", "heatmap": "e-0002da2371731c382439c36b", "modelKey": "event", "timestamp": 1643055518868 }, { "id": "be77a38fa3173618810fd085", "type": "motion", "start": 1643055506494, "end": 1643055537856, "score": 89, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-be77a38fa3173618810fd085", "heatmap": "e-be77a38fa3173618810fd085", "modelKey": "event", "timestamp": 1643055519494 }, { "id": "e613ac4cf59f8633a305ab59", "type": "motion", "start": 1643055520862, "end": 1643055542532, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e613ac4cf59f8633a305ab59", "heatmap": "e-e613ac4cf59f8633a305ab59", "modelKey": "event", "timestamp": 1643055532528 }, { "id": "91af7e8b1224684cad660d91", "type": "motion", "start": 1643055524709, "end": 1643055545876, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-91af7e8b1224684cad660d91", "heatmap": "e-91af7e8b1224684cad660d91", "modelKey": "event", "timestamp": 1643055519494 }, { "id": "a9ebb0891064af1c95a2df66", "type": "motion", "start": 1643055538147, "end": 1643055575971, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-a9ebb0891064af1c95a2df66", "heatmap": "e-a9ebb0891064af1c95a2df66", "modelKey": "event", "timestamp": 1643055556144 }, { "id": "1ba92829cd9f0f3ffe82d00a", "type": "sensorMotion", "start": 1643055545288, "end": 1643055545288, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Tjpdmqf Ucjs" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-1ba92829cd9f0f3ffe82d00a", "heatmap": "e-1ba92829cd9f0f3ffe82d00a", "modelKey": "event", "timestamp": null }, { "id": "22e772869c106dcb512c7226", "type": "sensorClosed", "start": 1643055549536, "end": 1643055549536, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Jrddx Urr" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-22e772869c106dcb512c7226", "heatmap": "e-22e772869c106dcb512c7226", "modelKey": "event", "timestamp": null }, { "id": "90f051ebf085214d331644a5", "type": "motion", "start": 1643055550167, "end": 1643055573501, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-90f051ebf085214d331644a5", "heatmap": "e-90f051ebf085214d331644a5", "modelKey": "event", "timestamp": 1643055561334 }, { "id": "bd5164750940a0864e08f87f", "type": "sensorMotion", "start": 1643055550567, "end": 1643055550567, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Twh Pvdivz" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-bd5164750940a0864e08f87f", "heatmap": "e-bd5164750940a0864e08f87f", "modelKey": "event", "timestamp": null }, { "id": "9299f143c03dd92af383bae6", "type": "sensorClosed", "start": 1643055553059, "end": 1643055553059, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ftc Trrp" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-9299f143c03dd92af383bae6", "heatmap": "e-9299f143c03dd92af383bae6", "modelKey": "event", "timestamp": null }, { "id": "25fe763b2b6b1d031e0eff17", "type": "sensorMotion", "start": 1643055555239, "end": 1643055555239, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Arq Xul" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-25fe763b2b6b1d031e0eff17", "heatmap": "e-25fe763b2b6b1d031e0eff17", "modelKey": "event", "timestamp": null }, { "id": "8d9d6bcd2deca4bae4a324b6", "type": "sensorMotion", "start": 1643055556505, "end": 1643055556505, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Hczjj Ahjbwgf" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-8d9d6bcd2deca4bae4a324b6", "heatmap": "e-8d9d6bcd2deca4bae4a324b6", "modelKey": "event", "timestamp": null }, { "id": "16670a85458b8d00d0a1af9c", "type": "access", "start": 1643055662539, "end": 1643055664550, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-16670a85458b8d00d0a1af9c", "heatmap": "e-16670a85458b8d00d0a1af9c", "modelKey": "event", "timestamp": null }, { "id": "265f46078ef81ee2798b01ff", "type": "access", "start": 1643055685331, "end": 1643055724820, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-265f46078ef81ee2798b01ff", "heatmap": "e-265f46078ef81ee2798b01ff", "modelKey": "event", "timestamp": null }, { "id": "d9bf979106283fe52998681c", "type": "access", "start": 1643055740252, "end": 1643055747997, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-d9bf979106283fe52998681c", "heatmap": "e-d9bf979106283fe52998681c", "modelKey": "event", "timestamp": null }, { "id": "8d9bae8359268f6048d92ed3", "type": "motion", "start": 1643055781132, "end": null, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8d9bae8359268f6048d92ed3", "heatmap": "e-8d9bae8359268f6048d92ed3", "modelKey": "event", "timestamp": 1643055793162 }, { "id": "e9114260a65e566d9667dfbb", "type": "motion", "start": 1643055782161, "end": null, "score": 100, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-e9114260a65e566d9667dfbb", "heatmap": "e-e9114260a65e566d9667dfbb", "modelKey": "event", "timestamp": 1643055798154 }, { "id": "00368a5908cad9e2afdcb583", "type": "access", "start": 1643055787954, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": "4c5f03a8c8bd48ad8e066285", "metadata": { "clientPlatform": "web" }, "thumbnail": "e-00368a5908cad9e2afdcb583", "heatmap": "e-00368a5908cad9e2afdcb583", "modelKey": "event", "timestamp": null }, { "id": "9a5112fdedbab7188edaaf8b", "type": "motion", "start": 1643055788720, "end": null, "score": 94, "smartDetectTypes": [], "smartDetectEvents": [ "b19e92fb1f4244a78e493757" ], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9a5112fdedbab7188edaaf8b", "heatmap": "e-9a5112fdedbab7188edaaf8b", "modelKey": "event", "timestamp": null }, { "id": "b19e92fb1f4244a78e493757", "type": "smartDetectZone", "start": 1643055790555, "end": null, "score": 52, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-b19e92fb1f4244a78e493757", "heatmap": "e-b19e92fb1f4244a78e493757", "modelKey": "event", "timestamp": null }, { "id": "9376e572cf89f17ef33a3fce", "type": "smartDetectZone", "start": 1643055792010, "end": null, "score": 78, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-9376e572cf89f17ef33a3fce", "heatmap": "e-9376e572cf89f17ef33a3fce", "modelKey": "event", "timestamp": null }, { "id": "27f23bc2fe5a0aa180efcc8b", "type": "sensorMotion", "start": 1643055793140, "end": 1643055793140, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ftfqoe Ccmeel" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-27f23bc2fe5a0aa180efcc8b", "heatmap": "e-27f23bc2fe5a0aa180efcc8b", "modelKey": "event", "timestamp": null }, { "id": "19c87e73d92c5e0dc83f9028", "type": "motion", "start": 1643055794261, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [ "9376e572cf89f17ef33a3fce" ], "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-19c87e73d92c5e0dc83f9028", "heatmap": "e-19c87e73d92c5e0dc83f9028", "modelKey": "event", "timestamp": null }, { "id": "576a8593de0b9d286d53d08a", "type": "sensorMotion", "start": 1643055794595, "end": 1643055794595, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Pdw Lbj" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-576a8593de0b9d286d53d08a", "heatmap": "e-576a8593de0b9d286d53d08a", "modelKey": "event", "timestamp": null }, { "id": "12a435bf95fb283c0df61f58", "type": "sensorOpened", "start": 1643055794712, "end": 1643055794712, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Upv Udusuv" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "thumbnail": "e-12a435bf95fb283c0df61f58", "heatmap": "e-12a435bf95fb283c0df61f58", "modelKey": "event", "timestamp": null }, { "id": "203d7d9c67917b453e6b51bb", "type": "sensorOpened", "start": 1643055798932, "end": 1643055798932, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Xccje Kfhkq" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "thumbnail": "e-203d7d9c67917b453e6b51bb", "heatmap": "e-203d7d9c67917b453e6b51bb", "modelKey": "event", "timestamp": null }, { "id": "8db016edca05e2632f3778ea", "type": "smartDetectZone", "start": 1643055799985, "end": null, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-8db016edca05e2632f3778ea", "heatmap": "e-8db016edca05e2632f3778ea", "modelKey": "event", "timestamp": null }, { "id": "a4002f8e0e0860ea3c7bc43a", "type": "sensorMotion", "start": 1643055800106, "end": 1643055800106, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Lkvz Gjmlce" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-a4002f8e0e0860ea3c7bc43a", "heatmap": "e-a4002f8e0e0860ea3c7bc43a", "modelKey": "event", "timestamp": null }, { "id": "dcaafd507e8a37badffb97de", "type": "motion", "start": 1643055801481, "end": null, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [ "8db016edca05e2632f3778ea" ], "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-dcaafd507e8a37badffb97de", "heatmap": "e-dcaafd507e8a37badffb97de", "modelKey": "event", "timestamp": null }, { "id": "433e706f29ca680d4101d5ce", "type": "ring", "start": 1643055803577, "end": 1643055804577, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": {}, "thumbnail": "e-433e706f29ca680d4101d5ce", "heatmap": "e-433e706f29ca680d4101d5ce", "modelKey": "event", "timestamp": null }, { "id": "628609548952eee457c94647", "type": "sensorOpened", "start": 1643055805813, "end": 1643055805813, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "20b1e28b9a5fbdf1060bf7d8" }, "sensorName": { "text": "Bbpqm Ezl" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "window" } }, "thumbnail": "e-628609548952eee457c94647", "heatmap": "e-628609548952eee457c94647", "modelKey": "event", "timestamp": null }, { "id": "1866ac25e630d3c37d0b57b2", "type": "sensorClosed", "start": 1643055811080, "end": 1643055811080, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "20b1e28b9a5fbdf1060bf7d8" }, "sensorName": { "text": "Ijylcci Gnueel" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "window" } }, "thumbnail": "e-1866ac25e630d3c37d0b57b2", "heatmap": "e-1866ac25e630d3c37d0b57b2", "modelKey": "event", "timestamp": null }, { "id": "4247f5ad2e1aa1327ecff28d", "type": "sensorMotion", "start": 1643055813651, "end": 1643055813651, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ylct Wvmvx" }, "type": { "text": "UFP-SENSE" } }, "thumbnail": "e-4247f5ad2e1aa1327ecff28d", "heatmap": "e-4247f5ad2e1aa1327ecff28d", "modelKey": "event", "timestamp": null }, { "id": "df90543f94ff894427b79827", "type": "lightMotion", "start": 1643055813957, "end": 1643055813957, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": null, "partition": null, "user": null, "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "thumbnail": "e-df90543f94ff894427b79827", "heatmap": "e-df90543f94ff894427b79827", "modelKey": "event", "timestamp": null }, { "id": "6730b5af01029603e4453bdb", "modelKey": "event", "type": "nfcCardScanned", "start": 1731245487257, "end": 1731245487257, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": { "nfc": { "nfcId": "64B2A621", "userId": "672b573764f79603e400031d" }, "ramDescription": "", "ramClassifications": [] }, "thumbnail": "e-6730b5af01029603e4003bdb", "heatmap": "e-6730b5af01029603e4003bdb", "timestamp": 1731245487257, "category": null }, { "id": "05cb9efc794deb5625d63901", "modelKey": "event", "type": "fingerprintIdentified", "start": 1731761536049, "end": 1731761536049, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": { "fingerprint": { "ulpId": "0ef32f28-f654-404d-ab34-30e373e66436" }, "ramDescription": "", "ramClassifications": [] }, "thumbnail": "e-05cb9efc794deb5625d63901", "heatmap": "e-05cb9efc794deb5625d63901", "timestamp": 1731761536049, "category": null }, { "id": "ebbf14dc11a45c2c846f43fc", "modelKey": "event", "type": "fingerprintIdentified", "start": 1731761533438, "end": 1731761533438, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "metadata": { "fingerprint": { "ulpId": null }, "ramDescription": "", "ramClassifications": [] }, "thumbnail": "e-ebbf14dc11a45c2c846f43fc", "heatmap": "e-ebbf14dc11a45c2c846f43fc", "timestamp": 1731761533438, "category": null }, { "id": "f1d59512b03f596d87db22c7", "modelKey": "event", "type": "smartDetectZone", "start": 1763817385975, "end": 1763817402675, "score": 94, "smartDetectTypes": [ "vehicle", "licensePlate" ], "camera": "dcb7b0d1318653f056420d16", "partition": null, "user": null, "metadata": { "weather": { "iconCode": 30, "temperature": 1, "temperatureUnit": "C" }, "zonesStatus": { "1": { "level": 0, "status": "none" }, "2": { "level": 94, "status": "leave" }, "3": { "level": 0, "status": "none" } }, "detectedAreas": [ { "areaIndexes": [ 59, 69, 79, 89, 58, 68, 78, 88, 57, 67, 77, 87, 56, 66, 76, 86, 65, 75 ], "smartDetectObject": "6921b7b903ac2703e401be0a" } ], "ramDescription": "", "detectedThumbnails": [ { "name": "Xzz Aobdf", "type": "vehicle", "coord": [ 598, 553, 142, 96 ], "objectId": "6921b7b903ac2703e401be0a", "croppedId": "1C6A1B87ED72-1763817401905", "attributes": { "zone": [ 2 ], "color": { "val": "white", "confidence": 86 }, "trackerId": 15915, "vehicleType": { "val": "car", "confidence": 92 } }, "confidence": 97, "clockBestWall": 1763817398276, "group": { "id": "6921b7ba010a2703e401be0f", "matchedName": "WWHXXXX", "confidence": 75 } } ] }, "thumbnail": "e-f1d59512b03f596d87db22c7", "heatmap": "e-f1d59512b03f596d87db22c7", "timestamp": 1763817394325, "isFavorite": false, "favoriteObjectIds": null, "description": { "eventMetadata": { "eventType": "smartDetectZone", "category": "detection", "subCategory": "smartDetectEvent", "severity": "medium", "title": "Smart Detection", "deviceEventClassId": 2161 }, "messageRaw": "{deviceLink} has recorded a vehicle, a license plate. {viewLink}", "messageKeys": [ { "key": "deviceLink", "text": "G4 Doorbell Pro PoE", "style": [ "link" ], "action": "viewDeviceDetails", "params": { "deviceId": "6878d82800215803e45928e1" } }, { "key": "viewLink", "text": "View", "style": [ "link" ], "action": "viewCameraTimelapse", "params": { "cameraId": "6878d82800215803e45928e1", "start": 1763817385975 } } ] }, "category": "smart", "smartDetectEvents": [] }, { "id": "989692fa0c87d6a12570691f", "modelKey": "event", "type": "smartDetectZone", "start": 1763816515775, "end": 1763816549575, "score": 89, "smartDetectTypes": [ "face", "person" ], "camera": "dcb7b0d1318653f056420d16", "partition": null, "user": null, "metadata": { "weather": { "iconCode": 30, "temperature": 1, "temperatureUnit": "C" }, "zonesStatus": { "1": { "level": 0, "status": "none" }, "2": { "level": 0, "status": "none" }, "3": { "level": 71, "status": "leave" } }, "detectedAreas": [ { "areaIndexes": [ 32, 42, 52, 62, 72, 82, 92, 102, 33, 43, 53, 63, 73, 83, 93, 103, 34, 44, 54, 64, 74, 84, 94, 104, 35, 45, 55, 65, 75, 85, 95, 105, 36, 46, 56, 66, 76, 86, 96, 106, 37, 47, 57, 67, 77, 87, 97, 107 ], "smartDetectObject": "6921b47603b12703e40195de" }, { "areaIndexes": [ 32, 42, 33, 43, 34, 44, 35, 45 ], "smartDetectObject": "6921b47700cd2703e40195ee" } ], "ramDescription": "", "detectedThumbnails": [ { "type": "person", "coord": [ 335, 305, 302, 692 ], "objectId": "6921b47603b12703e40195de", "croppedId": "1C6A1B87ED72-1763816566861", "attributes": { "zone": [ 1 ], "trackerId": 13583 }, "confidence": 96, "clockBestWall": 1763816543376 }, { "type": "face", "coord": [ 441, 329, 48, 91 ], "objectId": "6921b47700cd2703e40195ee", "croppedId": "1C6A1B87ED72-1763816567187", "attributes": { "zone": [ 3 ], "faceMask": { "val": "face", "confidence": 84 }, "trackerId": 13590 }, "confidence": 85, "clockBestWall": 1763816539676, "group": { "id": "6763e18900b67503e400042c", "name": "Hdvkkz Ehemy", "matchedName": "RapXXXXXXXXX", "confidence": 88 } } ] }, "thumbnail": "e-989692fa0c87d6a12570691f", "heatmap": "e-989692fa0c87d6a12570691f", "timestamp": 1763816532675, "isFavorite": false, "favoriteObjectIds": null, "description": { "eventMetadata": { "eventType": "smartDetectZone", "category": "detection", "subCategory": "smartDetectEvent", "severity": "medium", "title": "Smart Detection", "deviceEventClassId": 2161 }, "messageRaw": "{deviceLink} has recorded a face, a person. {viewLink}", "messageKeys": [ { "key": "deviceLink", "text": "G4 Doorbell Pro PoE", "style": [ "link" ], "action": "viewDeviceDetails", "params": { "deviceId": "6878d82800215803e45928e1" } }, { "key": "viewLink", "text": "View", "style": [ "link" ], "action": "viewCameraTimelapse", "params": { "cameraId": "6878d82800215803e45928e1", "start": 1763816515775 } } ] }, "category": "smart", "smartDetectEvents": [] } ] uilibs-uiprotect-317a52c/tests/sample_data/sample_sensor.json000066400000000000000000000043331524637705700245500ustar00rootroot00000000000000{ "mac": "4191A8E35F39", "host": null, "connectionHost": "192.168.102.63", "type": "UFP-SENSE", "name": "Vyiu Ccxeqw", "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": true, "openStatusChangedAt": 1643055798932, "alarmTriggeredAt": null, "motionDetectedAt": 1643055801008, "wiredConnectionState": { "phyRate": null }, "stats": { "light": { "value": 20, "status": "neutral" }, "humidity": { "value": 29, "status": "neutral" }, "temperature": { "value": 16.95, "status": "neutral" } }, "bluetoothConnectionState": { "signalQuality": 45, "signalStrength": -72 }, "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" } uilibs-uiprotect-317a52c/tests/sample_data/sample_ulp_users.json000066400000000000000000000017151524637705700252610ustar00rootroot00000000000000[ { "ulpId": "73791632-9805-419c-8351-f3afaab8f064", "firstName": "John Doe", "lastName": "", "fullName": "John Doe", "avatar": "", "status": "ACTIVE", "id": "73791632-9805-419c-8351-f3afaab8f064", "modelKey": "ulpUser" }, { "ulpId": "ddec43ea-1845-4a50-bdab-83bcd4b3c81d", "firstName": "Jane Doe", "lastName": "", "fullName": "Jane Doe", "avatar": "", "status": "ACTIVE", "id": "ddec43ea-1845-4a50-bdab-83bcd4b3c81d", "modelKey": "ulpUser" }, { "ulpId": "0ef32f28-f654-404d-ab34-30e373e66436", "firstName": "You Know Who", "lastName": "", "fullName": "You Know Who", "avatar": "/proxy/users/public/avatar/1732954155_589f14b3-b137-4487-9823-db62bb793c19.jpg", "status": "DEACTIVATED", "id": "0ef32f28-f654-404d-ab34-30e373e66436", "modelKey": "ulpUser" } ] uilibs-uiprotect-317a52c/tests/sample_data/sample_viewport.json000066400000000000000000000017161524637705700251200ustar00rootroot00000000000000{ "mac": "85EEA7DF1601", "host": "192.168.27.69", "connectionHost": "192.168.102.63", "type": "UP Viewport", "name": "Uknkgc Jpyuso", "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" } uilibs-uiprotect-317a52c/tests/sample_data/sample_ws_messages.json000066400000000000000000004701701524637705700255650ustar00rootroot00000000000000{ "2.7892892999807373": { "raw": "AQEBAAAAAHR4nB2MWwqDMBBFtyLz3YAmkzDpDorfLmCSGUFotfigFHHvknyee7jnBM77tMzwbOD4Cu8KjwZm/Q0VXlJEsIqx88mQxWDQ52jIiRgZO6eOOSKncvssou9e/zW26Vq2qRYw+7F1TJmSILGQtiFY8nDdlRMkmAIBAQAAAAEkeJyFkc1uwjAQhF8FRT2hHOz4Jz8P0HMv7aXisLbXwcJJkB1AqOq7NwSoCm3xxbJG34x3xx9ZG4bdNmbN4j2TVDErNCe0FlAShiUh06mzVb7IwPsXDJ2L0Q39mfduj3uHh2bZGCmU4lRTbiRAXSgLHKSh2eT8gfFaKSUIEbzgQAwTVBSypHCHqaLSrLK0KIiVijPLakaMtfdYXU0jC8agKhEkq6dHDWfVCev3oQkIplnemPSkjXiSdhHPRH4IbsTcoMcRm6er+UadQ+amLgn5b+fyO/UxEvUazc5jAvPYgj6+Pr/9MeQMqOBMm0rR0GGAf6FZ6dA4uAjz/dpZux5Tu2Afh9TCZhiCH/QmgZ3+J1mecXHr4Zjaeu26h82sPr8AeQX7wQ==", "action": { "action": "update", "newUpdateId": "62e4915b-8246-45c9-83dd-df13e3aa94ab", "modelKey": "user", "id": "4c5f03a8c8bd48ad8e066285" }, "data": { "groups": [ "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "2.79086060001282": { "raw": "AQEBAAAAAHR4nB2MWwrCQAxFt1Ly7UDTebXuQPx2AckkgYK2ohUR6d7LzOe5h3v+QGWb1wXOHXyeQpvCqYNFv7cGF6nCRI0xJJdKRhdiZDcReVfYevQYUYah3h6r6P2qvxZ766tucysQWwqZVHyKPeURizKhN9gPtb8lMQIBAQAAAACneJxVjkEKgzAURK8ioaviIsW2Sk7RfXERk6l+mhhJolJK714lUNLNwJ95n5k3672bp8BEcWfXU1c9LurMq6aRNa9Qc76pZm1ZMGnMDd5SCOTGxI+LFx5SiyPbAEMLFsIq1OZF7NYckIhy9RRRahhEiMOedZ50j+xfSQsvE76LhSaZIu2cN049/9r6IWZ3wBhcPmefgtzQFCYjX3njQPa3oP18AYpfVU8=", "action": { "action": "update", "newUpdateId": "fdefb146-6c71-455b-9aa3-cbf013151d22", "modelKey": "user", "id": "abf647aed3650a781ceba13f" }, "data": { "groups": [ "61b3f5c40388a703e70003ed" ], "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:*" ] } }, "2.792094599979464": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbWBqkmnjDaRrDzCTmUBBW7GVIuLdJVm+//jvC5z3eV3g0sH7qbwbnDpY7Lg1uGoVUQfyUrITHXsXPKJLUkaHKL1y4cSa6+2xqt0n+7TYZq+6za3Aajl6H4RSiVSIKYchnhF+f82IJUMCAQEAAAABBnicpVKxbsMgFPyVCHWyPDhy06R8QOcu7VJlIHCxUbGJwHYUVf33GoMSoiR46ILE3b1773H8kMro/mAJXXyRl+Wu3K/4c1FuNmxdlFgXxXgKki8u3PJ1FXGcbEeSKfUO00hrpW69VzsYasAEzVy1kgMGiSPlI9bBQb2FV+RHIzvkAgod6JPjdkaKClE9Zw0M83J3NBCSeUpobZTm31fdqrqL7hat1fE4bhTEgJD2oNgp7ljLJp7gX/u44it0MplePjjkt5XZ2TUtsbyG6BVmZAoV46ePt887Q2bRo6ddQhCPRJdwAhAF5VOZ2cUnlRadE0/LQsgzXiH4ma2nz5DQbH//AI70GPY=", "action": { "action": "update", "newUpdateId": "5d763bfc-bd81-4300-9bf8-00b1dafa9adc", "modelKey": "user", "id": "adec5334b69f56f6a6c47520" }, "data": { "groups": [ "61b3f5c40388a703e70003ed", "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "2.7935844999738038": { "raw": "AQEBAAAAAHR4nB2MQQqDQAxFryJZd2Aak3GmN5CuPUA0EYRWS1WKiHcvM8v3H/+dIMM2LTM8Ktg/KpvBrYLZfl2BVrPwCUdvlpwYoSMN6JIO6hCJme+Etdd8ey9qr6cdJbbaN29TKUROdeBGkPsmYhDCGPsgI1x/dysjpQIBAQAAAAEGeJylUrFuwyAU/JUIdbI8OHLTpHxA5y7tUmUgcLFRsYnAdhRV/fcagxKiJHjogsTdvXvvcfyQyuj+YAldfJGX5a7cr/hzUW42bF2UWBfFeAqSLy7c8nUVcZxsR5Ip9Q7TSGulbr1XOxhqwATNXLWSAwaJI+Uj1sFBvYVX5EcjO+QCCh3ok+N2RooKUT1nDQzzcnc0EJJ5SmhtlObfV92quovuFq3V8ThuFMSAkPag2CnuWMsmnuBf+7jiK3QymV4+OOS3ldnZNS2xvIboFWZkChXjp4+3zztDZtGjp11CEI9El3ACEAXlU5nZxSeVFp0TT8tCyDNeIfiZrafPkNBsf/8AjvQY9g==", "action": { "action": "update", "newUpdateId": "092f0ee9-ae42-4d62-9dcd-22455514230d", "modelKey": "user", "id": "8593657a25b7826a4288b6af" }, "data": { "groups": [ "61b3f5c40388a703e70003ed", "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "2.795322699996177": { "raw": "AQEBAAAAAHR4nB2MWwqDMBBFtyLz3UBoHmbcQfHbBUxmJiC0Kq0iUrr3knyee7jnC8T7vC4wdHBsQrvCrYNFz6nBQ6ooaK2g86b0TMYHfzeYYjHoMlEOzJhcvb1W0eeoV4t99F23uRWESQtyTqRiA/eSY0jkI/z+sX8lkQIBAQAAAAEGeJylUrFuwyAU/JUIdbI8OHLTpHxA5y7tUmUgcLFRsYnAdhRV/fcagxKiJHjogsTdvXvvcfyQyuj+YAldfJGX5a7cr/hzUW42bF2UWBfFeAqSLy7c8nUVcZxsR5Ip9Q7TSGulbr1XOxhqwATNXLWSAwaJI+Uj1sFBvYVX5EcjO+QCCh3ok+N2RooKUT1nDQzzcnc0EJJ5SmhtlObfV92quovuFq3V8ThuFMSAkPag2CnuWMsmnuBf+7jiK3QymV4+OOS3ldnZNS2xvIboFWZkChXjp4+3zztDZtGjp11CEI9El3ACEAXlU5nZxSeVFp0TT8tCyDNeIfiZrafPkNBsf/8AjvQY9g==", "action": { "action": "update", "newUpdateId": "f900d934-f7ca-4542-986f-93baab5cc983", "modelKey": "user", "id": "dcaef9cb8aed05c7db658a46" }, "data": { "groups": [ "61b3f5c40388a703e70003ed", "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "2.797002999985125": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbaDOT9LEG0jXHiDNTKCgrdhKEfHukizff/z3pZT3eV3o0tH7KWlXOnW06HFrcJUqMqNYCcWALRvLZ2ciT9EEADGzHeBDvT1W0fuonxbb9FW3uRWmDBEPOIfYD9Fx8kW5F/r9AU/zI0cCAQEAAAAAp3icVY5BCoMwFESvIqGr4iLFtkpO0X1xEZOpfpoYSaJSSu9eJVDSzcCfeZ+ZN+u9m6fARHFn11NXPS7qzKumkTWvUHO+qWZtWTBpzA3eUgjkxsSPixceUosj2wBDCxbCKtTmRezWHJCIcvUUUWoYRIjDnnWedI/sX0kLLxO+i4UmmSLtnDdOPf/a+iFmd8AYXD5nn4Lc0BQmI19540D2t6D9fAGKX1VP", "action": { "action": "update", "newUpdateId": "c23f4d8f-3242-4215-92b9-83339c247368", "modelKey": "user", "id": "bc3dd633553907952a6fe20d" }, "data": { "groups": [ "61b3f5c40388a703e70003ed" ], "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:*" ] } }, "3.4176020000013523": { "raw": "AQEBAAAAAHd4nB2MQQoCMQxFrzJkbcEY20m9gbj2AGmawOA4FRkREe8uneV/j/e/ILpObYHTAK9HldVgN8Bi7+s2zrULRpQxYwmYJYYjuoeS1IMSuTOVNJL27N6qzRf79Ka29pyb3jqfthdUxoPx3mvKJCVipFiMGH5/TaUmLQIBAQAAAAAheJyrVsrJT84OLkksKS1WslJQ8g9w9fP0c1eqBQBwiwgY", "action": { "action": "update", "newUpdateId": "811a791b-19a5-41ff-b6cf-c33ff83b673c", "modelKey": "doorlock", "id": "1c812e80fd693ab51535be38" }, "data": { "lockStatus": "OPENING" } }, "4.266343800001778": { "raw": "AQEBAAAAAHR4nB3MQQrCMBBA0auUWRtIk5k48QbStQeYZqZQ0NRFqIj07hq3D/7/gJS2bhUuA4gqnAao9ro9VZpdtWtYCsWU0Y1nJodjiI6peOez0syEjCI9e2xq98nevbHdauu4/heseRbjSDkkXpLHHwTTCMcX0pMilAIBAQAAAACveJxVjrsKwzAMRX8leO7gRx5253bq2q10cGIZAnESHKUQQv69UoeWbjpHl8vdBW4ziHMh0oT9NIpTIRb0GUmpujSyqhqrlNHsuylzVPKdKHMBhA7vVLCQfjz//fUFI34fCdAHj554P4g7nyAzCQVgrKugLSFq1UhZ1tFF3fGUmdr6z65zMa7DQGpdIP+oD1xhg2s9WFM5XdtYy5KEhmC4Ik0BhhtsnAOeJI43XpNJIA==", "action": { "action": "add", "newUpdateId": "2fc53694-1785-4123-85c0-09d5b85484aa", "modelKey": "event", "id": "8d9bae8359268f6048d92ed3" }, "data": { "type": "motion", "start": 1643055781132, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": {}, "camera": "1ee3895eb4ef2170046f9f2c", "partition": null, "user": null, "id": "8d9bae8359268f6048d92ed3", "modelKey": "event" } }, "4.361886799975764": { "raw": "AQEBAAAAAHZ4nB2MWwrCMBBFt1Lm20CazOThDsRvFzBNbqBgW5GKiLh3ST/vOZz7JS37vK10Huj1qLqDTgOteN+OcaldxOiZhdlwxmTYsRotoRkrNXGqIvChZ8tWcb/i05uiC57a6Xx8jIBPWTAxmhujtRxabq7Q7w++JSS6AgEBAAAAAMV4nF1QTQuDMAz9K6VnD61aP3beYZdd5nF4KBqcoBZqFET874vtRBwkIS95eX105Z0e8WmwNQO/MZnEkVAqzaSMwoDxdnxBZWzdDg1t0U7ghp5/B4QKoT43MMOABWocabby/pBdOZpaL9TFMdH0DFY3QFBJgruDu172m3eqAhbllEnA4shnLgirkpgWKnrgYSbryJIWFFnAQldJLHHVz39NudHl2GuLFyfiYkT8+RCHyBnltnkL/j+K6gP11IGjl9sXRVpcKw==", "action": { "action": "update", "newUpdateId": "77344544-49eb-424a-ac6f-05d848d55e36", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "lastMotion": 1643055781132, "isRecording": true, "isMotionDetected": true, "eventStats": { "motion": { "today": 44, "average": 51, "lastDays": [ 75, 39, 36, 43, 43, 90, 35 ], "recentHours": [ 10, 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 ] } }, "recordingSchedules": [] } }, "5.005999299988616": { "raw": "AQEBAAAAAHR4nB3MQQrCMBBA0auUWRvIxIxJvYG47gEmMxMoaOoiVES8u6TbD+9/gaWvW4PrBKwKpwmavZeXcrebjhqTzOUS1amRuUgY3YzBu1wJhQQ1cxrsuak97vYZxnZrfcT1WIRUw7lIqEbsmTF7qyK5wO8PFmskHQIBAQAAAADneJxtUE1rwzAM/StF5w6c73jnZTDGQqHbaezg2AqEJXax1ZFS8t8nh66lsJvehx5POgOdDgiPGwhog/NvjgZnYcuYlCcWkjLPRFFUMktywTxa8w8btPMxZp0n3nxCQk3vHB6Y/vy655sftHQVJiRlFCnG50uPF7MCwjl2AJEiyk7KPqlMKbO8RKFrLQQs27/mrZrwbqedv2e+YbPTbl59l0tvjo/n3cO+afcNLFHXnOBjCXscR8YHrjus77hSx4D+hoZYEtKqT7NOpz0WSiiV1AJ7resuPnFyBsdXPEUfxpth+QVxCHAi", "action": { "action": "add", "newUpdateId": "47c9b64d-de5e-4514-9120-8f51c5c1d8a7", "modelKey": "event", "id": "27f23bc2fe5a0aa180efcc8b" }, "data": { "type": "sensorMotion", "start": 1643055793140, "end": 1643055793140, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Nxkxtar Pcox" }, "type": { "text": "UFP-SENSE" } }, "camera": null, "partition": null, "user": null, "id": "27f23bc2fe5a0aa180efcc8b", "modelKey": "event" } }, "5.011190899997018": { "raw": "AQEBAAAAAHd4nB2MQQrCMBBFr1JmbWDSadKMNxDXHiCZGaGgidiKiHh3SZf/Pd7/QpZtaRWOA7wemjeDwwDV3pd9nLQLTcWP5LNT4dlNGMgxUXRUImaV4JOFnt2b2u1sn96sVtf27HTZP3A048J89bNGpikaShJE+P0B0mgk2AIBAQAAAAA5eJyrVsos9s0vyczPc0ktSU0uSU1RslIoKSpN1VFQykURdywByhiamRgbmJqaWxobmhjUAgCbTBPC", "action": { "action": "update", "newUpdateId": "d8b1231a-dc97-4053-9336-3b60adc518e5", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "isMotionDetected": true, "motionDetectedAt": 1643055793140 } }, "5.320390500011854": { "raw": "AQEBAAAAAHR4nB3MwQrCMAyA4VcZOVtYY5OtvoF43gMkTYSBdh7KRMR3l+76w/d/QUpbtwqXAcQMTgNUfy8vk+ZX6xXLRFjiOZBkDWkmDTkZBZoLEjpH1rGz52b+uPmnG9+9th7XY+E5xoQ8CpMTs2Xmye6q8PsD6gUi4AIBAQAAAACveJxVjj0LgzAURf+KZO5gYvKizu3UtVvpkI8nCEYlPgsi/vcmDi3d3jnvcrk7o21G1hYsTNRPI7sUbCETKSkOsiqV0rXgwLN3U8zRMt8hZa5I6OiRCpakn69/f3vjSN9HQDLekEm8H4mdCRgzMVWDsdoJa0GKylWd91Jjo/KUObX15662GNdhSGpdMP6o97kCG86lgNKAQgXgGwDtO2tzRZg8DnfczlyexI4PaolJRQ==", "action": { "action": "add", "newUpdateId": "2c752c13-5a9b-485b-94d5-58c252e616b0", "modelKey": "event", "id": "e9114260a65e566d9667dfbb" }, "data": { "type": "motion", "start": 1643055782161, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": {}, "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "id": "e9114260a65e566d9667dfbb", "modelKey": "event" } }, "5.393986199982464": { "raw": "AQEBAAAAAHZ4nB2MWwrCQAxFt1Ly7UA7yTzqDsRvF5BJMlCwrUhFRNy7zHzeczj3CyzHsm9wHuD1UD4MTgNs9r71cdEmMCcbx2iuxhodIQeXJ6xOJl/yzEpC3LJ1V7tf7dMa4dWenS79I+TIJYkvJZJHwapKyeYAvz/nWiVuAgEBAAAAAL54nF1QsQqDMBD9lZA5Q6LRSucOXbrUsTgEc1hBDSSnIOK/94x1UHgcubx37x638M4EfDls3cDvTOU6lVl2KxKVK8F4G3bqAQg1giUJ+hGIgQkGLNFgoL+F94fDwtFZM9MrSUlmJvCmgc26oHZb9jDzNvNJc8EIUrB0BzVaV6TyUJP5040+CrVglEUlUatizWI9oVppMvTG4ymFPIWQlwxXk+iz7hGct+3QlPUX7NhBlFfxJO+D+19j/QF9ultG", "action": { "action": "update", "newUpdateId": "387e006e-f6f6-43a5-813f-c12b89ad4c4a", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055782161, "isMotionDetected": true, "eventStats": { "motion": { "today": 23, "average": 18, "lastDays": [ 36, 6, 0, 3, 3, 36, 44 ], "recentHours": [ 4, 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 ] } }, "recordingSchedules": [], "isRecording": true } }, "5.838011400017422": { "raw": "AQEBAAAAAHh4nB2MQQrCMBBFr1JmbWCSTNqJNxDXHiDJjFCsHZGKiHh3SZf/Pd7/QmnbbCscB3g9pGwKhwFWfV/2cZIuqHjMCdHpSOIIK7uSw+QkUCPGSTFzz+4mupz10xsxey7Wbp3P+4tv7IMyXmXMsdTkU0xVI8PvDwd7JTYCAQEAAAAASnicq1ZKyilNLcnPL8lwzs/LS00uyczPCy5JLElVslKoVirOTM9LzAksTczJLKkEipiZ6yhABYNLilLz0ksygKK6Zsa1tQAD1Bo1", "action": { "action": "update", "newUpdateId": "4a109500-e64d-40b8-a927-d24c4807e098", "modelKey": "doorlock", "id": "1c812e80fd693ab51535be38" }, "data": { "bluetoothConnectionState": { "signalQuality": 67, "signalStrength": -63 } } }, "5.846109299978707": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbWDSppp4A3HtATKd+VDQRGxFRLy7psv/Hu9/KE/rXAsdO3reNa9Gu46KvS7bOGkTowWdkNjBD8mFCHaCABe9IiuG+Jctu1W169nerVmsLPXR6Lx99Cze+igpjxCF5z0LDhrp+wMwASaUAgEBAAAAAEp4nKtWSsopTS3Jzy/JcM7Py0tNLsnMzwsuSSxJVbJSqFYqzkzPS8wJLE3MySypBIqYGugoQAWDS4pS89JLMoCiuuYGtbUAAv4aKw==", "action": { "action": "update", "newUpdateId": "5e4dcf90-f139-48f0-bf4f-81dfadf38cf9", "modelKey": "sensor", "id": "20b1e28b9a5fbdf1060bf7d8" }, "data": { "bluetoothConnectionState": { "signalQuality": 50, "signalStrength": -70 } } }, "5.960623399994802": { "raw": "AQEBAAAAAHV4nB2MWwrCQAxFt1Ly7UDmmdEdSL9dQCaJULCtSEWkuHeZft5zOHcHlm1aF7gM8H4qbwanARb73I5x1S6SaEBDdJkCutQ0OPb34szXkFrkdKbcs3lVe4z27Y3wbC/udDo+JJUgSNq0RNaKmSn6KgS/P8rlJL0CAQEAAAAAInicq1YqSk3OL0rJzEsPTs5ITSnNSS1WslKIjq0FAIa2Caw=", "action": { "action": "update", "newUpdateId": "4cd20e00-5720-4bd2-a1f6-e1824b3a4975", "modelKey": "camera", "id": "c462c07dbd63ad805a7318c7" }, "data": { "recordingSchedules": [] } }, "5.982410800002981": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm20Aeg824A/HbBUwyN1CwrUiLiLh3ST/vOZz7Ja3btC50GWh/mm6g00AL3vdjXK0LtBJG9d6dDeo4cHJSuTgFjKPkqLn1bF4Njxs+vak646WdTscHa0rJRFJDRkTmMZYqPtDvDxMwJZkCAQEAAAAAInicq1YqSk3OL0rJzEsPTs5ITSnNSS1WslKIjq0FAIa2Caw=", "action": { "action": "update", "newUpdateId": "efb17a00-6dea-4143-9c4b-aeed42982a8f", "modelKey": "camera", "id": "4a333d993fe8e2e8472bc901" }, "data": { "recordingSchedules": [] } }, "5.984701499983203": { "raw": "AQEBAAAAAHR4nB2MSwrCQBAFrxJ67YDTcewebyCuPUD/AgGTiCSIiHeXyfJVUe8LYuu4zHDpYHu6rAGHDuZ43/dx9SbcNaOxJeSjpRNlSixUEtUchjV6PXPLpsXjcYtPa0ymeEmj4/4h2gfSgF7KIM6ZXIxUK/z+7fAl0QIBAQAAAAAieJyrVipKTc4vSsnMSw9OzkhNKc1JLVayUoiOrQUAhrYJrA==", "action": { "action": "update", "newUpdateId": "ddb12c8c-280c-4717-8a75-791ec29e3b68", "modelKey": "camera", "id": "ab3e27f2d55fad817dac7bb9" }, "data": { "recordingSchedules": [] } }, "5.985874200006947": { "raw": "AQEBAAAAAHV4nB2MWwrCQAxFt1Ly7cA8nDFxB9JvF5BJUijYVqQiUty7zHzeczj3AJZ93la4DvB+Ku8GpwFW+9z7uGkTYolSpuBiDOrOpaDjGi+ONQrShBm9b9myqT1G+/aGF3txo3P/mLxoyBWrKVlCJIpYGAV+f8kmJPQCAQEAAAAAInicq1YqSk3OL0rJzEsPTs5ITSnNSS1WslKIjq0FAIa2Caw=", "action": { "action": "update", "newUpdateId": "ce393591-221d-4668-ab27-ad2c89f85800", "modelKey": "camera", "id": "f0cd15b8bed9e38899286a8c" }, "data": { "recordingSchedules": [] } }, "5.988382300012745": { "raw": "AQEBAAAAAHV4nB2MSwrCQBAFrxJ67UA76YlpbyCuPUD/BgImEYmIiHeXyfJVUe8LYtu0LnDu4PVw2QIOHSzxvu3j4k04c0U1TKgFE41WEmeyJH02KQPZqNqyefW4X+PTGpM5ntLotH8cjSW7ktchqgudpC/IGvD7A/7lJhgCAQEAAAAAjnicTYzBCsIwEER/JeSs0CpR8NqzB+1RPIRkmyzETU22SCn9d1e9eJt585hFF3C5eKTQuwh+SlD1Sd3uG6VfOGCXicAxZurZMsi0aBetwCS5NXvxhgLPCcjNQszxYASNcb7+9F3TSK8YyKbLZBPyR2v/aM8FKHAUvP3+1Ypeij7nwlhVZx9QrF7XNyPiN0A=", "action": { "action": "update", "newUpdateId": "d99f0bc0-0b50-48c5-924c-a32ca564c8bb", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "recordingSchedules": [], "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -53, "ssid": "Mortis Camera" } } }, "5.9925161999999546": { "raw": "AQEBAAAAAHV4nB2MQQrCQAxFr1KydqATm9jxBuLaAySTBAq2FamIiHeX6fK/x/tfkLpN6wLnDl4Pk83h0MHi79s+LtaEqZaKJ0lEA6Uhs6axRKTAoJz1aJS5ZfNqfr/6pzVVZn9Ko9P+4RjRizmr94GC6sp55AK/PwCGJfUCAQEAAAAAknicXY3BCsIwEER/peSsUKtW8NqzB81RPIRkmyzETU22SCn9d7cIHrzNvJmdndUQppthUOfqeNpUKoNN2SF5bQO4MUKR5P6Q5I09dokILGMizd+jWdlgBEbRrbT6DK8RyE7im8N+nfz7UNCTidfRROS1tavrH9WcgTwHwdu2WXFBJ0ZdUmYsVWeekI1alg+QOzrj", "action": { "action": "update", "newUpdateId": "dbb9c27a-5545-416b-89ff-f2f511b3d516", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "phyRate": 57, "recordingSchedules": [], "wifiConnectionState": { "channel": 6, "frequency": 2437, "phyRate": 57, "signalQuality": 100, "signalStrength": -62, "ssid": "Mortis Camera" } } }, "5.993778900010511": { "raw": "AQEBAAAAAHZ4nB2MWwrCMBBFt1Lm20DbTKaJOxC/XcC8AgXbilRExL1L+nnP4dwvsO7ztsK5g9fDeHc4dbD6+3aMizVh2VLkvgbjMgTEiqFYpkAkaZAce1Ft2bKZ36/+aY3y4k9udD4+UiaWSUcRwjFqrGY4eUnw+wP9JiWfAgEBAAAAAD54nKtWykksLvHNL8nMz1OyUjA0MzE2MDU1tzQ2tjTSUVAqSk3OL0rJzEsPTs5ITSnNSS0GKoqOrQUA3EsRaQ==", "action": { "action": "update", "newUpdateId": "d8d53a0f-da91-44f4-9d86-66b51b830bcc", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055793392, "recordingSchedules": [] } }, "5.994852499978151": { "raw": "AQEBAAAAAHZ4nB2MQQqDMBBFryKzbiCJEzP2BqXrHiCZ/IBQtRSLSOndS1z+93j/S0m3aV3o2tHnVdIGunS0YH+c41aaGDz7kFmM48iGVWBytGqEawlVtM9BWzavBc87jtZomvFOjU7nhwN6GQMyo3oXreWhjtUr/f7TGyUWAgEBAAAAAD54nKtWykksLvHNL8nMz1OyUjA0MzE2MDU1tzQyNjbQUVAqSk3OL0rJzEsPTs5ITSnNSS0GKoqOrQUA20YRYA==", "action": { "action": "update", "newUpdateId": "62425b48-1474-4c8e-b70c-84fd5f8c3b5c", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "lastMotion": 1643055792330, "recordingSchedules": [] } }, "6.025717800017446": { "raw": "AQEBAAAAAHd4nB2MSwoCMRAFrzL02kDnM0naG4hrD5B0tzCgiTgjIuLdJbN8VdT7QuFt6Q2OE7weUjaFwwRN35d9nGSIwlkqp2RmJ2pCRWeozMEEb7Mlzd7ZOLJ7F72d9TOaVdvan4Mu+wc6VapEV5skkg9RkTMjwu8P4qEk4AIBAQAAAAA6eJyrVsos9s0vyczPc0ktSU0uSU1RslJIS8wpTtVRUMpFkXAsAUoZmpkYG5iamluaGJpZ1gIArx8UGQ==", "action": { "action": "update", "newUpdateId": "ac8dbc77-52de-4b02-9a54-431819e83216", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "isMotionDetected": false, "motionDetectedAt": 1643055794169 } }, "6.252706800005399": { "raw": "AQEBAAAAAHd4nB2MSwrDMAxErxK0rsGyHX96g9J1DyBZKgSaDyGllNK7J85uZh5vfkB1G+YJrh28F6FN4dLBpJ/HWW7SgEVb2KszDiOaYD0bKpINoUrMfOSETRtn0dddv82pNOpKbR3Ojz5H4lQdcwzOV/8UCUlLD/8d0DwlKwIBAQAAAAFFeJxtUsFOhDAU/JUNZ2MehdLWo8abHjYcjYcuvIXGWrAt6mbDv9uWuMvq3mBm3ryZlx4zi81gW2W6uumxnTS67G7z8nqzyZyXPv4cM/t9f/CJIDkRFAShZREU/oTnAIQJBlwwFpkvtVdptOmlMajDt5m0Dsze4seEpjmcIa3MWz0its+70Z1hpzoj9XaSWvmopnACa2/RdL4PKMwB3Unv0R7SxhFtg8bLDs9Wyj300nahZcD2UjuMVhpxrEPJKMxa5ZohRG08tln0/FQtDkv904m8tD62rUognLOy4FUepCfBo2kXugBKGaMcyJpO80/btUNZFPyPw68geQhgaYVX76jl6PBKBsrXgosMJQcO8G/+MsMVh3UGECEGm9OLGOxy12M2OUx7RDAQVZ5XhJSxyHLO8BJuSZELAjkvqBDzPP8Av5u4OQ==", "action": { "action": "update", "newUpdateId": "0109b3e2-2161-403b-a9d8-a1ed68ba9d71", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 21295092543, "txBytes": 1002797089773, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640288743861, "recordingEnd": 1643055775802, "recordingStartLQ": 1640288744338, "recordingEndLQ": 1643055790761, "timelapseStart": 1640288743858, "timelapseEnd": 1643055480800, "timelapseStartLQ": 1640288743858, "timelapseEndLQ": 1643055095797 }, "storage": { "used": 1987496116224, "rate": 708.231920183599 } } } }, "6.293135799991433": { "raw": "AQEBAAAAAHZ4nB2MSwrDMAwFrxK0rsF25MjpDUrWPYBsyRDIp5SUUkLvHuzlm2HeCZyPed/g3sHnJXwo3DrY9Pts4yFV9JGtJ5+Mw4EMilgzYrQmY3FBB0GKLVt30WXSX20yr/rmSuf2walXT8VLCIUlOhLOlNII/wvFUiVrAgEBAAAAAUJ4nG1STU+EMBT8K6ZnY1roFx413vSw4Wg8dOlbaKwF26JuNvx3WzDslzc6M2/evAkH5KHpvTaurZsO9GghoPub17fbGxSiivlxQP7nYR9ngghKC4mZ4Cwp4opzVhaswqQkFCfi2+zMPNl0yjmw6duN1iZm5+FzBNfsj5A17r0eAPTLdghHOJjWKbsZlTUxqxlewTp6cG3sEoqnhG5VjOD388YBfAMuqhaOViY8dsq36ciE7ZQNkK0swFCnG7MQaROaPkVtImiUPb+Mhn65fm0oKh9zCbysiCxlulnmUKvgyemZpiVmTAiOBTul5/nnzbkDkxcOf4LFoyKYV7lp8wFWDQH+ySDOBGcZOCEEk6v5ywxXDqcZCOGYyWn+IXq/9HpAY4B5DxUEF7yopFyqWOosS3ZXcCJFRXlaQqdp+gUkhLfw", "action": { "action": "update", "newUpdateId": "38a0272b-1467-4dd0-9480-c4f15e6d478e", "modelKey": "camera", "id": "ab3e27f2d55fad817dac7bb9" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 17442805765, "txBytes": 653259013140, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183853280, "recordingEnd": 1643055776075, "recordingStartLQ": 1639183853258, "recordingEndLQ": 1643055791069, "timelapseStart": 1639183853279, "timelapseEnd": 1643055611101, "timelapseStartLQ": 1639183853279, "timelapseEndLQ": 1643055116058 }, "storage": { "used": 1471026298880, "rate": 335.261879469184 } } } }, "6.3141223000129685": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbSCNyZh6A3HtAWaSP1CwrUhFRLy7JMv/Hu9/Sco+byudB3o9quygw0Ar3rc+LrWJYzilaQQ7ZvUuSlInVkaXc5RqSBqtZ8tWcb/i05oiC57S6Nw/EMy8VLDCW5CgUB4zT/T7AwmyJlUCAQEAAAABSnicbVLLTsMwEPyVymdAfifmCOIGhypHxMFNtomFcYLtAFWVf8dORCgtN3t2dnZ27CPyUPe+Ma6t6g6a0UJAt5vnl6sNClHHfDki/3V3iHOBc6VYKRnjiRBXmHAhFC4IV7hMhU+zN3Nj3WnnwKazTPDew/sIrj6kO+WsSJA17rUaAJqn3ZCF3GhtHm1ap+121NbEzCYYr2gVPbg2dgm+lnRK+E7HCP4wTxzA1+CibuFXzYT7Tvs27ZiwvbYBspgFGKq0YiaixoS6T1brCA3Kmh+mgX5Zfg0oah+zGckxw2XJsZB5h5Xw4JqlzLAQRSExVafluf9xe6bAzhR+CLOGIrigOWnzBlYPAf7xwNgp4Y8HQVNw4qL/3MOFwqkHwiVm5TT/h94vuR7RGCDPKXjB0ktiJXD+EH5Jk3F6QygtGSkpFpxO0/QNYZS1YA==", "action": { "action": "update", "newUpdateId": "327591e6-66b0-4a5b-afc1-884adfe5b4fe", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 4499386334, "txBytes": 145590714908, "wifi": { "channel": 6, "frequency": 2437, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -62 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640308840567, "recordingEnd": 1643055776029, "recordingStartLQ": 1640308840563, "recordingEndLQ": 1643055791072, "timelapseStart": 1640308840533, "timelapseEnd": 1643055521005, "timelapseStartLQ": 1640308840533, "timelapseEndLQ": 1643055146038 }, "storage": { "used": 747324309504, "rate": 342.122831820542 } } } }, "6.316282199986745": { "raw": "AQEBAAAAAHZ4nB2MWwrCMBBFt1Lm20CcPJq4A/HbBdxkJlCwrUiLiLh3ST/vOZz7JdRtWhe6DLQ/BZvSaaBF3/djXKULl9lWQTI2OW98C2wKQjLKOtoWS4yOezavoo+bfnpTMesLnU7Hx7lmsBQvLWoT+BEu2FyUfn/YTCWSAgEBAAAAAUl4nG1SwU7EIBT8lQ1nNUChFI8ab3rY9Gg8sPRtS0RagaqbTf9daGPdXb2VmXnz5k16RB507xvj2lp30IwWArrdPL9cbVCIKubHEfmvu0OcCSFJKcpKcJkEcYWpwIySqsCEsUR8mr2ZB3WnnAObvgkvErH38D6C04eEcFHyBFnjXusBoHnaDdnKjdbm5aZ1ym5HZU3MaoLxitbRg2tjl+BrXkwJ36kYwR/mnQN4DS6qFn7dTLjvlG/TlQnbKxsgm1mAoU5HZiFqTNB9CqsjNCh7fpgG+uX8taKofMxhSkYkJZhJLnMRq+DBNQtdYM6F4IKJU3qef9yeOwh24fAjmD0kFpTmrs0bWDUE+C8DOxWcZWAlFoT/mb/M8MfhNAOnKQOf5j+i90uvRzQGyHsYFQWTlHHJc0y/tFkyelNgWlWkSjvINE3f2x+12A==", "action": { "action": "update", "newUpdateId": "3920cda8-0834-4f52-ba58-e2e70f6b6632", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 7916768759, "txBytes": 270421830144, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -53 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1641921049599, "recordingEnd": 1643055775747, "recordingStartLQ": 1641921049574, "recordingEndLQ": 1643055790722, "timelapseStart": 1641921049594, "timelapseEnd": 1643055460715, "timelapseStartLQ": 1641921049594, "timelapseEndLQ": 1643055520725 }, "storage": { "used": 427349245952, "rate": 642.30288184191 } } } }, "6.318183100025635": { "raw": "AQEBAAAAAHR4nB2MQQrCMBBFr1JmbcBkBjvxBuK6B5gkP1CwrUhFpHh3SZb/Pd4/yPI+bytdB3o/i+2g00ArPlMft9IEIJfKFpwIqhPl4JQ1uuzZj6nEpKlny1bwuOPbmmwLXtbo3D/EmLnEyBWKAJUxpBzPnn5/65QlHQIBAQAAAAFGeJxtUsFOxCAU/JVNz8Y8oFDqUeNND5sejQe2fdsSkVag6mbTfxdas9vVvcHMvGHmhWPmsO5do21b1R02o0Gf3W1eXm82mQ8qpMsxc9/3hzATtOCUEiEY41ERTjjJy6KglHEmCYnMl97rebTulLVo4tmOxkRm7/BjRFsfzpDR9q0aEJvn3eDPsNetVWY7KqNDUnM4gVVwaNvQRRSmiO5UCOgO84sDuhptUC2erbR/6JRrY8uI7ZXxmKwM4lDFkkmYNdrXfYxaB2yy5PmpG+yX+qcVBeVCaitYSSSTnJaljNKT4NE2M50z4LwQQAms6Xn+abt2YAD5H4dfweJRAgiWVq3f0ajB47UMfC24yCAgZsj/zV9muOKwzkAAGBfT/CN6t+z1mI0e0zsxP8+BMcFZIVKRZZ2S0dscCklKCaIo5TRNP6O4uBI=", "action": { "action": "update", "newUpdateId": "ee46f3a2-44ef-4832-8389-c1317bd9b8be", "modelKey": "camera", "id": "4a333d993fe8e2e8472bc901" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 27522166335, "txBytes": 1497722353811, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852998, "recordingEnd": 1643055760210, "recordingStartLQ": 1639183853004, "recordingEndLQ": 1643055790063, "timelapseStart": 1639183852995, "timelapseEnd": 1643055600214, "timelapseStartLQ": 1639183852995, "timelapseEndLQ": 1643055100356 }, "storage": { "used": 3005403365376, "rate": 832.407819806798 } } } }, "6.3199059999897145": { "raw": "AQEBAAAAAHZ4nB2MWwrCMBBFt1Lm20CSjnm4A/HbBSSZO1CwrUiLiLh3ST/vOZz7pdK2aV3oMtD+lLKBTgMteN+PcZUuAqv1uYppKTrDgmhSttWEsUJUM7caejavgscNn960MuNVOp2ODweMKZ9RGepdtJaDZvWNfn8GDiWvAgEBAAAAAUp4nG1SQU7DMBD8SpUzINtrx3GPIG5wqHJEHNxkm1gYJzgOUFX5O3ZahRZ6i2dmZ2dWOWQeq87XxjVl1WI9Whyy9erl9WaVDUGH9Dhk/vt+H2aioEIoludMRUFYYOC5pERKVfA8El9mZ+bBqtXOoY3fVEAkdh4/RnTVPiJC5iJC1ri3skesn7d9snKjtWm5aZy2m1FbE5KaErKgZfDomtBG+JaLKeJbHQL6/byzR1+hC7rBXzczPLTaN7FlxHbaDpjMLGJfxpJJmNVmqLoYtgpYZ8nz09TYHesvJwrahxQmB8WoYgUnMh1iETy6eqY5ECGEBMGKc3qef9qcO4Aq5B+Hk+DkwQVJHsG8o9X9gFcyADkXXGagIHLyb/4ywxWHiwyQekzzH9H5410P2Thg2gNKRgMueTE3PR6Twh0QYACMU1IwNU3TD/xSteE=", "action": { "action": "update", "newUpdateId": "64f029bd-c871-4de7-890b-63bedff94cb6", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 8155926629, "txBytes": 346710779846, "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 } } } }, "6.321674300008453": { "raw": "AQEBAAAAAHZ4nB2MQQrCQAxFr1KydsBpxjHxBtK1B8gkGSjYVqQiUry7Tpf/fd7bQHQdlxkuHbweJqvDoYPZ37d9XK0deBYhjRhy9RRS5T4UlxwU2WPCokq5adNifh/80xyVyZ/S6Lg36lEtngoVN3YkYu4p/6vw/QEOQiXgAgEBAAAAAUJ4nG1STU/EIBT8K6ZnY6BAAY8ab3rY9Gg8sO3bloi0AlU3m/53gZrulzeYmTfMm3AoHDSDa7Xt6qaHdjLgi/ub17fbm8IHFdLlULifh33IBBa8IpQKLEVUhBXnCCHOaVnyEkXiW+90nmx6ZS2YeLaTMZHZOficwDb7I2S0fa9HgPZlO/oj7HVnldlMyuiQ1AytYB0c2C70EUVzRLcqBHD7/OIIrgEbVAdHK+0fe+W6uGTEdsp4SFYGYKzjjklYtNo3Q4zaBGiL5PmlWxiW7deGgnIhlVARiQURrJScR+kqeLJtpilBjMU6ZCVO6Tz/vDl3kOWFw59g8RBSVkkQ9AcYNXr4JwPFp4KzDExKKcTV/GWGK4fTDBgxhMScP8Tgll4PxeRheQcRJCtGSUnyIkudFLM7jASXFBEuMZ3n+RdltLgX", "action": { "action": "update", "newUpdateId": "37aa8c13-6fe4-4f92-bea6-c39e143bcc86", "modelKey": "camera", "id": "f0cd15b8bed9e38899286a8c" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 18763448198, "txBytes": 700077422720, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852977, "recordingEnd": 1643055774968, "recordingStartLQ": 1639183852992, "recordingEndLQ": 1643055789962, "timelapseStart": 1639183852941, "timelapseEnd": 1643055599988, "timelapseStartLQ": 1639183852941, "timelapseEndLQ": 1643055105008 }, "storage": { "used": 1603096543232, "rate": 415.108794037914 } } } }, "6.323465799971018": { "raw": "AQEBAAAAAHZ4nB2MQQrCQAxFr1KydmCcSZPRG0jXHiCTpFCwrUhFpHh3mS7/e7y/g+g2rQtcO3g/TTaHUweLf+7HuFkTxpiKcw6YxxKQDEO99BaqcB0TpShGLZtX88fg39aozP6SRqfjQ5GSRrZqlMVK7IXzuSjD7w/piSU6AgEBAAAAAUN4nGVSTU/EIBT8KxvOxpTPUo8ab3rY9Gg8sOVtS0RagaqbTf+70Jru163MzBtmHj0iD03vtXFt3XSgRwsBPWze3u82KEQV8+GI/O/jIc4EkYwJyXjFq6SIK465pFKSQpYUl4n5MXszjzadcg5s+najtYnZe/gawTWHE2SN+6gHAP26G8IJDqZ1ym5HZU3Mal6sYB09uDZ2CS2mhO5UjOAP840D+AZcVC2crEx46pRvU8uE7ZUNkK0swFCnklmItAlNn6I2ETTKnt9GQ7/UX1cUlY+5raAVToU5qQqRpKvg2emZZrTgvOQcF8U5Pc+/bC8dML5y+BcsHpJVQuZVm0+waghwm0HKC8FFhhQCC34zf5Xh1uE8A0mKspzmP6L3y16PaAyg50fBhBAhKWOU5CLLOnHJ6T0WggieCMqmafoD+iK4SQ==", "action": { "action": "update", "newUpdateId": "d7428e73-43f8-46d4-b95d-ba7bf2620ad6", "modelKey": "camera", "id": "c462c07dbd63ad805a7318c7" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 28446845959, "txBytes": 1583882087317, "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 } } } }, "6.458593200019095": { "raw": "AQEBAAAAAHN4nB3MSwoCMRBF0a00NTYQOh8r7kAcu4BKXhU0aNpBUETcu8TphXM/JG1se6fTQgLQYaGur+sDMvSMWWNQ9sbNtaTmolR2JRucxGpstlqKfrL7Dr1d9D2NPrWPGbf/Ih2zcCoB6mvByhkpwLPQ9wdUNCRCAgEBAAAAAOl4nG1QTWvDMAz9K8XnFpw6TuJdtwzKaBl0O40dXFuDQGwHWx3NSv775NC1FHbT+9DjSWeG4wDsYcES+BTiNmAXPFsSRh2RhKIqBZeyVqVUknjw9h82mRBzDM+zo80nQDD4RuGJ6I/Pe779Bo9XwQFqq1ETPl96bOwMEE65A5OmOBRFZdYKSpBS1/xLqFpUbFr+Nd9pB3c7j6M9ucUWhp9Bz77LpTfH+/Prat/u9i2bsm4oIeYS/tj3hAeq283vuFLHBPGGOjt3qyvdSCUs8IOy66ayUlje6PxEFyz0LzBmH+Sb2fQLQv9vHA==", "action": { "action": "add", "newUpdateId": "43e80f8c-c5ef-4ab8-96fd-a4bf8ff2f540", "modelKey": "event", "id": "576a8593de0b9d286d53d08a" }, "data": { "type": "sensorMotion", "start": 1643055794595, "end": 1643055794595, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Cydxm Mepzpa" }, "type": { "text": "UFP-SENSE" } }, "camera": null, "partition": null, "user": null, "id": "576a8593de0b9d286d53d08a", "modelKey": "event" } }, "6.4596403000177816": { "raw": "AQEBAAAAAHd4nB2MWwrCMBBFt1Lm20Aek5c7KP12AdPJFAqaiK1IEfcu6ec9h3O/QLyvrcJ1gPez0C5wGaDK53aOsXThEL0uwaskyApjsCoRoUpzsA550TpTzx6tyH2Sozeb1K29Ol3PD89mNiawzYLiPUW9uBxdgN8fvkMkeAIBAQAAAAA5eJyrVsos9s0vyczPc0ktSU0uSU1RslIoKSpN1VFQykURdywByhiamRgbmJqaW5qYWprWAgCbehPR", "action": { "action": "update", "newUpdateId": "34450d65-8e4c-4762-8aa4-8b6234cf009a", "modelKey": "sensor", "id": "5c1b116c29e4e55a70f39736" }, "data": { "isMotionDetected": true, "motionDetectedAt": 1643055794595 } }, "6.532936399977189": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbWDSTGrjDaRrDzCZmUBBU5HWIuLdJV3+93j/CyzrvFS4dLA9lVeDUwfV9tsxrtpEkR55GAeH4rMjSexSoeiUNRCX6EeTlj0Wtftkn9bU96uh+TjoKcRSIueAqN4nPSPlbAF+f5IuJJwCAQEAAAABRHiczVK7agMxEPyVoDrm9H6kTxFwFYc0IYVyt2cL7iEkneFi/O9Z2QEH/ANpJDSzmt0Z6USWWMII5OlBW2aspZQ+PpDB57IDmBBmWgqqlHFSK45UXnOB8WXqZyRPpI3LZfdHSH4P29l3VQsLsSoiVpZ0VT8jNsI4p/X3gg+D/xoupLAW+2BBn6ACjHJObRWZix8QQFpQLqtGLnPtdCfChJXaKWOUlqwOEPIrtGs7hGmPdO+HDPV6+K7VglmlnHVScMGr5bLGipPkQ0fwvGTorvapM84IJaSp/js4hhYyUh8nMs4d1PHI7o1pjO79GZcN33JGBbk1u3COUq0Zl9XWAfxQDjWIkhZAV/9R6rOmXcbY57usnRba6T/vw6i0yuhbblbiX8Jj9OVQR2mOPjVzLM0yhT5sYpoLtKVBeXI+n38AND2zgA==", "action": { "action": "update", "newUpdateId": "fc20a686-0c1b-4c9a-9f45-dad34af518ec", "modelKey": "nvr", "id": "2435ff5ab300d119d704bbe3" }, "data": { "uptime": 681788000, "lastSeen": 1643055794652, "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": 963696, "total": 1048576, "used": 84880, "path": "/var/opt/unifi-protect/tmp" } } } }, "6.581093100016005": { "raw": "AQEBAAAAAHR4nB3MQQrDIBBA0auEWVeIZnRMb1C67gHUmYFAa7KQllJy99RsP7z/g1Tasla4DpCY4TJAlc9j49Tkxr0SIXuNaMghGSS2JjOikYI5aLBjcCd7rSzPu3y7kbfU1uNyLqxLOPmss9fs4lRG/jv1EfYD/XAjQQIBAQAAAAD5eJxtUF1LwzAU/Ssjzxs0TdOuPlthKFOYgiA+pMnNKLZpTVKxjP53b8K0FHzL+bj3npML8dMA5GZDHBjX28cBDCiyReyF9SjQPGMJ50WZFTRFHoz6h3Wyt2FNEt4dTt6CB+mfcblD+u19zVdfYPyf0IEXSniB+HLNcVARePgOGQiXtKY0l2kJGXAuikSzsmA5mbe/yY+ig9XMw6gmOX1uXmvz0TbRee26eF7unnan6niqotz1o4mRV56zsOIMZA4OiUdsyGnGtkU8YKPGN71ZqNGBXVATehCaiozxWpdc1+meyUTpnGq+J/GogvYepuCD8C1k/gGO+Hq6", "action": { "action": "add", "newUpdateId": "774d5f84-7247-47d1-bd44-ec4b6f61062d", "modelKey": "event", "id": "12a435bf95fb283c0df61f58" }, "data": { "type": "sensorOpened", "start": 1643055794712, "end": 1643055794712, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Ludycyq Xbnkli" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "garage" } }, "camera": null, "partition": null, "user": null, "id": "12a435bf95fb283c0df61f58", "modelKey": "event" } }, "6.583303999970667": { "raw": "AQEBAAAAAHV4nB2MWwrCMBQFt1Lut4G8H+5A+u0CYu4JFDQRWyki7l3SzzPDnC/lsi290Xmi95PzBjpN1LBfj3HhITimHCGzQJRWWO2MiKhS6GrA7IN00Yzs0Rn3GZ/RrGhrfw26HB+uqJtSvugEC+dykNWkYDz9/udHJOMCAQEAAAAAPXicq1bKLPYvSM1LTVGyUigpKk3VUVDKB/KDSxJLSoudMxLz0lNTHEuAkoZmJsYGpqbmlibmhka1AB9uEbM=", "action": { "action": "update", "newUpdateId": "d89a8e0a-e804-4253-8ef0-2f3edd670583", "modelKey": "sensor", "id": "5c1b116c29e4e55a70f39736" }, "data": { "isOpened": true, "openStatusChangedAt": 1643055794712 } }, "9.09117349999724": { "raw": "AQEBAAAAAHR4nB2MWwrCQAxFt1Ly7cA4j5C4A/G7C8hMUihoK9oiRdy7zHyee7jnC1K3eV3gMsD+VNkMTgMs9hk7XLWJMlnhgsFRlOJSCt6xndkpiqpGzoyx3R6r2v1mR4+97dW2uRdSzZOPQpWKJhIl84iBMvz+txUkzgIBAQAAAAEkeJyFkc1uwjAQhF8FRT2hHOz4Jz8P0HMv7aXisLbXwcJJkB1AqOq7NwSoCm3xxbJG34x3xx9ZG4bdNmbN4j2TVDErNCe0FlAShiUh06mzVb7IwPsXDJ2L0Q39mfduj3uHh2bZGCmU4lRTbiRAXSgLHKSh2eT8gfFaKSUIEbzgQAwTVBSypHCHqaLSrLK0KIiVijPLakaMtfdYXU0jC8agKhEkq6dHDWfVCev3oQkIplnemPSkjXiSdhHPRH4IbsTcoMcRm6er+UadQ+amLgn5b+fyO/UxEvUazc5jAvPYgj6+Pr/9MeQMqOBMm0rR0GGAf6FZ6dA4uAjz/dpZux5Tu2Afh9TCZhiCH/QmgZ3+J1mecXHr4Zjaeu26h82sPr8AeQX7wQ==", "action": { "action": "update", "newUpdateId": "bfeb9b62-83ab-4420-9e19-d6addd395963", "modelKey": "user", "id": "4c5f03a8c8bd48ad8e066285" }, "data": { "groups": [ "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "9.091784799995366": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm28A0SZPWHUi/XcC8AgVtRStSxL1L8nnu4Z4vkOzLtsK5g/dDaTc4dbDa59rgolWwiDc/oUtYoosswfE0RqfqCyIPMSept/umdpvtaLGXPeu2tAJxSTGTaUgDUh57MaY+FPj9AbKXJToCAQEAAAAAp3icVY5BCoMwFESvIqGr4iLFtkpO0X1xEZOpfpoYSaJSSu9eJVDSzcCfeZ+ZN+u9m6fARHFn11NXPS7qzKumkTWvUHO+qWZtWTBpzA3eUgjkxsSPixceUosj2wBDCxbCKtTmRezWHJCIcvUUUWoYRIjDnnWedI/sX0kLLxO+i4UmmSLtnDdOPf/a+iFmd8AYXD5nn4Lc0BQmI19540D2t6D9fAGKX1VP", "action": { "action": "update", "newUpdateId": "bcc2e290-60f4-4bc3-b984-dd2f00b5476c", "modelKey": "user", "id": "abf647aed3650a781ceba13f" }, "data": { "groups": [ "61b3f5c40388a703e70003ed" ], "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:*" ] } }, "9.092388899996877": { "raw": "AQEBAAAAAHR4nB2MQQrCMBBFr1JmbSBMZqbGG4jrHmCSTKCgrWiLFPHukizff/z3Bc3bvC5wGWB/Ft0MTgMs9pk6XEsTFDyPZOhSzNERMzpljc6fBYMEsoqp3R5rsfvNjh5726ttcy9oscwhUJJYWaqoZBoZPfz+Zbwj1AIBAQAAAAEGeJylUrFuwyAU/JUIdbI8OHLTpHxA5y7tUmUgcLFRsYnAdhRV/fcagxKiJHjogsTdvXvvcfyQyuj+YAldfJGX5a7cr/hzUW42bF2UWBfFeAqSLy7c8nUVcZxsR5Ip9Q7TSGulbr1XOxhqwATNXLWSAwaJI+Uj1sFBvYVX5EcjO+QCCh3ok+N2RooKUT1nDQzzcnc0EJJ5SmhtlObfV92quovuFq3V8ThuFMSAkPag2CnuWMsmnuBf+7jiK3QymV4+OOS3ldnZNS2xvIboFWZkChXjp4+3zztDZtGjp11CEI9El3ACEAXlU5nZxSeVFp0TT8tCyDNeIfiZrafPkNBsf/8AjvQY9g==", "action": { "action": "update", "newUpdateId": "430574e2-b9c9-4552-a5a9-08623634ef2b", "modelKey": "user", "id": "adec5334b69f56f6a6c47520" }, "data": { "groups": [ "61b3f5c40388a703e70003ed", "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "9.093002400011756": { "raw": "AQEBAAAAAHR4nB2MQQoCMQxFrzJkbTdp06beQFx7gKRNYUBnREdEhrm7tMv3H//tIGWb1wXOE3yeVTaD0wSLfW8DLrULbBpCJHO1kLqgWR1TEeeTJI+5pVhLvz3Waver/Ubsba++zaPAlH2kJEiaGKMEZNYoDY4/rqgkeAIBAQAAAAEGeJylUrFuwyAU/JUIdbI8OHLTpHxA5y7tUmUgcLFRsYnAdhRV/fcagxKiJHjogsTdvXvvcfyQyuj+YAldfJGX5a7cr/hzUW42bF2UWBfFeAqSLy7c8nUVcZxsR5Ip9Q7TSGulbr1XOxhqwATNXLWSAwaJI+Uj1sFBvYVX5EcjO+QCCh3ok+N2RooKUT1nDQzzcnc0EJJ5SmhtlObfV92quovuFq3V8ThuFMSAkPag2CnuWMsmnuBf+7jiK3QymV4+OOS3ldnZNS2xvIboFWZkChXjp4+3zztDZtGjp11CEI9El3ACEAXlU5nZxSeVFp0TT8tCyDNeIfiZrafPkNBsf/8AjvQY9g==", "action": { "action": "update", "newUpdateId": "2fb4465e-dc5b-4b9b-85ca-37a7329f76dc", "modelKey": "user", "id": "8593657a25b7826a4288b6af" }, "data": { "groups": [ "61b3f5c40388a703e70003ed", "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "9.093608300026972": { "raw": "AQEBAAAAAHV4nB2MWwqDMBBFtyLz3UCseUy6A+m3CxhnJiC0WqpSRNx7ST7PPdxzAvE2LTM8Gtg/QpvCrYFZf0OFXorAiJHojqbtSI1zWQxaZZO67FubEga05fZeRF9PPWps1W/ZploQJs2JRyQV6znKGDySC3D9AZEoJQYCAQEAAAABBnicpVKxbsMgFPyVCHWyPDhy06R8QOcu7VJlIHCxUbGJwHYUVf33GoMSoiR46ILE3b1773H8kMro/mAJXXyRl+Wu3K/4c1FuNmxdlFgXxXgKki8u3PJ1FXGcbEeSKfUO00hrpW69VzsYasAEzVy1kgMGiSPlI9bBQb2FV+RHIzvkAgod6JPjdkaKClE9Zw0M83J3NBCSeUpobZTm31fdqrqL7hat1fE4bhTEgJD2oNgp7ljLJp7gX/u44it0MplePjjkt5XZ2TUtsbyG6BVmZAoV46ePt887Q2bRo6ddQhCPRJdwAhAF5VOZ2cUnlRadE0/LQsgzXiH4ma2nz5DQbH//AI70GPY=", "action": { "action": "update", "newUpdateId": "8787aa28-13ae-44fd-80ec-93f510998680", "modelKey": "user", "id": "dcaef9cb8aed05c7db658a46" }, "data": { "groups": [ "61b3f5c40388a703e70003ed", "61b3f5c40195a703e70003ec" ], "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:*" ] } }, "9.094171800010372": { "raw": "AQEBAAAAAHR4nB2MQQoCMQxFrzJkbSFt2tR6A3HtAWKTwoDOiI6IiHeXdvn+478vSN3mdYHDBK+7ymawm2Cx93nAUbtgTiHGyC4H8S763JxgJid1X1Cbqs/Yb7dV7Xqyz4g97dG3eRQulVSZKCUqmEsKws0CKvz+Y2QjxwIBAQAAAACneJxVjkEKgzAURK8ioaviIsW2Sk7RfXERk6l+mhhJolJK714lUNLNwJ95n5k3672bp8BEcWfXU1c9LurMq6aRNa9Qc76pZm1ZMGnMDd5SCOTGxI+LFx5SiyPbAEMLFsIq1OZF7NYckIhy9RRRahhEiMOedZ50j+xfSQsvE76LhSaZIu2cN049/9r6IWZ3wBhcPmefgtzQFCYjX3njQPa3oP18AYpfVU8=", "action": { "action": "update", "newUpdateId": "66524446-72a1-417f-a073-ac890dfdd170", "modelKey": "user", "id": "bc3dd633553907952a6fe20d" }, "data": { "groups": [ "61b3f5c40388a703e70003ed" ], "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:*" ] } }, "10.814676899986807": { "raw": "AQEBAAAAAHR4nB3MwQrCMAyA4VcZOVvY0qUxvoHs7AM0TQYD7TwURYbvLvX6w/cfkEvb9gqXAbIZnAao/r49LTe/Wq+ROBXxGERlDXMiD2fXMSCuWTURIktnj938vvinG395bT1u/wWO0dikJJaJdaboSWlShe8PDQUjSgIBAQAAAADyeJxtUMtKxDAU/ZUh6xlIX+nErXZAlFEYXYmLPI5YaJOSJuIw9N9NymgpuMt53HvPyYX48wBysyEjzGjd0wADTbYRe+F8FDJWFrSqar7nRR55GP0POyrr0hqa3n2cvIOH8i9x+Rjpt/c133zB+D+hhxdaeBHx5ZrjXs/A4ztlIDQHuOT8I6s140XJQNVeUUqm7W/yo+ixmrlVnwibxyBgZtu16GJ4PTzvTs3x1Mxyb4OZ86482lpHpqSruN+liCZ0XcRDLNP61pqFCiPcgtpUgeS00LXmitU8q2VZFWCyyqQk80mN7gHn5EP6ETL9AP7zeKk=", "action": { "action": "add", "newUpdateId": "3576c9e3-9b9f-465e-8eb0-22fabb652279", "modelKey": "event", "id": "203d7d9c67917b453e6b51bb" }, "data": { "type": "sensorOpened", "start": 1643055798932, "end": 1643055798932, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Ccheu Luaen" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "door" } }, "camera": null, "partition": null, "user": null, "id": "203d7d9c67917b453e6b51bb", "modelKey": "event" } }, "10.815824599994812": { "raw": "AQEBAAAAAHd4nB2MUQrDIBQErxLedwU1ifp6g5DvHkCfGwi0WpqUEkrvXsznzjD7pSj7WgtdO3o/c9xBl44KPrdzTLmJlJBFj1BmMVCDt0GF4ESJ7W30bjRJuGWPmnGfcbRmQ9nqq9H1/NAW4MS8GJ8d94ODliBa0+8PCGslcAIBAQAAAAA9eJyrVsos9i9IzUtNUbJSKCkqTdVRUMoH8oNLEktKi50zEvPSU1McS4CShmYmxgampuaWFpbGRrUAH5ARuw==", "action": { "action": "update", "newUpdateId": "bbedc05e-1f1e-4728-886c-c232a7651bc9", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "isOpened": true, "openStatusChangedAt": 1643055798932 } }, "11.673517499992158": { "raw": "AQEBAAAAAHN4nB3MQQrCMBBG4auUWRvIlCZNvIG47gFm8k+goKmLoIh4d4nbB9/7kJS+H43OEwlAp4mavbYHpNsFo/oCwVrhlEN0i4/FKfzsQpIqIfsUMw92P2C3q72Hsae1PuL+X2QJzHOFQUVXTskgUpPS9wc7siSsAgEBAAAAAK54nFWOuw6DMAxFfwVl7kB4JTC3U9duVQcndiQkQhCYSgjx700YWnXzOb66urvgbSLRZcIH7sMoLplYGGaOSjZVmde10loVefI2zCl63j5mrsRk+RELlqifr39/e9PI34cnBgSGyPsR2YKnOZGQtoUCTYWuIYdQKSjrvDWUpkyxrT93ddm4DkNU60Lzj3pMFS3UUhYOCQ0YJbUmBHDapAofkIY7bSlHaZI4Ps8RSuU=", "action": { "action": "add", "newUpdateId": "0cdad7fd-b156-406c-bd02-58afa5908691", "modelKey": "event", "id": "9a5112fdedbab7188edaaf8b" }, "data": { "type": "motion", "start": 1643055788720, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": {}, "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "id": "9a5112fdedbab7188edaaf8b", "modelKey": "event" } }, "11.772577999974601": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm20Ca5ukOxG8XcCczhYJtRSoi4t4l+bzncO6XUI9l3+g80OshOJROA236vvVxkSY4l4wQnQlWkvEIbIpOznAeY47Jw9qerbvo/aqf1lSs+kSjS/8Ya4ET9jJHnQU+YQq2sNLvD83qJWoCAQEAAAAAx3icXVDLCoMwEPyVkLOH+Ei0PffQSy/1WDwEXVqhNRBXQcR/7zRpDxY2MLszkx12lU878sVx7wZ5FKkpcqV1WVVlphIh+zFSJ2JqmTpI2E8EhmYauGbLI2arfP1+WCW7zi5ARQGZncnbO6E1B7SfZSe7fDw3XSZCY5hmJgKj8WDSeQOppxYbzm7yUZ2IDDRYOBKhAi4C1qEwyZsNxvFlPe+SqF0Q9ZdDBe+umm2LCZzv+uFetw/qpicFeRPOcv1x34tsb+ubXFs=", "action": { "action": "update", "newUpdateId": "b898a562-50d7-4a5b-9e32-b8168674a00e", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "lastMotion": 1643055788720, "isMotionDetected": true, "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 ] } }, "recordingSchedules": [], "isRecording": true } }, "11.967715699982364": { "raw": "AQEBAAAAAHR4nB3MTQrCMBCG4auUWRuY5qdJvYG49gCTzBcoaOoiVES8u8TtC8/7ISl92xudJxJVOk3U8Lo9VTouOmr0bl5nH0zlXI23KiatLpgcYoZFXBB4sMeuuF/xHgYHWh9x+y/EM9uawOC0MMSVmIt3Qt8f+e8jVwIBAQAAAADoeJxtkE9rwzAMxb9K8bkDpUndZOdlMMbKRtvT2MGxFQiL7WCrZaXku1cO/UNhN733JPGTToKOA4rnmYjoog8fnjrvxJw1qUAcZLLIYbksATKQ7KMz/7hR+5DWQKotT74goaYtL49sf/88+vUBHd0Ci6SMIsX6dOF4M5Mg/EsMAhaIVVNVbbYyssoLiaBLDSDG+ZV8rSw+zHw1v7P60NpmmLoud97z3evn06Zeb2oxplzzfEgIbt/3rAeG7aZn3Kx9xHBXXUIUqgBYtCUCQikBVa5XjS5ylV5ovcH+HY+pD9PFYjwDJ0xubw==", "action": { "action": "add", "newUpdateId": "74319145-f0bf-42da-8935-b57be2e76e50", "modelKey": "event", "id": "a4002f8e0e0860ea3c7bc43a" }, "data": { "type": "sensorMotion", "start": 1643055800106, "end": 1643055800106, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "02ee9b99f17d69346e0c8c00" }, "sensorName": { "text": "Qbk Evfmbp" }, "type": { "text": "UFP-SENSE" } }, "camera": null, "partition": null, "user": null, "id": "a4002f8e0e0860ea3c7bc43a", "modelKey": "event" } }, "11.968772899999749": { "raw": "AQEBAAAAAHZ4nCWMQQrCMBBFr1JmbWCapNOONxDXHiBmfqGgidiKiHh3iS7/e7z/ppS3pRbad/S4WdpAu44KnqffOFgTHLwM0SfnJ1UXZ8BpkN6lKCZjHjwCt+xaDZcjXq1ZUdZ6b3T5f3hAz6pzP5poiALOU2amzxe02SSHAgEBAAAAADl4nKtWyiz2zS/JzM9zSS1JTS5JTVGyUigpKk3VUVDKRRF3LAHKGJqZGBuYmloYGBgamNUCAJsOE7k=", "action": { "action": "update", "newUpdateId": "0326542a-2899-4fee-9361-a46d67c52e30", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "isMotionDetected": true, "motionDetectedAt": 1643055800106 } }, "12.870153600000776": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbWBq05rxBuK6B8hMfqGgidiKiPTumi7/+7z3pWjrXDKdG3o9UlxBh4Yy3uM+LqkeoYuJg5izYN55teA0TuZaY+l7DQC0aveScLviU50FeSnPSue9wUdAVGRqT2mQzg/gf42Zth8zyCY0AgEBAAAAADp4nKtWyiz2zS/JzM9zSS1JTS5JTVGyUkhLzClO1VFQykWRcCwBShmamRgbmJpaGBgaGFjUAgCuyRQG", "action": { "action": "update", "newUpdateId": "83ad089c-c8c4-4bc8-bafc-1c0955b8eeeb", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "isMotionDetected": false, "motionDetectedAt": 1643055801008 } }, "12.894433700013906": { "raw": "AQEBAAAAAHR4nB2MWwqDMBBFtyLz3UAmxqR2B6XfXcC8BKE+EKUUce8l+bzncO4JJPu4zPBo4FiVdoNbA7N933U8tQhBTVnuwbWI4iJldKzeOxbT3LGEQKlk06L2edmvNjTZRoWO9QOlp6AcdUg2KMVMbed7Nrj+BtgmOQIBAQAAAABMeJyrVspJLC7xzS/JzM9TslIwNDMxNjA1Nbe0tLAw01FQKsvPKUlMTwXKGJnrGQMFilKT84tSMvPSg5MzUlNKc1KLgXLRsbUAD70WHQ==", "action": { "action": "update", "newUpdateId": "c1d67c82-311c-4a71-bd00-bced75bc22a6", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "lastMotion": 1643055799886, "voltage": 27.3, "recordingSchedules": [] } }, "12.914624699973501": { "raw": "AQEBAAAAAHV4nB2MQQqDMBBFryKzNqCDSSbeQLruASYzExCqFrGUUnr3kiz/e7z/BZZrPXaYO3g9lS+DvoPd3vc2Fq1CNJMXVOdTYjcFjo58ITeyEEaZIkWp2XaoPW72aQ1vdnKla/swLGVgtZBtKMiYLYeRQoLfHwdfJgoCAQEAAAAAMnicq1Yqy88pSUxPVbJSMDLTM9FRUCpKTc4vSsnMSw9OzkhNKc1JLQbKRcfWAgAz+Q5S", "action": { "action": "update", "newUpdateId": "cdb85c2d-599a-46a7-85f8-1ac827c4787c", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "voltage": 26.4, "recordingSchedules": [] } }, "13.97603199997684": { "raw": "AQEBAAAAAHR4nB3MQQrCMBBG4auUWRtoMhPTeAPp2gMknT9Q0NRFqIj07hq3D773obS0dat0GSip0mmgitftqanhqr0KR14m680ZnI1AYbIb2Xi1TsKE0aJ09tgU9xnvbrCjth7X/yLbiOhKtkWcSPohiRx8oOMLB7MjEQIBAQAAAAC3eJxVjrEKgzAQhl9FMncwmpjGuZ26dmrpcCYnCGokngUR370Xl+Jy8H//3cdtgtYJRZ2JeYBINyR09AojigsjYsSdrFSZa20sD524CzHd6OJyunuyamb+FhPGOYzic+7vXxzpWEjFgAQeCDhvO2cHA8aUhHQWCt8o31bYelAGSp3b5vhpYltHHcvrbFz6ntEyY/ynzidFIy3aom1kqwqlwFxR2dJokxRD8Ng/cE17mF4S+w8R2VA/", "action": { "action": "add", "newUpdateId": "4393c815-6e3b-4ede-b203-5d12478e01ef", "modelKey": "event", "id": "b19e92fb1f4244a78e493757" }, "data": { "type": "smartDetectZone", "start": 1643055790555, "score": 52, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "metadata": {}, "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "id": "b19e92fb1f4244a78e493757", "modelKey": "event" } }, "13.976735699980054": { "raw": "AQEBAAAAAHR4nB2MQQoCMQxFrzJkbaEtKU29gbj2AMkkhQHtuKiKyNx97Cz/e7z/A577sjY4T/B6KneD0wTNPrdjXHSIVD0W0ugkkjicDZ34hA6zN8P8N4VG9ljV7lf7jsbe1vqAy3FROIUQq5oKSw5EpsyVBLYdtysliQIBAQAAAABHeJyrVipOzi9KVbJSsDTRUVAqzk0sKnFJLUlNLnEtS80rKQZKRCslGVqmWhqlJRmmmRiZmCSaW6SaWBqbm5orxdYCAI6mEzw=", "action": { "action": "update", "newUpdateId": "5f0498d2-b28b-4ce4-b054-470ee478d298", "modelKey": "event", "id": "9a5112fdedbab7188edaaf8b" }, "data": { "score": 94, "smartDetectEvents": [ "b19e92fb1f4244a78e493757" ] } }, "14.750952099973802": { "raw": "AQEBAAAAAHh4nB3MUQrCQAwE0KuUfLvQdTc18QbitwfYTVIo1kakIiLeXbefM8ObDxRZJ1/g2MHzrmU12HWw2OuyhZO2oTfJJBxD5MwhJ8LAWIdg48FyKpUVS2M3V5vP9m5G3R+zy7X10/YSheLeqB914D/CiAmrJYLvDzRdJd4CAQEAAAAAHnicq1bKyU/ODi5JLCktVrJSUPIPcPVTqgUAWUAHOg==", "action": { "action": "update", "newUpdateId": "0ec48c91-1949-4385-95b6-ef7e43ab9d5a", "modelKey": "doorlock", "id": "1c812e80fd693ab51535be38" }, "data": { "lockStatus": "OPEN" } }, "14.751857800001744": { "raw": "AQEBAAAAAHR4nB3MQQrCMBBA0auUWRtIkzGZegPp2gNMMjNQ0NRFUKR4d4nbD+8fwLVve4PLBCwCpwmavm9P4a5XGTXHWnmZxRFZdYg+uuJLcEqYMsXzEpIO9thF76t+htGXtj7i9l94McyBjcXmqIbJCpXkE3x/E8Ej0AIBAQAAAADheJxtUMFOwzAM/ZXI5x3Sps06zgOEQHDhhjhkiTNVtEmVeohp6r9jc1g1CZ/ynt+zX3wBOk8IdwpCzmXI/uttwoQBNgpmcoW4VdnG6LbtdN3tDPOYwj/s7HORQVreIzv3SOjpncfPTH983vL335jo2hiRXHDkGF+uSZ7CHyT8kRRgK5SK2gTcBW1wq7VvTQ3LZk3/6ka8cT264o6oHkpOpPasUi8sg0VMnsVFdqbTMDCeOF1PfU4rdZqxrKiXRKBDbLa1iy7EymBsbDx0B6ut3GzMAYdnPIsO5Yuw/AL2z2z9", "action": { "action": "add", "newUpdateId": "73cca91d-88fc-4403-b0b2-e8467835926e", "modelKey": "event", "id": "0df472afadf13ef46fb8b606" }, "data": { "type": "doorlockOpened", "start": 1643055802893, "end": 1643055802893, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "doorlockId": { "text": "61eeeeef03de9d03e700c532" }, "doorlockName": { "text": "Garage Front Door Lock" } }, "camera": null, "partition": null, "user": null, "id": "0df472afadf13ef46fb8b606", "modelKey": "event" } }, "15.305036499979906": { "raw": "AQEBAAAAAHR4nB2MXQrCMBAGr1L22UDNT5P1BtJnD5Bmv4WCpj6EikjvrvFtGJj5UC5t3SpdBsoidBqo4nV7Sm64SrdJvViri8GowXiZgkk/NEt0zIxQPI89e2yC+4x3b7Cjti7X/4JdnBCiLZpYzxHqXHZaQMcXJh0j8wIBAQAAAAC4eJxVjzEPgjAQhf+K6exQWmnBWSdXJ41DKa8JCVBSDhNC/O9eWYzj+97du3eboHWCOB/EPLhEFxA8PeIIcWREjNgrzEnLsrS1koXM3MeUd2x1/Nu7c9TM/CkmpDmO4vXvX98YaR/IxgByrSPHevuw9m5AykpAhSBdC9NABuVUg8YUlalzp4nTOuo4/HwYl75ntMxIP9W1OaLW1qC0yoeqDoVF0Nrp4Pe3htiiv2HdT+VK4vMFHINQzw==", "action": { "action": "add", "newUpdateId": "8f4d22fb-e0f5-4d65-8e0f-b73999e5c490", "modelKey": "event", "id": "9376e572cf89f17ef33a3fce" }, "data": { "type": "smartDetectZone", "start": 1643055792010, "score": 78, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "metadata": {}, "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "id": "9376e572cf89f17ef33a3fce", "modelKey": "event" } }, "15.53730899997754": { "raw": "AQEBAAAAAHR4nB3MQQoCMQxA0asMWVto2pi03kBce4C2ycCAdlwURcS7S91+eP8DpY1t73BaoKjCYYFur+tDy7CzzhqLZKZILnBKjmoVV7OIQ5RVFZEp8GT3Xe12sfc09rQ+Ztz+C4rRxPMaciucvBJ61GMz+P4A0RsifQIBAQAAAACveJxVjj0PgjAQhv+K6exQ6AfgrJOrm3E42sM0oUDKYUII/92rg8btnufee3OboHVCcTqIFIanOB7ETJCIRWG1ksbUUpmqyt6NKQdlniNnzkjo6MbnM+v7499fXjjQdxGRwAMB87YzO4iYMonCNVD6VvvOYudBV6CMbFrMr0zcFiiMAweHpe9ZLTOmHwWfK7RSWEnblY0DW0uvC1l44z4VcfTYX3HNOcwvif0NzQVI2Q==", "action": { "action": "add", "newUpdateId": "3a796434-2688-4bb7-b977-117fdd116426", "modelKey": "event", "id": "433e706f29ca680d4101d5ce" }, "data": { "type": "ring", "start": 1643055803577, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": {}, "camera": "1c9a2db4df6efda47a3509be", "partition": null, "user": null, "id": "433e706f29ca680d4101d5ce", "modelKey": "event" } }, "15.538912400021218": { "raw": "AQEBAAAAAHZ4nB2MWwrCMBBFt1Lm20DSzMTGHYjfLmA6DyjYVqRFRNy7pJ/3HM79Ass2rQtcOtifypvBqYPF3vdjXLWJKBx7Iw80lBQwK4VaMgXEmLzkIQl7y+ZV7XGzT2uEZ3txo9PxkaRyryOqF3NlPHOmWEeD3x/IsSVeAgEBAAAAAGl4nKtWykksLvHNL8nMz1OyUjA0MzE2MDW1MDAyN7LQUVDKLA7OTSwqcUktSU0uSU0BqigpKk0FSoB0BWXmpSPrMTY1NwdKFaUm5xelAOWCkzNSU0pzUouBiqJjwaYFweSgJtUCANG0KAk=", "action": { "action": "update", "newUpdateId": "0ca02e5f-5861-43d5-9635-4401f6381caf", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "lastMotion": 1643055802728, "isSmartDetected": true, "lastRing": 1643055803577, "recordingSchedules": [], "isRecording": true } }, "15.573490199982189": { "raw": "AQEBAAAAAHV4nB2MQQ4CIQxFrzLpWpIitAzewMzaA2ApySTKuEDNxHh3heV/P+99IElbtwqnCZ6PnJrCYYKq78sY59wPRXKIpGaWKxmfXDBRIxsSz/aPApfStfuW9bboPpyX1tbhOhLeOQ3I5Rgl8YzZW7SZROH7A63zJJICAQEAAAAAHnicq1ZKzUtRslIwNDMxNjA1tTAwMTU3rwUAQcwFdQ==", "action": { "action": "update", "newUpdateId": "e053005e-8cb5-4a37-9e96-5c461cb576ff", "modelKey": "event", "id": "433e706f29ca680d4101d5ce" }, "data": { "end": 1643055804577 } }, "15.84417029999895": { "raw": "AQEBAAAAAHh4nB2MWwrCMBBFt1Lm20AzebsD6bcLyGQmUKyNlEoRce+Sft5zOPcLuexzW+E6wPvFeRe4DLDKcT/Hjbsg4mpsMQo9ZmUdkko2VIVxxCCYAiXfs2djWSb59IZb25ZWHp3P54suUaPEsbJPJpPTzjgSE+H3BzE7JaACAQEAAAAASnicq1ZKyilNLcnPL8lwzs/LS00uyczPCy5JLElVslKoVirOTM9LzAksTczJLKkEipia6ihABYNLilLz0ksygKK6Zha1tQADlBo3", "action": { "action": "update", "newUpdateId": "bbdf34c3-262a-452b-947f-28027e297b96", "modelKey": "doorlock", "id": "1c812e80fd693ab51535be38" }, "data": { "bluetoothConnectionState": { "signalQuality": 55, "signalStrength": -68 } } }, "15.85507009999128": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbSBpatJ4A3HtASYzP1DQRGxFRLy7pMv/Hu9/iWVbWqXTQK+H8gY6DFTxvu7jrF2ELEGFnYkZRzOVmU3m6Ix4DQXRMkbfs3tT3C749GZFXduz02X/sCOQckrFRQ3JTwFWZrGWfn85uiYnAgEBAAAAAEp4nKtWSsopTS3Jzy/JcM7Py0tNLsnMzwsuSSxJVbJSqFYqzkzPS8wJLE3MySypBIqYGOkoQAWDS4pS89JLMoCiuubGtbUAAyAaLw==", "action": { "action": "update", "newUpdateId": "6bc6dca1-7be5-4f8a-ba71-c3d6fe70ae23", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "bluetoothConnectionState": { "signalQuality": 42, "signalStrength": -73 } } }, "15.856947600026615": { "raw": "AQEBAAAAAHd4nB2MQQrCMBBFr1JmbWCmSZPWG0jXHmDGmUBBE7GVIuLdJV3+93j/C3zbllrg3MH7qbwZnDootl+PcdEmMAyRQk4uKSYXKLITjd7xhD57NjTClj2q2n22T2tWK2t9NbocHz0KWT/KxEMWzYQRJScd4fcHzHklTgIBAQAAAABKeJyrVkrKKU0tyc8vyXDOz8tLTS7JzM8LLkksSVWyUqhWKs5Mz0vMCSxNzMksqQSKmJjrKEAFg0uKUvPSSzKAorrmhrW1AAOcGjI=", "action": { "action": "update", "newUpdateId": "045614f7-7d07-416a-bd63-a903f3ae0e10", "modelKey": "sensor", "id": "20b1e28b9a5fbdf1060bf7d8" }, "data": { "bluetoothConnectionState": { "signalQuality": 47, "signalStrength": -71 } } }, "15.857984500005841": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbWCmmSSMNxDXPcCYTKGgidiKSPHuki7/e7y/g+ZtaRXOA7yfRTeD0wDVPtMxLqULJAqBxbvIKI7Rk1NM7FDmkVhL9Cw9e7Ri96t9e7NaXdur0+X4CJluRDGPYmwhaMLZS/IRfn9xMyN8AgEBAAAAAEp4nKtWSsopTS3Jzy/JcM7Py0tNLsnMzwsuSSxJVbJSqFYqzkzPS8wJLE3MySypBIqYmeooQAWDS4pS89JLMoCiumYmtbUAA6MaNA==", "action": { "action": "update", "newUpdateId": "01155493-6409-4031-a074-09f214ad6349", "modelKey": "sensor", "id": "5c1b116c29e4e55a70f39736" }, "data": { "bluetoothConnectionState": { "signalQuality": 65, "signalStrength": -64 } } }, "15.959116899990477": { "raw": "AQEBAAAAAHV4nB2MQQrCQAxFr1KydmCcSSfRG0jXHiCTRCjYVqRFinh3mS7/e7z/BdF1XGa4drC9TFaHUwezf+7HuFkTTmT2QA5OlQMipXCpUUPuMyW2WNSwZdNi/hx8b43K5G9pdDw+FEvSSFatZDGOvVA+sxL8/uhLJRMCAQEAAAAAInicq1YqSk3OL0rJzEsPTs5ITSnNSS1WslKIjq0FAIa2Caw=", "action": { "action": "update", "newUpdateId": "e77ddf48-e7b8-4472-9b0c-353728d06cd4", "modelKey": "camera", "id": "c462c07dbd63ad805a7318c7" }, "data": { "recordingSchedules": [] } }, "15.993363299989142": { "raw": "AQEBAAAAAHV4nB2MQQrDMAwEvxJ0rg9JrEjuD0rPeYAsyRBoklJSSgn9e7GPO8PsCaLHsm9w7eD9NDkcLh1s/pnbuFkVLL17RAxEWEKUUQJPEUOZWBOjJy1Ss3U3f9z9WxuV1V+NLu1D8ugDlcEQixj3ZKKUc4LfH/jKJhUCAQEAAAAAInicq1YqSk3OL0rJzEsPTs5ITSnNSS1WslKIjq0FAIa2Caw=", "action": { "action": "update", "newUpdateId": "8a1ee455-775f-4a3a-8645-f68c985e9cfa", "modelKey": "camera", "id": "ab3e27f2d55fad817dac7bb9" }, "data": { "recordingSchedules": [] } }, "15.99451470002532": { "raw": "AQEBAAAAAHZ4nB2MSwrDMAwFrxK0riGKHVfuDUrXPYBkyRBoPoSUUkrvHuzlm2HeDzgf07rArYP3pnwYXDpY7PNs465VRNTBOESHgugCeXR8leBE+uBLwuKZazavaq+HfWuTeba90al9lD4rjkJimswTpTRQZMrwPwHkUyVhAgEBAAAAACJ4nKtWKkpNzi9KycxLD07OSE0pzUktVrJSiI6tBQCGtgms", "action": { "action": "update", "newUpdateId": "61d2ea46-1b11-4831-a7b4-bb043f91f3aa", "modelKey": "camera", "id": "f0cd15b8bed9e38899286a8c" }, "data": { "recordingSchedules": [] } }, "15.99775859998772": { "raw": "AQEBAAAAAHd4nB2MUQrCMBBEr1L224Am2SbrDaTfHmCzu4GCbUVapIh31/RnYObx5gMs67jMcO1geyqvBqcOZnvfj3LTBnzSJBjUVcLqovTFZf4HZgqBSbI3bNq0qD0G25sjPNmL2zoeHxch9lqi1t6qckwc8EzF4PsD/zEmDAIBAQAAAACheJxNjE0PgjAQRP8K6VmTIhQ/rpw5KEfjoSkL3aS22i4xhPDf3caLx3nzZlbhdKIuEAYvLkXZ1JVU6iSrszzvChHBhDign3pjYZgdJJbuD24+OGIbvAeTpz1pAq5WYaxm6PKXqtgbI7xn8GZhoo6NYvSyy+2nH6TknHDy2l1n7ZCyVv7RniL4iSzjvaozTjhwEF2IhKlo9ROiFtv2BeDFPvo=", "action": { "action": "update", "newUpdateId": "27d7c53d-f95f-4c6b-8a6b-58933a9c82e5", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "lastMotion": 1643055803909, "recordingSchedules": [], "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -54, "ssid": "Mortis Camera" } } }, "16.000480599992443": { "raw": "AQEBAAAAAHZ4nB2M0QrCMAxFf2Xk2UKb1tj5B+KzH5A0CQzcJjIREf9dusd7Dud+gds2rQucB3g9lDeDwwCLvW/7uGgX2JwdKYZapYQSWwoSs4dqacx0OqqU3LN5Vbtf7dObxrM9udNp/zB0j6xGYtGRUUwoVRrh9wfu+yW8AgEBAAAAALp4nGWPOw/CMAyE/0qVGaSWNLxWWBmgI2KIUrexlCaQOEII8d9xeS1s9nd38vkunE60C4TBi3VRzWtZKrUspVyoSSEwNYOOtAUCQ9Cyg2IGFs72dtAEDGrJawQTYou+b4yFNjtIrBxPrFyxw03wnvN8oqF36C6M1Qwdz3N2dREuGby58T6r5eL/QsLea7fP2iGNrqosf7ShCL4ny3iqViNOOHYVuxAJU7HRA0QtHq+HDt+qn2ceT4+PUmc=", "action": { "action": "update", "newUpdateId": "2cfaf260-88b4-40c1-b03f-8e193675db43", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "lastMotion": 1643055803375, "isSmartDetected": true, "phyRate": 43, "recordingSchedules": [], "wifiConnectionState": { "channel": 6, "frequency": 2437, "phyRate": 43, "signalQuality": 100, "signalStrength": -59, "ssid": "Mortis Camera" }, "isRecording": true } }, "16.00195479998365": { "raw": "AQEBAAAAAHZ4nCWMQQrCMBBFr1JmbaDJJM2MNxDXHmCSmUDBtiIVEfHuEl3+93j/DVL3eVvhOMDjprIbHAZY7Xn5jZN2EXVKbWrVJZLsovfoiqXiLOaRORBSaD1bNrXr2V69qbLYXTqd/x+CiMqMzciCUcyhVB49fL7WXyTBAgEBAAAAACJ4nKtWKkpNzi9KycxLD07OSE0pzUktVrJSiI6tBQCGtgms", "action": { "action": "update", "newUpdateId": "4d65f6fc-58a7-4113-be5b-e4709928382f", "modelKey": "camera", "id": "4a333d993fe8e2e8472bc901" }, "data": { "recordingSchedules": [] } }, "16.00301149999723": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm20CYZJLWHYjfLmBegYJtRSoi4t4l/bzncO4XWPd5W+E8wOthvDucBlj9fTvGxbqIEjEhcSiRJWRSClNTDiwtVh1VMUrPls38fvVPb5QXf3Kn8/FBY2GpiiIlY9LUzHL1ieD3BwT0JdwCAQEAAAAAPnicq1bKSSwu8c0vyczPU7JSMDQzMTYwNbUwMLa0MNNRUCpKTc4vSsnMSw9OzkhNKc1JLQYqio6tBQDcUxFq", "action": { "action": "update", "newUpdateId": "0b02325a-60ab-45c5-9fca-abf07c8cc20b", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055803986, "recordingSchedules": [] } }, "16.00560029997723": { "raw": "AQEBAAAAAHV4nB2MWwoCMQxFtzLk20KT1j7cgfjtAmKawIAzIzIiIu5d2s97Dud+gWWftxVOE7wejXeFwwSrvq9jnFsXYiEKVnGkXFzk4h2nQI6RrGJuqabcs2Vrer/oZzS86JM7nccHqoZSj3qLaoTZ+5isGgn8/uVRJRACAQEAAAAAoXicTYy7DsIwEAR/JXINkvNwgmipU0BKRGE5l/gkY4N9EYqi/Dtn0VDu7OxuwulEfSAMXpyLsm1qqdRJVl1bHQoRwYQ4op8HY2FcHCSW7g9uPjjhJXgPJk8H0gRcbcJYzdDlL1WzN0V4L+DNykR1rWL0suvtp1dSck44e+2ui3ZIWSv/6EAR/EyW8bHpMk44chB9iISpuOgnRC32/QveQz74", "action": { "action": "update", "newUpdateId": "cf34c19c-2ea8-4a80-a632-a12f917d6967", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "lastMotion": 1643055802762, "recordingSchedules": [], "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -47, "ssid": "Mortis Camera" } } }, "16.108367700013332": { "raw": "AQEBAAAAAHd4nCWMQQoCMQxFrzJkbaFpm9Z6g8G1B0ibCAPaijMiIt5dqsv/Hu+/geu29AaHCR434U1hN0HT5+k3ZhmCXInFRjQkqCZUtzccvDXCvhQSdSnkkV276OWor9Gs2tZ+H3T5f1QsiLG6rEGJONmzz8lH+HwB5jsk+AIBAQAAAAA6eJyrVsos9s0vyczPc0ktSU0uSU1RslJIS8wpTtVRUMpFkXAsAUoZmpkYG5iaWhiYGJka1QIAruMUCg==", "action": { "action": "update", "newUpdateId": "52b6b061-5d1e-4c28-a430-da3bb5de2749", "modelKey": "sensor", "id": "5c1b116c29e4e55a70f39736" }, "data": { "isMotionDetected": false, "motionDetectedAt": 1643055804252 } }, "16.214602899970487": { "raw": "AQEBAAAAAHV4nB2MWwqDMBBFtyLz3YAmmky6A+l3FzDmTkCoD0QppXTvJfm853DulySd87bSvaFrh5xKt4ZWfT/rGFEEB0buWm8wsJjewhrRAONi70KGtyo1Wzbo66Gf0iRZ9JBC5/qR24RumHhSRHXMMVr2wol+f/1eJbkCAQEAAAABQXicbVJNT4QwFPwrpmdjWvrtUeNNDxuOxkMX3kJjLdgWdbPhv1vAsF/e6My86bwpBxSg6kJtfVNWLdSDg4jub17fbm9QTCZNhwMKPw/7NBNESUEFE1LqrEgrLjHGilOOhSgy8W13dp6sWuM9uPztB+cyswvwOYCv9kfIWf9e9gD1y7aPRzjaxhu3GYyzaVJzvIJlCuCb1GYUjxndmpQg7OcbewgV+GQaOFrZ+Nia0OQlM7YzLsJk5QD6Mu84CVFtY9XlqFWCGk2eX7aGbtl+bSiZkKYSBNVEUcULLWWWroInX880o5hzqZhW4pSe55835w66uHD4EyweWuulafsBzvQR/snAyKngLAPP80pdzV9muHI4zUAwz287zj9EF5ZeD2iIsNyDKdaCM1rQeZGlTkb4HcFKaoap1ISN4/gLgRe4Kg==", "action": { "action": "update", "newUpdateId": "878df106-d58a-42d2-ae7d-39437fd62eae", "modelKey": "camera", "id": "f0cd15b8bed9e38899286a8c" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 18763646779, "txBytes": 700085350662, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852977, "recordingEnd": 1643055784986, "recordingStartLQ": 1639183852992, "recordingEndLQ": 1643055799979, "timelapseStart": 1639183852941, "timelapseEnd": 1643055599988, "timelapseStartLQ": 1639183852941, "timelapseEndLQ": 1643055105008 }, "storage": { "used": 1603096543232, "rate": 415.108794037914 } } } }, "16.288119599979836": { "raw": "AQEBAAAAAHZ4nB2MXQrCQAyEr1Ly7EKa7q83EJ89QDZJoWBbkRYR8e66fZyZ75sPsGzTusC5g/2hvBmcOljsdTvCRduQECmqBid9KM5jqS4XRkc5Mgrl3I+pafOqdr/auznCsz25tdPxEf5sTUK1Rk+DDKOqT1YCfH+ydySuAgEBAAAAAUF4nG1SwU7EIBT8FdOzMUBLAY8ab3rY9Gg8sPC2JSKtQNXNpv8utNrd1b3BzLxh5oVD4UH1XhvXNqoDPVoIxe3V88v1VRGijPlyKPzX3T7OBMFEUCJqwlhSxBXHCBGOao4rzDPzaXZmHlWddA5sOrvR2sTsPLyP4NT+CFnjXpsBQD9th3CEg2mdtJtRWhOzmqIVbKIH18YuoWhK6FbGCH4/vziAV+CibOFoZcJ9J32bWiZsJ22AbGUBhiaVzMJCm6D6FFVF0EX2/DAa+qX+uqIofcxt6yrV5awqeY2TdBU8OL3QJaKUI8TRGT3PP25OHaqy5H8cfgU/HmxZtXkDK4cAFzJQfio4y1DxlAH9mz/PcMHhNANllAk+zT+i98teD8UYYH5HJANRY1wTUuUiyzoZ4jekxIIgzEsqxDRN36uiuCw=", "action": { "action": "update", "newUpdateId": "70026dd5-c159-409b-89a0-286a0c2881f7", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 21295296277, "txBytes": 1002806814187, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640288743861, "recordingEnd": 1643055800801, "recordingStartLQ": 1640288744338, "recordingEndLQ": 1643055800777, "timelapseStart": 1640288743858, "timelapseEnd": 1643055480800, "timelapseStartLQ": 1640288743858, "timelapseEndLQ": 1643055575798 }, "storage": { "used": 1987496116224, "rate": 708.231920183599 } } } }, "16.302973599988036": { "raw": "AQEBAAAAAHV4nB2MQQqDQAxFryJZdyAzajL2BsV1D5AmGRCqlqIUKb17GZf/Pd7/gug2rQtcG9hfJpvDpYHFP/dz3KwKHooRpRhEGUOHGYOwl0BDn4w1umip2byaP0c/aqMy+1sqnc4P7Sgpsj2MWrGMvXAbszL8/vNdJWcCAQEAAAABRHicZVLBUsMgFPyVTs6OEyBA8FjHmx46OToeaHhNGJFEIGqnk38XiJO29hZ29y27j5wKB+3glLZd0/agJgO+eNi8vt1tCh9kSIdT4X62x5AJXFcVLxnHJYuKsOKI1qQWhCGBhIjMtz7oPNr20low8dtOxkTm4OBzAtsez5DR9r0ZAdTLfvRn2OvOSrObpNEhqWm5gk1wYLvQR7ScI7qXIYA75htHcC3YIDs4W2n/2EvXxZYRO0jjIVkZgLGJJZOwUNq3Q4zaBlBF8vzSCoal/rqiIF1IbRkRKBamWOQ9rIInqzJdkZJSzihC6JLO88+7a4drSXT4EyweohKcp1XrDzBy9HCboa7rS8FVBsopYvRm/l+GW4fLDDgqOJ/zHzG4Za+nYvKg8qMgjDGrSVURnIos60ScknvEGGY0EqSa5/kX7Y24Rg==", "action": { "action": "update", "newUpdateId": "79fd6621-ac70-4080-a7ef-6952d7c1eacf", "modelKey": "camera", "id": "c462c07dbd63ad805a7318c7" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 28447067206, "txBytes": 1583893619199, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852906, "recordingEnd": 1643055765111, "recordingStartLQ": 1639183852911, "recordingEndLQ": 1643055794977, "timelapseStart": 1639183852888, "timelapseEnd": 1643055575165, "timelapseStartLQ": 1639183852888, "timelapseEndLQ": 1643055285277 }, "storage": { "used": 5012226834432, "rate": 1753.16626544334 } } } }, "16.306021399970632": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbSDpBJPxBuLaA0wyP1CwrUiLiHh3SZf/Pd7/ktZtWhe6DLQ/TTfQaaAF7/sxrtZFaaF689UVhXcxC5xkia5ZgSKcmVPq2bwaHjd8elN1xks7nY6PqMxsItyQMSLHNJYqPtDvDxu2JaMCAQEAAAABSHicbVLLTsMwEPyVKmeENn7EDkcQNzhUOSIObrJNLIwTbAeoqvw7dorSFHqzZ2bHMysfM4d17xpt26rusBkN+uxu8/J6s8l8UCFdjpn7vj+EmSCCE8KglIxHRVjwnJVCUMJEQXIRmS+91/No3Slr0cSzHY2JzN7hx4i2Ppwho+1bNSA2z7vBn2GvW6vMdlRGh6TmsIBVcGjb0EUUpojuVAjoDvOLA7oabVAtnq20f+iUa2PLiO2V8ZisDOJQxZJJmDXa132MWgdssuT5qRvsT/WXFQXlQmpb0DKXVHJSljJKF8GjbWaaUeBcCCBQrul5/mm7dqAA7I/Dr2D2kAC5zNOq9TsaNXi8loGvBRcZCgCSs3/zlxmuOKwz5ACUF9P8I3p32usxGz2md2J+zoDSglNRpCKndUpKbhkImZcSClHKaZp+AMNzuCE=", "action": { "action": "update", "newUpdateId": "bf1c0d0c-bae0-489e-9894-fdbeae163377", "modelKey": "camera", "id": "4a333d993fe8e2e8472bc901" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 27522409845, "txBytes": 1497732476217, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852998, "recordingEnd": 1643055770209, "recordingStartLQ": 1639183853004, "recordingEndLQ": 1643055800181, "timelapseStart": 1639183852995, "timelapseEnd": 1643055600214, "timelapseStartLQ": 1639183852995, "timelapseEndLQ": 1643055100356 }, "storage": { "used": 3005403365376, "rate": 832.407819806798 } } } }, "16.308143400005065": { "raw": "AQEBAAAAAHR4nB2MQQrCQAxFr1KydqBTZ0j0BuLaAyT5KRRsK1IREe8u0+V/j/e/pL5N60Lnjl4P6BZ06GiJ920fFzQhghJFkAwWqWQtydw5RQ+WKsy95ZbNK+J+jU9rXOd4aqPT/qF2jIHHAbWOCskMdTY70e8PGi4mXgIBAQAAAAFDeJxtUk1PxCAU/CumZ2OgBQoeNd70sOnReGDp25aItAJVN5v+d4Ga7pe3MjNv3rxJD4UDNbhW265RPbSTAV/c37y+3d4UPsiQHofC/TzsQyZwTUgpCOE1joqw4oxWJasYZSVlkfjWO50nVS+tBRO/7WRMZHYOPiewan+EjLbvzQjQvmxHf4S97qw0m0kaHZKaohVsggPbhT6iaI7oVoYAbp83juAU2CA7OFpp/9hL18UjI7aTxkOyMgBjE29MwqLVXg0xqgrQFsnzS7cwLNevDQXpQiqBVQLzisebeQq1Cp5sm2lSIUprzhAXp3Sef96cO1B+4fAnyB4cYSTq1LT+ACNHD/9kqMWp4CwDwxgjfDV/meHK4TQDxgxRPucfYnBLr4di8pD3kBqjkpWC86WKpc6qonclw7wWhMUlZJ7nX0tBuAE=", "action": { "action": "update", "newUpdateId": "88d4e48d-bdbe-41a4-bcc7-e0d7858770b1", "modelKey": "camera", "id": "ab3e27f2d55fad817dac7bb9" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 17442944871, "txBytes": 653263656256, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183853280, "recordingEnd": 1643055786089, "recordingStartLQ": 1639183853258, "recordingEndLQ": 1643055801097, "timelapseStart": 1639183853279, "timelapseEnd": 1643055611101, "timelapseStartLQ": 1639183853279, "timelapseEndLQ": 1643055116058 }, "storage": { "used": 1471026298880, "rate": 335.261879469184 } } } }, "16.310351099993568": { "raw": "AQEBAAAAAHZ4nB2MQQrCQAxFr1KydmCaGTNTbyCuPUDSJFCwrUiLiHh3mS7/e7z/BR63aV3g0sH+VN4MTh0s9r4f46pNsConSjFIzUPIftYg2NdQSsqFohdVbNm8qj1u9mnNyLO9uNHp+DB0j6xGYtGRUUyorzTA7w/yfiXGAgEBAAAAAVZ4nG1STU+EMBD9K4azmn5C8ajx5h42HI2HLsxCYy3YDupmw3+3hbifhkv75s2bN6/sM6sDrno0vcsebmguOJFSEV4wcnuTeah73xjXVnUHzWghRNLrW6wE1Jgu+8z/PO5wLghRlkLGj0cCHmAqpCyZUILKVPg2WzM31p12Dmw85xHeevgcwdW7eGeCFxGyxr1XA0Cz2gxJyI3WptGmddquR20NJjYl5IBW6MG12EX4TpZTxDcaEfxunjiAr8GhbuGoZsJTp30bd4zYVtsAScwCDFVcMRGzxoS6j1ZrhCZLml+mgX5Z/hAQao9LgoQTpQSReXGa4LNrjgEXKifirDz3v6wvFPiFwh9heSRKVNJA8wFWDwH+8cD5KeHMg2QxOHnVf+nhSuHUAxU54Wqa/4feL7nuszFAmlOIgseXJKUkIu2xpMkFu6eMKU4VI1KwaZp+AW8fvRE=", "action": { "action": "update", "newUpdateId": "adda3630-b849-4f5d-b218-7734760f7dd2", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "lastMotion": 1643055803720, "recordingSchedules": [], "stats": { "rxBytes": 4499454543, "txBytes": 145592484153, "wifi": { "channel": 6, "frequency": 2437, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -59 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640308840567, "recordingEnd": 1643055786047, "recordingStartLQ": 1640308840563, "recordingEndLQ": 1643055801087, "timelapseStart": 1640308840533, "timelapseEnd": 1643055521005, "timelapseStartLQ": 1640308840533, "timelapseEndLQ": 1643055146038 }, "storage": { "used": 747324309504, "rate": 342.122831820542 } } } }, "16.312379500013776": { "raw": "AQEBAAAAAHV4nB2MSwrCQBAFrxJ67cA4v9jeQFx7gDfdPRAwiUhERLy7TJavinpfgmzTutB5oNdDsRkdBlrsfdvHRbuoCS1wFJdLhEtFssOJm4vsVaoftQTt2byq3a/26Y1gtic6nfaPozCC1qStWFOkETF7rka/PwRcJiUCAQEAAAABVnicbVJNU4MwFPwrHc7qJCEfxKOON3vocHQ8pPAKGWPAJKidDv/dBEakrQcO7Nu3u2/hlBnlw7YLurPZ/QZzmiPGCpQLIm42mYOqc7W2TVm1UA8GfCS9vMaJDyqkl1Pmvh+OYRoIiblEnCMZCWGBiUA0PjkvSBEHX/qgp8WqVdaCSbYsj4ODg48BbHWMCBOcRcho+1b2APV23ycpOxiTzHVjldkNyuiQ2BihBS2DA9uENsK3jI4R36sQwB0nzx5cBTaoBv7UtH9slWvilRE7KOMhiRmAvoxHJmJWa191MWwVoM6S5qeuoZvPXyoKyoW5QywJRlQyKdcdPtl6XTEStDirOO0/784VBL1Q+CUsGunyoN/BqN7DfxnomnCWgXIkMLvav8xwpbDOwAgShI3TH9G5uddTNnhIPvGz51QSyiQj6Y65TU7JXY5IUeAieuBxHH8A8lO9fw==", "action": { "action": "update", "newUpdateId": "b4af293c-563a-46c5-a89f-390dcb07d62d", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "lastMotion": 1643055803727, "recordingSchedules": [], "stats": { "rxBytes": 7916906609, "txBytes": 270427036828, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -54 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1641921049599, "recordingEnd": 1643055800748, "recordingStartLQ": 1641921049574, "recordingEndLQ": 1643055800740, "timelapseStart": 1641921049594, "timelapseEnd": 1643055460715, "timelapseStartLQ": 1641921049594, "timelapseEndLQ": 1643055520725 }, "storage": { "used": 427349245952, "rate": 642.30288184191 } } } }, "16.345541200018488": { "raw": "AQEBAAAAAHZ4nB2MSwrCQBBErxJ67UBnPm3GG0jWHmA+1RAwiUhEJHh3nWwKqh6vdkplm9aFLh29HjVtoFNHC963o1xrA5mhNetghAXGK4KJBe4f2SURKZZD0+a14j7i05ySZjxTW6fjowfcEAOyh9r+zOxFo9pC3x8hsCXUAgEBAAAAAU14nG1SwU7EIBD9FdOzGuhAAY8ab3rY9Gg8sO1sS0RaKVU3m/670N3Uru4N3nvz5s3AIfNYdb42rimrFuvR4pDdXb28Xl9lQ9AhXQ6Z/77fh5mQlBeUAM9JFIQFBlYIqggwKEQkvszOzIVVq51DG8+UQyR2Hj9GdNU+IlwUPELWuLeyR6yft32ycqO1qblpnLabUVsTkpoSsqBl8Oia0Eb4hokp4lsdAvr93LNHX6ELusFfNzM8tNo3ccqI7bQdMJlZxL6MQyZhVpuh6mLYKmCdJc9PU2N3HH9ZUdA+pDAFqJyqXDIiVJQugkdXzzQDwrlQkguypuf6p83aAZQUfxxOgpOHYjJPuzbvaHU/4IUMQNaCswycAi/Iv/rzDBcc1hk4xPeW0/wjOn/c6yEbB0x9QIlowASTMo1xXCaFWyCQA+SMEpmraZp+AMqNtdQ=", "action": { "action": "update", "newUpdateId": "b0efdbf8-606e-4fe5-9ce3-9cb3a666c205", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 8156103520, "txBytes": 346719034367, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -47 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639219284079, "recordingEnd": 1643055798570, "recordingStartLQ": 1639219283987, "recordingEndLQ": 1643055799482, "timelapseStart": 1639219284030, "timelapseEnd": 1643055513560, "timelapseStartLQ": 1639219284030, "timelapseEndLQ": 1643055533528 }, "storage": { "used": 39728447488, "rate": 13.3032332410829 } } } }, "16.526685399992857": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm20Di5NG4A+m3C5jJTKCgqUhrEXHvkn7eczj3C1TWeWlwGWB7Cq0KpwGa7rdjXKULYs+ZIhouthgfcTScQzUxaRqZgpCjnj0W0fukn96096uj+Tg4ewy1BmK0VpzLkqxnVoTfH5YpJJ8CAQEAAAABRXiczVHLTsMwEPyVymdK/H5w54DUE0VcEAeTbFpLeVixUylU+XfWAalI/AAXWzszO7seX8kcc+iBPOy0ZcZZSundjnQ+5SPAgDDTUlClLJVacaTSkjL0T0M7InkldZy3219g8ic4jL7BWt4bhVoURoTzPG0DzIpYD/04LT89PnT+o9tIYYWxEgXtBAWwxuqySh6z70rNtKBcFouUxzLrjwcTVmqnjFFaMo3CkJ6hXuouDCekW98lKO3hs6gFs0o566Tggm+TllhwMvnQEKznBM13ANQZZ4QS0pQEGriEGhJSb1fSjw2U9cjxhWkM7/URjz0/cEYFuQ3bOEep1oxLi/gZfJfPJYc8zYCv+o9W7yXt3Mc2/cnaWS61/PU/jEqrjL7lhqasxBV9PpdVqoufqjHmah5CG/ZxGjPUuUJ7sq7rF1Jms+E=", "action": { "action": "update", "newUpdateId": "ab4b9a63-bc0c-4638-b95f-67e78ba5da1a", "modelKey": "nvr", "id": "2435ff5ab300d119d704bbe3" }, "data": { "uptime": 681798000, "lastSeen": 1643055804652, "systemInfo": { "cpu": { "averageLoad": 4.75, "temperature": 67 }, "memory": { "available": 6383784, "free": 87860, "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": 982464, "total": 1048576, "used": 66112, "path": "/var/opt/unifi-protect/tmp" } } } }, "17.2274890000117": { "raw": "AQEBAAAAAHN4nB3MTQrCMBCG4auUWRvIb5t4A+naA4wzX6CgqYugSPHuErcvPO9BLH3bG50nYlU6TdTwvj6VOy46aqxhDqxsIleYGJHMLYgzqeZZkoNn6wZ77Ir7is8weKH1Ebf/whXJC5agxUuCVcmhFuszfX828iPHAgEBAAAAAK94nFWOsQ6DIBCGX8UwdwBUBOd26tqt6YBwJCaiBs8mxvjuPRzadLv/u8t3/85wm4G1BYsT9tPILgVb0CYkJFRV8rpuTCWVyNxNKZ/yPEe6uQKCwwcJFsLP1z+/vWHE7yICWm/RUt4Pys5GSDkxkCFw60F1wIO0soNOCa1MrjKTrT97tcW4DgOhdYH0S73PCmGcbqApvZGuBu6dLoPhUmdFnDwMd9jOV7kSOz6v80nU", "action": { "action": "add", "newUpdateId": "4f363ada-4afe-44e5-b3c1-5f86c51e2a01", "modelKey": "event", "id": "19c87e73d92c5e0dc83f9028" }, "data": { "type": "motion", "start": 1643055794261, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": {}, "camera": "e2ff0ade6be0f2a2beb61869", "partition": null, "user": null, "id": "19c87e73d92c5e0dc83f9028", "modelKey": "event" } }, "17.288530400022864": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm20Ae7TRxB+K3C5hkbqBgW5GKiLh3ST7vOZz7JSnHsm90Huj1UDlAp4E2vG99XLSJSVUQJBkONppRUjLRx9GUMjtMLszspGXrrrhf8WlNkRXPTpf+AV+rFQVn2OrFZ2R2kRP9/uaTJZkCAQEAAAAA0HicXVC7DoMwDPwVlJkhCY+Uzh26dGnHiiECq0UCIgVTqUL8ey9pGagUO7747mxlEb2d+OK4c6M4JqrMM1kUpsp1qdJEdNO3dSKmhqkFhf1M6NCLRr6x5Qlvixg2h0Wwa+07ehnw7Iu8fRBwVmbAYdzJvoPqro1Ok6wCLTcVqkIi6RJQFaikqSHw1GDS2c0+alQeOGkSbnVAgAkDjVCIII45ONcr9NNgPe8Wk7u15N9SwP+nXtfvIs633fi4NU9q554ivY6/dN16vw9aP6fKX1Y=", "action": { "action": "update", "newUpdateId": "5ddae3a9-6308-4a99-8284-cc71e513761a", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "lastMotion": 1643055794261, "isMotionDetected": true, "eventStats": { "motion": { "today": 167, "average": 363, "lastDays": [ 272, 397, 479, 350, 326, 415, 307 ], "recentHours": [ 14, 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 ] } }, "recordingSchedules": [], "isRecording": true } }, "17.350639200012665": { "raw": "AQEBAAAAAHV4nB2MQQoCMQxFrzJkbWHamjb1BuLaA8QkAwPacdFRRLy7tMv/Hu9/gaWtW4XTBPtTuRkcJqj2vo5x1i6SMMuC6kJBcccbRkfes/OSjFLggHlkj03tfrFPb+xltXW4jgtfhLLlqCUI2qxCcSlzIPj9AdepJPsCAQEAAAAAO3icq1Yqzk0sKnFJLUlNLnEtS80rKVayUohWsjQ2N0s1NTdKTrOwTDM0T00zNk40TktOVYqtBQC/HxBf", "action": { "action": "update", "newUpdateId": "6caacf5d-295c-4b53-811a-1c6e862a257e", "modelKey": "event", "id": "19c87e73d92c5e0dc83f9028" }, "data": { "smartDetectEvents": [ "9376e572cf89f17ef33a3fce" ] } }, "17.549540999985766": { "raw": "AQEBAAAAAHZ4nB2MUQrCMBBEr1L220C72XRTbyB+e4AkuwsF24q0iIh31+RvZh5vPpDKPm8rnDs4HpJ2hVMHq75urVykAowSMEdyWSg4Yvwn4+I8mxH6YCOnqi2b6P2q7+qUtOizrXP7GFR9nIJmUsOB+55GmwwLfH/dtSUZAgEBAAAAAFd4nKtWykksLvHNL8nMz1OyUjA0MzE2MDW1MDCxNLLQUVDKLA5KTc4vSsnMSwfKpiXmFKeCRSEaXFJLUpNLUlOQpIpgyoOTM1JTSnNSi4GS0bG1AL++Ij4=", "action": { "action": "update", "newUpdateId": "28d52b84-bd45-4724-bf7c-37ff4235f67a", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "lastMotion": 1643055804928, "isRecording": false, "isMotionDetected": false, "recordingSchedules": [] } }, "17.576620700012427": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbSBp0rTjDcS1B5hk/kDBtiIVEfHuki7/e7z/Jan7vK107uj1UNlBp45WvG/HuGgTzIOPXNlNRcylOmZXYhCnKlkNJXC0li2b4n7FpzVVFjyl0fn4CECceEBJsD6M3qdsbH2l3x8P3SXaAgEBAAAAAD54nKtWykksLvHNL8nMz1OyUjA0MzE2MDW1MDQyMbLQUVAqSk3OL0rJzEsPTs5ITSnNSS0GKoqOrQUA20gRYQ==", "action": { "action": "update", "newUpdateId": "995039c9-8baf-4c76-b31a-dda6dfeb193f", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "lastMotion": 1643055812428, "recordingSchedules": [] } }, "17.59743119997438": { "raw": "AQEBAAAAAHV4nCWMQQrCMBBFr1JmbWBM0iHxBtK1B5jOTKHQpi5ii4h31+jyv897L2Cp81bg0sHjrlwNTh0UO26/cdV2JEGRpJM7c/YukqkbMarDINwLeSXMTVs3tWWwZ3Nst1IbnP8JzSNbCn32lCbC+AXeNMD7A+M6JS0CAQEAAAAAKnicq1ZKzUtRslIwNDMxNjA1tTA0MjGy0FFQKk7OL0oFiRsY1AIApsQJBg==", "action": { "action": "update", "newUpdateId": "8c0cc8df-1a92-46ed-b04d-03ca5c62d609", "modelKey": "event", "id": "8d9bae8359268f6048d92ed3" }, "data": { "end": 1643055812428, "score": 100 } }, "17.677909999969415": { "raw": "AQEBAAAAAHN4nB3MQQrCMBBG4auUWRtoJ5NM4g2kaw+Qdv5FQVMXQRHp3SXdPvjej8ratr3SdaBiRpeBKj73l5WGm/U6Jq9lEXWBJ3MCqCvK3vGkPrNlXsLJnrvhMePbDd6orcftXEROccxBUg4MQIKuWaIoHX/gqSIzAgEBAAAAAPR4nG1QTUvEMBD9K0vOK6TdJE09W1GEVVg9iHhImykW2qQmU9dl6X83Caul4C3vY2bey5ngaQRyvSEejLfucQQDmmwDRuUwCJlgO8q5pFxmu8CD0f+wvrEurqHxPYTJG0Bo8Dks94F+e1/z1RcY/BMGQKUVqoDPlxz3OgGE75iB5LTOIJd1qXhb6zajgtZtoSWZt7/J92qA1cyd+/zYvFqFyXSpucgvt09Xh2p/qJI82MmktCvPsTPaHskcHU3Y72JEM/V9wGMo02FnzUJNHtyCuliBiFwKWnImS54DAONFUzLBCpKOaugf4BR9EH+EzD9E+3ii", "action": { "action": "add", "newUpdateId": "0837ab47-521d-4ee7-a723-217392d92b5d", "modelKey": "event", "id": "628609548952eee457c94647" }, "data": { "type": "sensorOpened", "start": 1643055805813, "end": 1643055805813, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "20b1e28b9a5fbdf1060bf7d8" }, "sensorName": { "text": "Hrqh Yoat" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "window" } }, "camera": null, "partition": null, "user": null, "id": "628609548952eee457c94647", "modelKey": "event" } }, "17.679753199976403": { "raw": "AQEBAAAAAHZ4nC2MWwrCMBBFt1Lm28BkqE3SHUi/XcCMM4GCJtIHRcS9S8TPew7nvoFv21wLjB3sT+XN4NRBseP6GxdtgpAlJDFHIaHrQ0YXI5ETHtg89srGLXtUtftkr9asVta6NDr/P8QbRUl8zqLZ44CSg0b4fAH3lyXgAgEBAAAAAD14nKtWyiz2L0jNS01RslIoKSpN1VFQygfyg0sSS0qLnTMS89JTUxxLgJKGZibGBqamFgamFobGtQAfShGu", "action": { "action": "update", "newUpdateId": "20ab79be-2790-47f0-8822-ba6ae104daea", "modelKey": "sensor", "id": "20b1e28b9a5fbdf1060bf7d8" }, "data": { "isOpened": true, "openStatusChangedAt": 1643055805813 } }, "19.00935790000949": { "raw": "AQEBAAAAAHh4nB2MUQrCMBBEr1L220CySTTxBtJvD5DNbqFYuyIVKeLdNfkaZh5vPlDqNusK5wFeDy6bwGGAVd7XXi7cQMiWiDEY/KcJHMUQ2mymVHw4WUHkrt2VZRllbw6rPhett7bP/cXV5FCSnfiYfaHooo8kPsH3B0ufJhcCAQEAAAAAIXicq1bKyU/ODi5JLCktVrJSUHL28Q/29HNXqgUAcGMIFw==", "action": { "action": "update", "newUpdateId": "490bbd24-20bb-4d5e-b209-f8a3470e22de", "modelKey": "doorlock", "id": "1c812e80fd693ab51535be38" }, "data": { "lockStatus": "CLOSING" } }, "20.851950599986594": { "raw": "AQEBAAAAAHV4nB2MSwrCQBAFrxJ67cA46fl5A3HtAfo3EDCJSIKIeHeZLF8V9b5Ask3rApcB9qfSZnAaYLH3/RhX7aJaixY4OPVcHJpHRzF5V6pgi3TOqNKzeVV73OzTG6HZXtTpdHzEkoizBOaEYZSxqWK2GuH3B/2BJZ0CAQEAAAAAXXicq1bKSSwu8c0vyczPU7JSMDQzMTYwNbUwsDA0NdJRUMoshki5pJakJpekpgCVpCXmFKcCpYpSk/OLUjLz0oOTM1JTSnNSi4GS0bFgTUEwOaBQSVFpai0ApzYh7A==", "action": { "action": "update", "newUpdateId": "9ef5e2b2-d0b8-4e04-a560-89c4f5a174dc", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055808152, "isMotionDetected": false, "recordingSchedules": [], "isRecording": true } }, "20.885154700023122": { "raw": "AQEBAAAAAHR4nB2MSwqDQBAFryK9zoD2/HOD4DoH6J8gRA3BEILk7mFcvirqHUCyz9sK1w7eT6Xd4NLBap/7OW7aRGWkXgmdGPUu+KG6wj46oWhp4Iplyi1bNrXHaN/WCC32okbn8yOWRJwFmVNAL35SDdlqhN8f++olkwIBAQAAAAA8eJyrVspJLC7xzS/JzM9TslIwNDMxNjA1tTAEIksdBaWi1OT8opTMvPTg5IzUlNKc1GKgoujYWgDcHBFo", "action": { "action": "update", "newUpdateId": "9b2a0da2-cea0-4319-8b35-ca5e61b928f7", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055815819, "recordingSchedules": [] } }, "20.897355899971444": { "raw": "AQEBAAAAAHR4nB2MQQoCMQxFrzJkbcHUJmm8gbj2AO0kwoB2XFRFxLtLZ/nf4/0vlLkva4PjBM+Hle6wm6D5+7KNkw0hKhUVKeQsGJJFDppyDvGAWoRI3eeR3Vfz29k/o/GXtz7gsl24IqbI+8LkxGzKLHatFX5/az8jzgIBAQAAAAAoeJyrVkrNS1GyUjA0MzE2MDW1MAQiSx0FpeLk/KJUkLiBQS0Ap0QJDQ==", "action": { "action": "update", "newUpdateId": "797b1915-8871-4d26-9488-2319a7559eec", "modelKey": "event", "id": "e9114260a65e566d9667dfbb" }, "data": { "end": 1643055815819, "score": 100 } }, "20.973723599978257": { "raw": "AQEBAAAAAHZ4nB2MQQoCMQxFrzJkbaGdTLT1BuLaA6RJhMFxIjIiIt5d2uV/j/e/wLLNvsJxgNdDeTPYDbDa+9LHSZvQKJGlaOBIGCbmFDKphInQSqVDZRpbdne15Wyf3rg/F5db43N/SZLTaDledV+QKyVCqoYZfn9fgCZGAgEBAAAAACB4nKtWSirKTElPdU7MS8lMSSxJLVayUoiOrQUAbxcIrA==", "action": { "action": "update", "newUpdateId": "d0c0ac9d-a053-4aa1-85dc-453e9b57ba52", "modelKey": "doorlock", "id": "1c812e80fd693ab51535be38" }, "data": { "bridgeCandidates": [] } }, "21.156368599971756": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm28Bo05hxB+J3F5B5BAqaiK2ISPcuyec9h3N/kGRbaoHLAO+nps3gMECxz9zHVZsQEkHF6EQDO59ZHPkxuimzMamXNPXsUdXuN/u2ZrWy1lejS//AkxkxUT6eNdDog6FEQYT9D0dUJmYCAQEAAAAAIHicq1ZKKspMSU91TsxLyUxJLEktVrJSiI6tBQBvFwis", "action": { "action": "update", "newUpdateId": "c9cc0d08-cd6b-4fbc-9438-5fbeb9d4ca5e", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "bridgeCandidates": [] } }, "21.15968659997452": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbaCZ6EzjDaRrD5DMTKGgidhKEfHumi7/+7z3gSTrXAucO3g9NK0Ghw6Kbdd9XLQdPeYoE3knhOaO00nc0Ht2KWZNgnHAwE27V7XbaO/mLFaW+mx03hsaRLMPypkosxIz/QkafH/72yXMAgEBAAAAACB4nKtWSirKTElPdU7MS8lMSSxJLVayUoiOrQUAbxcIrA==", "action": { "action": "update", "newUpdateId": "02b9cf61-c62e-4f5c-8017-a9bdac298237", "modelKey": "sensor", "id": "d3cdb13d7b66b7d67763cd2e" }, "data": { "bridgeCandidates": [] } }, "21.166233900003135": { "raw": "AQEBAAAAAHd4nB2MWwrCMBBFt1Lm28AkzeThDorfLiDJTKCgidiKiLh3ST/vOZz7hVT2tTc4T/B6cNoFThM0eV+PsfAQmhLzjKjY6qAsEavoxarZkyvWFB1cHNm9s9wu8hnNJm3rz0HX48Ng1mJCjolq5qrRYa6eA/z+wVclBQIBAQAAAAAgeJyrVkoqykxJT3VOzEvJTEksSS1WslKIjq0FAG8XCKw=", "action": { "action": "update", "newUpdateId": "15add300-d418-455d-97e4-3756c42c1869", "modelKey": "sensor", "id": "20b1e28b9a5fbdf1060bf7d8" }, "data": { "bridgeCandidates": [] } }, "21.173563799995463": { "raw": "AQEBAAAAAHd4nB3MUQrCMBAE0KuU/TbgNk3S9QbFbw+QbKZQ0ERsRUS8u6afM8ObD0Xdllro1NHznuMGOnRU8LrsYcptgPSzOE1G7ahmSGqNaC9GoD5HDpExNnarGdcz3s2sKGt9tHbZP5xyYvZ/hgHOxXCcrQTr6fsDHyYlqAIBAQAAAAAgeJyrVkoqykxJT3VOzEvJTEksSS1WslKIjq0FAG8XCKw=", "action": { "action": "update", "newUpdateId": "e92f95cb-c38c-4bc3-9c29-9ec6da17a1e8", "modelKey": "sensor", "id": "5c1b116c29e4e55a70f39736" }, "data": { "bridgeCandidates": [] } }, "22.208892999973614": { "raw": "AQEBAAAAAHN4nB2MQQqDQAxFryJZd2A0kXF6g9K1B0hMBgQdpahFSu8u4/K/x/s/4GEblwzPCvZVeTN4VJDt29/jpUUoNxpsQEfC0RF15oSQHRIGkehTZ1SyeVGb3naWJh+fgsb7oCFsU2pZ0Hut66jBk4gh/C+OqCRxAgEBAAAAAS94nM1RTU/DMAz9K1POjOaracqdA9JOgLggDqF110jtUiXppDL1v2NXkwDtD5BDIr9nP9svF5aWlGF8OnWBPewurJnm7XVniO4Ih+BajKt7/ueIux3Dqglz8hwBM4xdERthDHG5Cjg/uM9hIzXn1nJM6CIQYLmoDGmE7AaKhVFcapJIOVDjGw2hrDZ1WVWl0YJqfXqGZmkGfzoi3bkhAZX7L8pWwpZlbWutpJLUOC8T4Sw63zKM5wS0mMDR6qquVKl0JRFv4ewbSEi9X9gYWqDx2MurMLj22yNee3mQgiv202zjas6NEVJbxHtwQ+7JhxxnwK3+o9QHuZ3HqUs3XtdWaqN//Y/g2pbbj119Q1FBdk0u9zRKcXaxCFMu5pPv/H6KIUOTC5Rn67p+A289qhc=", "action": { "action": "update", "newUpdateId": "da2d7ec3-4ba9-448e-b43a-3437bb90f8e4", "modelKey": "nvr", "id": "2435ff5ab300d119d704bbe3" }, "data": { "systemInfo": { "cpu": { "averageLoad": 7.000000000000001, "temperature": 68 }, "memory": { "available": 6400880, "free": 80176, "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": 982464, "total": 1048576, "used": 66112, "path": "/var/opt/unifi-protect/tmp" } } } }, "22.94245959998807": { "raw": "AQEBAAAAAHV4nB3MTQoCMQxA4asMWVvopE1/vIG49gBpk8KAdlyUERHvLnX74Hsf4Dq2vcN5ARaB0wJdX7en8NCLzNpyQe+5meoJjU+YTQo5mqZILhFiXO1kj130ftX3NHpoHzNu/8WaQuCKpMFZcdVFsYViQfj+AN8jIn4CAQEAAAAA+HicbVBdS8MwFP0rI88TknZJq2/DdSDCEKZP4kM+brHQJqO53ayj/90kTEvBt5yPe+85uRIcT0AeVsSD9a5/bJ0HQ9YBo+wxCExscsp5yRgtaeDBmn9Yr10f16R3FyZ3gKDxNSz3gX7/WPLVGSz+CR2gNBJlwNdbjieTAMJXzEAyqhhkpbqXvFamZlRQVRemJNP6N/lBdrCY2cszgv9ebdvxE51O1lvZ2fS2f7k7VodjleTODTZlXngujTXuQqbo0OFKH4PaoW0DPoVKDTbOztTgoZ9RE4sQVgohdcZB5NTkOi8MVbxQGUlHDbTPMEYfxH8h0w+gfHuy", "action": { "action": "add", "newUpdateId": "f9b244af-c452-4829-8697-fe2538522710", "modelKey": "event", "id": "1866ac25e630d3c37d0b57b2" }, "data": { "type": "sensorClosed", "start": 1643055811080, "end": 1643055811080, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "20b1e28b9a5fbdf1060bf7d8" }, "sensorName": { "text": "Favtesz Alyhtoc" }, "type": { "text": "UFP-SENSE" }, "mountType": { "text": "window" } }, "camera": null, "partition": null, "user": null, "id": "1866ac25e630d3c37d0b57b2", "modelKey": "event" } }, "22.94359879998956": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbSBNm3TqDYprDzDTmYGCJmIrIuLdJVn+93j/C7QeW8lw7uD1EDoUTh1kfV/bWKSKNVmUJMmFeRQ3+hgcI5OTwZDQBvJTy+5F9HbRT212zXt5Vrq1j+C514A8UzQW633ybJMg/P4RbyYlAgEBAAAAAD54nKtWyiz2L0jNS01RslJIS8wpTtVRUMoHCgSXJJaUFjtnJOalp6Y4lgBlDc1MjA1MTS0MDQ0sDGoBMIUR8g==", "action": { "action": "update", "newUpdateId": "c6f5d6d6-294d-4052-b8ba-d3f8a8f3a07e", "modelKey": "sensor", "id": "20b1e28b9a5fbdf1060bf7d8" }, "data": { "isOpened": false, "openStatusChangedAt": 1643055811080 } }, "23.047045100014657": { "raw": "AQEBAAAAAHR4nB3MTQoCMQxA4asMWVvoj21abyCz9gBpk8CAdlwURQbvLnX74HsHUBvb3uGyADHDaYEu79uTaciVZ9UYcy3aDGIs5oyhmKLeGXWJKiXCYGWyx85yX+Uzjbykjxm3/yJztS4JN7JRfApeA2IWgu8PFjUjqQIBAQAAAAC3eJxVjrEOgzAMRH+lyswAhCSEuZ26dmrVISRGQgKCgqmEEP9eu0vFeO/OT94FbjOI5iKW0SW8AoLHZ5xAZISQEHWFrmSulLHW1oq5j4lvbJGd7h6kWoi/xAxpiZN4n/vbByb8DbgYAV1w6CjvB2XvRkichKq1a40v21ZXpfSyC6EyYBX/NJOtx57kzWVah4HQukD6pz6wog5tXmgI3uUKSi3LThpTg2PFGAMMd9h4B/ySOL74m1BI", "action": { "action": "add", "newUpdateId": "f558b9fc-7759-4739-9f21-f16aba6a730e", "modelKey": "event", "id": "8db016edca05e2632f3778ea" }, "data": { "type": "smartDetectZone", "start": 1643055799985, "score": 91, "smartDetectTypes": [ "person" ], "smartDetectEvents": [], "metadata": {}, "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "id": "8db016edca05e2632f3778ea", "modelKey": "event" } }, "24.39886120002484": { "raw": "AQEBAAAAAHR4nB3MQQrCMBBG4auUWRtok0nTeAPp2gNM8k+goKmLUBHp3SVuH3zvS5Lbtle6DiQAXQaq+r6/IE1v6JVtZhX2xtopGy5ezBJnZ/KMxG6yGmXs7LlDH6t+utFDa+tx+y+QRQr8GHQRF5KglBQDlM4fHQYkrgIBAQAAAACweJxVjrsOgzAMRX8FZe4QHiGhczt17VZ1cGIjIRFAYCohxL837tCqm8/x1dXdFW8TqXOm4sjdOKhTphaGmZPK66rUxjidVy4XH8ZZolrumDIXYgp8TwVL0o/nv7++aODvIxIDAkPi/UgcINIspIyrwdtQeF9XRRnKFrGy1BiZMqW27rPrnA1r3ye1LjT/qEOpwADQotGWHJTWA7atbyySVMQRqb/RJjmSSep4A6JJSrs=", "action": { "action": "add", "newUpdateId": "42c4ea45-221c-4f5a-8963-c6db4312e9a0", "modelKey": "event", "id": "dcaafd507e8a37badffb97de" }, "data": { "type": "motion", "start": 1643055801481, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": {}, "camera": "586ab7c2bb6423c3fdd47e95", "partition": null, "user": null, "id": "dcaafd507e8a37badffb97de", "modelKey": "event" } }, "24.42201829998521": { "raw": "AQEBAAAAAHR4nB2MSwrCMBRFt1Le2EBs82ncgTh2AbfvAwVNHURFxL1LOrzncO6XwG3dKp0Gej4ETekwUNX3dR9n6SKxwocgLvgEF4TFzXEq7mjjmBKsIHLP7pvo7aKf3uhLa+tw3S+EAZPos86Y8gIxW0oWpd8f5YQmcAIBAQAAAAA7eJyrVirOTSwqcUktSU0ucS1LzSspVrJSiFaySEkyMDRLTUlONDBNNTIzNkozNje3SE1Uiq0FAMSlEEY=", "action": { "action": "update", "newUpdateId": "6cea044d-406a-4dcd-8539-1f2266af9a5c", "modelKey": "event", "id": "dcaafd507e8a37badffb97de" }, "data": { "smartDetectEvents": [ "8db016edca05e2632f3778ea" ] } }, "24.43455449998146": { "raw": "AQEBAAAAAHd4nB2MQQrDMAwEvxJ0rsGxpdjuD0rOfYBkKRBoklJSSgn9e3GOO8PsAVz3eVvh2sH7qbwbXDpY7XM/x02bKNTHRIJOs/QOs0fHyOSUQ6CIWLyfWrZsao/Rvq2pvNiLG53PD8oDS6pBZMAQa5xUMVkh+P0Br2QkoQIBAQAAAADDeJxlUD0LgzAQ/Sshs0Oi8YPOHbp0qWNxCOawghpITkHE/94z1kELjyOX9+7d4xbeaY9Pi60d+I3JTCUiTQsZJ3keMd76nboDQo1gSIJuhMCUvXb4T8AEA5ao0dPfwvvDeuFojZ7pFSuS6QmcbmDbWVC7pbjreZt5J1nECCJiyQ5qlKpI5aAm84cdXRCmEZOEOGhlqGmoJ1QrTfot7CmFOIUQlwxXk+Cz7hGsM+3QlPUHzNhBkFfhIq+D+11j/QLKKWP6", "action": { "action": "update", "newUpdateId": "951375b4-d8b1-4804-a4a5-da225344900f", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055812377, "isMotionDetected": true, "isSmartDetected": true, "eventStats": { "motion": { "today": 24, "average": 18, "lastDays": [ 36, 6, 0, 3, 3, 36, 44 ], "recentHours": [ 5, 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 ] } }, "recordingSchedules": [], "isRecording": true } }, "25.245077100000344": { "raw": "AQEBAAAAAHR4nB3MQQrCMBBA0auUWRtI02mm4w2kaw+QZGagoKmLoEjx7pJuP7x/QCpt2ytcB0gicBmg6uf+ktT0Jr1yZJpJo5Nko8Pg0XGevBupWCkxFwwne+6ij1W/3ehba+txOxdi7GecjNFsYcRAmXgJBL8/EkojLAIBAQAAAADaeJxtkEFvwjAMhf9K5TOHtElIsvM2aYJx4jbtEBqHVUob1JoJhPrfidnEhLSb3+dn5zkXoPMB4amC1O2/6D1TlwdYVDCRH6nweqmk0NrW0mlTOA7hHzq1eeQtguu+TD4jYUvbsnsq+OPzkb9840D3Ro/kgydf9OUnxlu41YQnjgDLeiejbq2oo/VGSDRCCNVYmBe/uTe+x4eR15RzqNbcg5ltbXGM/MRwTKnoQwnT3Y69o+OE45/qOAOE6IRWMjoVo3VKNWZnnG0Mf1GfA6YVntmHfBHMVxbFY5w=", "action": { "action": "add", "newUpdateId": "969757e6-daf1-4204-9b30-17cfcc6bc42d", "modelKey": "event", "id": "df90543f94ff894427b79827" }, "data": { "type": "lightMotion", "start": 1643055813957, "end": 1643055813957, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "lightId": { "text": "61b3f5c801f8a703e7000428" }, "lightName": { "text": "Flood Light" } }, "camera": null, "partition": null, "user": null, "id": "df90543f94ff894427b79827", "modelKey": "event" } }, "25.51208840002073": { "raw": "AQEBAAAAAHN4nB3MQQoCMQxA0asMWVtw0tRJvYG49gBpksKAdlyUERHvLnX74f0PiPZ1a3CeQMzgMEHz1+1p0v1io0aKKFo5mBQPdCwcSmYLibKeqhbOSQd7bOb3q7+H8d1bH3H9LwhpqUkMfRaZIy6utSIbfH9JgiSLAgEBAAAAAOl4nG1QTWsCMRD9K5KzwibZ7Gqv7RakKAXbU+khJrN0IR+SjKUi+987Wawi9DbvYx5v5szwdAD2MGMZQo5pE3GIgc0Jo05IAm9qWSm15LJRnHgI9h82m5hKTFVmT5tPgGDwjcIz0R+f93z3DQGvggfUVqMmfL70WNsJIPyUDkwZvue8MWIFNSil26qXq1Y2bJz/Nd9qD3c7m338Ajd7NM7bOBkvp94s78+vi1233XVsLLqhiFRahKNzhA/Ud5j+caWOGdINDaUlq0Xd9kpbAVxrLkULpu/F0pYv+mjBvcCp+KAczcZfkCxvsg==", "action": { "action": "add", "newUpdateId": "3432acf8-dabe-40b8-b98d-549c6fcb895c", "modelKey": "event", "id": "4247f5ad2e1aa1327ecff28d" }, "data": { "type": "sensorMotion", "start": 1643055813651, "end": 1643055813651, "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "metadata": { "sensorId": { "text": "5c1b116c29e4e55a70f39736" }, "sensorName": { "text": "Mbohel Cclmdo" }, "type": { "text": "UFP-SENSE" } }, "camera": null, "partition": null, "user": null, "id": "4247f5ad2e1aa1327ecff28d", "modelKey": "event" } }, "25.51315459999023": { "raw": "AQEBAAAAAHV4nB3MUQrCMBAE0KuU/TbgNpuEeAPxuwfYZFcoaCK2IlK8u24/Z4Y3G3Bd597gNMDrIbwqHAZo+p72cBYbCGX0HIpLpN4RF3HMFZ3k5ElDII3Z2L2L3i76MbNoW/rT2nn/CBULYqxjViOcjlf/5xG+P/xBJTQCAQEAAAAAOXicq1bKLPbNL8nMz3NJLUlNLklNUbJSKCkqTdVRUMpFEXcsAcoYmpkYG5iaWhgam5ka1gIAmzwTwg==", "action": { "action": "update", "newUpdateId": "41d23a5b-74e3-4abd-aac1-d9734e554e69", "modelKey": "sensor", "id": "5c1b116c29e4e55a70f39736" }, "data": { "isMotionDetected": true, "motionDetectedAt": 1643055813651 } }, "25.84483040001942": { "raw": "AQEBAAAAAHh4nB2MWwrCQAxFt1Ly7cCkmUwz7kD8dgHziFCsjUhFRNy7TD/vOZz7hVy32VY4DvB6tLwpHAZY9X3Zx6l1EYtOSJ4dVR9dQGRXQlKX45iTVAkyUc/u1nQ566c3zey5WL11Pu8vWAVHFX9tMVEujExclAR+fwpKJTwCAQEAAAAASnicq1ZKyilNLcnPL8lwzs/LS00uyczPCy5JLElVslKoVirOTM9LzAksTczJLKkEipgZ6ShABYNLilLz0ksygKK6Zqa1tQADWBoy", "action": { "action": "update", "newUpdateId": "6be71305-3c06-4115-b49e-a62a98c84873", "modelKey": "doorlock", "id": "1c812e80fd693ab51535be38" }, "data": { "bluetoothConnectionState": { "signalQuality": 62, "signalStrength": -65 } } }, "25.853134399978444": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbWCapnXGG4hrD5BkfqGgidiKiHh3SZf/Pd7/UszbUgudOno9LG6gQ0cF7+s+ztYEgkUJPjlLGS7IMDphUZd7g6iMHP3csns13C74tGZFWeuz0WX/YA9oUp37o006hAmcJTPT7w8IiCV8AgEBAAAAAEp4nKtWSsopTS3Jzy/JcM7Py0tNLsnMzwsuSSxJVbJSqFYqzkzPS8wJLE3MySypBIqYmOooQAWDS4pS89JLMoCiuuZGtbUAA2saMQ==", "action": { "action": "update", "newUpdateId": "e4da842b-dbce-4835-8089-c1de89850a2f", "modelKey": "sensor", "id": "02ee9b99f17d69346e0c8c00" }, "data": { "bluetoothConnectionState": { "signalQuality": 45, "signalStrength": -72 } } }, "25.86031219997676": { "raw": "AQEBAAAAAHV4nB3MSwrDMAwE0KsErWsI/shyb1C6zgEUySSGfLpwKSX07o2znBneHMBSy77BvYP3S7lmuHWw5c9whYe2IbIjJREjOLLxTL1hi2IwOUWyIY19amzdNS/P/G1mKdNcW1muC8fKkYIinpBIowbrPTL8/s3eJMkCAQEAAAAAPHicq1bKLA7ILPLNL8nMz3NJLUlNLklNUbJSKCkqTdVRUMpJLC6ByAHFDM1MjA1MTS0MjS1NzWsBW5ESsw==", "action": { "action": "update", "newUpdateId": "7a38d8cc-c6ba-4a80-a26c-693d68259b09", "modelKey": "light", "id": "3ada785d6626c88d7d52446a" }, "data": { "isPirMotionDetected": true, "lastMotion": 1643055813957 } }, "25.863951799983624": { "raw": "AQEBAAAAAHd4nB2MWwrCMBBFt1Lm20ASOnm4A/HbBcwkM1DQRGyLiLh3ST/vOZz7BSrb0hucJ9iflTaB0wRN3rdjXOoQhYNEQmcQE5sZ1RvOWU0OUYOUuRDTyB69yv0qn9Gs0tb+GnQ5PrxlJz5xJlSu6mywrLEm+P0BHxQmYAIBAQAAAABKeJyrVkrKKU0tyc8vyXDOz8tLTS7JzM8LLkksSVWyUqhWKs5Mz0vMCSxNzMksqQSKmJjqKEAFg0uKUvPSSzKAorrmRrW1AANrGjE=", "action": { "action": "update", "newUpdateId": "cb6e7a51-558b-45f2-b99f-967f6ec4caba", "modelKey": "sensor", "id": "20b1e28b9a5fbdf1060bf7d8" }, "data": { "bluetoothConnectionState": { "signalQuality": 45, "signalStrength": -72 } } }, "25.977149199985433": { "raw": "AQEBAAAAAHZ4nB2MWwrCQAxFt1Ly7UCdZ+IOpN8uIJNEKNhWpEWKuHedft5zOecDLOu4zHDpYHsqrwanDmZ7345x1XYoCWH25Hyt6mLss8Pkg9MsiuVMVXps2rSoPQbbmyM82YsbHY9Gwsy1yL+Qow8S7qqxGCX4/gDWFyUeAgEBAAAAAD54nKtWykksLvHNL8nMz1OyUjA0MzE2MDW1MDS2sDTWUVAqSk3OL0rJzEsPTs5ITSnNSS0GKoqOrQUA3B4RaA==", "action": { "action": "update", "newUpdateId": "d9c98629-2bbd-4406-8523-d6cd8719bc08", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055813893, "recordingSchedules": [] } }, "26.084475699986797": { "raw": "AQEBAAAAAHd4nB2MSwrDMAxErxK0rkGOHH96g5J1DyBLWgSapJSEUkru3jqrYebx5gss27QucO1gfypvBpcOFnvfz3LTBlQkIHF1UXvvQvGDKxWLy0gUfPlHkqbNq9pjtE9zhGd7cVun80NC7AWTVo3EmnHgRD5LguMHuuAkaQIBAQAAAAAieJyrVipKTc4vSsnMSw9OzkhNKc1JLVayUoiOrQUAhrYJrA==", "action": { "action": "update", "newUpdateId": "dcc403ab-6d21-4915-9b09-80334198037c", "modelKey": "camera", "id": "c462c07dbd63ad805a7318c7" }, "data": { "recordingSchedules": [] } }, "26.112119500001427": { "raw": "AQEBAAAAAHZ4nB2MSwrCQBAFrxJ67YDzMd3jDSRrD9D2vIGASUQSJIh3l8nyVVHvS2rruMx07Wh7FV1Bp45mfO7HuJUm+povKUlxqFFdgvROYMEZB/OcWTj4lk1LwXPA3hrTCW9tdDw+ksYYS86xQhAgicPD8tnT7w/bGiTNAgEBAAAAACJ4nKtWKkpNzi9KycxLD07OSE0pzUktVrJSiI6tBQCGtgms", "action": { "action": "update", "newUpdateId": "6f95448d-ef3a-4e86-8ec2-c72c17978721", "modelKey": "camera", "id": "4a333d993fe8e2e8472bc901" }, "data": { "recordingSchedules": [] } }, "26.113241899991408": { "raw": "AQEBAAAAAHZ4nB2MSwqDQBAFryK9zoA6bWY6Nwiuc4Ce/oAQNQQlSMjdw7h8VdT7Ass2rQvcGthfypvBpYHFPo9z3LUKj617dgyZWgmI1AWOxkFKxCtmQaJUs3lVe4521EZ4tjdXOp0fXKL1yXsdBmfNXVKWVArB7w/xDCXaAgEBAAAAACJ4nKtWKkpNzi9KycxLD07OSE0pzUktVrJSiI6tBQCGtgms", "action": { "action": "update", "newUpdateId": "f30ff8f4-890c-4491-a3ea-cb34648c4997", "modelKey": "camera", "id": "ab3e27f2d55fad817dac7bb9" }, "data": { "recordingSchedules": [] } }, "26.122019900009036": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbWAaE5vxBtK1B5hmfqBgW5EWKeLdNV2+93n/Q5rXcZnp2tD2NF1Bp4ZmvO8H3KwO8GraRe9SYXOB2TvhPw45JoWIcLKaTYvh0WOvTdYJL612PD5a4JwkYggovu2Yw6VI8Zm+P+ApJQ4CAQEAAAAAj3icTYvBCsIwEER/JeSs0KpR8NqzB+1RPIRkmyzEjSZbpJT+u1u8eJt582bWBVwuHin0LoIfE1R9VvfHRukPDthlInCMmXq2DDLN2kUrMEluzV68ocB7BHKTEHM6GkGvON1++q5ppFcMZNN1tAl51do/2nMBChwFbw/ruVb0UvQlF8aqOvuEYvWyfAEj/TdB", "action": { "action": "update", "newUpdateId": "e2ada752-8f0d-4002-9052-bc58ae99908d", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "recordingSchedules": [], "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -45, "ssid": "Mortis Camera" } } }, "26.123121900018305": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbWBijc54A3HtASaZHyjYVqRSinh3SZf/Pd7/kpVlmCe6dvR5uS2gQ0cT1sc+bt5Erhy1sIXY1xROF0bIaghISbI7w1haNs6O5x1ba4qNeFujw/5RuXhMWTJc0YuoHuVsUuj3BxYCJgACAQEAAAAAInicq1YqSk3OL0rJzEsPTs5ITSnNSS1WslKIjq0FAIa2Caw=", "action": { "action": "update", "newUpdateId": "bf019c0a-13f5-470e-b9ae-e558bdd0ea08", "modelKey": "camera", "id": "f0cd15b8bed9e38899286a8c" }, "data": { "recordingSchedules": [] } }, "26.12451479997253": { "raw": "AQEBAAAAAHd4nB2MSwrDMAwFrxK0rsEf2Up6g5J1DyBZCgSapJSUUkLvXpzlm2HeAVz3eVvh2sH7qbwbXDpY7XM/x02bKDEhaQmupkQOfd87iZJdUfJIIZtladmyqT1G+7am8mIvbnQ+P0IdOKqgTsUmZSRO2Q9i8PsDti4lNQIBAQAAAACheJxNjTEPgjAQhf8K6awJBYvGlZlBGY1DUw56SW2xPWII4b97jYvj+9539zbhdKIuEAYvroVsTnWp1EXWsioPhYhgQhzQT72xMCwOEkuPJzcfHLEN3oPJpz1pAq42Yaxm6PIvVbM3Rngv4M3KRJ0bxWi26/2nV2UeSTh57W6LdkhZk3+0pwh+Isv4qKqMEw4cRBciYSpa/YKoxb5/Adb/Puo=", "action": { "action": "update", "newUpdateId": "62347d61-c337-4088-b2b5-6d704715ee5b", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "lastMotion": 1643055813120, "recordingSchedules": [], "wifiConnectionState": { "channel": 153, "frequency": 5765, "phyRate": 200, "signalQuality": 100, "signalStrength": -52, "ssid": "Mortis Camera" } } }, "26.125862699991558": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbSAZQ5p4A3HdA8x0fqBgW5GKSPHumi7/+7y3k4zbtC506ej1MNlAp44WvIdjXK0dIdek4N5xPEcXNcGpD8Vp1FB84Gylb9q8Gu43fJozyoynNDodDXCtXgz/kK8srNAUcir0/QHRhiVgAgEBAAAAAKN4nGWNuw7CMAxFf6XKDFJfSRFr5w7QETFEqdtYCgkkrlBV9d9xhcTC5nPutb0KpxN1gTB4cc4KVVe5lKeiUlIeMvG0y1UTcCIbxggmxAH91BsLw+wgcXK7c/LGEdvgPZj9Uk/fpVUYq1k6nhW3xgivGbxZmMu6av4/JJy8dpdZO6S9VeT5z/YUwU9kWR9VueuEA4PoQiRMWasfELXYtg+pBkKb", "action": { "action": "update", "newUpdateId": "18f6be27-2434-4b6e-b019-b4b190128d97", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "lastMotion": 1643055813655, "phyRate": 57, "recordingSchedules": [], "wifiConnectionState": { "channel": 6, "frequency": 2437, "phyRate": 57, "signalQuality": 100, "signalStrength": -62, "ssid": "Mortis Camera" } } }, "26.274484499997925": { "raw": "AQEBAAAAAHV4nB2MWwrCMBBFt1Lm24DGPG7cgfjtAiaZGSjYVqRFRNy7pJ/3HM79Erd1XGa6DLQ9hVelw0Czvu/7uEoX2QwZKTsGzAXU5LiG6Jr5kGMRD4+eTYvo46af3jSe9MWdjvuHHZucYkVVKXoGSvFIjEa/P+VgJUUCAQEAAAABQnicbVJNT8QgFPwrpmdjHqW04FHjTQ+bHo0HtrxtiUgrUHWz6X+XUtP98gYz84Z5Ew6Zw6Z3Stu2bjpUo0Gf3d+8vt3eZD7IMF8Omft52IdEEF6VtBKcEoiKsOIVAAjCRE5KEolvvdNpsumktWji2Y7GRGbn8HNE2+yPkNH2vR4Q1ct28EfY69ZKsxml0WFWM1jBOji0begiClNEtzIEdPv04oCuQRtki0cr7R876dq4ZMR20nicrQziUMcdZ2GmtG/6GLUJqLLZ80sr7Jft14aCdGEuoaSCcMpZLqoqSlfBk1WJLigwVolCCHFKp/nnzbmDyC8c/gTJgxMAMhca9AcaOXj8J0NxJjjLwGICzq/mLzNcOZxmIMAA+JQ+RO+WXg/Z6HF5ByiIkhU0p2mRpc6CsDsCPFYA8beQYpqmX03Ft/w=", "action": { "action": "update", "newUpdateId": "7ff87867-a88f-48b6-ab45-cf24759d2828", "modelKey": "camera", "id": "f0cd15b8bed9e38899286a8c" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 18763798310, "txBytes": 700091592161, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852977, "recordingEnd": 1643055794999, "recordingStartLQ": 1639183852992, "recordingEndLQ": 1643055810011, "timelapseStart": 1639183852941, "timelapseEnd": 1643055599988, "timelapseStartLQ": 1639183852941, "timelapseEndLQ": 1643055105008 }, "storage": { "used": 1603096543232, "rate": 415.108794037914 } } } }, "26.32569989998592": { "raw": "AQEBAAAAAHV4nB2MSwrCQBAFrxJ67cA4n+7EG4hrD9C/QMAkIgki4t1lsnxV1PsC6zatC1w62J/Gm8Opg8Xf92NcrQk0ieSZg3hvoUS1MHAcA1biM9ZEUbRl82r+uPmnNcqzv7jR6fioPbKQJhEsKWsezQr5UOH3BwxwJcICAQEAAAABUHicbVJBboMwEPxKxLmqbMBgemzVW3OIOFY9OLABq66h9tI2ivh715AmUOVmz86OZ0Y+RUZ53HaoOxs9bHiWJkwIyVNexHebyEHVuVrbpqxaqAcDnkivbzTxqDBcTpH7eTziNIh5XIhUcsYZMfCCc8ZiyQUj3Tyjybc+6Gm1apW1YOhsB2NocnDwOYCtjlfIaPte9gD1dt/7K+x1Y5XZDcpoDGzBLmCJDmyDLaFsJHSvEMEdpxd7cBVYVA1cpbR/apVrKCVhB2U8BCkD0JcUMhCjWvuqI6sVQh0FzS9dQzfHv1SEyuHcIcWVeZrIjC87fLb1smImGVtVHPZfdkuFNEnkP4U/wlkjzwMB9QcY1Xu44UGsCCsPqTx7WO+vPdxQWHoQucgLOU4/onNzr6do8DC9U5BAkXGexXEagsx15kzexwn9MMZlIopiHMdfrzK/zg==", "action": { "action": "update", "newUpdateId": "6db07e3a-be8d-40cd-9a0f-657a165270bc", "modelKey": "camera", "id": "586ab7c2bb6423c3fdd47e95" }, "data": { "lastMotion": 1643055814192, "recordingSchedules": [], "stats": { "rxBytes": 21295481010, "txBytes": 1002815058176, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640288743861, "recordingEnd": 1643055810800, "recordingStartLQ": 1640288744338, "recordingEndLQ": 1643055810778, "timelapseStart": 1640288743858, "timelapseEnd": 1643055480800, "timelapseStartLQ": 1640288743858, "timelapseEndLQ": 1643055575798 }, "storage": { "used": 1987496116224, "rate": 708.231920183599 } } } }, "26.33047240000451": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbSCJjWm8gbjuAWbmT6BgW5GKiHj3ki7/e7z/I9ZtWhe6dvR+gjejU0eLfcZj3NBEylW5GpyPIbnewzuRUJxc2KP0g8agLZtX2ONu39Yoz/biRqfjg+VsMdeIlCpjCBmsWaTQfwcPriZDAgEBAAAAAUF4nG1STU/EIBT8K4azMdAWCh413vSw6dF4YOnbloi0AlU3m/53gZrulzeYmTfMm3BADtTgWm27RvXQTgY8ur95fbu9QT7IkC4H5H4e9iETpK6qEvOS0DoqwoozWhaMM0EEFpH41judJ1UvrQUTz3YyJjI7B58TWLU/Qkbb92YEaF+2oz/CXndWms0kjQ5JTfEKNsGB7UIfUTxHdCtDALfPL47gFNggOzhaaf/YS9fFJSO2k8ZDsjIAYxN3TELUaq+GGFUFaFHy/NItDMv2a0NBupBKYKUgvORxZ55CrYIn22Y6VkRpLRjB7JTO88+bcwfKLxz+BNmDE4JJblp/gJGjh38y1OJUcJaBkWRwNX+Z4crhNAMhDFM+5w8xuKXXA5o85HeqmuCCFYLzpYqlzrKkdwUjvBZV/BO8muf5Fz3Et/I=", "action": { "action": "update", "newUpdateId": "57fcafed-0215-40d0-bb19-b6a0d948c21c", "modelKey": "camera", "id": "ab3e27f2d55fad817dac7bb9" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 17443083157, "txBytes": 653268691909, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183853280, "recordingEnd": 1643055796106, "recordingStartLQ": 1639183853258, "recordingEndLQ": 1643055811017, "timelapseStart": 1639183853279, "timelapseEnd": 1643055611101, "timelapseStartLQ": 1639183853279, "timelapseEndLQ": 1643055116058 }, "storage": { "used": 1471026298880, "rate": 335.261879469184 } } } }, "26.33272589999251": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbSCTTqLxBuLaA6SZP1CwrUiLiHh3TZf/fd77UKnruMx07mh7aFlBh45mvG77uGg7Aoe+WKjOzJKTKOYGpOg8tLIKImdt2rQo7le8m1PLhGdpdNwbDPSnHDEILPDRe0mW/036/gASwCXKAgEBAAAAAU54nG1SwVLDIBD9lU7O6gALhHjU8aaHTo6OB5psE0YkkRC108m/C0mtrfYG7719+3Zhn3msOl8b15RVi/VocchuV88vV6tsCDqkyz7zX3e7MBOKCslyKoFEQTjCwGXOJBMFBRmJT7M1c2HVaufQxjMVEImtx/cRXbWLiMiliJA17rXsEeunTZ+s3Ghtam4ap+161NaEpKaEHNEyeHRNaCN8zcUU8Y0OAf1u7tmjr9AF3eCvmxnuW+2bOGXEttoOmMwsYl/GIZMwq81QdTFsFbDOkueHqbFbxj+uKGgfUhgJBaMFU5zkRZQeBQ+unmkORAhFlGD0lJ7rH9enDlCo/I/DQXDwKDikNQXzhlb3A17IsDzGj+Asg6AgJPlXf57hgsNpBgEgmJrmH9H5Za/7bBww9YEijwY850qlMZZlUrgBAgyAcUoUK6Zp+gbGs7XF", "action": { "action": "update", "newUpdateId": "2123af2c-fff6-454f-be65-0edc1d4e519d", "modelKey": "camera", "id": "1ee3895eb4ef2170046f9f2c" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 8156271630, "txBytes": 346726259136, "wifi": { "channel": 153, "frequency": 5765, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -45 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639219284079, "recordingEnd": 1643055808521, "recordingStartLQ": 1639219283987, "recordingEndLQ": 1643055809435, "timelapseStart": 1639219284030, "timelapseEnd": 1643055513560, "timelapseStartLQ": 1639219284030, "timelapseEndLQ": 1643055533528 }, "storage": { "used": 39728447488, "rate": 13.3032332410829 } } } }, "26.341367200016975": { "raw": "AQEBAAAAAHV4nB2MSwqDQBAFryK9zsB8WpzODULWHqB/ghA1BEMIkruHcfmqqHcA6z5vK1w7eD+Nd4dLB6t/xnPcrAkWNFLCUCWmgKIaZEocsM9DFKI+FWvZspk/7v5tjfLiL250Pj+QSylGVCavnr3ikEUpJvj9Afs4JTYCAQEAAAABRnicbVJNT4QwFPwrG87GPOgnHjXe9LDhaDx04S001oJtUTcb/rsta2BXl1OZN286M+kxc1j3rtG2reoOm9Ggz+42L683m8wHFdLPMXPf94cwDwrBioLHD0RkhAXPaSkEJZRSoCxOvvRez6t1p6xFE892NCZO9g4/RrT1YYWMtm/VgNg87wa/wl63VpntqIwOic1gAavg0LahiyhMEd2pENAd5hsHdDXaoFpcpbR/6JRrY8qI7ZXxmKQM4lDFkImYNdrXfbRaB2yypPmpG+xP8ZeKgnIhpeWkzCWRrChLGakL4dE285gSYExIKOaaLveftucKBID+UfglzBoyB+Blqlq/o1GDx2se2DnhwgMHKHL6b//SwxWFcw/RAmF8ml9E7069HrPRY7on+mcUCOGMCJ6CnOqUpLilIGReSuCilNM0/QDA8bgj", "action": { "action": "update", "newUpdateId": "ab4d9c94-8b01-4bcc-bf1a-45270b99513d", "modelKey": "camera", "id": "4a333d993fe8e2e8472bc901" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 27522666607, "txBytes": 1497743444045, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852998, "recordingEnd": 1643055780207, "recordingStartLQ": 1639183853004, "recordingEndLQ": 1643055810069, "timelapseStart": 1639183852995, "timelapseEnd": 1643055600214, "timelapseStartLQ": 1639183852995, "timelapseEndLQ": 1643055100356 }, "storage": { "used": 3005403365376, "rate": 832.407819806798 } } } }, "26.34329859999707": { "raw": "AQEBAAAAAHR4nB2MSwrCQBAFrxJ67cCkaZsZbyCuPUBPfyBgEpFIEPHuMlm+Kup9QXSb1gUuA7yfJpvDaYDF9/sxrtZFidBAs1RHwUQuJVXMkQpVVWIKOnPP5tX8cfNPb1Rmf0mn0/HhGJHFnJvnQMHmjcfCFX5/GOQmNQIBAQAAAAFIeJxtUk1PwzAM/StTzoDy2bUcQdzgMPWIOGSN10aEtKQpME397zitVsa2W/z8/Pzs+EACVG0w1tdl1YAZHPTkfvX6drMifdQxBQcSfh72cUpIWRRKZIpJJMQFZlKpQnHBc1Fg4tvu7FRYNdp7cPjOEN4F+BzAV3uMuRRrhJz172UHYF62XRLyg3Opta29dptBOxsTm1G6oGUM4OvYIHyb8RHxrY4Rwn7q2EGowEddw5+a7R8bHWqcEbGddj0kMQfQlThiIhJj+6pFq1UEQ5LmlzXQzsMvC4o6xGQmk1TQPJdUZWmGhfDkzZwWVKmcMSrVaXqqf96cKYgzhSPhqJHztGn7AU53PVzxIMQp4Z8HxXFx6qL+3MOFwqkH/G3K6DjdQxvmvR7I0EPqs5ZrgT9JC0XTQYR5m0LyO8bxFljOqZJ8HMdfTky1Rw==", "action": { "action": "update", "newUpdateId": "8ffcf2dd-91a2-4ea8-920f-849cc464f456", "modelKey": "camera", "id": "e2ff0ade6be0f2a2beb61869" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 4499536514, "txBytes": 145595232839, "wifi": { "channel": 6, "frequency": 2437, "linkSpeedMbps": null, "signalQuality": 100, "signalStrength": -62 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1640308840567, "recordingEnd": 1643055811045, "recordingStartLQ": 1640308840563, "recordingEndLQ": 1643055811082, "timelapseStart": 1640308840533, "timelapseEnd": 1643055521005, "timelapseStartLQ": 1640308840533, "timelapseEndLQ": 1643055536010 }, "storage": { "used": 747324309504, "rate": 342.122831820542 } } } }, "26.3449478999828": { "raw": "AQEBAAAAAHZ4nB2M0QrCMAxFf2Xk2ULatEv1D8RnPyBNIgzcJjIREf9dusd7Dud+QXSb1gVOA7weJpvDYYDF39d9nK2LRpS9HCmoaQvZEwWpiQMj5hKjF8Nbz+bV/H7xT29UZn9Kp9P+oXlMimzNRhKrWIQpVmX4/QHc6ST0AgEBAAAAAUV4nGVSTU/EIBT8K5uejeGzpR413vSw6dF4YMvbloi0AlU3m/53gTXtftzKzLxh5tFj4aAdnNK2a9oe1GTAFw+bt/e7TeGDDOlwLNzv4yFkggjGKsJrjnhUhAXHXNAaUVFTRkVkfvRe59G2l9aCid92MiYyewdfE9j2sEJG249mBFCvu9GvsNedlWY7SaNDUnO0gE1wYLvQRxTNEd3JEMAd8o0juBZskB2sVto/9dJ1sWXE9tJ4SFYGYGxiySQslPbtEKO2AVSRPL+1guFUf1lRkC6ktiWtsaCCkxqVUboInq3KNKOI86pidU3P6Tz/sr10wPjK4V+QPQTiiCZB0J9g5OjhNoMQ4lxwkYFXHJf8Zv4qw63DeQYSFVU15z9icKe9HovJg8qPggkhpaCMUZKKnNaJK07vcVmSkkeCsnme/wDmPLg7", "action": { "action": "update", "newUpdateId": "b334e593-cdcb-4e23-a827-7004511e5d0f", "modelKey": "camera", "id": "c462c07dbd63ad805a7318c7" }, "data": { "recordingSchedules": [], "stats": { "rxBytes": 28447259505, "txBytes": 1583903893438, "wifi": { "channel": null, "frequency": null, "linkSpeedMbps": null, "signalQuality": 50, "signalStrength": 0 }, "battery": { "percentage": null, "isCharging": false, "sleepState": "disconnected" }, "video": { "recordingStart": 1639183852906, "recordingEnd": 1643055774993, "recordingStartLQ": 1639183852911, "recordingEndLQ": 1643055805031, "timelapseStart": 1639183852888, "timelapseEnd": 1643055575165, "timelapseStartLQ": 1639183852888, "timelapseEndLQ": 1643055285277 }, "storage": { "used": 5012226834432, "rate": 1753.16626544334 } } } }, "26.353903600014746": { "raw": "AQEBAAAAAHZ4nB2MQQrCMBBFr1JmbaBpJknjDcS1B/jNzEDBtiIVEfHuki7/e7z/JdR93lY6d/R6CHalU0ervm/HuEgTXCRKD3NxiOyYx+KAMLocc/DJEth8y5ZN9H7VT2sqFn2i0fn48LVgkInFkpqAM0Lsy6T0+wPPQCVuAgEBAAAAAVF4nG1Sy07DMBD8lSpnQH7GCUcQN3qockQc3GSbWBgn2BugqvLv2KkoTcvRs7MzsyMfMqsDrns0vcvuVzQXnEhZUJ7T8maVeah73xjXVnUHzWghRNLLa5wE1Jgeh8x/P+xxHqiSKiJLLvJIwBPMFBGc5UIIQuPgy+zMvFh32jmwyVbyONh5+BjB1fuISJXLCFnj3qoBoFlvhyTlRmuTuWmdtptRW4OJTQk5oRV6cC12Eb6VbIr4ViOC38+eA/gaHOoW/tRMeOy0b+OVEdtpGyCJWYChikcmYtaYUPcxbI3QZEnz0zTQH88/VYTa47FDWjJKRCnLRYdPrjmvmCgpFxWn/efNUkGJC4VfwkIDzTtYPQT4L4M4JywyKE4UVVf7lxmuFM4zSEYUk9P8I3p/7PWQjQGSj2CKi5IJWUqW7ji2mQt2xwkrClpEDzpN0w/ZVb14", "action": { "action": "update", "newUpdateId": "49d5d0af-5254-4489-aa38-757316f6a4f1", "modelKey": "camera", "id": "1c9a2db4df6efda47a3509be" }, "data": { "lastMotion": 1643055813619, "recordingSchedules": [], "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": 1643055810755, "recordingStartLQ": 1641921049574, "recordingEndLQ": 1643055810755, "timelapseStart": 1641921049594, "timelapseEnd": 1643055730717, "timelapseStartLQ": 1641921049594, "timelapseEndLQ": 1643055520725 }, "storage": { "used": 427349245952, "rate": 642.30288184191 } } } }, "26.413267600000836": { "raw": "AQEBAAAAAHd4nB2MQQrCMBBFr1JmbWBiJpPEG5SuPUDMTKGgidiKFPHuki7/e7z/hVy2pVW4DPB+St4UTgNU/VyPMUoXGDMLRTZKgoZm9CYFSYaQYnLCMSD17NFE75PuvVm1ru3V6XJ8+GJv1nI5JyX1PgecXQqO4fcHsyskLwIBAQAAAAA6eJyrVsos9s0vyczPc0ktSU0uSU1RslJIS8wpTtVRUMpFkXAsAUoZmpkYG5iaWhiamJoa1QIArvUUDg==", "action": { "action": "update", "newUpdateId": "08a6d486-e4d0-4f05-97d9-404893d68704", "modelKey": "sensor", "id": "5c1b116c29e4e55a70f39736" }, "data": { "isMotionDetected": false, "motionDetectedAt": 1643055814552 } }, "26.51632429996971": { "raw": "AQEBAAAAAHV4nB2MQQrCMBBFr1JmbSDJTEzjDaRrD5DJTKCgqUhrEfHuki7/e7z/hVzWeWlwGWB7Sl4VTgM03W/HuEoX1ScpIwdDCaMhcsFktKNRSq6wj2zPrmePRfQ+6ac37f3qaD4OPGGoNWRGa8W5JNESsyL8/kiXI4ICAQEAAAAAN3icq1YqLSjJzE1VslIwszC0MLAwMDDQUVDKSSwuCU5NzQMKG5qZGBuYmloYmpiZGtUCAEttDMU=", "action": { "action": "update", "newUpdateId": "f29dc8b5-4937-4415-a308-e491cb27b061", "modelKey": "nvr", "id": "2435ff5ab300d119d704bbe3" }, "data": { "uptime": 681808000, "lastSeen": 1643055814652 } } } uilibs-uiprotect-317a52c/tests/test_anonymize.py000066400000000000000000000032441524637705700221530ustar00rootroot00000000000000from __future__ import annotations from urllib.parse import urlparse from uiprotect.test_util.anonymize import anonymize_data _STREAMS = { "high": "rtsps://192.168.1.10:7441/abcdef123456?enableSrtp", "medium": "rtsps://192.168.1.10:7441/secretmediumalias", "package": "rtsps://192.168.1.10:7441/packagealias", } def test_rtsps_streams_dict_anonymized() -> None: result = anonymize_data({"rtspsStreams": dict(_STREAMS)}, name="camera")[ "rtspsStreams" ] assert set(result) == set(_STREAMS) for quality, url in result.items(): assert url != _STREAMS[quality] parts = urlparse(url) assert parts.scheme == "rtsps" assert "192.168.1.10" not in url assert parts.path.lstrip("/") not in _STREAMS[quality] def test_rtsps_streams_within_public_camera_dump() -> None: dump = { "modelKey": "camera", "id": "aabbccddeeff00112233", "rtspsStreams": dict(_STREAMS), } result = anonymize_data(dump) for quality, url in result["rtspsStreams"].items(): assert url != _STREAMS[quality] assert "192.168.1.10" not in url def test_rtsps_streams_list_anonymized() -> None: result = anonymize_data(list(_STREAMS.values()), name="rtspsStreams") for original, url in zip(_STREAMS.values(), result, strict=True): assert url != original assert "192.168.1.10" not in url assert urlparse(url).scheme == "rtsps" def test_rtsps_streams_preserves_non_string_values() -> None: result = anonymize_data( {"rtspsStreams": {"high": None, "enabled": True}}, name="camera" ) assert result["rtspsStreams"] == {"high": None, "enabled": True} uilibs-uiprotect-317a52c/tests/test_api.py000066400000000000000000005254001524637705700207160ustar00rootroot00000000000000"""Tests for uiprotect.unifi_protect_server.""" from __future__ import annotations import asyncio import contextlib import logging import time from copy import deepcopy from datetime import UTC, datetime, timedelta from io import BytesIO from ipaddress import IPv4Address, IPv6Address from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, patch import aiohttp import orjson import pytest from aiofiles import os as aos # Pillow ships with the optional `cli` extra; skip the whole module rather than # fail at collection when it's absent (a minimal install without --all-extras). pytest.importorskip("PIL") from PIL import Image from tests.conftest import ( TEST_BRIDGE_EXISTS, TEST_CAMERA_EXISTS, TEST_HEATMAP_EXISTS, TEST_LIGHT_EXISTS, TEST_LIVEVIEW_EXISTS, TEST_PUBLIC_API_SNAPSHOT_EXISTS, TEST_SENSOR_EXISTS, TEST_SMART_TRACK_EXISTS, TEST_SNAPSHOT_EXISTS, TEST_THUMBNAIL_EXISTS, TEST_VIDEO_EXISTS, TEST_VIEWPORT_EXISTS, MockDatetime, async_read_bytes, async_write_bytes, async_write_text, compare_objs, get_time, validate_video_file, ) from tests.sample_data.constants import CONSTANTS from uiprotect.api import ProtectApiClient, RTSPSStreams, get_user_hash from uiprotect.data import ( Camera, ChannelQuality, Event, EventType, ModelType, PTZPatrol, PTZPreset, PublicBootstrap, PublicCamera, PublicSirenStatus, Siren, create_from_unifi_dict, ) from uiprotect.data.devices import LEDSettings from uiprotect.data.types import DeviceState, Version, VideoMode from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.exceptions import ( ArmedModeError, BadRequest, GlobalAlarmManagerError, NotAuthorized, NvrError, ) from uiprotect.stream import TalkbackSession from uiprotect.utils import decode_token_cookie, to_js_time from uiprotect.websocket import WebsocketState from .common import assert_equal_dump OLD_VERSION = Version("1.2.3") NFC_FINGERPRINT_SUPPORT_VERSION = Version("5.1.57") if TYPE_CHECKING: from pathlib import Path from uiprotect.data.base import ProtectAdoptableDeviceModel from uiprotect.data.bootstrap import Bootstrap async def check_motion_event(event: Event): data = await event.get_thumbnail() assert data is not None img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} data = await event.get_heatmap() assert data is not None img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} async def check_camera(camera: Camera): if camera.last_motion_event is not None: await check_motion_event(camera.last_motion_event) if camera.last_smart_detect_event is not None: await check_motion_event(camera.last_smart_detect_event) assert camera.last_nfc_card_scanned is None assert camera.last_fingerprint_identified is None for channel in camera.channels: assert channel._api is not None data = await camera.get_snapshot() assert data is not None img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} pub_data = await camera.get_public_api_snapshot() assert pub_data is not None pub_img = Image.open(BytesIO(pub_data)) assert pub_img.format in {"PNG", "JPEG"} camera.last_ring_event # noqa: B018 assert camera.timelapse_url == f"https://127.0.0.1:0/protect/timelapse/{camera.id}" check_device(camera) for channel in camera.channels: if channel.is_rtsp_enabled: for _ in range(2): assert ( channel.rtsp_url == f"rtsp://{camera.api.connection_host}:7447/{channel.rtsp_alias}" ) assert ( channel.rtsps_url == f"rtsps://{camera.api.connection_host}:7441/{channel.rtsp_alias}?enableSrtp" ) assert ( channel.rtsps_no_srtp_url == f"rtsps://{camera.api.connection_host}:7441/{channel.rtsp_alias}" ) if VideoMode.HIGH_FPS in camera.feature_flags.video_modes: assert camera.feature_flags.has_highfps else: assert not camera.feature_flags.has_highfps if camera.feature_flags.has_hdr: assert not camera.feature_flags.has_wdr else: assert camera.feature_flags.has_wdr def check_device(device: ProtectAdoptableDeviceModel): assert device.protect_url == f"https://127.0.0.1:0/protect/devices/{device.id}" async def check_bootstrap(bootstrap: Bootstrap): bootstrap.api._api_key = "test_api_key" assert bootstrap.auth_user assert ( bootstrap.nvr.protect_url == f"https://127.0.0.1:0/protect/devices/{bootstrap.nvr.id}" ) for light in bootstrap.lights.values(): if light.camera is not None: await check_camera(light.camera) light.last_motion_event # noqa: B018 check_device(light) for camera in bootstrap.cameras.values(): await check_camera(camera) for viewer in bootstrap.viewers.values(): assert viewer.liveview check_device(viewer) for sensor in bootstrap.sensors.values(): check_device(sensor) for liveview in bootstrap.liveviews.values(): liveview.owner # noqa: B018 assert ( liveview.protect_url == f"https://127.0.0.1:0/protect/liveview/{liveview.id}" ) for slot in liveview.slots: expected_ids = set(slot.camera_ids).intersection( set(bootstrap.cameras.keys()), ) assert len(expected_ids) == len(slot.cameras) for user in bootstrap.users.values(): user.groups # noqa: B018 if user.cloud_account is not None: assert user.cloud_account.user == user for event in bootstrap.events.values(): event.smart_detect_events # noqa: B018 if event.type.value in EventType.motion_events() and event.camera is not None: await check_motion_event(event) def test_base_url(protect_client: ProtectApiClient): arg = f"{protect_client.private_ws_path}?lastUpdateId={protect_client.bootstrap.last_update_id}" assert protect_client.base_url == "https://127.0.0.1:0" assert protect_client.ws_url == f"wss://127.0.0.1:0{arg}" protect_client._port = 443 protect_client._update_url() assert protect_client.base_url == "https://127.0.0.1" assert protect_client.ws_url == f"wss://127.0.0.1{arg}" def test_api_client_creation(): """Test we can create the object.""" client = ProtectApiClient( "127.0.0.1", 0, "username", "password", debug=True, store_sessions=False, ) assert client def test_early_bootstrap(): client = ProtectApiClient( "127.0.0.1", 0, "username", "password", debug=True, store_sessions=False, ) with pytest.raises(BadRequest): client.bootstrap # noqa: B018 @pytest.mark.asyncio() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") async def test_get_is_prerelease_returns_false(protect_client: ProtectApiClient): protect_client._bootstrap = None await protect_client.update() assert await protect_client.bootstrap.get_is_prerelease() is False assert await protect_client.bootstrap.nvr.get_is_prerelease() is False @pytest.mark.asyncio() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") async def test_bootstrap_get_device_from_mac(bootstrap): orig_bootstrap = deepcopy(bootstrap) mac = bootstrap["cameras"][0]["mac"] client = ProtectApiClient( "127.0.0.1", 0, "username", "password", debug=True, store_sessions=False, ) client.api_request_obj = AsyncMock(side_effect=[bootstrap, orig_bootstrap]) client.update_device = AsyncMock() bootstrap_obj = await client.get_bootstrap() camera = bootstrap_obj.get_device_from_mac(mac) assert camera is not None assert camera.mac == mac @pytest.mark.asyncio() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") async def test_bootstrap_get_device_from_mac_bad_mac(bootstrap): orig_bootstrap = deepcopy(bootstrap) client = ProtectApiClient( "127.0.0.1", 0, "username", "password", debug=True, store_sessions=False, ) client.api_request_obj = AsyncMock(side_effect=[bootstrap, orig_bootstrap]) client.update_device = AsyncMock() bootstrap_obj = await client.get_bootstrap() camera = bootstrap_obj.get_device_from_mac("not_a_mac") assert camera is None def test_connection_host(protect_client: ProtectApiClient): protect_client.bootstrap.nvr.hosts = [ IPv4Address("192.168.1.1"), IPv4Address("192.168.2.1"), IPv4Address("192.168.3.1"), "se-gw.local", ] # mismatch between client IP and IP that NVR returns protect_client._connection_host = None protect_client._host = "192.168.10.1" assert protect_client.connection_host == IPv4Address("192.168.1.1") # same IP from client and NVR (first match) protect_client._connection_host = None protect_client._host = "192.168.1.1" assert protect_client.connection_host == IPv4Address("192.168.1.1") # same IP from client and NVR (not first match) protect_client._connection_host = None protect_client._host = "192.168.3.1" assert protect_client.connection_host == IPv4Address("192.168.3.1") # same IP from client and NVR (not first match, DNS host) protect_client._connection_host = None protect_client._host = "se-gw.local" assert protect_client.connection_host == "se-gw.local" def test_connection_host_with_hostname_fallback(protect_client: ProtectApiClient): """Test connection_host property when host is a hostname (not IP).""" protect_client.bootstrap.nvr.hosts = [ IPv4Address("192.168.1.1"), IPv4Address("192.168.2.1"), "hostname.local", ] # Hostname triggers ValueError, falls back to first host protect_client._connection_host = None protect_client._host = "unifi.local" assert protect_client.connection_host == IPv4Address("192.168.1.1") @pytest.mark.parametrize( ("host",), [ ("127.0.0.1",), ("2001:db8::1",), ("192.168.1.100",), ("fe80::1",), ("unifi.local",), ], ) def test_connection_host_override(host: str): """Test override_connection_host stores host as-is (string).""" protect = ProtectApiClient( host, 443, "test", "test", override_connection_host=True, store_sessions=False, ) # Host is always stored as string, format_host_for_url handles formatting assert protect._connection_host == host assert isinstance(protect._connection_host, str) @pytest.mark.asyncio() async def test_connection_host_update_with_dns_resolution( protect_client: ProtectApiClient, ): """Test that update() resolves hostnames via ip_from_host.""" # Setup: host is a hostname that will be resolved protect_client._connection_host = None protect_client._host = "unifi.local" # Create a new bootstrap object with specific hosts bootstrap = protect_client.bootstrap.model_copy() bootstrap.nvr.hosts = [ IPv4Address("192.168.1.1"), IPv4Address("192.168.1.100"), ] # Mock get_bootstrap to return our custom bootstrap # Mock ip_from_host to return an IP that exists in nvr.hosts with ( patch.object( protect_client, "get_bootstrap", new=AsyncMock(return_value=bootstrap) ), patch( "uiprotect.api.ip_from_host", new=AsyncMock(return_value=IPv4Address("192.168.1.100")), ), ): await protect_client.update() # Should have selected the resolved IP from nvr.hosts assert protect_client._connection_host == IPv4Address("192.168.1.100") @pytest.mark.asyncio() async def test_connection_host_update_fallback_to_first( protect_client: ProtectApiClient, ): """Test that update() falls back to first host if no match found.""" # Setup: host won't match anything protect_client._connection_host = None protect_client._host = "unknown.local" # Create a new bootstrap object with specific hosts bootstrap = protect_client.bootstrap.model_copy() bootstrap.nvr.hosts = [ IPv4Address("192.168.1.1"), IPv4Address("192.168.1.2"), ] # Mock get_bootstrap to return our custom bootstrap # Mock ip_from_host to return an IP that's NOT in nvr.hosts with ( patch.object( protect_client, "get_bootstrap", new=AsyncMock(return_value=bootstrap) ), patch( "uiprotect.api.ip_from_host", new=AsyncMock(return_value=IPv4Address("10.0.0.1")), ), ): await protect_client.update() # Should fall back to first host assert protect_client._connection_host == IPv4Address("192.168.1.1") def test_connection_host_ipv6(protect_client: ProtectApiClient): """Test that IPv6 addresses work correctly for connection_host.""" protect_client.bootstrap.nvr.hosts = [ IPv6Address("fe80::1ff:fe23:4567:890a"), IPv6Address("2001:db8::1"), IPv4Address("192.168.1.1"), ] # Test IPv6 address selection protect_client._connection_host = None protect_client._host = "192.168.10.1" assert protect_client.connection_host == IPv6Address("fe80::1ff:fe23:4567:890a") # Test matching IPv6 address protect_client._connection_host = None protect_client._host = "2001:db8::1" assert protect_client.connection_host == IPv6Address("2001:db8::1") def test_rtsp_urls_with_ipv6(protect_client: ProtectApiClient): """Test that RTSP URLs are correctly formatted with IPv6 addresses (with brackets).""" # Set an IPv6 address as connection_host ipv6_addr = IPv6Address("fe80::1ff:fe23:4567:890a") protect_client._connection_host = ipv6_addr camera = next(iter(protect_client.bootstrap.cameras.values())) # Set camera's connectionHost to None so it falls back to NVR's connection_host camera.connection_host = None for channel in camera.channels: if channel.is_rtsp_enabled: # IPv6 addresses should be wrapped in brackets in URLs expected_host = "[fe80::1ff:fe23:4567:890a]" assert ( channel.rtsp_url == f"rtsp://{expected_host}:7447/{channel.rtsp_alias}" ) assert ( channel.rtsps_url == f"rtsps://{expected_host}:7441/{channel.rtsp_alias}?enableSrtp" ) assert ( channel.rtsps_no_srtp_url == f"rtsps://{expected_host}:7441/{channel.rtsp_alias}" ) break @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") def test_rtsp_urls_with_stacked_nvr(protect_client: ProtectApiClient, camera): """Test that RTSP URLs use camera's connectionHost in stacked NVR scenario.""" # Simulate stacked NVR: NVR at 192.168.1.1, but camera connected via 192.168.2.100 protect_client._connection_host = IPv4Address("192.168.1.1") camera["connectionHost"] = IPv4Address("192.168.2.100") camera_obj = Camera.from_unifi_dict(api=protect_client, **camera) # Verify setup assert camera_obj.connection_host == IPv4Address("192.168.2.100") assert protect_client.connection_host == IPv4Address("192.168.1.1") # Test all RTSP-enabled channels use camera's connectionHost rtsp_channels = [ch for ch in camera_obj.channels if ch.is_rtsp_enabled] assert rtsp_channels, "No RTSP-enabled channels found" for channel in rtsp_channels: if channel.rtsp_alias: assert channel.rtsp_url == f"rtsp://192.168.2.100:7447/{channel.rtsp_alias}" assert ( channel.rtsps_url == f"rtsps://192.168.2.100:7441/{channel.rtsp_alias}?enableSrtp" ) assert ( channel.rtsps_no_srtp_url == f"rtsps://192.168.2.100:7441/{channel.rtsp_alias}" ) def test_api_client_with_ipv6(): """Test that ProtectApiClient handles IPv6 addresses correctly in URLs.""" # Test with IPv6 address protect = ProtectApiClient( "fe80::1", 443, "test", "test", store_sessions=False, ) # Check that URLs are formatted with brackets for IPv6 assert "[fe80::1]" in str(protect._url) assert "[fe80::1]" in str(protect._ws_url) # Test with IPv6 and custom port protect_port = ProtectApiClient( "2001:db8::1", 7443, "test", "test", store_sessions=False, ) assert "[2001:db8::1]:7443" in str(protect_port._url) assert "[2001:db8::1]:7443" in str(protect_port._ws_url) @pytest.mark.asyncio() async def test_force_update_with_old_version(protect_client: ProtectApiClient): protect_client._bootstrap = None await protect_client.update() assert protect_client.bootstrap original_bootstrap = protect_client.bootstrap protect_client._bootstrap = None with patch( "uiprotect.api.ProtectApiClient.get_bootstrap", AsyncMock(return_value=AsyncMock(nvr=AsyncMock(version=OLD_VERSION))), ) as mock: await protect_client.update() assert mock.called assert protect_client.bootstrap assert original_bootstrap != protect_client.bootstrap @pytest.mark.asyncio() async def test_force_update_with_nfc_fingerprint_version( protect_client: ProtectApiClient, ): protect_client._bootstrap = None await protect_client.update() assert protect_client.bootstrap original_bootstrap = protect_client.bootstrap protect_client._bootstrap = None with ( patch( "uiprotect.api.ProtectApiClient.get_bootstrap", return_value=AsyncMock( nvr=AsyncMock(version=NFC_FINGERPRINT_SUPPORT_VERSION) ), ) as get_bootstrap_mock, patch( "uiprotect.api.ProtectApiClient.api_request_list", side_effect=lambda endpoint: { "keyrings": [ { "deviceType": "camera", "deviceId": "new_device_id_1", "registryType": "fingerprint", "registryId": "new_registry_id_1", "lastActivity": 1733432893331, "metadata": {}, "ulpUser": "new_ulp_user_id_1", "id": "new_keyring_id_1", "modelKey": "keyring", } ], "ulp-users": [ { "ulpId": "new_ulp_id_1", "firstName": "localadmin", "lastName": "", "fullName": "localadmin", "avatar": "", "status": "ACTIVE", "id": "new_ulp_user_id_1", "modelKey": "ulpUser", } ], }.get(endpoint, []), ) as api_request_list_mock, ): await protect_client.update() assert get_bootstrap_mock.called assert api_request_list_mock.called api_request_list_mock.assert_any_call("keyrings") api_request_list_mock.assert_any_call("ulp-users") assert api_request_list_mock.call_count == 2 assert protect_client.bootstrap assert original_bootstrap != protect_client.bootstrap assert len(protect_client.bootstrap.keyrings) assert len(protect_client.bootstrap.ulp_users) @pytest.mark.asyncio() async def test_force_update_no_user_keyring_access(protect_client: ProtectApiClient): protect_client._bootstrap = None await protect_client.update() assert protect_client.bootstrap original_bootstrap = protect_client.bootstrap protect_client._bootstrap = None with ( patch( "uiprotect.api.ProtectApiClient.get_bootstrap", return_value=AsyncMock( nvr=AsyncMock(version=NFC_FINGERPRINT_SUPPORT_VERSION) ), ) as get_bootstrap_mock, patch( "uiprotect.api.ProtectApiClient.api_request_list", side_effect=NotAuthorized, ) as api_request_list_mock, ): await protect_client.update() assert get_bootstrap_mock.called assert api_request_list_mock.called api_request_list_mock.assert_any_call("keyrings") api_request_list_mock.assert_any_call("ulp-users") assert api_request_list_mock.call_count == 2 assert protect_client.bootstrap assert original_bootstrap != protect_client.bootstrap assert not len(protect_client.bootstrap.keyrings) assert not len(protect_client.bootstrap.ulp_users) @pytest.mark.asyncio() async def test_force_update_user_keyring_internal_error( protect_client: ProtectApiClient, ): protect_client._bootstrap = None await protect_client.update() assert protect_client.bootstrap protect_client._bootstrap = None with ( pytest.raises(BadRequest), patch( "uiprotect.api.ProtectApiClient.get_bootstrap", return_value=AsyncMock( nvr=AsyncMock(version=NFC_FINGERPRINT_SUPPORT_VERSION) ), ), patch( "uiprotect.api.ProtectApiClient.api_request_list", side_effect=BadRequest, ), ): await protect_client.update() @pytest.mark.asyncio() async def test_get_nvr(protect_client: ProtectApiClient, nvr): """Verifies the `get_nvr` method""" nvr_obj = await protect_client.get_nvr() nvr_dict = nvr_obj.unifi_dict() compare_objs(ModelType.NVR.value, nvr, nvr_dict) @pytest.mark.asyncio() async def test_bootstrap(protect_client: ProtectApiClient): """Verifies lookup of all object via ID""" await check_bootstrap(protect_client.bootstrap) @pytest.mark.asyncio() async def test_bootstrap_cached_property(protect_client: ProtectApiClient): """Test cached property works with bootstrap.""" bootstrap = protect_client.bootstrap assert bootstrap.has_doorbell is True bootstrap.cameras = {} assert bootstrap.has_doorbell is True bootstrap._has_doorbell = None assert bootstrap.has_doorbell is False @pytest.mark.asyncio() async def test_bootstrap_construct(protect_client_no_debug: ProtectApiClient): """Verifies lookup of all object via ID""" await check_bootstrap(protect_client_no_debug.bootstrap) @pytest.mark.asyncio() @patch("uiprotect.utils.datetime", MockDatetime) async def test_get_events_raw_default(protect_client: ProtectApiClient, now: datetime): events = await protect_client.get_events_raw(_allow_manual_paginate=False) end = now + timedelta(seconds=10) protect_client.api_request.assert_called_with( # type: ignore[attr-defined] url="events", method="get", require_auth=True, raise_exception=True, public_api=False, params={ "orderDirection": "ASC", "withoutDescriptions": "false", "start": to_js_time(end - timedelta(hours=1)), "end": to_js_time(end), }, ) assert len(events) == CONSTANTS["event_count"] for event in events: assert event["type"] in EventType.values() assert event["modelKey"] in ModelType.values() @pytest.mark.asyncio() async def test_get_events_raw_limit(protect_client: ProtectApiClient): await protect_client.get_events_raw(limit=10) protect_client.api_request.assert_called_with( # type: ignore[attr-defined] url="events", method="get", require_auth=True, raise_exception=True, public_api=False, params={"orderDirection": "ASC", "withoutDescriptions": "false", "limit": 10}, ) @pytest.mark.asyncio() async def test_get_events_raw_types(protect_client: ProtectApiClient): await protect_client.get_events_raw( limit=10, types=[EventType.MOTION, EventType.SMART_DETECT], ) protect_client.api_request.assert_called_with( # type: ignore[attr-defined] url="events", method="get", require_auth=True, raise_exception=True, public_api=False, params={ "orderDirection": "ASC", "withoutDescriptions": "false", "limit": 10, "types": ["motion", "smartDetectZone"], }, ) # test has a scaling "expected time to complete" based on the number of # events in the last 24 hours @pytest.mark.timeout(CONSTANTS["event_count"] * 0.1) # type: ignore[misc] @pytest.mark.asyncio() async def test_get_events(protect_client: ProtectApiClient, raw_events): expected_events = [ event for event in raw_events if event["score"] >= 50 and event["type"] in EventType.device_events() ] protect_client._minimum_score = 50 events = await protect_client.get_events(_allow_manual_paginate=False) assert len(events) == len(expected_events) for index, event in enumerate(events): compare_objs(event.model.value, expected_events[index], event.unifi_dict()) if event.type.value in EventType.motion_events(): await check_motion_event(event) @pytest.mark.asyncio() async def test_get_events_not_event(protect_client: ProtectApiClient, camera): protect_client.get_events_raw = AsyncMock(return_value=[camera]) # type: ignore[method-assign] assert await protect_client.get_events() == [] @pytest.mark.asyncio() async def test_get_events_not_event_with_type(protect_client: ProtectApiClient, camera): camera["type"] = EventType.MOTION.value protect_client.get_events_raw = AsyncMock(return_value=[camera]) # type: ignore[method-assign] assert await protect_client.get_events() == [] @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_device_mismatch(protect_client: ProtectApiClient, camera): protect_client.api_request_obj = AsyncMock(return_value=camera) # type: ignore[method-assign] with pytest.raises(NvrError): await protect_client.get_bridge("test_id") @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_device_not_adopted(protect_client: ProtectApiClient, camera): camera["isAdopted"] = False protect_client.api_request_obj = AsyncMock(return_value=camera) # type: ignore[method-assign] with pytest.raises(NvrError): await protect_client.get_camera("test_id") @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_device_not_adopted_enabled(protect_client: ProtectApiClient, camera): camera["isAdopted"] = False protect_client.ignore_unadopted = False protect_client.api_request_obj = AsyncMock(return_value=camera) # type: ignore[method-assign] obj = create_from_unifi_dict(camera) assert_equal_dump(obj, await protect_client.get_camera("test_id")) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_camera(protect_client: ProtectApiClient, camera): obj = create_from_unifi_dict(camera) assert_equal_dump(obj, await protect_client.get_camera("test_id")) @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_light(protect_client: ProtectApiClient, light): obj = create_from_unifi_dict(light) assert_equal_dump(obj, await protect_client.get_light("test_id")) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_sensor(protect_client: ProtectApiClient, sensor): obj = create_from_unifi_dict(sensor) assert_equal_dump(obj, await protect_client.get_sensor("test_id")) @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_viewer(protect_client: ProtectApiClient, viewport): obj = create_from_unifi_dict(viewport) assert_equal_dump(obj, await protect_client.get_viewer("test_id")) @pytest.mark.skipif(not TEST_BRIDGE_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_bridge(protect_client: ProtectApiClient, bridge): obj = create_from_unifi_dict(bridge) assert_equal_dump(obj, await protect_client.get_bridge("test_id")) @pytest.mark.skipif(not TEST_LIVEVIEW_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_liveview(protect_client: ProtectApiClient, liveview): obj = create_from_unifi_dict(liveview) assert_equal_dump(obj, (await protect_client.get_liveview("test_id"))) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_devices_mismatch(protect_client: ProtectApiClient, cameras): protect_client.api_request_list = AsyncMock(return_value=cameras) # type: ignore[method-assign] with pytest.raises(NvrError): await protect_client.get_bridges() @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_devices_not_adopted(protect_client: ProtectApiClient, cameras): cameras[0]["isAdopted"] = False protect_client.api_request_list = AsyncMock(return_value=cameras) # type: ignore[method-assign] assert len(await protect_client.get_cameras()) == len(cameras) - 1 @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_devices_not_adopted_enabled( protect_client: ProtectApiClient, cameras, ): cameras[0]["isAdopted"] = False protect_client.ignore_unadopted = False protect_client.api_request_list = AsyncMock(return_value=cameras) # type: ignore[method-assign] assert len(await protect_client.get_cameras()) == len(cameras) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_cameras(protect_client: ProtectApiClient, cameras): objs = [create_from_unifi_dict(d) for d in cameras] assert_equal_dump(objs, await protect_client.get_cameras()) @pytest.mark.skipif(not TEST_LIGHT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_lights(protect_client: ProtectApiClient, lights): objs = [create_from_unifi_dict(d) for d in lights] assert_equal_dump(objs, await protect_client.get_lights()) @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_sensors(protect_client: ProtectApiClient, sensors): objs = [create_from_unifi_dict(d) for d in sensors] assert_equal_dump(objs, await protect_client.get_sensors()) @pytest.mark.skipif(not TEST_VIEWPORT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_viewers(protect_client: ProtectApiClient, viewports): objs = [create_from_unifi_dict(d) for d in viewports] assert_equal_dump(objs, await protect_client.get_viewers()) @pytest.mark.skipif(not TEST_BRIDGE_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_bridges(protect_client: ProtectApiClient, bridges): objs = [create_from_unifi_dict(d) for d in bridges] assert_equal_dump(objs, await protect_client.get_bridges()) @pytest.mark.skipif(not TEST_LIVEVIEW_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_liveviews(protect_client: ProtectApiClient, liveviews): objs = [create_from_unifi_dict(d) for d in liveviews] assert_equal_dump(objs, await protect_client.get_liveviews()) @pytest.mark.skipif(not TEST_SNAPSHOT_EXISTS, reason="Missing testdata") @patch("uiprotect.utils.datetime", MockDatetime) @patch("uiprotect.api.time.time", get_time) @pytest.mark.asyncio() async def test_get_camera_snapshot(protect_client: ProtectApiClient, now): data = await protect_client.get_camera_snapshot("test_id") assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "cameras/test_id/snapshot", params={ "ts": to_js_time(now), "force": "true", }, raise_exception=False, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_PUBLIC_API_SNAPSHOT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_public_api_camera_snapshot(protect_client: ProtectApiClient, now): data = await protect_client.get_public_api_camera_snapshot("test_id") assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] public_api=True, raise_exception=False, url="/v1/cameras/test_id/snapshot", params={ "highQuality": "false", }, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_PUBLIC_API_SNAPSHOT_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_public_api_camera_snapshot_hq_true( protect_client: ProtectApiClient, now ): data = await protect_client.get_public_api_camera_snapshot( "test_id", high_quality=True ) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] public_api=True, raise_exception=False, url="/v1/cameras/test_id/snapshot", params={ "highQuality": "true", }, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_SNAPSHOT_EXISTS, reason="Missing testdata") @patch("uiprotect.utils.datetime", MockDatetime) @patch("uiprotect.api.time.time", get_time) @pytest.mark.asyncio() async def test_get_pacakge_camera_snapshot(protect_client: ProtectApiClient, now): data = await protect_client.get_package_camera_snapshot("test_id") assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "cameras/test_id/package-snapshot", params={ "ts": to_js_time(now), "force": "true", }, raise_exception=False, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_SNAPSHOT_EXISTS, reason="Missing testdata") @patch("uiprotect.utils.datetime", MockDatetime) @patch("uiprotect.api.time.time", get_time) @pytest.mark.asyncio() async def test_get_camera_snapshot_args(protect_client: ProtectApiClient, now): data = await protect_client.get_camera_snapshot("test_id", 1920, 1080) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "cameras/test_id/snapshot", params={ "ts": to_js_time(now), "force": "true", "w": 1920, "h": 1080, }, raise_exception=False, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_SNAPSHOT_EXISTS, reason="Missing testdata") @patch("uiprotect.utils.datetime", MockDatetime) @patch("uiprotect.api.time.time", get_time) @pytest.mark.asyncio() async def test_get_package_camera_snapshot_args(protect_client: ProtectApiClient, now): data = await protect_client.get_package_camera_snapshot("test_id", 1920, 1080) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "cameras/test_id/package-snapshot", params={ "ts": to_js_time(now), "force": "true", "w": 1920, "h": 1080, }, raise_exception=False, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_VIDEO_EXISTS, reason="Missing testdata") @patch("uiprotect.api.datetime", MockDatetime) @patch("uiprotect.api.time.time", get_time) @pytest.mark.asyncio() async def test_get_camera_video(protect_client: ProtectApiClient, now, tmp_path: Path): camera = next(iter(protect_client.bootstrap.cameras.values())) start = now - timedelta(seconds=CONSTANTS["camera_video_length"]) data = await protect_client.get_camera_video(camera.id, start, now) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "video/export", params={ "camera": camera.id, "channel": 0, "start": to_js_time(start), "end": to_js_time(now), }, raise_exception=False, ) out_path = tmp_path / "video.mp4" await async_write_bytes(out_path, data) await asyncio.to_thread( validate_video_file, out_path, CONSTANTS["camera_video_length"] ) @pytest.mark.asyncio() async def test_get_camera_video_http_error( protect_client: ProtectApiClient, now: datetime ) -> None: """Test get_camera_video with HTTP error response.""" camera = next(iter(protect_client.bootstrap.cameras.values())) start = now - timedelta(seconds=CONSTANTS["camera_video_length"]) # Test the simple path (no output_file) which uses api_request_raw protect_client.api_request_raw = AsyncMock( side_effect=NotAuthorized("Access denied") ) with pytest.raises(NotAuthorized): await protect_client.get_camera_video(camera.id, start, now) @pytest.mark.asyncio() async def test_get_camera_video_logging( protect_client: ProtectApiClient, now: datetime, caplog: pytest.LogCaptureFixture ) -> None: """Test get_camera_video debug logging.""" caplog.set_level(logging.DEBUG, logger="uiprotect.api") camera = next(iter(protect_client.bootstrap.cameras.values())) start = now - timedelta(seconds=CONSTANTS["camera_video_length"]) # Create a simple async context manager class for the file mock class MockAsyncFile: async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): pass async def write(self, data): pass # Mock the response mock_response = AsyncMock() mock_response.status = 200 mock_response.close = Mock() # Create a mock content with iter_chunked method class MockContent: def iter_chunked(self, chunk_size): return MockAsyncIterator() class MockAsyncIterator: def __init__(self): self.items = [b"test_chunk"] self.index = 0 def __aiter__(self): return self async def __anext__(self): if self.index >= len(self.items): raise StopAsyncIteration item = self.items[self.index] self.index += 1 return item mock_response.content = MockContent() # Mock aiofiles.open to return our mock file with patch("aiofiles.open", return_value=MockAsyncFile()): protect_client.request = AsyncMock(return_value=mock_response) await protect_client.get_camera_video( camera.id, start, now, output_file="/tmp/test.mp4", # noqa: S108 ) # Check that debug log was written assert any( "Requesting camera video:" in record.message for record in caplog.records ) @pytest.mark.skipif(not TEST_THUMBNAIL_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_thumbnail(protect_client: ProtectApiClient): data = await protect_client.get_event_thumbnail("e-test_id") assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "events/test_id/thumbnail", params={}, raise_exception=False, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_THUMBNAIL_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_thumbnail_args(protect_client: ProtectApiClient): data = await protect_client.get_event_thumbnail("test_id", 1920, 1080) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "events/test_id/thumbnail", params={ "w": 1920, "h": 1080, }, raise_exception=False, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_THUMBNAIL_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_thumbnail_uuid_with_e_dash(protect_client: ProtectApiClient): """Regression test for #810: event IDs containing 'e-' must not be corrupted.""" event_id = "4028adde-42c6-4873-a49e-94da9bd22190" data = await protect_client.get_event_thumbnail(event_id) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] f"events/{event_id}/thumbnail", params={}, raise_exception=False, ) @pytest.mark.skipif(not TEST_THUMBNAIL_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_animated_thumbnail_uuid_with_e_dash( protect_client: ProtectApiClient, ): """Regression test for #810: event IDs containing 'e-' must not be corrupted.""" event_id = "4028adde-42c6-4873-a49e-94da9bd22190" data = await protect_client.get_event_animated_thumbnail(event_id) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] f"events/{event_id}/animated-thumbnail", params={"keyFrameOnly": "true", "speedup": 10}, raise_exception=False, ) @pytest.mark.skipif(not TEST_HEATMAP_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_heatmap_uuid_with_e_dash(protect_client: ProtectApiClient): """Regression test for #810: event IDs containing 'e-' must not be corrupted.""" event_id = "4028adde-42c6-4873-a49e-94da9bd22190" data = await protect_client.get_event_heatmap(event_id) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] f"events/{event_id}/heatmap", raise_exception=False, ) @pytest.mark.skipif(not TEST_THUMBNAIL_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_thumbnail_strips_legacy_prefix( protect_client: ProtectApiClient, ): """Legacy thumbnail IDs of the form 'e-' should have the prefix stripped.""" data = await protect_client.get_event_thumbnail( "e-4028adde-42c6-4873-a49e-94da9bd22190" ) assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "events/4028adde-42c6-4873-a49e-94da9bd22190/thumbnail", params={}, raise_exception=False, ) @pytest.mark.skipif(not TEST_HEATMAP_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_heatmap(protect_client: ProtectApiClient): data = await protect_client.get_event_heatmap("e-test_id") assert data is not None protect_client.api_request_raw.assert_called_with( # type: ignore[attr-defined] "events/test_id/heatmap", raise_exception=False, ) img = Image.open(BytesIO(data)) assert img.format in {"PNG", "JPEG"} @pytest.mark.skipif(not TEST_SMART_TRACK_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_event_smart_detect_track(protect_client: ProtectApiClient): data = await protect_client.get_event_smart_detect_track("test_id") assert data.camera protect_client.api_request.assert_called_with( # type: ignore[attr-defined] url="events/test_id/smartDetectTrack", method="get", require_auth=True, raise_exception=True, public_api=False, ) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_aiport(protect_client: ProtectApiClient, aiport): obj = create_from_unifi_dict(aiport) assert_equal_dump(obj, await protect_client.get_aiport("test_id")) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_aiport_not_adopted(protect_client: ProtectApiClient, aiport): aiport["isAdopted"] = False protect_client.api_request_obj = AsyncMock(return_value=aiport) with pytest.raises(NvrError): await protect_client.get_aiport("test_id") @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_aiport_not_adopted_enabled(protect_client: ProtectApiClient, aiport): aiport["isAdopted"] = False protect_client.ignore_unadopted = False protect_client.api_request_obj = AsyncMock(return_value=aiport) obj = create_from_unifi_dict(aiport) assert_equal_dump(obj, await protect_client.get_aiport("test_id")) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_chime(protect_client: ProtectApiClient, chime): obj = create_from_unifi_dict(chime) assert_equal_dump(obj, await protect_client.get_chime("test_id")) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_chime_not_adopted(protect_client: ProtectApiClient, chime): chime["isAdopted"] = False protect_client.api_request_obj = AsyncMock(return_value=chime) with pytest.raises(NvrError): await protect_client.get_chime("test_id") @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_chime_not_adopted_enabled(protect_client: ProtectApiClient, chime): chime["isAdopted"] = False protect_client.ignore_unadopted = False protect_client.api_request_obj = AsyncMock(return_value=chime) obj = create_from_unifi_dict(chime) assert_equal_dump(obj, await protect_client.get_chime("test_id")) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_get_aiports(protect_client: ProtectApiClient, aiports): objs = [create_from_unifi_dict(d) for d in aiports] assert_equal_dump(objs, await protect_client.get_aiports()) @pytest.mark.asyncio() async def test_play_speaker(protect_client: ProtectApiClient): """Test play_speaker with default parameters.""" device_id = "cf1a330397c08f919d02bd7c" protect_client.api_request = AsyncMock() await protect_client.play_speaker(device_id) protect_client.api_request.assert_called_with( f"chimes/{device_id}/play-speaker", method="post", json=None, ) @pytest.mark.asyncio() async def test_play_speaker_with_volume(protect_client: ProtectApiClient): """Test play_speaker with volume parameter.""" device_id = "cf1a330397c08f919d02bd7c" volume = 5 chime = protect_client.bootstrap.chimes[device_id] protect_client.api_request = AsyncMock() await protect_client.play_speaker(device_id, volume=volume) protect_client.api_request.assert_called_with( f"chimes/{device_id}/play-speaker", method="post", json={ "volume": volume, "repeatTimes": chime.repeat_times, }, ) @pytest.mark.asyncio() async def test_play_speaker_with_ringtone_id(protect_client: ProtectApiClient): """Test play_speaker with ringtone_id parameter.""" device_id = "cf1a330397c08f919d02bd7c" ringtone_id = "ringtone_1" chime = protect_client.bootstrap.chimes[device_id] protect_client.api_request = AsyncMock() await protect_client.play_speaker(device_id, ringtone_id=ringtone_id) protect_client.api_request.assert_called_with( f"chimes/{device_id}/play-speaker", method="post", json={ "volume": chime.volume, "repeatTimes": chime.repeat_times, "ringtoneId": ringtone_id, }, ) @pytest.mark.asyncio() async def test_play_speaker_invalid_chime_id(protect_client: ProtectApiClient): """Test play_speaker with invalid chime ID.""" device_id = "invalid_id" protect_client.api_request = AsyncMock() with pytest.raises(BadRequest): await protect_client.play_speaker(device_id, volume=5) @pytest.mark.asyncio() async def test_play_speaker_with_all_parameters(protect_client: ProtectApiClient): """Test play_speaker with all parameters.""" device_id = "cf1a330397c08f919d02bd7c" volume = 5 repeat_times = 3 ringtone_id = "ringtone_1" track_no = 2 protect_client.api_request = AsyncMock() await protect_client.play_speaker( device_id, volume=volume, repeat_times=repeat_times, ringtone_id=ringtone_id, track_no=track_no, ) protect_client.api_request.assert_called_with( f"chimes/{device_id}/play-speaker", method="post", json={ "volume": volume, "repeatTimes": repeat_times, "ringtoneId": ringtone_id, }, ) @pytest.mark.asyncio() async def test_set_light_is_led_force_on(protect_client: ProtectApiClient): """Test set_light_is_led_force_on with valid parameters.""" device_id = "test_light_id" is_led_force_on = True protect_client.api_request = AsyncMock() await protect_client.set_light_is_led_force_on(device_id, is_led_force_on) protect_client.api_request.assert_called_with( f"lights/{device_id}", method="patch", json={"lightOnSettings": {"isLedForceOn": is_led_force_on}}, ) @pytest.mark.asyncio() async def test_set_light_is_led_force_on_false(protect_client: ProtectApiClient): """Test set_light_is_led_force_on with is_led_force_on set to False.""" device_id = "test_light_id" is_led_force_on = False protect_client.api_request = AsyncMock() await protect_client.set_light_is_led_force_on(device_id, is_led_force_on) protect_client.api_request.assert_called_with( f"lights/{device_id}", method="patch", json={"lightOnSettings": {"isLedForceOn": is_led_force_on}}, ) @pytest.mark.asyncio() async def test_set_light_is_led_force_on_invalid_device_id( protect_client: ProtectApiClient, ): """Test set_light_is_led_force_on with invalid device ID.""" device_id = "invalid_id" is_led_force_on = True protect_client.api_request = AsyncMock(side_effect=BadRequest) with pytest.raises(BadRequest): await protect_client.set_light_is_led_force_on(device_id, is_led_force_on) @pytest.mark.asyncio() async def test_create_api_key_success(protect_client: ProtectApiClient): protect_client.api_request = AsyncMock( return_value={"data": {"full_api_key": "test_api_key"}} ) result = await protect_client.create_api_key("test") assert result == "test_api_key" protect_client.api_request.assert_called_with( api_path="/proxy/users/api/v2", url="/user/self/keys", method="post", json={"name": "test"}, ) @pytest.mark.asyncio() async def test_create_api_key_empty_name(protect_client: ProtectApiClient): protect_client._last_token_cookie_decode = {"userId": "test_user_id"} with pytest.raises(BadRequest, match="API key name cannot be empty"): await protect_client.create_api_key("") @pytest.mark.asyncio() async def test_create_api_key_failure(protect_client: ProtectApiClient): protect_client.api_request = AsyncMock(return_value={}) protect_client._last_token_cookie_decode = {"userId": "test_user_id"} with pytest.raises(BadRequest, match="Failed to create API key"): await protect_client.create_api_key("test") @pytest.mark.asyncio() async def test_api_request_raw_with_custom_api_path() -> None: """Test api_request_raw uses custom api_path when provided""" client = ProtectApiClient( "127.0.0.1", 0, "test", "test", verify_ssl=False, ) # Mock the request method to verify the path mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=b"test_response") mock_response.close = Mock() mock_response.release = Mock() client.request = AsyncMock(return_value=mock_response) result = await client.api_request_raw("/test/endpoint", api_path="/custom/api/path") assert result == b"test_response" client.request.assert_called_with( "get", "/custom/api/path/test/endpoint", require_auth=True, auto_close=False, public_api=False, ) @pytest.mark.asyncio() async def test_api_request_raw_with_default_api_path() -> None: """Test api_request_raw uses default api_path when not provided""" client = ProtectApiClient( "127.0.0.1", 0, "test", "test", verify_ssl=False, ) # Mock the request method to verify the path mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=b"test_response") mock_response.close = Mock() mock_response.release = Mock() client.request = AsyncMock(return_value=mock_response) result = await client.api_request_raw("/test/endpoint") assert result == b"test_response" client.request.assert_called_with( "get", f"{client.private_api_path}/test/endpoint", require_auth=True, auto_close=False, public_api=False, ) @pytest.mark.asyncio async def test_read_auth_config_file_not_found(): client = ProtectApiClient( "127.0.0.1", 0, "test", "test", verify_ssl=False, ) with patch("aiofiles.open", side_effect=FileNotFoundError): result = await client._read_auth_config() assert result is None class AsyncMockOpen: def __init__(self, read_data: bytes): self._read_data = read_data async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): pass async def read(self): return self._read_data @pytest.mark.asyncio async def test_read_auth_config_empty_file(): client = ProtectApiClient( "127.0.0.1", 0, "test", "test", verify_ssl=False, ) with patch("aiofiles.open", return_value=AsyncMockOpen(b"")): result = await client._read_auth_config() assert result is None @pytest.mark.asyncio async def test_read_auth_config_invalid_json(): client = ProtectApiClient( "127.0.0.1", 0, "test", "test", verify_ssl=False, ) with patch("aiofiles.open", return_value=AsyncMockOpen(b"{invalid json")): result = await client._read_auth_config() assert result is None @pytest.mark.asyncio async def test_read_auth_config_no_session(): client = ProtectApiClient( "127.0.0.1", 0, "test", "test", verify_ssl=False, ) with patch("aiofiles.open", return_value=AsyncMockOpen(b"{}")): result = await client._read_auth_config() assert result is None def test_api_key_init(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) assert hasattr(client, "_api_key") assert client._api_key == "my_key" @pytest.mark.asyncio() @patch("uiprotect.api.ProtectApiClient.request") async def test_authenticate_sets_csrf_token(mock_request: AsyncMock) -> None: """Test that authenticate() extracts and sets the x-csrf-token header.""" mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = { "set-cookie": "TOKEN=test_token_value; path=/", "x-csrf-token": "test-csrf-token-12345", } mock_response.cookies = {} mock_request.return_value = mock_response client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=False, ) await client.authenticate() assert client.headers is not None assert client.headers.get("x-csrf-token") == "test-csrf-token-12345" assert client._is_authenticated is True mock_request.assert_called_once_with( "post", url="/api/auth/login", json={ "username": "test_user", "password": "test_pass", "rememberMe": False, }, ) @pytest.mark.asyncio() @patch("uiprotect.api.ProtectApiClient.request") async def test_authenticate_without_csrf_token(mock_request: AsyncMock) -> None: """Test that authenticate() works even if no x-csrf-token is returned.""" mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = { "set-cookie": "TOKEN=test_token_value; path=/", } mock_response.cookies = {} mock_request.return_value = mock_response client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=False, ) await client.authenticate() assert client.headers is None or client.headers.get("x-csrf-token") is None assert client._is_authenticated is True @pytest.mark.asyncio() async def test_load_session_rejects_missing_csrf_token(tmp_path: Path) -> None: """Test that _read_auth_config rejects sessions without CSRF token.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) # Create a config file with a session but no CSRF token (simulating old session) session_hash = get_user_hash(str(client._url), "test_user") config = { "sessions": { session_hash: { "metadata": {"path": "/", "expires": "Sun, 21 Dec 2025 13:44:52 GMT"}, "cookiename": "TOKEN", "value": "old_token_without_csrf", "csrf": None, # Old session without CSRF token } } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(config)) # Try to load the session cookie = await client._read_auth_config() # Should return None because CSRF token is missing assert cookie is None assert client._is_authenticated is False @pytest.mark.asyncio() async def test_load_session_accepts_valid_csrf_token(tmp_path: Path) -> None: """Test that _read_auth_config accepts sessions with valid CSRF token.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) # Create a config file with a session including CSRF token session_hash = get_user_hash(str(client._url), "test_user") config = { "sessions": { session_hash: { "metadata": {"path": "/", "expires": "Sun, 21 Dec 2025 13:44:52 GMT"}, "cookiename": "TOKEN", "value": "valid_token_with_csrf", "csrf": "valid-csrf-token-12345", } } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(config)) # Try to load the session cookie = await client._read_auth_config() # Should successfully load the session assert cookie is not None assert client._is_authenticated is True assert client.headers is not None assert client.headers.get("x-csrf-token") == "valid-csrf-token-12345" @pytest.mark.asyncio() async def test_load_session_with_invalid_token(tmp_path: Path) -> None: """Test that loading a session with an invalid token (not enough segments) handles gracefully.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) # Create a config file with a session containing an invalid token # (not a valid JWT format - should have 3 segments separated by dots) session_hash = get_user_hash(str(client._url), "test_user") config = { "sessions": { session_hash: { "metadata": {"path": "/", "expires": "Sun, 21 Dec 2025 13:44:52 GMT"}, "cookiename": "TOKEN", "value": "invalid_token_not_jwt", # Invalid: only 1 segment, not 3 "csrf": "valid-csrf-token-12345", } } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(config)) # Load the session cookie = await client._read_auth_config() # Should successfully load the cookie even if token is invalid assert cookie is not None assert client._is_authenticated is True assert client._last_token_cookie is not None # The decode should fail gracefully and return None # This simulates the "Authentication token decode error: Not enough segments" from the issue decoded = decode_token_cookie(client._last_token_cookie) assert decoded is None # Verify is_authenticated returns False for invalid token assert client.is_authenticated() is False @pytest.mark.asyncio() async def test_load_session_with_token_two_segments(tmp_path: Path) -> None: """Test that loading a session with a token having only 2 segments handles gracefully.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) # Create a config file with a token that has 2 segments instead of 3 session_hash = get_user_hash(str(client._url), "test_user") config = { "sessions": { session_hash: { "metadata": {"path": "/", "expires": "Sun, 21 Dec 2025 13:44:52 GMT"}, "cookiename": "TOKEN", "value": "segment1.segment2", # Invalid: only 2 segments instead of 3 "csrf": "valid-csrf-token-12345", } } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(config)) # Load the session cookie = await client._read_auth_config() # Should successfully load the cookie even if token is invalid assert cookie is not None assert client._is_authenticated is True assert client._last_token_cookie is not None # The decode should fail gracefully and return None decoded = decode_token_cookie(client._last_token_cookie) assert decoded is None # Verify is_authenticated returns False for invalid token assert client.is_authenticated() is False @pytest.mark.asyncio() @patch("uiprotect.api.ProtectApiClient.request") async def test_invalid_token_triggers_reauthentication( mock_request: AsyncMock, tmp_path: Path ) -> None: """Test that an invalid token in session triggers re-authentication and updates the session file.""" # Create a config file with an invalid token (like from the GitHub issue) session_hash = get_user_hash("https://127.0.0.1:0", "test_user") invalid_config = { "sessions": { session_hash: { "metadata": {"path": "/", "expires": "Sun, 21 Dec 2025 13:44:52 GMT"}, "cookiename": "TOKEN", "value": "invalid_token", # Not a valid JWT "csrf": "old-csrf-token", } } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(invalid_config)) # Setup mock for authentication mock_auth_response = AsyncMock() mock_auth_response.status = 200 mock_auth_response.headers = { "set-cookie": "TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNzMzMDAwMDAwfQ.test; path=/", "x-csrf-token": "new-csrf-token-12345", } mock_auth_response.cookies = {} mock_request.return_value = mock_auth_response client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) try: # Ensure authentication is triggered (which loads the session first) await client.ensure_authenticated() # Should have called authenticate() due to invalid token assert mock_request.called assert client._is_authenticated is True # Verify the session file was updated with the new valid token updated_config = orjson.loads(await async_read_bytes(config_file)) session_data = updated_config["sessions"][session_hash] assert session_data["csrf"] == "new-csrf-token-12345" # The new token should be a valid JWT format (3 segments) assert session_data["value"].count(".") == 2 finally: await client.close_session() @pytest.mark.asyncio() async def test_clear_session_removes_specific_session(tmp_path: Path) -> None: """Test that clear_session removes only the specific user/host session.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) session_hash = get_user_hash(str(client._url), "test_user") config = { "sessions": { session_hash: { "metadata": {"path": "/", "expires": "Sun, 21 Dec 2025 13:44:52 GMT"}, "cookiename": "TOKEN", "value": "some_token_value", "csrf": "some-csrf-token", }, "other_session_hash": { "metadata": {"path": "/"}, "cookiename": "TOKEN", "value": "other_token", "csrf": "other-csrf", }, } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(config)) await client.clear_session() # File should still exist with the other session intact updated_config = orjson.loads(await async_read_bytes(config_file)) assert session_hash not in updated_config["sessions"] assert "other_session_hash" in updated_config["sessions"] assert client._is_authenticated is False @pytest.mark.asyncio() @patch("uiprotect.api._LOGGER") async def test_clear_all_sessions_removes_file( mock_logger: Mock, tmp_path: Path ) -> None: """Test that clear_all_sessions removes the config file and logs debug message.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) config = { "sessions": { "hash1": { "metadata": {"path": "/"}, "cookiename": "TOKEN", "value": "token1", "csrf": "csrf1", }, "hash2": { "metadata": {"path": "/"}, "cookiename": "TOKEN", "value": "token2", "csrf": "csrf2", }, } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(config)) await client.clear_all_sessions() assert not await aos.path.exists(config_file) assert client._is_authenticated is False assert client._last_token_cookie is None assert client._last_token_cookie_decode is None mock_logger.debug.assert_called_once_with("Cleared all sessions from config file") @pytest.mark.asyncio() @pytest.mark.parametrize("clear_method", ["clear_session", "clear_all_sessions"]) async def test_clear_methods_do_nothing_when_sessions_disabled( tmp_path: Path, clear_method: str ) -> None: """Test that clear methods do nothing when store_sessions=False.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=False, config_dir=tmp_path, ) config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps({"sessions": {}})) await getattr(client, clear_method)() # File should still exist since sessions are disabled assert await aos.path.exists(config_file) config = orjson.loads(await async_read_bytes(config_file)) assert config == {"sessions": {}} @pytest.mark.asyncio() async def test_clear_session_with_invalid_config_file(tmp_path: Path) -> None: """Test that clear_session handles invalid config file gracefully.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) # Create a config file with invalid JSON config_file = tmp_path / "unifi_protect.json" await async_write_text(config_file, "invalid json content {{{") # Call clear_session - should handle the exception gracefully await client.clear_session() # File should still exist assert await aos.path.exists(config_file) @pytest.mark.asyncio() @pytest.mark.parametrize("clear_method", ["clear_session", "clear_all_sessions"]) async def test_clear_methods_handle_missing_file( tmp_path: Path, clear_method: str ) -> None: """Test that clear methods handle missing config file gracefully.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) # Set auth state to simulate authenticated client client._is_authenticated = True client._last_token_cookie = "some_token" # noqa: S105 # Don't create the config file await getattr(client, clear_method)() # No file should exist and no error should be raised config_file = tmp_path / "unifi_protect.json" assert not await aos.path.exists(config_file) # Client state should NOT be reset since no file was found assert client._is_authenticated is True assert client._last_token_cookie == "some_token" # noqa: S105 @pytest.mark.asyncio() async def test_clear_session_when_session_not_in_config(tmp_path: Path) -> None: """Test that clear_session handles missing session hash gracefully.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, config_dir=tmp_path, ) # Set auth state to simulate authenticated client client._is_authenticated = True client._last_token_cookie = "some_token" # noqa: S105 config = { "sessions": { "different_hash": { "metadata": {"path": "/"}, "cookiename": "TOKEN", "value": "token1", "csrf": "csrf1", } } } config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps(config)) await client.clear_session() # File should still have the original session updated_config = orjson.loads(await async_read_bytes(config_file)) assert "different_hash" in updated_config["sessions"] # Client state should NOT be reset since no session was actually removed assert client._is_authenticated is True assert client._last_token_cookie == "some_token" # noqa: S105 @pytest.mark.asyncio() @patch("uiprotect.api.aos.remove") async def test_clear_all_sessions_handles_file_disappearing( mock_remove: AsyncMock, tmp_path: Path ) -> None: """Test that clear_all_sessions handles FileNotFoundError if file disappears during removal.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, ) client.config_dir = tmp_path # Set auth state to simulate authenticated client client._is_authenticated = True client._last_token_cookie = "some_token" # noqa: S105 # Create config file so path.exists() check passes config_file = tmp_path / "unifi_protect.json" await async_write_bytes(config_file, orjson.dumps({"sessions": {}})) # Mock aos.remove to raise FileNotFoundError (race condition simulation) mock_remove.side_effect = FileNotFoundError( "File disappeared between exists() and remove()" ) # Should not raise exception even though remove() fails await client.clear_all_sessions() # Should have attempted to remove the file mock_remove.assert_called_once() # Client state should NOT be reset since file removal failed assert client._is_authenticated is True assert client._last_token_cookie == "some_token" # noqa: S105 @pytest.mark.asyncio() @patch("uiprotect.api.ProtectApiClient.request") async def test_authenticate_with_session_storage( mock_request: AsyncMock, tmp_path: Path ) -> None: """Test that authenticate() stores session with CSRF token when store_sessions=True.""" mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = { "set-cookie": "TOKEN=stored_token_value; path=/; Secure; HttpOnly", "x-csrf-token": "stored-csrf-token-67890", } mock_response.cookies = {} mock_request.return_value = mock_response client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=True, ) client.config_dir = tmp_path await client.authenticate() # Verify authentication succeeded assert client._is_authenticated is True assert client.headers is not None assert client.headers.get("x-csrf-token") == "stored-csrf-token-67890" # Verify session was saved to file config_file = tmp_path / "unifi_protect.json" assert await aos.path.exists(config_file) config = orjson.loads(await async_read_bytes(config_file)) assert "sessions" in config sessions = config["sessions"] assert len(sessions) == 1 # Get the session (there's only one) session = next(iter(sessions.values())) assert session["csrf"] == "stored-csrf-token-67890" assert "TOKEN" in session["cookiename"] @pytest.mark.asyncio() async def test_csrf_token_rotation() -> None: """Test that _update_last_token_cookie updates CSRF token when server sends new one.""" client = ProtectApiClient( "127.0.0.1", 0, "test_user", "test_pass", verify_ssl=False, store_sessions=False, ) # Set initial CSRF token client.set_header("x-csrf-token", "initial-csrf-token") # Simulate API response with new CSRF token mock_response = AsyncMock() mock_response.headers = { "x-csrf-token": "rotated-csrf-token", } mock_response.cookies = {} # Call the method that updates CSRF token await client._update_last_token_cookie(mock_response) # Verify CSRF token was updated assert client.headers is not None assert client.headers.get("x-csrf-token") == "rotated-csrf-token" @pytest.mark.asyncio() async def test_get_meta_info_calls_public_api(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) client.api_request_obj = AsyncMock(return_value={"applicationVersion": "1.0.0"}) result = await client.get_meta_info() assert result.application_version == "1.0.0" client.api_request_obj.assert_called_with(url="/v1/meta/info", public_api=True) @pytest.mark.asyncio() async def test_api_request_raw_public_api_sets_path_and_header(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=b"public_response") mock_response.close = Mock() mock_response.release = Mock() client.request = AsyncMock(return_value=mock_response) result = await client.api_request_raw( "/v1/meta/info", public_api=True, ) assert result == b"public_response" client.request.assert_called_with( "get", f"{client.public_api_path}/v1/meta/info", require_auth=True, auto_close=False, public_api=True, ) @pytest.mark.asyncio() async def test_api_request_raw_public_api_requires_api_key(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key=None, verify_ssl=False, ) # Patch get_session to avoid aiohttp session creation client.get_session = AsyncMock() with pytest.raises( NotAuthorized, match="API key is required for public API requests" ): await client.api_request_raw( "/v1/meta/info", public_api=True, ) @pytest.mark.asyncio() async def test_get_meta_info_invalid_response_type(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) # Mock api_request to return a non-dict value; api_request_obj raises NvrError client.api_request = AsyncMock(return_value=None) with pytest.raises(NvrError, match="Could not decode object from /v1/meta/info"): await client.get_meta_info() @pytest.mark.asyncio() async def test_public_api_sets_x_api_key_header() -> None: """Test that X-API-KEY header is set when using public API.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_api_key_123", verify_ssl=False, store_sessions=False, # Disable session storage to avoid auth cookie updates ) # Create a mock session and response mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 mock_response.content_type = "application/json" mock_response.read = AsyncMock(return_value=b'{"test": "data"}') mock_response.release = Mock() mock_response.close = Mock() mock_response.url = "https://127.0.0.1:0/proxy/protect/integration/v1/test" mock_response.headers = {} # Real mapping so x-csrf-token lookup stays sync mock_response.cookies = {} # Empty cookies to avoid cookie processing # Track headers passed to session.request actual_headers: dict[str, str] | None = None # Create async context manager for the request class MockRequestContext: async def __aenter__(self) -> AsyncMock: return mock_response async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object, ) -> None: return None def mock_session_request( method: str, url: str, headers: dict[str, str] | None = None, **kwargs: Any ) -> MockRequestContext: nonlocal actual_headers actual_headers = headers return MockRequestContext() mock_session.request = mock_session_request client.get_public_api_session = AsyncMock(return_value=mock_session) # Make a public API request await client.api_request_raw("/v1/test", public_api=True) # Verify the X-API-KEY header was set assert actual_headers == {"X-API-KEY": "test_api_key_123"} @pytest.mark.asyncio async def test_get_public_api_session_creates_and_reuses_session(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", verify_ssl=False, ) session1 = await client.get_public_api_session() assert isinstance(session1, aiohttp.ClientSession) session2 = await client.get_public_api_session() assert session1 is session2 await session1.close() session3 = await client.get_public_api_session() assert session3 is not session1 assert isinstance(session3, aiohttp.ClientSession) await session3.close() @pytest.mark.asyncio async def test_public_api_session_constructor_assignment(): async with aiohttp.ClientSession() as session: client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", public_api_session=session, verify_ssl=False, ) assert client._public_api_session is session @pytest.mark.asyncio() async def test_request_uses_get_session_for_private_api(): """Test that request uses get_session (not get_public_api_session) for private API calls.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", verify_ssl=False, ) # Patch get_session to return a mock session mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 mock_response.content_type = "application/json" mock_response.read = AsyncMock(return_value=b"{}") mock_response.release = Mock() mock_response.close = Mock() mock_response.headers = {} mock_response.cookies = {} # __aenter__ returns the response class MockRequestContext: async def __aenter__(self): return mock_response async def __aexit__(self, exc_type, exc_val, exc_tb): pass mock_session.request = lambda *args, **kwargs: MockRequestContext() client.get_session = AsyncMock(return_value=mock_session) client.ensure_authenticated = AsyncMock() # Should use get_session, not get_public_api_session client.get_public_api_session = AsyncMock() # Call request with public_api=False result = await client.request("get", "/test/endpoint", public_api=False) assert result is mock_response client.get_session.assert_awaited() client.get_public_api_session.assert_not_called() @pytest.mark.asyncio async def test_close_public_api_session(): async with aiohttp.ClientSession() as session: client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", public_api_session=session, verify_ssl=False, ) # Should be the same session assert client._public_api_session is session # Close the session await client.close_public_api_session() # Should be None after closing assert client._public_api_session is None # Should be idempotent (no error if called again) await client.close_public_api_session() @pytest.mark.asyncio async def test_close_session_closes_public_api_session(): """Test that close_session also closes the public API session.""" mock_session = AsyncMock() mock_public_session = AsyncMock() client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", verify_ssl=False, ) client._session = mock_session client._loaded_session = True client._public_api_session = mock_public_session await client.close_session() # Both sessions should be closed mock_session.close.assert_called_once() mock_public_session.close.assert_called_once() assert client._session is None assert client._public_api_session is None assert client._loaded_session is False def test_set_api_key_sets_value(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key=None, verify_ssl=False, ) client.set_api_key("test_key") assert client._api_key == "test_key" def test_set_api_key_empty_raises(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key=None, verify_ssl=False, ) with pytest.raises(BadRequest, match="API key cannot be empty"): client.set_api_key("") def test_set_api_key_rearms_public_websockets(): """set_api_key() clears auth-failure backoff on both public websockets.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key=None, verify_ssl=False, ) events_ws = Mock() devices_ws = Mock() client._events_websocket = events_ws client._devices_websocket = devices_ws client.set_api_key("fresh-key") assert client._api_key == "fresh-key" events_ws.reset_auth_failure.assert_called_once_with() devices_ws.reset_auth_failure.assert_called_once_with() def test_is_api_key_set_true(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) assert client.is_api_key_set() is True def test_is_api_key_set_false(): client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key=None, verify_ssl=False, ) assert client.is_api_key_set() is False @pytest.mark.asyncio async def test_create_camera_rtsps_streams_with_list_qualities(): """Test creating RTSPS streams with a list of qualities.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) mock_response = ( b'{"high": "rtsps://example.com/high", "medium": "rtsps://example.com/medium"}' ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=mock_response) ) as mock_request: result = await client.create_camera_rtsps_streams( "camera123", ["high", "medium"] ) mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="POST", json={"qualities": ["high", "medium"]}, ) assert result is not None assert result.get_stream_url("high") == "rtsps://example.com/high" assert result.get_stream_url("medium") == "rtsps://example.com/medium" @pytest.mark.asyncio async def test_create_camera_rtsps_streams_with_string_quality(): """Test creating RTSPS streams with a single quality string.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) mock_response = b'{"high": "rtsps://example.com/high"}' with patch.object( client, "api_request_raw", new=AsyncMock(return_value=mock_response) ) as mock_request: result = await client.create_camera_rtsps_streams("camera123", "high") mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="POST", json={"qualities": ["high"]}, ) assert result is not None assert result.get_stream_url("high") == "rtsps://example.com/high" @pytest.mark.asyncio async def test_create_camera_rtsps_streams_none_response(): """Test creating RTSPS streams with None response.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=None) ) as mock_request: result = await client.create_camera_rtsps_streams("camera123", ["high"]) mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="POST", json={"qualities": ["high"]}, ) assert result is None @pytest.mark.asyncio async def test_create_camera_rtsps_streams_invalid_json(): """Test creating RTSPS streams with invalid JSON response.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b"invalid json") ) as mock_request: result = await client.create_camera_rtsps_streams("camera123", ["high"]) mock_request.assert_called_once() assert result is None @pytest.mark.asyncio async def test_get_camera_rtsps_streams(): """Test getting existing RTSPS streams.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) mock_response = ( b'{"high": "rtsps://example.com/high", "medium": "rtsps://example.com/medium"}' ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=mock_response) ) as mock_request: result = await client.get_camera_rtsps_streams("camera123") mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="GET", ) assert result is not None assert result.get_stream_url("high") == "rtsps://example.com/high" assert result.get_stream_url("medium") == "rtsps://example.com/medium" assert set(result.get_available_stream_qualities()) == {"high", "medium"} @pytest.mark.asyncio async def test_get_camera_rtsps_streams_none_response(): """Test getting RTSPS streams with None response.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=None) ) as mock_request: result = await client.get_camera_rtsps_streams("camera123") mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="GET", ) assert result is None @pytest.mark.asyncio async def test_get_camera_rtsps_streams_invalid_json(): """Test getting RTSPS streams with invalid JSON response.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b"invalid json") ) as mock_request: result = await client.get_camera_rtsps_streams("camera123") mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="GET", ) assert result is None @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_success(): """Test deleting RTSPS streams successfully.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b"") ) as mock_request: result = await client.delete_camera_rtsps_streams( "camera123", ["high", "medium"] ) mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="DELETE", params=[("qualities", "high"), ("qualities", "medium")], ) assert result is True @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_single_quality(): """Test deleting RTSPS streams with single quality.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b"") ) as mock_request: result = await client.delete_camera_rtsps_streams("camera123", "high") mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="DELETE", params=[("qualities", "high")], ) assert result is True @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_failure(): """Test deleting RTSPS streams with failure.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=None) ) as mock_request: result = await client.delete_camera_rtsps_streams("camera123", ["high"]) mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="DELETE", params=[("qualities", "high")], ) assert result is False @pytest.mark.asyncio async def test_camera_rtsps_streams_channel_quality_enum_serialization(): """ChannelQuality members serialise to their wire values on both create + delete.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"high": "rtsps://example.com/high"}'), ) as mock_request: await client.create_camera_rtsps_streams("camera123", ChannelQuality.HIGH) mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="POST", json={"qualities": ["high"]}, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"package": "rtsps://example.com/package"}'), ) as mock_request: await client.create_camera_rtsps_streams("camera123", [ChannelQuality.PACKAGE]) mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="POST", json={"qualities": ["package"]}, ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b"") ) as mock_request: await client.delete_camera_rtsps_streams( "camera123", [ChannelQuality.HIGH, ChannelQuality.PACKAGE] ) mock_request.assert_called_once_with( public_api=True, url="/v1/cameras/camera123/rtsps-stream", method="DELETE", params=[("qualities", "high"), ("qualities", "package")], ) def _rtsps_client() -> ProtectApiClient: return ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="test_key", verify_ssl=False, ) def _seed_rtsps_camera( client: ProtectApiClient, camera_id: str, streams: RTSPSStreams | None = None, state: DeviceState = DeviceState.CONNECTED, ) -> PublicCamera: """Place a public camera (optionally carrying RTSPS streams) into the cache.""" camera = PublicCamera.model_construct( id=camera_id, state=state, rtsps_streams=streams ) client._public_bootstrap.cameras[camera_id] = camera return camera @pytest.mark.asyncio async def test_get_camera_rtsps_streams_is_flag_free_fetch(): """The fetch primitive always issues an HTTP GET and never touches the cache.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "camera123", RTSPSStreams(high="rtsps://example.com/stale") ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"high": "rtsps://example.com/fresh"}'), ) as mock_request: result = await client.get_camera_rtsps_streams("camera123") mock_request.assert_called_once() assert result is not None assert result.get_stream_url("high") == "rtsps://example.com/fresh" # The cached camera field is left untouched by the bare fetch primitive. assert camera.rtsps_streams.get_stream_url("high") == "rtsps://example.com/stale" @pytest.mark.asyncio async def test_create_camera_rtsps_streams_write_through(): """A successful create writes the new streams onto the cached camera field.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera(client, "camera123") with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"high": "rtsps://example.com/high"}'), ): result = await client.create_camera_rtsps_streams("camera123", "high") assert result is not None assert camera.rtsps_streams is result @pytest.mark.asyncio async def test_create_camera_rtsps_streams_no_camera_skips_write_through(): """Create returns the streams but writes nothing when the camera is not cached.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"high": "rtsps://example.com/high"}'), ): result = await client.create_camera_rtsps_streams("camera123", "high") assert result is not None assert "camera123" not in client._public_bootstrap.cameras @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_drops_quality_from_field(): """Deleting one quality removes only that key from the cached camera streams.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "camera123", RTSPSStreams( high="rtsps://example.com/high", low="rtsps://example.com/low", ), ) with patch.object(client, "api_request_raw", new=AsyncMock(return_value=b"")): result = await client.delete_camera_rtsps_streams("camera123", "high") assert result is True assert camera.rtsps_streams.get_stream_url("high") is None assert camera.rtsps_streams.get_stream_url("low") == "rtsps://example.com/low" @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_clears_field_when_empty(): """Deleting the last quality clears the camera's streams field to None.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "camera123", RTSPSStreams(high="rtsps://example.com/high") ) with patch.object(client, "api_request_raw", new=AsyncMock(return_value=b"")): result = await client.delete_camera_rtsps_streams("camera123", "high") assert result is True assert camera.rtsps_streams is None @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_failure_keeps_field(): """A failed delete leaves the cached camera streams untouched.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() streams = RTSPSStreams(high="rtsps://example.com/high") camera = _seed_rtsps_camera(client, "camera123", streams) with patch.object(client, "api_request_raw", new=AsyncMock(return_value=None)): result = await client.delete_camera_rtsps_streams("camera123", "high") assert result is False assert camera.rtsps_streams is streams @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_no_camera_is_noop(): """A delete for an uncached camera succeeds without touching the cache.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() with patch.object(client, "api_request_raw", new=AsyncMock(return_value=b"")): result = await client.delete_camera_rtsps_streams("camera123", "high") assert result is True assert "camera123" not in client._public_bootstrap.cameras @pytest.mark.asyncio async def test_schedule_rtsps_refresh_overwrites_in_place(): """A scheduled refresh re-fetches in place and clears its own task entry.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "cam1", RTSPSStreams(high="rtsps://example.com/stale") ) fresh = RTSPSStreams(high="rtsps://example.com/fresh") async def _fake_get(camera_id: str) -> RTSPSStreams: return fresh client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") await client._rtsps_refresh_tasks["cam1"] assert camera.rtsps_streams is fresh assert "cam1" not in client._rtsps_refresh_tasks @pytest.mark.asyncio async def test_schedule_rtsps_refresh_primes_streamless_camera(): """A streamless cached camera is primed (fetched + filled), not skipped.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera(client, "cam1") fresh = RTSPSStreams(high="rtsps://example.com/fresh") client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=fresh ) client._schedule_rtsps_refresh("cam1") await client._rtsps_refresh_tasks["cam1"] assert camera.rtsps_streams is fresh client.get_camera_rtsps_streams.assert_awaited_once_with("cam1") @pytest.mark.asyncio async def test_schedule_rtsps_refresh_skips_unknown_camera(): """Refresh is not scheduled for a camera that is not cached.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() client.get_camera_rtsps_streams = AsyncMock() # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") assert client._rtsps_refresh_tasks == {} client.get_camera_rtsps_streams.assert_not_called() @pytest.mark.asyncio async def test_schedule_rtsps_refresh_noop_without_bootstrap(): """No public bootstrap means no refresh task is created.""" client = _rtsps_client() client._schedule_rtsps_refresh("cam1") assert client._rtsps_refresh_tasks == {} @pytest.mark.asyncio async def test_schedule_rtsps_refresh_coalesces_in_flight(): """A refresh already in flight for a camera is not duplicated.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/high")) started = asyncio.Event() release = asyncio.Event() calls = 0 async def _fake_get(camera_id: str) -> None: nonlocal calls calls += 1 started.set() await release.wait() client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") await started.wait() first = client._rtsps_refresh_tasks["cam1"] client._schedule_rtsps_refresh("cam1") assert client._rtsps_refresh_tasks["cam1"] is first release.set() await first assert calls == 1 @pytest.mark.asyncio async def test_refresh_camera_rtsps_swallows_errors(caplog): """A failed background refresh is logged and the task entry is cleared.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/high")) client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] side_effect=RuntimeError("boom") ) with caplog.at_level("ERROR"): await client._refresh_camera_rtsps("cam1") assert "Failed to refresh RTSPS streams for camera cam1" in caplog.text assert "cam1" not in client._rtsps_refresh_tasks @pytest.mark.asyncio async def test_refresh_camera_rtsps_does_not_resurrect_evicted_camera(): """A refresh that completes after the camera is removed must not re-add it.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/stale")) fresh = RTSPSStreams(high="rtsps://example.com/fresh") started = asyncio.Event() release = asyncio.Event() async def _fake_get(camera_id: str) -> RTSPSStreams: started.set() await release.wait() return fresh client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") task = client._rtsps_refresh_tasks["cam1"] await started.wait() # Evict mid-flight, as a remove frame would (camera gone from the store). client._public_bootstrap.cameras.pop("cam1", None) release.set() await task assert "cam1" not in client._public_bootstrap.cameras assert "cam1" not in client._rtsps_refresh_tasks @pytest.mark.asyncio async def test_refresh_camera_rtsps_does_not_resurrect_cleared_field(): """A refresh completing after the field is cleared must not repopulate it.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "cam1", RTSPSStreams(high="rtsps://example.com/stale") ) fresh = RTSPSStreams(high="rtsps://example.com/fresh") started = asyncio.Event() release = asyncio.Event() async def _fake_get(camera_id: str) -> RTSPSStreams: started.set() await release.wait() return fresh client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") task = client._rtsps_refresh_tasks["cam1"] await started.wait() # Clear the field mid-flight, as a delete of the last quality would. camera.rtsps_streams = None release.set() await task assert camera.rtsps_streams is None assert "cam1" not in client._rtsps_refresh_tasks @pytest.mark.asyncio async def test_refresh_camera_rtsps_prime_does_not_clobber_concurrent_create(): """A prime completing after a create write-through must not overwrite it.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera(client, "cam1") created = RTSPSStreams(high="rtsps://example.com/created") primed = RTSPSStreams(high="rtsps://example.com/primed") started = asyncio.Event() release = asyncio.Event() async def _fake_get(camera_id: str) -> RTSPSStreams: started.set() await release.wait() return primed client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") task = client._rtsps_refresh_tasks["cam1"] await started.wait() # A create write-through lands while the prime fetch is in flight. camera.rtsps_streams = created release.set() await task assert camera.rtsps_streams is created @pytest.mark.asyncio async def test_refresh_camera_rtsps_prime_ignores_missing_camera(): """A prime scheduled for a camera gone before the task body runs is a no-op.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=RTSPSStreams(high="rtsps://example.com/fresh") ) await client._refresh_camera_rtsps("cam1") assert "cam1" not in client._public_bootstrap.cameras assert "cam1" not in client._rtsps_refresh_tasks @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_partial_does_not_resurrect_during_refresh(): """A partial delete during an in-flight refresh must not resurrect the deleted quality.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "camera123", RTSPSStreams( high="rtsps://example.com/high", low="rtsps://example.com/low", ), ) # The in-flight fetch returns the full set, including the to-be-deleted quality. refetched = RTSPSStreams( high="rtsps://example.com/high", low="rtsps://example.com/low", ) started = asyncio.Event() release = asyncio.Event() async def _fake_get(camera_id: str) -> RTSPSStreams: started.set() await release.wait() return refetched client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("camera123") await started.wait() task = client._rtsps_refresh_tasks["camera123"] with patch.object(client, "api_request_raw", new=AsyncMock(return_value=b"")): result = await client.delete_camera_rtsps_streams("camera123", "high") assert result is True after_delete = camera.rtsps_streams assert after_delete is not None assert after_delete.get_stream_url("high") is None assert after_delete.get_stream_url("low") == "rtsps://example.com/low" release.set() await task # The refresh saw the replaced object (identity changed) and backed off, # so the deleted 'high' quality is not resurrected. assert camera.rtsps_streams is after_delete assert camera.rtsps_streams.get_stream_url("high") is None assert "camera123" not in client._rtsps_refresh_tasks @pytest.mark.asyncio async def test_refresh_all_cached_rtsps_noop_without_bootstrap(): """Refreshing all cached entries is a no-op when nothing is primed.""" client = _rtsps_client() assert client._public_bootstrap is None await client._refresh_all_cached_rtsps() @pytest.mark.asyncio async def test_refresh_all_cached_rtsps_refreshes_each_populated_camera(): """Refreshing all cached entries re-fetches every camera that has streams once.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/a")) _seed_rtsps_camera(client, "cam2", RTSPSStreams(high="rtsps://example.com/b")) # A camera with no streams yet is left to the prime step, not refreshed. _seed_rtsps_camera(client, "cam3") client.get_camera_rtsps_streams = AsyncMock(return_value=None) # type: ignore[method-assign] await client._refresh_all_cached_rtsps() assert client.get_camera_rtsps_streams.await_count == 2 fetched = {call.args[0] for call in client.get_camera_rtsps_streams.await_args_list} assert fetched == {"cam1", "cam2"} @pytest.mark.asyncio async def test_refresh_all_cached_rtsps_coalesces_with_in_flight_refresh(): """A per-camera refresh already in flight is awaited, not duplicated.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/high")) started = asyncio.Event() release = asyncio.Event() calls = 0 async def _fake_get(camera_id: str) -> None: nonlocal calls calls += 1 started.set() await release.wait() client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") await started.wait() refresh_all = asyncio.create_task(client._refresh_all_cached_rtsps()) await asyncio.sleep(0) release.set() await refresh_all assert calls == 1 @pytest.mark.asyncio async def test_cancel_rtsps_refresh_cancels_in_flight_for_camera(): """A camera-targeted cancel stops its pending refresh and clears the entry.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/high")) started = asyncio.Event() release = asyncio.Event() async def _fake_get(camera_id: str) -> None: started.set() await release.wait() client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") await started.wait() task = client._rtsps_refresh_tasks["cam1"] client._cancel_rtsps_refresh("cam1") assert "cam1" not in client._rtsps_refresh_tasks await asyncio.gather(task, return_exceptions=True) assert task.cancelled() @pytest.mark.asyncio async def test_cancel_rtsps_refresh_no_pending_task_is_noop(): """Cancelling a camera with no pending refresh does nothing.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() client._cancel_rtsps_refresh("cam1") assert client._rtsps_refresh_tasks == {} @pytest.mark.asyncio async def test_cancel_rtsps_refresh_tasks_cancels_pending(): """Cancelling pending refresh tasks stops them and clears the registry.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/high")) started = asyncio.Event() release = asyncio.Event() async def _fake_get(camera_id: str) -> None: started.set() await release.wait() client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] client._schedule_rtsps_refresh("cam1") await started.wait() task = client._rtsps_refresh_tasks["cam1"] await client._cancel_rtsps_refresh_tasks() assert task.cancelled() assert client._rtsps_refresh_tasks == {} @pytest.mark.asyncio async def test_cancel_rtsps_refresh_tasks_empty_noop(): """Cancelling with no pending refresh tasks returns early.""" client = _rtsps_client() await client._cancel_rtsps_refresh_tasks() assert client._rtsps_refresh_tasks == {} @pytest.mark.asyncio async def test_refresh_camera_rtsps_notifies_subscribers_on_prime(): """Priming a streamless camera emits a devices-WS update carrying the camera.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera(client, "cam1") fresh = RTSPSStreams(high="rtsps://example.com/fresh") client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=fresh ) messages = [] client.subscribe_devices_websocket(messages.append) await client._refresh_camera_rtsps("cam1") assert camera.rtsps_streams is fresh assert len(messages) == 1 msg = messages[0] assert msg.action is WSAction.UPDATE assert msg.new_obj is camera assert msg.changed_data["modelKey"] == "camera" assert msg.changed_data["id"] == "cam1" assert "rtsps_streams" in msg.changed_data @pytest.mark.asyncio async def test_refresh_camera_rtsps_notifies_subscribers_on_change(): """A refresh that changes the streams emits one devices-WS update.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "cam1", RTSPSStreams(high="rtsps://example.com/stale") ) fresh = RTSPSStreams(high="rtsps://example.com/fresh") client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=fresh ) messages = [] client.subscribe_devices_websocket(messages.append) await client._refresh_camera_rtsps("cam1") assert camera.rtsps_streams is fresh assert len(messages) == 1 assert messages[0].new_obj is camera @pytest.mark.asyncio async def test_refresh_camera_rtsps_reaches_typed_device_subscribers(): """A background prime is observable through the typed ``subscribe_devices`` channel.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera(client, "cam1") fresh = RTSPSStreams(high="rtsps://example.com/fresh") client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=fresh ) changes = [] client.subscribe_devices(changes.append) await client._refresh_camera_rtsps("cam1") assert len(changes) == 1 change = changes[0] assert change.model is camera assert change.model_type is ModelType.CAMERA assert change.device_id == "cam1" assert "rtsps_streams" in change.changed_fields @pytest.mark.asyncio async def test_refresh_camera_rtsps_no_notify_on_unchanged(): """A refresh whose fetched streams equal the cached ones emits nothing.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/same")) client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=RTSPSStreams(high="rtsps://example.com/same") ) messages = [] client.subscribe_devices_websocket(messages.append) await client._refresh_camera_rtsps("cam1") assert messages == [] @pytest.mark.asyncio async def test_refresh_camera_rtsps_no_notify_on_failure(): """A failed refresh writes nothing and emits no devices-WS update.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/high")) client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] side_effect=RuntimeError("boom") ) messages = [] client.subscribe_devices_websocket(messages.append) await client._refresh_camera_rtsps("cam1") assert messages == [] @pytest.mark.asyncio async def test_refresh_camera_rtsps_no_notify_on_backoff(): """An identity-guard backoff (camera evicted mid-flight) emits nothing.""" client = _rtsps_client() client._public_bootstrap = PublicBootstrap() _seed_rtsps_camera(client, "cam1", RTSPSStreams(high="rtsps://example.com/stale")) fresh = RTSPSStreams(high="rtsps://example.com/fresh") started = asyncio.Event() release = asyncio.Event() async def _fake_get(camera_id: str) -> RTSPSStreams: started.set() await release.wait() return fresh client.get_camera_rtsps_streams = _fake_get # type: ignore[method-assign] messages = [] client.subscribe_devices_websocket(messages.append) client._schedule_rtsps_refresh("cam1") task = client._rtsps_refresh_tasks["cam1"] await started.wait() client._public_bootstrap.cameras.pop("cam1", None) release.set() await task assert messages == [] def test_rtsps_streams_class(): """Test RTSPSStreams class functionality.""" # Test with multiple qualities streams = RTSPSStreams( high="rtsps://example.com/high", medium="rtsps://example.com/medium", low="rtsps://example.com/low", ) assert streams.get_stream_url("high") == "rtsps://example.com/high" assert streams.get_stream_url("medium") == "rtsps://example.com/medium" assert streams.get_stream_url("low") == "rtsps://example.com/low" assert streams.get_stream_url("nonexistent") is None available_qualities = streams.get_available_stream_qualities() assert set(available_qualities) == {"high", "medium", "low"} # Test with single quality single_stream = RTSPSStreams(ultra="rtsps://example.com/ultra") assert single_stream.get_stream_url("ultra") == "rtsps://example.com/ultra" assert set(single_stream.get_available_stream_qualities()) == {"ultra"} # Test with empty streams empty_stream = RTSPSStreams() assert empty_stream.get_stream_url("any") is None assert empty_stream.get_available_stream_qualities() == [] def test_rtsps_streams_get_stream_url_srtp(): """Test get_stream_url srtp flag strips the ?enableSrtp query for go2rtc.""" streams = RTSPSStreams(high="rtsps://example.com:7441/abc?enableSrtp") assert streams.get_stream_url("high") == "rtsps://example.com:7441/abc?enableSrtp" assert ( streams.get_stream_url("high", srtp=True) == "rtsps://example.com:7441/abc?enableSrtp" ) assert streams.get_stream_url("high", srtp=False) == "rtsps://example.com:7441/abc" # Missing quality returns None regardless of srtp. assert streams.get_stream_url("nonexistent", srtp=False) is None # srtp=False is a no-op when there is no ?enableSrtp suffix, and only that # exact suffix is stripped — any other query stays intact. plain = RTSPSStreams(high="rtsps://example.com:7441/abc") assert plain.get_stream_url("high", srtp=False) == "rtsps://example.com:7441/abc" other = RTSPSStreams(high="rtsps://example.com:7441/abc?foo=bar") assert ( other.get_stream_url("high", srtp=False) == "rtsps://example.com:7441/abc?foo=bar" ) def _override_client(host: str) -> ProtectApiClient: return ProtectApiClient( host, 443, "user", "pass", override_connection_host=True, store_sessions=False, verify_ssl=False, ) @pytest.mark.parametrize( ("host", "url", "expected"), [ ( "10.0.0.5", "rtsps://192.168.1.1:7441/abc?enableSrtp", "rtsps://10.0.0.5:7441/abc?enableSrtp", ), ( "2001:db8::1", "rtsps://192.168.1.1:7441/abc?enableSrtp", "rtsps://[2001:db8::1]:7441/abc?enableSrtp", ), ( "unifi.local", "rtsps://192.168.1.1/abc", "rtsps://unifi.local/abc", ), ( "10.0.0.5", "rtsps://[2001:db8::2]:7441/abc", "rtsps://10.0.0.5:7441/abc", ), ], ) def test_rtsps_streams_get_stream_url_override_host( host: str, url: str, expected: str ) -> None: """override_connection_host replaces the host, keeping port, path and query.""" streams = RTSPSStreams(api=_override_client(host), high=url) assert streams.get_stream_url("high") == expected def test_rtsps_streams_get_stream_url_override_host_srtp_stripped() -> None: """The host rewrite composes with srtp=False.""" streams = RTSPSStreams( api=_override_client("10.0.0.5"), high="rtsps://192.168.1.1:7441/abc?enableSrtp", ) assert streams.get_stream_url("high", srtp=False) == "rtsps://10.0.0.5:7441/abc" def test_rtsps_streams_get_stream_url_override_host_missing_quality() -> None: """A missing quality stays None under the override.""" streams = RTSPSStreams(api=_override_client("10.0.0.5"), high=None) assert streams.get_stream_url("high") is None assert streams.get_stream_url("nonexistent") is None def test_rtsps_streams_get_stream_url_override_host_malformed_port() -> None: """An unparsable port leaves the server URL untouched.""" streams = RTSPSStreams( api=_override_client("10.0.0.5"), high="rtsps://192.168.1.1:noport/abc" ) assert streams.get_stream_url("high") == "rtsps://192.168.1.1:noport/abc" def test_rtsps_streams_get_stream_url_without_override_is_verbatim() -> None: """Without the override the server URL is returned as-is.""" client = ProtectApiClient( "10.0.0.5", 443, "user", "pass", store_sessions=False, verify_ssl=False ) streams = RTSPSStreams(api=client, high="rtsps://192.168.1.1:7441/abc") assert streams.get_stream_url("high") == "rtsps://192.168.1.1:7441/abc" def test_rtsps_streams_get_stream_url_without_api_is_verbatim() -> None: """Streams built without a client reference cannot rewrite the host.""" streams = RTSPSStreams(high="rtsps://192.168.1.1:7441/abc") assert streams.get_stream_url("high") == "rtsps://192.168.1.1:7441/abc" @pytest.mark.asyncio async def test_get_camera_rtsps_streams_applies_override_host() -> None: """Streams fetched from the public API honour override_connection_host.""" client = _override_client("10.0.0.5") client._api_key = "test_key" with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"high": "rtsps://192.168.1.1:7441/abc"}'), ): result = await client.get_camera_rtsps_streams("camera123") assert result is not None assert result.get_stream_url("high") == "rtsps://10.0.0.5:7441/abc" @pytest.mark.asyncio async def test_public_only_client_applies_override_host() -> None: """A public-only client can rewrite the RTSPS host without a private bootstrap.""" client = ProtectApiClient.public_only( "10.0.0.5", 443, "test_key", override_connection_host=True, verify_ssl=False ) with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"high": "rtsps://192.168.1.1:7441/abc"}'), ): result = await client.get_camera_rtsps_streams("camera123") assert client.is_public_only is True assert result is not None assert result.get_stream_url("high") == "rtsps://10.0.0.5:7441/abc" @pytest.mark.asyncio async def test_delete_camera_rtsps_streams_survivors_apply_override_host() -> None: """Surviving qualities after a delete still honour override_connection_host.""" client = _override_client("10.0.0.5") client._api_key = "test_key" client._public_bootstrap = PublicBootstrap() camera = _seed_rtsps_camera( client, "camera123", RTSPSStreams( api=client, high="rtsps://192.168.1.1:7441/high", low="rtsps://192.168.1.1:7441/low", ), ) with patch.object(client, "api_request_raw", new=AsyncMock(return_value=b"")): assert await client.delete_camera_rtsps_streams("camera123", "high") is True assert camera.rtsps_streams is not None assert camera.rtsps_streams.get_stream_url("low") == "rtsps://10.0.0.5:7441/low" @pytest.mark.asyncio async def test_create_camera_rtsps_streams_applies_override_host() -> None: """Streams created through the public API honour override_connection_host.""" client = _override_client("10.0.0.5") client._api_key = "test_key" with patch.object( client, "api_request_raw", new=AsyncMock(return_value=b'{"high": "rtsps://192.168.1.1:7441/abc"}'), ): result = await client.create_camera_rtsps_streams("camera123", "high") assert result is not None assert result.get_stream_url("high") == "rtsps://10.0.0.5:7441/abc" def test_rtsps_streams_active_inactive(): """Test RTSPSStreams active/inactive stream quality detection.""" # Test with mixed active and inactive streams streams = RTSPSStreams( high="rtsps://example.com/high", medium=None, # inactive low="rtsps://example.com/low", ultra=None, # inactive ) # All available qualities (active + inactive) available = streams.get_available_stream_qualities() assert set(available) == {"high", "medium", "low", "ultra"} # Only active streams (with URLs) active = streams.get_active_stream_qualities() assert set(active) == {"high", "low"} # Only inactive streams (without URLs) inactive = streams.get_inactive_stream_qualities() assert set(inactive) == {"medium", "ultra"} def test_rtsps_streams_all_active(): """Test RTSPSStreams when all streams are active.""" streams = RTSPSStreams( high="rtsps://example.com/high", medium="rtsps://example.com/medium", ) assert set(streams.get_available_stream_qualities()) == {"high", "medium"} assert set(streams.get_active_stream_qualities()) == {"high", "medium"} assert streams.get_inactive_stream_qualities() == [] def test_rtsps_streams_all_inactive(): """Test RTSPSStreams when all streams are inactive.""" streams = RTSPSStreams( high=None, medium=None, ) assert set(streams.get_available_stream_qualities()) == {"high", "medium"} assert streams.get_active_stream_qualities() == [] assert set(streams.get_inactive_stream_qualities()) == {"high", "medium"} def test_rtsps_streams_active_inactive_qualities(): """Test RTSPSStreams active and inactive quality methods.""" # Test with mixed active (URLs) and inactive (None) streams streams = RTSPSStreams( high="rtsps://example.com/high", medium=None, # inactive stream low="rtsps://example.com/low", ultra=None, # inactive stream ) # Test active streams (those with valid RTSPS URLs) active_qualities = streams.get_active_stream_qualities() assert set(active_qualities) == {"high", "low"} # Test inactive streams (those with None values) inactive_qualities = streams.get_inactive_stream_qualities() assert set(inactive_qualities) == {"medium", "ultra"} # Test all available streams (both active and inactive) available_qualities = streams.get_available_stream_qualities() assert set(available_qualities) == {"high", "medium", "low", "ultra"} # Test that active + inactive equals available assert set(active_qualities + inactive_qualities) == set(available_qualities) def test_rtsps_streams_only_active(): """Test RTSPSStreams with only active streams.""" streams = RTSPSStreams( high="rtsps://example.com/high", medium="rtsps://example.com/medium", ) active_qualities = streams.get_active_stream_qualities() assert set(active_qualities) == {"high", "medium"} inactive_qualities = streams.get_inactive_stream_qualities() assert inactive_qualities == [] available_qualities = streams.get_available_stream_qualities() assert set(available_qualities) == {"high", "medium"} def test_rtsps_streams_only_inactive(): """Test RTSPSStreams with only inactive streams.""" streams = RTSPSStreams( high=None, medium=None, low=None, ) active_qualities = streams.get_active_stream_qualities() assert active_qualities == [] inactive_qualities = streams.get_inactive_stream_qualities() assert set(inactive_qualities) == {"high", "medium", "low"} available_qualities = streams.get_available_stream_qualities() assert set(available_qualities) == {"high", "medium", "low"} def test_rtsps_streams_empty(): """Test RTSPSStreams with no streams.""" streams = RTSPSStreams() assert streams.get_active_stream_qualities() == [] assert streams.get_inactive_stream_qualities() == [] assert streams.get_available_stream_qualities() == [] def test_rtsps_streams_edge_cases(): """Test RTSPSStreams edge cases and string validation.""" # Test with empty strings and various URL formats streams = RTSPSStreams( high="rtsps://valid.com/stream", medium="", # empty string - still a string, so active low="http://invalid.com/stream", # still a string, so active ultra="invalid_url", # still a string, so active ) # All string values are considered active (including empty strings) active_qualities = streams.get_active_stream_qualities() assert set(active_qualities) == {"high", "medium", "low", "ultra"} # Test with None values (should be inactive) streams_with_none = RTSPSStreams( high="rtsps://valid.com/stream", medium=None, # None value - inactive ) active_qualities = streams_with_none.get_active_stream_qualities() assert active_qualities == ["high"] inactive_qualities = streams_with_none.get_inactive_stream_qualities() assert inactive_qualities == ["medium"] available_qualities = streams.get_available_stream_qualities() assert set(available_qualities) == {"high", "medium", "low", "ultra"} def test_rtsps_streams_none_pydantic_extra(): """Test RTSPSStreams when __pydantic_extra__ is None.""" # Create an RTSPSStreams instance and manually set __pydantic_extra__ to None # This simulates edge cases where pydantic might not initialize extra fields streams = RTSPSStreams() # Manually set __pydantic_extra__ to None to test the None check streams.__pydantic_extra__ = None # All methods should return empty lists when __pydantic_extra__ is None assert streams.get_available_stream_qualities() == [] assert streams.get_active_stream_qualities() == [] assert streams.get_inactive_stream_qualities() == [] # get_stream_url should still work (uses getattr, not __pydantic_extra__) assert streams.get_stream_url("any") is None @pytest.mark.asyncio async def test_camera_create_rtsps_streams(): """Test Camera.create_rtsps_streams method.""" # Mock camera and API camera = AsyncMock(spec=Camera) camera.id = "test_camera_id" camera._api = AsyncMock() camera._api._api_key = "test_api_key" camera._api.create_camera_rtsps_streams = AsyncMock( return_value=RTSPSStreams(high="rtsps://example.com/high") ) # Bind the actual method to the mock camera.create_rtsps_streams = Camera.create_rtsps_streams.__get__(camera, Camera) # Test successful creation result = await camera.create_rtsps_streams(["high"]) assert result is not None assert result.get_stream_url("high") == "rtsps://example.com/high" camera._api.create_camera_rtsps_streams.assert_called_once_with( "test_camera_id", ["high"] ) @pytest.mark.asyncio async def test_camera_create_rtsps_streams_no_api_key(): """Test Camera.create_rtsps_streams method without API key.""" # Mock camera and API without key camera = AsyncMock(spec=Camera) camera.id = "test_camera_id" camera._api = AsyncMock() camera._api._api_key = None # Bind the actual method to the mock camera.create_rtsps_streams = Camera.create_rtsps_streams.__get__(camera, Camera) # Test that it raises NotAuthorized with pytest.raises( NotAuthorized, match="Cannot create RTSPS streams without an API key" ): await camera.create_rtsps_streams(["high"]) @pytest.mark.asyncio async def test_camera_get_rtsps_streams(): """Test Camera.get_rtsps_streams method.""" # Mock camera and API camera = AsyncMock(spec=Camera) camera.id = "test_camera_id" camera._api = AsyncMock() camera._api._api_key = "test_api_key" camera._api.get_camera_rtsps_streams = AsyncMock( return_value=RTSPSStreams( high="rtsps://example.com/high", medium="rtsps://example.com/medium" ) ) # Bind the actual method to the mock camera.get_rtsps_streams = Camera.get_rtsps_streams.__get__(camera, Camera) # Test successful retrieval result = await camera.get_rtsps_streams() assert result is not None assert result.get_stream_url("high") == "rtsps://example.com/high" assert result.get_stream_url("medium") == "rtsps://example.com/medium" camera._api.get_camera_rtsps_streams.assert_called_once_with("test_camera_id") @pytest.mark.asyncio async def test_camera_get_rtsps_streams_no_api_key(): """Test Camera.get_rtsps_streams method without API key.""" # Mock camera and API without key camera = AsyncMock(spec=Camera) camera.id = "test_camera_id" camera._api = AsyncMock() camera._api._api_key = None # Bind the actual method to the mock camera.get_rtsps_streams = Camera.get_rtsps_streams.__get__(camera, Camera) # Test that it raises NotAuthorized with pytest.raises( NotAuthorized, match="Cannot get RTSPS streams without an API key" ): await camera.get_rtsps_streams() @pytest.mark.asyncio async def test_camera_delete_rtsps_streams(): """Test Camera.delete_rtsps_streams method.""" # Mock camera and API camera = AsyncMock(spec=Camera) camera.id = "test_camera_id" camera._api = AsyncMock() camera._api._api_key = "test_api_key" camera._api.delete_camera_rtsps_streams = AsyncMock(return_value=True) # Bind the actual method to the mock camera.delete_rtsps_streams = Camera.delete_rtsps_streams.__get__(camera, Camera) # Test successful deletion result = await camera.delete_rtsps_streams(["high", "medium"]) assert result is True camera._api.delete_camera_rtsps_streams.assert_called_once_with( "test_camera_id", ["high", "medium"] ) @pytest.mark.asyncio async def test_camera_delete_rtsps_streams_no_api_key(): """Test Camera.delete_rtsps_streams method without API key.""" # Mock camera and API without key camera = AsyncMock(spec=Camera) camera.id = "test_camera_id" camera._api = AsyncMock() camera._api._api_key = None # Bind the actual method to the mock camera.delete_rtsps_streams = Camera.delete_rtsps_streams.__get__(camera, Camera) # Test that it raises NotAuthorized with pytest.raises( NotAuthorized, match="Cannot delete RTSPS streams without an API key" ): await camera.delete_rtsps_streams(["high"]) @pytest.mark.asyncio async def test_raise_for_status_status_codes(): """Test _raise_for_status with different HTTP status codes.""" # Create API client api = ProtectApiClient("test.com", 443, "username", "password") # Test success codes (2xx) - should not raise success_tests = [ (199, True, NvrError), # Just below 2xx range (200, False, None), # Start of 2xx range (201, False, None), # Common success code (299, False, None), # End of 2xx range (300, True, NvrError), # Just above 2xx range ] for status_code, should_raise, expected_exception in success_tests: response = Mock() response.status = status_code response.url = "https://test.com/api/test" with patch("uiprotect.api.get_response_reason", return_value="Test"): if should_raise: with pytest.raises(expected_exception): await api._raise_for_status(response, raise_exception=True) else: # Should not raise any exception await api._raise_for_status(response, raise_exception=True) await api._raise_for_status(response, raise_exception=False) # Test specific error codes error_tests = [ (400, BadRequest), (401, NotAuthorized), (403, NotAuthorized), (404, BadRequest), (429, NvrError), (500, NvrError), ] for status_code, expected_exception in error_tests: response = Mock() response.status = status_code response.url = "https://test.com/api/test" with patch("uiprotect.api.get_response_reason", return_value="Error"): # Should raise with raise_exception=True with pytest.raises(expected_exception): await api._raise_for_status(response, raise_exception=True) # Should not raise with raise_exception=False await api._raise_for_status(response, raise_exception=False) @pytest.mark.asyncio async def test_raise_for_status_global_alarm_manager_error() -> None: """400 with global alarm manager reason raises GlobalAlarmManagerError.""" api = ProtectApiClient("test.com", 443, "username", "password") response = Mock() response.status = 400 response.url = "https://test.com/api/test" with ( patch( "uiprotect.api.get_response_reason", return_value="Global alarm manager is configured", ), pytest.raises(GlobalAlarmManagerError), ): await api._raise_for_status(response, raise_exception=True) @pytest.mark.asyncio @pytest.mark.parametrize( "reason", [ "Arm alarm is armed", "arm alarm is armed", "ARM ALARM IS ARMED", "Arm Alarm Is Armed", ], ) async def test_raise_for_status_armed_mode_error(reason: str) -> None: """400 with arm alarm armed reason raises ArmedModeError (case-insensitive).""" api = ProtectApiClient("test.com", 443, "username", "password") response = Mock() response.status = 400 response.url = "https://test.com/api/test" with ( patch( "uiprotect.api.get_response_reason", return_value=reason, ), pytest.raises(ArmedModeError), ): await api._raise_for_status(response, raise_exception=True) @pytest.mark.asyncio async def test_raise_for_status_generic_bad_request() -> None: """400 with an unrelated reason raises a plain BadRequest.""" api = ProtectApiClient("test.com", 443, "username", "password") response = Mock() response.status = 400 response.url = "https://test.com/api/test" with ( patch( "uiprotect.api.get_response_reason", return_value="Some other validation failure", ), pytest.raises(BadRequest) as exc_info, ): await api._raise_for_status(response, raise_exception=True) assert not isinstance(exc_info.value, (GlobalAlarmManagerError, ArmedModeError)) @pytest.mark.asyncio async def test_api_request_raw_error_handling(): """Test api_request_raw error handling with raise_exception parameter.""" # Create API client api = ProtectApiClient("test.com", 443, "username", "password") # Mock the request method to return a response with error status mock_response = AsyncMock() mock_response.status = 404 mock_response.url = "https://test.com/api/test" mock_response.read = AsyncMock(return_value=b'{"error": "not found"}') mock_response.release = Mock() api.request = AsyncMock(return_value=mock_response) # Mock get_response_reason with patch("uiprotect.api.get_response_reason", return_value="Not Found"): # Test with raise_exception=False - should return None result = await api.api_request_raw("/test", raise_exception=False) assert result is None # Verify that response.read() was NOT called since we return early mock_response.read.assert_not_called() # Reset mock for next test mock_response.reset_mock() api.request.reset_mock() # Test with raise_exception=True - should raise exception with pytest.raises(BadRequest): await api.api_request_raw("/test", raise_exception=True) # Verify that response.release() was called in the exception handler mock_response.release.assert_called_once() @pytest.mark.asyncio async def test_api_request_raw_success(): """Test api_request_raw with successful response.""" # Create API client api = ProtectApiClient("test.com", 443, "username", "password") # Mock the request method to return a response with success status mock_response = AsyncMock() mock_response.status = 200 mock_response.url = "https://test.com/api/test" mock_response.read = AsyncMock(return_value=b'{"success": true}') mock_response.release = Mock() api.request = AsyncMock(return_value=mock_response) # Test with success status - should return data result = await api.api_request_raw("/test", raise_exception=True) assert result == b'{"success": true}' # Verify that response.read() was called mock_response.read.assert_called_once() # Verify that response.release() was called mock_response.release.assert_called_once() # Public API Tests @pytest.mark.asyncio() async def test_get_nvr_public_success(): """Test successful NVR retrieval from public API.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) client.api_request_obj = AsyncMock( return_value={ "id": "663d0a9d001d8803e40003ea", "modelKey": "nvr", "name": "Test NVR", "doorbellSettings": { "defaultMessageText": "WELCOME", "defaultMessageResetTimeoutMs": 60000, "customMessages": [], "customImages": [], }, } ) result = await client.get_nvr_public() assert result is not None assert result.id == "663d0a9d001d8803e40003ea" assert result.name == "Test NVR" client.api_request_obj.assert_called_with(url="/v1/nvrs", public_api=True) @pytest.mark.asyncio() async def test_get_nvr_public_error(): """Test NVR retrieval error handling.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) client.api_request_obj = AsyncMock(side_effect=Exception("API Error")) with pytest.raises(Exception, match="API Error"): await client.get_nvr_public() client.api_request_obj.assert_called_with(url="/v1/nvrs", public_api=True) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicCamera.from_unifi_dict") async def test_get_cameras_public_success(mock_create): """Test successful cameras retrieval from public API.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) mock_cameras_data = [ { "id": "663d0aa400918803e4000445", "name": "Camera 1", "type": "UVC G4 Doorbell Pro PoE", "mac": "E438830EDA97", "modelKey": "camera", "firmwareVersion": "4.74.78", }, { "id": "663d0aa400918803e4000446", "name": "Camera 2", "type": "UVC G4 Doorbell Pro PoE", "mac": "E438830EDA98", "modelKey": "camera", "firmwareVersion": "4.74.78", }, ] # Mock the API request client.api_request_list = AsyncMock(return_value=mock_cameras_data) # Mock the Camera.from_unifi_dict method mock_camera = Mock() mock_create.return_value = mock_camera result = await client.get_cameras_public() assert result is not None assert isinstance(result, list) assert len(result) == 2 assert all(camera == mock_camera for camera in result) client.api_request_list.assert_called_with(url="/v1/cameras", public_api=True) assert mock_create.call_count == 2 @pytest.mark.asyncio() async def test_get_cameras_public_error(): """Test cameras retrieval error handling.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) client.api_request_list = AsyncMock(side_effect=Exception("API Error")) with pytest.raises(Exception, match="API Error"): await client.get_cameras_public() client.api_request_list.assert_called_with(url="/v1/cameras", public_api=True) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicCamera.from_unifi_dict") async def test_get_camera_public_success(mock_create): """Test successful single camera retrieval from public API.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) mock_camera_data = { "id": "663d0aa400918803e4000445", "name": "Camera 1", "type": "UVC G4 Doorbell Pro PoE", "mac": "E438830EDA97", "modelKey": "camera", "firmwareVersion": "4.74.78", } # Mock the API request client.api_request_obj = AsyncMock(return_value=mock_camera_data) # Mock the Camera.from_unifi_dict method mock_camera = Mock() mock_camera.id = "663d0aa400918803e4000445" mock_camera.name = "Camera 1" mock_create.return_value = mock_camera result = await client.get_camera_public("663d0aa400918803e4000445") assert result is not None assert result.id == "663d0aa400918803e4000445" assert result.name == "Camera 1" client.api_request_obj.assert_called_with( url="/v1/cameras/663d0aa400918803e4000445", public_api=True ) mock_create.assert_called_once_with(**mock_camera_data, api=client) @pytest.mark.asyncio() async def test_get_camera_public_error(): """Test single camera retrieval error handling.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) client.api_request_obj = AsyncMock(side_effect=Exception("API Error")) with pytest.raises(Exception, match="API Error"): await client.get_camera_public("663d0aa400918803e4000445") client.api_request_obj.assert_called_with( url="/v1/cameras/663d0aa400918803e4000445", public_api=True ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_get_chimes_public_success(mock_create): """Test successful chimes retrieval from public API.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) mock_chimes_data = [ { "id": "663d0aa401108803e4000447", "name": "Chime 1", "type": "UP Chime PoE", "mac": "E438830ABC01", "modelKey": "chime", "firmwareVersion": "1.5.4", } ] # Mock the API request client.api_request_list = AsyncMock(return_value=mock_chimes_data) # Mock the Chime.from_unifi_dict method mock_chime = Mock() mock_chime.id = "663d0aa401108803e4000447" mock_chime.name = "Chime 1" mock_create.return_value = mock_chime result = await client.get_chimes_public() assert result is not None assert isinstance(result, list) assert len(result) == 1 assert result[0].id == "663d0aa401108803e4000447" assert result[0].name == "Chime 1" client.api_request_list.assert_called_with(url="/v1/chimes", public_api=True) mock_create.assert_called_once_with(**mock_chimes_data[0], api=client) @pytest.mark.asyncio() async def test_get_chimes_public_error(): """Test chimes retrieval error handling.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) client.api_request_list = AsyncMock(side_effect=Exception("API Error")) with pytest.raises(Exception, match="API Error"): await client.get_chimes_public() client.api_request_list.assert_called_with(url="/v1/chimes", public_api=True) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_get_chime_public_success(mock_create): """Test successful single chime retrieval from public API.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) mock_chime_data = { "id": "663d0aa401108803e4000447", "name": "Chime 1", "type": "UP Chime PoE", "mac": "E438830ABC01", "modelKey": "chime", "firmwareVersion": "1.5.4", } # Mock the API request client.api_request_obj = AsyncMock(return_value=mock_chime_data) # Mock the Chime.from_unifi_dict method mock_chime = Mock() mock_chime.id = "663d0aa401108803e4000447" mock_chime.name = "Chime 1" mock_create.return_value = mock_chime result = await client.get_chime_public("663d0aa401108803e4000447") assert result is not None assert result.id == "663d0aa401108803e4000447" assert result.name == "Chime 1" client.api_request_obj.assert_called_with( url="/v1/chimes/663d0aa401108803e4000447", public_api=True ) mock_create.assert_called_once_with(**mock_chime_data, api=client) @pytest.mark.asyncio() async def test_get_chime_public_error(): """Test single chime retrieval error handling.""" client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", api_key="my_key", verify_ssl=False, ) client.api_request_obj = AsyncMock(side_effect=Exception("API Error")) with pytest.raises(Exception, match="API Error"): await client.get_chime_public("663d0aa401108803e4000447") client.api_request_obj.assert_called_with( url="/v1/chimes/663d0aa401108803e4000447", public_api=True ) def test_led_settings_deserialization_with_blink_rate(): """Test that LEDSettings can be created with blink_rate field (older Protect versions).""" led_data = { "isEnabled": False, "blinkRate": 100, } led_settings = LEDSettings.from_unifi_dict(**led_data) assert led_settings.is_enabled is False assert led_settings.blink_rate == 100 assert led_settings.welcome_led is None assert led_settings.flood_led is None def test_led_settings_deserialization_without_blink_rate(): """Test that LEDSettings can be created without blink_rate field (Protect 6.x+).""" led_data = { "isEnabled": True, } led_settings = LEDSettings.from_unifi_dict(**led_data) assert led_settings.is_enabled is True assert led_settings.blink_rate is None assert led_settings.welcome_led is None assert led_settings.flood_led is None def test_led_settings_with_new_fields(): """Test LED settings with welcome_led and flood_led fields (Protect 6.2+).""" led_data = { "isEnabled": True, "welcomeLed": True, "floodLed": False, } led_settings = LEDSettings.from_unifi_dict(**led_data) assert led_settings.is_enabled is True assert led_settings.blink_rate is None assert led_settings.welcome_led is True assert led_settings.flood_led is False def test_led_settings_serialization_with_all_fields(): """Test that LEDSettings serialization includes all fields when set.""" led_settings = LEDSettings( is_enabled=True, blink_rate=0, welcome_led=True, flood_led=False, ) # Test unifi_dict() serialization (for API) serialized = led_settings.unifi_dict() # All fields should be present in camelCase assert serialized["isEnabled"] is True assert serialized["blinkRate"] == 0 assert serialized["welcomeLed"] is True assert serialized["floodLed"] is False @pytest.mark.asyncio() async def test_update_bootstrap_soon_creates_task_when_none_exists( protect_client: ProtectApiClient, ): """Test _update_bootstrap_soon creates a new task when none exists.""" protect_client._update_task = None protect_client.update = AsyncMock() protect_client._update_bootstrap_soon() assert protect_client._update_task is not None await protect_client._update_task protect_client.update.assert_called_once() @pytest.mark.asyncio() async def test_update_bootstrap_soon_creates_task_when_previous_done( protect_client: ProtectApiClient, ): """Test _update_bootstrap_soon creates a new task when previous task is done.""" completed_task = asyncio.create_task(asyncio.sleep(0)) await completed_task protect_client._update_task = completed_task protect_client.update = AsyncMock() protect_client._update_bootstrap_soon() assert protect_client._update_task is not None await protect_client._update_task protect_client.update.assert_called_once() @pytest.mark.asyncio() async def test_update_bootstrap_soon_skips_when_task_running( protect_client: ProtectApiClient, ): """Test _update_bootstrap_soon does not create a new task when one is running.""" task_event = asyncio.Event() running_task = asyncio.create_task(task_event.wait()) protect_client._update_task = running_task protect_client.update = AsyncMock() protect_client._update_bootstrap_soon() assert protect_client._update_task is running_task protect_client.update.assert_not_called() task_event.set() await running_task # Tests for JSON deserialization in api_request, api_request_obj, api_request_list @pytest.mark.asyncio @pytest.mark.parametrize( "response_data,expected,expected_type", [ (b'{"key": "value", "number": 42}', {"key": "value", "number": 42}, dict), (b'[{"id": 1}, {"id": 2}]', [{"id": 1}, {"id": 2}], list), (None, None, type(None)), ], ids=["dict_response", "list_response", "empty_response"], ) async def test_api_request_success( simple_api_client, response_data, expected, expected_type ): """Test api_request successfully deserializes various JSON responses.""" simple_api_client.api_request_raw = AsyncMock(return_value=response_data) result = await simple_api_client.api_request("/test") assert result == expected assert isinstance(result, expected_type) @pytest.mark.asyncio @pytest.mark.parametrize( "invalid_data", [ pytest.param(b"invalid json {{{", id="invalid_syntax"), pytest.param(b'{"key": "value"', id="truncated"), pytest.param(b"\x00\x01\x02\x03\x04", id="binary_data"), ], ) async def test_api_request_json_decode_error(simple_api_client, invalid_data): """Test api_request raises NvrError on JSONDecodeError.""" simple_api_client.api_request_raw = AsyncMock(return_value=invalid_data) with pytest.raises(NvrError, match="Could not decode JSON from /test"): await simple_api_client.api_request("/test") @pytest.mark.asyncio async def test_api_request_complex_nested_json(simple_api_client): """Test api_request handles complex nested JSON structures.""" complex_json = b'{"devices": [{"id": "cam1"}], "meta": {"nested": {"deep": true}}}' simple_api_client.api_request_raw = AsyncMock(return_value=complex_json) result = await simple_api_client.api_request("/test") assert result["devices"][0]["id"] == "cam1" assert result["meta"]["nested"]["deep"] is True @pytest.mark.asyncio async def test_api_request_unicode(simple_api_client): """Test api_request handles JSON with unicode characters.""" simple_api_client.api_request_raw = AsyncMock( return_value='{"name": "Kamera Außen", "emoji": "📷"}'.encode() ) result = await simple_api_client.api_request("/test") assert result["name"] == "Kamera Außen" assert result["emoji"] == "📷" @pytest.mark.asyncio async def test_api_request_obj_success(simple_api_client): """Test api_request_obj successfully returns dict.""" simple_api_client.api_request_raw = AsyncMock(return_value=b'{"id": "test-id"}') result = await simple_api_client.api_request_obj("/test") assert result == {"id": "test-id"} @pytest.mark.asyncio @pytest.mark.parametrize( "response_data", [pytest.param(b'[{"id": 1}]', id="list"), pytest.param(b"null", id="null")], ) async def test_api_request_obj_invalid_type(simple_api_client, response_data): """Test api_request_obj raises NvrError when response is not a dict.""" simple_api_client.api_request_raw = AsyncMock(return_value=response_data) with pytest.raises(NvrError, match="Could not decode object from /test"): await simple_api_client.api_request_obj("/test") @pytest.mark.asyncio async def test_api_request_list_success(simple_api_client): """Test api_request_list successfully returns list.""" simple_api_client.api_request_raw = AsyncMock( return_value=b'[{"id": 1}, {"id": 2}]' ) result = await simple_api_client.api_request_list("/test") assert result == [{"id": 1}, {"id": 2}] @pytest.mark.asyncio @pytest.mark.parametrize( "response_data", [pytest.param(b'{"id": "test"}', id="dict"), pytest.param(b"null", id="null")], ) async def test_api_request_list_invalid_type(simple_api_client, response_data): """Test api_request_list raises NvrError when response is not a list.""" simple_api_client.api_request_raw = AsyncMock(return_value=response_data) with pytest.raises(NvrError, match="Could not decode list from /test"): await simple_api_client.api_request_list("/test") # PTZ Private API Tests @pytest.mark.asyncio async def test_get_presets_ptz_camera(protect_client: ProtectApiClient): """Test get_presets_ptz_camera API method.""" mock_response: list[dict[str, Any]] = [ { "id": "preset1", "name": "Home", "slot": -1, "ptz": {"pan": 0.0, "tilt": 0.0, "zoom": 1.0}, }, { "id": "preset2", "name": "Front Door", "slot": 0, "ptz": {"pan": 45.0, "tilt": 10.0, "zoom": 2.0}, }, ] protect_client.api_request = AsyncMock(return_value=mock_response) presets = await protect_client.get_presets_ptz_camera("camera123") assert len(presets) == 2 assert all(isinstance(p, PTZPreset) for p in presets) assert presets[0].name == "Home" assert presets[0].slot == -1 assert presets[0].id == "preset1" assert presets[1].name == "Front Door" assert presets[1].slot == 0 assert presets[1].id == "preset2" protect_client.api_request.assert_called_with("cameras/camera123/ptz/preset") @pytest.mark.asyncio async def test_get_patrols_ptz_camera(protect_client: ProtectApiClient): """Test get_patrols_ptz_camera API method.""" mock_response: list[dict[str, Any]] = [ { "name": "Patrol", "slot": 0, "presets": [0], "presetMovementSpeed": None, "presetDurationSeconds": 20, "camera": "camera123", "id": "patrol1", "modelKey": "ptzPatrol", }, { "name": "Patrol 2", "slot": 1, "presets": [0, 2, 3], "presetMovementSpeed": 50, "presetDurationSeconds": 23, "camera": "camera123", "id": "patrol2", "modelKey": "ptzPatrol", }, ] protect_client.api_request = AsyncMock(return_value=mock_response) patrols = await protect_client.get_patrols_ptz_camera("camera123") assert len(patrols) == 2 assert all(isinstance(p, PTZPatrol) for p in patrols) assert patrols[0].name == "Patrol" assert patrols[0].slot == 0 assert patrols[0].presets == [0] assert patrols[0].preset_movement_speed is None assert patrols[0].preset_duration_seconds == 20 assert patrols[1].name == "Patrol 2" assert patrols[1].preset_movement_speed == 50 assert patrols[1].presets == [0, 2, 3] protect_client.api_request.assert_called_with("cameras/camera123/ptz/patrol") @pytest.mark.parametrize( ("method", "api_path"), [ ("get_presets_ptz_camera", "cameras/camera123/ptz/preset"), ("get_patrols_ptz_camera", "cameras/camera123/ptz/patrol"), ], ) @pytest.mark.parametrize("return_value", [[], None]) @pytest.mark.asyncio async def test_ptz_get_methods_empty( protect_client: ProtectApiClient, method: str, api_path: str, return_value: list[Any] | None, ): """Test PTZ get methods return empty list when no data or None.""" protect_client.api_request = AsyncMock(return_value=return_value) result = await getattr(protect_client, method)("camera123") assert result == [] protect_client.api_request.assert_called_with(api_path) # PTZ Public API Tests @pytest.mark.parametrize( ("method", "args", "expected_path"), [ ("ptz_goto_preset_public", {"slot": 2}, "/ptz/goto/2"), ("ptz_patrol_start_public", {"slot": 1}, "/ptz/patrol/start/1"), ("ptz_patrol_stop_public", {}, "/ptz/patrol/stop"), ], ) @pytest.mark.asyncio() async def test_ptz_public_api( protect_client: ProtectApiClient, method: str, args: dict, expected_path: str ): """Test PTZ public API methods.""" protect_client.api_request_raw = AsyncMock(return_value=b"") await getattr(protect_client, method)("camera123", **args) protect_client.api_request_raw.assert_called_with( url=f"/v1/cameras/camera123{expected_path}", method="post", public_api=True, ) # Talkback Public API Tests @pytest.mark.asyncio async def test_create_talkback_session_public(protect_client: ProtectApiClient): """Test create_talkback_session_public API method.""" mock_response: dict[str, Any] = { "url": "rtp://192.168.1.100:7004", "codec": "opus", "samplingRate": 24000, } protect_client.api_request_obj = AsyncMock(return_value=mock_response) # type: ignore[method-assign] session = await protect_client.create_talkback_session_public("camera123") assert isinstance(session, TalkbackSession) assert session.url == "rtp://192.168.1.100:7004" assert session.codec == "opus" assert session.sampling_rate == 24000 protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url="/v1/cameras/camera123/talkback-session", method="post", public_api=True, ) def _siren_client() -> ProtectApiClient: client = _rtsps_client() client._public_bootstrap = PublicBootstrap() return client def _seed_siren( client: ProtectApiClient, siren_id: str = "siren1", *, seconds_left: float | None = 0.0, ) -> Siren: """Cache a siren whose timed run ends ``seconds_left`` from now.""" if seconds_left is None: status = PublicSirenStatus(is_active=False) else: activated_at = datetime.now(UTC) + timedelta(seconds=seconds_left - 5) status = PublicSirenStatus( is_active=True, activated_at=to_js_time(activated_at), duration=5000 ) siren = Siren.model_construct(id=siren_id, siren_status=status) client._public_bootstrap.sirens[siren_id] = siren return siren def _capture_devices_messages(client: ProtectApiClient) -> list[WSSubscriptionMessage]: messages: list[WSSubscriptionMessage] = [] client._devices_ws_subscriptions.append(messages.append) return messages @pytest.mark.asyncio async def test_siren_expiry_emits_synthetic_devices_update(): """An expiring timed run announces itself as a devices-WS update.""" client = _siren_client() siren = _seed_siren(client, seconds_left=0.01) messages = _capture_devices_messages(client) client._schedule_siren_off("siren1") await client._siren_off_tasks["siren1"] assert siren.is_active is False assert siren.siren_status.is_active is False assert len(messages) == 1 msg = messages[0] assert msg.action is WSAction.UPDATE assert msg.new_update_id == "siren1" assert msg.new_obj is siren assert msg.changed_data == { "modelKey": "siren", "id": "siren1", "sirenStatus": siren.siren_status.unifi_dict(), } assert msg.changed_data["sirenStatus"]["isActive"] is False assert "siren1" not in client._siren_off_tasks @pytest.mark.asyncio async def test_siren_expiry_announces_run_that_already_ended(): """A deadline already in the past still produces exactly one announcement.""" client = _siren_client() siren = _seed_siren(client, seconds_left=-30) messages = _capture_devices_messages(client) client._schedule_siren_off("siren1") await client._siren_off_tasks["siren1"] assert len(messages) == 1 assert siren.is_active is False # The cleared flag makes ``turn_off_at`` ``None``, so re-deriving from the # same cached status neither re-arms nor re-announces. client._schedule_siren_off("siren1") assert client._siren_off_tasks == {} assert len(messages) == 1 @pytest.mark.asyncio async def test_siren_schedule_disarms_idle_siren(): """An idle siren drops any armed timer without announcing.""" client = _siren_client() _seed_siren(client, seconds_left=60) messages = _capture_devices_messages(client) client._schedule_siren_off("siren1") armed = client._siren_off_tasks["siren1"] client._public_bootstrap.sirens["siren1"].siren_status.is_active = False client._schedule_siren_off("siren1") assert armed.cancelled() or armed.cancelling() assert client._siren_off_tasks == {} assert messages == [] @pytest.mark.asyncio async def test_siren_schedule_for_uncached_siren_disarms(): """A siren gone from the cache leaves no timer behind.""" client = _siren_client() _seed_siren(client, seconds_left=60) client._schedule_siren_off("siren1") armed = client._siren_off_tasks["siren1"] del client._public_bootstrap.sirens["siren1"] client._schedule_siren_off("siren1") assert armed.cancelled() or armed.cancelling() assert client._siren_off_tasks == {} @pytest.mark.asyncio async def test_siren_schedule_without_public_bootstrap_is_noop(): """Without the public cache there is no status to derive a deadline from.""" client = _rtsps_client() client._schedule_siren_off("siren1") assert client._siren_off_tasks == {} @pytest.mark.asyncio async def test_siren_schedule_rearms_on_a_newer_run(): """A restarted run replaces the timer armed for the previous one.""" client = _siren_client() _seed_siren(client, seconds_left=60) client._schedule_siren_off("siren1") first = client._siren_off_tasks["siren1"] client._schedule_siren_off("siren1") second = client._siren_off_tasks["siren1"] assert second is not first assert first.cancelled() or first.cancelling() @pytest.mark.asyncio async def test_siren_expiry_skips_a_siren_removed_mid_run(): """A siren removed while its timer is pending announces nothing.""" client = _siren_client() _seed_siren(client, seconds_left=0.01) messages = _capture_devices_messages(client) client._schedule_siren_off("siren1") del client._public_bootstrap.sirens["siren1"] await client._siren_off_tasks["siren1"] assert messages == [] assert client._siren_off_tasks == {} @pytest.mark.asyncio async def test_siren_expiry_honours_the_devices_ws_model_filter(): """A filtered-out model updates the cache without emitting a frame.""" client = _siren_client() siren = _seed_siren(client, seconds_left=0.01) client._devices_ws_subscribed_models = {ModelType.CAMERA} messages = _capture_devices_messages(client) client._schedule_siren_off("siren1") await client._siren_off_tasks["siren1"] assert messages == [] assert siren.is_active is False @pytest.mark.asyncio async def test_close_session_cancels_pending_siren_expiry(): """Shutdown drains the siren timers like every other background task.""" client = _siren_client() _seed_siren(client, seconds_left=60) client._schedule_siren_off("siren1") task = client._siren_off_tasks["siren1"] await client.close_session() assert task.cancelled() assert client._siren_off_tasks == {} @pytest.mark.asyncio async def test_devices_ws_disconnect_cancels_pending_siren_expiry(): """A dropped devices websocket disarms timers derived from now-stale status.""" client = _siren_client() _seed_siren(client, seconds_left=60) client._devices_ws_has_been_connected = True client._schedule_siren_off("siren1") task = client._siren_off_tasks["siren1"] client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) assert task.cancelled() or task.cancelling() assert client._siren_off_tasks == {} @pytest.mark.asyncio async def test_reschedule_siren_offs_rearms_snapshot_and_drops_departed(): """A refreshed snapshot re-arms its own sirens and forgets the ones it lost.""" client = _siren_client() _seed_siren(client, "gone", seconds_left=60) client._schedule_siren_off("gone") departed = client._siren_off_tasks["gone"] del client._public_bootstrap.sirens["gone"] _seed_siren(client, "siren1", seconds_left=60) client._reschedule_siren_offs(client._public_bootstrap) assert departed.cancelled() or departed.cancelling() assert set(client._siren_off_tasks) == {"siren1"} await client._cancel_siren_off_tasks() @pytest.mark.asyncio async def test_siren_rearm_keeps_tracking_the_live_timer(): """A re-armed timer stays tracked once the cancelled one finishes cleaning up.""" client = _siren_client() _seed_siren(client, seconds_left=60) client._schedule_siren_off("siren1") cancelled = client._siren_off_tasks["siren1"] # Let the timer reach its sleep, so cancelling it runs the cleanup that # would otherwise evict the replacement armed below. await asyncio.sleep(0) client._schedule_siren_off("siren1") live = client._siren_off_tasks["siren1"] with contextlib.suppress(asyncio.CancelledError): await cancelled assert client._siren_off_tasks == {"siren1": live} await client.close_session() assert live.cancelled() @pytest.mark.asyncio async def test_siren_rearm_announces_the_run_only_once(): """Frames arriving mid-run re-arm the timer without duplicating the announcement.""" client = _siren_client() _seed_siren(client, seconds_left=0.02) messages = _capture_devices_messages(client) for _ in range(3): client._schedule_siren_off("siren1") await asyncio.sleep(0) await asyncio.sleep(0.05) assert len(messages) == 1 assert client._siren_off_tasks == {} @pytest.mark.asyncio async def test_devices_ws_reconnect_rearms_siren_expiry(): """A reconnect re-arms from the cached status without waiting on the resync.""" client = _siren_client() _seed_siren(client, seconds_left=60) client._schedule_siren_off("siren1") client._devices_ws_has_been_connected = True client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) assert client._siren_off_tasks == {} # Debounced: the reconnect resync that would otherwise re-arm is skipped. client._last_public_resync = time.monotonic() client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert set(client._siren_off_tasks) == {"siren1"} await client._cancel_siren_off_tasks() @pytest.mark.asyncio async def test_siren_expiry_skips_a_run_superseded_mid_sleep(): """A timer armed for a finished run stays silent once a later run replaces it.""" client = _siren_client() siren = _seed_siren(client, seconds_left=0.01) messages = _capture_devices_messages(client) client._schedule_siren_off("siren1") task = client._siren_off_tasks["siren1"] siren.siren_status.activated_at = to_js_time(datetime.now(UTC) + timedelta(hours=1)) await task assert messages == [] assert siren.siren_status.is_active is True uilibs-uiprotect-317a52c/tests/test_api_camera_disable_mic_public.py000066400000000000000000000036521524637705700260770ustar00rootroot00000000000000""" Tests for the Public API ``disable-mic-permanently`` camera endpoint. Mock-only: the underlying action is irreversible on real hardware, so it must never be exercised against a captured fixture or a live device. """ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import PublicCamera if TYPE_CHECKING: from uiprotect.api import ProtectApiClient CAMERA_ID = "6878d82800215803e45928e1" @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicCamera.from_unifi_dict") async def test_disable_camera_mic_permanently_public_posts_correct_url( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: mock_cam = Mock(spec=PublicCamera) mock_cam.id = CAMERA_ID mock_create.return_value = mock_cam protect_client.api_request_obj = AsyncMock(return_value={"id": CAMERA_ID}) result = await protect_client.disable_camera_mic_permanently_public(CAMERA_ID) assert result is mock_cam protect_client.api_request_obj.assert_called_once_with( url=f"/v1/cameras/{CAMERA_ID}/disable-mic-permanently", method="post", public_api=True, ) # No body is sent — ``json=`` must not be passed. assert "json" not in protect_client.api_request_obj.call_args.kwargs @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicCamera.from_unifi_dict") async def test_disable_camera_mic_permanently_public_returns_camera( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: mock_cam = Mock(spec=PublicCamera) mock_cam.id = CAMERA_ID mock_create.return_value = mock_cam protect_client.api_request_obj = AsyncMock(return_value={"id": CAMERA_ID}) result = await protect_client.disable_camera_mic_permanently_public(CAMERA_ID) assert result.id == CAMERA_ID mock_create.assert_called_once_with(id=CAMERA_ID, api=protect_client) uilibs-uiprotect-317a52c/tests/test_api_camera_public.py000066400000000000000000001134311524637705700235610ustar00rootroot00000000000000"""Tests for Camera Public API methods.""" from __future__ import annotations import datetime as dt from http import HTTPStatus from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, patch import pytest from tests.conftest import TEST_CAMERA_EXISTS, read_camera_json_file from uiprotect.data import ( Camera, OsdOverlayLocation, PublicCamera, PublicHdrMode, PublicLcdMessage, PublicPosTransactionResponse, ) from uiprotect.data.devices import LCDMessage from uiprotect.data.types import ( DoorbellMessageType, HDRMode, PercentInt, PosTransactionType, SmartDetectAudioType, SmartDetectObjectType, VideoMode, ) from uiprotect.exceptions import BadRequest from uiprotect.utils import to_js_time if TYPE_CHECKING: from collections.abc import Iterator from uiprotect.api import ProtectApiClient from uiprotect.data.websocket import WSSubscriptionMessage CAMERA_ID = "6878d82800215803e45928e1" @pytest.fixture def patched_camera_response(protect_client: ProtectApiClient) -> Iterator[Mock]: """Patch Camera.from_unifi_dict and set a minimal successful API response.""" mock_cam = Mock() mock_cam.id = CAMERA_ID with patch( "uiprotect.data.public_devices.PublicCamera.from_unifi_dict" ) as mock_create: mock_create.return_value = mock_cam protect_client.api_request_obj = AsyncMock(return_value={"id": CAMERA_ID}) yield mock_create @pytest.fixture def camera(camera_obj: Camera | None) -> Camera: """Unwrap camera_obj, skipping the test when test data is absent.""" if camera_obj is None: pytest.skip("Missing camera test data") return camera_obj # type: ignore[return-value] # ============================================================================= # GET CAMERA TESTS # ============================================================================= @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicCamera.from_unifi_dict") async def test_get_cameras_public_success( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test successful cameras retrieval from public API.""" mock_cam1 = Mock() mock_cam1.id = CAMERA_ID mock_cam2 = Mock() mock_cam2.id = "other-camera-id" mock_create.side_effect = [mock_cam1, mock_cam2] protect_client.api_request_list = AsyncMock(return_value=[{"id": "1"}, {"id": "2"}]) result = await protect_client.get_cameras_public() assert len(result) == 2 protect_client.api_request_list.assert_called_with( url="/v1/cameras", public_api=True ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicCamera.from_unifi_dict") async def test_get_camera_public_success( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test successful single camera retrieval from public API.""" mock_cam = Mock() mock_cam.id = CAMERA_ID mock_create.return_value = mock_cam protect_client.api_request_obj = AsyncMock(return_value={"id": CAMERA_ID}) result = await protect_client.get_camera_public(CAMERA_ID) assert result.id == CAMERA_ID protect_client.api_request_obj.assert_called_with( url=f"/v1/cameras/{CAMERA_ID}", public_api=True ) # ============================================================================= # UPDATE CAMERA TESTS # ============================================================================= @pytest.mark.parametrize( ("kwargs", "payload"), [ ({"name": "Front Door"}, {"name": "Front Door"}), ({"hdr_type": PublicHdrMode.AUTO}, {"hdrType": "auto"}), ({"led_is_enabled": True}, {"ledSettings": {"isEnabled": True}}), ({"led_welcome_led": False}, {"ledSettings": {"welcomeLed": False}}), ({"led_flood_led": True}, {"ledSettings": {"floodLed": True}}), ({"mic_volume": 80}, {"micVolume": 80}), ({"mic_volume": 0}, {"micVolume": 0}), ( { "osd_name_enabled": True, "osd_date_enabled": False, "osd_logo_enabled": True, "osd_nerd_mode_enabled": False, }, { "osdSettings": { "isNameEnabled": True, "isDateEnabled": False, "isLogoEnabled": True, "isDebugEnabled": False, } }, ), ( {"osd_overlay_location": OsdOverlayLocation.TOP_LEFT}, {"osdSettings": {"overlayLocation": "topLeft"}}, ), ( { "smart_detect_object_types": [ SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, ] }, {"smartDetectSettings": {"objectTypes": ["person", "vehicle"]}}, ), ( {"smart_detect_audio_types": [SmartDetectAudioType.SMOKE]}, {"smartDetectSettings": {"audioTypes": ["alrmSmoke"]}}, ), ( { "smart_detect_object_types": [SmartDetectObjectType.PERSON], "smart_detect_audio_types": [SmartDetectAudioType.SMOKE], }, { "smartDetectSettings": { "objectTypes": ["person"], "audioTypes": ["alrmSmoke"], } }, ), ( {"lcd_message": {"type": "CUSTOM_MESSAGE", "text": "Hello"}}, {"lcdMessage": {"type": "CUSTOM_MESSAGE", "text": "Hello"}}, ), ( {"lcd_message": {"type": "DO_NOT_DISTURB", "resetAt": 0}}, {"lcdMessage": {"type": "DO_NOT_DISTURB", "resetAt": 0}}, ), ], ) @pytest.mark.asyncio() async def test_update_camera_public_payloads( protect_client: ProtectApiClient, patched_camera_response: Mock, kwargs: dict[str, Any], payload: dict[str, Any], ) -> None: await protect_client.update_camera_public(CAMERA_ID, **kwargs) protect_client.api_request_obj.assert_called_once_with( url=f"/v1/cameras/{CAMERA_ID}", method="patch", json=payload, public_api=True, ) patched_camera_response.assert_called_once_with(id=CAMERA_ID, api=protect_client) @pytest.mark.asyncio() async def test_update_camera_public_mic_volume_out_of_range( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest, match="mic_volume must be between 0 and 100"): await protect_client.update_camera_public(CAMERA_ID, mic_volume=101) with pytest.raises(BadRequest, match="mic_volume must be between 0 and 100"): await protect_client.update_camera_public(CAMERA_ID, mic_volume=-1) @pytest.mark.asyncio() async def test_update_camera_public_no_parameters( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest, match="At least one parameter must be provided"): await protect_client.update_camera_public(CAMERA_ID) @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_update_camera_public_drops_private_only_fields( protect_client_no_debug: ProtectApiClient, ) -> None: """PublicCamera keeps only spec fields; private-only keys are dropped, not stored.""" camera_data = read_camera_json_file() protect_client_no_debug.api_request_obj = AsyncMock(return_value=camera_data) result = await protect_client_no_debug.update_camera_public(CAMERA_ID, name="Test") assert isinstance(result, PublicCamera) assert "recording_settings" not in PublicCamera.model_fields assert "isp_settings" not in PublicCamera.model_fields assert "channels" not in PublicCamera.model_fields protect_client_no_debug.api_request_obj.assert_called_once_with( url=f"/v1/cameras/{CAMERA_ID}", method="patch", json={"name": "Test"}, public_api=True, ) @pytest.mark.asyncio() async def test_update_camera_public_parses_public_response_schema( protect_client_no_debug: ProtectApiClient, ) -> None: """ PublicCamera.from_unifi_dict parses a payload matching the public API schema (integration.json #/components/schemas/camera), including a ``null`` name. Must run in no-debug mode: production uses model_construct (no validation); debug mode uses the strict constructor which rejects partial schemas. """ public_response = { "id": CAMERA_ID, "modelKey": "camera", "state": "CONNECTED", "name": "Test Camera", "mac": "AABBCCDDEEFF", "isMicEnabled": True, "osdSettings": { "isNameEnabled": True, "isDateEnabled": True, "isLogoEnabled": False, "isDebugEnabled": False, "overlayLocation": "topLeft", }, "ledSettings": {"isEnabled": True, "welcomeLed": None, "floodLed": None}, "lcdMessage": {}, "micVolume": 100, "activePatrolSlot": None, "videoMode": "default", "hdrType": "auto", "featureFlags": { "supportFullHdSnapshot": False, "hasHdr": True, "hasMic": True, "hasLedStatus": True, "hasSpeaker": False, "videoModes": ["default"], "smartDetectTypes": ["person"], "smartDetectAudioTypes": ["alrmSmoke"], }, "smartDetectSettings": {"objectTypes": ["person"], "audioTypes": ["alrmSmoke"]}, "hasPackageCamera": False, } protect_client_no_debug.api_request_obj = AsyncMock(return_value=public_response) result = await protect_client_no_debug.update_camera_public(CAMERA_ID, name="Test") assert isinstance(result, PublicCamera) assert result.name == "Test Camera" assert result.osd_settings.overlay_location == OsdOverlayLocation.TOP_LEFT assert result.led_settings.welcome_led is None assert result.led_settings.flood_led is None public_response["name"] = None protect_client_no_debug.api_request_obj = AsyncMock(return_value=public_response) result_null = await protect_client_no_debug.update_camera_public( CAMERA_ID, name="x" ) assert isinstance(result_null, PublicCamera) assert result_null.name is None @pytest.mark.asyncio() async def test_update_camera_public_audio_types_passed_through( protect_client: ProtectApiClient, patched_camera_response: Mock, ) -> None: """Audio types are passed through as-is; SMOKE_CMONX is not a valid public API value.""" await protect_client.update_camera_public( CAMERA_ID, smart_detect_audio_types=[ SmartDetectAudioType.SMOKE, SmartDetectAudioType.CMONX, ], ) protect_client.api_request_obj.assert_called_once_with( url=f"/v1/cameras/{CAMERA_ID}", method="patch", json={"smartDetectSettings": {"audioTypes": ["alrmSmoke", "alrmCmonx"]}}, public_api=True, ) # ============================================================================= # CAMERA DEVICE METHOD TESTS # ============================================================================= def _led_updated_mock(camera_obj: Camera) -> Mock: # type: ignore[type-arg] m = Mock() m.led_settings = camera_obj.led_settings.model_copy( update={"is_enabled": not camera_obj.led_settings.is_enabled} ) return m def _hdr_updated_mock(camera_obj: Camera, hdr_on: bool) -> Mock: # type: ignore[type-arg] m = Mock() m.hdr_mode = hdr_on m.isp_settings.hdr_mode = HDRMode.NORMAL return m def _video_updated_mock(mode: VideoMode) -> Mock: # type: ignore[type-arg] m = Mock() m.video_mode = mode return m def _mic_updated_mock(level: int) -> Mock: # type: ignore[type-arg] m = Mock() m.mic_volume = PercentInt(level) return m def _osd_updated_mock(camera_obj: Camera) -> Mock: # type: ignore[type-arg] m = Mock() m.osd_settings = camera_obj.osd_settings.model_copy( update={"is_name_enabled": not camera_obj.osd_settings.is_name_enabled} ) return m @pytest.mark.asyncio() async def test_camera_set_name_public(camera: Camera) -> None: updated = Mock() updated.name = "Front Door" camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_name_public("Front Door") camera._api.update_camera_public.assert_called_once_with( camera.id, name="Front Door" ) assert camera.name == "Front Door" # --- LED --- @pytest.mark.asyncio() async def test_camera_set_status_light_public(camera: Camera) -> None: camera.feature_flags.has_led_status = True camera.use_global = False updated = _led_updated_mock(camera) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_status_light_public(True) camera._api.update_camera_public.assert_called_once_with( camera.id, led_is_enabled=True ) assert camera.led_settings == updated.led_settings @pytest.mark.asyncio() async def test_camera_set_welcome_led_public(camera: Camera) -> None: camera.feature_flags.has_led_status = True camera.led_settings.welcome_led = False updated = _led_updated_mock(camera) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_welcome_led_public(True) camera._api.update_camera_public.assert_called_once_with( camera.id, led_welcome_led=True ) assert camera.led_settings == updated.led_settings @pytest.mark.asyncio() async def test_camera_set_welcome_led_public_not_supported(camera: Camera) -> None: camera.feature_flags.has_led_status = True camera.led_settings.welcome_led = None with pytest.raises(BadRequest, match="does not have welcome LED"): await camera.set_welcome_led_public(True) @pytest.mark.asyncio() async def test_camera_set_flood_led_public(camera: Camera) -> None: camera.feature_flags.has_led_status = True camera.led_settings.flood_led = False updated = _led_updated_mock(camera) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_flood_led_public(True) camera._api.update_camera_public.assert_called_once_with( camera.id, led_flood_led=True ) assert camera.led_settings == updated.led_settings @pytest.mark.asyncio() async def test_camera_set_flood_led_public_not_supported(camera: Camera) -> None: camera.feature_flags.has_led_status = True camera.led_settings.flood_led = None with pytest.raises(BadRequest, match="does not have flood LED"): await camera.set_flood_led_public(True) @pytest.mark.parametrize( "method", ["set_status_light_public", "set_welcome_led_public", "set_flood_led_public"], ) @pytest.mark.asyncio() async def test_camera_set_led_public_no_led_status(camera: Camera, method: str) -> None: camera.feature_flags.has_led_status = False with pytest.raises(BadRequest, match="does not have status light"): await getattr(camera, method)(True) # --- HDR --- @pytest.mark.parametrize( ("mode", "hdr_on"), [ (PublicHdrMode.AUTO, True), (PublicHdrMode.ON, True), (PublicHdrMode.OFF, False), ], ) @pytest.mark.asyncio() async def test_camera_set_hdr_mode_public( camera: Camera, mode: PublicHdrMode, hdr_on: bool ) -> None: camera.feature_flags.has_hdr = True camera._api.update_camera_public = AsyncMock( return_value=_hdr_updated_mock(camera, hdr_on) ) await camera.set_hdr_mode_public(mode) camera._api.update_camera_public.assert_called_once_with(camera.id, hdr_type=mode) assert camera.hdr_mode == hdr_on @pytest.mark.asyncio() async def test_camera_set_hdr_mode_public_no_hdr(camera: Camera) -> None: camera.feature_flags.has_hdr = False with pytest.raises(BadRequest, match="does not have HDR"): await camera.set_hdr_mode_public(PublicHdrMode.ON) @pytest.mark.asyncio() async def test_camera_set_hdr_mode_public_updates_isp_settings(camera: Camera) -> None: """isp_settings.hdr_mode is updated when it is not None.""" camera.feature_flags.has_hdr = True camera.isp_settings.hdr_mode = HDRMode.NORMAL camera._api.update_camera_public = AsyncMock( return_value=_hdr_updated_mock(camera, hdr_on=True) ) await camera.set_hdr_mode_public(PublicHdrMode.ON) assert camera.hdr_mode is True assert camera.isp_settings.hdr_mode == HDRMode.ALWAYS_ON # --- Video mode --- @pytest.mark.asyncio() async def test_camera_set_video_mode_public(camera: Camera) -> None: camera.feature_flags.video_modes = [VideoMode.DEFAULT] updated = _video_updated_mock(VideoMode.DEFAULT) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_video_mode_public(VideoMode.DEFAULT) camera._api.update_camera_public.assert_called_once_with( camera.id, video_mode=VideoMode.DEFAULT.value ) assert camera.video_mode == VideoMode.DEFAULT @pytest.mark.asyncio() async def test_camera_set_video_mode_public_unsupported(camera: Camera) -> None: camera.feature_flags.video_modes = [] with pytest.raises(BadRequest, match="Camera does not have"): await camera.set_video_mode_public(VideoMode.HIGH_FPS) # --- Mic volume --- @pytest.mark.asyncio() async def test_camera_set_mic_volume_public(camera: Camera) -> None: camera.feature_flags.has_mic = True updated = _mic_updated_mock(75) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_mic_volume_public(75) camera._api.update_camera_public.assert_called_once_with(camera.id, mic_volume=75) assert camera.mic_volume == 75 @pytest.mark.asyncio() async def test_camera_set_mic_volume_public_mute(camera: Camera) -> None: camera.feature_flags.has_mic = True updated = _mic_updated_mock(0) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_mic_volume_public(0) camera._api.update_camera_public.assert_called_once_with(camera.id, mic_volume=0) assert camera.mic_volume == 0 @pytest.mark.asyncio() async def test_camera_set_mic_volume_public_no_mic(camera: Camera) -> None: camera.feature_flags.has_mic = False with pytest.raises(BadRequest, match="does not have mic"): await camera.set_mic_volume_public(50) @pytest.mark.asyncio() async def test_camera_set_mic_volume_public_coerces_float(camera: Camera) -> None: camera.feature_flags.has_mic = True updated = _mic_updated_mock(75) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_mic_volume_public(75.0) camera._api.update_camera_public.assert_called_once_with(camera.id, mic_volume=75) sent = camera._api.update_camera_public.call_args.kwargs["mic_volume"] assert isinstance(sent, int) @pytest.mark.parametrize("level", [-1, 101, 150]) @pytest.mark.asyncio() async def test_camera_set_mic_volume_public_out_of_range( camera: Camera, level: int ) -> None: camera.feature_flags.has_mic = True camera._api.update_camera_public = AsyncMock() with pytest.raises(BadRequest, match="mic_volume must be between 0 and 100"): await camera.set_mic_volume_public(level) assert not camera._api.update_camera_public.called # --- OSD --- @pytest.mark.parametrize( ("method", "kwarg"), [ ("set_osd_name_public", "osd_name_enabled"), ("set_osd_date_public", "osd_date_enabled"), ("set_osd_logo_public", "osd_logo_enabled"), ("set_osd_nerd_mode_public", "osd_nerd_mode_enabled"), ], ) @pytest.mark.asyncio() async def test_camera_set_osd_public(camera: Camera, method: str, kwarg: str) -> None: camera.use_global = False updated = _osd_updated_mock(camera) camera._api.update_camera_public = AsyncMock(return_value=updated) await getattr(camera, method)(True) camera._api.update_camera_public.assert_called_once_with(camera.id, **{kwarg: True}) assert camera.osd_settings == updated.osd_settings @pytest.mark.asyncio() async def test_camera_set_osd_overlay_location_public(camera: Camera) -> None: camera.use_global = False updated = _osd_updated_mock(camera) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_osd_overlay_location_public(OsdOverlayLocation.TOP_LEFT) camera._api.update_camera_public.assert_called_once_with( camera.id, osd_overlay_location=OsdOverlayLocation.TOP_LEFT ) assert camera.osd_settings == updated.osd_settings @pytest.mark.parametrize( ("method", "arg"), [ ("set_osd_name_public", True), ("set_osd_date_public", True), ("set_osd_logo_public", True), ("set_osd_nerd_mode_public", True), ("set_osd_overlay_location_public", OsdOverlayLocation.TOP_LEFT), ], ) @pytest.mark.asyncio() async def test_camera_set_osd_public_use_global( camera: Camera, method: str, arg: Any ) -> None: camera.use_global = True with pytest.raises(BadRequest, match="global recording settings"): await getattr(camera, method)(arg) # --- LCD message --- @pytest.mark.parametrize( ("text_type", "text", "expected_payload"), [ ( DoorbellMessageType.CUSTOM_MESSAGE, "Hi", {"type": "CUSTOM_MESSAGE", "text": "Hi"}, ), ( DoorbellMessageType.IMAGE, "abc123.png", {"type": "IMAGE", "text": "abc123.png"}, ), ( DoorbellMessageType.DO_NOT_DISTURB, None, {"type": "DO_NOT_DISTURB"}, ), ( DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR, None, {"type": "LEAVE_PACKAGE_AT_DOOR"}, ), ], ) @pytest.mark.asyncio() async def test_camera_set_lcd_message_public( camera: Camera, text_type: DoorbellMessageType, text: str | None, expected_payload: dict[str, str], ) -> None: camera.feature_flags.has_lcd_screen = True updated = Mock() updated.lcd_message = None camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_lcd_message_public(text_type, text=text) camera._api.update_camera_public.assert_called_once_with( camera.id, lcd_message=expected_payload ) assert camera.lcd_message == updated.lcd_message @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_populated_response(camera: Camera) -> None: """A populated PublicLcdMessage response rebuilds a private LCDMessage.""" camera.feature_flags.has_lcd_screen = True updated = Mock() updated.lcd_message = PublicLcdMessage( type="CUSTOM_MESSAGE", text="Hello", reset_at=1_700_000_000_000 ) camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_lcd_message_public( DoorbellMessageType.CUSTOM_MESSAGE, text="Hello" ) assert isinstance(camera.lcd_message, LCDMessage) assert camera.lcd_message.type == DoorbellMessageType.CUSTOM_MESSAGE assert camera.lcd_message.text == "Hello" assert isinstance(camera.lcd_message.reset_at, dt.datetime) @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_with_reset_at(camera: Camera) -> None: camera.feature_flags.has_lcd_screen = True updated = Mock() updated.lcd_message = None camera._api.update_camera_public = AsyncMock(return_value=updated) reset = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.UTC) await camera.set_lcd_message_public( DoorbellMessageType.DO_NOT_DISTURB, reset_at=reset ) call_kwargs = camera._api.update_camera_public.call_args assert "resetAt" in call_kwargs.kwargs["lcd_message"] @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_reset_at_none(camera: Camera) -> None: camera.feature_flags.has_lcd_screen = True updated = Mock() updated.lcd_message = None camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_lcd_message_public( DoorbellMessageType.DO_NOT_DISTURB, reset_at=None ) call_kwargs = camera._api.update_camera_public.call_args assert call_kwargs.kwargs["lcd_message"]["resetAt"] is None @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_clear(camera: Camera) -> None: """A ``None`` text type clears the message and the local state.""" camera.feature_flags.has_lcd_screen = True camera.lcd_message = LCDMessage.from_unifi_dict( type="CUSTOM_MESSAGE", text="Hello", resetAt=None, api=camera._api ) updated = Mock() updated.lcd_message = PublicLcdMessage() camera._api.update_camera_public = AsyncMock(return_value=updated) messages: list[WSSubscriptionMessage] = [] unsub = camera._api.subscribe_websocket(messages.append) try: await camera.set_lcd_message_public(None) finally: unsub() camera._api.update_camera_public.assert_called_once_with( camera.id, lcd_message={"type": DoorbellMessageType.DO_NOT_DISTURB, "resetAt": 0}, ) assert camera.lcd_message is None # Clearing emits no WS message of its own, so one is faked. assert len(messages) == 1 assert messages[0].new_obj.lcd_message is None @pytest.mark.parametrize( ("kwargs"), [ {"text": "oops"}, {"reset_at": None}, {"reset_at": dt.datetime(2026, 1, 1, tzinfo=dt.UTC)}, ], ) @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_clear_validation( camera: Camera, kwargs: dict[str, Any] ) -> None: camera.feature_flags.has_lcd_screen = True with pytest.raises(BadRequest, match="Clearing the LCD message"): await camera.set_lcd_message_public(None, **kwargs) @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_clear_detached(camera: Camera) -> None: """Clearing updates the local state even off the bootstrap's own instance.""" camera.feature_flags.has_lcd_screen = True camera.lcd_message = LCDMessage.from_unifi_dict( type="CUSTOM_MESSAGE", text="Hello", resetAt=None, api=camera._api ) # The faked frame is routed through the bootstrap, so it never reaches an # instance the bootstrap no longer holds. camera._api.bootstrap.cameras.pop(camera.id, None) updated = Mock() updated.lcd_message = PublicLcdMessage() camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_lcd_message_public(None) assert camera.lcd_message is None @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_no_lcd(camera: Camera) -> None: camera.feature_flags.has_lcd_screen = False with pytest.raises(BadRequest, match="does not have an LCD screen"): await camera.set_lcd_message_public( DoorbellMessageType.CUSTOM_MESSAGE, text="x" ) @pytest.mark.parametrize( ("text_type", "text", "match"), [ (DoorbellMessageType.CUSTOM_MESSAGE, None, "CUSTOM_MESSAGE requires text"), (DoorbellMessageType.IMAGE, None, "IMAGE requires text"), ( DoorbellMessageType.DO_NOT_DISTURB, "oops", "DO_NOT_DISTURB does not accept text", ), ( DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR, "oops", "LEAVE_PACKAGE_AT_DOOR does not accept text", ), ], ) @pytest.mark.asyncio() async def test_camera_set_lcd_message_public_validation( camera: Camera, text_type: DoorbellMessageType, text: str | None, match: str ) -> None: camera.feature_flags.has_lcd_screen = True with pytest.raises(BadRequest, match=match): await camera.set_lcd_message_public(text_type, text=text) # --- Smart detect object types --- @pytest.mark.parametrize( ("method", "obj_type"), [ ("set_person_detection_public", SmartDetectObjectType.PERSON), ("set_vehicle_detection_public", SmartDetectObjectType.VEHICLE), ("set_package_detection_public", SmartDetectObjectType.PACKAGE), ("set_animal_detection_public", SmartDetectObjectType.ANIMAL), ("set_face_detection_public", SmartDetectObjectType.FACE), ("set_license_plate_detection_public", SmartDetectObjectType.LICENSE_PLATE), ], ) @pytest.mark.asyncio() async def test_camera_set_object_detection_public_enable( camera: Camera, method: str, obj_type: SmartDetectObjectType ) -> None: camera.feature_flags.has_smart_detect = True camera.use_global = False camera.feature_flags.smart_detect_types = [obj_type] camera.smart_detect_settings.object_types = [] updated = Mock() updated.smart_detect_settings.object_types = [obj_type] camera._api.update_camera_public = AsyncMock(return_value=updated) await getattr(camera, method)(True) camera._api.update_camera_public.assert_called_once_with( camera.id, smart_detect_object_types=[obj_type] ) assert camera.smart_detect_settings.object_types == [obj_type] @pytest.mark.asyncio() async def test_camera_set_object_detection_public_disable(camera: Camera) -> None: camera.feature_flags.has_smart_detect = True camera.use_global = False camera.feature_flags.smart_detect_types = [SmartDetectObjectType.PERSON] camera.smart_detect_settings.object_types = [SmartDetectObjectType.PERSON] updated = Mock() updated.smart_detect_settings.object_types = [] camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_person_detection_public(False) camera._api.update_camera_public.assert_called_once_with( camera.id, smart_detect_object_types=[] ) assert camera.smart_detect_settings.object_types == [] @pytest.mark.asyncio() async def test_camera_set_object_detection_public_unsupported(camera: Camera) -> None: camera.feature_flags.smart_detect_types = [] with pytest.raises(BadRequest, match="does not support"): await camera.set_person_detection_public(True) @pytest.mark.asyncio() async def test_camera_set_object_detection_public_consecutive_calls( camera: Camera, ) -> None: """Consecutive enable calls must not clobber each other's state.""" camera.feature_flags.has_smart_detect = True camera.use_global = False camera.feature_flags.smart_detect_types = [ SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, ] camera.smart_detect_settings.object_types = [] after_person = Mock() after_person.smart_detect_settings.object_types = [SmartDetectObjectType.PERSON] after_vehicle = Mock() after_vehicle.smart_detect_settings.object_types = [ SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, ] camera._api.update_camera_public = AsyncMock( side_effect=[after_person, after_vehicle] ) await camera.set_person_detection_public(True) await camera.set_vehicle_detection_public(True) calls = camera._api.update_camera_public.call_args_list assert calls[0] == ( (camera.id,), {"smart_detect_object_types": [SmartDetectObjectType.PERSON]}, ) assert calls[1] == ( (camera.id,), { "smart_detect_object_types": [ SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, ] }, ) # --- Smart detect audio types --- @pytest.mark.parametrize( ("method", "audio_type"), [ ("set_smoke_detection_public", SmartDetectAudioType.SMOKE), ("set_co_detection_public", SmartDetectAudioType.CMONX), ("set_siren_detection_public", SmartDetectAudioType.SIREN), ("set_baby_cry_detection_public", SmartDetectAudioType.BABY_CRY), ("set_speaking_detection_public", SmartDetectAudioType.SPEAK), ("set_bark_detection_public", SmartDetectAudioType.BARK), ("set_burglar_detection_public", SmartDetectAudioType.BURGLAR), ("set_car_horn_detection_public", SmartDetectAudioType.CAR_HORN), ("set_glass_break_detection_public", SmartDetectAudioType.GLASS_BREAK), ], ) @pytest.mark.asyncio() async def test_camera_set_audio_detection_public_enable( camera: Camera, method: str, audio_type: SmartDetectAudioType ) -> None: camera.feature_flags.has_smart_detect = True camera.use_global = False camera.feature_flags.smart_detect_audio_types = [audio_type] camera.smart_detect_settings.audio_types = [] updated = Mock() updated.smart_detect_settings.audio_types = [audio_type] camera._api.update_camera_public = AsyncMock(return_value=updated) await getattr(camera, method)(True) camera._api.update_camera_public.assert_called_once_with( camera.id, smart_detect_audio_types=[audio_type] ) assert camera.smart_detect_settings.audio_types == [audio_type] @pytest.mark.asyncio() async def test_camera_set_audio_detection_public_disable(camera: Camera) -> None: camera.feature_flags.has_smart_detect = True camera.use_global = False camera.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE] camera.smart_detect_settings.audio_types = [SmartDetectAudioType.SMOKE] updated = Mock() updated.smart_detect_settings.audio_types = [] camera._api.update_camera_public = AsyncMock(return_value=updated) await camera.set_smoke_detection_public(False) camera._api.update_camera_public.assert_called_once_with( camera.id, smart_detect_audio_types=[] ) assert camera.smart_detect_settings.audio_types == [] @pytest.mark.asyncio() async def test_camera_set_audio_detection_public_unsupported(camera: Camera) -> None: camera.feature_flags.has_smart_detect = True camera.use_global = False camera.feature_flags.smart_detect_audio_types = None with pytest.raises(BadRequest, match="does not support"): await camera.set_smoke_detection_public(True) # --- Smart detect guards --- @pytest.mark.parametrize( "method", ["set_person_detection_public", "set_smoke_detection_public"], ) @pytest.mark.asyncio() async def test_camera_set_detection_public_no_smart_detect( camera: Camera, method: str ) -> None: camera.feature_flags.has_smart_detect = False with pytest.raises(BadRequest, match="does not have smart detections"): await getattr(camera, method)(True) @pytest.mark.parametrize( "method", ["set_person_detection_public", "set_smoke_detection_public"], ) @pytest.mark.asyncio() async def test_camera_set_detection_public_use_global( camera: Camera, method: str ) -> None: camera.feature_flags.has_smart_detect = True camera.use_global = True with pytest.raises(BadRequest, match="global recording settings"): await getattr(camera, method)(True) # ============================================================================= # POS TRANSACTION TESTS # ============================================================================= @pytest.mark.asyncio() async def test_create_pos_transaction_public_minimal_body( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_obj = AsyncMock(return_value={"created": True}) result = await protect_client.create_pos_transaction_public( CAMERA_ID, type=PosTransactionType.SALE, external_id="txn-1", amount=12.5, ) assert isinstance(result, PublicPosTransactionResponse) assert result.created is True assert result.event_id is None protect_client.api_request_obj.assert_called_once_with( url=f"/v1/pos/cameras/{CAMERA_ID}/transactions", method="post", json={"type": "sale", "externalId": "txn-1", "amount": 12.5}, public_api=True, ) @pytest.mark.asyncio() async def test_create_pos_transaction_public_full_body( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_obj = AsyncMock( return_value={"created": True, "eventId": "evt-1"} ) # The server rejects a timestamp older than 24 hours, so pin one that is # always inside the window. timestamp = to_js_time(dt.datetime.now(tz=dt.UTC)) result = await protect_client.create_pos_transaction_public( CAMERA_ID, type=PosTransactionType.REFUND, external_id="txn-2", amount=12.5, currency="USD", line_items=[{"title": "Coffee", "quantity": 2}], location={"id": "reg-1", "name": "Register 1"}, payment_types=["card"], timestamp=timestamp, ) assert result.created is True assert result.event_id == "evt-1" _args, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "type": "refund", "externalId": "txn-2", "amount": 12.5, "currency": "USD", "lineItems": [{"title": "Coffee", "quantity": 2}], "location": {"id": "reg-1", "name": "Register 1"}, "paymentTypes": ["card"], "timestamp": timestamp, } @pytest.mark.asyncio() async def test_create_pos_transaction_public_duplicate_not_created( protect_client: ProtectApiClient, ) -> None: """A repeated ``external_id`` echoes the already-ingested event id.""" protect_client.api_request_obj = AsyncMock( return_value={"created": False, "eventId": "evt-1"} ) result = await protect_client.create_pos_transaction_public( CAMERA_ID, type=PosTransactionType.SALE, external_id="txn-1", amount=12.5, ) assert result.created is False assert result.event_id == "evt-1" @pytest.mark.asyncio() async def test_create_pos_transaction_public_conflict_raises( protect_client: ProtectApiClient, ) -> None: """The server's documented 409 on a concurrent repeat surfaces as ``BadRequest``.""" response = Mock() response.status = HTTPStatus.CONFLICT.value response.url = "https://test.com/v1/pos/cameras/x/transactions" with ( patch("uiprotect.api.get_response_reason", return_value="Conflict"), pytest.raises(BadRequest), ): await protect_client._raise_for_status(response, raise_exception=True) protect_client.api_request_obj = AsyncMock( side_effect=BadRequest("Request failed: 409 - Conflict") ) with pytest.raises(BadRequest, match="409"): await protect_client.create_pos_transaction_public( CAMERA_ID, type=PosTransactionType.SALE, external_id="txn-1", amount=12.5, ) uilibs-uiprotect-317a52c/tests/test_api_chime_public.py000066400000000000000000000652671524637705700234330ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, dict-item, assignment, union-attr, method-assign" """Tests for Chime Public API methods.""" from __future__ import annotations import warnings from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, patch import pytest from pydantic import ValidationError from tests.conftest import TEST_CAMERA_EXISTS, TEST_CHIME_EXISTS from uiprotect.data import PublicRingSettings, RingSetting from uiprotect.exceptions import BadRequest if TYPE_CHECKING: from uiprotect.api import ProtectApiClient, PublicApiChimeRingSettingRequest from uiprotect.data import Camera, Chime CHIME_ID = "6878d82800155803e45928e0" CAMERA_ID = "6878d82800215803e45928e1" RINGTONE_ID = "67ececbd02fe9603e40003f0" # ============================================================================= # GET CHIMES TESTS # ============================================================================= @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_get_chimes_public_success( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test successful chimes retrieval from public API.""" mock_chime1 = Mock() mock_chime1.id = CHIME_ID mock_chime1.name = "Chime 1" mock_chime2 = Mock() mock_chime2.id = "other-chime-id" mock_chime2.name = "Chime 2" mock_create.side_effect = [mock_chime1, mock_chime2] protect_client.api_request_list = AsyncMock(return_value=[{"id": "1"}, {"id": "2"}]) result = await protect_client.get_chimes_public() assert result is not None assert len(result) == 2 assert result[0].id == CHIME_ID assert result[1].id == "other-chime-id" protect_client.api_request_list.assert_called_with( url="/v1/chimes", public_api=True ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_get_chime_public_success( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test successful single chime retrieval from public API.""" mock_chime = Mock() mock_chime.id = CHIME_ID mock_chime.name = "Smart PoE Chime" mock_create.return_value = mock_chime protect_client.api_request_obj = AsyncMock(return_value={"id": CHIME_ID}) result = await protect_client.get_chime_public(CHIME_ID) assert result is not None assert result.id == CHIME_ID protect_client.api_request_obj.assert_called_with( url=f"/v1/chimes/{CHIME_ID}", public_api=True ) # ============================================================================= # UPDATE CHIME TESTS # ============================================================================= @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_update_chime_public_name_only( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating only the name of a chime via public API.""" new_name = "Updated Chime Name" mock_chime = Mock() mock_chime.id = CHIME_ID mock_chime.name = new_name mock_create.return_value = mock_chime mock_chime_data: dict[str, Any] = {"id": CHIME_ID, "name": new_name} protect_client.api_request_obj = AsyncMock(return_value=mock_chime_data) result = await protect_client.update_chime_public(CHIME_ID, name=new_name) assert result is not None assert result.name == new_name protect_client.api_request_obj.assert_called_once_with( url=f"/v1/chimes/{CHIME_ID}", method="patch", json={"name": new_name}, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_update_chime_public_camera_ids( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating camera_ids via public API.""" camera_ids = [CAMERA_ID, "camera-2"] mock_chime = Mock() mock_chime.id = CHIME_ID mock_create.return_value = mock_chime mock_chime_data: dict[str, Any] = {"id": CHIME_ID, "cameraIds": camera_ids} protect_client.api_request_obj = AsyncMock(return_value=mock_chime_data) result = await protect_client.update_chime_public(CHIME_ID, camera_ids=camera_ids) assert result is not None protect_client.api_request_obj.assert_called_once_with( url=f"/v1/chimes/{CHIME_ID}", method="patch", json={"cameraIds": camera_ids}, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_update_chime_public_ring_settings( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating ring_settings via public API.""" ring_settings: list[PublicApiChimeRingSettingRequest] = [ { "cameraId": CAMERA_ID, "volume": 80, "repeatTimes": 2, "ringtoneId": RINGTONE_ID, } ] mock_chime = Mock() mock_chime.id = CHIME_ID mock_create.return_value = mock_chime mock_chime_data: dict[str, Any] = {"id": CHIME_ID, "ringSettings": ring_settings} protect_client.api_request_obj = AsyncMock(return_value=mock_chime_data) result = await protect_client.update_chime_public( CHIME_ID, ring_settings=ring_settings ) assert result is not None protect_client.api_request_obj.assert_called_once_with( url=f"/v1/chimes/{CHIME_ID}", method="patch", json={"ringSettings": ring_settings}, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicChime.from_unifi_dict") async def test_update_chime_public_all_parameters( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating all chime parameters via public API.""" new_name = "Full Update Chime" camera_ids = [CAMERA_ID] ring_settings: list[PublicApiChimeRingSettingRequest] = [ { "cameraId": CAMERA_ID, "volume": 50, "repeatTimes": 3, "ringtoneId": RINGTONE_ID, } ] mock_chime = Mock() mock_chime.id = CHIME_ID mock_chime.name = new_name mock_create.return_value = mock_chime mock_chime_data: dict[str, Any] = { "id": CHIME_ID, "name": new_name, "cameraIds": camera_ids, "ringSettings": ring_settings, } protect_client.api_request_obj = AsyncMock(return_value=mock_chime_data) result = await protect_client.update_chime_public( CHIME_ID, name=new_name, camera_ids=camera_ids, ring_settings=ring_settings, ) assert result is not None protect_client.api_request_obj.assert_called_once_with( url=f"/v1/chimes/{CHIME_ID}", method="patch", json={ "name": new_name, "cameraIds": camera_ids, "ringSettings": ring_settings, }, public_api=True, ) @pytest.mark.asyncio() async def test_update_chime_public_no_parameters( protect_client: ProtectApiClient, ) -> None: """Test that update_chime_public raises BadRequest without parameters.""" with pytest.raises(BadRequest, match="At least one parameter must be provided"): await protect_client.update_chime_public(CHIME_ID) # ============================================================================= # CHIME DEVICE METHOD TESTS (set_ring_settings_public, set_volume_for_camera_public) # ============================================================================= @pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_chime_set_name_public(chime_obj: Chime | None) -> None: """Chime rename writes through the public API and updates local state.""" if chime_obj is None: pytest.skip("No chime_obj found") updated_chime = Mock() updated_chime.name = "Hallway" chime_obj.api.update_chime_public = AsyncMock(return_value=updated_chime) await chime_obj.set_name_public("Hallway") chime_obj.api.update_chime_public.assert_called_once_with( chime_obj.id, name="Hallway", ) assert chime_obj.name == "Hallway" @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_set_ring_settings_public( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test set_ring_settings_public updates ring settings via public API.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") # Setup initial state chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=1, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] # Mock the API response updated_chime = Mock() updated_chime.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=3, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=80, ), ] chime_obj.api.update_chime_public = AsyncMock(return_value=updated_chime) # Call method ring_settings_update = [ { "cameraId": camera_obj.id, "volume": 80, "repeatTimes": 3, "ringtoneId": RINGTONE_ID, } ] await chime_obj.set_ring_settings_public(ring_settings_update) # Verify API was called correctly chime_obj.api.update_chime_public.assert_called_once_with( chime_obj.id, ring_settings=ring_settings_update, ) # Verify local state was updated assert chime_obj.ring_settings[0].volume == 80 assert chime_obj.ring_settings[0].repeat_times == 3 @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_chime_set_ring_settings_public_rebuilds_from_public( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """A PublicRingSettings response rebuilds the private RingSetting list.""" if chime_obj is None or camera_obj is None: pytest.skip("Missing test data") updated_chime = Mock() updated_chime.ring_settings = [ PublicRingSettings( camera_id=camera_obj.id, repeat_times=3, ringtone_id=RINGTONE_ID, volume=80, ) ] chime_obj.api.update_chime_public = AsyncMock(return_value=updated_chime) await chime_obj.set_ring_settings_public( [{"cameraId": camera_obj.id, "volume": 80, "repeatTimes": 3}] ) assert isinstance(chime_obj.ring_settings[0], RingSetting) assert chime_obj.ring_settings[0].camera_id == camera_obj.id assert chime_obj.ring_settings[0].volume == 80 assert chime_obj.ring_settings[0].repeat_times == 3 @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_public( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test set_volume_for_camera_public sets volume for specific camera.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") # Setup initial state chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=2, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] # Mock the API response updated_chime = Mock() updated_chime.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=2, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=80, ), ] chime_obj.api.update_chime_public = AsyncMock(return_value=updated_chime) # Call method await chime_obj.set_volume_for_camera_public(camera_obj, 80) # Verify API was called with correct ring_settings chime_obj.api.update_chime_public.assert_called_once() call_args = chime_obj.api.update_chime_public.call_args assert call_args[0][0] == chime_obj.id ring_settings = call_args[1]["ring_settings"] assert len(ring_settings) == 1 assert ring_settings[0]["cameraId"] == camera_obj.id assert ring_settings[0]["volume"] == 80 assert ring_settings[0]["repeatTimes"] == 2 assert ring_settings[0]["ringtoneId"] == RINGTONE_ID @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_public_not_paired( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test set_volume_for_camera_public raises BadRequest for unpaired camera.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") # Setup with different camera chime_obj.ring_settings = [ RingSetting( camera_id="other-camera-id", repeat_times=1, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] with pytest.raises(BadRequest): await chime_obj.set_volume_for_camera_public(camera_obj, 80) @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_public_multiple_cameras( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test set_volume_for_camera_public preserves settings for other cameras.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") other_camera_id = "other-doorbell-camera" # Setup with multiple cameras chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=2, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), RingSetting( camera_id=other_camera_id, repeat_times=1, # type: ignore[arg-type] ringtone_id="other-ringtone", volume=70, ), ] # Mock the API response updated_chime = Mock() updated_chime.ring_settings = chime_obj.ring_settings chime_obj.api.update_chime_public = AsyncMock(return_value=updated_chime) # Call method for first camera await chime_obj.set_volume_for_camera_public(camera_obj, 90) # Verify both cameras included in API call call_args = chime_obj.api.update_chime_public.call_args ring_settings = call_args[1]["ring_settings"] assert len(ring_settings) == 2 # Find the settings for each camera target_setting = next(s for s in ring_settings if s["cameraId"] == camera_obj.id) other_setting = next(s for s in ring_settings if s["cameraId"] == other_camera_id) # Target camera should have new volume assert target_setting["volume"] == 90 assert target_setting["repeatTimes"] == 2 # Other camera should be unchanged assert other_setting["volume"] == 70 assert other_setting["repeatTimes"] == 1 # Verify order is preserved (target camera was first in original list) assert ring_settings[0]["cameraId"] == camera_obj.id assert ring_settings[1]["cameraId"] == other_camera_id @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() @pytest.mark.parametrize("invalid_level", [-1, 101, 200]) async def test_chime_set_volume_for_camera_public_invalid_level( chime_obj: Chime | None, camera_obj: Camera | None, invalid_level: int, ) -> None: """Test set_volume_for_camera_public raises ValidationError for invalid level.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=1, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] with pytest.raises(ValidationError): await chime_obj.set_volume_for_camera_public(camera_obj, invalid_level) @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_public( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test set_repeat_times_for_camera_public sets repeat times for specific camera.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=2, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] updated_chime = Mock() updated_chime.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=4, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] chime_obj.api.update_chime_public = AsyncMock(return_value=updated_chime) await chime_obj.set_repeat_times_for_camera_public(camera_obj, 4) chime_obj.api.update_chime_public.assert_called_once() call_args = chime_obj.api.update_chime_public.call_args assert call_args[0][0] == chime_obj.id ring_settings = call_args[1]["ring_settings"] assert len(ring_settings) == 1 assert ring_settings[0]["cameraId"] == camera_obj.id assert ring_settings[0]["repeatTimes"] == 4 assert ring_settings[0]["volume"] == 50 assert ring_settings[0]["ringtoneId"] == RINGTONE_ID @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_public_not_paired( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test set_repeat_times_for_camera_public raises BadRequest for unpaired camera.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") chime_obj.ring_settings = [ RingSetting( camera_id="other-camera-id", repeat_times=1, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] with pytest.raises(BadRequest): await chime_obj.set_repeat_times_for_camera_public(camera_obj, 4) @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_public_multiple_cameras( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test set_repeat_times_for_camera_public preserves settings for other cameras.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") other_camera_id = "other-doorbell-camera" chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=2, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), RingSetting( camera_id=other_camera_id, repeat_times=1, # type: ignore[arg-type] ringtone_id="other-ringtone", volume=70, ), ] updated_chime = Mock() updated_chime.ring_settings = chime_obj.ring_settings chime_obj.api.update_chime_public = AsyncMock(return_value=updated_chime) await chime_obj.set_repeat_times_for_camera_public(camera_obj, 5) call_args = chime_obj.api.update_chime_public.call_args ring_settings = call_args[1]["ring_settings"] assert len(ring_settings) == 2 target_setting = next(s for s in ring_settings if s["cameraId"] == camera_obj.id) other_setting = next(s for s in ring_settings if s["cameraId"] == other_camera_id) assert target_setting["repeatTimes"] == 5 assert target_setting["volume"] == 50 assert other_setting["repeatTimes"] == 1 assert other_setting["volume"] == 70 assert ring_settings[0]["cameraId"] == camera_obj.id assert ring_settings[1]["cameraId"] == other_camera_id @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() @pytest.mark.parametrize("invalid_value", [0, 7, 100]) async def test_chime_set_repeat_times_for_camera_public_invalid_value( chime_obj: Chime | None, camera_obj: Camera | None, invalid_value: int, ) -> None: """Test set_repeat_times_for_camera_public raises ValidationError for out-of-range value.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") chime_obj.ring_settings = [ RingSetting( camera_id=camera_obj.id, repeat_times=1, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ), ] with pytest.raises(ValidationError): await chime_obj.set_repeat_times_for_camera_public(camera_obj, invalid_value) @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_ring_setting_to_api_dict_repeat_times_override( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test that to_api_dict applies the repeat_times override.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") ring_setting = RingSetting( camera_id=camera_obj.id, repeat_times=2, # type: ignore[arg-type] ringtone_id=RINGTONE_ID, volume=50, ) result = ring_setting.to_api_dict(repeat_times=5) assert result["repeatTimes"] == 5 assert result["volume"] == 50 @pytest.mark.skipif( not TEST_CHIME_EXISTS or not TEST_CAMERA_EXISTS, reason="Missing testdata", ) @pytest.mark.asyncio() async def test_ring_setting_to_api_dict_omits_none_ringtone_id( chime_obj: Chime | None, camera_obj: Camera | None, ) -> None: """Test that to_api_dict omits ringtoneId when it's None.""" if chime_obj is None: pytest.skip("No chime_obj found") if camera_obj is None: pytest.skip("No camera_obj found") ring_setting = RingSetting( camera_id=camera_obj.id, repeat_times=2, # type: ignore[arg-type] ringtone_id=None, volume=50, ) result = ring_setting.to_api_dict() assert "cameraId" in result assert "volume" in result assert "repeatTimes" in result assert "ringtoneId" not in result # ============================================================================= # PLAY_SPEAKER EDGE CASE TESTS # ============================================================================= @pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_chime_play_with_volume_minimum(chime_obj: Chime | None) -> None: """Test that volume=1 (minimum in Protect UI) is properly handled.""" if chime_obj is None: pytest.skip("No chime_obj found") chime_obj.volume = 100 chime_obj.repeat_times = 1 chime_obj.api.api_request.reset_mock() await chime_obj.play(volume=1) # volume=1 should be sent, not ignored or replaced with default chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}/play-speaker", method="post", json={ "volume": 1, "repeatTimes": 1, }, ) @pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_chime_play_with_ringtone_id(chime_obj: Chime | None) -> None: """Test playing chime with specific ringtone_id.""" if chime_obj is None: pytest.skip("No chime_obj found") chime_obj.volume = 50 chime_obj.repeat_times = 2 chime_obj.api.api_request.reset_mock() await chime_obj.play(ringtone_id=RINGTONE_ID) chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}/play-speaker", method="post", json={ "volume": 50, "repeatTimes": 2, "ringtoneId": RINGTONE_ID, }, ) @pytest.mark.skipif(not TEST_CHIME_EXISTS, reason="Missing testdata") @pytest.mark.asyncio() async def test_play_speaker_track_no_deprecation_warning( chime_obj: Chime | None, ) -> None: """Test that using track_no emits a deprecation warning.""" if chime_obj is None: pytest.skip("No chime_obj found") chime_obj.volume = 50 chime_obj.repeat_times = 1 chime_obj.api.api_request.reset_mock() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") await chime_obj._api.play_speaker(chime_obj.id, track_no=1) # Check deprecation warning was raised assert len(w) == 1 assert issubclass(w[0].category, DeprecationWarning) assert "track_no is deprecated" in str(w[0].message) # trackNo should still be sent 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_track_no_deprecation_warning( chime_obj: Chime | None, ) -> None: """Test that Chime.play() with track_no emits a deprecation warning.""" if chime_obj is None: pytest.skip("No chime_obj found") chime_obj.volume = 50 chime_obj.repeat_times = 1 chime_obj.api.api_request.reset_mock() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") await chime_obj.play(track_no=1) # Check deprecation warning was raised (from play() and play_speaker()) assert len(w) >= 1 deprecation_warnings = [ warning for warning in w if issubclass(warning.category, DeprecationWarning) ] assert len(deprecation_warnings) >= 1 assert any( "track_no is deprecated" in str(warning.message) for warning in deprecation_warnings ) # trackNo should still be sent chime_obj.api.api_request.assert_called_with( f"chimes/{chime_obj.id}/play-speaker", method="post", json={ "volume": 50, "repeatTimes": 1, "trackNo": 1, }, ) uilibs-uiprotect-317a52c/tests/test_api_devices_public.py000066400000000000000000000403631524637705700237560ustar00rootroot00000000000000"""Tests for the typed public device-state contract (``subscribe_devices``).""" from __future__ import annotations from types import SimpleNamespace from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock import orjson import pytest from uiprotect.data.public_bootstrap import DeviceWSResult, PublicBootstrap from uiprotect.data.public_devices import PublicCamera from uiprotect.data.types import ModelType from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.devices import DeviceChange, ProtectDeviceChange from uiprotect.devices.dispatcher import DeviceDispatcher if TYPE_CHECKING: from uiprotect import ProtectApiClient CAMERA_ID = "cam-1" CAMERA_MAC = "AABBCCDDEEFF" def _camera_payload(cam_id: str = CAMERA_ID, **over: Any) -> dict[str, Any]: payload: dict[str, Any] = { "id": cam_id, "modelKey": "camera", "state": "CONNECTED", "name": "Test Camera", "mac": CAMERA_MAC, "isMicEnabled": True, "osdSettings": { "isNameEnabled": True, "isDateEnabled": True, "isLogoEnabled": False, "isDebugEnabled": False, "overlayLocation": "topLeft", }, "ledSettings": {"isEnabled": True, "welcomeLed": None, "floodLed": None}, "lcdMessage": {}, "micVolume": 100, "activePatrolSlot": None, "videoMode": "default", "hdrType": "auto", "featureFlags": { "supportFullHdSnapshot": False, "hasHdr": True, "hasMic": True, "hasLedStatus": True, "hasSpeaker": False, "videoModes": ["default"], "smartDetectTypes": ["person"], "smartDetectAudioTypes": ["alrmSmoke"], }, "smartDetectSettings": { "objectTypes": ["person"], "audioTypes": ["alrmSmoke"], }, "hasPackageCamera": False, } payload.update(over) return payload def _ws(data: dict[str, Any]) -> MagicMock: msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = orjson.dumps(data) return msg def _prime(client: ProtectApiClient) -> None: client._public_bootstrap = PublicBootstrap() def _subscribe( client: ProtectApiClient, ) -> tuple[list[ProtectDeviceChange], Any]: received: list[ProtectDeviceChange] = [] unsub = client.subscribe_devices(received.append) return received, unsub # --------------------------------------------------------------------------- # Full message-path tests (orjson frame -> dispatch -> ProtectDeviceChange) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_single_add_emits_added( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) client._process_devices_ws_message(_ws({"type": "add", "item": _camera_payload()})) assert len(received) == 1 change = received[0] assert change.change is DeviceChange.ADDED assert change.model_type is ModelType.CAMERA assert change.device_id == CAMERA_ID assert change.device_mac == CAMERA_MAC assert isinstance(change.model, PublicCamera) assert change.changed_fields == frozenset() assert client.public_bootstrap.cameras[CAMERA_ID] is change.model unsub() @pytest.mark.asyncio async def test_state_update_emits_updated_with_changed_fields( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) client._process_devices_ws_message(_ws({"type": "add", "item": _camera_payload()})) client._process_devices_ws_message( _ws( { "type": "update", "item": { "id": CAMERA_ID, "modelKey": "camera", "state": "DISCONNECTED", }, } ) ) assert [c.change for c in received] == [ DeviceChange.ADDED, DeviceChange.UPDATED, ] update = received[1] assert "state" in update.changed_fields assert update.model is client.public_bootstrap.cameras[CAMERA_ID] unsub() @pytest.mark.asyncio async def test_multi_field_update_changed_fields( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) client._process_devices_ws_message(_ws({"type": "add", "item": _camera_payload()})) client._process_devices_ws_message( _ws( { "type": "update", "item": { "id": CAMERA_ID, "modelKey": "camera", "state": "DISCONNECTED", "name": "Renamed", }, } ) ) update = received[1] assert {"state", "name"} <= update.changed_fields unsub() @pytest.mark.asyncio async def test_remove_emits_removed( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) client._process_devices_ws_message(_ws({"type": "add", "item": _camera_payload()})) client._process_devices_ws_message( _ws({"type": "remove", "item": {"id": CAMERA_ID, "modelKey": "camera"}}) ) removed = received[-1] assert removed.change is DeviceChange.REMOVED assert removed.device_id == CAMERA_ID assert removed.model is None assert removed.device_mac == CAMERA_MAC assert CAMERA_ID not in client.public_bootstrap.cameras unsub() @pytest.mark.asyncio async def test_bulk_add_expands_per_id( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) ids = ["cam-a", "cam-b", "cam-c"] item = _camera_payload() item["id"] = ids client._process_devices_ws_message(_ws({"type": "add", "item": item})) assert len(received) == 3 assert {c.device_id for c in received} == set(ids) assert all(c.change is DeviceChange.ADDED for c in received) assert set(client.public_bootstrap.cameras) == set(ids) unsub() @pytest.mark.asyncio async def test_bulk_single_element_id_is_one_change( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) item = _camera_payload() item["id"] = [CAMERA_ID] client._process_devices_ws_message(_ws({"type": "add", "item": item})) assert len(received) == 1 assert received[0].device_id == CAMERA_ID unsub() @pytest.mark.asyncio async def test_empty_id_array_emits_nothing( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) item = _camera_payload() item["id"] = [] client._process_devices_ws_message(_ws({"type": "add", "item": item})) assert received == [] unsub() @pytest.mark.asyncio async def test_update_for_uncached_id_dropped( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) client._process_devices_ws_message( _ws( { "type": "update", "item": {"id": "ghost", "modelKey": "camera", "state": "OFFLINE"}, } ) ) assert received == [] unsub() @pytest.mark.asyncio async def test_remove_unsupported_model_dropped( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) received, unsub = _subscribe(client) client._process_devices_ws_message( _ws({"type": "remove", "item": {"id": "dl-1", "modelKey": "doorlock"}}) ) assert received == [] unsub() # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_subscribe_requires_public_bootstrap( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug client._public_bootstrap = None with pytest.raises(RuntimeError, match="update_public"): client.subscribe_devices(lambda c: None) @pytest.mark.asyncio async def test_subscribe_reference_counted( protect_client_no_debug: ProtectApiClient, monkeypatch: pytest.MonkeyPatch, ) -> None: client = protect_client_no_debug _prime(client) starts: list[None] = [] stops: list[None] = [] class _WS: def start(self) -> None: starts.append(None) def stop(self) -> None: stops.append(None) ws = _WS() monkeypatch.setattr(client, "_get_devices_websocket", lambda: ws) unsub_a = client.subscribe_devices(lambda c: None) unsub_b = client.subscribe_devices(lambda c: None) assert len(starts) == 1 assert client._device_dispatcher is not None assert client._device_dispatcher.subscriber_count == 2 unsub_a() assert stops == [] unsub_b() assert len(stops) == 1 assert client._device_dispatcher.subscriber_count == 0 # Idempotent double-unsubscribe must not raise. unsub_b() def test_unsubscribe_without_dispatcher_is_noop( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug client._device_dispatcher = None client._unsubscribe_devices(lambda c: None) # --------------------------------------------------------------------------- # Façade parity + dispatcher-direct behaviour # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_singular_facade_returns_first_tuple( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug bootstrap = PublicBootstrap() data = {"type": "add", "item": _camera_payload()} model_type, new_obj, old_obj = bootstrap.process_devices_ws_message(client, data) assert model_type is ModelType.CAMERA assert isinstance(new_obj, PublicCamera) assert old_obj is None @pytest.mark.asyncio async def test_plural_returns_one_result_per_id( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug bootstrap = PublicBootstrap() item = _camera_payload() item["id"] = ["cam-a", "cam-b"] results = bootstrap.process_devices_ws_messages( client, {"type": "add", "item": item} ) assert [r.item["id"] for r in results] == ["cam-a", "cam-b"] assert all(isinstance(r, DeviceWSResult) for r in results) def test_dispatch_raising_callback_is_isolated( protect_client_no_debug: ProtectApiClient, ) -> None: client = protect_client_no_debug _prime(client) dispatcher = DeviceDispatcher(client) seen: list[ProtectDeviceChange] = [] def boom(_change: ProtectDeviceChange) -> None: raise ValueError("boom") dispatcher.add_subscriber(boom) dispatcher.add_subscriber(seen.append) camera = PublicCamera.from_unifi_dict(api=client, **_camera_payload()) client.public_bootstrap.cameras[CAMERA_ID] = camera msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id=CAMERA_ID, changed_data={"id": CAMERA_ID, "modelKey": "camera"}, new_obj=camera, ) dispatcher.dispatch(msg) assert len(seen) == 1 assert seen[0].change is DeviceChange.ADDED def test_dispatch_generic_model_type( protect_client_no_debug: ProtectApiClient, ) -> None: """A model type with no per-type allowlist still dispatches.""" client = protect_client_no_debug _prime(client) dispatcher = DeviceDispatcher(client) seen: list[ProtectDeviceChange] = [] dispatcher.add_subscriber(seen.append) camera = PublicCamera.from_unifi_dict(api=client, **_camera_payload("siren-1")) msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id="siren-1", changed_data={"id": "siren-1", "modelKey": "siren"}, new_obj=camera, ) dispatcher.dispatch(msg) assert len(seen) == 1 assert seen[0].model_type is ModelType.SIREN assert seen[0].device_id == "siren-1" # --------------------------------------------------------------------------- # Defensive branches (no observable change emitted) # --------------------------------------------------------------------------- def test_remove_without_resolvable_id_is_dropped( protect_client_no_debug: ProtectApiClient, ) -> None: """A REMOVE for a supported type but no id (no old_obj, no payload id).""" client = protect_client_no_debug _prime(client) dispatcher = DeviceDispatcher(client) seen: list[ProtectDeviceChange] = [] dispatcher.add_subscriber(seen.append) msg = WSSubscriptionMessage( action=WSAction.REMOVE, new_update_id="", changed_data={"modelKey": "camera"}, old_obj=None, ) dispatcher.dispatch(msg) assert seen == [] def test_upsert_without_resolvable_id_is_dropped( protect_client_no_debug: ProtectApiClient, ) -> None: """An ADD whose merged model has no id and whose payload omits id.""" client = protect_client_no_debug _prime(client) dispatcher = DeviceDispatcher(client) seen: list[ProtectDeviceChange] = [] dispatcher.add_subscriber(seen.append) msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id="", changed_data={"modelKey": "camera"}, new_obj=SimpleNamespace(id=None, mac=None), ) dispatcher.dispatch(msg) assert seen == [] def test_update_with_empty_payload_has_no_changed_fields( protect_client_no_debug: ProtectApiClient, ) -> None: """An UPDATE carrying only id/modelKey yields an empty changed-field set.""" client = protect_client_no_debug _prime(client) dispatcher = DeviceDispatcher(client) seen: list[ProtectDeviceChange] = [] dispatcher.add_subscriber(seen.append) camera = PublicCamera.from_unifi_dict(api=client, **_camera_payload()) client.public_bootstrap.cameras[CAMERA_ID] = camera msg = WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=CAMERA_ID, changed_data={"id": CAMERA_ID, "modelKey": "camera"}, new_obj=camera, ) dispatcher.dispatch(msg) assert len(seen) == 1 assert seen[0].change is DeviceChange.UPDATED assert seen[0].changed_fields == frozenset() def test_update_changed_fields_falls_back_when_conversion_raises( protect_client_no_debug: ProtectApiClient, monkeypatch: pytest.MonkeyPatch, ) -> None: """If the model's remap pass raises, changed_fields is snake_cased keys.""" client = protect_client_no_debug _prime(client) dispatcher = DeviceDispatcher(client) seen: list[ProtectDeviceChange] = [] dispatcher.add_subscriber(seen.append) camera = PublicCamera.from_unifi_dict(api=client, **_camera_payload()) client.public_bootstrap.cameras[CAMERA_ID] = camera def _boom(_data: dict[str, Any]) -> dict[str, Any]: raise ValueError("bad payload") monkeypatch.setattr(PublicCamera, "unifi_dict_to_dict", staticmethod(_boom)) msg = WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=CAMERA_ID, changed_data={ "id": CAMERA_ID, "modelKey": "camera", "isMicEnabled": False, }, new_obj=camera, ) dispatcher.dispatch(msg) assert len(seen) == 1 # Fallback stays in the snake_case key space (not raw camelCase) so # membership checks remain stable regardless of which branch ran. assert seen[0].changed_fields == frozenset({"is_mic_enabled"}) def test_adapt_devices_ws_message_without_dispatcher_is_noop( protect_client_no_debug: ProtectApiClient, ) -> None: """The WS adapter swallows frames once the dispatcher has been torn down.""" client = protect_client_no_debug client._device_dispatcher = None msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id=CAMERA_ID, changed_data={"id": CAMERA_ID, "modelKey": "camera"}, new_obj=None, ) # Must not raise; nothing to assert beyond the no-op return. client._adapt_devices_ws_message(msg) uilibs-uiprotect-317a52c/tests/test_api_files_public.py000066400000000000000000000153371524637705700234410ustar00rootroot00000000000000"""Tests for the Public API file (device asset) endpoints.""" from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import ANY, AsyncMock, patch import aiohttp import orjson import pytest from uiprotect.data import AssetFileType, PublicFile from uiprotect.exceptions import NvrError if TYPE_CHECKING: from uiprotect.api import ProtectApiClient @pytest.mark.asyncio() async def test_get_files_public_calls_correct_url( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_list = AsyncMock(return_value=[]) result = await protect_client.get_files_public() assert result == [] protect_client.api_request_list.assert_called_once_with( url="/v1/files/animations", public_api=True ) @pytest.mark.asyncio() async def test_get_files_public_accepts_custom_file_type( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_list = AsyncMock(return_value=[]) await protect_client.get_files_public("future-type") protect_client.api_request_list.assert_called_once_with( url="/v1/files/future-type", public_api=True ) @pytest.mark.asyncio() async def test_get_files_public_parses_list( protect_client_no_debug: ProtectApiClient, ) -> None: payload = [ { "name": "abc.png", "type": "animations", "path": "/files/animations/abc.png", "originalName": "welcome.png", }, { "name": "def.png", "type": "animations", "path": "/files/animations/def.png", }, ] protect_client_no_debug.api_request_list = AsyncMock(return_value=payload) files = await protect_client_no_debug.get_files_public() assert [f.name for f in files] == ["abc.png", "def.png"] assert files[0].original_name == "welcome.png" assert files[1].original_name is None assert all(isinstance(f, PublicFile) for f in files) @pytest.mark.asyncio() async def test_upload_file_public_sends_multipart( protect_client: ProtectApiClient, ) -> None: response = orjson.dumps( { "name": "abc.png", "type": "animations", "path": "/files/animations/abc.png", "originalName": "welcome.png", } ) protect_client.api_request_raw = AsyncMock(return_value=response) payload = b"\x89PNG\r\n\x1a\n" with patch.object(aiohttp.FormData, "add_field", autospec=True) as mock_add: await protect_client.upload_file_public( "animations", payload, original_name="welcome.png", ) mock_add.assert_called_once_with( ANY, "file", payload, filename="welcome.png", content_type="image/png", ) protect_client.api_request_raw.assert_called_once() call_kwargs = protect_client.api_request_raw.call_args.kwargs assert call_kwargs["url"] == "/v1/files/animations" assert call_kwargs["method"] == "post" assert call_kwargs["public_api"] is True assert isinstance(call_kwargs["data"], aiohttp.FormData) @pytest.mark.asyncio() async def test_upload_file_public_parses_response( protect_client_no_debug: ProtectApiClient, ) -> None: response = orjson.dumps( { "name": "abc.png", "type": "animations", "path": "/files/animations/abc.png", "originalName": "welcome.png", } ) protect_client_no_debug.api_request_raw = AsyncMock(return_value=response) result = await protect_client_no_debug.upload_file_public( "animations", b"\x89PNG\r\n\x1a\n", original_name="welcome.png", ) assert isinstance(result, PublicFile) assert result.name == "abc.png" assert result.type == "animations" assert result.original_name == "welcome.png" @pytest.mark.asyncio() @pytest.mark.parametrize("empty", [None, b""]) async def test_upload_file_public_raises_on_empty_response( protect_client: ProtectApiClient, empty: bytes | None, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=empty) with pytest.raises(NvrError, match="Empty response"): await protect_client.upload_file_public( "animations", b"\x89PNG\r\n\x1a\n", original_name="welcome.png", ) @pytest.mark.asyncio() async def test_upload_file_public_accepts_custom_content_type( protect_client: ProtectApiClient, ) -> None: response = orjson.dumps( { "name": "abc.wav", "type": "animations", "path": "/files/animations/abc.wav", } ) protect_client.api_request_raw = AsyncMock(return_value=response) payload = b"RIFF" with patch.object(aiohttp.FormData, "add_field", autospec=True) as mock_add: await protect_client.upload_file_public( "animations", payload, original_name="ding.wav", content_type="audio/wave", ) mock_add.assert_called_once_with( ANY, "file", payload, filename="ding.wav", content_type="audio/wave", ) assert isinstance( protect_client.api_request_raw.call_args.kwargs["data"], aiohttp.FormData ) @pytest.mark.asyncio() @pytest.mark.parametrize( "file_type", [AssetFileType.ANIMATIONS, "animations"], ) async def test_get_files_public_accepts_enum_and_str( protect_client: ProtectApiClient, file_type: AssetFileType | str, ) -> None: protect_client.api_request_list = AsyncMock(return_value=[]) await protect_client.get_files_public(file_type) protect_client.api_request_list.assert_called_once_with( url="/v1/files/animations", public_api=True ) @pytest.mark.asyncio() @pytest.mark.parametrize( "file_type", [AssetFileType.ANIMATIONS, "animations"], ) async def test_upload_file_public_accepts_enum_and_str( protect_client: ProtectApiClient, file_type: AssetFileType | str, ) -> None: response = orjson.dumps( { "name": "abc.png", "type": "animations", "path": "/files/animations/abc.png", } ) protect_client.api_request_raw = AsyncMock(return_value=response) result = await protect_client.upload_file_public( file_type, b"\x89PNG\r\n\x1a\n", original_name="welcome.png", ) assert isinstance(result, PublicFile) assert result.type is AssetFileType.ANIMATIONS assert protect_client.api_request_raw.call_args.kwargs["url"] == ( "/v1/files/animations" ) def test_public_file_type_unknown_value_falls_back() -> None: file = PublicFile.from_unifi_dict( name="x.png", type="future-type", path="/files/future-type/x.png", ) assert file.type is AssetFileType.UNKNOWN uilibs-uiprotect-317a52c/tests/test_api_light_public.py000066400000000000000000000355401524637705700234440ustar00rootroot00000000000000"""Tests for Light Public API methods.""" from __future__ import annotations from datetime import timedelta from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data.devices import ( LightDeviceSettings, LightModeEnableType, LightModeSettings, LightModeType, ) from uiprotect.data.types import LowMedHigh from uiprotect.exceptions import BadRequest if TYPE_CHECKING: from uiprotect.api import ProtectApiClient LIGHT_ID = "663d0aa401218803e4000449" # GET LIGHTS TESTS @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_get_lights_public_success( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test successful lights retrieval from public API.""" mock_light1 = Mock() mock_light1.id = "663d0aa401218803e4000449" mock_light1.name = "Light 1" mock_light2 = Mock() mock_light2.id = "663d0aa401218803e4000450" mock_light2.name = "Light 2" mock_create.side_effect = [mock_light1, mock_light2] protect_client.api_request_list = AsyncMock(return_value=[{"id": "1"}, {"id": "2"}]) # type: ignore[method-assign] result = await protect_client.get_lights_public() assert result is not None assert len(result) == 2 assert result[0].id == "663d0aa401218803e4000449" assert result[1].id == "663d0aa401218803e4000450" protect_client.api_request_list.assert_called_with( url="/v1/lights", public_api=True ) # type: ignore[attr-defined] @pytest.mark.asyncio() async def test_get_lights_public_error( protect_client: ProtectApiClient, ) -> None: """Test lights retrieval error handling.""" protect_client.api_request_list = AsyncMock(side_effect=Exception("API Error")) # type: ignore[method-assign] with pytest.raises(Exception, match="API Error"): await protect_client.get_lights_public() protect_client.api_request_list.assert_called_with( url="/v1/lights", public_api=True ) # type: ignore[attr-defined] @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_get_light_public_success( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test successful single light retrieval from public API.""" mock_light = Mock() mock_light.id = LIGHT_ID mock_light.name = "Light 1" mock_create.return_value = mock_light protect_client.api_request_obj = AsyncMock(return_value={"id": "test"}) # type: ignore[method-assign] result = await protect_client.get_light_public(LIGHT_ID) assert result is not None assert result.id == LIGHT_ID assert result.name == "Light 1" protect_client.api_request_obj.assert_called_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", public_api=True ) @pytest.mark.asyncio() async def test_get_light_public_error( protect_client: ProtectApiClient, ) -> None: """Test single light retrieval error handling.""" protect_client.api_request_obj = AsyncMock(side_effect=Exception("API Error")) # type: ignore[method-assign] with pytest.raises(Exception, match="API Error"): await protect_client.get_light_public(LIGHT_ID) protect_client.api_request_obj.assert_called_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", public_api=True ) # UPDATE LIGHT TESTS @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_name_only( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating only the name of a light via public API.""" new_name = "Updated Light Name" mock_light = Mock() mock_light.id = LIGHT_ID mock_light.name = new_name mock_create.return_value = mock_light mock_light_data: dict[str, Any] = {"id": LIGHT_ID, "name": new_name} protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public(LIGHT_ID, name=new_name) assert result is not None assert result.id == LIGHT_ID assert result.name == new_name protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", method="patch", json={"name": new_name}, public_api=True, ) mock_create.assert_called_once_with(**mock_light_data, api=protect_client) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_force_enabled( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating force enabled setting.""" mock_light = Mock() mock_light.id = LIGHT_ID mock_create.return_value = mock_light mock_light_data: dict[str, Any] = {"id": LIGHT_ID, "isLightForceEnabled": True} protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public( LIGHT_ID, is_light_force_enabled=True ) assert result is not None protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", method="patch", json={"isLightForceEnabled": True}, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_light_mode_settings( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating light mode settings.""" light_mode_settings = LightModeSettings( mode=LightModeType.MOTION, enable_at=LightModeEnableType.DARK, ) mock_light = Mock() mock_light.id = LIGHT_ID mock_create.return_value = mock_light mock_light_data: dict[str, Any] = { "id": LIGHT_ID, "lightModeSettings": { "mode": "motion", "enableAt": "dark", }, } protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public( LIGHT_ID, light_mode_settings=light_mode_settings ) assert result is not None protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", method="patch", json={ "lightModeSettings": { "mode": "motion", "enableAt": "dark", } }, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_light_device_settings( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating light device settings.""" light_device_settings = LightDeviceSettings( is_indicator_enabled=True, led_level=6, pir_duration=timedelta(seconds=60), pir_sensitivity=80, ) mock_light = Mock() mock_light.id = LIGHT_ID mock_create.return_value = mock_light mock_light_data: dict[str, Any] = { "id": LIGHT_ID, "lightDeviceSettings": { "isIndicatorEnabled": True, "ledLevel": 6, "pirDuration": 60000, "pirSensitivity": 80, }, } protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public( LIGHT_ID, light_device_settings=light_device_settings ) assert result is not None protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", method="patch", json={ "lightDeviceSettings": { "isIndicatorEnabled": True, "ledLevel": 6, "pirDuration": 60000, "pirSensitivity": 80, } }, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_all_parameters( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test updating all parameters at once.""" light_mode_settings = LightModeSettings( mode=LightModeType.WHEN_DARK, enable_at=LightModeEnableType.DARK, ) light_device_settings = LightDeviceSettings( is_indicator_enabled=True, led_level=6, pir_duration=timedelta(seconds=60), pir_sensitivity=80, ) mock_light = Mock() mock_light.id = LIGHT_ID mock_light.name = "Complete Update" mock_create.return_value = mock_light mock_light_data: dict[str, Any] = { "id": LIGHT_ID, "name": "Complete Update", "isLightForceEnabled": True, "lightModeSettings": {"mode": "always", "enableAt": "dark"}, "lightDeviceSettings": { "isIndicatorEnabled": True, "ledLevel": 6, "pirDuration": 60000, "pirSensitivity": 80, }, } protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public( LIGHT_ID, name="Complete Update", is_light_force_enabled=True, light_mode_settings=light_mode_settings, light_device_settings=light_device_settings, ) assert result is not None assert result.id == LIGHT_ID protect_client.api_request_obj.assert_called_once() # type: ignore[attr-defined] @pytest.mark.asyncio() async def test_update_light_public_no_parameters( protect_client: ProtectApiClient, ) -> None: """Test that calling with no parameters raises BadRequest.""" with pytest.raises(BadRequest, match="At least one parameter must be provided"): await protect_client.update_light_public(LIGHT_ID) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_filters_lux_sensitivity( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test that lux_sensitivity is filtered out when sending to Public API.""" light_device_settings = LightDeviceSettings( is_indicator_enabled=True, led_level=5, pir_duration=timedelta(seconds=30), pir_sensitivity=60, lux_sensitivity=LowMedHigh.HIGH, # Should be filtered out ) mock_light = Mock() mock_light.id = LIGHT_ID mock_create.return_value = mock_light mock_light_data: dict[str, Any] = { "id": LIGHT_ID, "lightDeviceSettings": { "isIndicatorEnabled": True, "ledLevel": 5, "pirDuration": 30000, "pirSensitivity": 60, }, } protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public( LIGHT_ID, light_device_settings=light_device_settings ) assert result is not None # Verify that luxSensitivity is NOT in the JSON payload sent to API protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", method="patch", json={ "lightDeviceSettings": { "isIndicatorEnabled": True, "ledLevel": 5, "pirDuration": 30000, "pirSensitivity": 60, # luxSensitivity should NOT be here } }, public_api=True, ) @pytest.mark.asyncio() async def test_update_light_public_api_error( protect_client: ProtectApiClient, ) -> None: """Test API error handling.""" protect_client.api_request_obj = AsyncMock(side_effect=Exception("API Error")) # type: ignore[method-assign] with pytest.raises(Exception, match="API Error"): await protect_client.update_light_public(LIGHT_ID, name="Test") @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_false_values( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test that False values are not filtered out.""" mock_light = Mock() mock_light.id = LIGHT_ID mock_create.return_value = mock_light mock_light_data: dict[str, Any] = {"id": LIGHT_ID, "isLightForceEnabled": False} protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public( LIGHT_ID, is_light_force_enabled=False ) assert result is not None protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", method="patch", json={"isLightForceEnabled": False}, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_partial_update( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test partial updates with subset of parameters.""" light_mode_settings = LightModeSettings( mode=LightModeType.MANUAL, enable_at=LightModeEnableType.ALWAYS, ) mock_light = Mock() mock_light.id = LIGHT_ID mock_create.return_value = mock_light mock_light_data: dict[str, Any] = { "id": LIGHT_ID, "lightModeSettings": {"mode": "off", "enableAt": "fulltime"}, } protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public( LIGHT_ID, light_mode_settings=light_mode_settings ) assert result is not None protect_client.api_request_obj.assert_called_once_with( # type: ignore[attr-defined] url=f"/v1/lights/{LIGHT_ID}", method="patch", json={"lightModeSettings": {"mode": "off", "enableAt": "fulltime"}}, public_api=True, ) @pytest.mark.asyncio() @patch("uiprotect.data.public_devices.PublicLight.from_unifi_dict") async def test_update_light_public_returns_light_object( mock_create: Mock, protect_client: ProtectApiClient, ) -> None: """Test that the method returns a proper Light object.""" mock_light = Mock() mock_light.id = LIGHT_ID mock_light.name = "Test Light" mock_create.return_value = mock_light mock_light_data: dict[str, Any] = {"id": LIGHT_ID, "name": "Test Light"} protect_client.api_request_obj = AsyncMock(return_value=mock_light_data) # type: ignore[method-assign] result = await protect_client.update_light_public(LIGHT_ID, name="Test Light") assert result is not None assert result.id == LIGHT_ID assert result.name == "Test Light" mock_create.assert_called_once_with(**mock_light_data, api=protect_client) uilibs-uiprotect-317a52c/tests/test_api_linkstation_public.py000066400000000000000000000172741524637705700247000ustar00rootroot00000000000000"""Public-API LinkStation alarm-hub accessor tests.""" from __future__ import annotations from copy import deepcopy from typing import Any from uiprotect.data import ( AlarmHubBattery, AlarmHubBatteryStatus, AlarmHubConnectionState, AlarmHubCover, AlarmHubCoverStatus, AlarmHubInput, AlarmHubInputContactType, AlarmHubInputStatus, AlarmHubInputType, AlarmHubOutput, AlarmHubOutputStatus, LinkStation, OnOffState, ) from .conftest import read_json_file def _load_alarm_hub_fixture() -> dict[str, Any]: return read_json_file("sample_link_station_alarm_hub") def _load_link_station_only_fixture() -> dict[str, Any]: data = _load_alarm_hub_fixture() data["id"] = "linkstation01linkstation01ls" data["isAlarmHub"] = False data["alarmHub"] = None data["name"] = "Garage Link" return data def test_alarm_hub_armed_returns_typed_state() -> None: ls = LinkStation.from_unifi_dict(**_load_alarm_hub_fixture()) assert ls.alarm_hub_armed is OnOffState.ON def test_alarm_hub_armed_unknown_value_coerces() -> None: data = _load_alarm_hub_fixture() data["alarmHub"]["armed"] = "partial" ls = LinkStation.from_unifi_dict(**data) assert ls.alarm_hub_armed is OnOffState.UNKNOWN def test_alarm_hub_armed_missing_field_returns_none() -> None: data = _load_alarm_hub_fixture() del data["alarmHub"]["armed"] ls = LinkStation.from_unifi_dict(**data) assert ls.alarm_hub_armed is None def test_alarm_hub_battery_is_typed() -> None: ls = LinkStation.from_unifi_dict(**_load_alarm_hub_fixture()) battery = ls.alarm_hub_battery assert isinstance(battery, AlarmHubBattery) assert battery.charging is OnOffState.OFF assert battery.connection is AlarmHubConnectionState.CONNECTED assert battery.voltage == 12.4 assert battery.battery_status is AlarmHubBatteryStatus.OK def test_alarm_hub_cover_is_typed() -> None: ls = LinkStation.from_unifi_dict(**_load_alarm_hub_fixture()) cover = ls.alarm_hub_cover assert isinstance(cover, AlarmHubCover) assert cover.status is AlarmHubCoverStatus.CLOSE assert cover.distance == 3 def test_alarm_hub_battery_sparse_payload_when_disconnected() -> None: # When the backup battery is disconnected the hub emits only ``connection`` # (observed on real hardware). The ``battery`` object has no ``required`` # array in the OpenAPI spec, so every sub-field must be optional. data = _load_alarm_hub_fixture() data["alarmHub"]["battery"] = {"connection": "disconnected"} ls = LinkStation.from_unifi_dict(**data) battery = ls.alarm_hub_battery assert isinstance(battery, AlarmHubBattery) assert battery.connection is AlarmHubConnectionState.DISCONNECTED assert battery.charging is None assert battery.voltage is None assert battery.battery_status is None def test_alarm_hub_cover_sparse_payload() -> None: # ``cover`` likewise has no ``required`` array in the spec. data = _load_alarm_hub_fixture() data["alarmHub"]["cover"] = {"distance": 66} ls = LinkStation.from_unifi_dict(**data) cover = ls.alarm_hub_cover assert isinstance(cover, AlarmHubCover) assert cover.distance == 66 assert cover.status is None def test_alarm_hub_inputs_keyed_by_int_and_non_numeric_skipped() -> None: ls = LinkStation.from_unifi_dict(**_load_alarm_hub_fixture()) inputs = ls.alarm_hub_inputs assert set(inputs) == {0, 1, 2, 3} assert all(isinstance(v, AlarmHubInput) for v in inputs.values()) front = inputs[0] assert front.name == "Front Door" assert front.enable is OnOffState.ON assert front.type is AlarmHubInputContactType.NO assert front.status is AlarmHubInputStatus.NORMAL assert front.input_type is AlarmHubInputType.ENTRY assert front.last_triggered_at == 1700000010000 assert front.camera_id == "cam01cam01cam01cam01cam01" smoke = inputs[1] assert smoke.input_type is AlarmHubInputType.SMOKE assert smoke.last_triggered_at is None assert smoke.camera_id is None unconfigured = inputs[2] assert unconfigured.name is None assert unconfigured.input_type is None def test_alarm_hub_input_unknown_type_falls_back() -> None: ls = LinkStation.from_unifi_dict(**_load_alarm_hub_fixture()) future = ls.alarm_hub_inputs[3] assert future.input_type is AlarmHubInputType.UNKNOWN def test_alarm_hub_status_enums_coerce_unknown_values() -> None: data = _load_alarm_hub_fixture() data["alarmHub"]["battery"]["batteryStatus"] = "future-state" data["alarmHub"]["battery"]["charging"] = "trickle" data["alarmHub"]["battery"]["connection"] = "intermittent" data["alarmHub"]["cover"]["status"] = "ajar" data["alarmHub"]["input"]["0"]["status"] = "supervised" data["alarmHub"]["input"]["0"]["enable"] = "pending" data["alarmHub"]["input"]["0"]["type"] = "eolr" data["alarmHub"]["output"]["0"]["status"] = "shorted" data["alarmHub"]["output"]["0"]["enable"] = "scheduled" data["alarmHub"]["output"]["0"]["active"] = "pulsing" ls = LinkStation.from_unifi_dict(**data) battery = ls.alarm_hub_battery assert battery is not None assert battery.battery_status is AlarmHubBatteryStatus.UNKNOWN assert battery.charging is OnOffState.UNKNOWN assert battery.connection is AlarmHubConnectionState.UNKNOWN cover = ls.alarm_hub_cover assert cover is not None assert cover.status is AlarmHubCoverStatus.UNKNOWN front_input = ls.alarm_hub_inputs[0] assert front_input.status is AlarmHubInputStatus.UNKNOWN assert front_input.enable is OnOffState.UNKNOWN assert front_input.type is AlarmHubInputContactType.UNKNOWN siren_output = ls.alarm_hub_outputs[0] assert siren_output.status is AlarmHubOutputStatus.UNKNOWN assert siren_output.enable is OnOffState.UNKNOWN assert siren_output.active is OnOffState.UNKNOWN def test_alarm_hub_outputs_keyed_by_int_and_non_numeric_skipped() -> None: ls = LinkStation.from_unifi_dict(**_load_alarm_hub_fixture()) outputs = ls.alarm_hub_outputs assert set(outputs) == {0, 1} assert all(isinstance(v, AlarmHubOutput) for v in outputs.values()) siren = outputs[0] assert siren.name == "Siren" assert siren.enable is OnOffState.ON assert siren.active is OnOffState.OFF assert siren.status is AlarmHubOutputStatus.DRY assert siren.delay == 0 assert siren.duration == 30 minimal = outputs[1] assert minimal.name is None assert minimal.status is AlarmHubOutputStatus.WET assert minimal.delay is None assert minimal.duration is None def test_alarm_hub_dict_round_trips_unmodeled_keys() -> None: raw = _load_alarm_hub_fixture() ls = LinkStation.from_unifi_dict(**deepcopy(raw)) assert ls.alarm_hub is not None assert ls.alarm_hub["buckboost"] == "off" assert "connector" in ls.alarm_hub assert ls.alarm_hub["connector"]["12v"] == { "ch0": {"+": "connected", "-": "connected"} } assert "currentMeterStatus" in ls.alarm_hub def test_link_station_without_alarm_hub_accessors_return_empty() -> None: ls = LinkStation.from_unifi_dict(**_load_link_station_only_fixture()) assert ls.is_alarm_hub is False assert ls.alarm_hub_armed is None assert ls.alarm_hub_battery is None assert ls.alarm_hub_cover is None assert ls.alarm_hub_inputs == {} assert ls.alarm_hub_outputs == {} def test_alarm_hub_accessors_when_section_absent() -> None: data = _load_alarm_hub_fixture() data["alarmHub"] = {} ls = LinkStation.from_unifi_dict(**data) assert ls.is_alarm_hub is True assert ls.alarm_hub_armed is None assert ls.alarm_hub_battery is None assert ls.alarm_hub_cover is None assert ls.alarm_hub_inputs == {} assert ls.alarm_hub_outputs == {} uilibs-uiprotect-317a52c/tests/test_api_polling.py000066400000000000000000000160621524637705700224410ustar00rootroot00000000000000"""Tests for uiprotect.unifi_protect_server.""" from __future__ import annotations from datetime import timedelta from typing import TYPE_CHECKING from unittest.mock import patch import pytest from tests.conftest import MockDatetime from uiprotect.data import Camera, EventType from uiprotect.utils import to_js_time from .common import assert_equal_dump if TYPE_CHECKING: from uiprotect import ProtectApiClient @pytest.mark.asyncio() async def test_process_events_none(protect_client: ProtectApiClient, camera): def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] bootstrap_before = protect_client.bootstrap.unifi_dict() camera_before = get_camera().model_copy() async def get_events(*args, **kwargs): return [] protect_client.get_events_raw = get_events # type: ignore[method-assign] await protect_client.update() assert protect_client.bootstrap.unifi_dict() == bootstrap_before assert_equal_dump(get_camera(), camera_before) def _reset_events(camera: Camera) -> None: camera.last_ring_event_id = None camera.last_ring = None camera.last_motion_event_id = None camera.last_motion = None camera.last_smart_detect = None camera.last_smart_detect_event_id = None camera.last_fingerprint_identified_event_id = None camera.last_fingerprint_identified = None camera.last_nfc_card_scanned_event_id = None camera.last_nfc_card_scanned = None camera.last_smart_detects = {} camera.last_smart_detect_event_ids = {} camera._active_smart_detect_events = {} @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_process_events_ring(protect_client: ProtectApiClient, now, camera): def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera_before = get_camera().model_copy() expected_event_id = "bf9a241afe74821ceffffd05" async def get_events(*args, **kwargs): return [ { "id": expected_event_id, "type": "ring", "start": to_js_time(now - timedelta(seconds=1)), "end": to_js_time(now), "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": camera["id"], "partition": None, "user": None, "metadata": {}, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", "modelKey": "event", }, ] protect_client.get_events_raw = get_events # type: ignore[method-assign] await protect_client.update() # fetch initial bootstrap await protect_client.poll_events() # process events since bootstrap camera = get_camera() event = camera.last_ring_event _reset_events(camera) _reset_events(camera_before) assert camera.model_dump() == camera_before.model_dump() assert event.id == expected_event_id assert event.type == EventType.RING assert event.thumbnail_id == f"e-{expected_event_id}" assert event.heatmap_id == f"e-{expected_event_id}" @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_process_events_motion(protect_client: ProtectApiClient, now, camera): def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera_before = get_camera().model_copy() expected_event_id = "bf9a241afe74821ceffffd05" async def get_events(*args, **kwargs): return [ { "id": expected_event_id, "type": "motion", "start": to_js_time(now - timedelta(seconds=30)), "end": to_js_time(now), "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": camera["id"], "partition": None, "user": None, "metadata": {}, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", "modelKey": "event", }, ] protect_client.get_events_raw = get_events # type: ignore[method-assign] await protect_client.update() # fetch initial bootstrap await protect_client.poll_events() # process events since bootstrap camera_before.is_motion_detected = False camera = get_camera() event = camera.last_motion_event _reset_events(camera) _reset_events(camera_before) assert camera.model_dump() == camera_before.model_dump() assert event.id == expected_event_id assert event.type == EventType.MOTION assert event.thumbnail_id == f"e-{expected_event_id}" assert event.heatmap_id == f"e-{expected_event_id}" assert event.start == (now - timedelta(seconds=30)) @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_process_events_smart(protect_client: ProtectApiClient, now, camera): def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera_before = get_camera().model_copy() expected_event_id = "bf9a241afe74821ceffffd05" async def get_events(*args, **kwargs): return [ { "id": expected_event_id, "type": "smartDetectZone", "start": to_js_time(now - timedelta(seconds=30)), "end": to_js_time(now), "score": 0, "smartDetectTypes": ["person"], "smartDetectEvents": [], "camera": camera["id"], "partition": None, "user": None, "metadata": {}, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", "modelKey": "event", }, ] protect_client.get_events_raw = get_events # type: ignore[method-assign] await protect_client.update() # fetch initial bootstrap await protect_client.poll_events() # process events since bootstrap camera = get_camera() smart_event = camera.last_smart_detect_event assert camera.last_smart_detect == smart_event.start _reset_events(camera) _reset_events(camera_before) assert camera.model_dump() == camera_before.model_dump() assert smart_event.id == expected_event_id assert smart_event.type == EventType.SMART_DETECT assert smart_event.thumbnail_id == f"e-{expected_event_id}" assert smart_event.heatmap_id == f"e-{expected_event_id}" assert smart_event.start == (now - timedelta(seconds=30)) assert smart_event.end == now @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_event_return_none(protect_client: ProtectApiClient, now, camera): def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera = get_camera() _reset_events(camera) assert camera.last_smart_detect_event is None assert camera.last_nfc_card_scanned_event is None assert camera.last_motion_event is None assert camera.last_fingerprint_identified_event is None uilibs-uiprotect-317a52c/tests/test_api_public.py000066400000000000000000006005361524637705700222600ustar00rootroot00000000000000# mypy: disable-error-code="attr-defined, method-assign" """Tests for the Public Integration API extensions.""" from __future__ import annotations import asyncio import time from copy import deepcopy from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, patch import aiohttp import orjson import pytest from pydantic import ValidationError from uiprotect import api as api_module from uiprotect.api import _UNSET, RTSPSStreams, _UnsetType from uiprotect.data import ( ArmProfile, Bridge, DeviceState, Fob, FobAwayState, FobButton, FobButtonLabels, LinkStation, Liveview, NvrArmModeStatus, PublicBootstrap, PublicBridge, PublicCamera, PublicLiveview, PublicLiveviewSlot, PublicNVR, PublicSpeakerFeatureFlags, PublicSpeakerState, PublicUlpUser, PublicViewer, Relay, RelayInputActionTrigger, RelayInputActionType, RelayOutputRebootState, RelayOutputState, RelayOutputType, Siren, SirenConnectionType, Speaker, SpeakerMode, SpeakerStatus, Viewer, ) from uiprotect.data.convert import MODEL_TO_CLASS, create_from_unifi_dict from uiprotect.data.types import ( EventType, LiveviewCycleMode, ModelType, SensorScheduleMode, SirenDuration, UlpUserStatus, ) from uiprotect.data.websocket import WSAction from uiprotect.devices import DeviceChange, ProtectDeviceChange from uiprotect.exceptions import BadRequest, NotAuthorized from uiprotect.utils import convert_to_datetime from uiprotect.websocket import WebsocketState from .test_public_devices_models import CAMERA_PAYLOAD if TYPE_CHECKING: from uiprotect.api import ProtectApiClient from uiprotect.data.websocket import WSSubscriptionMessage SENSOR_ID = "66d025b301ebc903e80003ea" SIREN_ID = "672094f900e26303e800062a" RELAY_ID = "66d025b301ebc903e80003eb" FOB_ID = "66d025b301ebc903e80003ed" SPEAKER_ID = "66d025b301ebc903e80003ee" LINK_STATION_ID = "66d025b301ebc903e80003ef" ALARM_HUB_ID = "66d025b301ebc903e80003f0" PROFILE_ID = "6878d82800155803e45928e0" NVR_ID = "66d025b301ebc903e80003ec" LIVEVIEW_ID = "d65bb41c14d6aa92bfa4a6d1" OWNER_ID = "4c5f03a8c8bd48ad8e066285" BRIDGE_ID = "66d025b301ebc903e80003f1" VIEWER_ID = "66d025b301ebc903e80003f2" # Minimal raw payload matching the Public API NVR schema (v7.0 — no armMode). _NVR_RAW_BASE: dict[str, Any] = { "id": NVR_ID, "modelKey": "nvr", "name": "Test NVR", "doorbellSettings": { "defaultMessageText": "WELCOME", "defaultMessageResetTimeoutMs": 60000, "customMessages": [], "customImages": [], }, } def _make_public_nvr( client: ProtectApiClient, arm_mode: dict[str, Any] | None = None, ) -> PublicNVR: """Build a minimal :class:`PublicNVR` instance for use in tests.""" raw = deepcopy(_NVR_RAW_BASE) if arm_mode is not None: raw["armMode"] = arm_mode return PublicNVR.from_unifi_dict(**raw, api=client) def _arm_mode_raw( status: str = "disabled", profile_id: str | None = None ) -> dict[str, Any]: return { "status": status, "armProfileId": profile_id, "armedAt": None, "willBeArmedAt": None, "breachDetectedAt": None, "breachEventCount": 0, "breachTriggerEventId": None, "breachEventId": None, } def _mock_update_public_endpoints(client: ProtectApiClient, **overrides: Any) -> None: """Stub every endpoint ``update_public`` calls. Overrides win over defaults.""" defaults: dict[str, Any] = { "get_nvr_public": AsyncMock(return_value=_make_public_nvr(client)), "get_cameras_public": AsyncMock(return_value=[]), "get_lights_public": AsyncMock(return_value=[]), "get_chimes_public": AsyncMock(return_value=[]), "get_sensors_public": AsyncMock(return_value=[]), "get_sirens_public": AsyncMock(return_value=[]), "get_relays_public": AsyncMock(return_value=[]), "get_fobs_public": AsyncMock(return_value=[]), "get_speakers_public": AsyncMock(return_value=[]), "get_link_stations_public": AsyncMock(return_value=[]), "get_liveviews_public": AsyncMock(return_value=[]), "get_bridges_public": AsyncMock(return_value=[]), "get_viewers_public": AsyncMock(return_value=[]), "get_ulp_users_public": AsyncMock(return_value=[]), "_fetch_arm_profiles": AsyncMock(return_value=[]), } defaults.update(overrides) for name, value in defaults.items(): setattr(client, name, value) # --------------------------------------------------------------------------- # Camera snapshot: package channel # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_get_public_api_camera_snapshot_package( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=b"snap") result = await protect_client.get_public_api_camera_snapshot("cam-1", package=True) assert result == b"snap" _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == "/v1/cameras/cam-1/snapshot" assert kwargs["params"]["channel"] == "package" @pytest.mark.asyncio() async def test_get_public_api_camera_snapshot_no_package( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=b"snap") await protect_client.get_public_api_camera_snapshot("cam-1") _, kwargs = protect_client.api_request_raw.call_args assert "channel" not in kwargs["params"] # --------------------------------------------------------------------------- # Sensors # --------------------------------------------------------------------------- @pytest.mark.asyncio() @patch("uiprotect.api.PublicSensor.from_unifi_dict") async def test_get_sensors_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=SENSOR_ID), Mock(id="other")] protect_client.api_request_list = AsyncMock( return_value=[{"id": SENSOR_ID}, {"id": "other"}] ) result = await protect_client.get_sensors_public() assert len(result) == 2 protect_client.api_request_list.assert_called_with( url="/v1/sensors", public_api=True ) @pytest.mark.asyncio() @patch("uiprotect.api.PublicSensor.from_unifi_dict") async def test_get_sensor_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SENSOR_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SENSOR_ID}) await protect_client.get_sensor_public(SENSOR_ID) protect_client.api_request_obj.assert_called_with( url=f"/v1/sensors/{SENSOR_ID}", public_api=True ) @pytest.mark.asyncio() async def test_update_sensor_public_requires_args( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_sensor_public(SENSOR_ID) @pytest.mark.asyncio() @patch("uiprotect.api.PublicSensor.from_unifi_dict") async def test_update_sensor_public_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SENSOR_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SENSOR_ID}) await protect_client.update_sensor_public( SENSOR_ID, name="kitchen", motion_settings={"isEnabled": True, "sensitivity": 80}, ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["method"] == "patch" assert kwargs["json"] == { "name": "kitchen", "motionSettings": {"isEnabled": True, "sensitivity": 80}, } # --------------------------------------------------------------------------- # Sirens # --------------------------------------------------------------------------- @pytest.mark.asyncio() @patch("uiprotect.api.Siren.from_unifi_dict") async def test_get_sirens_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=SIREN_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": SIREN_ID}]) await protect_client.get_sirens_public() protect_client.api_request_list.assert_called_with( url="/v1/sirens", public_api=True ) @pytest.mark.asyncio() async def test_play_siren_public(protect_client: ProtectApiClient) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.play_siren_public(SIREN_ID, duration=10) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/sirens/{SIREN_ID}/play" assert kwargs["method"] == "post" assert kwargs["json"] == {"duration": 10} @pytest.mark.asyncio() async def test_play_siren_public_default_duration( protect_client: ProtectApiClient, ) -> None: """Without explicit duration, the default SirenDuration.FIVE must be sent.""" protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.play_siren_public(SIREN_ID) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["json"] == {"duration": 5} @pytest.mark.asyncio() async def test_play_siren_public_enum_duration( protect_client: ProtectApiClient, ) -> None: """Passing a SirenDuration enum value directly should be forwarded as-is.""" protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.play_siren_public(SIREN_ID, duration=SirenDuration.TWENTY) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["json"] == {"duration": SirenDuration.TWENTY} @pytest.mark.asyncio() async def test_play_siren_public_invalid_duration( protect_client: ProtectApiClient, ) -> None: """An integer that is not a valid SirenDuration value must raise BadRequest.""" with pytest.raises(BadRequest): await protect_client.play_siren_public(SIREN_ID, duration=99) @pytest.mark.asyncio() async def test_stop_siren_public(protect_client: ProtectApiClient) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.stop_siren_public(SIREN_ID) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/sirens/{SIREN_ID}/stop" assert kwargs["method"] == "post" @pytest.mark.asyncio() async def test_test_siren_sound_public(protect_client: ProtectApiClient) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.test_siren_sound_public(SIREN_ID, volume=70) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/sirens/{SIREN_ID}/test-sound" assert kwargs["json"] == {"volume": 70} @pytest.mark.asyncio() @patch("uiprotect.api.Siren.from_unifi_dict") async def test_update_siren_public_led( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SIREN_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SIREN_ID}) await protect_client.update_siren_public(SIREN_ID, volume=80, led_is_enabled=True) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == {"volume": 80, "ledSettings": {"isEnabled": True}} @pytest.mark.asyncio() async def test_update_siren_public_empty( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_siren_public(SIREN_ID) # --------------------------------------------------------------------------- # Relays # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_activate_relay_output_toggle( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.activate_relay_output_public(RELAY_ID, 0) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/relays/{RELAY_ID}/outputs/0/activate" assert kwargs["json"] is None @pytest.mark.asyncio() async def test_activate_relay_output_on_with_pulse( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.activate_relay_output_public( RELAY_ID, 1, state="on", pulse_duration_ms=500 ) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["json"] == {"state": "on", "pulseDuration": 500} @pytest.mark.asyncio() @patch("uiprotect.api.Relay.from_unifi_dict") async def test_update_relay_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=RELAY_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": RELAY_ID}) await protect_client.update_relay_public( RELAY_ID, name="garage", led_is_enabled=False ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "name": "garage", "ledSettings": {"isEnabled": False}, } # --------------------------------------------------------------------------- # Fobs # --------------------------------------------------------------------------- @pytest.mark.asyncio() @patch("uiprotect.api.Fob.from_unifi_dict") async def test_get_fobs_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=FOB_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": FOB_ID}]) result = await protect_client.get_fobs_public() protect_client.api_request_list.assert_called_with(url="/v1/fobs", public_api=True) assert len(result) == 1 assert result[0].id == FOB_ID @pytest.mark.asyncio() @patch("uiprotect.api.Fob.from_unifi_dict") async def test_get_fob_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=FOB_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": FOB_ID}) result = await protect_client.get_fob_public(FOB_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/fobs/{FOB_ID}" assert kwargs["public_api"] is True assert result.id == FOB_ID @pytest.mark.asyncio() @patch("uiprotect.api.Fob.from_unifi_dict") async def test_update_fob_public_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=FOB_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": FOB_ID}) await protect_client.update_fob_public(FOB_ID, name="Front Fob") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/fobs/{FOB_ID}" assert kwargs["method"] == "patch" assert kwargs["json"] == {"name": "Front Fob"} @pytest.mark.asyncio() async def test_update_fob_public_empty( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_fob_public(FOB_ID) # --------------------------------------------------------------------------- # Speakers # --------------------------------------------------------------------------- @pytest.mark.asyncio() @patch("uiprotect.api.Speaker.from_unifi_dict") async def test_get_speakers_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=SPEAKER_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": SPEAKER_ID}]) result = await protect_client.get_speakers_public() protect_client.api_request_list.assert_called_with( url="/v1/speakers", public_api=True ) assert len(result) == 1 assert result[0].id == SPEAKER_ID @pytest.mark.asyncio() @patch("uiprotect.api.Speaker.from_unifi_dict") async def test_get_speaker_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SPEAKER_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SPEAKER_ID}) result = await protect_client.get_speaker_public(SPEAKER_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/speakers/{SPEAKER_ID}" assert kwargs["public_api"] is True assert result.id == SPEAKER_ID @pytest.mark.asyncio() @patch("uiprotect.api.Speaker.from_unifi_dict") async def test_update_speaker_public_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SPEAKER_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SPEAKER_ID}) await protect_client.update_speaker_public( SPEAKER_ID, name="Lobby", volume=70, mic_volume=40, is_mic_enabled=False, ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/speakers/{SPEAKER_ID}" assert kwargs["method"] == "patch" assert kwargs["json"] == { "name": "Lobby", "volume": 70, "micVolume": 40, "isMicEnabled": False, } @pytest.mark.asyncio() @patch("uiprotect.api.Speaker.from_unifi_dict") async def test_update_speaker_public_partial( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SPEAKER_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SPEAKER_ID}) await protect_client.update_speaker_public(SPEAKER_ID, mic_volume=20) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == {"micVolume": 20} @pytest.mark.asyncio() async def test_update_speaker_public_empty( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_speaker_public(SPEAKER_ID) @pytest.mark.asyncio() async def test_test_speaker_sound_public( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.test_speaker_sound_public(SPEAKER_ID, volume=70) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/speakers/{SPEAKER_ID}/test-sound" assert kwargs["method"] == "post" assert kwargs["json"] == {"volume": 70} @pytest.mark.asyncio() async def test_test_speaker_sound_public_no_volume( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.test_speaker_sound_public(SPEAKER_ID) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["json"] == {} # --------------------------------------------------------------------------- # Link stations / Alarm hubs # --------------------------------------------------------------------------- _LINK_STATION_FIXTURE: dict[str, Any] = { "id": LINK_STATION_ID, "modelKey": "linkstation", "state": "CONNECTED", "name": "Garage Link", "mac": "AA:BB:CC:DD:EE:01", "isAlarmHub": False, "ledSettings": {"isEnabled": True}, "lastEvent": None, "alarmHub": None, } _ALARM_HUB_FIXTURE: dict[str, Any] = { "id": ALARM_HUB_ID, "modelKey": "linkstation", "state": "CONNECTED", "name": "Main Hub", "mac": "AA:BB:CC:DD:EE:02", "isAlarmHub": True, "ledSettings": {"isEnabled": True}, "lastEvent": 1700000000000, "alarmHub": { "12v": {"state": "on"}, "+": "positive-terminal", "-": "negative-terminal", "outputs": [{"id": 0, "state": "off"}], }, } @pytest.mark.asyncio() @patch("uiprotect.api.LinkStation.from_unifi_dict") async def test_get_link_stations_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=LINK_STATION_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": LINK_STATION_ID}]) result = await protect_client.get_link_stations_public() protect_client.api_request_list.assert_called_with( url="/v1/link-stations", public_api=True ) assert len(result) == 1 assert result[0].id == LINK_STATION_ID @pytest.mark.asyncio() @patch("uiprotect.api.LinkStation.from_unifi_dict") async def test_get_alarm_hubs_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=ALARM_HUB_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": ALARM_HUB_ID}]) result = await protect_client.get_alarm_hubs_public() protect_client.api_request_list.assert_called_with( url="/v1/alarm-hubs", public_api=True ) assert len(result) == 1 assert result[0].id == ALARM_HUB_ID @pytest.mark.asyncio() @patch("uiprotect.api.LinkStation.from_unifi_dict") async def test_get_link_station_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=LINK_STATION_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": LINK_STATION_ID}) result = await protect_client.get_link_station_public(LINK_STATION_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/link-stations/{LINK_STATION_ID}" assert kwargs["public_api"] is True assert result.id == LINK_STATION_ID @pytest.mark.asyncio() @patch("uiprotect.api.LinkStation.from_unifi_dict") async def test_get_alarm_hub_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=ALARM_HUB_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": ALARM_HUB_ID}) result = await protect_client.get_alarm_hub_public(ALARM_HUB_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/alarm-hubs/{ALARM_HUB_ID}" assert kwargs["public_api"] is True assert result.id == ALARM_HUB_ID def test_link_station_model_from_unifi_dict() -> None: ls = LinkStation.from_unifi_dict(**deepcopy(_LINK_STATION_FIXTURE)) assert ls.id == LINK_STATION_ID assert ls.model is ModelType.LINK_STATION assert ls.state is DeviceState.CONNECTED assert ls.name == "Garage Link" assert ls.is_alarm_hub is False assert ls.alarm_hub is None assert ls.last_event is None def test_alarm_hub_model_from_unifi_dict() -> None: ah = LinkStation.from_unifi_dict(**deepcopy(_ALARM_HUB_FIXTURE)) assert ah.id == ALARM_HUB_ID assert ah.is_alarm_hub is True assert ah.last_event == convert_to_datetime(1700000000000) assert ah.unifi_dict()["lastEvent"] == 1700000000000 assert ah.alarm_hub is not None assert ah.alarm_hub["12v"] == {"state": "on"} assert ah.alarm_hub["+"] == "positive-terminal" assert ah.alarm_hub["-"] == "negative-terminal" def test_link_station_model_modelkey_lowercase() -> None: """Wire ``modelKey`` for both link stations and alarm hubs is literally ``linkstation``.""" assert ModelType.LINK_STATION.value == "linkstation" ls = LinkStation.from_unifi_dict(**deepcopy(_LINK_STATION_FIXTURE)) assert ls.unifi_dict()["modelKey"] == "linkstation" @pytest.mark.asyncio() async def test_update_link_station_public_requires_args( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_link_station_public(LINK_STATION_ID) @pytest.mark.asyncio() async def test_update_alarm_hub_public_requires_args( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_alarm_hub_public(ALARM_HUB_ID) @pytest.mark.asyncio() @patch("uiprotect.api.LinkStation.from_unifi_dict") async def test_update_link_station_public_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=LINK_STATION_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": LINK_STATION_ID}) await protect_client.update_link_station_public(LINK_STATION_ID, name="Garage") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/link-stations/{LINK_STATION_ID}" assert kwargs["method"] == "patch" assert kwargs["json"] == {"name": "Garage"} @pytest.mark.asyncio() @patch("uiprotect.api.LinkStation.from_unifi_dict") async def test_update_alarm_hub_public_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=ALARM_HUB_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": ALARM_HUB_ID}) await protect_client.update_alarm_hub_public(ALARM_HUB_ID, name="Main") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/alarm-hubs/{ALARM_HUB_ID}" assert kwargs["method"] == "patch" assert kwargs["json"] == {"name": "Main"} @pytest.mark.asyncio() async def test_trigger_alarm_hub_output_public( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.trigger_alarm_hub_output_public( ALARM_HUB_ID, 0, enable=True, duration=5000 ) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/alarm-hubs/{ALARM_HUB_ID}/outputs/0/trigger" assert kwargs["method"] == "post" assert kwargs["public_api"] is True assert kwargs["json"] == {"enable": True, "duration": 5000} @pytest.mark.asyncio() async def test_trigger_alarm_hub_output_public_no_body( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.trigger_alarm_hub_output_public(ALARM_HUB_ID, 1) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/alarm-hubs/{ALARM_HUB_ID}/outputs/1/trigger" assert kwargs["json"] is None @pytest.mark.asyncio() async def test_trigger_alarm_hub_output_public_with_delay( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.trigger_alarm_hub_output_public(ALARM_HUB_ID, 0, delay=1000) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/alarm-hubs/{ALARM_HUB_ID}/outputs/0/trigger" assert kwargs["method"] == "post" assert kwargs["public_api"] is True assert kwargs["json"] == {"delay": 1000} @pytest.mark.asyncio() async def test_trigger_alarm_hub_output_public_rejects_negative_delay( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) with pytest.raises(BadRequest): await protect_client.trigger_alarm_hub_output_public(ALARM_HUB_ID, 0, delay=-1) protect_client.api_request_raw.assert_not_called() @pytest.mark.asyncio() async def test_trigger_alarm_hub_output_public_rejects_negative_duration( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) with pytest.raises(BadRequest): await protect_client.trigger_alarm_hub_output_public( ALARM_HUB_ID, 0, duration=-1 ) protect_client.api_request_raw.assert_not_called() @pytest.mark.asyncio() async def test_link_station_set_name_routes_to_link_station_endpoint( protect_client: ProtectApiClient, ) -> None: ls = LinkStation.from_unifi_dict( **deepcopy(_LINK_STATION_FIXTURE), api=protect_client ) protect_client.update_link_station_public = AsyncMock(return_value=ls) protect_client.update_alarm_hub_public = AsyncMock(return_value=ls) await ls.set_name("Renamed") protect_client.update_link_station_public.assert_awaited_once_with( LINK_STATION_ID, name="Renamed" ) protect_client.update_alarm_hub_public.assert_not_called() @pytest.mark.asyncio() async def test_link_station_set_name_routes_to_alarm_hub_endpoint( protect_client: ProtectApiClient, ) -> None: ah = LinkStation.from_unifi_dict(**deepcopy(_ALARM_HUB_FIXTURE), api=protect_client) protect_client.update_link_station_public = AsyncMock(return_value=ah) protect_client.update_alarm_hub_public = AsyncMock(return_value=ah) await ah.set_name("Renamed") protect_client.update_alarm_hub_public.assert_awaited_once_with( ALARM_HUB_ID, name="Renamed" ) protect_client.update_link_station_public.assert_not_called() @pytest.mark.asyncio() async def test_link_station_trigger_output_rejects_non_alarm_hub( protect_client: ProtectApiClient, ) -> None: ls = LinkStation.from_unifi_dict( **deepcopy(_LINK_STATION_FIXTURE), api=protect_client ) protect_client.trigger_alarm_hub_output_public = AsyncMock() with pytest.raises(BadRequest, match="Not an alarm hub"): await ls.trigger_output(0, enable=True) protect_client.trigger_alarm_hub_output_public.assert_not_called() @pytest.mark.asyncio() async def test_link_station_trigger_output_delegates_for_alarm_hub( protect_client: ProtectApiClient, ) -> None: ah = LinkStation.from_unifi_dict(**deepcopy(_ALARM_HUB_FIXTURE), api=protect_client) protect_client.trigger_alarm_hub_output_public = AsyncMock() await ah.trigger_output(0, enable=True, delay=500, duration=2000) protect_client.trigger_alarm_hub_output_public.assert_awaited_once_with( ALARM_HUB_ID, 0, enable=True, delay=500, duration=2000 ) @pytest.mark.asyncio() async def test_link_station_api_update_rejects_generic_mutations( protect_client: ProtectApiClient, ) -> None: """Generic mutation path must fail loudly for Public API link stations.""" ls = LinkStation.from_unifi_dict( **deepcopy(_LINK_STATION_FIXTURE), api=protect_client ) with pytest.raises(BadRequest, match="LinkStation mutations"): await ls._api_update({"name": "new"}) def test_public_bootstrap_routes_link_station_ws_message( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() mt, new, old = pb.process_devices_ws_message( protect_client, {"type": "add", "item": deepcopy(_LINK_STATION_FIXTURE)}, ) assert mt is ModelType.LINK_STATION assert new is not None assert old is None assert LINK_STATION_ID in pb.link_stations # Partial WS update merges into the existing object. mt, merged, prev = pb.process_devices_ws_message( protect_client, { "type": "update", "item": { "id": LINK_STATION_ID, "modelKey": "linkstation", "name": "patched", }, }, ) assert merged is not None and prev is not None assert pb.link_stations[LINK_STATION_ID].name == "patched" assert pb._warned_merge_failures == set() def test_public_bootstrap_alarm_hubs_property( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() pb.process_devices_ws_message( protect_client, {"type": "add", "item": deepcopy(_LINK_STATION_FIXTURE)}, ) pb.process_devices_ws_message( protect_client, {"type": "add", "item": deepcopy(_ALARM_HUB_FIXTURE)}, ) # link_stations carries both; alarm_hubs filters down to the hubs. assert set(pb.link_stations) == {LINK_STATION_ID, ALARM_HUB_ID} assert set(pb.alarm_hubs) == {ALARM_HUB_ID} # --------------------------------------------------------------------------- # Alarm webhook + arm profiles # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_send_alarm_webhook(protect_client: ProtectApiClient) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.send_alarm_webhook_public("my-trigger") _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == "/v1/alarm-manager/webhook/my-trigger" assert kwargs["method"] == "post" @pytest.mark.asyncio() async def test_send_alarm_webhook_empty( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.send_alarm_webhook_public("") @pytest.mark.asyncio() async def test_set_current_arm_profile_updates_cache( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) # Without an arm_mode in the cache the profile_id update is a no-op. protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap await protect_client.set_current_arm_profile_public(PROFILE_ID) # arm_mode is None (global alarm manager) — no crash, no state change. assert pb.arm_mode is None @pytest.mark.asyncio() async def test_enable_disable_arm_alarm_tracks_state( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap pb.nvr = _make_public_nvr(protect_client, arm_mode=_arm_mode_raw("disabled")) await protect_client.enable_arm_alarm_public() assert pb.arm_mode.status == NvrArmModeStatus.ARMING await protect_client.disable_arm_alarm_public() assert pb.arm_mode.status == NvrArmModeStatus.DISABLED @pytest.mark.asyncio() @patch("uiprotect.api.ArmProfile.from_unifi_dict") async def test_get_arm_profiles_populates_cache( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=PROFILE_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": PROFILE_ID}]) protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap await protect_client.get_arm_profiles_public() assert PROFILE_ID in pb.arm_profiles @pytest.mark.asyncio() @patch("uiprotect.api.ArmProfile.from_unifi_dict") async def test_get_arm_profiles_no_bootstrap( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: """Standalone fetch with no bootstrap still returns profiles without applying.""" mock_ctor.side_effect = [Mock(id=PROFILE_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": PROFILE_ID}]) protect_client._public_bootstrap = None profiles = await protect_client.get_arm_profiles_public() assert [p.id for p in profiles] == [PROFILE_ID] assert protect_client._public_bootstrap is None @pytest.mark.asyncio() async def test_update_arm_profile_empty( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_arm_profile_public(PROFILE_ID) # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- def test_siren_model_from_unifi_dict() -> None: # activatedAt and duration are both Unix-ms on the status object; only the # play request takes seconds. # Use a timestamp safely in the future so is_active stays True throughout the test. activated_at_ms = int((time.time() + 60) * 1000) siren = Siren.from_unifi_dict( id=SIREN_ID, modelKey="siren", state="CONNECTED", name="Front Siren", mac="AA:BB:CC:DD:EE:FF", volume=80, ledSettings={"isEnabled": True}, sirenStatus={ "isActive": True, "activatedAt": activated_at_ms, "duration": 5000, }, connectionType="lora", wirelessConnectionState={ "signalState": {"signalQuality": 85, "signalStrength": -45}, "batteryStatus": {"percentage": 90, "isLow": False}, "bridge": None, }, ) assert siren.id == SIREN_ID assert siren.model is ModelType.SIREN assert siren.volume == 80 assert siren.is_active is True # When the timer has expired the siren should appear inactive even if # sirenStatus.isActive is still True (server sends no stop event). expired_at_ms = int((time.time() - 10) * 1000) # 10 s in the past siren_expired = Siren.from_unifi_dict( id=SIREN_ID, modelKey="siren", state="CONNECTED", name="Front Siren", mac="AA:BB:CC:DD:EE:FF", volume=80, ledSettings={"isEnabled": True}, sirenStatus={"isActive": True, "activatedAt": expired_at_ms, "duration": 5000}, connectionType="lora", wirelessConnectionState={ "signalState": {"signalQuality": 85, "signalStrength": -45}, "batteryStatus": {"percentage": 90, "isLow": False}, "bridge": None, }, ) assert siren_expired.is_active is False # Manual stop before the timer elapsed: server clears isActive but may # leave activatedAt/duration populated. The clock check would still say # "active"; the server flag must win. future_at_ms = int((time.time() + 60) * 1000) siren_stopped = Siren.from_unifi_dict( id=SIREN_ID, modelKey="siren", state="CONNECTED", name="Front Siren", mac="AA:BB:CC:DD:EE:FF", volume=80, ledSettings={"isEnabled": True}, sirenStatus={"isActive": False, "activatedAt": future_at_ms, "duration": 5000}, connectionType="lora", wirelessConnectionState={ "signalState": {"signalQuality": 85, "signalStrength": -45}, "batteryStatus": {"percentage": 90, "isLow": False}, "bridge": None, }, ) assert siren_stopped.is_active is False def test_siren_state_and_connection_type_round_trip() -> None: activated_at_ms = int((time.time() + 60) * 1000) payload: dict[str, Any] = { "id": SIREN_ID, "modelKey": "siren", "state": "CONNECTED", "name": "Front Siren", "mac": "AA:BB:CC:DD:EE:FF", "volume": 80, "ledSettings": {"isEnabled": True}, "sirenStatus": { "isActive": True, "activatedAt": activated_at_ms, "duration": 5000, }, "connectionType": "lora", "wirelessConnectionState": { "signalState": {"signalQuality": 85, "signalStrength": -45}, "batteryStatus": {"percentage": 90, "isLow": False}, "bridge": None, }, } siren = Siren.from_unifi_dict(**payload) assert siren.state is DeviceState.CONNECTED assert siren.connection_type is SirenConnectionType.LORA wire = siren.unifi_dict() assert wire["state"] == "CONNECTED" assert wire["connectionType"] == "lora" def test_siren_state_and_connection_type_coerce_unknown() -> None: activated_at_ms = int((time.time() + 60) * 1000) siren = Siren.from_unifi_dict( id=SIREN_ID, modelKey="siren", state="FUTURE_STATE", name="Front Siren", mac="AA:BB:CC:DD:EE:FF", volume=80, ledSettings={"isEnabled": True}, sirenStatus={ "isActive": True, "activatedAt": activated_at_ms, "duration": 5000, }, connectionType="future-radio", wirelessConnectionState={ "signalState": {"signalQuality": 85, "signalStrength": -45}, "batteryStatus": {"percentage": 90, "isLow": False}, "bridge": None, }, ) assert siren.state is DeviceState.UNKNOWN assert siren.connection_type is SirenConnectionType.UNKNOWN def test_relay_model_from_unifi_dict() -> None: relay = Relay.from_unifi_dict( id=RELAY_ID, modelKey="relay", state="CONNECTED", name="Garage Relay", mac="AA:BB:CC:DD:EE:FF", ledSettings={"isEnabled": True}, outputs=[ { "id": 0, "name": "Garage Door", "type": "garageDoor", "delay": 0, "pulseDuration": 500, "state": "off", "rebootState": "off", } ], inputs=[ { "id": 0, "name": "Trigger A", "state": "off", "actionTrigger": "switchedOn", "actionType": "toggleOutput", "actionOutputId": 0, } ], wirelessConnectionState={ "signalState": {"signalQuality": 85, "signalStrength": -45}, "batteryStatus": {"percentage": 90, "isLow": False}, "bridge": None, }, ) assert relay.id == RELAY_ID assert relay.model is ModelType.RELAY assert relay.state is DeviceState.CONNECTED out = relay.get_output(0) assert out is not None assert out.name == "Garage Door" assert out.type is RelayOutputType.GARAGE_DOOR assert out.state is RelayOutputState.OFF assert out.reboot_state is RelayOutputRebootState.OFF assert relay.get_output(5) is None inp = relay.inputs[0] assert inp.action_trigger is RelayInputActionTrigger.SWITCHED_ON assert inp.action_type is RelayInputActionType.TOGGLE_OUTPUT def test_fob_model_from_unifi_dict_unreported() -> None: """Null signal/battery parses; state/away/buttons coerce to enums.""" fob = Fob.from_unifi_dict( id=FOB_ID, modelKey="fob", state="CONNECTED", name="Front Fob", mac="AA:BB:CC:DD:EE:FF", awayState="ONLINE", buttonLabels="securityActions", featureFlags={"buttons": ["arm", "disarm", "panic", "main"]}, wirelessConnectionState={ "signalState": {"signalQuality": None, "signalStrength": None}, "batteryStatus": {"percentage": None, "isLow": False}, "bridge": None, }, ) assert fob.id == FOB_ID assert fob.model is ModelType.FOB assert fob.state is DeviceState.CONNECTED assert fob.away_state is FobAwayState.ONLINE assert fob.feature_flags.buttons == [ FobButton.ARM, FobButton.DISARM, FobButton.PANIC, FobButton.MAIN, ] assert fob.wireless_connection_state.signal_state.signal_quality is None assert fob.wireless_connection_state.signal_state.signal_strength is None assert fob.wireless_connection_state.battery_status.percentage is None assert fob.wireless_connection_state.battery_status.is_low is False # Unknown server values (future firmware) coerce to the ``UNKNOWN`` member # rather than raising. fob_unknown = Fob.from_unifi_dict( id=FOB_ID, modelKey="fob", state="REBOOTING", name="Bare Fob", mac="AA:BB:CC:DD:EE:FF", awayState="FORGOTTEN", buttonLabels="securityActions", featureFlags={"buttons": ["teleport"]}, wirelessConnectionState={ "signalState": {"signalQuality": None, "signalStrength": None}, "batteryStatus": {"percentage": None, "isLow": False}, "bridge": None, }, ) assert fob_unknown.state is DeviceState.UNKNOWN assert fob_unknown.away_state is FobAwayState.UNKNOWN assert fob_unknown.feature_flags.buttons == [FobButton.UNKNOWN] # ``name`` is nullable on the wire. fob_null_name = Fob.from_unifi_dict( id=FOB_ID, modelKey="fob", state="CONNECTED", name=None, mac="AA:BB:CC:DD:EE:FF", awayState="ONLINE", buttonLabels="securityActions", featureFlags={"buttons": ["arm"]}, wirelessConnectionState={ "signalState": {"signalQuality": None, "signalStrength": None}, "batteryStatus": {"percentage": None, "isLow": False}, "bridge": None, }, ) assert fob_null_name.name is None def test_fob_button_labels_parses_and_round_trips() -> None: """``buttonLabels`` types to ``FobButtonLabels``; unknown styles coerce.""" payload: dict[str, Any] = { "id": FOB_ID, "modelKey": "fob", "state": "CONNECTED", "name": "Front Fob", "mac": "AA:BB:CC:DD:EE:FF", "awayState": "ONLINE", "buttonLabels": "securityActions", "featureFlags": {"buttons": ["arm"]}, "wirelessConnectionState": { "signalState": {"signalQuality": None, "signalStrength": None}, "batteryStatus": {"percentage": None, "isLow": False}, "bridge": None, }, } fob = Fob.from_unifi_dict(**payload) assert fob.button_labels is FobButtonLabels.SECURITY_ACTIONS assert fob.unifi_dict()["buttonLabels"] == "securityActions" unknown = Fob.from_unifi_dict(**{**payload, "buttonLabels": "hieroglyphs"}) assert unknown.button_labels is FobButtonLabels.UNKNOWN with pytest.raises(ValidationError): Fob.from_unifi_dict(**{k: v for k, v in payload.items() if k != "buttonLabels"}) def test_public_bootstrap_applies_fob_add_and_update( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() add_payload = { "type": "add", "item": { "id": FOB_ID, "modelKey": "fob", "state": "CONNECTED", "name": "Fob", "mac": "AA", "awayState": "ONLINE", "buttonLabels": "securityActions", "featureFlags": {"buttons": ["arm", "disarm"]}, "wirelessConnectionState": { "signalState": {"signalQuality": None, "signalStrength": None}, "batteryStatus": {"percentage": None, "isLow": False}, "bridge": None, }, }, } mt, new, old = pb.process_devices_ws_message(protect_client, add_payload) assert mt is ModelType.FOB assert new is not None and new.id == FOB_ID assert old is None assert FOB_ID in pb.fobs # Partial update — only the changed field on the wire. update_payload: dict[str, Any] = { "type": "update", "item": {"id": FOB_ID, "modelKey": "fob", "awayState": "DEVICE_LOST"}, } mt, new, old = pb.process_devices_ws_message(protect_client, update_payload) assert old is not None assert new is not None assert new.away_state == "DEVICE_LOST" # type: ignore[attr-defined] # Other fields preserved from the cached object. assert new.name == "Fob" # type: ignore[attr-defined] # A ``name: null`` partial update must merge, not drop the frame. name_null_payload: dict[str, Any] = { "type": "update", "item": {"id": FOB_ID, "modelKey": "fob", "name": None}, } _, new, _ = pb.process_devices_ws_message(protect_client, name_null_payload) assert new is not None and new.name is None # type: ignore[attr-defined] def _speaker_raw(**overrides: Any) -> dict[str, Any]: raw: dict[str, Any] = { "id": SPEAKER_ID, "modelKey": "speaker", "state": "CONNECTED", "name": "Lobby Speaker", "mac": "AA:BB:CC:DD:EE:FF", "volume": 80, "micVolume": 50, "isMicEnabled": True, "speakerState": {"status": "idle", "mode": "listen"}, "featureFlags": {"hasMic": True}, } raw.update(overrides) return raw def test_speaker_model_from_unifi_dict() -> None: raw = _speaker_raw(name=None) speaker = Speaker.from_unifi_dict(**raw) assert speaker.id == SPEAKER_ID assert speaker.model is ModelType.SPEAKER assert speaker.state is DeviceState.CONNECTED assert speaker.name is None assert speaker.mac == "AA:BB:CC:DD:EE:FF" assert speaker.volume == 80 assert speaker.mic_volume == 50 assert speaker.is_mic_enabled is True assert isinstance(speaker.speaker_state, PublicSpeakerState) assert speaker.speaker_state.status is SpeakerStatus.IDLE assert speaker.speaker_state.mode is SpeakerMode.LISTEN assert isinstance(speaker.feature_flags, PublicSpeakerFeatureFlags) assert speaker.feature_flags.has_mic is True def test_speaker_model_unknown_values() -> None: """Unknown server values for ``state`` / ``speakerState`` coerce to ``UNKNOWN``.""" raw = _speaker_raw( state="REBOOTING", speakerState={"status": "encoding", "mode": "ambient"}, ) speaker = Speaker.from_unifi_dict(**raw) assert speaker.state is DeviceState.UNKNOWN assert speaker.speaker_state.status is SpeakerStatus.UNKNOWN assert speaker.speaker_state.mode is SpeakerMode.UNKNOWN def test_public_bootstrap_applies_speaker_add_and_update( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() add_payload = {"type": "add", "item": _speaker_raw()} mt, new, old = pb.process_devices_ws_message(protect_client, add_payload) assert mt is ModelType.SPEAKER assert new is not None and new.id == SPEAKER_ID assert old is None assert SPEAKER_ID in pb.speakers # Partial update — only the changed field on the wire. Verify untouched # sub-models survive the merge. update_payload: dict[str, Any] = { "type": "update", "item": {"id": SPEAKER_ID, "modelKey": "speaker", "volume": 90}, } mt, new, old = pb.process_devices_ws_message(protect_client, update_payload) assert old is not None assert new is not None assert new.volume == 90 # type: ignore[attr-defined] assert new.name == "Lobby Speaker" # type: ignore[attr-defined] assert new.speaker_state.status is SpeakerStatus.IDLE # type: ignore[attr-defined] assert new.feature_flags.has_mic is True # type: ignore[attr-defined] # A ``name: null`` partial update must merge as ``None``. name_null_payload: dict[str, Any] = { "type": "update", "item": {"id": SPEAKER_ID, "modelKey": "speaker", "name": None}, } _, new, _ = pb.process_devices_ws_message(protect_client, name_null_payload) assert new is not None and new.name is None # type: ignore[attr-defined] def test_arm_profile_model_from_unifi_dict() -> None: profile = ArmProfile.from_unifi_dict( id=PROFILE_ID, name="Night", automations=["a1", "a2"], creator="user-1", schedules=[{"start": "0 22 * * *", "end": "0 6 * * *"}], recordEverything=True, activationDelay=60000, createdAt=1600000000000, updatedAt=1600000100000, ) assert profile.id == PROFILE_ID assert profile.name == "Night" assert profile.record_everything is True assert profile.activation_delay == 60000 assert len(profile.schedules) == 1 def test_arm_profile_model_iso_timestamps() -> None: """API returns ISO-8601 strings for createdAt/updatedAt — must parse correctly.""" profile = ArmProfile.from_unifi_dict( id=PROFILE_ID, name="Day", automations=[], creator="user-1", schedules=[], recordEverything=False, activationDelay=0, createdAt="2026-04-23T18:15:43.213Z", updatedAt="2026-04-24T09:00:00.000Z", ) assert profile.created_at is not None assert profile.updated_at is not None assert profile.created_at.year == 2026 assert profile.created_at.month == 4 assert profile.created_at.day == 23 assert profile.updated_at.day == 24 # --------------------------------------------------------------------------- # PublicBootstrap WS apply # --------------------------------------------------------------------------- def test_public_bootstrap_applies_add_and_update( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() # Wired to the client so the siren-expiry scheduling hook actually runs. protect_client._public_bootstrap = pb add_payload = { "type": "add", "item": { "id": SIREN_ID, "modelKey": "siren", "state": "CONNECTED", "name": "Siren", "mac": "AA", "volume": 50, "ledSettings": {"isEnabled": True}, "sirenStatus": {"isActive": False, "activatedAt": None, "duration": None}, "connectionType": "lora", "wirelessConnectionState": { "signalState": {"signalQuality": 80, "signalStrength": -50}, "batteryStatus": {"percentage": 100, "isLow": False}, "bridge": None, }, }, } mt, new, old = pb.process_devices_ws_message(protect_client, add_payload) assert mt is ModelType.SIREN assert new is not None and new.id == SIREN_ID assert old is None assert SIREN_ID in pb.sirens # Partial update — only changed fields on the wire (realistic). update_payload: dict[str, Any] = { "type": "update", "item": {"id": SIREN_ID, "modelKey": "siren", "volume": 10}, } mt, new, old = pb.process_devices_ws_message(protect_client, update_payload) assert old is not None assert new is not None and new.volume == 10 # type: ignore[attr-defined] # Other fields must be preserved from the cached object. assert new.name == "Siren" # type: ignore[attr-defined] # Partial update of a nested model (``sirenStatus.isActive``). # activatedAt and duration are both Unix-ms — use a timestamp safely in the # future so turn_off_at lies in the future for the whole test. future_ms = int((time.time() + 60) * 1000) status_payload: dict[str, Any] = { "type": "update", "item": { "id": SIREN_ID, "modelKey": "siren", "sirenStatus": { "isActive": True, "activatedAt": future_ms, "duration": 5000, }, }, } mt, new, old = pb.process_devices_ws_message(protect_client, status_payload) assert new is not None and new.is_active is True # type: ignore[attr-defined] assert new.siren_status.is_active is True # type: ignore[attr-defined] # Update for an id that isn't in the cache → dropped. stranger: dict[str, Any] = { "type": "update", "item": {"id": "unknown", "modelKey": "siren", "volume": 1}, } mt, new, old = pb.process_devices_ws_message(protect_client, stranger) assert new is None and old is None # remove remove_payload = { "type": "remove", "item": {"id": SIREN_ID, "modelKey": "siren"}, } mt, new, old = pb.process_devices_ws_message(protect_client, remove_payload) assert new is None assert old is not None assert SIREN_ID not in pb.sirens def test_public_bootstrap_ignores_unknown_model( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() _mt, new, old = pb.process_devices_ws_message( protect_client, {"type": "add", "item": {"id": "x", "modelKey": "nope"}}, ) # ModelType.from_string returns UNKNOWN for unknown values, which is not # in the store map, so no object is created. assert new is None assert old is None def test_public_bootstrap_warning_state_is_per_instance() -> None: """Warning dedupe keys must not leak across multiple client instances.""" first = PublicBootstrap() second = PublicBootstrap() key = ("add", "siren") first._warned_merge_failures.add(key) assert key in first._warned_merge_failures assert key not in second._warned_merge_failures # --------------------------------------------------------------------------- # update_public wires everything together # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_update_public_populates_cache( protect_client: ProtectApiClient, ) -> None: _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[Mock(id="c1")]), get_lights_public=AsyncMock(return_value=[Mock(id="l1")]), get_sensors_public=AsyncMock(return_value=[Mock(id="s1")]), get_sirens_public=AsyncMock( return_value=[Mock(id="si1", siren_status=Mock(turn_off_at=None))] ), get_relays_public=AsyncMock(return_value=[Mock(id="r1")]), get_fobs_public=AsyncMock(return_value=[Mock(id="fb1")]), get_speakers_public=AsyncMock(return_value=[Mock(id="sp1")]), ) pb = await protect_client.update_public() assert "c1" in pb.cameras assert "l1" in pb.lights assert "s1" in pb.sensors assert "si1" in pb.sirens assert "r1" in pb.relays assert "fb1" in pb.fobs assert "sp1" in pb.speakers protect_client._fetch_arm_profiles.assert_awaited_once() assert pb.nvr is not None @pytest.mark.asyncio() async def test_update_public_populates_ulp_users( protect_client: ProtectApiClient, ) -> None: user = PublicUlpUser( api=protect_client, id="ulp-1", model=ModelType.ULP_USER, first_name="A", last_name="B", full_name="A B", status=UlpUserStatus.ACTIVE, ) _mock_update_public_endpoints( protect_client, get_ulp_users_public=AsyncMock(return_value=[user]), ) pb = await protect_client.update_public() assert pb.ulp_users == {"ulp-1": user} @pytest.mark.asyncio() async def test_update_public_tolerates_ulp_users_disabled( protect_client: ProtectApiClient, ) -> None: ulp_mock = AsyncMock(side_effect=NotAuthorized("identity off")) _mock_update_public_endpoints( protect_client, get_ulp_users_public=ulp_mock, ) pb = await protect_client.update_public() assert pb.ulp_users == {} ulp_mock.assert_awaited_once() @pytest.mark.asyncio() async def test_update_public_tolerates_missing_endpoints( protect_client: ProtectApiClient, ) -> None: _mock_update_public_endpoints( protect_client, get_sensors_public=AsyncMock(side_effect=BadRequest("404")), get_sirens_public=AsyncMock(side_effect=BadRequest("404")), get_relays_public=AsyncMock(side_effect=BadRequest("404")), _fetch_arm_profiles=AsyncMock(side_effect=BadRequest("404")), ) pb = await protect_client.update_public() assert pb.sensors == {} assert pb.sirens == {} assert pb.relays == {} @pytest.mark.asyncio() async def test_update_public_tolerates_every_endpoint_failing( protect_client: ProtectApiClient, ) -> None: """Even core endpoints (cameras/lights/chimes/nvr) are best-effort.""" _mock_update_public_endpoints( protect_client, get_nvr_public=AsyncMock(side_effect=BadRequest("X")), get_cameras_public=AsyncMock(side_effect=BadRequest("X")), get_lights_public=AsyncMock(side_effect=BadRequest("X")), get_chimes_public=AsyncMock(side_effect=BadRequest("X")), get_sensors_public=AsyncMock(side_effect=BadRequest("X")), get_sirens_public=AsyncMock(side_effect=BadRequest("X")), get_relays_public=AsyncMock(side_effect=BadRequest("X")), _fetch_arm_profiles=AsyncMock(side_effect=BadRequest("X")), ) pb = await protect_client.update_public() assert pb.cameras == {} assert pb.lights == {} assert pb.chimes == {} assert pb.nvr is None @pytest.mark.asyncio() async def test_update_public_tolerates_arm_profiles_failure( protect_client: ProtectApiClient, ) -> None: """ Firmware without alarm-manager endpoints: _fetch_arm_profiles raises; update_public must still complete, leave arm_profiles empty, and not propagate. """ _mock_update_public_endpoints( protect_client, _fetch_arm_profiles=AsyncMock(side_effect=BadRequest("404")), ) pb = await protect_client.update_public() assert pb.arm_profiles == {} assert pb.nvr is not None assert pb.arm_mode is None protect_client._fetch_arm_profiles.assert_awaited_once() def _siren_snapshot_item() -> dict[str, Any]: """A full siren payload usable as an ``update_public`` snapshot device.""" return { "id": SIREN_ID, "modelKey": "siren", "state": "CONNECTED", "name": "Siren", "mac": "AA", "volume": 50, "ledSettings": {"isEnabled": True}, "sirenStatus": {"isActive": False, "activatedAt": None, "duration": None}, "connectionType": "lora", "wirelessConnectionState": { "signalState": {"signalQuality": 80, "signalStrength": -50}, "batteryStatus": {"percentage": 100, "isLow": False}, "bridge": None, }, } def _mock_text_ws_message(payload: dict[str, Any]) -> Mock: """Build a TEXT public-WS message mock carrying ``payload``.""" msg = Mock() msg.type = aiohttp.WSMsgType.TEXT msg.data = orjson.dumps(payload) return msg @pytest.mark.asyncio() async def test_update_public_replays_ws_frames_from_prime_window( protect_client: ProtectApiClient, ) -> None: """A devices-WS update landing mid-prime is replayed onto the fresh snapshot.""" client = protect_client siren = Siren.from_unifi_dict(api=client, **deepcopy(_siren_snapshot_item())) update_msg = _mock_text_ws_message( {"type": "update", "item": {"id": SIREN_ID, "modelKey": "siren", "volume": 10}} ) captured: list[WSSubscriptionMessage] = [] client._devices_ws_subscriptions.append(captured.append) async def cameras_side_effect() -> list[Any]: # Simulate a frame arriving while the snapshot is still being fetched: # it must be buffered (not applied to the empty cache) and not yet # delivered to the subscriber. client._process_devices_ws_message(update_msg) assert client._prime_ws_buffer assert captured == [] return [] _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(return_value=[siren]), get_cameras_public=AsyncMock(side_effect=cameras_side_effect), ) pb = await client.update_public() # Snapshot volume (50) overwritten by the replayed WS diff (10). assert pb.sirens[SIREN_ID].volume == 10 assert client._prime_ws_buffer is None # Exactly one merged frame delivered, after priming. assert len(captured) == 1 assert captured[0].action == WSAction.UPDATE assert captured[0].new_obj is not None assert captured[0].new_obj.volume == 10 @pytest.mark.asyncio() async def test_update_public_rearms_siren_expiry_on_the_snapshot_deadline( protect_client: ProtectApiClient, ) -> None: """A snapshot superseding a live timed run re-arms against its own deadline.""" client = protect_client client._public_bootstrap = PublicBootstrap() running = _siren_snapshot_item() running["sirenStatus"] = { "isActive": True, "activatedAt": int((time.time() + 55) * 1000), "duration": 5000, } client._public_bootstrap.sirens[SIREN_ID] = Siren.from_unifi_dict( api=client, **running ) client._schedule_siren_off(SIREN_ID) superseded = client._siren_off_tasks[SIREN_ID] # The snapshot reports a run that has already ended. ended = _siren_snapshot_item() ended["sirenStatus"] = { "isActive": True, "activatedAt": int((time.time() - 30) * 1000), "duration": 5000, } _mock_update_public_endpoints( client, get_sirens_public=AsyncMock( return_value=[Siren.from_unifi_dict(api=client, **ended)] ), ) captured: list[WSSubscriptionMessage] = [] client._devices_ws_subscriptions.append(captured.append) pb = await client.update_public() armed = client._siren_off_tasks[SIREN_ID] assert armed is not superseded await armed assert pb.sirens[SIREN_ID].siren_status.is_active is False assert [msg.new_update_id for msg in captured] == [SIREN_ID] @pytest.mark.asyncio() async def test_update_public_replays_events_ws_frames_from_prime_window( protect_client: ProtectApiClient, ) -> None: """An events-WS frame landing mid-prime is buffered and replayed.""" client = protect_client add_msg = _mock_text_ws_message( { "type": "add", "item": { "id": "evt-1", "modelKey": "event", "type": "motion", "start": 1234567890, }, } ) captured: list[WSSubscriptionMessage] = [] client._events_ws_subscriptions.append(captured.append) async def cameras_side_effect() -> list[Any]: client._process_events_ws_message(add_msg) assert client._prime_ws_buffer assert captured == [] return [] _mock_update_public_endpoints( client, get_cameras_public=AsyncMock(side_effect=cameras_side_effect) ) await client.update_public() assert client._prime_ws_buffer is None # Replayed after priming; the primed cache builds a merged event object. assert len(captured) == 1 assert captured[0].action == WSAction.ADD assert captured[0].new_obj is not None @pytest.mark.asyncio() async def test_update_public_disarms_buffer_when_prime_fails( protect_client: ProtectApiClient, ) -> None: """A failed prime tears down the buffer but still delivers its frames.""" client = protect_client client._public_bootstrap = PublicBootstrap() siren = Siren.from_unifi_dict(api=client, **deepcopy(_siren_snapshot_item())) client._public_bootstrap.sirens[SIREN_ID] = siren update_msg = _mock_text_ws_message( {"type": "update", "item": {"id": SIREN_ID, "modelKey": "siren", "volume": 10}} ) captured: list[WSSubscriptionMessage] = [] client._devices_ws_subscriptions.append(captured.append) async def cameras_side_effect() -> list[Any]: client._process_devices_ws_message(update_msg) raise NotAuthorized("boom") _mock_update_public_endpoints( client, get_cameras_public=AsyncMock(side_effect=cameras_side_effect), ) with pytest.raises(NotAuthorized): await client.update_public() assert client._prime_ws_buffer is None # The frame is not swallowed: it drains onto the previous cache (the # pre-buffering live behavior) and reaches the subscriber. assert client._public_bootstrap.sirens[SIREN_ID].volume == 10 assert len(captured) == 1 assert captured[0].action == WSAction.UPDATE @pytest.mark.asyncio() async def test_update_public_concurrent_calls_serialize( protect_client: ProtectApiClient, ) -> None: """ Concurrent primes serialize: the second fetches after the first drains. Without serialization, the second prime could apply an older snapshot over the frame the first prime replayed, losing the update. """ client = protect_client siren = Siren.from_unifi_dict(api=client, **deepcopy(_siren_snapshot_item())) update_msg = _mock_text_ws_message( {"type": "update", "item": {"id": SIREN_ID, "modelKey": "siren", "volume": 10}} ) captured: list[WSSubscriptionMessage] = [] client._devices_ws_subscriptions.append(captured.append) order: list[str] = [] first_fetch_started = asyncio.Event() release_first = asyncio.Event() async def cameras_side_effect() -> list[Any]: if not order: order.append("first-fetch") first_fetch_started.set() # A frame lands in the first prime window... client._process_devices_ws_message(update_msg) # ...and the first prime is held open while the second call starts. await release_first.wait() return [] order.append("second-fetch") # The second prime only fetches after the first drained its buffer. assert client._prime_ws_buffer == [] assert len(captured) == 1 return [] _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(return_value=[siren]), get_cameras_public=AsyncMock(side_effect=cameras_side_effect), ) task1 = asyncio.create_task(client.update_public()) await first_fetch_started.wait() task2 = asyncio.create_task(client.update_public()) await asyncio.sleep(0) release_first.set() pb1 = await task1 pb2 = await task2 assert order == ["first-fetch", "second-fetch"] # The buffered frame is delivered exactly once and nothing regresses it. assert len(captured) == 1 assert client._prime_ws_buffer is None assert pb1 is pb2 @pytest.mark.asyncio() async def test_subscribe_devices_and_prime_is_order_independent( protect_client: ProtectApiClient, ) -> None: """The combined entrypoint delivers a mid-prime frame to the typed sub.""" client = protect_client client._public_bootstrap = None siren = Siren.from_unifi_dict(api=client, **deepcopy(_siren_snapshot_item())) update_msg = _mock_text_ws_message( {"type": "update", "item": {"id": SIREN_ID, "modelKey": "siren", "volume": 10}} ) received: list[ProtectDeviceChange] = [] async def sirens_side_effect() -> list[Any]: client._process_devices_ws_message(update_msg) return [siren] _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(side_effect=sirens_side_effect) ) with patch.object(client, "_get_devices_websocket", return_value=Mock()): unsub = await client.subscribe_devices_and_prime(received.append) assert client.has_public_bootstrap assert client.public_bootstrap.sirens[SIREN_ID].volume == 10 assert [(c.device_id, c.change) for c in received if c.device_id == SIREN_ID] == [ (SIREN_ID, DeviceChange.UPDATED) ] unsub() @pytest.mark.asyncio() async def test_subscribe_devices_and_prime_rolls_back_on_failure( protect_client: ProtectApiClient, ) -> None: """A prime failure unsubscribes the typed device sub before propagating.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints( client, get_cameras_public=AsyncMock(side_effect=NotAuthorized("boom")) ) with ( patch.object(client, "_get_devices_websocket", return_value=Mock()), pytest.raises(NotAuthorized), ): await client.subscribe_devices_and_prime(lambda _c: None) assert client._device_dispatcher is not None assert client._device_dispatcher.subscriber_count == 0 @pytest.mark.asyncio() async def test_subscribe_events_and_prime_primes_and_registers( protect_client: ProtectApiClient, ) -> None: """The events combined entrypoint primes and registers without prior prime.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints(client) with patch.object(client, "_get_events_websocket", return_value=Mock()): unsub = await client.subscribe_events_and_prime(lambda _e, _c: None) assert client.has_public_bootstrap assert client._event_dispatcher is not None assert client._event_dispatcher.subscriber_count == 1 unsub() assert client._event_dispatcher.subscriber_count == 0 @pytest.mark.asyncio() async def test_subscribe_events_and_prime_rolls_back_on_failure( protect_client: ProtectApiClient, ) -> None: """A prime failure unsubscribes the typed event sub before propagating.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints( client, get_cameras_public=AsyncMock(side_effect=NotAuthorized("boom")) ) with ( patch.object(client, "_get_events_websocket", return_value=Mock()), pytest.raises(NotAuthorized), ): await client.subscribe_events_and_prime(lambda _e, _c: None) assert client._event_dispatcher is not None assert client._event_dispatcher.subscriber_count == 0 # --------------------------------------------------------------------------- # Relay pulse guard & dict access # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_activate_relay_output_pulse_without_on_raises( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.activate_relay_output_public( RELAY_ID, 0, pulse_duration_ms=500 ) @pytest.mark.asyncio() async def test_activate_relay_output_pulse_with_off_raises( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.activate_relay_output_public( RELAY_ID, 0, state="off", pulse_duration_ms=500 ) def test_relay_getitem_and_get_output() -> None: relay = Relay.from_unifi_dict( id=RELAY_ID, modelKey="relay", state="CONNECTED", name="R", mac="AA", ledSettings={"isEnabled": True}, outputs=[ { "id": 0, "name": "Out A", "type": "generic", "delay": 0, "pulseDuration": 200, "state": "off", "rebootState": "off", }, { "id": 1, "name": "Out B", "type": "generic", "delay": 10, "pulseDuration": 100, "state": "on", "rebootState": "off", }, ], inputs=[], ) assert relay[1].name == "Out B" assert relay[1].state is RelayOutputState.ON assert relay[0].state is RelayOutputState.OFF assert relay.get_output(1) is not None with pytest.raises(KeyError): relay[99] def test_relay_output_unknown_state_does_not_raise() -> None: """Forward-compat: an unknown output state from newer firmware coerces to UNKNOWN.""" relay = Relay.from_unifi_dict( id=RELAY_ID, modelKey="relay", state="CONNECTED", name="R", mac="AA", ledSettings={"isEnabled": True}, outputs=[ { "id": 0, "name": None, "type": None, "delay": None, "pulseDuration": None, "state": "totally_new_state_from_future_firmware", "rebootState": None, } ], inputs=[], ) assert relay.outputs[0].state is RelayOutputState.UNKNOWN def test_relay_unknown_enum_values_do_not_raise() -> None: """Forward-compat: unknown future-firmware values coerce to UNKNOWN for the new enums.""" relay = Relay.from_unifi_dict( id=RELAY_ID, modelKey="relay", state="CONNECTED", name="R", mac="AA", ledSettings={"isEnabled": True}, outputs=[ { "id": 0, "name": None, "type": "newTypeNotYetInSpec", "delay": None, "pulseDuration": None, "state": None, "rebootState": None, } ], inputs=[ { "id": 0, "name": None, "state": None, "actionTrigger": "newTriggerNotYetInSpec", "actionType": "newActionNotYetInSpec", "actionOutputId": None, } ], ) assert relay.outputs[0].type is RelayOutputType.UNKNOWN assert relay.inputs[0].action_trigger is RelayInputActionTrigger.UNKNOWN assert relay.inputs[0].action_type is RelayInputActionType.UNKNOWN # --------------------------------------------------------------------------- # URL-quoting of trigger ids # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_send_alarm_webhook_quotes_id( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.send_alarm_webhook_public("my trigger/with+special") _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == ("/v1/alarm-manager/webhook/my%20trigger%2Fwith%2Bspecial") # --------------------------------------------------------------------------- # Arm-manager settings (from NVR armMode) # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_get_arm_manager_settings_returns_from_cache( protect_client: ProtectApiClient, ) -> None: protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap pb.nvr = _make_public_nvr( protect_client, arm_mode=_arm_mode_raw("armed", PROFILE_ID) ) result = await protect_client.get_arm_manager_settings_public() assert result is pb.nvr.arm_mode assert result is not None assert result.arm_profile_id == PROFILE_ID @pytest.mark.asyncio() async def test_get_arm_manager_settings_fetches_nvr_when_no_cache( protect_client: ProtectApiClient, ) -> None: protect_client._public_bootstrap = None arm_mode = _arm_mode_raw("disabled", PROFILE_ID) protect_client.get_nvr_public = AsyncMock( return_value=_make_public_nvr(protect_client, arm_mode=arm_mode) ) result = await protect_client.get_arm_manager_settings_public() assert result is not None assert result.arm_profile_id == PROFILE_ID protect_client.get_nvr_public.assert_called_once() @pytest.mark.asyncio() async def test_get_arm_manager_settings_returns_none_when_nvr_has_no_arm_mode( protect_client: ProtectApiClient, ) -> None: protect_client._public_bootstrap = None protect_client.get_nvr_public = AsyncMock( return_value=_make_public_nvr(protect_client, arm_mode=None) ) result = await protect_client.get_arm_manager_settings_public() assert result is None protect_client.get_nvr_public.assert_called_once() @pytest.mark.asyncio() async def test_set_current_arm_profile_updates_arm_mode_profile_id( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_raw = AsyncMock(return_value=None) protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap pb.nvr = _make_public_nvr(protect_client, arm_mode=_arm_mode_raw("disabled")) await protect_client.set_current_arm_profile_public(PROFILE_ID) assert pb.arm_mode.arm_profile_id == PROFILE_ID # --------------------------------------------------------------------------- # Typed settings are forwarded as plain dict # --------------------------------------------------------------------------- @pytest.mark.asyncio() @patch("uiprotect.api.PublicSensor.from_unifi_dict") async def test_update_sensor_public_all_settings( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SENSOR_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SENSOR_ID}) await protect_client.update_sensor_public( SENSOR_ID, light_settings={"isEnabled": True, "lowThreshold": 10}, humidity_settings={"highThreshold": 80}, temperature_settings={"lowThreshold": 15.5}, motion_settings={"sensitivity": 60}, alarm_settings={"isEnabled": False}, ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "lightSettings": {"isEnabled": True, "lowThreshold": 10}, "humiditySettings": {"highThreshold": 80}, "temperatureSettings": {"lowThreshold": 15.5}, "motionSettings": {"sensitivity": 60}, "alarmSettings": {"isEnabled": False}, } @pytest.mark.asyncio() @patch("uiprotect.api.PublicSensor.from_unifi_dict") async def test_update_sensor_public_armed_fields( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=SENSOR_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SENSOR_ID}) await protect_client.update_sensor_public( SENSOR_ID, motion_settings={"sensitivity": 60, "sensitivityWhenArmed": 90}, glass_break_settings={"isEnabled": True, "sensitivityWhenArmed": 80}, schedule_mode=SensorScheduleMode.WHEN_ARMED, arm_profile_ids=["p1", "p2"], has_custom_sensitivity_when_armed=True, ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "motionSettings": {"sensitivity": 60, "sensitivityWhenArmed": 90}, "glassBreakSettings": {"isEnabled": True, "sensitivityWhenArmed": 80}, "scheduleMode": "when_armed", "armProfileIds": ["p1", "p2"], "hasCustomSensitivityWhenArmed": True, } # --------------------------------------------------------------------------- # Arm profile create/update forward typed schedules # --------------------------------------------------------------------------- @pytest.mark.asyncio() @patch("uiprotect.api.ArmProfile.from_unifi_dict") async def test_create_arm_profile( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=PROFILE_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": PROFILE_ID}) protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap await protect_client.create_arm_profile_public( name="Night", automations=["a"], schedules=[{"start": "0 22 * * *", "end": "0 6 * * *"}], record_everything=True, activation_delay=60000, ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "name": "Night", "automations": ["a"], "schedules": [{"start": "0 22 * * *", "end": "0 6 * * *"}], "recordEverything": True, "activationDelay": 60000, } assert PROFILE_ID in pb.arm_profiles @pytest.mark.asyncio() @patch("uiprotect.api.ArmProfile.from_unifi_dict") async def test_update_arm_profile( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=PROFILE_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": PROFILE_ID}) await protect_client.update_arm_profile_public( PROFILE_ID, name="Day", schedules=[{"start": "0 8 * * *", "end": "0 18 * * *"}] ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "name": "Day", "schedules": [{"start": "0 8 * * *", "end": "0 18 * * *"}], } @pytest.mark.asyncio() async def test_delete_arm_profile(protect_client: ProtectApiClient) -> None: protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap pb.arm_profiles[PROFILE_ID] = Mock() protect_client.api_request_raw = AsyncMock(return_value=None) await protect_client.delete_arm_profile_public(PROFILE_ID) _, kwargs = protect_client.api_request_raw.call_args assert kwargs["url"] == f"/v1/arm-profiles/{PROFILE_ID}" assert kwargs["method"] == "delete" assert PROFILE_ID not in pb.arm_profiles # --------------------------------------------------------------------------- # Review fixes: strict property, arm-settings consistency, WS fallback, # concurrent update_public, resync on reconnect # --------------------------------------------------------------------------- def test_public_bootstrap_property_raises_before_update( protect_client: ProtectApiClient, ) -> None: assert protect_client.has_public_bootstrap is False with pytest.raises(BadRequest): _ = protect_client.public_bootstrap @pytest.mark.asyncio() async def test_update_public_creates_cache( protect_client: ProtectApiClient, ) -> None: _mock_update_public_endpoints(protect_client) pb = await protect_client.update_public() assert pb is protect_client.public_bootstrap assert protect_client.has_public_bootstrap is True @pytest.mark.asyncio() async def test_get_nvr_public_returns_none_arm_mode_when_absent( protect_client: ProtectApiClient, ) -> None: """get_nvr_public with no armMode in payload → returned PublicNVR.arm_mode is None.""" protect_client.api_request_obj = AsyncMock(return_value=deepcopy(_NVR_RAW_BASE)) result = await protect_client.get_nvr_public() assert isinstance(result, PublicNVR) assert result.arm_mode is None @pytest.mark.asyncio() async def test_get_nvr_public_sets_arm_mode_when_present( protect_client: ProtectApiClient, ) -> None: """get_nvr_public with armMode in payload → returned PublicNVR.arm_mode is populated.""" raw = deepcopy(_NVR_RAW_BASE) raw["armMode"] = _arm_mode_raw("armed", PROFILE_ID) protect_client.api_request_obj = AsyncMock(return_value=raw) result = await protect_client.get_nvr_public() assert isinstance(result, PublicNVR) assert result.arm_mode is not None assert result.arm_mode.arm_profile_id == PROFILE_ID @pytest.mark.asyncio() async def test_get_nvr_public_mac_none_when_absent( protect_client: ProtectApiClient, ) -> None: """get_nvr_public with no mac in payload (older firmware) → mac is None.""" protect_client.api_request_obj = AsyncMock(return_value=deepcopy(_NVR_RAW_BASE)) result = await protect_client.get_nvr_public() assert isinstance(result, PublicNVR) assert result.mac is None @pytest.mark.asyncio() async def test_get_nvr_public_parses_mac_when_present( protect_client: ProtectApiClient, ) -> None: """get_nvr_public with mac in payload (Protect > 7.1) → mac is parsed.""" raw = deepcopy(_NVR_RAW_BASE) raw["mac"] = "AABBCCDDEEFF" protect_client.api_request_obj = AsyncMock(return_value=raw) result = await protect_client.get_nvr_public() assert isinstance(result, PublicNVR) assert result.mac == "AABBCCDDEEFF" def test_ws_handler_without_cache_emits_none_obj( protect_client: ProtectApiClient, ) -> None: """With the cache unmaterialised the WS handler no longer fabricates objects.""" captured: list[Any] = [] protect_client._devices_ws_subscriptions.append(captured.append) msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( { "type": "add", "item": {"id": SIREN_ID, "modelKey": "siren", "state": "CONNECTED"}, } ).decode(), None, ) protect_client._process_devices_ws_message(msg) assert len(captured) == 1 assert captured[0].new_obj is None assert captured[0].changed_data["id"] == SIREN_ID @pytest.mark.asyncio() async def test_update_public_runs_concurrently( protect_client: ProtectApiClient, ) -> None: """All endpoint fetches are dispatched via asyncio.gather, not awaited serially.""" active = 0 peak = 0 async def _slow_empty() -> list[Any]: nonlocal active, peak active += 1 peak = max(peak, active) await asyncio.sleep(0.01) active -= 1 return [] async def _slow_nvr() -> PublicNVR: nonlocal active, peak active += 1 peak = max(peak, active) await asyncio.sleep(0.01) active -= 1 nvr = _make_public_nvr(protect_client) nvr.mac = "AABBCCDDEEFF" # present → no console backfill return nvr protect_client.get_nvr_public = _slow_nvr # type: ignore[method-assign] for name in ( "get_cameras_public", "get_lights_public", "get_chimes_public", "get_sensors_public", "get_sirens_public", "get_relays_public", "_fetch_arm_profiles", ): setattr(protect_client, name, _slow_empty) await protect_client.update_public() assert peak >= 7 @pytest.mark.asyncio() async def test_ws_reconnect_schedules_resync( protect_client: ProtectApiClient, ) -> None: """When the devices WS reconnects, update_public is scheduled if cache exists.""" protect_client._public_bootstrap = PublicBootstrap() update_called = asyncio.Event() async def _fake_update() -> PublicBootstrap: update_called.set() return protect_client.public_bootstrap protect_client.update_public = _fake_update # type: ignore[assignment] # Initial connect is not a reconnect and must NOT schedule a resync. protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert protect_client._public_resync_task is None # Drop and reconnect. protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) await asyncio.wait_for(update_called.wait(), timeout=1.0) def test_ws_initial_connect_does_not_resync( protect_client: ProtectApiClient, ) -> None: """First CONNECTED transition is the initial connect, not a reconnect.""" protect_client._public_bootstrap = PublicBootstrap() protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert protect_client._public_resync_task is None def test_ws_reconnect_skips_resync_without_cache( protect_client: ProtectApiClient, ) -> None: """Without a materialised cache, reconnect does not schedule anything.""" assert protect_client._public_bootstrap is None protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert protect_client._public_resync_task is None # --------------------------------------------------------------------------- # HA-realistic roundtrip: partial WS diffs applied to full cached objects # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_camera_motion_start_partial_ws_update( protect_client: ProtectApiClient, ) -> None: """ A minimal ``isMotionDetected=true`` diff flips the cached camera. This is the exact shape HA's motion entity relies on. """ # Seed the public cache with the private-bootstrap cameras (same model # class, same ids — realistic because the Public API returns Camera # instances identical in shape to the private one). pb = PublicBootstrap() camera = next(iter(protect_client.bootstrap.cameras.values())) pb.cameras[camera.id] = camera protect_client._public_bootstrap = pb assert camera.is_motion_detected is False _, new, old = pb.process_devices_ws_message( protect_client, { "type": "update", "item": { "id": camera.id, "modelKey": "camera", "isMotionDetected": True, "lastMotion": 1643055781132, }, }, ) assert old is camera assert new is camera assert new is not None and new.is_motion_detected is True # type: ignore[attr-defined] # Cache now holds the same object, updated in place. assert pb.cameras[camera.id].is_motion_detected is True @pytest.mark.asyncio() async def test_doorbell_ring_partial_ws_update( protect_client: ProtectApiClient, ) -> None: """Doorbell ring arrives as a ``lastRing`` partial diff; HA reads it.""" pb = PublicBootstrap() camera = next(iter(protect_client.bootstrap.cameras.values())) pb.cameras[camera.id] = camera protect_client._public_bootstrap = pb before = camera.last_ring _, new, _ = pb.process_devices_ws_message( protect_client, { "type": "update", "item": { "id": camera.id, "modelKey": "camera", "lastRing": 1700000000000, }, }, ) assert new is not None assert new.last_ring != before # type: ignore[attr-defined] def test_events_ws_motion_minimal_add_and_end_update( protect_client: ProtectApiClient, ) -> None: """Public API motion event: minimal ``add`` then ``end`` partial diff.""" pb = PublicBootstrap() protect_client._public_bootstrap = pb # Minimal payload — the public integration websocket sends only # these fields on motion-start (no ``score`` / ``smartDetect*``). new_event, old_event, _ = pb.process_events_ws_message( protect_client, { "type": "add", "item": { "id": "evt-motion-1", "modelKey": "event", "type": "motion", "start": 1700000000000, }, }, ) assert old_event is None assert new_event is not None assert new_event.id == "evt-motion-1" assert new_event.type.value == "motion" assert new_event.end is None assert new_event.smart_detect_types == [] # default applied assert "evt-motion-1" in pb.events # Partial update closing the event — only ``end`` on the wire. new2, old2, _ = pb.process_events_ws_message( protect_client, { "type": "update", "item": { "id": "evt-motion-1", "modelKey": "event", "end": 1700000005000, }, }, ) # ``old2`` is a pre-merge snapshot of the cached event (end still unset), # distinct from the in-place-merged ``new2`` returned to the caller. assert old2 is not None and old2.end is None assert old2 is not new_event assert new2 is new_event # merged in place from cache assert new2 is not None and new2.end is not None # And the cache-stored copy reflects the merge. assert pb.events["evt-motion-1"].end is not None def test_events_ws_sensor_button_pressed_add( protect_client: ProtectApiClient, ) -> None: """Public API events WS accepts ``sensorButtonPressed`` payloads.""" pb = PublicBootstrap() protect_client._public_bootstrap = pb new_event, old_event, _ = pb.process_events_ws_message( protect_client, { "type": "add", "item": { "id": "evt-sensor-button-1", "modelKey": "event", "type": "sensorButtonPressed", "start": 1700000000000, "device": SENSOR_ID, "metadata": { "button": { "text": "alarmHubButton", } }, }, }, ) assert old_event is None assert new_event is not None assert new_event.type is EventType.SENSOR_BUTTON_PRESSED assert new_event.id == "evt-sensor-button-1" assert pb.events["evt-sensor-button-1"].type is EventType.SENSOR_BUTTON_PRESSED @pytest.mark.parametrize( ("event_type", "expected"), [ ("sensorSmokeTest", EventType.SENSOR_SMOKE_TEST), ("sensorTamper", EventType.SENSOR_TAMPER), ("relayInputChanged", EventType.RELAY_INPUT_CHANGED), ("alarmHubMotion", EventType.ALARM_HUB_MOTION), ("alarmHubEntryOpened", EventType.ALARM_HUB_ENTRY_OPENED), ("alarmHubEntryClosed", EventType.ALARM_HUB_ENTRY_CLOSED), ("alarmHubRelaySwitched", EventType.ALARM_HUB_RELAY_SWITCHED), ("alarmHubButtonPress", EventType.ALARM_HUB_BUTTON_PRESS), ("alarmHubSmoke", EventType.ALARM_HUB_SMOKE), ("alarmHubGlassBreak", EventType.ALARM_HUB_GLASS_BREAK), ("alarmHubTamper", EventType.ALARM_HUB_TAMPER), ("alarmHubBatteryConnected", EventType.ALARM_HUB_BATTERY_CONNECTED), ("alarmHubBatteryLow", EventType.ALARM_HUB_BATTERY_LOW), ("smartDetectLoiterZone", EventType.SMART_DETECT_LOITER), ], ) def test_events_ws_add_supports_additional_public_event_types( protect_client: ProtectApiClient, event_type: str, expected: EventType, ) -> None: """Public API events WS can parse additional event types from the spec.""" pb = PublicBootstrap() protect_client._public_bootstrap = pb event_id = f"evt-{event_type}" new_event, old_event, _ = pb.process_events_ws_message( protect_client, { "type": "add", "item": { "id": event_id, "modelKey": "event", "type": event_type, "start": 1700000000000, }, }, ) assert old_event is None assert new_event is not None assert new_event.type is expected assert pb.events[event_id].type is expected def test_events_ws_add_evicts_oldest( protect_client: ProtectApiClient, ) -> None: """Event cache is bounded; oldest entry is evicted on overflow.""" pb = PublicBootstrap(max_event_cache_size=2) protect_client._public_bootstrap = pb for i in range(3): pb.process_events_ws_message( protect_client, { "type": "add", "item": { "id": f"evt-{i}", "modelKey": "event", "type": "motion", "start": 1700000000000 + i, }, }, ) assert list(pb.events) == ["evt-1", "evt-2"] def test_public_bootstrap_rejects_negative_event_cache_size() -> None: with pytest.raises(ValueError, match="max_event_cache_size must be >= 0"): PublicBootstrap(max_event_cache_size=-1) def test_events_ws_add_zero_cache_size_keeps_cache_empty( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap(max_event_cache_size=0) protect_client._public_bootstrap = pb pb.process_events_ws_message( protect_client, { "type": "add", "item": { "id": "evt-0", "modelKey": "event", "type": "motion", "start": 1700000000000, }, }, ) assert pb.events == {} def test_nvr_ws_partial_update_merges_in_place( protect_client: ProtectApiClient, ) -> None: """``modelKey: nvr`` WS messages update :attr:`PublicBootstrap.nvr`.""" pb = PublicBootstrap() pb.nvr = protect_client.bootstrap.nvr # seed with a valid NVR object protect_client._public_bootstrap = pb before_version = pb.nvr.version mt, new, old = pb.process_devices_ws_message( protect_client, { "type": "update", "item": { "id": pb.nvr.id, "modelKey": "nvr", "isAway": True, }, }, ) assert mt is ModelType.NVR assert old is not None and new is not None assert pb.nvr.is_away is True # Unchanged fields preserved. assert pb.nvr.version == before_version @pytest.mark.asyncio() async def test_reconnect_resync_is_debounced( protect_client: ProtectApiClient, ) -> None: """Rapid reconnects collapse into a single ``update_public`` call.""" protect_client._public_bootstrap = PublicBootstrap() call_count = 0 async def _fake_update() -> PublicBootstrap: nonlocal call_count call_count += 1 return protect_client.public_bootstrap protect_client.update_public = _fake_update # type: ignore[assignment] # Initial connect — no resync. protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) # First real reconnect fires. protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) # Let the task run. await asyncio.sleep(0) if protect_client._public_resync_task is not None: await protect_client._public_resync_task # Immediate second reconnect — debounced away. protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) await asyncio.sleep(0) assert call_count == 1 @pytest.mark.asyncio() async def test_reconnect_queues_follow_up_resync_while_running( protect_client: ProtectApiClient, ) -> None: """Reconnect during active resync queues exactly one follow-up refresh.""" protect_client._public_bootstrap = PublicBootstrap() first_started = asyncio.Event() release_first = asyncio.Event() call_count = 0 async def _fake_update() -> PublicBootstrap: nonlocal call_count call_count += 1 if call_count == 1: first_started.set() await release_first.wait() return protect_client.public_bootstrap protect_client.update_public = _fake_update # type: ignore[assignment] # Initial connect — no resync. protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) # First reconnect starts first resync run. protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) await asyncio.wait_for(first_started.wait(), timeout=1.0) # Another reconnect during the active task should queue one follow-up. protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert protect_client._public_resync_pending is True # Complete first run; queued second run should happen once. release_first.set() if protect_client._public_resync_task is not None: await protect_client._public_resync_task await asyncio.sleep(0) if protect_client._public_resync_task is not None: await protect_client._public_resync_task assert call_count == 2 assert protect_client._public_resync_pending is False @pytest.mark.asyncio() async def test_subscribed_models_filters_devices_ws( protect_client_no_debug: ProtectApiClient, ) -> None: """``subscribed_models`` is respected by the public devices WS handler.""" protect_client_no_debug._subscribed_models = {ModelType.CAMERA} got: list[WSSubscriptionMessage] = [] unsub = protect_client_no_debug.subscribe_devices_websocket(got.append) try: # A ``light`` message must be filtered out entirely. msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( { "type": "update", "item": {"id": "x", "modelKey": "light", "isLightOn": True}, } ).decode(), None, ) protect_client_no_debug._process_devices_ws_message(msg) assert got == [] finally: unsub() @pytest.mark.asyncio() async def test_devices_ws_subscribed_models_overrides_subscribed_models( protect_client_no_debug: ProtectApiClient, ) -> None: """devices_ws_subscribed_models takes priority over subscribed_models.""" # Global filter says CAMERA only, but per-WS filter allows RELAY. protect_client_no_debug._subscribed_models = {ModelType.CAMERA} protect_client_no_debug._devices_ws_subscribed_models = {ModelType.RELAY} got: list[WSSubscriptionMessage] = [] unsub = protect_client_no_debug.subscribe_devices_websocket(got.append) try: relay_msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( {"type": "update", "item": {"id": "r1", "modelKey": "relay"}} ).decode(), None, ) protect_client_no_debug._process_devices_ws_message(relay_msg) # RELAY passes through because devices_ws_subscribed_models overrides assert len(got) == 1 got.clear() camera_msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( {"type": "update", "item": {"id": "c1", "modelKey": "camera"}} ).decode(), None, ) protect_client_no_debug._process_devices_ws_message(camera_msg) # CAMERA is filtered out because devices_ws_subscribed_models={RELAY} assert got == [] finally: protect_client_no_debug._devices_ws_subscribed_models = None unsub() @pytest.mark.asyncio() async def test_events_ws_subscribed_models_overrides_subscribed_models( protect_client_no_debug: ProtectApiClient, ) -> None: """events_ws_subscribed_models takes priority over subscribed_models.""" # Global filter says CAMERA only, but per-WS filter allows EVENT. protect_client_no_debug._subscribed_models = {ModelType.CAMERA} protect_client_no_debug._events_ws_subscribed_models = {ModelType.EVENT} got: list[WSSubscriptionMessage] = [] unsub = protect_client_no_debug.subscribe_events_websocket(got.append) try: event_msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( {"type": "add", "item": {"id": "e1", "modelKey": "event"}} ).decode(), None, ) protect_client_no_debug._process_events_ws_message(event_msg) # EVENT passes through because events_ws_subscribed_models overrides assert len(got) == 1 got.clear() camera_msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( {"type": "update", "item": {"id": "c1", "modelKey": "camera"}} ).decode(), None, ) protect_client_no_debug._process_events_ws_message(camera_msg) # CAMERA is filtered out because events_ws_subscribed_models={EVENT} assert got == [] finally: protect_client_no_debug._events_ws_subscribed_models = None unsub() # --------------------------------------------------------------------------- # Device action helpers (public_devices.py) # --------------------------------------------------------------------------- def _build_siren(protect_client: ProtectApiClient, siren_id: str = SIREN_ID) -> Siren: pb = PublicBootstrap() protect_client._public_bootstrap = pb pb.process_devices_ws_message( protect_client, { "type": "add", "item": { "id": siren_id, "modelKey": "siren", "state": "CONNECTED", "name": "Siren", "mac": "AA", "volume": 50, "ledSettings": {"isEnabled": True}, "sirenStatus": { "isActive": False, "activatedAt": None, "duration": None, }, "connectionType": "lora", "wirelessConnectionState": { "signalState": {"signalQuality": 80, "signalStrength": -50}, "batteryStatus": {"percentage": 100, "isLow": False}, "bridge": None, }, }, }, ) return pb.sirens[siren_id] def _build_relay(protect_client: ProtectApiClient, relay_id: str = RELAY_ID) -> Relay: pb = protect_client._public_bootstrap or PublicBootstrap() protect_client._public_bootstrap = pb pb.process_devices_ws_message( protect_client, { "type": "add", "item": { "id": relay_id, "modelKey": "relay", "state": "CONNECTED", "name": "Relay", "mac": "BB", "ledSettings": {"isEnabled": True}, "outputs": [ { "id": 0, "name": "Out0", "type": "relay", "delay": 0, "pulseDuration": 500, "state": "off", "rebootState": "off", } ], "inputs": [], }, }, ) return pb.relays[relay_id] @pytest.mark.asyncio() async def test_siren_device_action_helpers( protect_client: ProtectApiClient, ) -> None: siren = _build_siren(protect_client) protect_client.play_siren_public = AsyncMock() protect_client.stop_siren_public = AsyncMock() protect_client.test_siren_sound_public = AsyncMock() protect_client.update_siren_public = AsyncMock(return_value=siren) # Siren built with activatedAt=None: turn_off_at is None, fallback to server flag. assert siren.siren_status.turn_off_at is None assert siren.is_active is False # play() forwards duration unchanged; play_siren_public is the single # validation/normalization site (covered by test_play_siren_public_*). await siren.play() protect_client.play_siren_public.assert_awaited_with(SIREN_ID, duration=None) await siren.play(duration=SirenDuration.TEN) protect_client.play_siren_public.assert_awaited_with( SIREN_ID, duration=SirenDuration.TEN ) protect_client.play_siren_public.reset_mock() await siren.play(duration=5) protect_client.play_siren_public.assert_awaited_once_with(SIREN_ID, duration=5) await siren.stop() protect_client.stop_siren_public.assert_awaited_once_with(SIREN_ID) await siren.test_sound(volume=70) protect_client.test_siren_sound_public.assert_awaited_once_with(SIREN_ID, volume=70) assert await siren.set_name("New") is siren protect_client.update_siren_public.assert_awaited_with(SIREN_ID, name="New") assert await siren.set_volume(33) is siren protect_client.update_siren_public.assert_awaited_with(SIREN_ID, volume=33) assert await siren.set_status_light(False) is siren protect_client.update_siren_public.assert_awaited_with( SIREN_ID, led_is_enabled=False ) @pytest.mark.asyncio() async def test_relay_device_action_helpers( protect_client: ProtectApiClient, ) -> None: relay = _build_relay(protect_client) protect_client.activate_relay_output_public = AsyncMock() protect_client.update_relay_public = AsyncMock(return_value=relay) await relay.activate_output(0, state="on", pulse_duration_ms=200) protect_client.activate_relay_output_public.assert_awaited_once_with( RELAY_ID, 0, state="on", pulse_duration_ms=200 ) assert await relay.set_name("R1") is relay protect_client.update_relay_public.assert_awaited_with(RELAY_ID, name="R1") assert await relay.set_status_light(True) is relay protect_client.update_relay_public.assert_awaited_with( RELAY_ID, led_is_enabled=True ) def _build_speaker( protect_client: ProtectApiClient, speaker_id: str = SPEAKER_ID ) -> Speaker: pb = protect_client._public_bootstrap or PublicBootstrap() protect_client._public_bootstrap = pb pb.process_devices_ws_message( protect_client, {"type": "add", "item": _speaker_raw(id=speaker_id)}, ) return pb.speakers[speaker_id] @pytest.mark.asyncio() async def test_speaker_device_action_helpers( protect_client: ProtectApiClient, ) -> None: speaker = _build_speaker(protect_client) protect_client.update_speaker_public = AsyncMock(return_value=speaker) protect_client.test_speaker_sound_public = AsyncMock() assert await speaker.set_name("Lobby") is speaker protect_client.update_speaker_public.assert_awaited_with(SPEAKER_ID, name="Lobby") assert await speaker.set_volume(60) is speaker protect_client.update_speaker_public.assert_awaited_with(SPEAKER_ID, volume=60) assert await speaker.set_mic_volume(30) is speaker protect_client.update_speaker_public.assert_awaited_with(SPEAKER_ID, mic_volume=30) assert await speaker.set_mic_enabled(False) is speaker protect_client.update_speaker_public.assert_awaited_with( SPEAKER_ID, is_mic_enabled=False ) await speaker.test_sound(volume=55) protect_client.test_speaker_sound_public.assert_awaited_once_with( SPEAKER_ID, volume=55 ) @pytest.mark.asyncio() async def test_speaker_api_update_rejects_generic_mutations( protect_client: ProtectApiClient, ) -> None: """Generic mutation path must fail loudly for Public API speakers.""" speaker = _build_speaker(protect_client) with pytest.raises(BadRequest, match="Speaker mutations"): await speaker._api_update({"name": "new"}) # --------------------------------------------------------------------------- # PublicBootstrap edge cases # --------------------------------------------------------------------------- def test_public_bootstrap_get_and_unknown_model( protect_client: ProtectApiClient, ) -> None: """``PublicBootstrap.get`` returns cached obj for known type, ``None`` otherwise.""" siren = _build_siren(protect_client) pb = protect_client.public_bootstrap assert pb.get(ModelType.SIREN, SIREN_ID) is siren assert pb.get(ModelType.SIREN, "missing") is None # ModelType.UNKNOWN (or any type not in _PUBLIC_STORES) → None. assert pb.get(ModelType.UNKNOWN, SIREN_ID) is None def test_public_bootstrap_store_for( protect_client: ProtectApiClient, ) -> None: """``store_for`` returns the live store for both registries, else ``None``.""" siren = _build_siren(protect_client) pb = protect_client.public_bootstrap assert pb.store_for(ModelType.SIREN) is pb.sirens assert pb.store_for(ModelType.SIREN) == {SIREN_ID: siren} # Dedicated-slot type — only reachable through the second registry. assert pb.store_for(ModelType.VIEWPORT) is pb.viewers assert pb.store_for(ModelType.LIVEVIEW) is pb.liveviews assert pb.store_for(ModelType.UNKNOWN) is None assert pb.store_for(ModelType.NVR) is None def test_public_bootstrap_all_devices_empty() -> None: """Empty bootstrap yields nothing, with or without the NVR flag.""" pb = PublicBootstrap() assert list(pb.all_devices()) == [] assert list(pb.all_devices(include_nvr=True)) == [] def test_public_bootstrap_all_devices( protect_client: ProtectApiClient, ) -> None: """``all_devices`` enumerates every store, spanning both registries.""" siren = _build_siren(protect_client) pb = protect_client.public_bootstrap camera = next(iter(protect_client.bootstrap.cameras.values())) pb.cameras[camera.id] = camera # A dedicated-slot device (``viewers``) — collision-routed to its own store # by ``ModelType``, so it is only reachable through the second registry. pb.process_devices_ws_message( protect_client, {"type": "add", "item": _viewer_raw()} ) viewer = pb.viewers[VIEWER_ID] # A liveview is a saved-view config, not a device (no ``mac``); it must be # excluded even though it lives in ``_DEDICATED_SLOT_STORE_ATTRS``. pb.process_devices_ws_message( protect_client, {"type": "add", "item": _liveview_raw()} ) liveview = pb.liveviews[LIVEVIEW_ID] by_id = {id(d) for d in pb.all_devices()} assert by_id == {id(camera), id(siren), id(viewer)} assert all(d is not liveview for d in pb.all_devices()) # NVR excluded by default, yielded first when requested. pb.nvr = _make_public_nvr(protect_client, arm_mode=_arm_mode_raw("disabled")) assert all(d is not pb.nvr for d in pb.all_devices()) with_nvr = list(pb.all_devices(include_nvr=True)) assert with_nvr[0] is pb.nvr assert {id(d) for d in with_nvr} == { id(pb.nvr), id(camera), id(siren), id(viewer), } def test_apply_fetch_result_removes_stale_entries( protect_client: ProtectApiClient, ) -> None: siren = _build_siren(protect_client) pb = protect_client.public_bootstrap # Pretend the HTTP fetch returned an empty list → the cached siren is stale. pb.apply_fetch_result("sirens", []) assert SIREN_ID not in pb.sirens # Reinsert via fetch. pb.apply_fetch_result("sirens", [siren]) assert pb.sirens[SIREN_ID] is siren def test_process_devices_ws_message_invalid_envelope( protect_client: ProtectApiClient, ) -> None: """Missing ``type`` / ``modelKey`` / ``id`` short-circuits to ``(None, None, None)``.""" pb = PublicBootstrap() # Missing type. assert pb.process_devices_ws_message( protect_client, {"item": {"id": "x", "modelKey": "siren"}} ) == (None, None, None) # Missing modelKey. assert pb.process_devices_ws_message( protect_client, {"type": "add", "item": {"id": "x"}} ) == (None, None, None) # Missing id. assert pb.process_devices_ws_message( protect_client, {"type": "add", "item": {"modelKey": "siren"}} ) == (None, None, None) # Completely missing item. assert pb.process_devices_ws_message(protect_client, {"type": "add"}) == ( None, None, None, ) def test_process_events_ws_message_invalid_or_non_event( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() # Invalid envelope. assert pb.process_events_ws_message(protect_client, {}) == (None, None, []) # Non-event modelKey routed to the events handler. assert pb.process_events_ws_message( protect_client, {"type": "add", "item": {"id": "x", "modelKey": "siren"}}, ) == (None, None, []) def test_apply_action_add_creation_failure( protect_client: ProtectApiClient, ) -> None: """``create_from_unifi_dict`` raising → cache untouched, ``_warn_once`` fires.""" pb = PublicBootstrap() protect_client._public_bootstrap = pb # A ``siren`` payload missing required fields triggers a validation error. mt, new, old = pb.process_devices_ws_message( protect_client, { "type": "add", "item": {"id": "bad-siren", "modelKey": "siren"}, }, ) assert mt is ModelType.SIREN assert new is None and old is None assert "bad-siren" not in pb.sirens def test_nvr_ws_remove_clears_slot( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() protect_client._public_bootstrap = pb # First an NVR update to populate (reuse existing merge-in-place logic). # Simplest: set the slot directly, then issue a remove envelope. pb.nvr = Mock(id="nvr-1") mt, new, old = pb.process_devices_ws_message( protect_client, {"type": "remove", "item": {"id": "nvr-1", "modelKey": "nvr"}}, ) assert mt is ModelType.NVR assert new is None and old is not None assert pb.nvr is None def test_events_ws_remove_clears_cache( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() protect_client._public_bootstrap = pb pb.process_events_ws_message( protect_client, { "type": "add", "item": { "id": "evt-rm", "modelKey": "event", "type": "motion", "start": 1700000000000, }, }, ) new, old, _ = pb.process_events_ws_message( protect_client, {"type": "remove", "item": {"id": "evt-rm", "modelKey": "event"}}, ) assert new is None assert old is not None assert "evt-rm" not in pb.events def test_apply_action_unknown_action_falls_through( protect_client: ProtectApiClient, ) -> None: """An unrecognized action returns ``(None, old)`` without touching the cache.""" siren = _build_siren(protect_client) pb = protect_client.public_bootstrap mt, new, old = pb.process_devices_ws_message( protect_client, {"type": "weird", "item": {"id": SIREN_ID, "modelKey": "siren"}}, ) assert mt is ModelType.SIREN assert new is None assert old is siren assert pb.sirens[SIREN_ID] is siren def test_merge_empty_and_no_op_payloads( protect_client: ProtectApiClient, ) -> None: siren = _build_siren(protect_client) pb = protect_client.public_bootstrap # Empty payload (only identity fields) → merge returns original. _, new, old = pb.process_devices_ws_message( protect_client, {"type": "update", "item": {"id": SIREN_ID, "modelKey": "siren"}}, ) assert new is siren assert old is siren # Payload with a camelCase key that ``unifi_dict_to_dict`` drops (unknown) # → cleaned is empty → returns original. _, new, old = pb.process_devices_ws_message( protect_client, { "type": "update", "item": { "id": SIREN_ID, "modelKey": "siren", "totallyUnknownField": 123, }, }, ) assert new is siren def test_merge_exception_and_warn_once( protect_client: ProtectApiClient, monkeypatch: pytest.MonkeyPatch, ) -> None: """A merge that raises triggers WARNING once, then DEBUG.""" siren = _build_siren(protect_client) pb = protect_client.public_bootstrap # Reset the per-instance WARNING tracker so we can reliably see the # first-occurrence path. pb._warned_merge_failures.clear() def _boom(self: Any, cleaned: dict[str, Any]) -> None: raise RuntimeError("boom") monkeypatch.setattr(type(siren), "update_from_dict", _boom) update = { "type": "update", "item": {"id": SIREN_ID, "modelKey": "siren", "volume": 10}, } # First call → WARNING path in _warn_once. _, new, old = pb.process_devices_ws_message(protect_client, update) assert new is None assert old is siren # cache still contains the old value. assert pb.sirens[SIREN_ID] is siren # Second call → DEBUG path (already warned for this key). _, new2, _ = pb.process_devices_ws_message(protect_client, update) assert new2 is None def test_parse_ws_envelope_none_item() -> None: """Direct test of ``_parse_ws_envelope`` for the ``data['item'] is None`` path.""" from uiprotect.data.public_bootstrap import _parse_ws_envelope # noqa: PLC0415 assert _parse_ws_envelope({"type": "add", "item": None}) == (None, {}, None) assert _parse_ws_envelope({}) == (None, {}, None) # --------------------------------------------------------------------------- # Additional api.py Public API coverage # --------------------------------------------------------------------------- @pytest.mark.asyncio() @patch("uiprotect.api.Siren.from_unifi_dict") async def test_get_siren_public( mock_ctor: Mock, protect_client: ProtectApiClient ) -> None: mock_ctor.return_value = Mock(id=SIREN_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SIREN_ID}) result = await protect_client.get_siren_public(SIREN_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/sirens/{SIREN_ID}" assert result.id == SIREN_ID @pytest.mark.asyncio() @patch("uiprotect.api.Siren.from_unifi_dict") async def test_update_siren_public_name_only( mock_ctor: Mock, protect_client: ProtectApiClient ) -> None: mock_ctor.return_value = Mock(id=SIREN_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": SIREN_ID}) await protect_client.update_siren_public(SIREN_ID, name="Front") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == {"name": "Front"} @pytest.mark.asyncio() @patch("uiprotect.api.Relay.from_unifi_dict") async def test_get_relays_and_relay_public( mock_ctor: Mock, protect_client: ProtectApiClient ) -> None: mock_ctor.side_effect = [Mock(id=RELAY_ID), Mock(id=RELAY_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": RELAY_ID}]) protect_client.api_request_obj = AsyncMock(return_value={"id": RELAY_ID}) relays = await protect_client.get_relays_public() assert len(relays) == 1 assert relays[0].id == RELAY_ID _, kwargs = protect_client.api_request_list.call_args assert kwargs["url"] == "/v1/relays" relay = await protect_client.get_relay_public(RELAY_ID) assert relay.id == RELAY_ID _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/relays/{RELAY_ID}" @pytest.mark.asyncio() async def test_update_relay_public_empty( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_relay_public(RELAY_ID) @pytest.mark.asyncio() @patch("uiprotect.api.ArmProfile.from_unifi_dict") async def test_update_arm_profile_all_params_updates_cache( mock_ctor: Mock, protect_client: ProtectApiClient ) -> None: """Exercises ``automations`` / ``recordEverything`` / ``activationDelay`` + cache upsert.""" profile = Mock(id=PROFILE_ID) mock_ctor.return_value = profile protect_client.api_request_obj = AsyncMock(return_value={"id": PROFILE_ID}) protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap await protect_client.update_arm_profile_public( PROFILE_ID, automations=["a1"], record_everything=True, activation_delay=30, ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "automations": ["a1"], "recordEverything": True, "activationDelay": 30, } assert pb.arm_profiles[PROFILE_ID] is profile def test_process_devices_ws_message_uses_cache_when_materialised( protect_client: ProtectApiClient, ) -> None: """When ``_public_bootstrap`` exists, ``new_obj`` comes from the cache.""" captured: list[Any] = [] protect_client._devices_ws_subscriptions.append(captured.append) _build_siren(protect_client) # populates the cache with SIREN_ID msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( { "type": "update", "item": {"id": SIREN_ID, "modelKey": "siren", "volume": 11}, } ).decode(), None, ) protect_client._process_devices_ws_message(msg) assert len(captured) == 1 assert captured[0].new_obj is not None assert captured[0].new_obj.volume == 11 assert captured[0].old_obj is not None @pytest.mark.asyncio() async def test_process_events_ws_message_uses_cache_when_materialised( protect_client: ProtectApiClient, ) -> None: """Events WS pipes cached ``new_obj`` / ``old_obj`` from ``PublicBootstrap``.""" protect_client._public_bootstrap = PublicBootstrap() captured: list[Any] = [] protect_client._events_ws_subscriptions.append(captured.append) msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( { "type": "add", "item": { "id": "evt-cache", "modelKey": "event", "type": "motion", "start": 1700000000000, }, } ).decode(), None, ) protect_client._process_events_ws_message(msg) assert len(captured) == 1 assert captured[0].new_obj is not None assert captured[0].new_obj.id == "evt-cache" @pytest.mark.asyncio() async def test_detection_transition_emits_devices_ws_update( protect_client: ProtectApiClient, ) -> None: """A detection transition emits a devices-WS update alongside the events emit.""" pb = PublicBootstrap() protect_client._public_bootstrap = pb pb.process_devices_ws_message(Mock(), {"type": "add", "item": dict(CAMERA_PAYLOAD)}) events_got: list[Any] = [] devices_got: list[Any] = [] protect_client._events_ws_subscriptions.append(events_got.append) protect_client._devices_ws_subscriptions.append(devices_got.append) msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( { "type": "add", "item": { "id": "m1", "modelKey": "event", "type": "motion", "start": 1700000000000, "device": "cam1", }, } ).decode(), None, ) protect_client._process_events_ws_message(msg) # Existing events-WS emit is unchanged. assert len(events_got) == 1 # A devices-WS update is additionally emitted for the affected camera. assert len(devices_got) == 1 dmsg = devices_got[0] assert dmsg.action is WSAction.UPDATE assert dmsg.new_obj is pb.cameras["cam1"] assert dmsg.new_update_id == "cam1" assert dmsg.changed_data == {"is_motion_detected": True} @pytest.mark.asyncio() async def test_detection_transition_typed_subscriber_resolves_camera( protect_client: ProtectApiClient, ) -> None: """A detection transition reaches ``subscribe_devices`` as a typed CAMERA change.""" pb = PublicBootstrap() protect_client._public_bootstrap = pb pb.process_devices_ws_message(Mock(), {"type": "add", "item": dict(CAMERA_PAYLOAD)}) changes: list[Any] = [] protect_client.subscribe_devices(changes.append) msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( { "type": "add", "item": { "id": "m1", "modelKey": "event", "type": "motion", "start": 1700000000000, "device": "cam1", }, } ).decode(), None, ) protect_client._process_events_ws_message(msg) assert len(changes) == 1 change = changes[0] # The synthetic ``changed_data`` lacks ``modelKey``; the dispatcher must # still resolve CAMERA from ``new_obj`` rather than falling back to UNKNOWN. assert change.model_type is ModelType.CAMERA assert change.device_id == "cam1" # ``device_mac`` is the camera's mac, not its id. assert change.device_mac == CAMERA_PAYLOAD["mac"] assert change.model is pb.cameras["cam1"] @pytest.mark.asyncio() async def test_non_transition_event_emits_no_devices_ws_update( protect_client: ProtectApiClient, ) -> None: """An events frame that flips no detection boolean emits no devices-WS update.""" pb = PublicBootstrap() protect_client._public_bootstrap = pb pb.process_devices_ws_message(Mock(), {"type": "add", "item": dict(CAMERA_PAYLOAD)}) devices_got: list[Any] = [] protect_client._devices_ws_subscriptions.append(devices_got.append) msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( { "type": "add", "item": { "id": "r1", "modelKey": "event", "type": "ring", "start": 1700000000000, "device": "cam1", }, } ).decode(), None, ) protect_client._process_events_ws_message(msg) assert devices_got == [] @pytest.mark.asyncio() async def test_resync_public_bootstrap_logs_on_failure( protect_client: ProtectApiClient, caplog: pytest.LogCaptureFixture, ) -> None: """``_resync_public_bootstrap`` swallows and logs any ``update_public`` exception.""" protect_client.update_public = AsyncMock(side_effect=RuntimeError("offline")) # type: ignore[method-assign] with caplog.at_level("ERROR"): await protect_client._resync_public_bootstrap() assert "Failed to resync public bootstrap after reconnect" in caplog.text @pytest.mark.asyncio() async def test_resync_public_bootstrap_refreshes_rtsps_cache_in_place( protect_client: ProtectApiClient, ) -> None: """A reconnect resync re-fetches populated RTSPS fields in place, never emptying.""" protect_client._public_bootstrap = PublicBootstrap() camera = PublicCamera.model_construct( id="cam1", state=DeviceState.CONNECTED, rtsps_streams=RTSPSStreams(high="rtsps://example.com/stale"), ) protect_client._public_bootstrap.cameras["cam1"] = camera protect_client.update_public = AsyncMock() # type: ignore[method-assign] fresh = RTSPSStreams(high="rtsps://example.com/fresh") protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=fresh ) await protect_client._resync_public_bootstrap() protect_client.get_camera_rtsps_streams.assert_awaited_once_with("cam1") # Field stayed populated throughout — refreshed in place, never cleared. assert camera.rtsps_streams is fresh @pytest.mark.asyncio() async def test_resync_public_bootstrap_keeps_rtsps_on_refresh_failure( protect_client: ProtectApiClient, ) -> None: """A failing per-camera refresh during resync leaves the stale field in place.""" protect_client._public_bootstrap = PublicBootstrap() stale = RTSPSStreams(high="rtsps://example.com/stale") camera = PublicCamera.model_construct( id="cam1", state=DeviceState.CONNECTED, rtsps_streams=stale ) protect_client._public_bootstrap.cameras["cam1"] = camera protect_client.update_public = AsyncMock() # type: ignore[method-assign] protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] side_effect=RuntimeError("offline") ) await protect_client._resync_public_bootstrap() assert camera.rtsps_streams is stale @pytest.mark.asyncio() async def test_resync_public_bootstrap_skips_rtsps_refresh_when_empty( protect_client: ProtectApiClient, ) -> None: """With no populated RTSPS fields, resync issues no per-camera refetch.""" protect_client._public_bootstrap = PublicBootstrap() protect_client._public_bootstrap.cameras["cam1"] = PublicCamera.model_construct( id="cam1", state=DeviceState.CONNECTED, rtsps_streams=None ) protect_client.update_public = AsyncMock() # type: ignore[method-assign] protect_client.get_camera_rtsps_streams = AsyncMock() # type: ignore[method-assign] await protect_client._resync_public_bootstrap() protect_client.get_camera_rtsps_streams.assert_not_awaited() def _public_camera(camera_id: str, state: DeviceState) -> PublicCamera: """Build a bare public camera for ``update_public`` priming tests.""" return PublicCamera.model_construct(id=camera_id, state=state, rtsps_streams=None) @pytest.mark.asyncio() async def test_update_public_primes_connected_cameras( protect_client: ProtectApiClient, ) -> None: """``update_public`` fetches and stores RTSPS streams for each connected camera.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) cam2 = _public_camera("cam2", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1, cam2]), ) streams = { "cam1": RTSPSStreams(high="rtsps://example.com/cam1"), "cam2": RTSPSStreams(high="rtsps://example.com/cam2"), } protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] side_effect=lambda cid: streams[cid] ) pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is streams["cam1"] assert pb.cameras["cam2"].rtsps_streams is streams["cam2"] assert protect_client.get_camera_rtsps_streams.await_count == 2 @pytest.mark.asyncio() async def test_update_public_skips_disconnected_cameras( protect_client: ProtectApiClient, ) -> None: """Priming only touches connected cameras; offline ones are left unprimed.""" online = _public_camera("cam1", DeviceState.CONNECTED) offline = _public_camera("cam2", DeviceState.DISCONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[online, offline]), ) fresh = RTSPSStreams(high="rtsps://example.com/cam1") protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=fresh ) pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is fresh assert pb.cameras["cam2"].rtsps_streams is None protect_client.get_camera_rtsps_streams.assert_awaited_once_with("cam1") @pytest.mark.asyncio() async def test_update_public_prime_is_best_effort( protect_client: ProtectApiClient, ) -> None: """One camera's failed prime is swallowed and does not abort the rest.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) cam2 = _public_camera("cam2", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1, cam2]), ) good = RTSPSStreams(high="rtsps://example.com/cam2") async def _fetch(camera_id: str) -> RTSPSStreams: if camera_id == "cam1": raise RuntimeError("offline") return good protect_client.get_camera_rtsps_streams = _fetch # type: ignore[method-assign] pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is None assert pb.cameras["cam2"].rtsps_streams is good @pytest.mark.asyncio() async def test_update_public_prime_skips_none_result( protect_client: ProtectApiClient, ) -> None: """A camera whose fetch returns ``None`` is left with an unset field.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1]), ) protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=None ) pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is None @pytest.mark.asyncio() async def test_update_public_carries_forward_rtsps_across_reparse( protect_client: ProtectApiClient, ) -> None: """Existing streams survive the re-parse and the carried camera is not refetched.""" carried = RTSPSStreams(high="rtsps://example.com/carried") protect_client._public_bootstrap = PublicBootstrap() protect_client._public_bootstrap.cameras["cam1"] = PublicCamera.model_construct( id="cam1", state=DeviceState.CONNECTED, rtsps_streams=carried ) # The re-parse hands back freshly-built cameras with ``rtsps_streams=None``. fresh_cam1 = _public_camera("cam1", DeviceState.CONNECTED) cam2 = _public_camera("cam2", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[fresh_cam1, cam2]), ) primed = RTSPSStreams(high="rtsps://example.com/cam2") protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=primed ) pb = await protect_client.update_public() # cam1's streams are carried forward (idempotent — never re-fetched). assert pb.cameras["cam1"].rtsps_streams is carried assert pb.cameras["cam2"].rtsps_streams is primed protect_client.get_camera_rtsps_streams.assert_awaited_once_with("cam2") @pytest.mark.asyncio() async def test_update_public_prime_skips_write_when_camera_replaced( protect_client: ProtectApiClient, ) -> None: """A camera replaced (remove+add) mid-fetch is not resurrected by the prime.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1]), ) replacement = _public_camera("cam1", DeviceState.CONNECTED) streams = RTSPSStreams(high="rtsps://example.com/cam1") async def _fetch(camera_id: str) -> RTSPSStreams: # Simulate a remove+add swapping the cached camera while the fetch runs. protect_client._public_bootstrap.cameras["cam1"] = replacement return streams protect_client.get_camera_rtsps_streams = _fetch # type: ignore[method-assign] pb = await protect_client.update_public() assert pb.cameras["cam1"] is replacement assert replacement.rtsps_streams is None assert cam1.rtsps_streams is None @pytest.mark.asyncio() async def test_update_public_prime_does_not_clobber_concurrent_write_through( protect_client: ProtectApiClient, ) -> None: """A write-through landing mid-fetch is not overwritten by the stale prime.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1]), ) created = RTSPSStreams(high="rtsps://example.com/created") fetched = RTSPSStreams(high="rtsps://example.com/fetched") async def _fetch(camera_id: str) -> RTSPSStreams: # A create write-through fills the field while the prime fetch is awaited. cam1.rtsps_streams = created return fetched protect_client.get_camera_rtsps_streams = _fetch # type: ignore[method-assign] pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is created @pytest.mark.asyncio() async def test_update_public_prime_respects_concurrency_bound( protect_client: ProtectApiClient, monkeypatch: pytest.MonkeyPatch, ) -> None: """No more than ``RTSPS_PRIME_CONCURRENCY`` prime fetches run at once.""" monkeypatch.setattr(api_module, "RTSPS_PRIME_CONCURRENCY", 2) cameras = [_public_camera(f"cam{i}", DeviceState.CONNECTED) for i in range(4)] _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=cameras), ) started: list[str] = [] release = asyncio.Event() async def _fetch(camera_id: str) -> RTSPSStreams: started.append(camera_id) await release.wait() return RTSPSStreams(high=f"rtsps://example.com/{camera_id}") protect_client.get_camera_rtsps_streams = _fetch # type: ignore[method-assign] task = asyncio.create_task(protect_client.update_public()) while len(started) < 2: await asyncio.sleep(0) # Give any over-eager third fetch a chance to (wrongly) start. await asyncio.sleep(0) assert len(started) == 2 release.set() await task assert len(started) == 4 @pytest.mark.asyncio() async def test_update_public_prime_bounds_hung_fetch( protect_client: ProtectApiClient, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """A camera whose prime fetch hangs is bounded by the per-request timeout.""" monkeypatch.setattr(api_module, "RTSPS_PRIME_TIMEOUT", 0.01) monkeypatch.setattr(api_module, "RTSPS_PRIME_RETRIES", 0) hung = _public_camera("cam1", DeviceState.CONNECTED) good = _public_camera("cam2", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[hung, good]), ) fresh = RTSPSStreams(high="rtsps://example.com/cam2") async def _fetch(camera_id: str) -> RTSPSStreams: if camera_id == "cam1": await asyncio.sleep(10) return fresh protect_client.get_camera_rtsps_streams = _fetch # type: ignore[method-assign] with caplog.at_level("WARNING"): pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is None assert pb.cameras["cam2"].rtsps_streams is fresh assert "Could not prime RTSPS streams for 1 camera(s): cam1" in caplog.text @pytest.mark.asyncio() async def test_update_public_prime_retries_transient_failure( protect_client: ProtectApiClient, caplog: pytest.LogCaptureFixture, ) -> None: """A single transient prime failure is healed by the retry.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1]), ) healed = RTSPSStreams(high="rtsps://example.com/cam1") calls = 0 async def _fetch(camera_id: str) -> RTSPSStreams: nonlocal calls calls += 1 if calls == 1: raise RuntimeError("transient") return healed protect_client.get_camera_rtsps_streams = _fetch # type: ignore[method-assign] with caplog.at_level("WARNING"): pb = await protect_client.update_public() assert calls == 2 assert pb.cameras["cam1"].rtsps_streams is healed assert "Could not prime RTSPS streams" not in caplog.text @pytest.mark.asyncio() async def test_update_public_prime_aggregates_failures_into_one_warning( protect_client: ProtectApiClient, caplog: pytest.LogCaptureFixture, ) -> None: """Every camera that still fails after retries lands in a single WARNING.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) cam2 = _public_camera("cam2", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1, cam2]), ) protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] side_effect=RuntimeError("offline") ) with caplog.at_level("WARNING"): pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is None assert pb.cameras["cam2"].rtsps_streams is None warnings = [ record.message for record in caplog.records if "Could not prime RTSPS streams" in record.message ] assert len(warnings) == 1 assert "cam1" in warnings[0] assert "cam2" in warnings[0] assert "2 camera(s)" in warnings[0] @pytest.mark.asyncio() async def test_update_public_prime_retries_none_result( protect_client: ProtectApiClient, caplog: pytest.LogCaptureFixture, ) -> None: """A ``None`` prime result is retried and healed on the next attempt.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1]), ) healed = RTSPSStreams(high="rtsps://example.com/cam1") calls = 0 async def _fetch(camera_id: str) -> RTSPSStreams | None: nonlocal calls calls += 1 if calls == 1: return None return healed protect_client.get_camera_rtsps_streams = _fetch # type: ignore[method-assign] with caplog.at_level("WARNING"): pb = await protect_client.update_public() assert calls == 2 assert pb.cameras["cam1"].rtsps_streams is healed assert "Could not prime RTSPS streams" not in caplog.text @pytest.mark.asyncio() async def test_update_public_prime_none_result_warns( protect_client: ProtectApiClient, caplog: pytest.LogCaptureFixture, ) -> None: """A camera returning ``None`` on every attempt lands in the aggregated WARNING.""" cam1 = _public_camera("cam1", DeviceState.CONNECTED) _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[cam1]), ) protect_client.get_camera_rtsps_streams = AsyncMock( # type: ignore[method-assign] return_value=None ) with caplog.at_level("WARNING"): pb = await protect_client.update_public() assert pb.cameras["cam1"].rtsps_streams is None assert "Could not prime RTSPS streams for 1 camera(s): cam1" in caplog.text @pytest.mark.asyncio() async def test_update_public_reraises_unexpected_exception( protect_client: ProtectApiClient, ) -> None: """Unexpected (non-``BadRequest``/``NvrError``) endpoint failures propagate.""" _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(side_effect=RuntimeError("boom")), ) with pytest.raises(RuntimeError, match="boom"): await protect_client.update_public() @pytest.mark.asyncio() async def test_update_public_unexpected_error_leaves_snapshot_untouched( protect_client: ProtectApiClient, ) -> None: """An unexpected error mid-batch must not half-apply the bootstrap.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap prior_camera = Mock(id="old-cam") pb.cameras["old-cam"] = prior_camera prior_profile = Mock(id="old-profile") pb.arm_profiles["old-profile"] = prior_profile _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[Mock(id="new-cam")]), get_sensors_public=AsyncMock(side_effect=RuntimeError("boom")), _fetch_arm_profiles=AsyncMock(return_value=[Mock(id="new-profile")]), ) with pytest.raises(RuntimeError, match="boom"): await protect_client.update_public() # A successful endpoint (cameras) is NOT applied; arm profiles NOT re-applied. assert pb.cameras == {"old-cam": prior_camera} assert pb.arm_profiles == {"old-profile": prior_profile} @pytest.mark.asyncio() async def test_update_public_revoked_key_aborts( protect_client: ProtectApiClient, ) -> None: """A revoked key (401) surfaces as NotAuthorized before anything is applied.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap prior_camera = Mock(id="old-cam") pb.cameras["old-cam"] = prior_camera _mock_update_public_endpoints( protect_client, get_cameras_public=AsyncMock(return_value=[Mock(id="new-cam")]), get_nvr_public=AsyncMock(side_effect=NotAuthorized("401")), ) with pytest.raises(NotAuthorized): await protect_client.update_public() assert pb.cameras == {"old-cam": prior_camera} @pytest.mark.asyncio() async def test_update_public_first_refresh_failure_leaves_bootstrap_none( protect_client: ProtectApiClient, ) -> None: """A failed first refresh must not publish an empty bootstrap.""" protect_client._public_bootstrap = None _mock_update_public_endpoints( protect_client, get_nvr_public=AsyncMock(side_effect=RuntimeError("boom")), ) with pytest.raises(RuntimeError, match="boom"): await protect_client.update_public() assert protect_client._public_bootstrap is None assert protect_client.has_public_bootstrap is False @pytest.mark.asyncio() async def test_update_public_applies_arm_profiles_in_batch( protect_client: ProtectApiClient, ) -> None: """Arm profiles are applied in place during update_public's batch phase.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap pb.arm_profiles["stale"] = Mock(id="stale") original_dict = pb.arm_profiles _mock_update_public_endpoints( protect_client, _fetch_arm_profiles=AsyncMock(return_value=[Mock(id="ap1")]), ) await protect_client.update_public() assert pb.arm_profiles is original_dict # identity preserved assert "stale" not in pb.arm_profiles assert "ap1" in pb.arm_profiles def test_process_events_ws_message_invalid_envelope_early_return( protect_client: ProtectApiClient, ) -> None: """Events WS short-circuits on missing ``type`` / ``modelKey``.""" captured: list[Any] = [] protect_client._events_ws_subscriptions.append(captured.append) for payload in ( {"item": {"id": "x", "modelKey": "event"}}, # missing type {"type": "add", "item": {"id": "x"}}, # missing modelKey {"type": "add", "item": {"id": "x", "modelKey": "totally_unknown"}}, ): msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps(payload).decode(), None, ) protect_client._process_events_ws_message(msg) # Covers the ``_subscribed_models`` filter branch (early return). protect_client._subscribed_models = {ModelType.CAMERA} try: msg = aiohttp.WSMessage( aiohttp.WSMsgType.TEXT, orjson.dumps( {"type": "add", "item": {"id": "x", "modelKey": "event"}} ).decode(), None, ) protect_client._process_events_ws_message(msg) finally: protect_client._subscribed_models = set() assert captured == [] @pytest.mark.asyncio() @patch("uiprotect.api.ArmProfile.from_unifi_dict") async def test_get_arm_profiles_preserves_dict_identity( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: """Refreshing arm profiles must update the dict in place, not replace it.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap original_dict = pb.arm_profiles # Seed with a stale entry that must be evicted after refresh. pb.arm_profiles["stale"] = Mock(id="stale") mock_ctor.side_effect = [Mock(id=PROFILE_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": PROFILE_ID}]) await protect_client.get_arm_profiles_public() assert pb.arm_profiles is original_dict # identity preserved assert "stale" not in pb.arm_profiles assert PROFILE_ID in pb.arm_profiles @pytest.mark.asyncio() @patch("uiprotect.api.ArmProfile.from_unifi_dict") async def test_fetch_arm_profiles_does_not_mutate_cache( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: """`_fetch_arm_profiles` returns profiles without touching the cache.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap pb.arm_profiles["stale"] = Mock(id="stale") mock_ctor.side_effect = [Mock(id=PROFILE_ID)] protect_client.api_request_list = AsyncMock(return_value=[{"id": PROFILE_ID}]) profiles = await protect_client._fetch_arm_profiles() assert [p.id for p in profiles] == [PROFILE_ID] assert pb.arm_profiles == {"stale": pb.arm_profiles["stale"]} # untouched original_dict = pb.arm_profiles protect_client._apply_arm_profiles(profiles) assert pb.arm_profiles is original_dict # identity preserved assert "stale" not in pb.arm_profiles assert PROFILE_ID in pb.arm_profiles @pytest.mark.asyncio() async def test_siren_api_update_rejects_generic_mutations( protect_client: ProtectApiClient, ) -> None: """Generic mutation path must fail loudly for Public API sirens.""" siren = _build_siren(protect_client) with pytest.raises(BadRequest, match="Siren mutations"): await siren._api_update({"name": "new"}) @pytest.mark.asyncio() async def test_relay_api_update_rejects_generic_mutations( protect_client: ProtectApiClient, ) -> None: """Generic mutation path must fail loudly for Public API relays.""" relay = _build_relay(protect_client) with pytest.raises(BadRequest, match="Relay mutations"): await relay._api_update({"name": "new"}) # --------------------------------------------------------------------------- # Liveviews # --------------------------------------------------------------------------- def _liveview_raw(**overrides: Any) -> dict[str, Any]: raw: dict[str, Any] = { "id": LIVEVIEW_ID, "modelKey": "liveview", "name": "Garage", "isDefault": False, "isGlobal": True, "owner": OWNER_ID, "layout": 1, "slots": [ { "cameras": ["cam-1", "cam-2"], "cycleMode": "motion", "cycleInterval": 10, } ], } raw.update(overrides) return raw def test_liveview_model_from_unifi_dict() -> None: raw = _liveview_raw( layout=4, slots=[ { "cameras": ["cam-1", "cam-2", "cam-3"], "cycleMode": "motion", "cycleInterval": 10, }, { "cameras": ["cam-4"], "cycleMode": "time", "cycleInterval": 30, }, ], ) liveview = PublicLiveview.from_unifi_dict(**raw) assert liveview.id == LIVEVIEW_ID assert liveview.model is ModelType.LIVEVIEW assert liveview.name == "Garage" assert liveview.is_default is False assert liveview.is_global is True assert liveview.owner == OWNER_ID assert liveview.layout == 4 assert len(liveview.slots) == 2 assert isinstance(liveview.slots[0], PublicLiveviewSlot) assert liveview.slots[0].camera_ids == ["cam-1", "cam-2", "cam-3"] assert liveview.slots[0].cycle_mode is LiveviewCycleMode.MOTION assert liveview.slots[0].cycle_interval == 10 assert liveview.slots[1].camera_ids == ["cam-4"] assert liveview.slots[1].cycle_mode is LiveviewCycleMode.TIME def test_liveview_model_does_not_collide_with_private() -> None: """``PublicLiveview`` and private ``Liveview`` are distinct types.""" raw = _liveview_raw() assert PublicLiveview is not Liveview public = PublicLiveview.from_unifi_dict(**raw) assert isinstance(public, PublicLiveview) # The private bootstrap path must still resolve ``liveview`` modelKey to # the private ``Liveview`` class via ``MODEL_TO_CLASS``. private_raw = dict(raw) # Private Liveview uses ``ownerId`` (remapped from ``owner``); the dict # path already accepts ``owner`` via the remap on the private side. private = create_from_unifi_dict(private_raw) assert isinstance(private, Liveview) assert not isinstance(private, PublicLiveview) @pytest.mark.asyncio() @patch("uiprotect.api.PublicLiveview.from_unifi_dict") async def test_get_liveviews_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=LIVEVIEW_ID), Mock(id="other")] protect_client.api_request_list = AsyncMock( return_value=[{"id": LIVEVIEW_ID}, {"id": "other"}] ) # Initialize public bootstrap to verify the list getter does NOT write to it. pb = protect_client._public_bootstrap = PublicBootstrap() result = await protect_client.get_liveviews_public() protect_client.api_request_list.assert_called_with( url="/v1/liveviews", public_api=True ) assert len(result) == 2 assert result[0].id == LIVEVIEW_ID # Cache-write policy: list getter does not touch public_bootstrap. assert protect_client._public_bootstrap is pb assert pb.liveviews == {} @pytest.mark.asyncio() @patch("uiprotect.api.PublicLiveview.from_unifi_dict") async def test_get_liveview_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=LIVEVIEW_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": LIVEVIEW_ID}) result = await protect_client.get_liveview_public(LIVEVIEW_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/liveviews/{LIVEVIEW_ID}" assert kwargs["public_api"] is True assert result.id == LIVEVIEW_ID @pytest.mark.asyncio() @patch("uiprotect.api.PublicLiveview.from_unifi_dict") async def test_get_liveview_public_writes_pb( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: obj = Mock(id=LIVEVIEW_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": LIVEVIEW_ID}) pb = protect_client._public_bootstrap = PublicBootstrap() await protect_client.get_liveview_public(LIVEVIEW_ID) assert pb.liveviews[LIVEVIEW_ID] is obj @pytest.mark.asyncio() @patch("uiprotect.api.PublicLiveview.from_unifi_dict") async def test_create_liveview_public_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: new_id = "new-liveview-id" obj = Mock(id=new_id) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": new_id}) pb = protect_client._public_bootstrap = PublicBootstrap() await protect_client.create_liveview_public( name="X", is_default=False, is_global=True, owner="u1", layout=4, slots=[ { "cameras": ["c1"], "cycleMode": "motion", "cycleInterval": 10, } ], ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == "/v1/liveviews" assert kwargs["method"] == "post" assert kwargs["public_api"] is True assert kwargs["json"] == { "name": "X", "isDefault": False, "isGlobal": True, "owner": "u1", "layout": 4, "slots": [{"cameras": ["c1"], "cycleMode": "motion", "cycleInterval": 10}], } # No id, no modelKey in the create body. assert "id" not in kwargs["json"] assert "modelKey" not in kwargs["json"] assert pb.liveviews[new_id] is obj @pytest.mark.asyncio() @patch("uiprotect.api.PublicLiveview.from_unifi_dict") async def test_update_liveview_public_partial( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: obj = Mock(id=LIVEVIEW_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": LIVEVIEW_ID}) pb = protect_client._public_bootstrap = PublicBootstrap() await protect_client.update_liveview_public(LIVEVIEW_ID, name="new") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/liveviews/{LIVEVIEW_ID}" assert kwargs["method"] == "patch" assert kwargs["json"] == {"name": "new"} assert pb.liveviews[LIVEVIEW_ID] is obj @pytest.mark.asyncio() @patch("uiprotect.api.PublicLiveview.from_unifi_dict") async def test_update_liveview_public_full_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=LIVEVIEW_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": LIVEVIEW_ID}) await protect_client.update_liveview_public( LIVEVIEW_ID, name="N", is_default=True, is_global=False, owner="u9", layout=9, slots=[ { "cameras": ["a", "b"], "cycleMode": "time", "cycleInterval": 5, } ], ) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == { "name": "N", "isDefault": True, "isGlobal": False, "owner": "u9", "layout": 9, "slots": [{"cameras": ["a", "b"], "cycleMode": "time", "cycleInterval": 5}], } @pytest.mark.asyncio() async def test_update_liveview_public_empty( protect_client: ProtectApiClient, ) -> None: with pytest.raises(BadRequest): await protect_client.update_liveview_public(LIVEVIEW_ID) @pytest.mark.asyncio() @pytest.mark.parametrize("bad_layout", [0, 27, -1]) async def test_create_liveview_public_rejects_invalid_layout( protect_client: ProtectApiClient, bad_layout: int, ) -> None: with pytest.raises(BadRequest, match="layout must be between 1 and 26"): await protect_client.create_liveview_public( name="X", is_default=False, is_global=True, owner="u1", layout=bad_layout, slots=[], ) @pytest.mark.asyncio() @pytest.mark.parametrize("bad_layout", [0, 27, -1]) async def test_update_liveview_public_rejects_invalid_layout( protect_client: ProtectApiClient, bad_layout: int, ) -> None: with pytest.raises(BadRequest, match="layout must be between 1 and 26"): await protect_client.update_liveview_public(LIVEVIEW_ID, layout=bad_layout) def test_public_bootstrap_applies_liveview_add_and_update( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() add_payload = {"type": "add", "item": _liveview_raw()} mt, new, old = pb.process_devices_ws_message(protect_client, add_payload) assert mt is ModelType.LIVEVIEW assert new is not None and new.id == LIVEVIEW_ID assert isinstance(new, PublicLiveview) assert not isinstance(new, Liveview) assert old is None assert LIVEVIEW_ID in pb.liveviews # Partial WS update — only ``layout`` on the wire. Untouched fields # (slots, owner) must survive the in-place merge. update_payload: dict[str, Any] = { "type": "update", "item": {"id": LIVEVIEW_ID, "modelKey": "liveview", "layout": 4}, } mt, new, old = pb.process_devices_ws_message(protect_client, update_payload) assert old is not None assert new is not None assert new.layout == 4 # type: ignore[attr-defined] assert new.owner == OWNER_ID # type: ignore[attr-defined] assert len(new.slots) == 1 # type: ignore[attr-defined] assert new.slots[0].camera_ids == ["cam-1", "cam-2"] # type: ignore[attr-defined] def test_public_bootstrap_liveview_get_lookup( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() pb.process_devices_ws_message( protect_client, {"type": "add", "item": _liveview_raw()} ) fetched = pb.get(ModelType.LIVEVIEW, LIVEVIEW_ID) assert fetched is not None assert fetched is pb.liveviews[LIVEVIEW_ID] assert isinstance(fetched, PublicLiveview) @pytest.mark.asyncio() async def test_update_public_populates_liveviews( protect_client: ProtectApiClient, ) -> None: lv1 = Mock(id="lv-1") lv2 = Mock(id="lv-2") _mock_update_public_endpoints( protect_client, get_liveviews_public=AsyncMock(return_value=[lv1, lv2]), ) pb = await protect_client.update_public() assert "lv-1" in pb.liveviews assert "lv-2" in pb.liveviews # Drop ``lv-1`` from a subsequent fetch — ``apply_fetch_result`` removes # stale entries via the standard list-fetch path (no special-case). protect_client.get_liveviews_public = AsyncMock(return_value=[lv2]) pb = await protect_client.update_public() assert "lv-1" not in pb.liveviews assert "lv-2" in pb.liveviews @pytest.mark.asyncio() async def test_liveview_api_update_rejects_generic_mutations() -> None: """Generic mutation path must fail loudly for Public API liveviews.""" liveview = PublicLiveview.from_unifi_dict(**_liveview_raw()) with pytest.raises(BadRequest, match="Liveview mutations"): await liveview._api_update({"name": "new"}) # --------------------------------------------------------------------------- # Bridges # --------------------------------------------------------------------------- def _bridge_raw(**overrides: Any) -> dict[str, Any]: raw: dict[str, Any] = { "id": BRIDGE_ID, "modelKey": "bridge", "state": "CONNECTED", "name": "Bridge 1", "mac": "AABBCCDDEEFF", "platform": "mt7621", "clients": ["112233445566"], "maxClients": 7, } raw.update(overrides) return raw def test_bridge_model_from_unifi_dict() -> None: bridge = PublicBridge.from_unifi_dict(**_bridge_raw()) assert bridge.id == BRIDGE_ID assert bridge.model is ModelType.BRIDGE assert bridge.state is DeviceState.CONNECTED assert bridge.name == "Bridge 1" assert bridge.mac == "AABBCCDDEEFF" assert bridge.platform == "mt7621" assert bridge.clients == ["112233445566"] assert bridge.max_clients == 7 def test_bridge_model_does_not_collide_with_private() -> None: """``PublicBridge`` and private ``Bridge`` are distinct types.""" assert PublicBridge is not Bridge public = PublicBridge.from_unifi_dict(**_bridge_raw()) assert isinstance(public, PublicBridge) # ``MODEL_TO_CLASS`` must still resolve ``bridge`` modelKey to the private # ``Bridge`` class — the public-API path is opt-in via PublicBootstrap. assert MODEL_TO_CLASS[ModelType.BRIDGE] is Bridge assert PublicBridge not in MODEL_TO_CLASS.values() def test_bridge_model_preserves_optional_platform() -> None: """``bridgePlatform`` is nullable per the spec.""" bridge = PublicBridge.from_unifi_dict(**_bridge_raw(platform=None)) assert bridge.platform is None @pytest.mark.asyncio() @patch("uiprotect.api.PublicBridge.from_unifi_dict") async def test_get_bridges_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=BRIDGE_ID), Mock(id="other")] protect_client.api_request_list = AsyncMock( return_value=[{"id": BRIDGE_ID}, {"id": "other"}] ) pb = protect_client._public_bootstrap = PublicBootstrap() result = await protect_client.get_bridges_public() protect_client.api_request_list.assert_called_with( url="/v1/bridges", public_api=True ) assert len(result) == 2 # List getter must not write into the cache. assert pb.bridges == {} @pytest.mark.asyncio() @patch("uiprotect.api.PublicBridge.from_unifi_dict") async def test_get_bridge_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=BRIDGE_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": BRIDGE_ID}) await protect_client.get_bridge_public(BRIDGE_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/bridges/{BRIDGE_ID}" assert kwargs["public_api"] is True @pytest.mark.asyncio() @patch("uiprotect.api.PublicBridge.from_unifi_dict") async def test_get_bridge_public_writes_pb( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: obj = Mock(id=BRIDGE_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": BRIDGE_ID}) pb = protect_client._public_bootstrap = PublicBootstrap() await protect_client.get_bridge_public(BRIDGE_ID) assert pb.bridges[BRIDGE_ID] is obj @pytest.mark.asyncio() @patch("uiprotect.api.PublicBridge.from_unifi_dict") async def test_update_bridge_public_body( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: obj = Mock(id=BRIDGE_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": BRIDGE_ID}) pb = protect_client._public_bootstrap = PublicBootstrap() await protect_client.update_bridge_public(BRIDGE_ID, name="renamed") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/bridges/{BRIDGE_ID}" assert kwargs["method"] == "patch" assert kwargs["json"] == {"name": "renamed"} assert pb.bridges[BRIDGE_ID] is obj @pytest.mark.asyncio() async def test_update_bridge_public_empty( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_obj = AsyncMock() with pytest.raises(BadRequest): await protect_client.update_bridge_public(BRIDGE_ID) protect_client.api_request_obj.assert_not_called() @pytest.mark.asyncio() @patch("uiprotect.api.PublicBridge.from_unifi_dict") async def test_update_bridge_public_no_bootstrap( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: """``_public_bootstrap is None`` must skip cache writes without raising.""" obj = Mock(id=BRIDGE_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": BRIDGE_ID}) protect_client._public_bootstrap = None result = await protect_client.update_bridge_public(BRIDGE_ID, name="renamed") assert result is obj def test_public_bootstrap_applies_bridge_add_and_update( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() add_payload = {"type": "add", "item": _bridge_raw()} mt, new, old = pb.process_devices_ws_message(protect_client, add_payload) assert mt is ModelType.BRIDGE assert new is not None and new.id == BRIDGE_ID assert isinstance(new, PublicBridge) assert not isinstance(new, Bridge) assert old is None assert BRIDGE_ID in pb.bridges # The public-API cache must not contaminate other stores. assert BRIDGE_ID not in pb.cameras update_payload: dict[str, Any] = { "type": "update", "item": {"id": BRIDGE_ID, "modelKey": "bridge", "maxClients": 12}, } mt, new, old = pb.process_devices_ws_message(protect_client, update_payload) assert old is not None assert new is not None assert new.max_clients == 12 # type: ignore[attr-defined] # Untouched fields survive the in-place merge. assert new.clients == ["112233445566"] # type: ignore[attr-defined] assert new.platform == "mt7621" # type: ignore[attr-defined] def test_public_bootstrap_bridge_get_lookup( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() pb.process_devices_ws_message( protect_client, {"type": "add", "item": _bridge_raw()} ) fetched = pb.get(ModelType.BRIDGE, BRIDGE_ID) assert fetched is not None assert fetched is pb.bridges[BRIDGE_ID] assert isinstance(fetched, PublicBridge) @pytest.mark.asyncio() async def test_update_public_populates_bridges( protect_client: ProtectApiClient, ) -> None: b1 = Mock(id="b-1") b2 = Mock(id="b-2") _mock_update_public_endpoints( protect_client, get_bridges_public=AsyncMock(return_value=[b1, b2]), ) pb = await protect_client.update_public() assert "b-1" in pb.bridges assert "b-2" in pb.bridges @pytest.mark.asyncio() async def test_update_public_tolerates_bridge_endpoint_404( protect_client: ProtectApiClient, ) -> None: """A 404 on /v1/bridges must not abort the whole refresh.""" _mock_update_public_endpoints( protect_client, get_bridges_public=AsyncMock(side_effect=BadRequest("404 Not Found")), get_viewers_public=AsyncMock(return_value=[Mock(id="v-1")]), ) pb = await protect_client.update_public() assert pb.bridges == {} assert "v-1" in pb.viewers @pytest.mark.asyncio() async def test_bridge_api_update_rejects_generic_mutations() -> None: """Generic mutation path must fail loudly for Public API bridges.""" bridge = PublicBridge.from_unifi_dict(**_bridge_raw()) with pytest.raises(BadRequest, match="Bridge mutations"): await bridge._api_update({"name": "new"}) # --------------------------------------------------------------------------- # Viewers # --------------------------------------------------------------------------- def _viewer_raw(**overrides: Any) -> dict[str, Any]: raw: dict[str, Any] = { "id": VIEWER_ID, "modelKey": "viewer", "state": "CONNECTED", "name": "Viewer 1", "mac": "AABBCCDDEE01", "liveview": LIVEVIEW_ID, "streamLimit": 16, } raw.update(overrides) return raw def test_viewer_model_from_unifi_dict() -> None: viewer = PublicViewer.from_unifi_dict(**_viewer_raw()) assert viewer.id == VIEWER_ID assert viewer.model is ModelType.VIEWPORT assert viewer.state is DeviceState.CONNECTED assert viewer.name == "Viewer 1" assert viewer.mac == "AABBCCDDEE01" assert viewer.liveview_id == LIVEVIEW_ID assert viewer.stream_limit == 16 def test_viewer_model_preserves_null_liveview() -> None: viewer = PublicViewer.from_unifi_dict(**_viewer_raw(liveview=None)) assert viewer.liveview_id is None def test_viewer_model_round_trip_emits_liveview_wire_key() -> None: viewer = PublicViewer.from_unifi_dict(**_viewer_raw()) wire = viewer.unifi_dict() assert wire["liveview"] == LIVEVIEW_ID assert "liveviewId" not in wire def test_viewer_model_does_not_collide_with_private() -> None: assert PublicViewer is not Viewer public = PublicViewer.from_unifi_dict(**_viewer_raw()) assert isinstance(public, PublicViewer) # ``MODEL_TO_CLASS`` must still resolve ``viewer`` modelKey to the private # ``Viewer`` class — the public-API path is opt-in via PublicBootstrap. assert MODEL_TO_CLASS[ModelType.VIEWPORT] is Viewer assert PublicViewer not in MODEL_TO_CLASS.values() @pytest.mark.asyncio() @patch("uiprotect.api.PublicViewer.from_unifi_dict") async def test_get_viewers_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.side_effect = [Mock(id=VIEWER_ID), Mock(id="other")] protect_client.api_request_list = AsyncMock( return_value=[{"id": VIEWER_ID}, {"id": "other"}] ) pb = protect_client._public_bootstrap = PublicBootstrap() result = await protect_client.get_viewers_public() protect_client.api_request_list.assert_called_with( url="/v1/viewers", public_api=True ) assert len(result) == 2 assert pb.viewers == {} @pytest.mark.asyncio() @patch("uiprotect.api.PublicViewer.from_unifi_dict") async def test_get_viewer_public( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=VIEWER_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": VIEWER_ID}) await protect_client.get_viewer_public(VIEWER_ID) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/viewers/{VIEWER_ID}" assert kwargs["public_api"] is True @pytest.mark.asyncio() @patch("uiprotect.api.PublicViewer.from_unifi_dict") async def test_get_viewer_public_writes_pb( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: obj = Mock(id=VIEWER_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": VIEWER_ID}) pb = protect_client._public_bootstrap = PublicBootstrap() await protect_client.get_viewer_public(VIEWER_ID) assert pb.viewers[VIEWER_ID] is obj @pytest.mark.asyncio() @patch("uiprotect.api.PublicViewer.from_unifi_dict") async def test_update_viewer_public_name_only( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: obj = Mock(id=VIEWER_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": VIEWER_ID}) pb = protect_client._public_bootstrap = PublicBootstrap() await protect_client.update_viewer_public(VIEWER_ID, name="kitchen") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["url"] == f"/v1/viewers/{VIEWER_ID}" assert kwargs["method"] == "patch" # ``liveview`` must be omitted (no change) when not explicitly passed. assert kwargs["json"] == {"name": "kitchen"} assert pb.viewers[VIEWER_ID] is obj @pytest.mark.asyncio() @patch("uiprotect.api.PublicViewer.from_unifi_dict") async def test_update_viewer_public_explicit_null_liveview( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: """``liveview=None`` must serialize as an explicit null, not be omitted.""" mock_ctor.return_value = Mock(id=VIEWER_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": VIEWER_ID}) await protect_client.update_viewer_public(VIEWER_ID, liveview=None) _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == {"liveview": None} @pytest.mark.asyncio() @patch("uiprotect.api.PublicViewer.from_unifi_dict") async def test_update_viewer_public_set_liveview( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: mock_ctor.return_value = Mock(id=VIEWER_ID) protect_client.api_request_obj = AsyncMock(return_value={"id": VIEWER_ID}) await protect_client.update_viewer_public(VIEWER_ID, liveview="lv-1") _, kwargs = protect_client.api_request_obj.call_args assert kwargs["json"] == {"liveview": "lv-1"} @pytest.mark.asyncio() async def test_update_viewer_public_empty( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_obj = AsyncMock() with pytest.raises(BadRequest): await protect_client.update_viewer_public(VIEWER_ID) protect_client.api_request_obj.assert_not_called() def test_public_bootstrap_applies_viewer_add_and_update( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() add_payload = {"type": "add", "item": _viewer_raw()} mt, new, old = pb.process_devices_ws_message(protect_client, add_payload) assert mt is ModelType.VIEWPORT assert new is not None and new.id == VIEWER_ID assert isinstance(new, PublicViewer) assert not isinstance(new, Viewer) assert old is None assert VIEWER_ID in pb.viewers assert VIEWER_ID not in pb.cameras # Partial update — only the wire ``liveview`` key flips to null. update_payload: dict[str, Any] = { "type": "update", "item": {"id": VIEWER_ID, "modelKey": "viewer", "liveview": None}, } mt, new, old = pb.process_devices_ws_message(protect_client, update_payload) assert old is not None assert new is not None assert new.liveview_id is None # type: ignore[attr-defined] # ``streamLimit`` (untouched) survives the merge. assert new.stream_limit == 16 # type: ignore[attr-defined] def test_public_bootstrap_viewer_get_lookup( protect_client: ProtectApiClient, ) -> None: pb = PublicBootstrap() pb.process_devices_ws_message( protect_client, {"type": "add", "item": _viewer_raw()} ) fetched = pb.get(ModelType.VIEWPORT, VIEWER_ID) assert fetched is not None assert fetched is pb.viewers[VIEWER_ID] assert isinstance(fetched, PublicViewer) @pytest.mark.asyncio() async def test_update_public_populates_viewers( protect_client: ProtectApiClient, ) -> None: v1 = Mock(id="v-1") v2 = Mock(id="v-2") _mock_update_public_endpoints( protect_client, get_viewers_public=AsyncMock(return_value=[v1, v2]), ) pb = await protect_client.update_public() assert "v-1" in pb.viewers assert "v-2" in pb.viewers @pytest.mark.asyncio() async def test_viewer_api_update_rejects_generic_mutations() -> None: """Generic mutation path must fail loudly for Public API viewers.""" viewer = PublicViewer.from_unifi_dict(**_viewer_raw()) with pytest.raises(BadRequest, match="Viewer mutations"): await viewer._api_update({"name": "new"}) @pytest.mark.asyncio() @patch("uiprotect.api.PublicViewer.from_unifi_dict") async def test_update_viewer_public_no_bootstrap( mock_ctor: Mock, protect_client: ProtectApiClient, ) -> None: """``_public_bootstrap is None`` must skip cache writes without raising.""" obj = Mock(id=VIEWER_ID) mock_ctor.return_value = obj protect_client.api_request_obj = AsyncMock(return_value={"id": VIEWER_ID}) protect_client._public_bootstrap = None result = await protect_client.update_viewer_public(VIEWER_ID, name="renamed") assert result is obj @pytest.mark.asyncio() async def test_bridge_set_name_routes_to_bridge_endpoint( protect_client: ProtectApiClient, ) -> None: bridge = PublicBridge.from_unifi_dict(**_bridge_raw(), api=protect_client) protect_client.update_bridge_public = AsyncMock(return_value=bridge) await bridge.set_name("Renamed") protect_client.update_bridge_public.assert_awaited_once_with( BRIDGE_ID, name="Renamed" ) @pytest.mark.asyncio() async def test_viewer_set_name_routes_to_viewer_endpoint( protect_client: ProtectApiClient, ) -> None: viewer = PublicViewer.from_unifi_dict(**_viewer_raw(), api=protect_client) protect_client.update_viewer_public = AsyncMock(return_value=viewer) await viewer.set_name("Renamed") protect_client.update_viewer_public.assert_awaited_once_with( VIEWER_ID, name="Renamed" ) @pytest.mark.asyncio() async def test_viewer_set_liveview_routes_to_viewer_endpoint( protect_client: ProtectApiClient, ) -> None: viewer = PublicViewer.from_unifi_dict(**_viewer_raw(), api=protect_client) protect_client.update_viewer_public = AsyncMock(return_value=viewer) await viewer.set_liveview("lv-9") protect_client.update_viewer_public.assert_awaited_once_with( VIEWER_ID, liveview="lv-9" ) await viewer.set_liveview(None) protect_client.update_viewer_public.assert_awaited_with(VIEWER_ID, liveview=None) def test_unset_sentinel_repr_and_singleton() -> None: """``_UNSET`` is typed as ``_UnsetType``; ``repr`` is stable for log/debug output.""" assert isinstance(_UNSET, _UnsetType) assert repr(_UNSET) == "" def _other_siren_item(siren_id: str = "other-siren-id") -> dict[str, Any]: """A second siren payload so a fetch can add or drop one device.""" return {**_siren_snapshot_item(), "id": siren_id, "mac": "BB"} @pytest.mark.asyncio() async def test_update_public_initial_prime_emits_no_membership_frames( protect_client: ProtectApiClient, ) -> None: """The first prime is not a diff — every device would otherwise look added.""" client = protect_client client._public_bootstrap = None siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(return_value=[siren]) ) captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) await client.update_public() assert captured == [] @pytest.mark.asyncio() async def test_update_public_emits_add_frame_for_device_added_during_gap( protect_client: ProtectApiClient, ) -> None: """A device the refresh newly reports reaches subscribers as a devices-WS add.""" client = protect_client client._public_bootstrap = None first = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(return_value=[first]) ) await client.update_public() captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) second = Siren.from_unifi_dict(api=client, **_other_siren_item()) client.get_sirens_public = AsyncMock(return_value=[first, second]) # type: ignore[method-assign] pb = await client.update_public() assert len(captured) == 1 msg = captured[0] assert msg.action is WSAction.ADD assert msg.new_update_id == "other-siren-id" assert msg.new_obj is pb.sirens["other-siren-id"] assert msg.old_obj is None assert msg.changed_data == {"modelKey": "siren", "id": "other-siren-id"} @pytest.mark.asyncio() async def test_update_public_emits_remove_frame_for_device_dropped_during_gap( protect_client: ProtectApiClient, ) -> None: """A device the refresh no longer reports reaches subscribers as a remove.""" client = protect_client client._public_bootstrap = None first = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) second = Siren.from_unifi_dict(api=client, **_other_siren_item()) _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(return_value=[first, second]) ) await client.update_public() captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) client.get_sirens_public = AsyncMock(return_value=[first]) # type: ignore[method-assign] pb = await client.update_public() assert "other-siren-id" not in pb.sirens assert len(captured) == 1 msg = captured[0] assert msg.action is WSAction.REMOVE assert msg.new_update_id == "other-siren-id" assert msg.new_obj is None assert msg.old_obj is second assert msg.changed_data == {"modelKey": "siren", "id": "other-siren-id"} @pytest.mark.asyncio() async def test_update_public_emits_nothing_when_membership_is_unchanged( protect_client: ProtectApiClient, ) -> None: """An in-place refresh of the same device set stays quiet.""" client = protect_client client._public_bootstrap = None siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(return_value=[siren]) ) await client.update_public() captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) await client.update_public() assert captured == [] @pytest.mark.asyncio() async def test_update_public_does_not_emit_for_non_device_stores( protect_client: ProtectApiClient, ) -> None: """ULP users are not devices; their membership changes emit no devices frame.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints(client) await client.update_public() captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) user = PublicUlpUser( api=client, id="ulp-1", model=ModelType.ULP_USER, first_name="A", last_name="B", full_name="A B", status=UlpUserStatus.ACTIVE, ) client.get_ulp_users_public = AsyncMock(return_value=[user]) # type: ignore[method-assign] pb = await client.update_public() assert "ulp-1" in pb.ulp_users assert captured == [] @pytest.mark.asyncio() async def test_update_public_add_frame_carries_primed_rtsps_streams( protect_client: ProtectApiClient, ) -> None: """A camera's add frame lands after the RTSPS prime, so its streams are set.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints(client) await client.update_public() streams = RTSPSStreams(high="rtsps://example.com/fresh") client.get_camera_rtsps_streams = AsyncMock(return_value=streams) # type: ignore[method-assign] camera = PublicCamera.from_unifi_dict(api=client, **dict(CAMERA_PAYLOAD)) client.get_cameras_public = AsyncMock(return_value=[camera]) # type: ignore[method-assign] captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) await client.update_public() assert len(captured) == 1 assert captured[0].action is WSAction.ADD assert captured[0].new_obj is camera assert camera.rtsps_streams is streams @pytest.mark.asyncio() async def test_update_public_skips_add_frame_when_replayed_frame_removed_device( protect_client: ProtectApiClient, ) -> None: """A prime-window remove wins: no add is announced for an object already gone.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints(client) await client.update_public() siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) remove_msg = _mock_text_ws_message( {"type": "remove", "item": {"id": SIREN_ID, "modelKey": "siren"}} ) async def cameras_side_effect() -> list[Any]: client._process_devices_ws_message(remove_msg) return [] client.get_sirens_public = AsyncMock(return_value=[siren]) # type: ignore[method-assign] client.get_cameras_public = AsyncMock(side_effect=cameras_side_effect) # type: ignore[method-assign] captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) pb = await client.update_public() assert SIREN_ID not in pb.sirens # Only the replayed remove reaches subscribers — no add for a gone device. assert [msg.action for msg in captured] == [WSAction.REMOVE] @pytest.mark.asyncio() async def test_update_public_membership_frames_reach_typed_subscribers( protect_client: ProtectApiClient, ) -> None: """The synthetic frames resolve to typed ADDED/REMOVED device changes.""" client = protect_client client._public_bootstrap = None siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) _mock_update_public_endpoints(client) await client.update_public() changes: list[ProtectDeviceChange] = [] client.subscribe_devices(changes.append) client.get_sirens_public = AsyncMock(return_value=[siren]) # type: ignore[method-assign] await client.update_public() client.get_sirens_public = AsyncMock(return_value=[]) # type: ignore[method-assign] await client.update_public() assert [change.change for change in changes] == [ DeviceChange.ADDED, DeviceChange.REMOVED, ] assert {change.model_type for change in changes} == {ModelType.SIREN} assert {change.device_id for change in changes} == {SIREN_ID} assert changes[0].model is siren assert changes[1].device_mac == "AA" def test_apply_fetch_result_reports_added_and_removed( protect_client: ProtectApiClient, ) -> None: """``apply_fetch_result`` returns the membership change it applied.""" siren = _build_siren(protect_client) pb = protect_client.public_bootstrap unchanged = pb.apply_fetch_result("sirens", [siren]) assert unchanged.attr == "sirens" assert unchanged.added_ids == [] assert unchanged.removed == [] dropped = pb.apply_fetch_result("sirens", []) assert dropped.added_ids == [] assert dropped.removed == [siren] re_added = pb.apply_fetch_result("sirens", [siren]) assert re_added.added_ids == [SIREN_ID] assert re_added.removed == [] @pytest.mark.asyncio() async def test_update_public_does_not_repeat_a_wire_announced_add( protect_client: ProtectApiClient, ) -> None: """A device announced by a live add during the fetch is not announced twice.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints(client) await client.update_public() siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) add_msg = _mock_text_ws_message( {"type": "add", "item": _siren_snapshot_item()}, ) async def cameras_side_effect() -> list[Any]: client._process_devices_ws_message(add_msg) return [] client.get_sirens_public = AsyncMock(return_value=[siren]) # type: ignore[method-assign] client.get_cameras_public = AsyncMock(side_effect=cameras_side_effect) # type: ignore[method-assign] captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) pb = await client.update_public() assert SIREN_ID in pb.sirens assert [msg.action for msg in captured] == [WSAction.ADD] @pytest.mark.asyncio() async def test_update_public_does_not_repeat_a_wire_announced_remove( protect_client: ProtectApiClient, ) -> None: """A device removed by a live frame during the fetch is not announced twice.""" client = protect_client client._public_bootstrap = None siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) _mock_update_public_endpoints( client, get_sirens_public=AsyncMock(return_value=[siren]) ) await client.update_public() remove_msg = _mock_text_ws_message( {"type": "remove", "item": {"id": SIREN_ID, "modelKey": "siren"}} ) async def cameras_side_effect() -> list[Any]: client._process_devices_ws_message(remove_msg) return [] client.get_sirens_public = AsyncMock(return_value=[]) # type: ignore[method-assign] client.get_cameras_public = AsyncMock(side_effect=cameras_side_effect) # type: ignore[method-assign] captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) pb = await client.update_public() assert SIREN_ID not in pb.sirens assert [msg.action for msg in captured] == [WSAction.REMOVE] @pytest.mark.asyncio() async def test_update_public_membership_frames_respect_model_filter( protect_client: ProtectApiClient, ) -> None: """A caller filtering sirens out is not handed synthetic siren frames.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints(client) await client.update_public() client._devices_ws_subscribed_models = {ModelType.CAMERA} siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) client.get_sirens_public = AsyncMock(return_value=[siren]) # type: ignore[method-assign] captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) pb = await client.update_public() assert SIREN_ID in pb.sirens assert captured == [] @pytest.mark.asyncio() async def test_update_public_clears_membership_tracking_when_prime_fails( protect_client: ProtectApiClient, ) -> None: """A failed prime must not leave the membership tracker armed.""" client = protect_client _mock_update_public_endpoints( client, get_cameras_public=AsyncMock(side_effect=RuntimeError("boom")) ) with pytest.raises(RuntimeError): await client.update_public() assert client._public_membership_seen is None @pytest.mark.asyncio() async def test_update_public_still_announces_after_an_unparsable_add_frame( protect_client: ProtectApiClient, ) -> None: """An add frame the library could not parse must not swallow the diff's add.""" client = protect_client client._public_bootstrap = None _mock_update_public_endpoints(client) await client.update_public() siren = Siren.from_unifi_dict(api=client, **_siren_snapshot_item()) # Missing every required field — model construction fails, so the frame # reaches subscribers with no usable object. bad_add = _mock_text_ws_message( {"type": "add", "item": {"id": SIREN_ID, "modelKey": "siren"}} ) async def cameras_side_effect() -> list[Any]: client._process_devices_ws_message(bad_add) return [] client.get_sirens_public = AsyncMock(return_value=[siren]) # type: ignore[method-assign] client.get_cameras_public = AsyncMock(side_effect=cameras_side_effect) # type: ignore[method-assign] captured: list[WSSubscriptionMessage] = [] client.subscribe_devices_websocket(captured.append) pb = await client.update_public() assert pb.sirens[SIREN_ID] is siren # The unparsable frame carried no object; the diff still announces the add. assert [msg.new_obj for msg in captured] == [None, siren] uilibs-uiprotect-317a52c/tests/test_api_public_only.py000066400000000000000000000436631524637705700233230ustar00rootroot00000000000000"""Tests for the public-only (API-key-only) ProtectApiClient mode.""" from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import AsyncMock, Mock, patch import pytest from aiofiles import os as aos from uiprotect.api import ProtectApiClient from uiprotect.data import PublicBootstrap from uiprotect.data.nvr import MetaInfo from uiprotect.data.types import Version from uiprotect.exceptions import ( BadRequest, NotAuthorized, NvrError, PublicOnlyModeError, ) from .test_api_public import _make_public_nvr, _mock_update_public_endpoints if TYPE_CHECKING: from pathlib import Path API_KEY = "test-public-key" def _public_only_client() -> ProtectApiClient: return ProtectApiClient.public_only( "127.0.0.1", 443, api_key=API_KEY, verify_ssl=False, ) # --------------------------------------------------------------------------- # Construction # --------------------------------------------------------------------------- def test_public_only_factory_sets_flag() -> None: client = _public_only_client() assert client.is_public_only is True assert client._api_key == API_KEY def test_keyword_api_key_only_is_public_only() -> None: client = ProtectApiClient("127.0.0.1", 443, api_key=API_KEY) assert client.is_public_only is True def test_full_credentials_not_public_only() -> None: client = ProtectApiClient("127.0.0.1", 443, "user", "pw") assert client.is_public_only is False def test_credentials_plus_api_key_not_public_only() -> None: client = ProtectApiClient("127.0.0.1", 443, "user", "pw", api_key=API_KEY) assert client.is_public_only is False def test_positional_credentials_still_work() -> None: creds = ("user", "pw") client = ProtectApiClient("127.0.0.1", 443, *creds) assert client._username == creds[0] assert client._password == creds[1] assert client.is_public_only is False def test_no_auth_material_raises() -> None: with pytest.raises(BadRequest): ProtectApiClient("127.0.0.1", 443) def test_partial_credentials_raise() -> None: with pytest.raises(BadRequest): ProtectApiClient("127.0.0.1", 443, "user") with pytest.raises(BadRequest): ProtectApiClient("127.0.0.1", 443, "user", api_key=API_KEY) # --------------------------------------------------------------------------- # Private-session entry points are guarded # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_authenticate_raises_public_only() -> None: client = _public_only_client() with ( patch.object(client, "request", new=AsyncMock()) as request, pytest.raises(PublicOnlyModeError), ): await client.authenticate() request.assert_not_called() @pytest.mark.asyncio() async def test_ensure_authenticated_raises_public_only() -> None: client = _public_only_client() with pytest.raises(PublicOnlyModeError): await client.ensure_authenticated() @pytest.mark.asyncio() async def test_update_raises_public_only() -> None: client = _public_only_client() with ( patch.object(client, "request", new=AsyncMock()) as request, pytest.raises(PublicOnlyModeError), ): await client.update() request.assert_not_called() @pytest.mark.asyncio() async def test_get_bootstrap_raises_public_only() -> None: client = _public_only_client() with pytest.raises(PublicOnlyModeError): await client.get_bootstrap() def test_private_bootstrap_property_raises() -> None: client = _public_only_client() with pytest.raises(BadRequest): _ = client.bootstrap # --------------------------------------------------------------------------- # Public surface remains available # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_update_public_works_without_private_session() -> None: client = _public_only_client() _mock_update_public_endpoints(client) with ( patch.object(client, "authenticate", new=AsyncMock()) as authenticate, patch.object(client, "get_console_mac", new=AsyncMock(return_value=None)), ): result = await client.update_public() assert isinstance(result, PublicBootstrap) assert client.has_public_bootstrap is True authenticate.assert_not_called() @pytest.mark.asyncio() async def test_subscribe_events_registers_without_auth() -> None: client = _public_only_client() unsub = client.subscribe_events_websocket(lambda _msg: None) assert len(client._events_ws_subscriptions) == 1 unsub() assert len(client._events_ws_subscriptions) == 0 @pytest.mark.asyncio() async def test_subscribe_devices_registers_without_auth() -> None: client = _public_only_client() unsub = client.subscribe_devices_websocket(lambda _msg: None) assert len(client._devices_ws_subscriptions) == 1 unsub() assert len(client._devices_ws_subscriptions) == 0 @pytest.mark.asyncio() async def test_public_ws_auth_returns_api_key_header() -> None: client = _public_only_client() headers = await client._auth_public_api_websocket() assert headers == {"X-API-KEY": API_KEY} @pytest.mark.asyncio() async def test_public_ws_auth_revoked_key_raises() -> None: client = _public_only_client() client._api_key = None with pytest.raises(NotAuthorized, match="API key is required"): await client._auth_public_api_websocket() # --------------------------------------------------------------------------- # Auth-error mapping on public requests # --------------------------------------------------------------------------- @pytest.mark.asyncio() @pytest.mark.parametrize("status", [401, 403]) async def test_public_request_maps_auth_error(status: int) -> None: client = _public_only_client() response = Mock() response.status = status response.url = "https://127.0.0.1/integration/v1/meta/info" with ( patch.object(client, "request", new=AsyncMock(return_value=response)), patch("uiprotect.api.get_response_reason", return_value="denied"), pytest.raises(NotAuthorized), ): await client.get_meta_info() @pytest.mark.asyncio() async def test_public_request_missing_key_raises() -> None: client = ProtectApiClient("127.0.0.1", 443, "user", "pw") assert client._api_key is None with pytest.raises(NotAuthorized, match="API key is required"): await client.get_meta_info() # --------------------------------------------------------------------------- # Console mac resolution (off-contract UniFi-OS /api/system fallback) # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_get_console_mac_returns_mac() -> None: client = _public_only_client() with patch.object( client, "api_request", new=AsyncMock(return_value={"mac": "AABBCCDDEEFF", "name": "UNVR"}), ) as api_request: mac = await client.get_console_mac() assert mac == "AABBCCDDEEFF" api_request.assert_awaited_once() kwargs = api_request.await_args.kwargs assert kwargs["url"] == "/system" assert kwargs["api_path"] == "/api" assert kwargs["require_auth"] is False @pytest.mark.asyncio() async def test_get_console_mac_missing_mac_returns_none() -> None: client = _public_only_client() with patch.object( client, "api_request", new=AsyncMock(return_value={"name": "UNVR"}) ): assert await client.get_console_mac() is None @pytest.mark.asyncio() async def test_get_console_mac_empty_mac_returns_none() -> None: client = _public_only_client() with patch.object(client, "api_request", new=AsyncMock(return_value={"mac": ""})): assert await client.get_console_mac() is None @pytest.mark.asyncio() async def test_get_console_mac_non_dict_returns_none() -> None: client = _public_only_client() with patch.object(client, "api_request", new=AsyncMock(return_value=None)): assert await client.get_console_mac() is None @pytest.mark.asyncio() async def test_get_console_mac_unreachable_returns_none() -> None: client = _public_only_client() with patch.object( client, "api_request", new=AsyncMock(side_effect=NvrError("unreachable")) ): assert await client.get_console_mac() is None @pytest.mark.asyncio() async def test_get_console_mac_timeout_returns_none() -> None: client = _public_only_client() with patch.object(client, "api_request", new=AsyncMock(side_effect=TimeoutError)): assert await client.get_console_mac() is None @pytest.mark.asyncio() async def test_resolve_nvr_mac_prefers_public_bootstrap() -> None: """Public bootstrap mac wins and skips the console call.""" client = _public_only_client() client._public_bootstrap = Mock(nvr=Mock(mac="AA:BB:CC:DD:EE:FF")) client._bootstrap = Mock(nvr=Mock(mac="112233445566")) console = AsyncMock(return_value="778899AABBCC") with patch.object(client, "get_console_mac", new=console): assert await client.resolve_nvr_mac() == "aabbccddeeff" console.assert_not_awaited() @pytest.mark.asyncio() async def test_resolve_nvr_mac_falls_back_to_private_bootstrap() -> None: """A mac-less public nvr falls back to the private bootstrap mac.""" client = _public_only_client() client._public_bootstrap = Mock(nvr=Mock(mac=None)) client._bootstrap = Mock(nvr=Mock(mac="11:22:33:44:55:66")) console = AsyncMock(return_value="778899AABBCC") with patch.object(client, "get_console_mac", new=console): assert await client.resolve_nvr_mac() == "112233445566" console.assert_not_awaited() @pytest.mark.asyncio() async def test_resolve_nvr_mac_skips_public_when_nvr_absent() -> None: """A public bootstrap without an nvr falls back to the private mac.""" client = _public_only_client() client._public_bootstrap = Mock(nvr=None) client._bootstrap = Mock(nvr=Mock(mac="11:22:33:44:55:66")) with patch.object(client, "get_console_mac", new=AsyncMock()): assert await client.resolve_nvr_mac() == "112233445566" @pytest.mark.asyncio() async def test_resolve_nvr_mac_fetches_public_nvr_when_unprimed() -> None: """Unprimed client resolves via /v1/nvrs without update_public first.""" client = _public_only_client() assert client._public_bootstrap is None nvr = AsyncMock(return_value=Mock(mac="AA:BB:CC:DD:EE:FF")) console = AsyncMock(return_value="778899AABBCC") with ( patch.object(client, "get_nvr_public", new=nvr), patch.object(client, "get_console_mac", new=console), ): assert await client.resolve_nvr_mac() == "aabbccddeeff" console.assert_not_awaited() @pytest.mark.asyncio() async def test_resolve_nvr_mac_falls_back_to_console() -> None: """Old firmware: mac-less public nvr falls through to the console mac.""" client = _public_only_client() assert client._public_bootstrap is None assert client._bootstrap is None with ( patch.object( client, "get_nvr_public", new=AsyncMock(return_value=Mock(mac=None)) ), patch.object( client, "get_console_mac", new=AsyncMock(return_value="AA:BB:CC:DD:EE:FF") ), ): assert await client.resolve_nvr_mac() == "aabbccddeeff" @pytest.mark.asyncio() async def test_resolve_nvr_mac_falls_back_to_console_when_public_errors() -> None: """A /v1/nvrs failure (endpoint absent) falls through to the console.""" client = _public_only_client() assert client._public_bootstrap is None with ( patch.object( client, "get_nvr_public", new=AsyncMock(side_effect=NvrError("no endpoint")) ), patch.object( client, "get_console_mac", new=AsyncMock(return_value="AA:BB:CC:DD:EE:FF") ), ): assert await client.resolve_nvr_mac() == "aabbccddeeff" @pytest.mark.asyncio() async def test_resolve_nvr_mac_falls_back_to_console_when_public_times_out() -> None: """A hung /v1/nvrs raising bare TimeoutError falls through to the console.""" client = _public_only_client() assert client._public_bootstrap is None with ( patch.object(client, "get_nvr_public", new=AsyncMock(side_effect=TimeoutError)), patch.object( client, "get_console_mac", new=AsyncMock(return_value="AA:BB:CC:DD:EE:FF") ), ): assert await client.resolve_nvr_mac() == "aabbccddeeff" @pytest.mark.asyncio() async def test_resolve_nvr_mac_returns_none_when_no_source() -> None: """Resolver returns None when no source yields a mac.""" client = _public_only_client() with ( patch.object( client, "get_nvr_public", new=AsyncMock(return_value=Mock(mac=None)) ), patch.object(client, "get_console_mac", new=AsyncMock(return_value=None)), ): assert await client.resolve_nvr_mac() is None # --------------------------------------------------------------------------- # update_public backfills the public NVR mac from the console fallback # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_update_public_backfills_nvr_mac_from_console() -> None: """Older firmware: a mac-less public nvr is stamped in native format.""" client = _public_only_client() _mock_update_public_endpoints(client) # default nvr has mac=None console = AsyncMock(return_value="AA:BB:CC:DD:EE:FF") with patch.object(client, "get_console_mac", new=console): pb = await client.update_public() assert pb.nvr is not None assert pb.nvr.mac == "AABBCCDDEEFF" # native: uppercase, no separators console.assert_awaited_once() @pytest.mark.asyncio() async def test_update_public_skips_backfill_when_mac_present() -> None: """Newer firmware: mac already on the payload, no console request made.""" client = _public_only_client() nvr = _make_public_nvr(client) nvr.mac = "AABBCCDDEEFF" _mock_update_public_endpoints(client, get_nvr_public=AsyncMock(return_value=nvr)) console = AsyncMock(return_value="AA:BB:CC:DD:EE:FF") with patch.object(client, "get_console_mac", new=console): pb = await client.update_public() assert pb.nvr is not None assert pb.nvr.mac == "AABBCCDDEEFF" console.assert_not_awaited() @pytest.mark.asyncio() async def test_update_public_backfill_noop_when_nvr_absent() -> None: """A failed nvr fetch leaves pb.nvr None; backfill is a no-op.""" client = _public_only_client() _mock_update_public_endpoints( client, get_nvr_public=AsyncMock(side_effect=NvrError("no nvr")) ) console = AsyncMock(return_value="AABBCCDDEEFF") with patch.object(client, "get_console_mac", new=console): pb = await client.update_public() assert pb.nvr is None console.assert_not_awaited() @pytest.mark.asyncio() async def test_update_public_backfill_leaves_mac_none_when_unresolvable() -> None: """Console unreachable: mac-less nvr stays mac-less, no exception.""" client = _public_only_client() _mock_update_public_endpoints(client) # default nvr has mac=None with patch.object(client, "get_console_mac", new=AsyncMock(return_value=None)): pb = await client.update_public() assert pb.nvr is not None assert pb.nvr.mac is None @pytest.mark.asyncio() async def test_update_public_backfill_respects_nvr_replaced_during_await() -> None: """A websocket write-through that replaces pb.nvr mid-resolve is not clobbered.""" client = _public_only_client() _mock_update_public_endpoints(client) # default nvr has mac=None replacement = _make_public_nvr(client) replacement.mac = "AABBCCDDEEFF" async def _resolve_and_swap() -> str: client.public_bootstrap.nvr = replacement return "ffffffffffff" with patch.object(client, "resolve_nvr_mac", side_effect=_resolve_and_swap): pb = await client.update_public() assert pb.nvr is replacement assert pb.nvr.mac == "AABBCCDDEEFF" @pytest.mark.asyncio() async def test_update_public_backfill_when_nvr_removed_during_await() -> None: """pb.nvr removed mid-resolve leaves nothing to stamp, without crashing.""" client = _public_only_client() _mock_update_public_endpoints(client) # default nvr has mac=None async def _resolve_and_remove() -> str: client.public_bootstrap.nvr = None return "aabbccddeeff" with patch.object(client, "resolve_nvr_mac", side_effect=_resolve_and_remove): pb = await client.update_public() assert pb.nvr is None # --------------------------------------------------------------------------- # Min-version source # --------------------------------------------------------------------------- def test_meta_info_version_is_parsed() -> None: meta = MetaInfo(application_version="7.0.104") assert meta.version == Version("7.0.104") assert meta.version >= Version("5.0.0") def test_meta_info_from_unifi_dict_maps_wire_key() -> None: meta = MetaInfo.from_unifi_dict(applicationVersion="7.0.104") assert meta.application_version == "7.0.104" assert meta.version == Version("7.0.104") # --------------------------------------------------------------------------- # Session helpers short-circuit (no private username to key the store on) # --------------------------------------------------------------------------- @pytest.mark.asyncio() async def test_load_session_noop_public_only() -> None: client = _public_only_client() with patch.object(client, "get_session", new=AsyncMock()) as get_session: await client._load_session() get_session.assert_not_called() assert client._session is None assert client._loaded_session is False @pytest.mark.asyncio() async def test_clear_session_noop_public_only(tmp_path: Path) -> None: client = ProtectApiClient( "127.0.0.1", 443, api_key=API_KEY, verify_ssl=False, config_dir=tmp_path, ) assert client.is_public_only is True assert client.store_sessions is True await client.clear_session() assert not await aos.path.exists(client.config_file) uilibs-uiprotect-317a52c/tests/test_api_users_public.py000066400000000000000000000150131524637705700234670ustar00rootroot00000000000000"""Tests for the Public API user / ULP-user endpoints.""" from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import AsyncMock import pytest from tests.conftest import read_json_file from uiprotect.data import PublicUlpUser, PublicUser, UlpUserStatus from uiprotect.data.types import ModelType if TYPE_CHECKING: from uiprotect.api import ProtectApiClient USER_ID = "u1" ULP_USER_ID = "ulp-1" @pytest.mark.asyncio() async def test_get_users_public_calls_correct_url( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_list = AsyncMock(return_value=[]) result = await protect_client.get_users_public() assert result == [] protect_client.api_request_list.assert_called_once_with( url="/v1/users", public_api=True ) @pytest.mark.asyncio() async def test_get_user_public_calls_correct_url( protect_client: ProtectApiClient, ) -> None: payload = { "id": USER_ID, "modelKey": "user", "name": "John Doe", "firstName": "John", "lastName": "Doe", "email": "john@example.com", "ucoreUserId": "ucore-1", } protect_client.api_request_obj = AsyncMock(return_value=payload) result = await protect_client.get_user_public(USER_ID) assert isinstance(result, PublicUser) assert result.id == USER_ID assert result.model is ModelType.USER protect_client.api_request_obj.assert_called_once_with( url=f"/v1/users/{USER_ID}", public_api=True ) @pytest.mark.asyncio() async def test_public_user_parses_minimal_payload( protect_client_no_debug: ProtectApiClient, ) -> None: """All four optional spec fields may legitimately be ``null`` on the wire.""" payload = { "id": USER_ID, "modelKey": "user", "name": "Anonymous", "firstName": None, "lastName": None, "email": None, "ucoreUserId": None, } protect_client_no_debug.api_request_obj = AsyncMock(return_value=payload) user = await protect_client_no_debug.get_user_public(USER_ID) assert user.first_name is None assert user.last_name is None assert user.email is None assert user.ucore_user_id is None assert user.name == "Anonymous" @pytest.mark.asyncio() async def test_get_users_public_parses_list( protect_client_no_debug: ProtectApiClient, ) -> None: payload = [ { "id": "u1", "modelKey": "user", "name": "A B", "firstName": "A", "lastName": "B", "email": None, "ucoreUserId": None, }, { "id": "u2", "modelKey": "user", "name": "C D", "firstName": "C", "lastName": "D", "email": "c.d@example.com", "ucoreUserId": "ucore-2", }, ] protect_client_no_debug.api_request_list = AsyncMock(return_value=payload) users = await protect_client_no_debug.get_users_public() assert [u.id for u in users] == ["u1", "u2"] assert all(isinstance(u, PublicUser) for u in users) @pytest.mark.asyncio() async def test_get_ulp_users_public_calls_correct_url( protect_client: ProtectApiClient, ) -> None: protect_client.api_request_list = AsyncMock(return_value=[]) result = await protect_client.get_ulp_users_public() assert result == [] protect_client.api_request_list.assert_called_once_with( url="/v1/ulp-users", public_api=True ) @pytest.mark.asyncio() async def test_get_ulp_user_public_calls_correct_url( protect_client: ProtectApiClient, ) -> None: payload = { "id": ULP_USER_ID, "modelKey": "ulpUser", "firstName": "John", "lastName": "Doe", "fullName": "John Doe", "status": "ACTIVE", } protect_client.api_request_obj = AsyncMock(return_value=payload) result = await protect_client.get_ulp_user_public(ULP_USER_ID) assert isinstance(result, PublicUlpUser) assert result.id == ULP_USER_ID assert result.model is ModelType.ULP_USER protect_client.api_request_obj.assert_called_once_with( url=f"/v1/ulp-users/{ULP_USER_ID}", public_api=True ) @pytest.mark.asyncio() async def test_public_ulp_user_parses_minimal_payload( protect_client_no_debug: ProtectApiClient, ) -> None: payload = { "id": ULP_USER_ID, "modelKey": "ulpUser", "firstName": "John", "lastName": "Doe", "fullName": "John Doe", "status": "DEACTIVATED", } protect_client_no_debug.api_request_obj = AsyncMock(return_value=payload) user = await protect_client_no_debug.get_ulp_user_public(ULP_USER_ID) assert user.first_name == "John" assert user.last_name == "Doe" assert user.full_name == "John Doe" assert user.status is UlpUserStatus.DEACTIVATED def test_public_ulp_user_status_round_trip_from_fixture() -> None: payload = read_json_file("sample_ulp_users") active_raw = next(p for p in payload if p["status"] == "ACTIVE") deactivated_raw = next(p for p in payload if p["status"] == "DEACTIVATED") active = PublicUlpUser.from_unifi_dict(**active_raw) deactivated = PublicUlpUser.from_unifi_dict(**deactivated_raw) assert active.status is UlpUserStatus.ACTIVE assert deactivated.status is UlpUserStatus.DEACTIVATED assert active.unifi_dict()["status"] == "ACTIVE" assert deactivated.unifi_dict()["status"] == "DEACTIVATED" def test_public_ulp_user_email_parses_and_round_trips() -> None: """``email`` is modelled; the server's empty-string "none set" survives.""" payload = { "id": ULP_USER_ID, "modelKey": "ulpUser", "firstName": "John", "lastName": "Doe", "fullName": "John Doe", "email": "john.doe@example.com", "status": "ACTIVE", } user = PublicUlpUser.from_unifi_dict(**payload) assert user.email == "john.doe@example.com" assert user.unifi_dict()["email"] == "john.doe@example.com" assert PublicUlpUser.from_unifi_dict(**{**payload, "email": ""}).email == "" assert ( PublicUlpUser.from_unifi_dict( **{k: v for k, v in payload.items() if k != "email"} ).email is None ) def test_public_ulp_user_status_accepts_unknown_value() -> None: payload = { "id": ULP_USER_ID, "modelKey": "ulpUser", "firstName": "John", "lastName": "Doe", "fullName": "John Doe", "status": "SUSPENDED", } user = PublicUlpUser.from_unifi_dict(**payload) assert user.status is UlpUserStatus.UNKNOWN uilibs-uiprotect-317a52c/tests/test_api_ws.py000066400000000000000000001545761524637705700214430ustar00rootroot00000000000000"""Tests for uiprotect.unifi_protect_server.""" from __future__ import annotations import asyncio import base64 import logging from copy import deepcopy from datetime import datetime, timedelta from http.cookies import SimpleCookie from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp import pytest from aiohttp import WSServerHandshakeError from yarl import URL from tests.conftest import ( TEST_CAMERA_EXISTS, TEST_SENSOR_EXISTS, MockDatetime, MockWebsocket, ) from uiprotect.data import EventType, WSPacket from uiprotect.data.base import ProtectModel from uiprotect.data.devices import EVENT_PING_INTERVAL, Camera from uiprotect.data.types import ModelType from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers from uiprotect.data.websocket import ( WSAction, WSJSONPacketFrame, WSSubscriptionMessage, ) from uiprotect.exceptions import DataDecodeError from uiprotect.utils import print_ws_stat_summary, to_js_time, utc_now from uiprotect.websocket import Websocket, WebsocketState if TYPE_CHECKING: from collections.abc import Callable from uiprotect import ProtectApiClient PACKET_RAW = "AQEBAAAAAHR4nCXMQQrCMBBA0auUWRvIpJOm8Qbi2gNMZiZQ0NRFqIh4dwluP7z/AZa+7Q3OE7AqnCZo9ro9lbtddFRPhCayuOorOyqYXfEJXcprEqQZ55UHe+xq96u9h7HDWh9x+y+UqeZAsUrQrCFajFQWgu8PBLYjMAIBAQAAAAC2eJxVjr0KwzAMhF8leO6QOLZDOrdT126lg2PLxRA7wVYKIeTdK1PoD2jQfTodtzFcZ2DHiiUfH+xQsYw6IYFGtbyplaKRnDhE+0u7N81mSuW9LnugzxMgGLxSaCZ8u//z8xMifg4BUFuNmvS2kzY6QCqKdaaXsrNOcSN1ywfbgXGtg1JwpjSPfopkjMs4EloypK/ypSirrRau50I6w21vuQQpxaBEiQiThfECa/FBqcT2F6ZyTac=" @pytest.fixture(name="packet") def packet_fixture(): return WSPacket(base64.b64decode(PACKET_RAW)) class SubscriptionTest: callback_count: int = 0 unsub: Callable[[], None] | None = None def callback(self, msg: WSSubscriptionMessage): self.callback_count += 1 assert isinstance(msg.new_obj, ProtectModel) if msg.action == WSAction.ADD: assert msg.old_obj is None else: assert isinstance(msg.old_obj, ProtectModel) assert msg.old_obj.update_from_dict(msg.changed_data) == msg.new_obj if self.callback_count == 2: raise Exception if self.callback_count >= 3 and self.unsub is not None: self.unsub() @pytest.mark.asyncio() @pytest.mark.timeout(0) async def test_ws_all( protect_client_ws: ProtectApiClient, ws_messages: dict[str, dict[str, Any]], ): """Unfiltered WS subscription dispatches every packet through the callback.""" protect_client = protect_client_ws sub = SubscriptionTest() sub.unsub = protect_client.subscribe_websocket(sub.callback) websocket = protect_client._get_websocket() while not websocket.is_connected: await asyncio.sleep(0.05) ws_connect: MockWebsocket | None = websocket._ws_connection # type: ignore[assignment] assert ws_connect is not None while websocket.is_connected: await asyncio.sleep(0.05) assert sub.callback_count == 3 @pytest.mark.asyncio() @pytest.mark.timeout(0) async def test_ws_filtered( protect_client_ws: ProtectApiClient, ): """Model-filtered WS subscription captures stats for every packet.""" protect_client = protect_client_ws protect_client.bootstrap.capture_ws_stats = True protect_client._ignore_stats = True protect_client._subscribed_models = { ModelType.EVENT, ModelType.CAMERA, ModelType.LIGHT, ModelType.VIEWPORT, ModelType.SENSOR, ModelType.LIVEVIEW, } sub = SubscriptionTest() sub.unsub = protect_client.subscribe_websocket(sub.callback) websocket = protect_client._get_websocket() while not websocket.is_connected: await asyncio.sleep(0.05) ws_connect: MockWebsocket | None = websocket._ws_connection # type: ignore[assignment] assert ws_connect is not None while websocket.is_connected: await asyncio.sleep(0.05) print_ws_stat_summary(protect_client.bootstrap.ws_stats) @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_ring( protect_client_no_debug: ProtectApiClient, now, camera, packet: WSPacket, ): protect_client = protect_client_no_debug def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera_before = get_camera().model_copy() expected_updated_id = "0441ecc6-f0fa-4b19-b071-7987c143138a" expected_event_id = "bf9a241afe74821ceffffd05" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data["newUpdateId"] = expected_updated_id data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": expected_event_id, "type": "ring", "start": to_js_time(now - timedelta(seconds=1)), "end": to_js_time(now), "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": camera["id"], "partition": None, "user": None, "metadata": {}, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", "modelKey": "event", } msg = MagicMock() msg.data = packet.pack_frames() protect_client._process_ws_message(msg) camera = get_camera() event = camera.last_ring_event camera_before.last_ring_event_id = None camera.last_ring_event_id = None assert camera.last_ring == event.start camera.last_ring = None camera_before.last_ring = None assert camera.model_dump() == camera_before.model_dump() assert event.id == expected_event_id assert event.type == EventType.RING assert event.thumbnail_id == f"e-{expected_event_id}" assert event.heatmap_id == f"e-{expected_event_id}" for channel in camera.channels: assert channel._api is not None @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_motion( protect_client_no_debug: ProtectApiClient, now, camera, packet: WSPacket, ): protect_client = protect_client_no_debug def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera_before = get_camera().model_copy() expected_updated_id = "0441ecc6-f0fa-4b19-b071-7987c143138a" expected_event_id = "bf9a241afe74821ceffffd05" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data["newUpdateId"] = expected_updated_id data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": expected_event_id, "type": "motion", "start": to_js_time(now - timedelta(seconds=30)), "end": to_js_time(now), "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": camera["id"], "partition": None, "user": None, "metadata": {}, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", "modelKey": "event", } msg = MagicMock() msg.data = packet.pack_frames() protect_client._process_ws_message(msg) camera = get_camera() event = camera.last_motion_event camera_before.last_motion_event_id = None camera.last_motion_event_id = None assert camera.last_motion == event.start camera_before.last_motion = None camera.last_motion = None assert camera.model_dump() == camera_before.model_dump() assert event.id == expected_event_id assert event.type == EventType.MOTION assert event.thumbnail_id == f"e-{expected_event_id}" assert event.heatmap_id == f"e-{expected_event_id}" assert event.start == (now - timedelta(seconds=30)) for channel in camera.channels: assert channel._api is not None @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_unknown_type_dropped( protect_client_no_debug: ProtectApiClient, now, camera, packet: WSPacket, caplog: pytest.LogCaptureFixture, ): """An ``add`` event frame with a type newer firmware introduced is dropped, not raised.""" protect_client = protect_client_no_debug expected_event_id = "bf9a241afe74821ceffffd05" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data["newUpdateId"] = "0441ecc6-f0fa-4b19-b071-7987c143138a" data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": expected_event_id, "type": "someFutureFirmwareEvent", "start": to_js_time(now - timedelta(seconds=1)), "end": to_js_time(now), "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": camera["id"], "partition": None, "user": None, "metadata": {}, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", "modelKey": "event", } msg = MagicMock() msg.data = packet.pack_frames() with caplog.at_level(logging.DEBUG, logger="uiprotect.data.bootstrap"): # Must not raise a pydantic ValidationError on the unknown enum value. protect_client._process_ws_message(msg) assert expected_event_id not in protect_client.bootstrap.events # Skipped up front by the proactive guard, not the generic validation backstop. assert any("Unknown event type" in record.message for record in caplog.records) @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_nfc_card_scanned( protect_client_no_debug: ProtectApiClient, now, camera, packet: WSPacket, ): protect_client = protect_client_no_debug def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera_before = get_camera().model_copy() expected_updated_id = "0441ecc6-f0fa-4b03-b071-7987c143138a" expected_event_id = "6730b5af01029603e4003bdb" expected_nfc_id = "66B2A649" expected_user_id = "672b570000f79603e400049d" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data["newUpdateId"] = expected_updated_id data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": expected_event_id, "modelKey": "event", "type": "nfcCardScanned", "start": to_js_time(now - timedelta(seconds=30)), "end": to_js_time(now), "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": camera["id"], "metadata": { "nfc": {"nfcId": expected_nfc_id, "userId": expected_user_id}, "ramDescription": "", "ramClassifications": [], }, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", } camera = get_camera() assert camera.last_nfc_card_scanned_event is None assert camera.feature_flags.support_nfc is True msg = MagicMock() msg.data = packet.pack_frames() protect_client._process_ws_message(msg) event = camera.last_nfc_card_scanned_event camera_before.last_nfc_card_scanned_event_id = None camera.last_nfc_card_scanned_event_id = None assert camera.last_nfc_card_scanned == event.start camera_before.last_nfc_card_scanned = None camera.last_nfc_card_scanned = None assert camera.model_dump() == camera_before.model_dump() assert event.id == expected_event_id assert event.type == EventType.NFC_CARD_SCANNED assert event.metadata.nfc.nfc_id == expected_nfc_id assert event.metadata.nfc.user_id == expected_user_id assert event.thumbnail_id == f"e-{expected_event_id}" assert event.heatmap_id == f"e-{expected_event_id}" assert event.start == (now - timedelta(seconds=30)) assert event.end == now for channel in camera.channels: assert channel._api is not None @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_fingerprint_identified( protect_client_no_debug: ProtectApiClient, now, camera, packet: WSPacket, ): protect_client = protect_client_no_debug def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] camera_before = get_camera().model_copy() expected_updated_id = "0441ecc6-f0fa-4b03-b071-7987c143138a" expected_event_id = "6730b5af01029603e4003bdb" expected_ulp_id = "0ef32f12-f291-123d-ab12-30e373e12345" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data["newUpdateId"] = expected_updated_id data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": expected_event_id, "modelKey": "event", "type": "fingerprintIdentified", "start": to_js_time(now - timedelta(seconds=30)), "end": to_js_time(now), "score": 0, "smartDetectTypes": [], "smartDetectEvents": [], "camera": camera["id"], "metadata": { "fingerprint": {"ulpId": expected_ulp_id}, "ramDescription": "", "ramClassifications": [], }, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", } camera = get_camera() assert camera.last_fingerprint_identified_event is None assert camera.feature_flags.has_fingerprint_sensor is True msg = MagicMock() msg.data = packet.pack_frames() protect_client._process_ws_message(msg) event = camera.last_fingerprint_identified_event camera_before.last_fingerprint_identified_event_id = None camera.last_fingerprint_identified_event_id = None assert camera.last_fingerprint_identified == event.start camera_before.last_fingerprint_identified = None camera.last_fingerprint_identified = None assert camera.model_dump() == camera_before.model_dump() assert event.id == expected_event_id assert event.type == EventType.FINGERPRINT_IDENTIFIED assert event.metadata.fingerprint.ulp_id == expected_ulp_id assert event.thumbnail_id == f"e-{expected_event_id}" assert event.heatmap_id == f"e-{expected_event_id}" assert event.start == (now - timedelta(seconds=30)) assert event.end == now for channel in camera.channels: assert channel._api is not None @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_smart( protect_client_no_debug: ProtectApiClient, now, camera, packet: WSPacket, ): protect_client = protect_client_no_debug def get_camera(): return protect_client.bootstrap.cameras[camera["id"]] bootstrap_before = protect_client.bootstrap.unifi_dict() camera_before = get_camera().model_copy() expected_updated_id = "0441ecc6-f0fa-4b19-b071-7987c143138a" expected_event_id = "bf9a241afe74821ceffffd05" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data["newUpdateId"] = expected_updated_id data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": expected_event_id, "type": "smartDetectZone", "start": to_js_time(now - timedelta(seconds=30)), "end": to_js_time(now), "score": 0, "smartDetectTypes": ["person"], "smartDetectEvents": [], "camera": camera["id"], "partition": None, "user": None, "metadata": {}, "thumbnail": f"e-{expected_event_id}", "heatmap": f"e-{expected_event_id}", "modelKey": "event", } msg = MagicMock() msg.data = packet.pack_frames() protect_client._process_ws_message(msg) bootstrap_before["lastUpdateId"] = expected_updated_id bootstrap = protect_client.bootstrap.unifi_dict() camera = get_camera() smart_event = camera.last_smart_detect_event camera.last_smart_detect_event_id = None camera.last_smart_detect = None camera_before.last_smart_detect_event_id = None camera_before.last_smart_detect = None assert bootstrap == bootstrap_before assert camera.model_dump() == camera_before.model_dump() assert smart_event.id == expected_event_id assert smart_event.type == EventType.SMART_DETECT assert smart_event.thumbnail_id == f"e-{expected_event_id}" assert smart_event.heatmap_id == f"e-{expected_event_id}" assert smart_event.start == (now - timedelta(seconds=30)) assert smart_event.end == now for channel in camera.channels: assert channel._api is not None @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_update( protect_client_no_debug: ProtectApiClient, now, camera, packet: WSPacket, ): protect_client = protect_client_no_debug def get_camera() -> Camera: return protect_client.bootstrap.cameras[camera["id"]] bootstrap_before = protect_client.bootstrap.unifi_dict() camera_before = get_camera().model_copy() new_stats = camera_before.stats.unifi_dict() # rxBytes and txBytes were removed in Protect 6.1+, handle both cases if new_stats.get("rxBytes") is not None: new_stats["rxBytes"] += 100 if new_stats.get("txBytes") is not None: new_stats["txBytes"] += 100 new_stats["video"]["recordingEnd"] = to_js_time(now) new_stats_unifi = camera_before.unifi_dict(data={"stats": deepcopy(new_stats)}) del new_stats_unifi["stats"]["wifiQuality"] del new_stats_unifi["stats"]["wifiStrength"] expected_updated_id = "0441ecc6-f0fa-4b19-b071-7987c143138a" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "update", "newUpdateId": expected_updated_id, "modelKey": "camera", "id": camera["id"], } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = new_stats_unifi msg = MagicMock() msg.data = packet.pack_frames() protect_client._process_ws_message(msg) camera_index = -1 for index, camera_dict in enumerate(bootstrap_before["cameras"]): if camera_dict["id"] == camera["id"]: camera_index = index break bootstrap_before["cameras"][camera_index]["stats"] = new_stats bootstrap_before["lastUpdateId"] = expected_updated_id bootstrap = protect_client.bootstrap.unifi_dict() assert bootstrap == bootstrap_before @pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata") @patch("uiprotect.data.devices.utc_now") @patch("uiprotect.data.base.EVENT_PING_INTERVAL_SECONDS", 0) @pytest.mark.asyncio() async def test_ws_emit_alarm_callback( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, sensor, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug protect_client.emit_message = Mock() # type: ignore[method-assign] obj = protect_client.bootstrap.sensors[sensor["id"]] expected_updated_id = "0441ecc6-f0fa-4b19-b071-7987c143138a" data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {"alarmTriggeredAt": to_js_time(now)} action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "update", "newUpdateId": expected_updated_id, "modelKey": "sensor", "id": sensor["id"], } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {"alarmTriggeredAt": to_js_time(now)} msg = MagicMock() msg.data = packet.pack_frames() assert not obj.is_alarm_detected with patch("uiprotect.data.bootstrap.utc_now", mock_now): protect_client._process_ws_message(msg) assert obj.is_alarm_detected mock_now.return_value = utc_now() + EVENT_PING_INTERVAL assert not obj.is_alarm_detected # The event message should be emitted assert protect_client.emit_message.call_count == 1 await asyncio.sleep(0) await asyncio.sleep(0) # An empty messages should be emitted assert protect_client.emit_message.call_count == 2 message: WSSubscriptionMessage = protect_client.emit_message.call_args[0][0] assert message.changed_data == {} assert not obj.is_alarm_detected @pytest.mark.asyncio() async def test_check_ws_connected( protect_client_ws: ProtectApiClient, caplog: pytest.LogCaptureFixture, ): caplog.set_level(logging.DEBUG) unsub = protect_client_ws.subscribe_websocket(lambda _: None) while not protect_client_ws._private_websocket.is_connected: await asyncio.sleep(0.01) assert protect_client_ws._private_websocket.is_connected unsub() @pytest.mark.asyncio() async def test_check_ws_connected_state_callback( protect_client_ws: ProtectApiClient, caplog: pytest.LogCaptureFixture, ): websocket = protect_client_ws._private_websocket assert not websocket.is_connected caplog.set_level(logging.DEBUG) states: list[bool] = [] def _on_state(state: bool): states.append(state) unsub_state = protect_client_ws.subscribe_websocket_state(_on_state) unsub = protect_client_ws.subscribe_websocket(lambda _: None) while websocket._current_state is not WebsocketState.CONNECTED: await asyncio.sleep(0.01) assert states == [WebsocketState.CONNECTED] await protect_client_ws.async_disconnect_ws() while websocket._current_state is not WebsocketState.DISCONNECTED: await asyncio.sleep(0.01) assert states == [WebsocketState.CONNECTED, WebsocketState.DISCONNECTED] unsub() unsub_state() class _QuietWebsocket: """WS that connects but never delivers a data frame (blocks on receive).""" is_closed = False @property def closed(self) -> bool: return self.is_closed async def close(self) -> None: self.is_closed = True async def receive(self, timeout: float | None) -> Any: await asyncio.Event().wait() # blocks until the task is cancelled def _build_websocket(session: Any, **kwargs: Any): states: list[WebsocketState] = [] ws = Websocket( lambda: URL("wss://test.example/ws"), AsyncMock(return_value={}), lambda: None, AsyncMock(return_value=session), lambda _: None, states.append, **kwargs, ) return ws, states @pytest.mark.asyncio() async def test_ws_connected_emitted_before_first_frame(): """CONNECTED must fire on connect, not gated behind the first data frame.""" session = AsyncMock() session.ws_connect = AsyncMock(return_value=_QuietWebsocket()) ws, states = _build_websocket(session) ws.start() try: for _ in range(500): if states: break await asyncio.sleep(0.01) assert states == [WebsocketState.CONNECTED] assert not ws._seen_non_close_message finally: ws.stop() await ws.wait_closed() @pytest.mark.asyncio() async def test_ws_heartbeat_passed_to_ws_connect(): """The heartbeat kwarg is threaded through to aiohttp's ws_connect.""" session = AsyncMock() session.ws_connect = AsyncMock(return_value=_QuietWebsocket()) ws, _ = _build_websocket(session, heartbeat=180.0) ws.start() try: for _ in range(500): if session.ws_connect.call_count: break await asyncio.sleep(0.01) assert session.ws_connect.call_args.kwargs["heartbeat"] == 180.0 finally: ws.stop() await ws.wait_closed() def test_websocket_state_auth_failed_is_distinct_member(): """AUTH_FAILED is a third, non-aliasing WebsocketState member.""" assert len(WebsocketState) == 3 assert WebsocketState.AUTH_FAILED not in ( WebsocketState.CONNECTED, WebsocketState.DISCONNECTED, ) assert WebsocketState.AUTH_FAILED.value == "auth_failed" def _handshake_401() -> WSServerHandshakeError: return WSServerHandshakeError( request_info=Mock(), history=(), status=401, message="Unauthorized", ) @pytest.mark.parametrize( "exc", [ WSServerHandshakeError( request_info=Mock(), history=(), status=500, message="Boom" ), aiohttp.ClientError(), TimeoutError(), ValueError("unexpected"), ], ) @pytest.mark.asyncio() async def test_run_inner_loop_non_auth_error_resets_counter(exc: Exception): """Any non-401 failure returns False and zeroes the auth-failure counter.""" session = AsyncMock() session.ws_connect = AsyncMock(side_effect=exc) ws, _ = _build_websocket(session) ws._consecutive_auth_failures = 5 result = await ws._run_inner_loop(URL("wss://test.example/ws")) assert result is False assert ws._consecutive_auth_failures == 0 @pytest.mark.asyncio() async def test_ws_auth_failed_emitted_after_repeated_401(): """Repeated 401 handshakes surface a single AUTH_FAILED edge, not CONNECTED.""" session = AsyncMock() session.ws_connect = AsyncMock(side_effect=_handshake_401()) ws, states = _build_websocket(session, backoff=0, auth_failed_backoff=0) ws.start() try: for _ in range(500): if WebsocketState.AUTH_FAILED in states: break await asyncio.sleep(0.01) assert WebsocketState.AUTH_FAILED in states assert WebsocketState.CONNECTED not in states # a single AUTH_FAILED edge despite repeated 401s (dedup) assert states.count(WebsocketState.AUTH_FAILED) == 1 # AUTH_FAILED needs at least the threshold (2) consecutive handshakes assert session.ws_connect.call_count >= 2 finally: ws.stop() await ws.wait_closed() class _ClosingWebsocket: """WS that connects, then delivers a CLOSE frame so the loop reconnects.""" is_closed = False @property def closed(self) -> bool: return self.is_closed async def close(self) -> None: self.is_closed = True async def receive(self, timeout: float | None) -> Any: self.is_closed = True return aiohttp.WSMessage(aiohttp.WSMsgType.CLOSE, None, None) class _ErroringWebsocket: """WS that connects, then delivers an ERROR frame so the loop bails out.""" is_closed = False @property def closed(self) -> bool: return self.is_closed async def close(self) -> None: self.is_closed = True async def receive(self, timeout: float | None) -> Any: self.is_closed = True return aiohttp.WSMessage(aiohttp.WSMsgType.ERROR, "boom", None) @pytest.mark.asyncio() async def test_ws_error_frame_logs_payload_without_traceback( caplog: pytest.LogCaptureFixture, ) -> None: """An ERROR frame ends the receive loop and logs its payload.""" session = AsyncMock() session.ws_connect = AsyncMock(return_value=_ErroringWebsocket()) ws, states = _build_websocket(session) with caplog.at_level(logging.ERROR, logger="uiprotect.websocket"): await ws._websocket_inner_loop(URL("wss://test.example/ws")) assert states == [WebsocketState.CONNECTED] record = next(r for r in caplog.records if "Error from Websocket" in r.getMessage()) assert "boom" in record.getMessage() # No exception is in flight here, so nothing may be attached as one: # .exception() would render a bogus "NoneType: None" traceback. assert record.exc_info is None @pytest.mark.asyncio() async def test_ws_auth_failed_after_connected_emits_disconnected_first(): """CONNECTED -> AUTH_FAILED must surface a DISCONNECTED edge in between.""" session = AsyncMock() calls = 0 def connect(*args: Any, **kwargs: Any) -> Any: nonlocal calls calls += 1 if calls == 1: return _ClosingWebsocket() raise _handshake_401() session.ws_connect = AsyncMock(side_effect=connect) ws, states = _build_websocket(session, backoff=0, auth_failed_backoff=0) ws.start() try: for _ in range(500): if WebsocketState.AUTH_FAILED in states: break await asyncio.sleep(0.01) assert WebsocketState.AUTH_FAILED in states # a consumer tracking only connect/disconnect edges sees the loss assert WebsocketState.DISCONNECTED in states # ordering: CONNECTED, then DISCONNECTED, then AUTH_FAILED assert states.index(WebsocketState.CONNECTED) < states.index( WebsocketState.DISCONNECTED ) assert states.index(WebsocketState.DISCONNECTED) < states.index( WebsocketState.AUTH_FAILED ) # AUTH_FAILED stays deduped across the backoff loop (no flapping) assert states.count(WebsocketState.AUTH_FAILED) == 1 assert states.count(WebsocketState.DISCONNECTED) == 1 finally: ws.stop() await ws.wait_closed() @pytest.mark.asyncio() async def test_ws_single_401_then_connect_does_not_emit_auth_failed(): """A transient 401 that reconnects successfully never trips AUTH_FAILED.""" session = AsyncMock() session.ws_connect = AsyncMock(side_effect=[_handshake_401(), _QuietWebsocket()]) ws, states = _build_websocket(session, backoff=0, auth_failed_backoff=0) ws.start() try: for _ in range(500): if WebsocketState.CONNECTED in states: break await asyncio.sleep(0.01) assert WebsocketState.CONNECTED in states assert WebsocketState.AUTH_FAILED not in states finally: ws.stop() await ws.wait_closed() @pytest.mark.asyncio() async def test_reset_auth_failure_wakes_backoff(): """reset_auth_failure() interrupts a long post-auth-failure backoff.""" session = AsyncMock() session.ws_connect = AsyncMock(side_effect=_handshake_401()) ws, states = _build_websocket(session, backoff=0, auth_failed_backoff=1000) ws.start() try: for _ in range(500): if WebsocketState.AUTH_FAILED in states: break await asyncio.sleep(0.01) assert WebsocketState.AUTH_FAILED in states calls_before = session.ws_connect.call_count ws.reset_auth_failure() # would otherwise wait ~1000s for _ in range(500): if session.ws_connect.call_count > calls_before: break await asyncio.sleep(0.01) # the parked 1000s backoff was interrupted: a fresh dial happened assert session.ws_connect.call_count > calls_before finally: ws.stop() await ws.wait_closed() @pytest.mark.asyncio() async def test_check_ws_no_ws_initial( protect_client: ProtectApiClient, caplog: pytest.LogCaptureFixture, ): caplog.set_level(logging.DEBUG) await protect_client.async_disconnect_ws() assert not protect_client._private_websocket @pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata") @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_ignores_nvr_mac_and_guid( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, camera, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "update", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "camera", "id": camera["id"], } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {"nvrMac": "any", "guid": "any", "isMotionDetected": True} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) protect_client._process_ws_message(msg) assert len(messages) == 1 action_frame: WSJSONPacketFrame = packet.action_frame action_frame.data = { "action": "update", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138b", "modelKey": "camera", "id": camera["id"], } data_frame: WSJSONPacketFrame = packet.data_frame data_frame.data = {"nvrMac": "any", "guid": "any"} msg = MagicMock() msg.data = packet.pack_frames() protect_client._process_ws_message(msg) assert len(messages) == 1 unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_keyring_update( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug keyring_id = "some_id" old_ulp_user = "b45f9411-133d-400d-b92f-a434877123" new_ulp_user = "b45f9411-133d-400d-b92f-a434877321" keyring = Keyring( id=keyring_id, ulp_user=old_ulp_user, device_type="test", device_id="test", registry_type="test", registry_id="test", created_at=utc_now(), updated_at=utc_now(), ) protect_client.bootstrap.keyrings = Keyrings() protect_client.bootstrap.keyrings.add(keyring) messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "update", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "keyring", "id": "some_id", } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {"ulpUser": new_ulp_user} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) assert protect_client.bootstrap.keyrings.by_id(keyring_id).ulp_user == old_ulp_user protect_client._process_ws_message(msg) assert protect_client.bootstrap.keyrings.by_id(keyring_id).ulp_user == new_ulp_user unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_keyring_remove( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug keyring_id = "some_id" ulp_user = "b45f9411-133d-400d-b92f-a434877123" keyring = Keyring( id=keyring_id, ulp_user=ulp_user, device_type="test", device_id="test", registry_type="test", registry_id="test", created_at=utc_now(), updated_at=utc_now(), ) protect_client.bootstrap.keyrings = Keyrings() protect_client.bootstrap.keyrings.add(keyring) messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "remove", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "keyring", "id": keyring_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) assert protect_client.bootstrap.keyrings.by_id(keyring_id) is not None protect_client._process_ws_message(msg) assert protect_client.bootstrap.keyrings.by_id(keyring_id) is None unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_keyring_add_nfc( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug protect_client.bootstrap.keyrings = Keyrings() keyring_id = "some_id" ulp_user = "b45f9411-133d-400d-b92f-a434877123" messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "add", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "keyring", "id": keyring_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": keyring_id, "modelKey": "keyring", "deviceType": "camera", "deviceId": "663d0aa400918803e4006454", "registryType": "nfc", "registryId": "046A5702E27548", "lastActivity": None, "metadata": {"nfc": {"isUACard": False}}, "ulpUser": ulp_user, "createdAt": to_js_time(now), "updatedAt": to_js_time(now), } msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) assert keyring_id not in protect_client.bootstrap.keyrings.as_list() protect_client._process_ws_message(msg) assert protect_client.bootstrap.keyrings.by_id(keyring_id) is not None assert protect_client.bootstrap.keyrings.by_id(keyring_id).ulp_user == ulp_user unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_keyring_add_fingerprint( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug protect_client.bootstrap.keyrings = Keyrings() keyring_id = "some_id" ulp_user = "b45f9411-133d-400d-b92f-a434877123" messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "add", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "keyring", "id": keyring_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "id": keyring_id, "modelKey": "keyring", "deviceType": "camera", "deviceId": "663d0aa400918803e4004578", "registryType": "fingerprint", "registryId": "2", "lastActivity": None, "ulpUser": ulp_user, } msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) assert protect_client.bootstrap.keyrings.by_id(keyring_id) is None protect_client._process_ws_message(msg) assert protect_client.bootstrap.keyrings.by_id(keyring_id) is not None assert protect_client.bootstrap.keyrings.by_id(keyring_id).ulp_user == ulp_user unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_ulp_user_add( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug protect_client.bootstrap.ulp_users = UlpUsers() some_id = "some_id" some_ulp_id = "42313461-eaa0-45f6-b12d-a0783ed3d4s2" messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "add", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "ulpUser", "id": some_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = { "ulpId": some_ulp_id, "firstName": "viewonly", "lastName": "", "fullName": "viewonly", "avatar": "", "status": "ACTIVE", "id": some_id, "modelKey": "ulpUser", } msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) assert protect_client.bootstrap.ulp_users.by_id(some_id) is None protect_client._process_ws_message(msg) assert protect_client.bootstrap.ulp_users.by_id(some_id) is not None unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_ulp_user_update( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug some_ulp_id = "42313461-eaa0-45f6-b12d-a0783ed3d4s2" ulp_usr = UlpUser( id=some_ulp_id, ulp_id=some_ulp_id, first_name="viewonly", last_name="", full_name="viewonly", avatar="", status="ACTIVE", model_key="ulpUser", ) protect_client.bootstrap.ulp_users = UlpUsers() protect_client.bootstrap.ulp_users.add(ulp_usr) messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "update", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "ulpUser", "id": some_ulp_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {"status": "DEACTIVATED"} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) assert protect_client.bootstrap.ulp_users.by_id(some_ulp_id).status == "ACTIVE" protect_client._process_ws_message(msg) assert protect_client.bootstrap.ulp_users.by_id(some_ulp_id).status == "DEACTIVATED" unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_ulp_user_remove( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug some_ulp_id = "42313461-eaa0-45f6-b12d-a0783ed3d4s2" ulp_usr = UlpUser( id=some_ulp_id, ulp_id=some_ulp_id, first_name="viewonly", last_name="", full_name="viewonly", avatar="", status="ACTIVE", model_key="ulpUser", ) protect_client.bootstrap.ulp_users = UlpUsers() protect_client.bootstrap.ulp_users.add(ulp_usr) messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "remove", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "ulpUser", "id": some_ulp_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) assert protect_client.bootstrap.ulp_users.by_id(some_ulp_id) is not None protect_client._process_ws_message(msg) assert protect_client.bootstrap.ulp_users.by_id(some_ulp_id) is None unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_ulp_user_remove_user_not_exist( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug some_ulp_id = "42313461-eaa0-45f6-b12d-a0783ed3d4s2" protect_client.bootstrap.ulp_users = UlpUsers() messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "remove", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "ulpUser", "id": some_ulp_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) protect_client._process_ws_message(msg) unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_ulp_user_update_user_not_exist( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug some_ulp_id = "42313461-eaa0-45f6-b12d-a0783ed3d4s2" protect_client.bootstrap.ulp_users = UlpUsers() messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "update", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "ulpUser", "id": some_ulp_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {"status": "DEACTIVATED"} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) protect_client._process_ws_message(msg) unsub() @patch("uiprotect.data.devices.utc_now") @pytest.mark.asyncio() async def test_ws_ulp_user_unknown_action( mock_now, protect_client_no_debug: ProtectApiClient, now: datetime, packet: WSPacket, ): mock_now.return_value = now protect_client = protect_client_no_debug some_ulp_id = "42313461-eaa0-45f6-b12d-a0783ed3d4s2" protect_client.bootstrap.ulp_users = UlpUsers() messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_websocket(capture_ws) action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": "not_supported", "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": "ulpUser", "id": some_ulp_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = {"status": "DEACTIVATED"} msg = MagicMock() msg.data = packet.pack_frames() assert len(messages) == 0 packet = WSPacket(msg.data) protect_client._process_ws_message(msg) unsub() def _setup_auth_state( client: ProtectApiClient, cookie: str = "test-cookie", csrf: str = "test-csrf-token", ) -> None: """Helper to set up authentication state for testing.""" client._is_authenticated = True client.set_header("cookie", cookie) client.set_header("x-csrf-token", csrf) def _assert_auth_cleared(client: ProtectApiClient) -> None: """Helper to assert authentication state is cleared.""" assert client._is_authenticated is False assert client.headers.get("cookie") is None assert client.headers.get("x-csrf-token") is None @pytest.mark.asyncio() async def test_auth_websocket_without_force( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _auth_websocket without force parameter doesn't clear auth state.""" protect_client = protect_client_no_debug _setup_auth_state(protect_client) headers = await protect_client._auth_websocket(force=False) assert protect_client._is_authenticated is True assert headers is not None assert protect_client.headers.get("cookie") == "test-cookie" assert protect_client.headers.get("x-csrf-token") == "test-csrf-token" @pytest.mark.asyncio() async def test_auth_websocket_with_force_clears_auth_state( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _auth_websocket with force=True clears authentication state.""" protect_client = protect_client_no_debug _setup_auth_state(protect_client) headers = await protect_client._auth_websocket(force=True) assert headers is not None _assert_auth_cleared(protect_client) @pytest.mark.asyncio() async def test_auth_websocket_with_force_clears_session_cookies( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _auth_websocket with force=True clears session cookie jar.""" protect_client = protect_client_no_debug session = await protect_client.get_session() cookies = SimpleCookie() cookies["test_cookie"] = "test_value" session.cookie_jar.update_cookies(cookies) # Verify cookie was actually added before testing removal assert len(list(session.cookie_jar)) > 0 await protect_client._auth_websocket(force=True) assert len(list(session.cookie_jar)) == 0 @pytest.mark.asyncio() async def test_auth_websocket_returns_headers( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _auth_websocket returns the headers dictionary.""" protect_client = protect_client_no_debug _setup_auth_state(protect_client, cookie="auth-cookie", csrf="csrf-token") headers = await protect_client._auth_websocket(force=False) assert headers is not None assert isinstance(headers, dict) assert headers == protect_client.headers @pytest.mark.asyncio() async def test_auth_websocket_calls_ensure_authenticated( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _auth_websocket calls ensure_authenticated.""" protect_client = protect_client_no_debug protect_client.ensure_authenticated.reset_mock() await protect_client._auth_websocket(force=False) protect_client.ensure_authenticated.assert_called_once() @pytest.mark.asyncio() async def test_auth_websocket_with_force_calls_ensure_authenticated( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _auth_websocket with force=True calls ensure_authenticated after clearing state.""" protect_client = protect_client_no_debug protect_client._is_authenticated = True protect_client.ensure_authenticated.reset_mock() await protect_client._auth_websocket(force=True) protect_client.ensure_authenticated.assert_called_once() @pytest.mark.asyncio() async def test_auth_websocket_with_no_session( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _auth_websocket with force=True when session is None.""" protect_client = protect_client_no_debug if protect_client._session is not None: await protect_client._session.close() protect_client._session = None _setup_auth_state(protect_client) headers = await protect_client._auth_websocket(force=True) assert headers is not None _assert_auth_cleared(protect_client) def _send_ws_packet( protect_client: ProtectApiClient, packet: WSPacket, action: str, model_key: str, device_id: str, data: dict[str, Any], ) -> list[WSSubscriptionMessage]: """Build a WS packet from action/data dicts and send it through the client.""" action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] action_frame.data = { "action": action, "newUpdateId": "0441ecc6-f0fa-4b19-b071-7987c143138a", "modelKey": model_key, "id": device_id, } data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] data_frame.data = data msg = MagicMock() msg.data = packet.pack_frames() messages: list[WSSubscriptionMessage] = [] unsub = protect_client.subscribe_websocket(messages.append) protect_client._process_ws_message(msg) unsub() return messages @pytest.mark.asyncio() @pytest.mark.parametrize( "model_key", ["automationEvent", "automation", "linkstation", "siren", "smartDetectObjects"], ) async def test_ws_unknown_model_type_ignored( protect_client_no_debug: ProtectApiClient, packet: WSPacket, model_key: str, ): """Unknown model types (not in ModelType enum) are silently ignored.""" messages = _send_ws_packet( protect_client_no_debug, packet, "update", model_key, "some-id", {"k": "v"} ) assert len(messages) == 0 @pytest.mark.asyncio() async def test_ws_known_model_type_without_class_add( protect_client_no_debug: ProtectApiClient, packet: WSPacket, ): """ModelType in enum but not in MODEL_TO_CLASS raises DataDecodeError on add, caught gracefully.""" mock_refresh = AsyncMock() with patch.object( type(protect_client_no_debug.bootstrap), "refresh_device", mock_refresh ): messages = _send_ws_packet( protect_client_no_debug, packet, "add", "schedule", "some-schedule-id", {"id": "some-schedule-id", "modelKey": "schedule", "name": "test"}, ) assert len(messages) == 0 mock_refresh.assert_called_once_with(ModelType.SCHEDULE, "some-schedule-id") @pytest.mark.asyncio() async def test_ws_known_model_type_without_class_update_ignored( protect_client_no_debug: ProtectApiClient, packet: WSPacket, ): """Known ModelType not in bootstrap_models_types_and_event_set falls through without error on update.""" messages = _send_ws_packet( protect_client_no_debug, packet, "update", "schedule", "some-schedule-id", {"name": "updated-name"}, ) assert len(messages) == 0 @pytest.mark.asyncio() async def test_refresh_device_catches_data_decode_error( protect_client_no_debug: ProtectApiClient, ): """refresh_device catches DataDecodeError from get_device for model types without a class.""" protect_client = protect_client_no_debug protect_client.get_device = AsyncMock( # type: ignore[method-assign] side_effect=DataDecodeError("Unknown modelKey"), ) # Should not raise — DataDecodeError is caught and logged as warning await protect_client.bootstrap.refresh_device(ModelType.SCHEDULE, "some-id") uilibs-uiprotect-317a52c/tests/test_api_ws_public.py000066400000000000000000001333471524637705700227720ustar00rootroot00000000000000"""Tests for Public API Websockets (Events and Devices).""" from __future__ import annotations import logging from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import aiohttp import orjson import pytest from uiprotect.api import ( PRIVATE_WS_HEARTBEAT, PUBLIC_WS_HEARTBEAT, BaseApiClient, ) from uiprotect.data import NvrArmModeStatus, PublicBootstrap, PublicNVR from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.exceptions import NotAuthorized from uiprotect.websocket import WebsocketState if TYPE_CHECKING: from uiprotect import ProtectApiClient def create_mock_ws_message(data: str, msg_type: int = 1) -> MagicMock: """Create a mock websocket message with the given data.""" msg = MagicMock() msg.type = msg_type # WSMsgType.TEXT by default msg.data = data return msg @pytest.mark.asyncio() async def test_subscribe_events_websocket( protect_client_no_debug: ProtectApiClient, ) -> None: """Test subscribing to events websocket.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) # Subscribe unsub = protect_client.subscribe_events_websocket(capture_ws) assert len(protect_client._events_ws_subscriptions) == 1 assert protect_client._events_ws_subscriptions[0] == capture_ws # Unsubscribe unsub() assert len(protect_client._events_ws_subscriptions) == 0 @pytest.mark.asyncio() async def test_subscribe_devices_websocket( protect_client_no_debug: ProtectApiClient, ) -> None: """Test subscribing to devices websocket.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) # Subscribe unsub = protect_client.subscribe_devices_websocket(capture_ws) assert len(protect_client._devices_ws_subscriptions) == 1 assert protect_client._devices_ws_subscriptions[0] == capture_ws # Unsubscribe unsub() assert len(protect_client._devices_ws_subscriptions) == 0 @pytest.mark.asyncio() async def test_subscribe_events_websocket_state( protect_client_no_debug: ProtectApiClient, ) -> None: """Test subscribing to events websocket state changes.""" protect_client = protect_client_no_debug states: list[WebsocketState] = [] def capture_state(state: WebsocketState) -> None: states.append(state) # Subscribe unsub = protect_client.subscribe_events_websocket_state(capture_state) assert len(protect_client._events_ws_state_subscriptions) == 1 assert protect_client._events_ws_state_subscriptions[0] == capture_state # Unsubscribe unsub() assert len(protect_client._events_ws_state_subscriptions) == 0 @pytest.mark.asyncio() async def test_subscribe_devices_websocket_state( protect_client_no_debug: ProtectApiClient, ) -> None: """Test subscribing to devices websocket state changes.""" protect_client = protect_client_no_debug states: list[WebsocketState] = [] def capture_state(state: WebsocketState) -> None: states.append(state) # Subscribe unsub = protect_client.subscribe_devices_websocket_state(capture_state) assert len(protect_client._devices_ws_state_subscriptions) == 1 assert protect_client._devices_ws_state_subscriptions[0] == capture_state # Unsubscribe unsub() assert len(protect_client._devices_ws_state_subscriptions) == 0 @pytest.mark.asyncio() async def test_process_events_ws_message_add( protect_client_no_debug: ProtectApiClient, ) -> None: """ Test processing events websocket message with ADD action. Without a materialised public bootstrap, events are not cached — the subscription message carries ``new_obj=None`` and callers must use ``changed_data``. """ messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client_no_debug.subscribe_events_websocket(capture_ws) msg = create_mock_ws_message( '{"type":"add","item":{"id":"test-event-123","modelKey":"event","type":"motion","start":1234567890}}' ) protect_client_no_debug._process_events_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.ADD assert messages[0].changed_data["id"] == "test-event-123" assert messages[0].changed_data["modelKey"] == "event" # No cache materialised → no cached object built. assert messages[0].new_obj is None assert messages[0].new_update_id == "test-event-123" unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_update( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing events websocket message with UPDATE action.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) # Create a mock websocket message msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = '{"type":"update","item":{"id":"test-event-123","modelKey":"event","end":1234567900}}' protect_client._process_events_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.UPDATE assert messages[0].changed_data["id"] == "test-event-123" assert messages[0].changed_data["end"] == 1234567900 unsub() @pytest.mark.asyncio() async def test_process_devices_ws_message_update( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing devices websocket message with UPDATE action.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_devices_websocket(capture_ws) # Create a mock websocket message msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = '{"type":"update","item":{"id":"test-sensor-123","modelKey":"sensor","leakDetectedAt":null}}' protect_client._process_devices_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.UPDATE assert messages[0].changed_data["id"] == "test-sensor-123" assert messages[0].changed_data["modelKey"] == "sensor" unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_remove( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing events websocket message with REMOVE action.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) # Create a mock websocket message msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = '{"type":"remove","item":{"id":"test-event-123","modelKey":"event"}}' protect_client._process_events_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.REMOVE assert messages[0].changed_data["id"] == "test-event-123" unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_invalid_json( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing events websocket message with invalid JSON.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) # Create a mock websocket message with invalid JSON msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = "invalid json" # Should not raise exception, just log error protect_client._process_events_ws_message(msg) assert len(messages) == 0 unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_missing_type( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing events websocket message with missing type.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) # Create a mock websocket message without type msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = '{"item":{"id":"test-event-123","modelKey":"event"}}' protect_client._process_events_ws_message(msg) assert len(messages) == 0 unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_unknown_model( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing events websocket message with unknown model type.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) # Create a mock websocket message with unknown model msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = '{"type":"add","item":{"id":"test-123","modelKey":"unknownModel"}}' protect_client._process_events_ws_message(msg) assert len(messages) == 0 unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_object_creation_failure( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing events websocket message when object creation fails.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) # Create a mock websocket message with invalid data that will cause object creation to fail msg = create_mock_ws_message( '{"type":"add","item":{"id":"test-event-123","modelKey":"event","invalid_field":"bad_data"}}' ) # Patch create_from_unifi_dict to raise an exception with patch( "uiprotect.api.create_from_unifi_dict", side_effect=Exception("Object creation failed"), ): protect_client._process_events_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.ADD assert messages[0].changed_data["id"] == "test-event-123" # Object creation should have failed gracefully assert messages[0].new_obj is None assert messages[0].old_obj is None # But other data should still be set assert messages[0].new_update_id == "test-event-123" unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_without_cache_emits_none_obj_events( protect_client: ProtectApiClient, ) -> None: """ Without a materialised cache, event add/update messages emit ``new_obj=None``. The raw payload is still forwarded via ``changed_data`` so subscribers can react without opting into the :class:`PublicBootstrap` event cache. """ messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) msg = create_mock_ws_message( '{"type":"add","item":{"id":"test-event-123","modelKey":"event","invalid_field":"bad_data"}}' ) protect_client._process_events_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.ADD assert messages[0].changed_data["id"] == "test-event-123" assert messages[0].new_obj is None assert messages[0].old_obj is None unsub() @pytest.mark.asyncio() async def test_process_events_ws_message_non_text( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing events websocket message with non-text type.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_events_websocket(capture_ws) # Create a mock websocket message with binary type msg = MagicMock() msg.type = 2 # WSMsgType.BINARY msg.data = b"binary data" protect_client._process_events_ws_message(msg) assert len(messages) == 0 unsub() @pytest.mark.asyncio() @pytest.mark.parametrize( ("new_obj", "old_obj", "use_debug"), [ (None, None, False), # Basic test without objects (MagicMock(model="event", id="test-1"), None, False), # With new_obj (None, MagicMock(model="event", id="test-2"), False), # With old_obj ( MagicMock(model="event", id="test-3"), None, True, ), # Debug logging with new_obj ( None, MagicMock(model="event", id="test-4"), True, ), # Debug logging with old_obj (None, None, True), # Debug logging without objects ], ) async def test_emit_events_message( protect_client_no_debug: ProtectApiClient, protect_client: ProtectApiClient, new_obj: MagicMock | None, old_obj: MagicMock | None, use_debug: bool, ) -> None: """Test emitting events messages with various configurations.""" client = protect_client if use_debug else protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = client.subscribe_events_websocket(capture_ws) # Create a test message test_msg = WSSubscriptionMessage( action=WSAction.ADD if new_obj else WSAction.REMOVE if old_obj else WSAction.UPDATE, new_update_id="test-123", changed_data={"id": "test-event", "type": "motion"}, new_obj=new_obj, old_obj=old_obj, ) if use_debug: with patch("uiprotect.api._LOGGER") as mock_logger: mock_logger.isEnabledFor.return_value = True client.emit_events_message(test_msg) # Verify debug log was called assert mock_logger.debug.call_count >= 1 call_args = mock_logger.debug.call_args[0] assert "emitting events message" in call_args[0] else: client.emit_events_message(test_msg) assert len(messages) == 1 assert messages[0] == test_msg unsub() @pytest.mark.asyncio() @pytest.mark.parametrize( ("new_obj", "old_obj", "use_debug"), [ (None, None, False), # Basic test without objects (MagicMock(model="camera", id="test-1"), None, False), # With new_obj (None, MagicMock(model="camera", id="test-2"), False), # With old_obj ( MagicMock(model="camera", id="test-3"), None, True, ), # Debug logging with new_obj ( None, MagicMock(model="camera", id="test-4"), True, ), # Debug logging with old_obj (None, None, True), # Debug logging without objects ], ) async def test_emit_devices_message( protect_client_no_debug: ProtectApiClient, protect_client: ProtectApiClient, new_obj: MagicMock | None, old_obj: MagicMock | None, use_debug: bool, ) -> None: """Test emitting devices messages with various configurations.""" client = protect_client if use_debug else protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = client.subscribe_devices_websocket(capture_ws) # Create a test message test_msg = WSSubscriptionMessage( action=WSAction.UPDATE if new_obj else WSAction.REMOVE if old_obj else WSAction.ADD, new_update_id="test-456", changed_data={"id": "test-device", "name": "Test Camera"}, new_obj=new_obj, old_obj=old_obj, ) if use_debug: with patch("uiprotect.api._LOGGER") as mock_logger: mock_logger.isEnabledFor.return_value = True client.emit_devices_message(test_msg) # Verify debug log was called assert mock_logger.debug.call_count >= 1 call_args = mock_logger.debug.call_args[0] assert "emitting devices message" in call_args[0] else: client.emit_devices_message(test_msg) assert len(messages) == 1 assert messages[0] == test_msg unsub() @pytest.mark.asyncio() async def test_events_websocket_multiple_subscriptions( protect_client_no_debug: ProtectApiClient, ) -> None: """Test multiple subscriptions and unsubscriptions for events websocket.""" protect_client = protect_client_no_debug messages1: list[WSSubscriptionMessage] = [] messages2: list[WSSubscriptionMessage] = [] def capture_ws1(message: WSSubscriptionMessage) -> None: messages1.append(message) def capture_ws2(message: WSSubscriptionMessage) -> None: messages2.append(message) # Subscribe both unsub1 = protect_client.subscribe_events_websocket(capture_ws1) unsub2 = protect_client.subscribe_events_websocket(capture_ws2) assert len(protect_client._events_ws_subscriptions) == 2 # Unsubscribe first unsub1() assert len(protect_client._events_ws_subscriptions) == 1 assert protect_client._events_ws_subscriptions[0] == capture_ws2 # Unsubscribe second unsub2() assert len(protect_client._events_ws_subscriptions) == 0 @pytest.mark.asyncio() async def test_devices_websocket_multiple_subscriptions( protect_client_no_debug: ProtectApiClient, ) -> None: """Test multiple subscriptions and unsubscriptions for devices websocket.""" protect_client = protect_client_no_debug messages1: list[WSSubscriptionMessage] = [] messages2: list[WSSubscriptionMessage] = [] def capture_ws1(message: WSSubscriptionMessage) -> None: messages1.append(message) def capture_ws2(message: WSSubscriptionMessage) -> None: messages2.append(message) # Subscribe both unsub1 = protect_client.subscribe_devices_websocket(capture_ws1) unsub2 = protect_client.subscribe_devices_websocket(capture_ws2) assert len(protect_client._devices_ws_subscriptions) == 2 # Unsubscribe first unsub1() assert len(protect_client._devices_ws_subscriptions) == 1 assert protect_client._devices_ws_subscriptions[0] == capture_ws2 # Unsubscribe second unsub2() assert len(protect_client._devices_ws_subscriptions) == 0 @pytest.mark.asyncio() async def test_process_devices_ws_message_add( protect_client_no_debug: ProtectApiClient, ) -> None: """Test processing devices websocket message with ADD action.""" protect_client = protect_client_no_debug messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_devices_websocket(capture_ws) # Create a mock websocket message msg = MagicMock() msg.type = 1 # WSMsgType.TEXT msg.data = '{"type":"add","item":{"id":"test-camera-123","modelKey":"camera","name":"Test Camera"}}' protect_client._process_devices_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.ADD assert messages[0].changed_data["id"] == "test-camera-123" assert messages[0].changed_data["modelKey"] == "camera" unsub() @pytest.mark.asyncio() async def test_events_websocket_exception_handling( protect_client_no_debug: ProtectApiClient, ) -> None: """Test that exceptions in event callbacks don't crash the system.""" protect_client = protect_client_no_debug call_count = 0 def raising_callback(message: WSSubscriptionMessage) -> None: nonlocal call_count call_count += 1 raise ValueError("Test exception") unsub = protect_client.subscribe_events_websocket(raising_callback) # Create a test message test_msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id="test-123", changed_data={"id": "test-event", "modelKey": "event"}, ) # Should not raise, just log the exception protect_client.emit_events_message(test_msg) assert call_count == 1 unsub() @pytest.mark.asyncio() async def test_devices_websocket_exception_handling( protect_client_no_debug: ProtectApiClient, ) -> None: """Test that exceptions in device callbacks don't crash the system.""" protect_client = protect_client_no_debug call_count = 0 def raising_callback(message: WSSubscriptionMessage) -> None: nonlocal call_count call_count += 1 raise ValueError("Test exception") unsub = protect_client.subscribe_devices_websocket(raising_callback) # Create a test message test_msg = WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id="test-456", changed_data={"id": "test-sensor", "modelKey": "sensor"}, ) # Should not raise, just log the exception protect_client.emit_devices_message(test_msg) assert call_count == 1 unsub() @pytest.mark.asyncio() async def test_events_ws_url( protect_client_no_debug: ProtectApiClient, ) -> None: """Test events websocket URL property.""" protect_client = protect_client_no_debug # Get the events websocket URL events_url = protect_client.events_ws_url # Verify it's a string and contains the expected path assert isinstance(events_url, str) assert "/proxy/protect/integration/v1/subscribe/events" in events_url assert events_url.startswith("https://") @pytest.mark.asyncio() async def test_devices_ws_url( protect_client_no_debug: ProtectApiClient, ) -> None: """Test devices websocket URL property.""" protect_client = protect_client_no_debug # Get the devices websocket URL devices_url = protect_client.devices_ws_url # Verify it's a string and contains the expected path assert isinstance(devices_url, str) assert "/proxy/protect/integration/v1/subscribe/devices" in devices_url assert devices_url.startswith("https://") @pytest.mark.asyncio() async def test_get_events_websocket( protect_client_no_debug: ProtectApiClient, ) -> None: """Test getting events websocket instance.""" protect_client = protect_client_no_debug # Get the websocket instance (should create it if not exists) ws = protect_client._get_events_websocket() # Verify it's the same instance when called again ws2 = protect_client._get_events_websocket() assert ws is ws2 @pytest.mark.asyncio() async def test_get_devices_websocket( protect_client_no_debug: ProtectApiClient, ) -> None: """Test getting devices websocket instance.""" protect_client = protect_client_no_debug # Get the websocket instance (should create it if not exists) ws = protect_client._get_devices_websocket() # Verify it's the same instance when called again ws2 = protect_client._get_devices_websocket() assert ws is ws2 @pytest.mark.asyncio() async def test_websockets_use_heartbeat( protect_client_no_debug: ProtectApiClient, ) -> None: """Every WS is created with a keepalive heartbeat.""" protect_client = protect_client_no_debug assert protect_client._get_events_websocket().heartbeat == PUBLIC_WS_HEARTBEAT assert protect_client._get_devices_websocket().heartbeat == PUBLIC_WS_HEARTBEAT assert protect_client._get_websocket().heartbeat == PRIVATE_WS_HEARTBEAT @pytest.mark.asyncio() async def test_on_events_websocket_state_change( protect_client_no_debug: ProtectApiClient, ) -> None: """Test events websocket state change handler.""" protect_client = protect_client_no_debug states: list[WebsocketState] = [] def capture_state(state: WebsocketState) -> None: states.append(state) # Subscribe to state changes unsub = protect_client.subscribe_events_websocket_state(capture_state) # Simulate state changes protect_client._on_events_websocket_state_change(WebsocketState.CONNECTED) assert len(states) == 1 assert states[0] == WebsocketState.CONNECTED protect_client._on_events_websocket_state_change(WebsocketState.DISCONNECTED) assert len(states) == 2 assert states[1] == WebsocketState.DISCONNECTED unsub() @pytest.mark.asyncio() async def test_on_devices_websocket_state_change( protect_client_no_debug: ProtectApiClient, ) -> None: """Test devices websocket state change handler.""" protect_client = protect_client_no_debug states: list[WebsocketState] = [] def capture_state(state: WebsocketState) -> None: states.append(state) # Subscribe to state changes unsub = protect_client.subscribe_devices_websocket_state(capture_state) # Simulate state changes protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert len(states) == 1 assert states[0] == WebsocketState.CONNECTED protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) assert len(states) == 2 assert states[1] == WebsocketState.DISCONNECTED unsub() @pytest.mark.asyncio() async def test_on_events_websocket_state_change_multiple_subscribers( protect_client_no_debug: ProtectApiClient, ) -> None: """Test events websocket state change with multiple subscribers.""" protect_client = protect_client_no_debug states1: list[WebsocketState] = [] states2: list[WebsocketState] = [] def capture_state1(state: WebsocketState) -> None: states1.append(state) def capture_state2(state: WebsocketState) -> None: states2.append(state) # Subscribe both unsub1 = protect_client.subscribe_events_websocket_state(capture_state1) unsub2 = protect_client.subscribe_events_websocket_state(capture_state2) # Simulate state change protect_client._on_events_websocket_state_change(WebsocketState.CONNECTED) assert len(states1) == 1 assert len(states2) == 1 assert states1[0] == WebsocketState.CONNECTED assert states2[0] == WebsocketState.CONNECTED unsub1() unsub2() @pytest.mark.asyncio() async def test_on_devices_websocket_state_change_multiple_subscribers( protect_client_no_debug: ProtectApiClient, ) -> None: """Test devices websocket state change with multiple subscribers.""" protect_client = protect_client_no_debug states1: list[WebsocketState] = [] states2: list[WebsocketState] = [] def capture_state1(state: WebsocketState) -> None: states1.append(state) def capture_state2(state: WebsocketState) -> None: states2.append(state) # Subscribe both unsub1 = protect_client.subscribe_devices_websocket_state(capture_state1) unsub2 = protect_client.subscribe_devices_websocket_state(capture_state2) # Simulate state change protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert len(states1) == 1 assert len(states2) == 1 assert states1[0] == WebsocketState.CONNECTED assert states2[0] == WebsocketState.CONNECTED unsub1() unsub2() @pytest.mark.asyncio() async def test_on_events_websocket_state_change_exception_handling( protect_client_no_debug: ProtectApiClient, ) -> None: """Test that exceptions in state change callbacks don't crash the system.""" protect_client = protect_client_no_debug call_count = 0 def raising_callback(state: WebsocketState) -> None: nonlocal call_count call_count += 1 raise ValueError("Test exception") unsub = protect_client.subscribe_events_websocket_state(raising_callback) # Should not raise, just log the exception protect_client._on_events_websocket_state_change(WebsocketState.CONNECTED) assert call_count == 1 unsub() @pytest.mark.asyncio() async def test_on_devices_websocket_state_change_exception_handling( protect_client_no_debug: ProtectApiClient, ) -> None: """Test that exceptions in state change callbacks don't crash the system.""" protect_client = protect_client_no_debug call_count = 0 def raising_callback(state: WebsocketState) -> None: nonlocal call_count call_count += 1 raise ValueError("Test exception") unsub = protect_client.subscribe_devices_websocket_state(raising_callback) # Should not raise, just log the exception protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert call_count == 1 unsub() @pytest.mark.asyncio() async def test_on_events_websocket_state_change_debug_logging( protect_client: ProtectApiClient, ) -> None: """Test events websocket state change with debug logging enabled.""" states: list[WebsocketState] = [] def capture_state(state: WebsocketState) -> None: states.append(state) # Subscribe to state changes unsub = protect_client.subscribe_events_websocket_state(capture_state) # Simulate different state changes with debug logging protect_client._on_events_websocket_state_change(WebsocketState.CONNECTED) assert len(states) == 1 assert states[0] == WebsocketState.CONNECTED protect_client._on_events_websocket_state_change(WebsocketState.DISCONNECTED) assert len(states) == 2 assert states[1] == WebsocketState.DISCONNECTED protect_client._on_events_websocket_state_change(WebsocketState.CONNECTED) assert len(states) == 3 assert states[2] == WebsocketState.CONNECTED unsub() @pytest.mark.asyncio() async def test_on_devices_websocket_state_change_debug_logging( protect_client: ProtectApiClient, ) -> None: """Test devices websocket state change with debug logging enabled.""" states: list[WebsocketState] = [] def capture_state(state: WebsocketState) -> None: states.append(state) # Subscribe to state changes unsub = protect_client.subscribe_devices_websocket_state(capture_state) # Simulate different state changes with debug logging protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert len(states) == 1 assert states[0] == WebsocketState.CONNECTED protect_client._on_devices_websocket_state_change(WebsocketState.DISCONNECTED) assert len(states) == 2 assert states[1] == WebsocketState.DISCONNECTED protect_client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert len(states) == 3 assert states[2] == WebsocketState.CONNECTED unsub() @pytest.mark.asyncio() async def test_auth_public_api_websocket_with_api_key( protect_client_no_debug: ProtectApiClient, ) -> None: """Test authentication for public API websocket with API key.""" protect_client = protect_client_no_debug # Set an API key for testing protect_client._api_key = "test-api-key-12345" headers = await protect_client._auth_public_api_websocket() assert headers is not None assert "X-API-KEY" in headers assert headers["X-API-KEY"] == "test-api-key-12345" @pytest.mark.asyncio() async def test_auth_public_api_websocket_without_api_key( protect_client_no_debug: ProtectApiClient, ) -> None: """Test authentication for public API websocket without API key raises error.""" protect_client = protect_client_no_debug # Ensure API key is None protect_client._api_key = None with pytest.raises(NotAuthorized, match="API key is required"): await protect_client._auth_public_api_websocket() @pytest.mark.asyncio() async def test_auth_public_api_websocket_force_param( protect_client_no_debug: ProtectApiClient, ) -> None: """Test authentication for public API websocket with force parameter.""" protect_client = protect_client_no_debug # Set an API key for testing protect_client._api_key = "test-api-key-force" # Test with force=True headers_forced = await protect_client._auth_public_api_websocket(force=True) assert headers_forced is not None assert "X-API-KEY" in headers_forced assert headers_forced["X-API-KEY"] == "test-api-key-force" # Test with force=False headers_not_forced = await protect_client._auth_public_api_websocket(force=False) assert headers_not_forced is not None assert "X-API-KEY" in headers_not_forced assert headers_not_forced["X-API-KEY"] == "test-api-key-force" @pytest.mark.asyncio() async def test_get_last_update_id_bootstrap_none( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _get_last_update_id when bootstrap is None.""" protect_client = protect_client_no_debug # Ensure bootstrap is None protect_client._bootstrap = None result = protect_client._get_last_update_id() assert result is None @pytest.mark.asyncio() async def test_process_devices_ws_message_exception_handling( protect_client_no_debug: ProtectApiClient, ) -> None: """Test exception handling in _process_devices_ws_message.""" protect_client = protect_client_no_debug # Create valid data structure that will pass initial checks valid_data = { "type": "update", "item": { "modelKey": "camera", "id": "test-id", }, } # Create a mock WSMessage mock_msg = MagicMock(spec=aiohttp.WSMessage) mock_msg.type = aiohttp.WSMsgType.TEXT mock_msg.data = orjson.dumps(valid_data) # Patch orjson.loads to raise an exception during processing with ( patch( "uiprotect.api.orjson.loads", side_effect=Exception("Test JSON parsing exception"), ), patch("uiprotect.api._LOGGER") as mock_logger, ): # This should catch the exception and log it protect_client._process_devices_ws_message(mock_msg) # Verify that exception was logged mock_logger.exception.assert_called_once() call_args = mock_logger.exception.call_args assert ( "Error processing public API devices websocket message" in call_args[0][0] ) @pytest.mark.asyncio() async def test_base_api_client_events_websocket_state_change_debug( protect_client_no_debug: ProtectApiClient, ) -> None: """Test BaseApiClient._on_events_websocket_state_change debug logging.""" protect_client = protect_client_no_debug # Call the BaseApiClient method directly (not the overridden one) with patch("uiprotect.api._LOGGER") as mock_logger: mock_logger.isEnabledFor.return_value = True # Call the base class method directly BaseApiClient._on_events_websocket_state_change( protect_client, WebsocketState.CONNECTED ) # Verify debug log was called mock_logger.debug.assert_called_once() call_args = mock_logger.debug.call_args assert "Events websocket state changed" in call_args[0][0] assert WebsocketState.CONNECTED in call_args[0] @pytest.mark.asyncio() async def test_base_api_client_devices_websocket_state_change_debug( protect_client_no_debug: ProtectApiClient, ) -> None: """Test BaseApiClient._on_devices_websocket_state_change debug logging.""" protect_client = protect_client_no_debug # Call the BaseApiClient method directly (not the overridden one) with patch("uiprotect.api._LOGGER") as mock_logger: mock_logger.isEnabledFor.return_value = True # Call the base class method directly BaseApiClient._on_devices_websocket_state_change( protect_client, WebsocketState.DISCONNECTED ) # Verify debug log was called mock_logger.debug.assert_called_once() call_args = mock_logger.debug.call_args assert "Devices websocket state changed" in call_args[0][0] assert WebsocketState.DISCONNECTED in call_args[0] @pytest.mark.asyncio() async def test_process_devices_ws_message_non_text_debug( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _process_devices_ws_message debug logging for non-text messages.""" protect_client = protect_client_no_debug # Create a non-text WebSocket message (e.g., BINARY) mock_msg = MagicMock(spec=aiohttp.WSMessage) mock_msg.type = aiohttp.WSMsgType.BINARY with patch("uiprotect.api._LOGGER") as mock_logger: # Process the non-text message protect_client._process_devices_ws_message(mock_msg) # Verify debug log was called mock_logger.debug.assert_called_once() call_args = mock_logger.debug.call_args assert "Ignoring non-text websocket message" in call_args[0][0] assert aiohttp.WSMsgType.BINARY in call_args[0] @pytest.mark.asyncio() async def test_process_devices_ws_message_invalid_data_debug( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _process_devices_ws_message debug logging for invalid data.""" protect_client = protect_client_no_debug # Create a message with missing required fields invalid_data = { "type": "update", # Missing "item" or "item.modelKey" } mock_msg = MagicMock(spec=aiohttp.WSMessage) mock_msg.type = aiohttp.WSMsgType.TEXT mock_msg.data = orjson.dumps(invalid_data) with patch("uiprotect.api._LOGGER") as mock_logger: # Process the invalid message protect_client._process_devices_ws_message(mock_msg) # Verify debug log was called mock_logger.debug.assert_called_once() call_args = mock_logger.debug.call_args assert "Invalid public API websocket message" in call_args[0][0] @pytest.mark.asyncio() async def test_process_devices_ws_message_unknown_model_debug( protect_client_no_debug: ProtectApiClient, ) -> None: """Test _process_devices_ws_message debug logging for unknown model type.""" protect_client = protect_client_no_debug # Create a message with an unknown model type unknown_model_data = { "type": "update", "item": { "modelKey": "unknown_model_type_xyz", "id": "test-id", }, } mock_msg = MagicMock(spec=aiohttp.WSMessage) mock_msg.type = aiohttp.WSMsgType.TEXT mock_msg.data = orjson.dumps(unknown_model_data) with patch("uiprotect.api._LOGGER") as mock_logger: # Process the message with unknown model protect_client._process_devices_ws_message(mock_msg) # Verify debug log was called mock_logger.debug.assert_called_once() call_args = mock_logger.debug.call_args assert "Unknown model type in public API message" in call_args[0][0] assert "unknown_model_type_xyz" in call_args[0] @pytest.mark.asyncio() async def test_process_devices_ws_message_without_cache_emits_none_obj( protect_client: ProtectApiClient, caplog: pytest.LogCaptureFixture, ) -> None: """ Without a materialised public bootstrap, ``new_obj`` is ``None``. The public devices WS handler no longer eagerly constructs model instances via ``create_from_unifi_dict`` — consumers that haven't opted into :meth:`update_public` must use ``changed_data`` (the raw payload). """ messages: list[WSSubscriptionMessage] = [] def capture_ws(message: WSSubscriptionMessage) -> None: messages.append(message) unsub = protect_client.subscribe_devices_websocket(capture_ws) msg = create_mock_ws_message( '{"type":"add","item":{"id":"test-device-123","modelKey":"camera","invalid_field":"bad_data"}}' ) with caplog.at_level(logging.DEBUG): protect_client._process_devices_ws_message(msg) assert len(messages) == 1 assert messages[0].action == WSAction.ADD assert messages[0].changed_data["id"] == "test-device-123" assert messages[0].new_obj is None assert messages[0].old_obj is None unsub() # --------------------------------------------------------------------------- # NVR WS updates (PublicNVR — devices websocket) # --------------------------------------------------------------------------- _NVR_ID = "66d025b301ebc903e80003ec" _NVR_ADD_PAYLOAD = { "type": "add", "item": { "id": _NVR_ID, "modelKey": "nvr", "name": "Test NVR", "doorbellSettings": { "defaultMessageText": "WELCOME", "defaultMessageResetTimeoutMs": 60000, "customMessages": [], "customImages": [], }, }, } _ARM_MODE_ARMED = { "status": "armed", "armProfileId": "profile-abc", "armedAt": 1700000000000, "willBeArmedAt": None, "breachDetectedAt": None, "breachEventCount": 0, "breachTriggerEventId": None, "breachEventId": None, } @pytest.mark.asyncio() async def test_nvr_ws_add_populates_public_bootstrap( protect_client: ProtectApiClient, ) -> None: """NVR add message via devices WS creates a PublicNVR in the bootstrap.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap assert pb.nvr is None msg = create_mock_ws_message(orjson.dumps(_NVR_ADD_PAYLOAD).decode()) protect_client._process_devices_ws_message(msg) assert isinstance(pb.nvr, PublicNVR) assert pb.nvr.id == _NVR_ID assert pb.nvr.name == "Test NVR" @pytest.mark.asyncio() async def test_nvr_ws_add_with_arm_mode_populates_arm_mode( protect_client: ProtectApiClient, ) -> None: """NVR add with armMode → pb.arm_mode is populated via PublicNVR.arm_mode.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap payload = dict(_NVR_ADD_PAYLOAD) payload["item"] = dict(payload["item"]) payload["item"]["armMode"] = _ARM_MODE_ARMED msg = create_mock_ws_message(orjson.dumps(payload).decode()) protect_client._process_devices_ws_message(msg) assert pb.arm_mode is not None assert pb.arm_mode.status == NvrArmModeStatus.ARMED assert pb.arm_mode.arm_profile_id == "profile-abc" @pytest.mark.asyncio() async def test_nvr_ws_update_arm_mode_merges_into_nvr( protect_client: ProtectApiClient, ) -> None: """NVR update diff with armMode updates pb.nvr.arm_mode in place.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap # Prime the cache with an add that has no armMode (older firmware without alarm manager support). msg_add = create_mock_ws_message(orjson.dumps(_NVR_ADD_PAYLOAD).decode()) protect_client._process_devices_ws_message(msg_add) assert pb.nvr is not None assert pb.arm_mode is None # Now receive an update diff that includes armMode (newer firmware with alarm manager support). update_payload = { "type": "update", "item": { "id": _NVR_ID, "modelKey": "nvr", "armMode": _ARM_MODE_ARMED, }, } msg_update = create_mock_ws_message(orjson.dumps(update_payload).decode()) protect_client._process_devices_ws_message(msg_update) assert pb.arm_mode is not None assert pb.arm_mode.status == NvrArmModeStatus.ARMED assert pb.arm_mode.arm_profile_id == "profile-abc" @pytest.mark.asyncio() async def test_nvr_ws_update_without_arm_mode_leaves_existing_arm_mode( protect_client: ProtectApiClient, ) -> None: """NVR update diff WITHOUT armMode (v7.0) leaves existing arm_mode untouched.""" protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap # Prime with armed NVR. add_payload = dict(_NVR_ADD_PAYLOAD) add_payload["item"] = dict(add_payload["item"]) add_payload["item"]["armMode"] = _ARM_MODE_ARMED protect_client._process_devices_ws_message( create_mock_ws_message(orjson.dumps(add_payload).decode()) ) assert pb.arm_mode is not None assert pb.arm_mode.status == NvrArmModeStatus.ARMED # Receive a v7.0-style update that does NOT include armMode. update_payload = { "type": "update", "item": {"id": _NVR_ID, "modelKey": "nvr", "name": "Renamed NVR"}, } protect_client._process_devices_ws_message( create_mock_ws_message(orjson.dumps(update_payload).decode()) ) # name updated, arm_mode unchanged. assert pb.nvr.name == "Renamed NVR" assert pb.arm_mode.status == NvrArmModeStatus.ARMED @pytest.mark.asyncio() async def test_nvr_ws_update_arm_mode_emits_subscription_message( protect_client: ProtectApiClient, ) -> None: """Subscribers receive a WSSubscriptionMessage with the updated PublicNVR as new_obj.""" protect_client._public_bootstrap = PublicBootstrap() # Prime. protect_client._process_devices_ws_message( create_mock_ws_message(orjson.dumps(_NVR_ADD_PAYLOAD).decode()) ) messages: list[WSSubscriptionMessage] = [] unsub = protect_client.subscribe_devices_websocket(messages.append) update_payload = { "type": "update", "item": {"id": _NVR_ID, "modelKey": "nvr", "armMode": _ARM_MODE_ARMED}, } protect_client._process_devices_ws_message( create_mock_ws_message(orjson.dumps(update_payload).decode()) ) assert len(messages) == 1 msg = messages[0] assert msg.action == WSAction.UPDATE assert isinstance(msg.new_obj, PublicNVR) assert msg.new_obj.arm_mode is not None assert msg.new_obj.arm_mode.status == NvrArmModeStatus.ARMED unsub() @pytest.mark.asyncio() async def test_nvr_ws_unknown_arm_mode_status_does_not_raise( protect_client: ProtectApiClient, ) -> None: """ Forward-compat: an unknown armMode status from a newer firmware must not raise a ValidationError — it should coerce to NvrArmModeStatus.UNKNOWN. """ protect_client._public_bootstrap = PublicBootstrap() pb = protect_client.public_bootstrap payload = dict(_NVR_ADD_PAYLOAD) payload["item"] = dict(payload["item"]) payload["item"]["armMode"] = { **_ARM_MODE_ARMED, "status": "totally_new_status_from_future_firmware", } msg = create_mock_ws_message(orjson.dumps(payload).decode()) # Must not raise. protect_client._process_devices_ws_message(msg) assert pb.arm_mode is not None assert pb.arm_mode.status == NvrArmModeStatus.UNKNOWN uilibs-uiprotect-317a52c/tests/test_backup_paths.py000066400000000000000000000151471524637705700226130ustar00rootroot00000000000000"""Tests for path-traversal hardening in the backup CLI.""" from __future__ import annotations from datetime import UTC, datetime from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest pytest.importorskip("sqlalchemy") from uiprotect.cli.backup import ( BackupContext, Event, _safe_first_glob_match, _safe_join, _safe_slug, ) if TYPE_CHECKING: from pathlib import Path def _make_ctx(tmp_path: Path, **overrides: str) -> BackupContext: mock_protect = MagicMock() defaults = { "thumbnail_format": "{camera_slug}thumb.jpg", "gif_format": "{camera_slug}animated.gif", "event_format": "{camera_slug}video.mp4", "title_format": "{camera_name}", } defaults.update(overrides) return BackupContext( protect=mock_protect, start=datetime(2024, 1, 1, tzinfo=UTC), end=None, output_format=MagicMock(), output=tmp_path, separator="-", thumbnail_format=defaults["thumbnail_format"], gif_format=defaults["gif_format"], event_format=defaults["event_format"], title_format=defaults["title_format"], max_download=1, page_size=1, length_cutoff=None, # type: ignore[arg-type] ) def _make_event(display_name: str) -> tuple[Event, MagicMock]: camera = MagicMock() camera.display_name = display_name event = Event(id="evt-1", camera_mac="aabbccddeeff", event_type="motion") event.start_naive = datetime(2024, 1, 1, tzinfo=UTC) event.end_naive = None return event, camera def test_safe_slug_strips_traversal_characters() -> None: assert _safe_slug("../../etc/passwd", "-") == "etc-passwd" def test_safe_slug_strips_leading_dots_and_seps() -> None: assert _safe_slug("./..//evil", "-") == "evil" def test_safe_slug_empty_falls_back() -> None: assert _safe_slug("../../", "-") == "unknown" assert _safe_slug("", "-") == "unknown" def test_safe_slug_keeps_safe_characters() -> None: assert _safe_slug("Front_Door-1.0", "-") == "Front_Door-1.0" def test_safe_join_rejects_parent_traversal(tmp_path: Path) -> None: with pytest.raises(ValueError): _safe_join(tmp_path, "../etc/passwd") def test_safe_join_rejects_absolute_paths(tmp_path: Path) -> None: with pytest.raises(ValueError): _safe_join(tmp_path, "/etc/passwd") def test_safe_join_allows_paths_inside_base(tmp_path: Path) -> None: result = _safe_join(tmp_path, "sub/dir/file.jpg") assert result == (tmp_path / "sub/dir/file.jpg").resolve() def test_file_context_sanitizes_traversal_in_camera_slug(tmp_path: Path) -> None: ctx = _make_ctx(tmp_path) event, camera = _make_event("../../etc/foo") ctx.protect.bootstrap.get_device_from_mac.return_value = camera context = event.get_file_context(ctx) assert ".." not in context["camera_slug"] assert "/" not in context["camera_slug"] def test_file_context_preserves_raw_camera_name(tmp_path: Path) -> None: ctx = _make_ctx(tmp_path) event, camera = _make_event("Café Front Door") ctx.protect.bootstrap.get_device_from_mac.return_value = camera context = event.get_file_context(ctx) assert context["camera_name"] == "Café Front Door" def test_thumbnail_path_stays_inside_output_for_malicious_name( tmp_path: Path, ) -> None: ctx = _make_ctx(tmp_path) event, camera = _make_event("../../etc/foo") ctx.protect.bootstrap.get_device_from_mac.return_value = camera path = event.get_thumbnail_path(ctx) assert path.resolve().is_relative_to(tmp_path.resolve()) def test_gif_path_stays_inside_output_for_malicious_name(tmp_path: Path) -> None: ctx = _make_ctx(tmp_path) event, camera = _make_event("../../.ssh/authorized_keys") ctx.protect.bootstrap.get_device_from_mac.return_value = camera path = event.get_gif_path(ctx) assert path.resolve().is_relative_to(tmp_path.resolve()) def test_event_path_stays_inside_output_for_malicious_name( tmp_path: Path, ) -> None: ctx = _make_ctx(tmp_path) event, camera = _make_event("/etc/passwd") ctx.protect.bootstrap.get_device_from_mac.return_value = camera path = event.get_event_path(ctx) assert path.resolve().is_relative_to(tmp_path.resolve()) def test_thumbnail_path_rejects_traversal_template(tmp_path: Path) -> None: ctx = _make_ctx( tmp_path, thumbnail_format="../../../etc/{camera_slug}thumb.jpg", ) event, camera = _make_event("cam") ctx.protect.bootstrap.get_device_from_mac.return_value = camera with pytest.raises(ValueError): event.get_thumbnail_path(ctx) def test_event_path_rejects_traversal_template(tmp_path: Path) -> None: ctx = _make_ctx( tmp_path, event_format="../../{camera_slug}video.mp4", ) event, camera = _make_event("cam") ctx.protect.bootstrap.get_device_from_mac.return_value = camera with pytest.raises(ValueError): event.get_event_path(ctx) def test_safe_first_glob_match_returns_none_for_no_match(tmp_path: Path) -> None: assert _safe_first_glob_match(tmp_path, "missing*.jpg") is None def test_safe_first_glob_match_returns_match_inside_base(tmp_path: Path) -> None: (tmp_path / "thumb.jpg").write_text("x") result = _safe_first_glob_match(tmp_path, "thumb.jpg") assert result == (tmp_path / "thumb.jpg").resolve() def test_safe_first_glob_match_rejects_absolute_pattern(tmp_path: Path) -> None: assert _safe_first_glob_match(tmp_path, "/etc/passwd") is None def test_safe_first_glob_match_rejects_traversal_pattern(tmp_path: Path) -> None: assert _safe_first_glob_match(tmp_path, "../../etc/*") is None def test_existing_thumbnail_path_rejects_traversal_template(tmp_path: Path) -> None: ctx = _make_ctx( tmp_path, thumbnail_format="../../../etc/{camera_slug}thumb.jpg", ) event, camera = _make_event("cam") ctx.protect.bootstrap.get_device_from_mac.return_value = camera assert event.get_existing_thumbnail_path(ctx) is None def test_existing_gif_path_rejects_traversal_template(tmp_path: Path) -> None: ctx = _make_ctx( tmp_path, gif_format="../../{camera_slug}animated.gif", ) event, camera = _make_event("cam") ctx.protect.bootstrap.get_device_from_mac.return_value = camera assert event.get_existing_gif_path(ctx) is None def test_existing_event_path_rejects_absolute_template(tmp_path: Path) -> None: ctx = _make_ctx( tmp_path, event_format="/etc/{camera_slug}video.mp4", ) event, camera = _make_event("cam") ctx.protect.bootstrap.get_device_from_mac.return_value = camera assert event.get_existing_event_path(ctx) is None uilibs-uiprotect-317a52c/tests/test_cli.py000066400000000000000000001406571524637705700207230ustar00rootroot00000000000000import re import ssl from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import pytest # The CLI stack (typer + rich) ships with the optional `cli` extra; importing # uiprotect.cli pulls the whole chain, so skip the module when any part is # absent (a minimal install without --all-extras) instead of failing collection. pytest.importorskip("uiprotect.cli") import typer from typer.testing import CliRunner from uiprotect.cli import _is_ssl_error, app from uiprotect.cli import base as base_cli from uiprotect.cli import cameras as cameras_cli from uiprotect.cli import lights as lights_cli from uiprotect.cli import sensors as sensors_cli from uiprotect.cli.arm import app as arm_app from uiprotect.cli.bridges import app as bridges_app from uiprotect.cli.cameras import app as cameras_app from uiprotect.cli.chimes import app as chime_app from uiprotect.cli.chimes import cameras, set_repeat_times, set_volume from uiprotect.cli.files_public import app as files_public_app from uiprotect.cli.fobs import app as fob_app from uiprotect.cli.link_stations import app as link_station_app from uiprotect.cli.liveviews import app as liveview_app from uiprotect.cli.relays import app as relay_app from uiprotect.cli.sensors import app as sensor_app from uiprotect.cli.sensors import ( set_alarm_public, set_arm_profiles_public, set_custom_sensitivity_public, set_glass_break_public, set_glass_break_settings_public, set_humidity_public, set_humidity_settings_public, set_light_public, set_light_settings_public, set_motion_public, set_motion_sensitivity_public, set_motion_settings_public, set_name_public, set_schedule_mode_public, set_temperature_public, set_temperature_settings_public, ) from uiprotect.cli.sirens import app as siren_app from uiprotect.cli.speakers import app as speaker_app from uiprotect.cli.ulp_users_public import app as ulp_users_public_app from uiprotect.cli.users_public import app as users_public_app from uiprotect.cli.viewers import app as viewer_app from uiprotect.cli.viewers import liveview from uiprotect.cli.viewers_public import app as viewer_public_app from uiprotect.data import ( NVR, AiPort, Camera, Chime, Light, RingSetting, Sensor, Viewer, ) from uiprotect.data.types import ( DoorbellMessageType, PublicHdrMode, SensorScheduleMode, SmartDetectAudioType, SmartDetectObjectType, VideoMode, ) from uiprotect.exceptions import BadRequest runner = CliRunner() _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") def test_help(): """The help message includes the CLI name.""" result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "UniFi Protect CLI" in result.stdout def test_is_ssl_error_with_ssl_exceptions(): """SSL-related exceptions should be detected.""" # Direct SSL errors assert ( _is_ssl_error(ssl.SSLCertVerificationError("certificate verify failed")) is True ) # Mock aiohttp SSL errors (they require complex OSError arguments) ssl_error = MagicMock(spec=aiohttp.ClientConnectorSSLError) ssl_error.__class__ = aiohttp.ClientConnectorSSLError assert _is_ssl_error(ssl_error) is True cert_error = MagicMock(spec=aiohttp.ClientConnectorCertificateError) cert_error.__class__ = aiohttp.ClientConnectorCertificateError assert _is_ssl_error(cert_error) is True def test_is_ssl_error_with_wrapped_ssl_exceptions(): """SSL exceptions wrapped in other exceptions should be detected.""" ssl_error = ssl.SSLCertVerificationError() wrapped = RuntimeError("Connection failed") wrapped.__cause__ = ssl_error assert _is_ssl_error(wrapped) is True # Deeply nested outer = ValueError("Outer error") outer.__cause__ = wrapped assert _is_ssl_error(outer) is True def test_is_ssl_error_with_non_ssl_exceptions(): """Non-SSL exceptions should not be detected as SSL errors.""" assert _is_ssl_error(ValueError("some error")) is False assert _is_ssl_error(RuntimeError("connection refused")) is False assert _is_ssl_error(aiohttp.ClientError("generic error")) is False assert _is_ssl_error(ConnectionError("network error")) is False # --------------------------------------------------------------------------- # New Public-API sub-app smoke tests (no server needed) # --------------------------------------------------------------------------- def test_root_help_shows_public_subcommands() -> None: """Top-level --help must list the new public-API sub-apps.""" result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "sirens" in result.stdout assert "relays" in result.stdout assert "fobs" in result.stdout assert "speakers" in result.stdout assert "link-stations" in result.stdout assert "liveviews" in result.stdout assert "bridges" in result.stdout assert "viewers-public" in result.stdout assert "users-public" in result.stdout assert "ulp-users-public" in result.stdout assert "files-public" in result.stdout assert "arm" in result.stdout def test_sirens_help() -> None: """``sirens --help`` renders without error.""" result = runner.invoke(siren_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout def test_relays_help() -> None: """``relays --help`` renders without error.""" result = runner.invoke(relay_app, ["--help"]) assert result.exit_code == 0 assert "activate" in result.stdout def test_fobs_help() -> None: """``fobs --help`` renders without error.""" result = runner.invoke(fob_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "set-name" in result.stdout def test_speakers_help() -> None: """``speakers --help`` renders without error.""" result = runner.invoke(speaker_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "show" in result.stdout assert "set-name" in result.stdout assert "set-volume" in result.stdout assert "set-mic-volume" in result.stdout assert "set-mic-enabled" in result.stdout assert "test-sound" in result.stdout def test_arm_help() -> None: """``arm --help`` renders without error.""" result = runner.invoke(arm_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout def test_link_stations_help() -> None: """``link-stations --help`` renders without error.""" result = runner.invoke(link_station_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "show" in result.stdout assert "set-name" in result.stdout assert "trigger-output" in result.stdout def test_bridges_help() -> None: """``bridges --help`` renders without error.""" result = runner.invoke(bridges_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "show" in result.stdout assert "set-name" in result.stdout def test_viewers_public_help() -> None: """``viewers-public --help`` renders without error.""" result = runner.invoke(viewer_public_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "show" in result.stdout assert "set-name" in result.stdout assert "set-liveview" in result.stdout def test_users_public_help() -> None: """``users-public --help`` renders without error.""" result = runner.invoke(users_public_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "show" in result.stdout def test_ulp_users_public_help() -> None: """``ulp-users-public --help`` renders without error.""" result = runner.invoke(ulp_users_public_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "show" in result.stdout def test_files_public_help() -> None: """``files-public --help`` renders without error.""" result = runner.invoke(files_public_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "upload" in result.stdout def test_public_only_command_constructs_without_credentials() -> None: """A public-API subcommand builds the client with only an API key.""" with patch("uiprotect.cli.ProtectApiClient") as client_cls: client_cls.return_value = MagicMock( get_sirens_public=AsyncMock(return_value=[]), close_session=AsyncMock(), close_public_api_session=AsyncMock(), ) result = runner.invoke( app, ["--api-key", "k", "--address", "192.0.2.10", "sirens", "list"], ) assert result.exit_code == 0 assert client_cls.call_count == 1 kwargs = client_cls.call_args.kwargs assert kwargs["api_key"] == "k" assert kwargs["username"] is None assert kwargs["password"] is None def test_client_construction_bad_request_exits_with_error() -> None: """A ``BadRequest`` from client construction prints in red and exits 1.""" with patch("uiprotect.cli.ProtectApiClient") as client_cls: client_cls.side_effect = BadRequest("api key cannot be empty") result = runner.invoke( app, ["--api-key", "k", "--address", "192.0.2.10", "sirens", "list"], ) assert result.exit_code == 1 output = _ANSI_ESCAPE_RE.sub("", result.stdout + (result.stderr or "")) assert "api key cannot be empty" in output def test_cameras_disable_mic_listed_in_help() -> None: """ ``cameras --help`` advertises the new ``disable-mic-permanently`` subcommand. The cameras CLI takes an optional positional ``device_id`` before the subcommand, so invoking ``["disable-mic-permanently", "--help"]`` is parsed by typer as ``device_id="disable-mic-permanently"`` followed by the parent's ``--help``; the parent help is what we assert on instead. """ result = runner.invoke(cameras_app, ["--help"]) assert result.exit_code == 0 plain_output = _ANSI_ESCAPE_RE.sub("", result.output) assert "disable-mic-permanently" in plain_output def test_link_stations_trigger_output_rejects_negative_delay() -> None: """``trigger-output ... --delay -1`` must fail typer's ``min=0`` validator.""" result = runner.invoke( link_station_app, ["trigger-output", "hub-id", "0", "--delay", "-1"], ) assert result.exit_code != 0 plain_output = _ANSI_ESCAPE_RE.sub("", result.output) assert "Invalid value" in plain_output assert "--delay" in plain_output def test_liveviews_help() -> None: """``liveviews --help`` renders without error.""" result = runner.invoke(liveview_app, ["--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "show" in result.stdout assert "create" in result.stdout assert "update" in result.stdout def test_liveviews_create_rejects_invalid_slots_json() -> None: """``create --slots `` must exit with code 1 before any API call.""" result = runner.invoke( liveview_app, [ "create", "--name", "X", "--owner", "u1", "--layout", "1", "--slots", "not-json", ], ) assert result.exit_code == 1 assert "--slots must be valid JSON" in result.stdout def test_liveviews_create_rejects_non_array_slots() -> None: """``--slots`` must be a JSON array, not an object.""" result = runner.invoke( liveview_app, [ "create", "--name", "X", "--owner", "u1", "--layout", "1", "--slots", '{"foo": 1}', ], ) assert result.exit_code == 1 assert "--slots must be a JSON array" in result.stdout def test_liveviews_create_rejects_non_object_slot_entries() -> None: """``--slots`` entries must be JSON objects, not scalars.""" result = runner.invoke( liveview_app, [ "create", "--name", "X", "--owner", "u1", "--layout", "1", "--slots", '["bad"]', ], ) assert result.exit_code == 1 assert "--slots entries must be JSON objects" in result.stdout def test_liveviews_update_rejects_empty_args() -> None: """``update `` without any field must exit with code 1.""" result = runner.invoke(liveview_app, ["update", "lv-1"]) assert result.exit_code == 1 assert "At least one field must be provided" in result.stdout def test_relays_activate_rejects_invalid_state() -> None: """``activate --state bad`` must exit with code 1 before any API call.""" result = runner.invoke(relay_app, ["activate", "relay-id", "0", "--state", "bad"]) assert result.exit_code == 1 assert "--state must be" in result.stdout def test_relays_activate_rejects_pulse_without_on_state() -> None: """``--pulse-duration-ms`` with ``--state off`` must exit with code 1.""" result = runner.invoke( relay_app, ["activate", "relay-id", "0", "--state", "off", "--pulse-duration-ms", "500"], ) assert result.exit_code == 1 assert "--pulse-duration-ms requires" in result.stdout def test_relays_activate_rejects_pulse_without_any_state() -> None: """``--pulse-duration-ms`` without a state must exit with code 1.""" result = runner.invoke( relay_app, ["activate", "relay-id", "0", "--pulse-duration-ms", "500"], ) assert result.exit_code == 1 assert "--pulse-duration-ms requires" in result.stdout # --------------------------------------------------------------------------- # SSL verification failure behaviour # --------------------------------------------------------------------------- _BASE_AUTH_ARGS = [ "--username", "u", "--password", "p", "--address", "192.0.2.10", ] def test_ssl_failure_does_not_prompt_or_retry() -> None: """SSL failure must exit 1 without offering to disable verification.""" with ( patch("uiprotect.cli.ProtectApiClient") as client_cls, patch( "uiprotect.cli._connect_and_bootstrap", new_callable=AsyncMock ) as connect, patch( "uiprotect.cli._get_cert_fingerprint", return_value="AA:BB:CC", ), ): client_cls.return_value = MagicMock( close_session=AsyncMock(), close_public_api_session=AsyncMock(), ) connect.side_effect = ssl.SSLCertVerificationError("certificate verify failed") result = runner.invoke(app, [*_BASE_AUTH_ARGS, "get-meta-info"]) assert result.exit_code == 1 output = result.stdout + (result.stderr or "") assert "Would you like to disable" not in output assert "Tip:" not in output assert client_cls.call_count == 1 kwargs = client_cls.call_args.kwargs assert kwargs.get("verify_ssl") is True def test_ssl_failure_prints_fingerprint_and_instructions() -> None: """Operator-visible output must show fingerprint and --no-verify-ssl.""" with ( patch("uiprotect.cli.ProtectApiClient") as client_cls, patch( "uiprotect.cli._connect_and_bootstrap", new_callable=AsyncMock ) as connect, patch( "uiprotect.cli._get_cert_fingerprint", return_value="DE:AD:BE:EF", ), ): client_cls.return_value = MagicMock( close_session=AsyncMock(), close_public_api_session=AsyncMock(), ) connect.side_effect = ssl.SSLCertVerificationError("certificate verify failed") result = runner.invoke(app, [*_BASE_AUTH_ARGS, "get-meta-info"]) assert result.exit_code == 1 output = result.stdout + (result.stderr or "") assert "DE:AD:BE:EF" in output assert "--no-verify-ssl" in output def test_ssl_failure_when_fingerprint_unavailable() -> None: """Missing fingerprint must still exit 1 with --no-verify-ssl guidance.""" with ( patch("uiprotect.cli.ProtectApiClient") as client_cls, patch( "uiprotect.cli._connect_and_bootstrap", new_callable=AsyncMock ) as connect, patch( "uiprotect.cli._get_cert_fingerprint", return_value=None, ), ): client_cls.return_value = MagicMock( close_session=AsyncMock(), close_public_api_session=AsyncMock(), ) connect.side_effect = ssl.SSLCertVerificationError("certificate verify failed") result = runner.invoke(app, [*_BASE_AUTH_ARGS, "get-meta-info"]) assert result.exit_code == 1 output = result.stdout + (result.stderr or "") assert "--no-verify-ssl" in output assert client_cls.call_count == 1 def test_non_ssl_failure_still_exits_with_message() -> None: """Non-SSL connection failures keep their existing exit-1 path.""" with ( patch("uiprotect.cli.ProtectApiClient") as client_cls, patch( "uiprotect.cli._connect_and_bootstrap", new_callable=AsyncMock ) as connect, ): client_cls.return_value = MagicMock( close_session=AsyncMock(), close_public_api_session=AsyncMock(), ) connect.side_effect = RuntimeError("boom") result = runner.invoke(app, [*_BASE_AUTH_ARGS, "get-meta-info"]) assert result.exit_code == 1 output = result.stdout + (result.stderr or "") assert "Connection failed" in output assert client_cls.call_count == 1 # --------------------------------------------------------------------------- # Chime CLI — public-API migration # --------------------------------------------------------------------------- def _make_chime_ctx( *, ring_settings: list[RingSetting] | None = None, camera_ids: list[str] | None = None, cameras_map: dict[str, MagicMock] | None = None, ): """Build a typer context double wired to mocked chime + client.""" chime = MagicMock() chime.id = "chime-1" chime.ring_settings = ring_settings if ring_settings is not None else [] chime.camera_ids = camera_ids if camera_ids is not None else [] chime.cameras = [] chime.set_volume_for_camera_public = AsyncMock() chime.set_ring_settings_public = AsyncMock() chime.set_repeat_times_for_camera_public = AsyncMock() protect = MagicMock() protect.update_chime_public = AsyncMock() protect.close_session = AsyncMock() protect.close_public_api_session = AsyncMock() protect.bootstrap.cameras = cameras_map if cameras_map is not None else {} ctx = MagicMock() ctx.obj.device = chime ctx.obj.protect = protect return ctx, chime, protect def _doorbell_camera(camera_id: str) -> MagicMock: camera = MagicMock() camera.id = camera_id camera.feature_flags.is_doorbell = True return camera def test_chime_help() -> None: """Chime CLI exposes its subcommands.""" result = runner.invoke(chime_app, ["--help"]) assert result.exit_code == 0 assert "cameras" in result.stdout assert "set-volume" in result.stdout assert "set-repeat-times" in result.stdout def test_chime_cameras_set_uses_update_chime_public() -> None: """Setting cameras patches via the public API, not save_device.""" camera = _doorbell_camera("cam-1") ctx, _chime, protect = _make_chime_ctx(cameras_map={"cam-1": camera}) cameras(ctx, camera_ids=["cam-1"], add=False, remove=False) protect.update_chime_public.assert_awaited_once_with( "chime-1", camera_ids=["cam-1"] ) def test_chime_cameras_empty_clears_list() -> None: """`[]` sentinel clears paired cameras via the public API.""" ctx, _chime, protect = _make_chime_ctx(camera_ids=["cam-1"]) cameras(ctx, camera_ids=["[]"], add=False, remove=False) protect.update_chime_public.assert_awaited_once_with("chime-1", camera_ids=[]) def test_chime_cameras_add_merges_existing() -> None: """--add unions with current cameras.""" camera = _doorbell_camera("cam-2") ctx, _chime, protect = _make_chime_ctx( camera_ids=["cam-1"], cameras_map={"cam-2": camera} ) cameras(ctx, camera_ids=["cam-2"], add=True, remove=False) protect.update_chime_public.assert_awaited_once() sent = protect.update_chime_public.await_args.kwargs["camera_ids"] assert set(sent) == {"cam-1", "cam-2"} def test_chime_cameras_remove_subtracts() -> None: """--remove drops the named cameras.""" camera = _doorbell_camera("cam-1") ctx, _chime, protect = _make_chime_ctx( camera_ids=["cam-1", "cam-2"], cameras_map={"cam-1": camera} ) cameras(ctx, camera_ids=["cam-1"], add=False, remove=True) protect.update_chime_public.assert_awaited_once() sent = protect.update_chime_public.await_args.kwargs["camera_ids"] assert set(sent) == {"cam-2"} def test_chime_cameras_add_and_remove_rejected() -> None: """--add and --remove are mutually exclusive.""" ctx, _chime, protect = _make_chime_ctx() with pytest.raises(typer.Exit) as exc: cameras(ctx, camera_ids=["cam-1"], add=True, remove=True) assert exc.value.exit_code == 1 protect.update_chime_public.assert_not_called() def test_chime_cameras_no_args_lists_cameras() -> None: """No camera ids prints the current pairing.""" ctx, _chime, protect = _make_chime_ctx() cameras(ctx, camera_ids=[], add=False, remove=False) protect.update_chime_public.assert_not_called() def test_chime_cameras_invalid_id_rejected() -> None: """Unknown camera id exits 1.""" ctx, _chime, protect = _make_chime_ctx(cameras_map={}) with pytest.raises(typer.Exit) as exc: cameras(ctx, camera_ids=["nope"], add=False, remove=False) assert exc.value.exit_code == 1 protect.update_chime_public.assert_not_called() def test_chime_cameras_non_doorbell_rejected() -> None: """Non-doorbell camera exits 1.""" camera = _doorbell_camera("cam-1") camera.feature_flags.is_doorbell = False ctx, _chime, protect = _make_chime_ctx(cameras_map={"cam-1": camera}) with pytest.raises(typer.Exit) as exc: cameras(ctx, camera_ids=["cam-1"], add=False, remove=False) assert exc.value.exit_code == 1 protect.update_chime_public.assert_not_called() def test_chime_set_volume_whole_device_uses_ring_settings() -> None: """Whole-device volume routes through update_chime_public(ring_settings=...).""" ring = RingSetting( camera_id="cam-1", repeat_times=2, # type: ignore[arg-type] ringtone_id="rt-1", volume=20, ) ctx, _chime, protect = _make_chime_ctx(ring_settings=[ring]) set_volume(ctx, value=80, camera_id=None) protect.update_chime_public.assert_awaited_once_with( "chime-1", ring_settings=[ { "cameraId": "cam-1", "volume": 80, "repeatTimes": 2, "ringtoneId": "rt-1", } ], ) def test_chime_set_volume_per_camera_uses_public_wrapper() -> None: """Per-camera volume uses set_volume_for_camera_public.""" camera = _doorbell_camera("cam-1") ctx, chime, protect = _make_chime_ctx(cameras_map={"cam-1": camera}) set_volume(ctx, value=55, camera_id="cam-1") chime.set_volume_for_camera_public.assert_awaited_once_with(camera, 55) protect.update_chime_public.assert_not_called() def test_chime_set_volume_per_camera_invalid_id_rejected() -> None: """Per-camera volume with unknown camera exits 1.""" ctx, chime, _protect = _make_chime_ctx(cameras_map={}) with pytest.raises(typer.Exit) as exc: set_volume(ctx, value=55, camera_id="nope") assert exc.value.exit_code == 1 chime.set_volume_for_camera_public.assert_not_called() def test_chime_set_repeat_times_whole_device_uses_ring_settings() -> None: """Whole-device repeat routes through update_chime_public(ring_settings=...).""" ring = RingSetting( camera_id="cam-1", repeat_times=1, # type: ignore[arg-type] ringtone_id="rt-1", volume=20, ) ctx, _chime, protect = _make_chime_ctx(ring_settings=[ring]) set_repeat_times(ctx, value=4, camera_id=None) protect.update_chime_public.assert_awaited_once_with( "chime-1", ring_settings=[ { "cameraId": "cam-1", "volume": 20, "repeatTimes": 4, "ringtoneId": "rt-1", } ], ) def test_chime_set_repeat_times_per_camera_uses_public_wrapper() -> None: """Per-camera repeat delegates to set_repeat_times_for_camera_public.""" camera = _doorbell_camera("cam-1") ctx, chime, protect = _make_chime_ctx(cameras_map={"cam-1": camera}) set_repeat_times(ctx, value=5, camera_id="cam-1") chime.set_repeat_times_for_camera_public.assert_awaited_once_with(camera, 5) protect.update_chime_public.assert_not_called() def test_chime_set_repeat_times_per_camera_invalid_id_rejected() -> None: """Per-camera repeat with unknown camera exits 1.""" ctx, chime, _protect = _make_chime_ctx(cameras_map={}) with pytest.raises(typer.Exit) as exc: set_repeat_times(ctx, value=5, camera_id="nope") assert exc.value.exit_code == 1 chime.set_repeat_times_for_camera_public.assert_not_called() def _make_viewer_ctx(liveview_ids: list[str] | None = None): """Build a typer context double wired to a mocked viewer + client.""" viewer = MagicMock() viewer.id = "viewer-1" viewer.set_liveview = AsyncMock() protect = MagicMock() protect.update_viewer_public = AsyncMock() protect.close_session = AsyncMock() protect.close_public_api_session = AsyncMock() protect.bootstrap.liveviews = dict.fromkeys(liveview_ids or [], MagicMock()) ctx = MagicMock() ctx.obj.device = viewer ctx.obj.protect = protect return ctx, viewer, protect def test_viewer_help() -> None: """Private viewers CLI exposes its liveview subcommand.""" result = runner.invoke(viewer_app, ["--help"]) assert result.exit_code == 0 assert "liveview" in result.stdout def test_viewer_liveview_set_uses_update_viewer_public() -> None: """Assigning a liveview patches via the public API, not the deprecated setter.""" ctx, viewer, protect = _make_viewer_ctx(liveview_ids=["lv-1"]) liveview(ctx, "lv-1") protect.update_viewer_public.assert_awaited_once_with("viewer-1", liveview="lv-1") viewer.set_liveview.assert_not_called() def test_viewer_liveview_set_rejects_unknown_id() -> None: """An unknown liveview ID exits non-zero without a write.""" ctx, _viewer, protect = _make_viewer_ctx(liveview_ids=[]) with pytest.raises(typer.Exit) as exc: liveview(ctx, "lv-missing") assert exc.value.exit_code == 1 protect.update_viewer_public.assert_not_awaited() def _make_sensor_ctx(): """Build a typer context double wired to a mocked sensor + client.""" sensor = MagicMock() sensor.id = "sensor-1" sensor.set_name_public = AsyncMock() sensor.set_temperature_settings_public = AsyncMock() sensor.set_humidity_settings_public = AsyncMock() sensor.set_light_settings_public = AsyncMock() sensor.set_motion_settings_public = AsyncMock() sensor.set_glass_break_settings_public = AsyncMock() sensor.set_alarm_public = AsyncMock() sensor.set_motion_status_public = AsyncMock() sensor.set_motion_sensitivity_public = AsyncMock() sensor.set_temperature_status_public = AsyncMock() sensor.set_humidity_status_public = AsyncMock() sensor.set_light_status_public = AsyncMock() sensor.set_glass_break_status_public = AsyncMock() sensor.set_schedule_mode_public = AsyncMock() sensor.set_arm_profile_ids_public = AsyncMock() sensor.set_custom_sensitivity_when_armed_public = AsyncMock() protect = MagicMock() protect.close_session = AsyncMock() protect.close_public_api_session = AsyncMock() ctx = MagicMock() ctx.obj.device = sensor ctx.obj.protect = protect return ctx, sensor def test_sensor_help() -> None: """Sensor CLI exposes the public setter subcommands.""" result = runner.invoke(sensor_app, ["--help"]) assert result.exit_code == 0 plain_output = _ANSI_ESCAPE_RE.sub("", result.output) assert "set-name-public" in plain_output assert "set-glass-break-settings-public" in plain_output assert "set-schedule-mode-public" in plain_output def test_sensor_set_name_public() -> None: ctx, sensor = _make_sensor_ctx() set_name_public(ctx, "Kitchen") sensor.set_name_public.assert_awaited_once_with("Kitchen") def test_sensor_set_temperature_settings_public() -> None: ctx, sensor = _make_sensor_ctx() set_temperature_settings_public( ctx, is_enabled=True, low_threshold=5.0, high_threshold=30.0, margin=1.0 ) sensor.set_temperature_settings_public.assert_awaited_once_with( is_enabled=True, low_threshold=5.0, high_threshold=30.0, margin=1.0 ) def test_sensor_set_humidity_settings_public() -> None: ctx, sensor = _make_sensor_ctx() set_humidity_settings_public( ctx, is_enabled=True, low_threshold=10, high_threshold=80, margin=2 ) sensor.set_humidity_settings_public.assert_awaited_once_with( is_enabled=True, low_threshold=10, high_threshold=80, margin=2 ) def test_sensor_set_light_settings_public() -> None: ctx, sensor = _make_sensor_ctx() set_light_settings_public( ctx, is_enabled=False, low_threshold=1, high_threshold=900, margin=5 ) sensor.set_light_settings_public.assert_awaited_once_with( is_enabled=False, low_threshold=1, high_threshold=900, margin=5 ) def test_sensor_set_motion_settings_public() -> None: ctx, sensor = _make_sensor_ctx() set_motion_settings_public( ctx, is_enabled=True, sensitivity=70, sensitivity_when_armed=90 ) sensor.set_motion_settings_public.assert_awaited_once_with( is_enabled=True, sensitivity=70, sensitivity_when_armed=90 ) def test_sensor_set_glass_break_settings_public() -> None: ctx, sensor = _make_sensor_ctx() set_glass_break_settings_public( ctx, is_enabled=True, sensitivity=55, sensitivity_when_armed=65 ) sensor.set_glass_break_settings_public.assert_awaited_once_with( is_enabled=True, sensitivity=55, sensitivity_when_armed=65 ) def test_sensor_set_alarm_public() -> None: ctx, sensor = _make_sensor_ctx() set_alarm_public(ctx, True) sensor.set_alarm_public.assert_awaited_once_with(True) def test_sensor_set_motion_public() -> None: ctx, sensor = _make_sensor_ctx() set_motion_public(ctx, True) sensor.set_motion_status_public.assert_awaited_once_with(True) def test_sensor_set_motion_sensitivity_public() -> None: ctx, sensor = _make_sensor_ctx() set_motion_sensitivity_public(ctx, 42) sensor.set_motion_sensitivity_public.assert_awaited_once_with(42) def test_sensor_set_temperature_public() -> None: ctx, sensor = _make_sensor_ctx() set_temperature_public(ctx, False) sensor.set_temperature_status_public.assert_awaited_once_with(False) def test_sensor_set_humidity_public() -> None: ctx, sensor = _make_sensor_ctx() set_humidity_public(ctx, True) sensor.set_humidity_status_public.assert_awaited_once_with(True) def test_sensor_set_light_public() -> None: ctx, sensor = _make_sensor_ctx() set_light_public(ctx, False) sensor.set_light_status_public.assert_awaited_once_with(False) def test_sensor_set_glass_break_public() -> None: ctx, sensor = _make_sensor_ctx() set_glass_break_public(ctx, True) sensor.set_glass_break_status_public.assert_awaited_once_with(True) def test_sensor_set_schedule_mode_public() -> None: ctx, sensor = _make_sensor_ctx() set_schedule_mode_public(ctx, SensorScheduleMode.WHEN_ARMED) sensor.set_schedule_mode_public.assert_awaited_once_with( SensorScheduleMode.WHEN_ARMED ) def test_sensor_set_arm_profiles_public() -> None: ctx, sensor = _make_sensor_ctx() set_arm_profiles_public(ctx, ["p1", "p2"]) sensor.set_arm_profile_ids_public.assert_awaited_once_with(["p1", "p2"]) def test_sensor_set_custom_sensitivity_public() -> None: ctx, sensor = _make_sensor_ctx() set_custom_sensitivity_public(ctx, True) sensor.set_custom_sensitivity_when_armed_public.assert_awaited_once_with(True) def _make_device_ctx(*, device_id: str): """Build a typer context double wired to a mocked device + client.""" device = MagicMock() device.id = device_id protect = MagicMock() protect.close_session = AsyncMock() protect.close_public_api_session = AsyncMock() ctx = MagicMock() ctx.obj.device = device ctx.obj.protect = protect return ctx, device def _make_camera_ctx(): ctx, camera = _make_device_ctx(device_id="camera-1") camera.set_status_light_public = AsyncMock() camera.set_hdr_mode_public = AsyncMock() camera.set_video_mode_public = AsyncMock() camera.set_mic_volume_public = AsyncMock() camera.set_osd_name_public = AsyncMock() camera.set_osd_date_public = AsyncMock() camera.set_osd_logo_public = AsyncMock() camera.set_osd_nerd_mode_public = AsyncMock() camera.set_lcd_message_public = AsyncMock() camera.set_lcd_text = AsyncMock() return ctx, camera def test_camera_set_status_light_uses_public() -> None: """Camera set-status-light writes through the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_status_light(ctx, True) camera.set_status_light_public.assert_awaited_once_with(True) camera.set_status_light.assert_not_called() @pytest.mark.parametrize("mode", list(PublicHdrMode)) def test_camera_set_hdr_uses_public(mode: PublicHdrMode) -> None: """Camera set-hdr passes the requested mode to the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_hdr(ctx, mode) camera.set_hdr_mode_public.assert_awaited_once_with(mode) def test_camera_set_hdr_rejects_unknown_mode() -> None: """``set-hdr`` only accepts the public API's three mode values.""" result = runner.invoke( cameras_app, ["cam-1", "set-hdr", "always"], obj=MagicMock(), ) assert result.exit_code == 2 plain_output = _ANSI_ESCAPE_RE.sub("", result.output) assert "Invalid value" in plain_output def test_camera_set_video_mode_uses_public() -> None: """Camera set-video-mode writes through the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_video_mode(ctx, VideoMode.HIGH_FPS) camera.set_video_mode_public.assert_awaited_once_with(VideoMode.HIGH_FPS) camera.set_video_mode.assert_not_called() def test_camera_set_mic_volume_uses_public() -> None: """Camera set-mic-volume writes through the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_mic_volume(ctx, 55) camera.set_mic_volume_public.assert_awaited_once_with(55) camera.set_mic_volume.assert_not_called() def test_camera_set_mic_volume_allows_zero() -> None: """``set-mic-volume 0`` (mute) passes typer's ``min=0`` validator.""" ctx, camera = _make_camera_ctx() cameras_cli.set_mic_volume(ctx, 0) camera.set_mic_volume_public.assert_awaited_once_with(0) result = runner.invoke( cameras_app, ["cam-1", "set-mic-volume", "0"], obj=MagicMock(), ) plain_output = _ANSI_ESCAPE_RE.sub("", result.output) assert "Invalid value" not in plain_output def test_camera_set_osd_name_uses_public() -> None: """Camera set-osd-name writes through the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_osd_name(ctx, True) camera.set_osd_name_public.assert_awaited_once_with(True) camera.set_osd_name.assert_not_called() def test_camera_set_osd_date_uses_public() -> None: """Camera set-osd-date writes through the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_osd_date(ctx, False) camera.set_osd_date_public.assert_awaited_once_with(False) camera.set_osd_date.assert_not_called() def test_camera_set_osd_logo_uses_public() -> None: """Camera set-osd-logo writes through the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_osd_logo(ctx, True) camera.set_osd_logo_public.assert_awaited_once_with(True) camera.set_osd_logo.assert_not_called() def test_camera_set_osd_bitrate_uses_public_nerd_mode() -> None: """Camera set-osd-bitrate writes through the public nerd-mode setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_osd_bitrate(ctx, True) camera.set_osd_nerd_mode_public.assert_awaited_once_with(True) camera.set_osd_bitrate.assert_not_called() def test_camera_set_lcd_text_uses_public() -> None: """Camera set-lcd-text writes through the public setter when given a type.""" ctx, camera = _make_camera_ctx() reset_at = datetime(2026, 1, 1, 12, 0) cameras_cli.set_lcd_text( ctx, DoorbellMessageType.CUSTOM_MESSAGE, "hello", reset_at=reset_at ) text_type, text, awaited_reset_at = camera.set_lcd_message_public.await_args.args assert (text_type, text) == (DoorbellMessageType.CUSTOM_MESSAGE, "hello") # The CLI localises the naive timestamp to the host timezone first. assert awaited_reset_at.tzinfo is not None assert awaited_reset_at.replace(tzinfo=None) == reset_at camera.set_lcd_text.assert_not_called() def test_camera_set_lcd_text_clear_uses_public() -> None: """Clearing the LCD message also writes through the public setter.""" ctx, camera = _make_camera_ctx() cameras_cli.set_lcd_text(ctx, None, None, reset_at=None) camera.set_lcd_message_public.assert_awaited_once_with(None) camera.set_lcd_text.assert_not_called() def test_camera_set_lcd_text_clear_rejects_reset_time() -> None: """--reset-time is not silently dropped when clearing the message.""" ctx, camera = _make_camera_ctx() with pytest.raises(typer.Exit) as exc: cameras_cli.set_lcd_text(ctx, None, None, reset_at=datetime(2026, 1, 1, 12, 0)) assert exc.value.exit_code == 1 camera.set_lcd_message_public.assert_not_called() camera.set_lcd_text.assert_not_called() def _make_light_ctx(): ctx, light = _make_device_ctx(device_id="light-1") light.set_status_light_public = AsyncMock() light.set_led_level_public = AsyncMock() light.set_sensitivity_public = AsyncMock() light.set_duration_public = AsyncMock() light.set_flood_light_public = AsyncMock() return ctx, light def test_light_set_status_light_uses_public() -> None: """Light set-status-light writes through the public setter.""" ctx, light = _make_light_ctx() lights_cli.set_status_light(ctx, True) light.set_status_light_public.assert_awaited_once_with(True) light.set_status_light.assert_not_called() def test_light_set_led_level_uses_public() -> None: """Light set-led-level writes through the public setter.""" ctx, light = _make_light_ctx() lights_cli.set_led_level(ctx, 4) light.set_led_level_public.assert_awaited_once_with(4) light.set_led_level.assert_not_called() def test_light_set_sensitivity_uses_public() -> None: """Light set-sensitivity writes through the public setter.""" ctx, light = _make_light_ctx() lights_cli.set_sensitivity(ctx, 80) light.set_sensitivity_public.assert_awaited_once_with(80) light.set_sensitivity.assert_not_called() def test_light_set_duration_uses_public() -> None: """Light set-duration writes through the public setter.""" ctx, light = _make_light_ctx() lights_cli.set_duration(ctx, 60) light.set_duration_public.assert_awaited_once_with(timedelta(seconds=60)) light.set_duration.assert_not_called() def test_light_set_flood_light_uses_public() -> None: """Light set-flood-light writes through the public setter.""" ctx, light = _make_light_ctx() lights_cli.set_flood_light(ctx, False) light.set_flood_light_public.assert_awaited_once_with(False) light.set_flood_light.assert_not_called() def test_sensor_set_motion_uses_public() -> None: """Sensor set-motion writes through the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_motion(ctx, True) sensor.set_motion_status_public.assert_awaited_once_with(True) sensor.set_motion_status.assert_not_called() def test_sensor_set_temperature_uses_public() -> None: """Sensor set-temperature writes through the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_temperature(ctx, False) sensor.set_temperature_status_public.assert_awaited_once_with(False) sensor.set_temperature_status.assert_not_called() def test_sensor_set_humidity_uses_public() -> None: """Sensor set-humidity writes through the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_humidity(ctx, True) sensor.set_humidity_status_public.assert_awaited_once_with(True) sensor.set_humidity_status.assert_not_called() def test_sensor_set_light_uses_public() -> None: """Sensor set-light writes through the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_light(ctx, False) sensor.set_light_status_public.assert_awaited_once_with(False) sensor.set_light_status.assert_not_called() def test_sensor_set_motion_sensitivity_uses_public() -> None: """Sensor set-motion-sensitivity writes through the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_motion_sensitivity(ctx, 30) sensor.set_motion_sensitivity_public.assert_awaited_once_with(30) sensor.set_motion_sensitivity.assert_not_called() def test_sensor_set_status_light_stays_private() -> None: """The sensor status LED has no public-API field, so it stays on the private setter.""" ctx, sensor = _make_sensor_ctx() sensor.set_status_light = AsyncMock() sensors_cli.set_status_light(ctx, True) sensor.set_status_light.assert_awaited_once_with(True) def test_sensor_set_alarm_uses_public() -> None: """Sensor set-alarm writes through the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_alarm(ctx, True) sensor.set_alarm_public.assert_awaited_once_with(True) sensor.set_alarm_status.assert_not_called() def test_sensor_set_temperature_range_uses_public() -> None: """Sensor set-temperature-range writes both thresholds via the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_temperature_range(ctx, 5.0, 30.0) sensor.set_temperature_settings_public.assert_awaited_once_with( low_threshold=5.0, high_threshold=30.0 ) sensor.set_temperature_safe_range.assert_not_called() def test_sensor_set_humidity_range_uses_public() -> None: """Sensor set-humidity-range writes both thresholds via the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_humidity_range(ctx, 10.0, 80.0) sensor.set_humidity_settings_public.assert_awaited_once_with( low_threshold=10.0, high_threshold=80.0 ) sensor.set_humidity_safe_range.assert_not_called() def test_sensor_set_light_range_uses_public() -> None: """Sensor set-light-range writes both thresholds via the public setter.""" ctx, sensor = _make_sensor_ctx() sensors_cli.set_light_range(ctx, 2.0, 900.0) sensor.set_light_settings_public.assert_awaited_once_with( low_threshold=2.0, high_threshold=900.0 ) sensor.set_light_safe_range.assert_not_called() def _make_smart_detect_camera_ctx(): """Build a camera context double with smart-detect capabilities wired up.""" ctx, camera = _make_device_ctx(device_id="camera-1") camera.feature_flags.has_smart_detect = True camera.feature_flags.smart_detect_types = [ SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, ] camera.feature_flags.smart_detect_audio_types = [ SmartDetectAudioType.SMOKE, SmartDetectAudioType.CMONX, ] camera.smart_detect_settings.object_types = [SmartDetectObjectType.PERSON] camera.smart_detect_settings.audio_types = [SmartDetectAudioType.SMOKE] ctx.obj.protect.update_camera_public = AsyncMock() return ctx, camera def test_camera_smart_detects_uses_public() -> None: """Camera smart-detects patches the object types via the public API.""" ctx, camera = _make_smart_detect_camera_ctx() cameras_cli.smart_detects( ctx, [SmartDetectObjectType.VEHICLE], add=False, remove=False ) ctx.obj.protect.update_camera_public.assert_awaited_once_with( "camera-1", smart_detect_object_types=[SmartDetectObjectType.VEHICLE] ) camera.save_device.assert_not_called() def test_camera_smart_detects_add_uses_public() -> None: """``--add`` unions with the current types before the public write.""" ctx, _camera = _make_smart_detect_camera_ctx() cameras_cli.smart_detects( ctx, [SmartDetectObjectType.VEHICLE], add=True, remove=False ) ctx.obj.protect.update_camera_public.assert_awaited_once() _args, kwargs = ctx.obj.protect.update_camera_public.call_args assert set(kwargs["smart_detect_object_types"]) == { SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, } def test_camera_smart_detects_unsupported_type_does_not_write() -> None: """An unsupported detection type exits non-zero without a write.""" ctx, _camera = _make_smart_detect_camera_ctx() with pytest.raises(typer.Exit) as exc: cameras_cli.smart_detects( ctx, [SmartDetectObjectType.ANIMAL], add=False, remove=False ) assert exc.value.exit_code == 1 ctx.obj.protect.update_camera_public.assert_not_awaited() def test_camera_smart_audio_detects_uses_public() -> None: """Camera smart-audio-detects patches the audio types via the public API.""" ctx, camera = _make_smart_detect_camera_ctx() cameras_cli.smart_audio_detects( ctx, [SmartDetectAudioType.CMONX], add=False, remove=False ) ctx.obj.protect.update_camera_public.assert_awaited_once_with( "camera-1", smart_detect_audio_types=[SmartDetectAudioType.CMONX] ) camera.save_device.assert_not_called() def test_camera_smart_audio_detects_remove_uses_public() -> None: """``--remove`` subtracts from the current types before the public write.""" ctx, _camera = _make_smart_detect_camera_ctx() cameras_cli.smart_audio_detects( ctx, [SmartDetectAudioType.SMOKE], add=False, remove=True ) ctx.obj.protect.update_camera_public.assert_awaited_once_with( "camera-1", smart_detect_audio_types=[] ) def _make_named_device_ctx(model_class): """Build a context double whose device is a spec'd instance of ``model_class``.""" device = MagicMock(spec=model_class) device.id = "device-1" device.set_name = AsyncMock() if hasattr(model_class, "set_name_public"): device.set_name_public = AsyncMock() protect = MagicMock() protect.close_session = AsyncMock() protect.close_public_api_session = AsyncMock() ctx = MagicMock() ctx.obj.device = device ctx.obj.protect = protect return ctx, device @pytest.mark.parametrize("model_class", [Camera, Chime, Light, Sensor, Viewer]) def test_set_name_uses_public(model_class) -> None: """set-name writes through the public setter for every model that has one.""" ctx, device = _make_named_device_ctx(model_class) base_cli.set_name(ctx, "Kitchen") device.set_name_public.assert_awaited_once_with("Kitchen") device.set_name.assert_not_called() def test_set_name_aiport_stays_private() -> None: """AiPort has no public-API endpoint of its own, so it keeps the private setter.""" ctx, device = _make_named_device_ctx(AiPort) base_cli.set_name(ctx, "Kitchen") device.set_name.assert_awaited_once_with("Kitchen") device.set_name_public.assert_not_called() def test_set_name_nvr_stays_private() -> None: """The NVR has no public name setter, so it keeps the private setter.""" ctx, device = _make_named_device_ctx(NVR) base_cli.set_name(ctx, "Console") device.set_name.assert_awaited_once_with("Console") def test_set_name_clear_stays_private() -> None: """Clearing a name is not expressible on the public API, so it stays private.""" ctx, device = _make_named_device_ctx(Camera) base_cli.set_name(ctx, None) device.set_name.assert_awaited_once_with(None) device.set_name_public.assert_not_called() @pytest.mark.parametrize( ("command", "setter"), [ (sensors_cli.set_temperature_range, "set_temperature_settings_public"), (sensors_cli.set_humidity_range, "set_humidity_settings_public"), (sensors_cli.set_light_range, "set_light_settings_public"), ], ) def test_sensor_set_range_rejects_inverted_bounds(command, setter) -> None: """An inverted safe range exits non-zero without a write.""" ctx, sensor = _make_sensor_ctx() with pytest.raises(typer.Exit) as exc: command(ctx, 30.0, 5.0) assert exc.value.exit_code == 1 getattr(sensor, setter).assert_not_awaited() uilibs-uiprotect-317a52c/tests/test_dunder_main.py000066400000000000000000000027271524637705700224340ustar00rootroot00000000000000import subprocess import sys import pytest # uiprotect.__main__ imports uiprotect.cli, which pulls the whole CLI stack # (typer + rich) from the optional `cli` extra; skip rather than fail at # collection when any part is absent. pytest.importorskip("uiprotect.cli") import uiprotect.__main__ as dunder_main def test_can_run_as_python_module(): """Run the CLI as a Python module.""" result = subprocess.run( [sys.executable, "-m", "uiprotect", "--help"], # S603,S607 check=True, capture_output=True, ) assert result.returncode == 0 assert b"uiprotect [OPTIONS]" in result.stdout def test_start_loads_env_file_when_present(monkeypatch, tmp_path): """`start()` passes the cwd `.env` to load_dotenv when it exists.""" seen: dict[str, object] = {} monkeypatch.setattr(dunder_main, "load_dotenv", lambda **kw: seen.update(kw)) monkeypatch.setattr(dunder_main, "app", lambda: None) monkeypatch.chdir(tmp_path) (tmp_path / ".env").write_text("") dunder_main.start() assert seen["dotenv_path"] == tmp_path / ".env" def test_start_without_env_file(monkeypatch, tmp_path): """`start()` falls back to a bare load_dotenv when no cwd `.env` exists.""" calls: list[dict[str, object]] = [] monkeypatch.setattr(dunder_main, "load_dotenv", lambda **kw: calls.append(kw)) monkeypatch.setattr(dunder_main, "app", lambda: None) monkeypatch.chdir(tmp_path) dunder_main.start() assert calls == [{}] uilibs-uiprotect-317a52c/tests/test_events_adapter.py000066400000000000000000000124631524637705700231510ustar00rootroot00000000000000"""Adapter tests for ``ProtectApiClient._adapt_events_ws_message``.""" from __future__ import annotations import time from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, cast from uiprotect.api import ProtectApiClient from uiprotect.data.public_event import PublicEvent from uiprotect.data.types import EventType from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.events.dispatcher import EventDispatcher if TYPE_CHECKING: import pytest def _make_client() -> ProtectApiClient: return ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) def _attach( api: ProtectApiClient, ) -> tuple[EventDispatcher, list[tuple[Any, Any, Any]]]: dispatcher = EventDispatcher(api) api._event_dispatcher = dispatcher received: list[tuple[Any, Any, Any]] = [] dispatcher.dispatch = ( # type: ignore[method-assign] lambda action, new, old: received.append((action, new, old)) ) return dispatcher, received def _motion(api: ProtectApiClient, event_id: str = "m1") -> PublicEvent: return PublicEvent( api=api, id=event_id, type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", ) def test_adapter_no_dispatcher_is_noop() -> None: api = _make_client() msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id="u", changed_data={}, new_obj=_motion(api), ) api._adapt_events_ws_message(msg) def test_adapter_add_without_new_obj_logs_debug_and_drops( caplog: pytest.LogCaptureFixture, ) -> None: api = _make_client() _, received = _attach(api) msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id="u", changed_data={}, new_obj=None, ) with caplog.at_level("DEBUG", logger="uiprotect.api"): api._adapt_events_ws_message(msg) assert received == [] # A missing merged Event is a benign cache-miss/desync, logged at DEBUG. assert any("without merged Event obj" in r.message for r in caplog.records) assert not any(r.levelname == "WARNING" for r in caplog.records) def test_adapter_update_with_wrong_obj_type_warns_and_drops( caplog: pytest.LogCaptureFixture, ) -> None: api = _make_client() _, received = _attach(api) msg = WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id="u", changed_data={}, new_obj=cast("Any", object()), ) with caplog.at_level("WARNING", logger="uiprotect.api"): api._adapt_events_ws_message(msg) assert received == [] assert any("merged obj is not an Event" in r.message for r in caplog.records) def test_adapter_add_forwards_to_dispatch_with_old_obj() -> None: api = _make_client() _, received = _attach(api) new = _motion(api) old = _motion(api) msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id="u", changed_data={}, new_obj=new, old_obj=old, ) api._adapt_events_ws_message(msg) assert received == [(WSAction.ADD, new, old)] def test_adapter_add_forwards_none_old_when_absent() -> None: api = _make_client() _, received = _attach(api) new = _motion(api) msg = WSSubscriptionMessage( action=WSAction.ADD, new_update_id="u", changed_data={}, new_obj=new, ) api._adapt_events_ws_message(msg) assert received == [(WSAction.ADD, new, None)] def test_adapter_remove_unknown_event_logs_once_within_throttle( caplog: pytest.LogCaptureFixture, ) -> None: api = _make_client() _, received = _attach(api) msg = WSSubscriptionMessage( action=WSAction.REMOVE, new_update_id="u", changed_data={"id": "ghost"}, old_obj=None, ) with caplog.at_level("INFO", logger="uiprotect.api"): api._adapt_events_ws_message(msg) first_ts = api._events_remove_unknown_last_log api._adapt_events_ws_message(msg) matching = [r for r in caplog.records if "remove for unknown event" in r.message] assert len(matching) == 1 assert received == [] assert api._events_remove_unknown_last_log == first_ts def test_adapter_remove_unknown_event_relogs_after_throttle( caplog: pytest.LogCaptureFixture, ) -> None: api = _make_client() _attach(api) msg = WSSubscriptionMessage( action=WSAction.REMOVE, new_update_id="u", changed_data={"id": "ghost"}, old_obj=None, ) with caplog.at_level("INFO", logger="uiprotect.api"): api._adapt_events_ws_message(msg) api._events_remove_unknown_last_log = time.monotonic() - 120.0 api._adapt_events_ws_message(msg) matching = [r for r in caplog.records if "remove for unknown event" in r.message] assert len(matching) == 2 def test_adapter_remove_known_event_forwards_old_obj_to_dispatch() -> None: api = _make_client() _, received = _attach(api) old = _motion(api, event_id="kept") msg = WSSubscriptionMessage( action=WSAction.REMOVE, new_update_id="u", changed_data={"id": "kept"}, old_obj=old, ) api._adapt_events_ws_message(msg) assert received == [(WSAction.REMOVE, None, old)] uilibs-uiprotect-317a52c/tests/test_events_dispatcher.py000066400000000000000000000465321524637705700236630ustar00rootroot00000000000000"""Dispatcher tests for the public events contract (single-store model).""" from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any import orjson import pytest from tests.conftest import SAMPLE_DATA_DIRECTORY from uiprotect.api import ProtectApiClient from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.public_event import PublicEvent from uiprotect.data.types import EventType, ModelType from uiprotect.data.websocket import WSAction, WSSubscriptionMessage from uiprotect.events import EventChange, ProtectEvent from uiprotect.events.dispatcher import EventDispatcher _FIXTURES = SAMPLE_DATA_DIRECTORY / "events_ws_public" def _load(name: str) -> Any: return orjson.loads((_FIXTURES / name).read_bytes()) def _payload_to_event(api: ProtectApiClient, payload: dict[str, Any]) -> PublicEvent: return PublicEvent.from_unifi_dict(api=api, **payload) def _make_client() -> ProtectApiClient: return ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) @pytest.fixture def api() -> ProtectApiClient: client = _make_client() client._public_bootstrap = PublicBootstrap() return client @pytest.fixture def dispatcher(api: ProtectApiClient) -> EventDispatcher: return EventDispatcher(api) def _collect( dispatcher: EventDispatcher, ) -> list[tuple[ProtectEvent, EventChange]]: received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher.add_subscriber(lambda e, c: received.append((e, c))) return received def _store(api: ProtectApiClient, event: PublicEvent) -> None: """Place ``event`` in the single store, mirroring the WS merge path.""" api.public_bootstrap.events[event.id] = event def test_dispatch_drops_non_device_event( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) event = PublicEvent( api=api, id="x", type=EventType.REBOOT, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", ) dispatcher.dispatch(WSAction.ADD, event, None) assert received == [] def test_dispatch_drops_missing_device_id( api: ProtectApiClient, dispatcher: EventDispatcher, caplog: pytest.LogCaptureFixture, ) -> None: received = _collect(dispatcher) event = PublicEvent( api=api, id="y", type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), ) with caplog.at_level("WARNING", logger="uiprotect.events.dispatcher"): dispatcher.dispatch(WSAction.ADD, event, None) assert received == [] assert any("missing required 'device'" in r.message for r in caplog.records) def test_dispatch_started_then_ended_through_update( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) start = datetime(2026, 1, 1, tzinfo=UTC) add = PublicEvent( api=api, id="m1", type=EventType.MOTION, start=start, device_id="cam-1", ) _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) assert [e.id for e in dispatcher.active_events()] == ["m1"] old_snapshot = add.model_copy() upd = add upd.end = start + timedelta(seconds=5) dispatcher.dispatch(WSAction.UPDATE, upd, old_snapshot) assert [c for _, c in received] == [EventChange.STARTED, EventChange.ENDED] assert dispatcher.active_events() == [] def test_dispatch_close_window_nfc_add_with_end( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) payload = _load("nfc_add_with_end.json")["item"] event = _payload_to_event(api, payload) _store(api, event) dispatcher.dispatch(WSAction.ADD, event, None) assert [c for _, c in received] == [EventChange.STARTED, EventChange.ENDED] assert received[0][0].id == received[1][0].id assert dispatcher.active_events() == [] def test_dispatch_close_window_light_motion( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) payload = _load("light_motion_add.json")["item"] event = _payload_to_event(api, payload) _store(api, event) dispatcher.dispatch(WSAction.ADD, event, None) assert [c for _, c in received] == [EventChange.STARTED, EventChange.ENDED] pe = received[0][0] assert pe.end == pe.start # The dispatcher closes the instantaneous event in the store so it no # longer shows as active and a replay is suppressed. assert dispatcher.active_events() == [] assert event.end is not None def test_dispatch_close_window_add_with_end_replay_suppressed( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) payload = _load("nfc_add_with_end.json")["item"] first = _payload_to_event(api, payload) _store(api, first) dispatcher.dispatch(WSAction.ADD, first, None) # Server retransmits the same ADD-with-end; the snapshot now carries the # terminal ``end`` so the chokepoint suppresses both STARTED and ENDED. snapshot = first.model_copy() replay = _payload_to_event(api, payload) _store(api, replay) dispatcher.dispatch(WSAction.ADD, replay, snapshot) changes = [c for _, c in received] assert changes.count(EventChange.STARTED) == 1 assert changes.count(EventChange.ENDED) == 1 def test_dispatch_smartdetect_lifecycle_idempotent_ended( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) payloads = _load("smartdetect_lifecycle.json") merged: dict[str, Any] = {} prev: PublicEvent | None = None for frame in payloads: merged.update(frame["item"]) event = _payload_to_event(api, dict(merged)) snapshot = prev.model_copy() if prev is not None else None _store(api, event) dispatcher.dispatch(WSAction(frame["type"]), event, snapshot) prev = event changes = [c for _, c in received] assert changes.count(EventChange.STARTED) == 1 assert changes.count(EventChange.ENDED) == 1 assert dispatcher.active_events() == [] def test_dispatch_update_to_end_retransmit_suppressed( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) start = datetime(2026, 1, 1, tzinfo=UTC) add = PublicEvent( api=api, id="r", type=EventType.MOTION, start=start, device_id="cam-1" ) _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) open_snapshot = add.model_copy() add.end = start + timedelta(seconds=5) dispatcher.dispatch(WSAction.UPDATE, add, open_snapshot) # Retransmitted close: old snapshot already terminal -> suppressed. terminal_snapshot = add.model_copy() dispatcher.dispatch(WSAction.UPDATE, add, terminal_snapshot) changes = [c for _, c in received] assert changes.count(EventChange.ENDED) == 1 def test_dispatch_update_still_open_is_updated( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) start = datetime(2026, 1, 1, tzinfo=UTC) add = PublicEvent( api=api, id="u", type=EventType.MOTION, start=start, device_id="cam-1" ) _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) snapshot = add.model_copy() dispatcher.dispatch(WSAction.UPDATE, add, snapshot) assert [c for _, c in received] == [EventChange.STARTED, EventChange.UPDATED] def test_dispatch_remove_terminates( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) start = datetime(2026, 1, 1, tzinfo=UTC) add = PublicEvent( api=api, id="r1", type=EventType.MOTION, start=start, device_id="cam-1", ) _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) snapshot = add.model_copy() api.public_bootstrap.events.pop("r1", None) dispatcher.dispatch(WSAction.REMOVE, None, snapshot) assert [c for _, c in received] == [EventChange.STARTED, EventChange.REMOVED] assert dispatcher.active_events() == [] def test_dispatch_remove_already_terminal_suppressed( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) start = datetime(2026, 1, 1, tzinfo=UTC) ended = PublicEvent( api=api, id="r2", type=EventType.MOTION, start=start, end=start + timedelta(seconds=1), device_id="cam-1", ) dispatcher.dispatch(WSAction.REMOVE, None, ended) assert received == [] def test_subscriber_exception_isolation( api: ProtectApiClient, dispatcher: EventDispatcher, caplog: pytest.LogCaptureFixture, ) -> None: def bad(_e: ProtectEvent, _c: EventChange) -> None: raise RuntimeError("boom") good: list[tuple[ProtectEvent, EventChange]] = [] dispatcher.add_subscriber(bad) dispatcher.add_subscriber(lambda e, c: good.append((e, c))) add = PublicEvent( api=api, id="m", type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", ) with caplog.at_level("ERROR", logger="uiprotect.events.dispatcher"): dispatcher.dispatch(WSAction.ADD, add, None) assert len(good) == 1 def test_active_events_filter_by_device( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: start = datetime(2026, 1, 1, tzinfo=UTC) for idx, dev in enumerate(["cam-a", "cam-b"]): event = PublicEvent( api=api, id=f"e-{idx}", type=EventType.MOTION, start=start, device_id=dev, ) _store(api, event) assert {e.device_id for e in dispatcher.active_events()} == {"cam-a", "cam-b"} assert [e.device_id for e in dispatcher.active_events("cam-a")] == ["cam-a"] def test_active_events_skips_ended_and_other( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: start = datetime(2026, 1, 1, tzinfo=UTC) open_a = PublicEvent( api=api, id="open-a", type=EventType.MOTION, start=start, device_id="cam-a" ) open_b = PublicEvent( api=api, id="open-b", type=EventType.RING, start=start, device_id="cam-b" ) ended = PublicEvent( api=api, id="ended", type=EventType.MOTION, start=start, end=start + timedelta(seconds=1), device_id="cam-a", ) other = PublicEvent( api=api, id="other", type=EventType.REBOOT, start=start, device_id="cam-a" ) for ev in (open_a, open_b, ended, other): _store(api, ev) assert {e.id for e in dispatcher.active_events()} == {"open-a", "open-b"} class _MacStub: """Minimal device stand-in carrying an ``id`` and ``mac``.""" def __init__(self, obj_id: str, mac: str) -> None: self.id = obj_id self.mac = mac def test_dispatch_resolves_device_mac_when_device_known( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) api.public_bootstrap.cameras["cam-1"] = _MacStub("cam-1", "aabbccddeeff") # type: ignore[assignment] add = PublicEvent( api=api, id="m1", type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", ) _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) assert received[0][0].device_mac == "aabbccddeeff" def test_dispatch_device_mac_none_when_device_absent( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) add = PublicEvent( api=api, id="m2", type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-missing", ) _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) assert received[0][0].device_mac is None def test_device_mac_none_when_device_id_missing(dispatcher: EventDispatcher) -> None: assert dispatcher._device_mac(None) is None def test_dispatch_subject_none_is_noop( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) dispatcher.dispatch(WSAction.REMOVE, None, None) assert received == [] def test_remove_subscriber_is_idempotent(dispatcher: EventDispatcher) -> None: def cb(_e: ProtectEvent, _c: EventChange) -> None: return None dispatcher.add_subscriber(cb) dispatcher.remove_subscriber(cb) dispatcher.remove_subscriber(cb) # must not raise def test_unsubscribe_during_fan_out_does_not_skip( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: seen: list[str] = [] def first(_e: ProtectEvent, _c: EventChange) -> None: seen.append("first") dispatcher.remove_subscriber(second) # mutate the list mid-delivery def second(_e: ProtectEvent, _c: EventChange) -> None: seen.append("second") dispatcher.add_subscriber(first) dispatcher.add_subscriber(second) add = PublicEvent( api=api, id="fo1", type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-x", ) _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) assert seen == ["first", "second"] def test_active_events_without_dispatcher_returns_empty() -> None: api = _make_client() assert api._event_dispatcher is None assert api.active_events() == [] assert api.active_events(device_id="cam-1") == [] def test_active_events_derives_before_subscribe( api: ProtectApiClient, ) -> None: assert api._event_dispatcher is None start = datetime(2026, 1, 1, tzinfo=UTC) event = PublicEvent( api=api, id="pre", type=EventType.MOTION, start=start, device_id="cam-1" ) _store(api, event) assert [e.id for e in api.active_events()] == ["pre"] def test_active_events_with_dispatcher_delegates( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: api._event_dispatcher = dispatcher start = datetime(2026, 1, 1, tzinfo=UTC) for idx, dev in enumerate(["cam-a", "cam-b"]): event = PublicEvent( api=api, id=f"api-e-{idx}", type=EventType.MOTION, start=start, device_id=dev, ) _store(api, event) assert {e.device_id for e in api.active_events()} == {"cam-a", "cam-b"} assert [e.device_id for e in api.active_events(device_id="cam-a")] == ["cam-a"] def test_unsubscribe_events_without_dispatcher_is_noop() -> None: api = _make_client() assert api._event_dispatcher is None # Should silently no-op rather than raise. api._unsubscribe_events(lambda e, c: None) @pytest.mark.asyncio async def test_subscribe_events_requires_public_bootstrap() -> None: api = _make_client() with pytest.raises(RuntimeError, match="update_public"): api.subscribe_events(lambda e, c: None) @pytest.mark.asyncio async def test_subscribe_events_reference_counted( monkeypatch: pytest.MonkeyPatch, ) -> None: api = _make_client() api._public_bootstrap = PublicBootstrap() starts: list[None] = [] stops: list[None] = [] class _WS: def start(self) -> None: starts.append(None) def stop(self) -> None: stops.append(None) ws = _WS() monkeypatch.setattr(api, "_get_events_websocket", lambda: ws) unsub_a = api.subscribe_events(lambda e, c: None) unsub_b = api.subscribe_events(lambda e, c: None) assert len(starts) == 1 assert api._event_dispatcher is not None assert api._event_dispatcher.subscriber_count == 2 unsub_a() assert stops == [] assert api._event_dispatcher.subscriber_count == 1 unsub_b() assert len(stops) == 1 assert api._event_dispatcher.subscriber_count == 0 @pytest.mark.asyncio async def test_start_ttl_sweep_idempotent_while_running( dispatcher: EventDispatcher, ) -> None: dispatcher.start_ttl_sweep() first = dispatcher._sweep_task assert first is not None dispatcher.start_ttl_sweep() assert dispatcher._sweep_task is first dispatcher.stop_ttl_sweep() def _feed_ws( api: ProtectApiClient, dispatcher: EventDispatcher, frame: dict[str, Any] ) -> None: """Drive a raw events-WS frame through store + adapter, as the WS path does.""" api._event_dispatcher = dispatcher new_event, old_event, _ = api.public_bootstrap.process_events_ws_message(api, frame) msg = WSSubscriptionMessage( action=WSAction(frame["type"]), new_update_id=frame["item"]["id"], changed_data=frame["item"], new_obj=new_event, old_obj=old_event, ) api._adapt_events_ws_message(msg) def test_born_closed_update_dispatches_started_and_ended( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) frame = _load("package_born_closed_update.json") _feed_ws(api, dispatcher, frame) assert [c for _, c in received] == [EventChange.STARTED, EventChange.ENDED] assert received[0][0].id == frame["item"]["id"] # Synthesized into the store as a closed event; not surfaced as active. assert dispatcher.active_events() == [] def test_born_closed_update_retransmit_suppressed( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) frame = _load("package_born_closed_update.json") _feed_ws(api, dispatcher, frame) _feed_ws(api, dispatcher, frame) changes = [c for _, c in received] assert changes.count(EventChange.STARTED) == 1 assert changes.count(EventChange.ENDED) == 1 def test_open_unknown_update_still_drops( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: received = _collect(dispatcher) frame = _load("open_unknown_update.json") _feed_ws(api, dispatcher, frame) assert received == [] assert "unseen-open" not in api.public_bootstrap.events def test_born_closed_synthesis_skipped_when_construction_fails( api: ProtectApiClient, ) -> None: frame = _load("born_closed_update_bad_start.json") new_event, old_event, _ = api.public_bootstrap.process_events_ws_message(api, frame) assert new_event is None assert old_event is None assert "bad-event" not in api.public_bootstrap.events def test_born_closed_synthesis_scoped_to_events_only( api: ProtectApiClient, ) -> None: """A closed-looking update for a non-event model is never synthesized.""" item = { "id": "cam-unseen", "modelKey": "camera", "type": "smartDetectZone", "start": 1735690000000, "end": 1735690006000, "device": "aabbccddeeff00112233aabb", } synthesized = api.public_bootstrap._synthesize_born_closed_event( api, item, ModelType.CAMERA ) assert synthesized is None def test_merge_precedes_fan_out_invariant( api: ProtectApiClient, dispatcher: EventDispatcher ) -> None: """During a callback the event is already present in the store.""" seen: list[bool] = [] def cb(event: ProtectEvent, _change: EventChange) -> None: seen.append(event.id in api.public_bootstrap.events) dispatcher.add_subscriber(cb) add = PublicEvent( api=api, id="inv", type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", ) # The WS path stores before fan-out; emulate that ordering here. _store(api, add) dispatcher.dispatch(WSAction.ADD, add, None) assert seen == [True] uilibs-uiprotect-317a52c/tests/test_events_enricher.py000066400000000000000000000114071524637705700233250ustar00rootroot00000000000000"""Enricher tests for the public events contract (pure read over the store).""" from __future__ import annotations from datetime import UTC, datetime from uiprotect.api import ProtectApiClient from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.public_devices import PublicUlpUser from uiprotect.data.public_event import ( PublicEvent, PublicEventMetadata, PublicFingerprintMetadata, PublicNfcMetadata, ) from uiprotect.data.types import EventType, ModelType, UlpUserStatus from uiprotect.events import UlpUserIdentity, UnknownIdentity from uiprotect.events.enricher import EventEnricher def _make_client() -> ProtectApiClient: client = ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) client._public_bootstrap = PublicBootstrap() return client def _make_user(api: ProtectApiClient, ulp_id: str) -> PublicUlpUser: return PublicUlpUser( api=api, id=ulp_id, model=ModelType.ULP_USER, first_name="A", last_name="B", full_name="A B", status=UlpUserStatus.ACTIVE, ) def _make_nfc_event( api: ProtectApiClient, *, metadata: PublicNfcMetadata | None ) -> PublicEvent: return PublicEvent( api=api, id="evt-nfc", type=EventType.NFC_CARD_SCANNED, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", metadata=PublicEventMetadata(api=api, nfc=metadata) if metadata is not None else None, ) def _make_fp_event( api: ProtectApiClient, *, metadata: PublicFingerprintMetadata | None ) -> PublicEvent: return PublicEvent( api=api, id="evt-fp", type=EventType.FINGERPRINT_IDENTIFIED, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", metadata=( PublicEventMetadata(api=api, fingerprint=metadata) if metadata is not None else None ), ) def test_enrich_returns_none_for_non_credential_event() -> None: api = _make_client() enricher = EventEnricher(api) motion = PublicEvent( api=api, id="m", type=EventType.MOTION, start=datetime(2026, 1, 1, tzinfo=UTC), device_id="cam-1", ) assert enricher.enrich(motion) is None def test_enrich_nfc_no_metadata() -> None: api = _make_client() enricher = EventEnricher(api) result = enricher.enrich(_make_nfc_event(api, metadata=None)) assert result == UnknownIdentity(reason="no_metadata") def test_enrich_nfc_ulp_id_null() -> None: api = _make_client() enricher = EventEnricher(api) md = PublicNfcMetadata(api=api, ulp_id=None) result = enricher.enrich(_make_nfc_event(api, metadata=md)) assert result == UnknownIdentity(reason="ulp_id_null") def test_enrich_nfc_cache_miss() -> None: api = _make_client() enricher = EventEnricher(api) md = PublicNfcMetadata(api=api, ulp_id="ulp-abc") result = enricher.enrich(_make_nfc_event(api, metadata=md)) assert result == UnknownIdentity(reason="ulp_user_not_cached") def test_enrich_nfc_cache_hit() -> None: api = _make_client() user = _make_user(api, "ulp-abc") api.public_bootstrap.ulp_users["ulp-abc"] = user enricher = EventEnricher(api) md = PublicNfcMetadata(api=api, ulp_id="ulp-abc") result = enricher.enrich(_make_nfc_event(api, metadata=md)) assert isinstance(result, UlpUserIdentity) assert result.user is user assert result.ulp_id == "ulp-abc" def test_enrich_fingerprint_cache_hit() -> None: api = _make_client() user = _make_user(api, "ulp-fp") api.public_bootstrap.ulp_users["ulp-fp"] = user enricher = EventEnricher(api) md = PublicFingerprintMetadata(api=api, ulp_id="ulp-fp") result = enricher.enrich(_make_fp_event(api, metadata=md)) assert isinstance(result, UlpUserIdentity) assert result.user is user def test_enrich_fingerprint_no_metadata() -> None: api = _make_client() enricher = EventEnricher(api) result = enricher.enrich(_make_fp_event(api, metadata=None)) assert result == UnknownIdentity(reason="no_metadata") def test_enrich_fingerprint_ulp_id_null() -> None: api = _make_client() enricher = EventEnricher(api) md = PublicFingerprintMetadata(api=api, ulp_id=None) result = enricher.enrich(_make_fp_event(api, metadata=md)) assert result == UnknownIdentity(reason="ulp_id_null") def test_enrich_fingerprint_cache_miss() -> None: api = _make_client() enricher = EventEnricher(api) md = PublicFingerprintMetadata(api=api, ulp_id="ulp-absent") result = enricher.enrich(_make_fp_event(api, metadata=md)) assert result == UnknownIdentity(reason="ulp_user_not_cached") uilibs-uiprotect-317a52c/tests/test_events_lifecycle_callbacks.py000066400000000000000000000054711524637705700254700ustar00rootroot00000000000000"""Lifecycle/task callback coverage for events plumbing.""" from __future__ import annotations import asyncio import contextlib import logging from datetime import timedelta from typing import Any import pytest from uiprotect.api import ProtectApiClient from uiprotect.events.dispatcher import ( EVENTS_TTL_SWEEP_INTERVAL, EventDispatcher, ) def _make_client() -> ProtectApiClient: return ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) def test_on_sweep_task_done_cancelled_is_noop( caplog: pytest.LogCaptureFixture, ) -> None: class _Stub: def cancelled(self) -> bool: return True def exception(self) -> BaseException | None: # pragma: no cover - guard raise AssertionError("should not be consulted") with caplog.at_level(logging.ERROR, logger="uiprotect.events.dispatcher"): EventDispatcher._on_sweep_task_done(_Stub()) # type: ignore[arg-type] assert not any("TTL sweep" in r.message for r in caplog.records) def test_on_sweep_task_done_exception_is_logged( caplog: pytest.LogCaptureFixture, ) -> None: boom = RuntimeError("boom") class _Stub: def cancelled(self) -> bool: return False def exception(self) -> BaseException | None: return boom with caplog.at_level(logging.ERROR, logger="uiprotect.events.dispatcher"): EventDispatcher._on_sweep_task_done(_Stub()) # type: ignore[arg-type] assert any( "TTL sweep loop terminated unexpectedly" in r.message for r in caplog.records ) @pytest.mark.asyncio async def test_ttl_sweep_loop_swallows_sweep_exception( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: api = _make_client() dispatcher = EventDispatcher(api) monkeypatch.setattr( "uiprotect.events.dispatcher.EVENTS_TTL_SWEEP_INTERVAL", timedelta(seconds=0), ) calls: list[int] = [] second_iteration = asyncio.Event() def fake_sweep() -> int: calls.append(1) if len(calls) == 1: raise RuntimeError("boom") second_iteration.set() return 0 monkeypatch.setattr(dispatcher, "sweep_stale", fake_sweep) with caplog.at_level(logging.ERROR, logger="uiprotect.events.dispatcher"): task: Any = asyncio.create_task(dispatcher._ttl_sweep_loop()) try: await asyncio.wait_for(second_iteration.wait(), timeout=1.0) finally: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task assert len(calls) >= 2 assert any("TTL sweep iteration failed" in r.message for r in caplog.records) assert EVENTS_TTL_SWEEP_INTERVAL is not None uilibs-uiprotect-317a52c/tests/test_events_mapping.py000066400000000000000000000121321524637705700231550ustar00rootroot00000000000000"""Phase 2 mapping tests for the public events contract.""" from __future__ import annotations from datetime import UTC, datetime from typing import Any import orjson import pytest from tests.conftest import SAMPLE_DATA_DIRECTORY from uiprotect.data.public_event import PublicEvent, PublicNfcMetadata from uiprotect.data.types import EventType, SensorAlarmType, SmartDetectObjectType from uiprotect.events import EVENT_TYPE_TO_CHANNEL, ProtectEventChannel from uiprotect.events._mapping import event_to_protect_event _FIXTURES = SAMPLE_DATA_DIRECTORY / "events_ws_public" def _load(name: str) -> Any: return orjson.loads((_FIXTURES / name).read_bytes()) def _event_from_payload(item: dict[str, Any]) -> PublicEvent: return PublicEvent.from_unifi_dict(**item) def test_nfc_metadata_optionality_and_ulp_id() -> None: md = PublicNfcMetadata.from_unifi_dict(ulpId="22222222-2222-2222-2222-222222222222") assert md.ulp_id == "22222222-2222-2222-2222-222222222222" def test_event_device_id_remap_from_public_payload() -> None: payload = _load("nfc_add_with_end.json")["item"] event = _event_from_payload(payload) assert event.device_id == "aabbccddeeff00112233aabb" def test_map_nfc_event_to_access_channel() -> None: payload = _load("nfc_add_with_end.json")["item"] event = _event_from_payload(payload) channel = EVENT_TYPE_TO_CHANNEL[event.type] assert channel is ProtectEventChannel.ACCESS out = event_to_protect_event(event, channel, identity=None) assert out.id == event.id assert out.device_id == event.device_id assert out.type is EventType.NFC_CARD_SCANNED assert out.end is not None assert out.smart_detect_types == () assert out.raw is event def test_map_light_motion_with_end_override() -> None: payload = _load("light_motion_add.json")["item"] event = _event_from_payload(payload) channel = EVENT_TYPE_TO_CHANNEL[event.type] assert channel is ProtectEventChannel.DETECTION end_override = datetime(2026, 1, 1, tzinfo=UTC) out = event_to_protect_event( event, channel, identity=None, end_override=end_override ) assert out.end == end_override def test_map_smart_detect_carries_smart_types() -> None: payloads = _load("smartdetect_lifecycle.json") event = _event_from_payload(payloads[0]["item"]) channel = EVENT_TYPE_TO_CHANNEL[event.type] assert channel is ProtectEventChannel.DETECTION out = event_to_protect_event(event, channel, identity=None) assert SmartDetectObjectType.PERSON in out.smart_detect_types assert isinstance(out.smart_detect_types, tuple) def _sensor_alarm_event(alarm_text: str) -> Any: return _event_from_payload( { "id": "11111111-1111-1111-1111-1111111111aa", "modelKey": "event", "type": "sensorAlarm", "start": 1735689900000, "device": "aabbccddeeff00112233aabb", "metadata": {"alarmType": {"text": alarm_text}}, } ) def test_map_sensor_alarm_carries_alarm_type() -> None: """``sensorAlarm`` exposes the collapsed ``metadata.alarmType.text`` as the enum.""" event = _sensor_alarm_event("glassBreak") out = event_to_protect_event( event, EVENT_TYPE_TO_CHANNEL[event.type], identity=None ) assert out.alarm_type is SensorAlarmType.GLASS_BREAK def test_map_sensor_alarm_unknown_value_coerces() -> None: """An alarm sound absent from the enum coerces to ``UNKNOWN`` rather than raising.""" event = _sensor_alarm_event("somethingNew") out = event_to_protect_event( event, EVENT_TYPE_TO_CHANNEL[event.type], identity=None ) assert out.alarm_type is SensorAlarmType.UNKNOWN def test_event_metadata_alarm_type_round_trips() -> None: """The collapsed ``alarmType`` enum re-wraps to ``{"text": ...}`` on serialisation.""" event = _sensor_alarm_event("glassBreak") assert event.metadata is not None assert event.metadata.alarm_type is SensorAlarmType.GLASS_BREAK assert event.unifi_dict()["metadata"]["alarmType"] == {"text": "glassBreak"} def test_map_event_without_alarm_type_is_none() -> None: """Non-alarm events leave ``alarm_type`` unset.""" payload = _load("light_motion_add.json")["item"] event = _event_from_payload(payload) out = event_to_protect_event( event, EVENT_TYPE_TO_CHANNEL[event.type], identity=None ) assert out.alarm_type is None def test_fingerprint_payload_with_ulpid_null() -> None: payload = _load("fingerprint_add_ulpid_null.json")["item"] event = _event_from_payload(payload) assert event.metadata is not None assert event.metadata.fingerprint is not None assert event.metadata.fingerprint.ulp_id is None def test_map_raises_when_device_id_missing() -> None: event = _event_from_payload( { "id": "no-device", "modelKey": "event", "type": "motion", "start": 1735689700000, } ) assert event.device_id is None channel = EVENT_TYPE_TO_CHANNEL[event.type] with pytest.raises(ValueError, match="device_id must be present"): event_to_protect_event(event, channel, identity=None) uilibs-uiprotect-317a52c/tests/test_events_reconnect.py000066400000000000000000000113051524637705700235030ustar00rootroot00000000000000"""Reconnect-sweep tests for the public events contract (single store).""" from __future__ import annotations import time from collections import OrderedDict from datetime import UTC, datetime, timedelta import pytest from uiprotect.api import ProtectApiClient from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.public_event import PublicEvent from uiprotect.data.types import EventType from uiprotect.data.websocket import WSAction from uiprotect.events import EventChange, ProtectEvent from uiprotect.events.dispatcher import EventDispatcher from uiprotect.websocket import WebsocketState def _make_client() -> ProtectApiClient: return ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) def _store_started( api: ProtectApiClient, *, age: timedelta, event_id: str | None = None ) -> str: start = datetime.now(tz=UTC) - age eid = event_id or f"e-{int(start.timestamp() * 1000)}" ev = PublicEvent( api=api, id=eid, type=EventType.MOTION, start=start, device_id="cam-1", ) api.public_bootstrap.events[eid] = ev return eid def _wire(api: ProtectApiClient) -> list[tuple[ProtectEvent, EventChange]]: api._public_bootstrap = PublicBootstrap() received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher = EventDispatcher(api) dispatcher.add_subscriber(lambda e, c: received.append((e, c))) api._event_dispatcher = dispatcher # Mark devices WS already connected and suppress the resync (debounced) # so only the sweep runs in these unit tests. api._devices_ws_has_been_connected = True api._last_public_resync = time.monotonic() return received @pytest.mark.asyncio async def test_reconnect_flushes_stale_entries() -> None: api = _make_client() received = _wire(api) _store_started(api, age=timedelta(hours=2), event_id="stale") fresh = _store_started(api, age=timedelta(seconds=30), event_id="fresh") api._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert [c for _, c in received] == [EventChange.ENDED] # The 2h entry is now closed in the store; the 30s entry survives open. assert api.public_bootstrap.events["stale"].end is not None assert api.public_bootstrap.events[fresh].end is None assert [e.id for e in api._event_dispatcher.active_events()] == ["fresh"] @pytest.mark.asyncio async def test_reconnect_swept_entry_not_refired_on_close_retransmit() -> None: api = _make_client() received = _wire(api) _store_started(api, age=timedelta(hours=2), event_id="stale") api._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert [c for _, c in received] == [EventChange.ENDED] # A later real close retransmit for the same id: the stored event already # carries ``end`` (sweep marked it), so the chokepoint suppresses it. received.clear() stored = api.public_bootstrap.events["stale"] snapshot = stored.model_copy() # already terminal api._event_dispatcher.dispatch(WSAction.UPDATE, stored, snapshot) assert received == [] @pytest.mark.asyncio async def test_first_connect_is_not_treated_as_reconnect() -> None: api = _make_client() api._public_bootstrap = PublicBootstrap() received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher = EventDispatcher(api) dispatcher.add_subscriber(lambda e, c: received.append((e, c))) api._event_dispatcher = dispatcher api.public_bootstrap.events = OrderedDict() _store_started(api, age=timedelta(hours=2), event_id="stale") # First connect: not a reconnect, so no sweep. api._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert api._devices_ws_has_been_connected is True assert received == [] @pytest.mark.asyncio async def test_reconnect_warning_logged_when_entries_swept( caplog: pytest.LogCaptureFixture, ) -> None: api = _make_client() _wire(api) _store_started(api, age=timedelta(hours=2), event_id="stale") with caplog.at_level("WARNING", logger="uiprotect.api"): api._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert sum("reconnected" in r.message for r in caplog.records) == 1 @pytest.mark.asyncio async def test_reconnect_with_no_stale_entries_skips_warning( caplog: pytest.LogCaptureFixture, ) -> None: api = _make_client() _wire(api) _store_started(api, age=timedelta(seconds=30), event_id="fresh") with caplog.at_level("WARNING", logger="uiprotect.api"): api._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert not any("reconnected" in r.message for r in caplog.records) uilibs-uiprotect-317a52c/tests/test_events_reconnect_force_end.py000066400000000000000000000204451524637705700255140ustar00rootroot00000000000000"""Events-WS reconnect force-ends active detection events and syncs camera state.""" from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from uiprotect.api import ProtectApiClient from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.public_event import PublicEvent from uiprotect.data.types import EventType from uiprotect.events import EventChange, ProtectEvent from uiprotect.events.dispatcher import EventDispatcher from uiprotect.websocket import WebsocketState from .test_public_devices_models import CAMERA_PAYLOAD if TYPE_CHECKING: from uiprotect.data.websocket import WSSubscriptionMessage def _make_client() -> ProtectApiClient: client = ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) client._public_bootstrap = PublicBootstrap() client.public_bootstrap.process_devices_ws_message( Mock(), {"type": "add", "item": dict(CAMERA_PAYLOAD)} ) return client def _capture_devices(client: ProtectApiClient) -> list[WSSubscriptionMessage]: updates: list[WSSubscriptionMessage] = [] client._devices_ws_subscriptions.append(updates.append) return updates def _add_motion(client: ProtectApiClient, *, age: timedelta, event_id: str) -> None: start_ms = int((datetime.now(tz=UTC) - age).timestamp() * 1000) client.public_bootstrap.process_events_ws_message( Mock(), { "type": "add", "item": { "modelKey": "event", "id": event_id, "type": "motion", "start": start_ms, "device": "cam1", }, }, ) def _store_sensor(client: ProtectApiClient, *, age: timedelta, event_id: str) -> None: start = datetime.now(tz=UTC) - age client.public_bootstrap.events[event_id] = PublicEvent( api=client, id=event_id, type=EventType.SENSOR_OPENED, start=start, device_id="sensor-1", ) def _reconnect(client: ProtectApiClient) -> None: # First CONNECTED is the initial connect; the second is the reconnect. client._on_events_websocket_state_change(WebsocketState.CONNECTED) client._on_events_websocket_state_change(WebsocketState.CONNECTED) @pytest.mark.asyncio async def test_reconnect_force_ends_short_gap_detection() -> None: """A recent motion whose END was missed clears on events-WS reconnect.""" client = _make_client() cam = client.public_bootstrap.cameras["cam1"] dispatcher = EventDispatcher(client) received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher.add_subscriber(lambda e, c: received.append((e, c))) client._event_dispatcher = dispatcher device_updates = _capture_devices(client) _add_motion(client, age=timedelta(seconds=30), event_id="m1") assert cam.is_motion_detected is True _reconnect(client) assert cam.is_motion_detected is False assert client.public_bootstrap.events["m1"].end is not None assert [c for _, c in received] == [EventChange.ENDED] assert len(device_updates) == 1 assert list(device_updates[0].changed_data) == ["is_motion_detected"] assert device_updates[0].new_obj is cam @pytest.mark.asyncio async def test_first_connect_does_not_force_end() -> None: """The initial events-WS connect is not treated as a reconnect.""" client = _make_client() cam = client.public_bootstrap.cameras["cam1"] client._event_dispatcher = EventDispatcher(client) _add_motion(client, age=timedelta(seconds=30), event_id="m1") client._on_events_websocket_state_change(WebsocketState.CONNECTED) assert client._events_ws_has_been_connected is True assert cam.is_motion_detected is True assert client.public_bootstrap.events["m1"].end is None @pytest.mark.asyncio async def test_reconnect_lazily_creates_dispatcher() -> None: """A reconnect with no prior events subscriber still fixes camera state.""" client = _make_client() cam = client.public_bootstrap.cameras["cam1"] device_updates = _capture_devices(client) _add_motion(client, age=timedelta(seconds=30), event_id="m1") assert client._event_dispatcher is None _reconnect(client) assert client._event_dispatcher is not None assert cam.is_motion_detected is False assert list(device_updates[0].changed_data) == ["is_motion_detected"] @pytest.mark.asyncio async def test_reconnect_keeps_age_gate_for_non_detection_channels() -> None: """Non-detection channels are only flushed once past the staleness window.""" client = _make_client() client._event_dispatcher = EventDispatcher(client) _store_sensor(client, age=timedelta(minutes=5), event_id="fresh") _store_sensor(client, age=timedelta(hours=2), event_id="stale") _reconnect(client) assert client.public_bootstrap.events["fresh"].end is None assert client.public_bootstrap.events["stale"].end is not None @pytest.mark.asyncio async def test_reconnect_no_public_bootstrap_is_noop() -> None: """Reconnect without a materialised public cache only fans out WS state.""" client = ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) states: list[WebsocketState] = [] client.subscribe_events_websocket_state(states.append) _reconnect(client) assert client._event_dispatcher is None assert states == [WebsocketState.CONNECTED, WebsocketState.CONNECTED] @pytest.mark.asyncio async def test_reconnect_logs_warning_when_events_force_ended( caplog: pytest.LogCaptureFixture, ) -> None: """A force-end on reconnect emits a single warning naming the count.""" client = _make_client() client._event_dispatcher = EventDispatcher(client) _add_motion(client, age=timedelta(seconds=30), event_id="m1") with caplog.at_level("WARNING", logger="uiprotect.api"): _reconnect(client) assert sum("reconnected after gap" in r.message for r in caplog.records) == 1 @pytest.mark.asyncio async def test_reconnect_with_nothing_active_skips_warning( caplog: pytest.LogCaptureFixture, ) -> None: """A reconnect with no active events logs no warning.""" client = _make_client() client._event_dispatcher = EventDispatcher(client) with caplog.at_level("WARNING", logger="uiprotect.api"): _reconnect(client) assert not any("reconnected after gap" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_reconnect_force_end_raise_still_delivers_state( caplog: pytest.LogCaptureFixture, ) -> None: """A raising force-end is logged and does not skip CONNECTED delivery.""" client = _make_client() client._events_ws_has_been_connected = True dispatcher = EventDispatcher(client) dispatcher.force_end_on_events_reconnect = Mock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] client._event_dispatcher = dispatcher states: list[WebsocketState] = [] client._events_ws_state_subscriptions.append(states.append) with caplog.at_level("ERROR", logger="uiprotect.api"): client._on_events_websocket_state_change(WebsocketState.CONNECTED) assert states == [WebsocketState.CONNECTED] assert any("force-ending events" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_devices_reconnect_flush_raise_still_delivers_state( caplog: pytest.LogCaptureFixture, ) -> None: """A raising devices-WS flush is logged and does not skip CONNECTED delivery.""" client = _make_client() client._devices_ws_has_been_connected = True dispatcher = EventDispatcher(client) dispatcher.add_subscriber(lambda e, c: None) dispatcher.flush_stale_on_reconnect = Mock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] client._event_dispatcher = dispatcher states: list[WebsocketState] = [] client._devices_ws_state_subscriptions.append(states.append) with caplog.at_level("ERROR", logger="uiprotect.api"): client._on_devices_websocket_state_change(WebsocketState.CONNECTED) assert states == [WebsocketState.CONNECTED] assert any("flushing stale events" in r.message for r in caplog.records) uilibs-uiprotect-317a52c/tests/test_events_ttl_sweep.py000066400000000000000000000125171524637705700235370ustar00rootroot00000000000000"""TTL sweep tests over the single event store.""" from __future__ import annotations import asyncio from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from uiprotect.api import ProtectApiClient from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.public_event import PublicEvent from uiprotect.data.types import EventType from uiprotect.events import EventChange, ProtectEvent from uiprotect.events.dispatcher import ( EVENTS_ACTIVE_TTL, EventDispatcher, ) from .test_public_devices_models import CAMERA_PAYLOAD if TYPE_CHECKING: from uiprotect.data.websocket import WSSubscriptionMessage def _make_client() -> ProtectApiClient: client = ProtectApiClient( host="127.0.0.1", port=443, username="u", password="p", # noqa: S106 verify_ssl=False, store_sessions=False, ) client._public_bootstrap = PublicBootstrap() return client def _store_started(api: ProtectApiClient, *, age: timedelta) -> str: start = datetime.now(tz=UTC) - age eid = f"e-{int(start.timestamp() * 1000)}" ev = PublicEvent( api=api, id=eid, type=EventType.MOTION, start=start, device_id="cam-1", ) api.public_bootstrap.events[eid] = ev return eid def test_sweep_force_ends_stale_entry() -> None: api = _make_client() dispatcher = EventDispatcher(api) received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher.add_subscriber(lambda e, c: received.append((e, c))) stale = _store_started(api, age=EVENTS_ACTIVE_TTL + timedelta(seconds=10)) fresh = _store_started(api, age=timedelta(seconds=30)) count = dispatcher.sweep_stale() assert count == 1 assert [c for _, c in received] == [EventChange.ENDED] assert api.public_bootstrap.events[stale].end is not None assert [e.id for e in dispatcher.active_events()] == [fresh] def test_sweep_skips_other_channel_and_missing_device_id() -> None: api = _make_client() dispatcher = EventDispatcher(api) received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher.add_subscriber(lambda e, c: received.append((e, c))) stale = datetime.now(tz=UTC) - (EVENTS_ACTIVE_TTL + timedelta(seconds=10)) other = PublicEvent( api=api, id="other", type=EventType.REBOOT, start=stale, device_id="cam-1" ) no_device = PublicEvent(api=api, id="no-device", type=EventType.MOTION, start=stale) api.public_bootstrap.events["other"] = other api.public_bootstrap.events["no-device"] = no_device assert dispatcher.sweep_stale() == 0 assert received == [] def test_sweep_no_op_when_within_ttl() -> None: api = _make_client() dispatcher = EventDispatcher(api) received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher.add_subscriber(lambda e, c: received.append((e, c))) _store_started(api, age=timedelta(seconds=60)) assert dispatcher.sweep_stale() == 0 assert received == [] def test_sweep_marks_store_so_retransmit_not_refired() -> None: api = _make_client() dispatcher = EventDispatcher(api) received: list[tuple[ProtectEvent, EventChange]] = [] dispatcher.add_subscriber(lambda e, c: received.append((e, c))) stale = _store_started(api, age=EVENTS_ACTIVE_TTL + timedelta(seconds=10)) assert dispatcher.sweep_stale() == 1 # Sweeping again finds the now-terminal entry and does not re-fire. received.clear() assert dispatcher.sweep_stale() == 0 assert received == [] assert api.public_bootstrap.events[stale].end is not None def test_sweep_clears_camera_detection_and_emits_device_update() -> None: api = _make_client() api.public_bootstrap.process_devices_ws_message( Mock(), {"type": "add", "item": dict(CAMERA_PAYLOAD)} ) cam = api.public_bootstrap.cameras["cam1"] device_updates: list[WSSubscriptionMessage] = [] api._devices_ws_subscriptions.append(device_updates.append) dispatcher = EventDispatcher(api) start = datetime.now(tz=UTC) - (EVENTS_ACTIVE_TTL + timedelta(seconds=10)) api.public_bootstrap.process_events_ws_message( Mock(), { "type": "add", "item": { "modelKey": "event", "id": "m1", "type": "motion", "start": int(start.timestamp() * 1000), "device": "cam1", }, }, ) assert cam.is_motion_detected is True assert dispatcher.sweep_stale() == 1 assert cam.is_motion_detected is False assert len(device_updates) == 1 assert list(device_updates[0].changed_data) == ["is_motion_detected"] @pytest.mark.asyncio async def test_sweep_task_cancelled_when_last_subscriber_unsubscribes( monkeypatch: pytest.MonkeyPatch, ) -> None: api = _make_client() class _WS: def start(self) -> None: ... def stop(self) -> None: ... ws = _WS() monkeypatch.setattr(api, "_get_events_websocket", lambda: ws) unsub = api.subscribe_events(lambda e, c: None) assert api._event_dispatcher is not None sweep_task = api._event_dispatcher._sweep_task assert sweep_task is not None assert not sweep_task.done() unsub() await asyncio.sleep(0) assert api._event_dispatcher._sweep_task is None assert sweep_task.cancelled() or sweep_task.done() uilibs-uiprotect-317a52c/tests/test_events_value_types.py000066400000000000000000000064741524637705700240760ustar00rootroot00000000000000"""Phase 1 value-type tests for the public events contract.""" from __future__ import annotations from datetime import UTC, datetime import orjson import pytest from pydantic import TypeAdapter, ValidationError from uiprotect.data.types import EventType from uiprotect.events import ( EVENT_TYPE_TO_CHANNEL, INSTANTANEOUS_EVENT_TYPES, EventChange, EventIdentity, ProtectEvent, ProtectEventChannel, UlpUserIdentity, UnknownIdentity, ) def test_event_type_to_channel_is_exhaustive() -> None: assert set(EVENT_TYPE_TO_CHANNEL) == set(EventType) def test_event_type_to_channel_buckets() -> None: assert EVENT_TYPE_TO_CHANNEL[EventType.MOTION] is ProtectEventChannel.DETECTION assert EVENT_TYPE_TO_CHANNEL[EventType.RING] is ProtectEventChannel.DETECTION assert ( EVENT_TYPE_TO_CHANNEL[EventType.SMART_DETECT] is ProtectEventChannel.DETECTION ) assert ( EVENT_TYPE_TO_CHANNEL[EventType.MOTION_LIGHT] is ProtectEventChannel.DETECTION ) assert EVENT_TYPE_TO_CHANNEL[EventType.SENSOR_OPENED] is ProtectEventChannel.SENSOR assert ( EVENT_TYPE_TO_CHANNEL[EventType.ALARM_HUB_MOTION] is ProtectEventChannel.ALARM_HUB ) assert ( EVENT_TYPE_TO_CHANNEL[EventType.NFC_CARD_SCANNED] is ProtectEventChannel.ACCESS ) assert ( EVENT_TYPE_TO_CHANNEL[EventType.FINGERPRINT_IDENTIFIED] is ProtectEventChannel.ACCESS ) assert EVENT_TYPE_TO_CHANNEL[EventType.REBOOT] is ProtectEventChannel.OTHER def test_instantaneous_event_types_includes_light_motion() -> None: assert EventType.MOTION_LIGHT in INSTANTANEOUS_EVENT_TYPES def test_event_change_members() -> None: assert {c.value for c in EventChange} == { "started", "updated", "ended", "removed", } def test_event_identity_round_trip_via_json() -> None: adapter: TypeAdapter[EventIdentity] = TypeAdapter(EventIdentity) ulp = UlpUserIdentity(ulp_id="abc") blob = adapter.dump_json(ulp) assert orjson.loads(blob)["kind"] == "ulp_user" assert adapter.validate_json(blob) == ulp unk = UnknownIdentity(reason="no_metadata") blob = adapter.dump_json(unk) assert orjson.loads(blob)["kind"] == "unknown" assert adapter.validate_json(blob) == unk def test_protect_event_is_frozen() -> None: event = ProtectEvent( id="abcd", type=EventType.MOTION, channel=ProtectEventChannel.DETECTION, device_id="cam-1", start=datetime(2026, 1, 1, tzinfo=UTC), ) with pytest.raises(ValidationError): event.id = "x" # type: ignore[misc] def test_protect_event_defaults_smart_detect_types_to_empty_tuple() -> None: event = ProtectEvent( id="abcd", type=EventType.MOTION, channel=ProtectEventChannel.DETECTION, device_id="cam-1", start=datetime(2026, 1, 1, tzinfo=UTC), ) assert event.smart_detect_types == () assert event.end is None assert event.identity is None assert event.raw is None def test_protect_event_excludes_raw_from_dump() -> None: event = ProtectEvent( id="abcd", type=EventType.MOTION, channel=ProtectEventChannel.DETECTION, device_id="cam-1", start=datetime(2026, 1, 1, tzinfo=UTC), ) dumped = event.model_dump() assert "raw" not in dumped uilibs-uiprotect-317a52c/tests/test_fetch_openapi.py000066400000000000000000000147711524637705700227550ustar00rootroot00000000000000""" Network-free unit tests for the OpenAPI-spec fetcher. Every network call (`urllib.request.urlopen`) is mocked; the tests pin the portal-parsing, content-type guarding, 404 handling and version-stamping behaviour of ``scripts/fetch_openapi.py``. """ from __future__ import annotations import json import urllib.error from contextlib import contextmanager from typing import TYPE_CHECKING, Any from unittest.mock import patch import fetch_openapi # local import via conftest sys.path insert import pytest if TYPE_CHECKING: from pathlib import Path class _FakeResponse: """Minimal stand-in for the object returned by ``urlopen`` as a context manager.""" def __init__(self, body: bytes, content_type: str = "application/json") -> None: self._body = body self.headers = {"Content-Type": content_type} def read(self) -> bytes: return self._body @contextmanager def _urlopen_returning(response: _FakeResponse) -> Any: yield response def test_list_versions_parses_portal_payload() -> None: """`list_versions` extracts vX.Y.Z entries from the Next.js payload.""" html = ( 'foobar' ) with patch( "fetch_openapi.urllib.request.urlopen", return_value=_urlopen_returning(_FakeResponse(html.encode())), ): assert fetch_openapi.list_versions() == ["7.1.87", "7.1.83", "5.3.48"] def test_list_versions_raises_when_payload_missing() -> None: """`list_versions` raises when the version array is absent from the page.""" with ( patch( "fetch_openapi.urllib.request.urlopen", return_value=_urlopen_returning(_FakeResponse(b"no versions here")), ), pytest.raises(RuntimeError, match="version list"), ): fetch_openapi.list_versions() def test_fetch_from_portal_returns_json_bytes() -> None: """`_fetch_from_portal` returns the raw JSON body on a JSON response.""" body = b'{"openapi": "3.0.0"}' with patch( "fetch_openapi.urllib.request.urlopen", return_value=_urlopen_returning(_FakeResponse(body)), ): assert fetch_openapi._fetch_from_portal("v7.1.87") == body def test_fetch_from_portal_rejects_non_json() -> None: """`_fetch_from_portal` raises when the portal returns non-JSON content.""" with ( patch( "fetch_openapi.urllib.request.urlopen", return_value=_urlopen_returning( _FakeResponse(b"", content_type="text/html") ), ), pytest.raises(RuntimeError, match="Unexpected content type"), ): fetch_openapi._fetch_from_portal("7.1.87") def test_fetch_from_portal_404_hints_at_fallback() -> None: """A 404 from the portal surfaces a hint to use --list or --from-deb.""" err = urllib.error.HTTPError(url="x", code=404, msg="Not Found", hdrs=None, fp=None) with ( patch("fetch_openapi.urllib.request.urlopen", side_effect=err), pytest.raises(RuntimeError, match="--from-deb"), ): fetch_openapi._fetch_from_portal("9.9.9") def test_fetch_from_portal_reraises_other_http_errors() -> None: """Non-404 HTTP errors propagate unchanged.""" err = urllib.error.HTTPError( url="x", code=500, msg="Server Error", hdrs=None, fp=None ) with ( patch("fetch_openapi.urllib.request.urlopen", side_effect=err), pytest.raises(urllib.error.HTTPError), ): fetch_openapi._fetch_from_portal("7.1.87") def test_fetch_spec_stamps_placeholder_version(tmp_path: Path) -> None: """The portal's placeholder `info.version` is replaced with the resolved one.""" out = tmp_path / "integration.json" spec = {"info": {"version": "0.0.0"}, "paths": {"/x": {}}} with ( patch( "fetch_openapi._query_firmware", return_value=("deb-url", "7.1.87"), ), patch( "fetch_openapi._fetch_from_portal", return_value=json.dumps(spec).encode(), ), ): fetch_openapi.fetch_spec(output=out) written = json.loads(out.read_bytes()) assert written["info"]["version"] == "7.1.87" def test_fetch_spec_from_deb_preserves_real_version(tmp_path: Path) -> None: """The deb path keeps a real `info.version` and does not fetch the portal.""" out = tmp_path / "integration.json" spec = {"info": {"version": "7.1.87"}, "paths": {}} with ( patch( "fetch_openapi._query_firmware", return_value=("deb-url", "7.1.87"), ), patch( "fetch_openapi._fetch_from_deb", return_value=json.dumps(spec).encode(), ) as deb, patch("fetch_openapi._fetch_from_portal") as portal, ): fetch_openapi.fetch_spec(output=out, from_deb=True) deb.assert_called_once() portal.assert_not_called() assert json.loads(out.read_bytes())["info"]["version"] == "7.1.87" def _firmware_payload(version: str) -> bytes: return json.dumps( { "_embedded": { "firmware": [ {"_links": {"data": {"href": "deb-url"}}, "version": version} ] } } ).encode() @pytest.mark.parametrize("wire", ["v7.2.105", "7.2.105"]) def test_query_firmware_returns_bare_version(wire: str) -> None: """`_query_firmware` normalises the firmware API's version to MAJOR.MINOR.PATCH.""" with patch( "fetch_openapi.urllib.request.urlopen", return_value=_urlopen_returning(_FakeResponse(_firmware_payload(wire))), ): assert fetch_openapi._query_firmware(None) == ("deb-url", "7.2.105") def test_query_firmware_rejects_malformed_version() -> None: """A requested version that is not MAJOR.MINOR.PATCH is rejected before any call.""" with ( patch("fetch_openapi.urllib.request.urlopen") as urlopen, pytest.raises(ValueError, match=r"MAJOR\.MINOR\.PATCH"), ): fetch_openapi._query_firmware("7.2") urlopen.assert_not_called() def test_query_firmware_raises_when_no_firmware_matches() -> None: """An empty firmware list surfaces as a RuntimeError naming the version.""" empty = json.dumps({"_embedded": {"firmware": []}}).encode() with ( patch( "fetch_openapi.urllib.request.urlopen", return_value=_urlopen_returning(_FakeResponse(empty)), ), pytest.raises(RuntimeError, match="No firmware found"), ): fetch_openapi._query_firmware("7.2.105") uilibs-uiprotect-317a52c/tests/test_public_api_decorators.py000066400000000000000000000200361524637705700244740ustar00rootroot00000000000000"""Framework, registry, and signature/docstring invariants for the public-API decorators.""" from __future__ import annotations import inspect from typing import Any from unittest.mock import AsyncMock import pytest from uiprotect import _public_api from uiprotect._public_api import ( _PublicEndpointRegistry, _to_camel, public_get, public_patch, public_post, registry, ) from uiprotect.api import ProtectApiClient from uiprotect.exceptions import BadRequest class _Model: def __init__(self, **kwargs: Any) -> None: self.kwargs = kwargs @classmethod def from_unifi_dict(cls, **kwargs: Any) -> _Model: return cls(**kwargs) class _Client: @public_get("/v1/things", items=_Model) async def list_things(self) -> list[_Model]: """List things.""" raise NotImplementedError @public_get("/v1/things/{thing_id}", item=_Model) async def get_thing(self, thing_id: str) -> _Model: """Get a thing.""" raise NotImplementedError @public_patch("/v1/things/{thing_id}", item=_Model) async def update_thing( self, thing_id: str, *, name: str | None = None, mic_volume: int | None = None, ) -> _Model: """Update a thing.""" raise NotImplementedError @public_post("/v1/things/{thing_id}/go/{slot}") async def go(self, thing_id: str, *, slot: int) -> None: """Fire and forget.""" raise NotImplementedError def _client() -> _Client: client = _Client() client.api_request_list = AsyncMock() # type: ignore[method-assign] client.api_request_obj = AsyncMock() # type: ignore[method-assign] client.api_request_raw = AsyncMock() # type: ignore[method-assign] return client def test_to_camel() -> None: assert _to_camel("name") == "name" assert _to_camel("mic_volume") == "micVolume" assert _to_camel("is_mic_enabled") == "isMicEnabled" @pytest.mark.asyncio() async def test_list_dispatch_and_model_construction() -> None: client = _client() client.api_request_list.return_value = [{"id": "1"}, {"id": "2"}] result = await client.list_things() client.api_request_list.assert_called_once_with(url="/v1/things", public_api=True) assert [m.kwargs for m in result] == [ {"id": "1", "api": client}, {"id": "2", "api": client}, ] @pytest.mark.asyncio() async def test_list_empty_is_empty_list() -> None: client = _client() client.api_request_list.return_value = [] assert await client.list_things() == [] @pytest.mark.asyncio() async def test_obj_get_binds_path_placeholder() -> None: client = _client() client.api_request_obj.return_value = {"id": "abc"} result = await client.get_thing("abc") client.api_request_obj.assert_called_once_with( url="/v1/things/abc", public_api=True ) assert result.kwargs == {"id": "abc", "api": client} @pytest.mark.asyncio() async def test_patch_assembles_camel_body_and_drops_none() -> None: client = _client() client.api_request_obj.return_value = {"id": "x"} await client.update_thing("x", name="hi", mic_volume=5) client.api_request_obj.assert_called_once_with( url="/v1/things/x", method="patch", json={"name": "hi", "micVolume": 5}, public_api=True, ) @pytest.mark.asyncio() async def test_patch_path_param_not_emitted_into_body() -> None: client = _client() client.api_request_obj.return_value = {"id": "x"} await client.update_thing("x", name="only") _, kwargs = client.api_request_obj.call_args assert "thingId" not in kwargs["json"] assert kwargs["json"] == {"name": "only"} @pytest.mark.asyncio() async def test_empty_patch_body_raises_bad_request() -> None: client = _client() with pytest.raises(BadRequest, match="At least one parameter must be provided"): await client.update_thing("x") client.api_request_obj.assert_not_called() @pytest.mark.asyncio() async def test_post_fire_and_forget_binds_all_path_params() -> None: client = _client() result = await client.go("cam", slot=3) assert result is None client.api_request_raw.assert_called_once_with( url="/v1/things/cam/go/3", method="post", public_api=True ) def test_duplicate_endpoint_raises_at_decoration( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(_public_api, "registry", _PublicEndpointRegistry()) with pytest.raises(RuntimeError, match="Duplicate public endpoint"): class _Dup: @public_get("/v1/dup", item=_Model) async def a(self) -> _Model: """First.""" raise NotImplementedError @public_get("/v1/dup", item=_Model) async def b(self) -> _Model: """Second.""" raise NotImplementedError def test_public_get_requires_exactly_one_model() -> None: with pytest.raises(TypeError, match="exactly one of item= or items="): public_get("/v1/x") with pytest.raises(TypeError, match="exactly one of item= or items="): public_get("/v1/x", item=_Model, items=_Model) def test_unknown_path_placeholder_rejected_at_decoration() -> None: with pytest.raises(TypeError, match=r"placeholder.*missing"): class _Bad: @public_get("/v1/things/{missing}", item=_Model) async def get(self) -> _Model: """Bad.""" raise NotImplementedError def test_body_less_endpoint_rejects_extra_param_at_decoration() -> None: with pytest.raises(TypeError, match=r"non-placeholder parameter.*extra"): class _Bad: @public_post("/v1/things/{thing_id}/go") async def go(self, thing_id: str, *, extra: int) -> None: """Bad.""" raise NotImplementedError # --------------------------------------------------------------------------- # Invariants against the real client # --------------------------------------------------------------------------- _REGISTRY = registry.for_class("ProtectApiClient") _REGISTERED_METHODS = sorted(_REGISTRY.values()) def test_registry_is_non_empty_and_covers_converted_endpoints() -> None: assert len(_REGISTRY) == 39 # paths are unique per (verb, path) key by construction assert len(set(_REGISTRY)) == len(_REGISTRY) def test_registry_all_endpoints_includes_client() -> None: assert "ProtectApiClient" in registry.all_endpoints() @pytest.mark.parametrize("method_name", _REGISTERED_METHODS) def test_each_registered_method_appears_exactly_once(method_name: str) -> None: assert _REGISTERED_METHODS.count(method_name) == 1 @pytest.mark.parametrize( "excluded", [ "update_camera_public", "update_light_public", "update_viewer_public", "get_bridge_public", "get_viewer_public", "get_liveview_public", "send_alarm_webhook_public", "update_public", ], ) def test_hand_written_exceptions_absent_from_registry(excluded: str) -> None: assert excluded not in _REGISTERED_METHODS @pytest.mark.parametrize( ("method_name", "endpoint"), [ ("get_alarm_hubs_public", ("get", "/v1/alarm-hubs")), ("get_alarm_hub_public", ("get", "/v1/alarm-hubs/{hub_id}")), ("update_alarm_hub_public", ("patch", "/v1/alarm-hubs/{hub_id}")), ], ) def test_alarm_hub_family_is_declarative( method_name: str, endpoint: tuple[str, str] ) -> None: assert _REGISTRY[endpoint] == method_name def test_signature_preserved() -> None: # ``from __future__ import annotations`` keeps annotations as strings, so # compare structurally rather than against a fully-resolved repr. sig = inspect.signature(ProtectApiClient.get_camera_public) assert list(sig.parameters) == ["self", "camera_id"] assert sig.parameters["camera_id"].annotation == "str" assert sig.return_annotation == "PublicCamera" @pytest.mark.parametrize("method_name", _REGISTERED_METHODS) def test_decorated_method_has_docstring(method_name: str) -> None: doc = getattr(ProtectApiClient, method_name).__doc__ assert doc is not None and doc.strip() uilibs-uiprotect-317a52c/tests/test_public_camera_detection.py000066400000000000000000000547121524637705700247740ustar00rootroot00000000000000"""Derived detection-state booleans on PublicCamera, driven by the public events WS.""" from __future__ import annotations from typing import Any from unittest.mock import Mock import pytest from uiprotect.data import PublicCamera from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.public_event import PublicEvent from uiprotect.data.types import EventType from uiprotect.data.websocket import WSAction from .test_public_devices_models import CAMERA_PAYLOAD def _bootstrap_with_camera() -> PublicBootstrap: pb = PublicBootstrap() pb.process_devices_ws_message(Mock(), {"type": "add", "item": dict(CAMERA_PAYLOAD)}) return pb def _bootstrap_with_two_cameras() -> PublicBootstrap: pb = _bootstrap_with_camera() second = dict(CAMERA_PAYLOAD) second["id"] = "cam2" pb.process_devices_ws_message(Mock(), {"type": "add", "item": second}) return pb def _event(action: str, **item: Any) -> dict[str, Any]: return {"type": action, "item": {"modelKey": "event", **item}} def test_no_active_events_all_false() -> None: """A freshly cached camera reports every detection flag as ``False``.""" cam = _bootstrap_with_camera().cameras["cam1"] assert cam.is_motion_detected is False assert cam.is_smart_currently_detected is False assert cam.is_person_currently_detected is False assert cam.is_vehicle_currently_detected is False assert cam.is_animal_currently_detected is False assert cam.is_audio_currently_detected is False assert cam.is_smoke_currently_detected is False assert cam.is_cmonx_currently_detected is False assert cam.is_siren_currently_detected is False assert cam.is_baby_cry_currently_detected is False assert cam.is_speaking_currently_detected is False assert cam.is_bark_currently_detected is False assert cam.is_car_alarm_currently_detected is False assert cam.is_car_horn_currently_detected is False assert cam.is_glass_break_currently_detected is False def test_motion_event_start_and_end() -> None: """An open motion event sets ``is_motion_detected``; its end clears it.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1"), ) assert cam.is_motion_detected is True pb.process_events_ws_message(Mock(), _event("update", id="m1", end=2000)) assert cam.is_motion_detected is False def test_smart_detect_person_start_and_end() -> None: """A smartDetectZone person event drives the smart + person flags only.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="s1", type="smartDetectZone", start=1000, device="cam1", smartDetectTypes=["person"], ), ) assert cam.is_smart_currently_detected is True assert cam.is_person_currently_detected is True assert cam.is_vehicle_currently_detected is False assert cam.is_animal_currently_detected is False pb.process_events_ws_message(Mock(), _event("update", id="s1", end=2000)) assert cam.is_smart_currently_detected is False assert cam.is_person_currently_detected is False def test_smart_detect_line_drives_smart_flags() -> None: """A ``smartDetectLine`` event drives the smart/person flags like ``smartDetectZone``.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="l1", type="smartDetectLine", start=1000, device="cam1", smartDetectTypes=["person"], ), ) assert cam.is_smart_currently_detected is True assert cam.is_person_currently_detected is True pb.process_events_ws_message(Mock(), _event("update", id="l1", end=2000)) assert cam.is_smart_currently_detected is False assert cam.is_person_currently_detected is False def test_smart_audio_smoke_start_and_end() -> None: """A smartAudioDetect smoke event drives the audio + smoke flags only.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="a1", type="smartAudioDetect", start=1000, device="cam1", smartDetectTypes=["alrmSmoke"], ), ) assert cam.is_audio_currently_detected is True assert cam.is_smoke_currently_detected is True assert cam.is_cmonx_currently_detected is False assert cam.is_siren_currently_detected is False pb.process_events_ws_message(Mock(), _event("update", id="a1", end=2000)) assert cam.is_audio_currently_detected is False assert cam.is_smoke_currently_detected is False def test_overlapping_motion_events_stay_on_until_all_end() -> None: """Two concurrent motion events keep the flag set until both close.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) pb.process_events_ws_message( Mock(), _event("add", id="m2", type="motion", start=1100, device="cam1") ) assert cam.is_motion_detected is True pb.process_events_ws_message(Mock(), _event("update", id="m1", end=2000)) assert cam.is_motion_detected is True pb.process_events_ws_message(Mock(), _event("update", id="m2", end=2100)) assert cam.is_motion_detected is False def test_overlapping_person_events_stay_on_until_all_end() -> None: """Two concurrent smartDetectZone person events keep the person/smart flags set until both close.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="p1", type="smartDetectZone", start=1000, device="cam1", smartDetectTypes=["person"], ), ) pb.process_events_ws_message( Mock(), _event( "add", id="p2", type="smartDetectZone", start=1100, device="cam1", smartDetectTypes=["person"], ), ) assert cam.is_smart_currently_detected is True assert cam.is_person_currently_detected is True pb.process_events_ws_message(Mock(), _event("update", id="p1", end=2000)) assert cam.is_smart_currently_detected is True assert cam.is_person_currently_detected is True pb.process_events_ws_message(Mock(), _event("update", id="p2", end=2100)) assert cam.is_smart_currently_detected is False assert cam.is_person_currently_detected is False def test_remove_frame_clears_active_detection() -> None: """A server ``remove`` frame clears an active detection.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) assert cam.is_motion_detected is True pb.process_events_ws_message(Mock(), _event("remove", id="m1")) assert cam.is_motion_detected is False def test_born_closed_event_does_not_turn_flag_on() -> None: """An event that arrives already-ended never flips a sustained-state flag.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="m1", type="motion", start=1000, end=1000, device="cam1", ), ) assert cam.is_motion_detected is False def test_event_for_unknown_camera_is_ignored() -> None: """An event for a device not in the camera store does not raise.""" pb = _bootstrap_with_camera() pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="ghost") ) assert pb.cameras["cam1"].is_motion_detected is False def test_non_detection_event_type_ignored() -> None: """A non-detection event type (e.g. ring) leaves all flags clear.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event("add", id="r1", type="ring", start=1000, device="cam1") ) assert cam.is_motion_detected is False assert cam.is_smart_currently_detected is False def test_unknown_event_update_is_a_noop() -> None: """An ``update`` for an unseen, un-promotable event id touches no camera state.""" pb = _bootstrap_with_camera() pb.process_events_ws_message(Mock(), _event("update", id="ghost-evt", end=2000)) assert pb.cameras["cam1"].is_motion_detected is False def test_active_detection_set_is_per_instance() -> None: """Two cameras maintain independent active-event sets.""" cam_a = PublicCamera.model_construct() cam_b = PublicCamera.model_construct() cam_a._active_detection_events["m1"] = Mock() assert cam_b._active_detection_events == {} def test_smart_audio_cmonx_start_and_end() -> None: """A smartAudioDetect CO event drives the audio + cmonx flags only.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="a1", type="smartAudioDetect", start=1000, device="cam1", smartDetectTypes=["alrmCmonx"], ), ) assert cam.is_audio_currently_detected is True assert cam.is_cmonx_currently_detected is True assert cam.is_smoke_currently_detected is False assert cam.is_siren_currently_detected is False pb.process_events_ws_message(Mock(), _event("update", id="a1", end=2000)) assert cam.is_audio_currently_detected is False assert cam.is_cmonx_currently_detected is False def test_smart_audio_siren_start_and_end() -> None: """A smartAudioDetect siren event drives the audio + siren flags only.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="a1", type="smartAudioDetect", start=1000, device="cam1", smartDetectTypes=["alrmSiren"], ), ) assert cam.is_audio_currently_detected is True assert cam.is_siren_currently_detected is True assert cam.is_smoke_currently_detected is False assert cam.is_cmonx_currently_detected is False pb.process_events_ws_message(Mock(), _event("update", id="a1", end=2000)) assert cam.is_audio_currently_detected is False assert cam.is_siren_currently_detected is False # Each remaining smart-audio subtype: (wire smartDetectType, flag attribute). _AUDIO_SUBTYPES = [ ("alrmBabyCry", "is_baby_cry_currently_detected"), ("alrmSpeak", "is_speaking_currently_detected"), ("alrmBark", "is_bark_currently_detected"), ("alrmBurglar", "is_car_alarm_currently_detected"), ("alrmCarHorn", "is_car_horn_currently_detected"), ("alrmGlassBreak", "is_glass_break_currently_detected"), ] @pytest.mark.parametrize(("wire_type", "flag"), _AUDIO_SUBTYPES) def test_smart_audio_subtype_start_and_end(wire_type: str, flag: str) -> None: """Each remaining smart-audio subtype drives the audio + its own flag only.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event( "add", id="a1", type="smartAudioDetect", start=1000, device="cam1", smartDetectTypes=[wire_type], ), ) assert cam.is_audio_currently_detected is True assert getattr(cam, flag) is True # No cross-talk with the alarm-audio flags from #1054. assert cam.is_smoke_currently_detected is False assert cam.is_cmonx_currently_detected is False assert cam.is_siren_currently_detected is False pb.process_events_ws_message(Mock(), _event("update", id="a1", end=2000)) assert cam.is_audio_currently_detected is False assert getattr(cam, flag) is False @pytest.mark.parametrize(("wire_type", "flag"), _AUDIO_SUBTYPES) def test_smart_audio_subtype_emits_transition(wire_type: str, flag: str) -> None: """Each remaining smart-audio subtype emits its own start/end transitions.""" pb = _bootstrap_with_camera() updates = pb.process_events_ws_message( Mock(), _event( "add", id="a1", type="smartAudioDetect", start=1000, device="cam1", smartDetectTypes=[wire_type], ), ).model_updates assert len(updates) == 1 assert updates[0].changed_data == { "is_audio_currently_detected": True, flag: True, } updates = pb.process_events_ws_message( Mock(), _event("update", id="a1", end=2000) ).model_updates assert len(updates) == 1 assert updates[0].changed_data == { "is_audio_currently_detected": False, flag: False, } def test_detection_state_is_isolated_per_camera() -> None: """An event for one camera never leaks into another camera's flags.""" pb = _bootstrap_with_two_cameras() cam1 = pb.cameras["cam1"] cam2 = pb.cameras["cam2"] pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) assert cam1.is_motion_detected is True assert cam2.is_motion_detected is False pb.process_events_ws_message( Mock(), _event("add", id="m2", type="motion", start=1100, device="cam2") ) pb.process_events_ws_message(Mock(), _event("update", id="m1", end=2000)) assert cam1.is_motion_detected is False assert cam2.is_motion_detected is True def test_eviction_clears_stuck_open_detection() -> None: """An open event evicted from the bounded cache turns its flag back off.""" pb = _bootstrap_with_camera() pb.max_event_cache_size = 1 cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) assert cam.is_motion_detected is True # A second cached event evicts the open motion event; without eviction-aware # sync the flag would stay stuck on and the active set would grow unbounded. pb.process_events_ws_message( Mock(), _event("add", id="r1", type="ring", start=1100, device="cam1") ) assert cam.is_motion_detected is False assert cam._active_detection_events == {} def test_evicted_open_event_close_update_is_a_noop() -> None: """A close update arriving after eviction cannot resurrect the flag.""" pb = _bootstrap_with_camera() pb.max_event_cache_size = 1 cam = pb.cameras["cam1"] pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) pb.process_events_ws_message( Mock(), _event("add", id="r1", type="ring", start=1100, device="cam1") ) pb.process_events_ws_message(Mock(), _event("update", id="m1", end=2000)) assert cam.is_motion_detected is False def test_event_without_device_is_tolerated() -> None: """An event carrying no ``device`` neither raises on add nor on remove.""" pb = _bootstrap_with_camera() pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000) ) assert pb.cameras["cam1"].is_motion_detected is False pb.process_events_ws_message(Mock(), _event("remove", id="m1")) assert pb.cameras["cam1"].is_motion_detected is False def test_license_plate_state_not_exposed() -> None: """License-plate recognition is deliberately out of scope on the public model.""" assert not hasattr(PublicCamera, "is_license_plate_currently_detected") # --------------------------------------------------------------------------- # Detection-state model updates (process_events_ws_message third return value) # --------------------------------------------------------------------------- def test_motion_start_emits_transition() -> None: """An open motion event yields a single ``is_motion_detected: True`` update.""" pb = _bootstrap_with_camera() updates = pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ).model_updates assert len(updates) == 1 assert updates[0].action is WSAction.UPDATE assert updates[0].old_obj is None assert updates[0].new_obj is pb.cameras["cam1"] assert updates[0].new_update_id == "cam1" assert updates[0].changed_data == {"is_motion_detected": True} def test_motion_end_emits_off_transition() -> None: """The motion close frame yields a ``is_motion_detected: False`` update.""" pb = _bootstrap_with_camera() pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) updates = pb.process_events_ws_message( Mock(), _event("update", id="m1", end=2000) ).model_updates assert len(updates) == 1 assert updates[0].changed_data == {"is_motion_detected": False} def test_person_start_emits_both_smart_and_person() -> None: """A person smartDetect start flips both the smart and person flags in one message.""" pb = _bootstrap_with_camera() updates = pb.process_events_ws_message( Mock(), _event( "add", id="p1", type="smartDetectZone", start=1000, device="cam1", smartDetectTypes=["person"], ), ).model_updates assert len(updates) == 1 assert updates[0].changed_data == { "is_smart_currently_detected": True, "is_person_currently_detected": True, } def test_overlapping_motion_emits_no_second_transition() -> None: """A second overlapping motion event does not re-emit an update.""" pb = _bootstrap_with_camera() pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) updates = pb.process_events_ws_message( Mock(), _event("add", id="m2", type="motion", start=1100, device="cam1") ).model_updates assert updates == [] def test_non_detection_event_emits_no_transition() -> None: """A non-detection event type produces no update.""" pb = _bootstrap_with_camera() updates = pb.process_events_ws_message( Mock(), _event("add", id="r1", type="ring", start=1000, device="cam1") ).model_updates assert updates == [] def test_eviction_emits_off_transition() -> None: """An open event evicted from the cache emits a flag-off update.""" pb = _bootstrap_with_camera() pb.max_event_cache_size = 1 pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) # The ring event evicts the open motion event in the same frame. updates = pb.process_events_ws_message( Mock(), _event("add", id="r1", type="ring", start=1100, device="cam1") ).model_updates assert len(updates) == 1 assert updates[0].new_obj is pb.cameras["cam1"] assert updates[0].changed_data == {"is_motion_detected": False} def test_remove_frame_emits_off_transition() -> None: """A server ``remove`` frame for an open event emits a flag-off update.""" pb = _bootstrap_with_camera() pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) updates = pb.process_events_ws_message( Mock(), _event("remove", id="m1") ).model_updates assert len(updates) == 1 assert updates[0].changed_data == {"is_motion_detected": False} def test_drain_skips_camera_removed_after_snapshot() -> None: """A camera dropped from the cache after its pre-frame snapshot yields no update.""" pb = _bootstrap_with_camera() # Snapshot a camera, then remove it before the post-frame diff runs. pb._detection_state_before["cam1"] = pb.cameras["cam1"]._detection_state() del pb.cameras["cam1"] assert pb._drain_detection_updates() == [] def test_transition_state_does_not_leak_across_frames() -> None: """A transition reported on one frame is not re-reported on the next.""" pb = _bootstrap_with_camera() first = pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ).model_updates assert first # Re-applying the same open motion event is a no-op flip — no fresh update. second = pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ).model_updates assert second == [] def test_transition_isolated_to_affected_camera() -> None: """Only the camera owning the event reports an update.""" pb = _bootstrap_with_two_cameras() updates = pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ).model_updates assert len(updates) == 1 assert updates[0].new_obj is pb.cameras["cam1"] def test_net_transition_when_event_starts_and_evicts_same_frame() -> None: """A frame that nets out to no change (start then self-evict) emits nothing.""" pb = _bootstrap_with_camera() pb.max_event_cache_size = 0 updates = pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ).model_updates assert pb.cameras["cam1"].is_motion_detected is False assert updates == [] def test_detection_state_memoized_until_mutation() -> None: """Repeated reads share one cached dict; each mutation yields a fresh object.""" pb = _bootstrap_with_camera() cam = pb.cameras["cam1"] first = cam._detection_state() assert cam._detection_state() is first pb.process_events_ws_message( Mock(), _event("add", id="m1", type="motion", start=1000, device="cam1") ) after_add = cam._detection_state() assert after_add is not first assert cam._detection_state() is after_add pb.process_events_ws_message(Mock(), _event("update", id="m1", end=2000)) after_end = cam._detection_state() assert after_end is not after_add def test_snapshot_stays_distinct_from_post_mutation_state() -> None: """A snapshot captured before a mutation is a distinct, unmutated object.""" cam = PublicCamera.model_construct() before = cam._detection_state() cam._apply_detection_event( PublicEvent.model_construct(id="m1", type=EventType.MOTION, end=None) ) after = cam._detection_state() assert after is not before assert before["is_motion_detected"] is False assert after["is_motion_detected"] is True uilibs-uiprotect-317a52c/tests/test_public_devices_models.py000066400000000000000000001025611524637705700244670ustar00rootroot00000000000000"""Dedicated public device models (PublicCamera/Light/Sensor/Chime) and WS routing.""" from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any from unittest.mock import Mock import pytest from uiprotect.api import RTSPSStreams from uiprotect.data import ( AlarmHubInput, AlarmHubInputContactType, AlarmHubInputStatus, Fob, LinkStation, NvrArmMode, NvrArmModeStatus, OnOffState, ProtectDeviceIdentity, PublicBridge, PublicCamera, PublicChime, PublicDeviceModel, PublicLcdMessage, PublicLight, PublicNVR, PublicSensor, PublicSensorFeatureFlags, PublicSirenStatus, PublicViewer, Relay, SensorFeatureCapability, Siren, Speaker, ) from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.types import ( ChannelQuality, ModelType, SensorScheduleMode, SmartDetectObjectType, ) from uiprotect.exceptions import BadRequest if TYPE_CHECKING: from collections.abc import Callable CAMERA_PAYLOAD: dict[str, Any] = { "id": "cam1", "modelKey": "camera", "state": "CONNECTED", "name": "Front Door", "mac": "AABBCCDDEEFF", "isMicEnabled": True, "osdSettings": { "isNameEnabled": True, "isDateEnabled": True, "isLogoEnabled": False, "isDebugEnabled": False, "overlayLocation": "topLeft", }, "ledSettings": {"isEnabled": True, "welcomeLed": None, "floodLed": None}, "lcdMessage": {}, "micVolume": 100, "activePatrolSlot": None, "videoMode": "default", "hdrType": "auto", "featureFlags": { "supportFullHdSnapshot": False, "hasHdr": True, "hasMic": True, "hasLedStatus": True, "hasSpeaker": False, "videoModes": ["default"], "smartDetectTypes": ["person"], "smartDetectAudioTypes": ["alrmSmoke"], }, "smartDetectSettings": {"objectTypes": ["person"], "audioTypes": ["alrmSmoke"]}, "hasPackageCamera": False, } LIGHT_PAYLOAD: dict[str, Any] = { "id": "light1", "modelKey": "light", "state": "CONNECTED", "name": "Garage Light", "mac": "AABBCCDDEE01", "lightModeSettings": {"mode": "motion", "enableAt": "dark"}, "lightDeviceSettings": { "isIndicatorEnabled": True, "pirDuration": 15000, "pirSensitivity": 80, "ledLevel": 3, }, "isDark": True, "isLightOn": False, "isLightForceEnabled": False, "lastMotion": None, "isPirMotionDetected": False, "camera": "cam1", } SENSOR_PAYLOAD: dict[str, Any] = { "id": "sensor1", "modelKey": "sensor", "state": "CONNECTED", "name": "Kitchen Sensor", "mac": "AABBCCDDEE02", "mountType": "door", "batteryStatus": {"percentage": 90, "isLow": False}, "stats": { "light": {"value": 12.0, "status": "neutral"}, "humidity": {"value": None, "status": "unknown"}, "temperature": {"value": 22.5, "status": "neutral"}, }, "lightSettings": { "isEnabled": True, "margin": 1, "lowThreshold": 1.0, "highThreshold": 100.0, }, "humiditySettings": { "isEnabled": False, "margin": 1, "lowThreshold": None, "highThreshold": None, }, "temperatureSettings": { "isEnabled": False, "margin": 1, "lowThreshold": None, "highThreshold": None, }, "isOpened": False, "openStatusChangedAt": None, "isMotionDetected": False, "motionDetectedAt": None, "motionSettings": { "isEnabled": True, "sensitivity": 50, "sensitivityWhenArmed": 80, }, "alarmTriggeredAt": None, "alarmSettings": {"isEnabled": False}, "leakDetectedAt": None, "externalLeakDetectedAt": None, "leakSettings": {"isInternalEnabled": False, "isExternalEnabled": False}, "tamperingDetectedAt": None, "wirelessConnectionState": { "signalState": {"signalQuality": 80, "signalStrength": -50}, "batteryStatus": {"percentage": 90, "isLow": False}, "bridge": "bridge1", }, "scheduleMode": "when_armed", "glassBreakSettings": { "isEnabled": True, "sensitivity": 60, "sensitivityWhenArmed": 90, }, "armProfileIds": ["profile1"], "hasCustomSensitivityWhenArmed": True, } CHIME_PAYLOAD: dict[str, Any] = { "id": "chime1", "modelKey": "chime", "state": "CONNECTED", "name": "Hallway Chime", "mac": "AABBCCDDEE03", "cameraIds": ["cam1", "cam2"], "ringSettings": [ { "cameraId": "cam1", "repeatTimes": 2, "ringtoneId": "rt1", "volume": 75, } ], } @pytest.mark.parametrize( ("cls", "payload", "field_count"), [ (PublicCamera, CAMERA_PAYLOAD, 19), (PublicLight, LIGHT_PAYLOAD, 15), (PublicSensor, SENSOR_PAYLOAD, 30), (PublicChime, CHIME_PAYLOAD, 9), ], ) def test_public_model_field_set( cls: type, payload: dict[str, Any], field_count: int ) -> None: """Each public model exposes exactly its spec field count and parses its payload.""" assert len(cls.model_fields) == field_count obj = cls.from_unifi_dict(api=Mock(), **dict(payload)) assert obj.name == payload["name"] @pytest.mark.parametrize( ("cls", "payload"), [ (PublicCamera, CAMERA_PAYLOAD), (PublicLight, LIGHT_PAYLOAD), (PublicSensor, SENSOR_PAYLOAD), (PublicChime, CHIME_PAYLOAD), ], ) def test_public_model_name_nullable(cls: type, payload: dict[str, Any]) -> None: """``name`` is present-but-nullable: a ``null`` wire value parses to ``None``.""" data = dict(payload) data["name"] = None obj = cls.from_unifi_dict(api=Mock(), **data) assert obj.name is None @pytest.mark.parametrize( ("cls", "payload"), [ (PublicCamera, CAMERA_PAYLOAD), (PublicLight, LIGHT_PAYLOAD), (PublicSensor, SENSOR_PAYLOAD), (PublicChime, CHIME_PAYLOAD), ], ) def test_public_device_model_shared_base(cls: type, payload: dict[str, Any]) -> None: """Dedicated public devices share ``PublicDeviceModel`` and type ``mac`` / ``state``.""" assert issubclass(cls, PublicDeviceModel) assert PublicDeviceModel.model_fields.keys() >= {"mac", "state"} obj: PublicDeviceModel = cls.from_unifi_dict(api=Mock(), **dict(payload)) assert obj.mac == payload["mac"] assert obj.state.value == payload["state"] @pytest.mark.parametrize( "cls", [Siren, Relay, Fob, Speaker, LinkStation, PublicBridge, PublicViewer], ) def test_mac_state_devices_share_base(cls: type[PublicDeviceModel]) -> None: """Every mac/state-carrying public device subclasses ``PublicDeviceModel``.""" assert issubclass(cls, PublicDeviceModel) assert cls.model_fields.keys() >= {"mac", "state"} def test_reparented_viewer_is_public_device_model() -> None: """A constructed viewer satisfies the generic ``isinstance`` dispatch guard.""" obj = PublicViewer.from_unifi_dict( api=Mock(), id="viewer-1", modelKey="viewer", state="CONNECTED", name="Viewer 1", mac="AABBCCDDEE01", liveview="lv-1", streamLimit=16, ) assert isinstance(obj, PublicDeviceModel) assert obj.mac == "AABBCCDDEE01" assert obj.state.value == "CONNECTED" def test_public_camera_drops_private_only_fields() -> None: """A private-only key on the payload is not modeled and not stored.""" assert "recording_settings" not in PublicCamera.model_fields obj = PublicCamera.from_unifi_dict( api=Mock(), **{**CAMERA_PAYLOAD, "recordingSettings": {"mode": "always"}} ) assert not hasattr(obj, "recording_settings") @pytest.mark.parametrize( ("has_package_camera", "expected"), [ (False, [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW]), ( True, [ ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW, ChannelQuality.PACKAGE, ], ), ], ) def test_public_camera_hardware_stream_qualities( has_package_camera: bool, expected: list[ChannelQuality] ) -> None: """Hardware qualities are high/medium/low plus package only when supported.""" obj = PublicCamera.from_unifi_dict( api=Mock(), **{**CAMERA_PAYLOAD, "hasPackageCamera": has_package_camera} ) assert obj.hardware_stream_qualities() == expected @pytest.mark.parametrize( ("smart_detect_types", "smart_detect_audio_types", "smart_type", "expected"), [ (["person"], [], SmartDetectObjectType.PERSON, True), (["person"], [], SmartDetectObjectType.VEHICLE, False), ([], ["alrmCmonx"], SmartDetectObjectType.CMONX, True), ([], ["alrmSmoke"], SmartDetectObjectType.CMONX, False), ([], ["alrmBurglar"], SmartDetectObjectType.BURGLAR, True), ([], ["alrmSmoke"], SmartDetectObjectType.BURGLAR, False), ([], ["alrmSmoke"], SmartDetectObjectType.PERSON, False), (["alrmSmoke"], [], SmartDetectObjectType.SMOKE, False), ], ) def test_public_camera_can_detect( smart_detect_types: list[str], smart_detect_audio_types: list[str], smart_type: SmartDetectObjectType, expected: bool, ) -> None: """Object types match the object list, audio types the audio list via ``audio_type``.""" obj = PublicCamera.from_unifi_dict( api=Mock(), **{ **CAMERA_PAYLOAD, "featureFlags": { **CAMERA_PAYLOAD["featureFlags"], "smartDetectTypes": smart_detect_types, "smartDetectAudioTypes": smart_detect_audio_types, }, }, ) assert obj.can_detect(smart_type) is expected def test_public_camera_unknown_smart_detect_type_dropped( caplog: pytest.LogCaptureFixture, ) -> None: """A runtime-generated smart detect type (e.g. ``linecrossing_basic``) is dropped, not fatal.""" payload = { **CAMERA_PAYLOAD, "featureFlags": { **CAMERA_PAYLOAD["featureFlags"], "smartDetectTypes": ["person", "linecrossing_basic"], "smartDetectAudioTypes": ["alrmSmoke", "linecrossing_audio"], "videoModes": ["default", "linecrossing_video"], }, "smartDetectSettings": { "objectTypes": ["person", "linecrossing_basic"], "audioTypes": ["alrmSmoke", "linecrossing_audio"], }, } obj = PublicCamera.from_unifi_dict(api=Mock(), **payload) assert obj.feature_flags.smart_detect_types == [SmartDetectObjectType.PERSON] assert obj.smart_detect_settings.object_types == [SmartDetectObjectType.PERSON] assert "linecrossing_basic" in caplog.text @pytest.mark.parametrize( ("object_types", "audio_types", "prop", "expected"), [ (["person"], [], "is_person_detection_on", True), ([], [], "is_person_detection_on", False), (["vehicle"], [], "is_vehicle_detection_on", True), (["face"], [], "is_face_detection_on", True), (["licensePlate"], [], "is_license_plate_detection_on", True), (["package"], [], "is_package_detection_on", True), ([], [], "is_package_detection_on", False), (["animal"], [], "is_animal_detection_on", True), ([], ["alrmSmoke"], "is_smoke_detection_on", True), ([], [], "is_smoke_detection_on", False), ([], ["alrmCmonx"], "is_co_detection_on", True), ([], ["alrmSiren"], "is_siren_detection_on", True), ([], ["alrmBabyCry"], "is_baby_cry_detection_on", True), ([], ["alrmSpeak"], "is_speaking_detection_on", True), ([], ["alrmBark"], "is_bark_detection_on", True), ([], ["alrmBurglar"], "is_car_alarm_detection_on", True), ([], ["alrmCarHorn"], "is_car_horn_detection_on", True), ([], ["alrmGlassBreak"], "is_glass_break_detection_on", True), ([], ["alrmSmoke"], "is_siren_detection_on", False), ], ) def test_public_camera_detection_on_properties( object_types: list[str], audio_types: list[str], prop: str, expected: bool, ) -> None: """``is_*_detection_on`` are membership tests over the smart-detect settings.""" obj = PublicCamera.from_unifi_dict( api=Mock(), **{ **CAMERA_PAYLOAD, "smartDetectSettings": { "objectTypes": object_types, "audioTypes": audio_types, }, }, ) assert getattr(obj, prop) is expected @pytest.mark.parametrize( ("video_modes", "expected"), [(["default"], False), (["default", "highFps"], True)], ) def test_public_camera_feature_flags_has_highfps( video_modes: list[str], expected: bool ) -> None: """``has_highfps`` tracks ``highFps`` membership in ``video_modes``.""" obj = PublicCamera.from_unifi_dict( api=Mock(), **{ **CAMERA_PAYLOAD, "featureFlags": { **CAMERA_PAYLOAD["featureFlags"], "videoModes": video_modes, }, }, ) assert obj.feature_flags.has_highfps is expected @pytest.mark.parametrize( ("video_mode", "expected"), [("default", False), ("highFps", True)], ) def test_public_camera_is_high_fps_enabled(video_mode: str, expected: bool) -> None: """``is_high_fps_enabled`` tracks the ``highFps`` video mode.""" obj = PublicCamera.from_unifi_dict( api=Mock(), **{**CAMERA_PAYLOAD, "videoMode": video_mode} ) assert obj.is_high_fps_enabled is expected @pytest.mark.parametrize( ("hdr_type", "expected"), [("auto", "auto"), ("on", "always"), ("off", "off")], ) def test_public_camera_hdr_mode_display(hdr_type: str, expected: str) -> None: """``hdr_mode_display`` inverts the public HDR enum to the interface labels.""" obj = PublicCamera.from_unifi_dict( api=Mock(), **{**CAMERA_PAYLOAD, "hdrType": hdr_type} ) assert obj.hdr_mode_display == expected def test_public_sensor_sub_models_typed() -> None: """Sensor leaf payloads parse into the dedicated read-shape sub-models.""" sensor = PublicSensor.from_unifi_dict(api=Mock(), **dict(SENSOR_PAYLOAD)) assert sensor.battery_status.percentage == 90 assert sensor.stats.temperature.value == 22.5 assert sensor.stats.humidity.value is None assert sensor.leak_settings.is_internal_enabled is False assert sensor.schedule_mode is SensorScheduleMode.WHEN_ARMED assert sensor.glass_break_settings.is_enabled is True assert sensor.glass_break_settings.sensitivity_when_armed == 90 assert sensor.motion_settings.sensitivity_when_armed == 80 assert sensor.arm_profile_ids == ["profile1"] assert sensor.has_custom_sensitivity_when_armed is True def test_public_device_identity_optional_and_round_trips() -> None: """``type`` / ``guid`` map to ``device_type`` / ``device_guid`` and round-trip.""" data = dict(SENSOR_PAYLOAD) data["type"] = "Example-Model" data["guid"] = "00000000-0000-0000-0000-000000000000" sensor = PublicSensor.from_unifi_dict(api=Mock(), **data) assert sensor.device_type == "Example-Model" assert sensor.device_guid == "00000000-0000-0000-0000-000000000000" # ``model`` (the modelKey enum) is untouched by the new fields. assert sensor.model is ModelType.SENSOR dumped = sensor.unifi_dict() assert dumped["type"] == "Example-Model" assert dumped["guid"] == "00000000-0000-0000-0000-000000000000" def test_sensor_feature_capability_enum_matches_flag_fields() -> None: """``supports`` does ``getattr`` by enum value, so the names must stay in lockstep.""" assert {c.value for c in SensorFeatureCapability} == set( PublicSensorFeatureFlags.model_fields ) def test_public_sensor_feature_flags_capabilities() -> None: """``featureFlags`` parses into the capability map and the presence helpers.""" data = dict(SENSOR_PAYLOAD) data["featureFlags"] = { "temperature": {"channelCount": 1}, "humidity": {"channelCount": 1}, "light": {"channelCount": 1}, "waterLeak": {"channelCount": 2}, # A present capability that omits ``channelCount`` must not raise. "motion": {}, } sensor = PublicSensor.from_unifi_dict(api=Mock(), **data) assert sensor.has_feature_flags is True assert sensor.feature_flags.water_leak.channel_count == 2 assert sensor.feature_flags.motion.channel_count == 0 assert sensor.supports(SensorFeatureCapability.TEMPERATURE) is True assert sensor.supports(SensorFeatureCapability.WATER_LEAK) is True assert sensor.supports(SensorFeatureCapability.MOTION) is True # An absent capability key means "not supported". assert sensor.supports(SensorFeatureCapability.SMOKE) is False @pytest.mark.parametrize( ("internal", "external", "expected"), [(None, None, False), (1, None, True), (None, 1, True), (1, 1, True)], ) def test_public_sensor_is_leak_detected_aggregates_channels( internal: int | None, external: int | None, expected: bool ) -> None: """``is_leak_detected`` is the OR of the internal and external leak channels.""" data = dict(SENSOR_PAYLOAD) data["leakDetectedAt"] = internal data["externalLeakDetectedAt"] = external sensor = PublicSensor.from_unifi_dict(api=Mock(), **data) assert sensor.is_leak_detected is expected @pytest.mark.parametrize(("at", "expected"), [(None, False), (123, True)]) def test_public_sensor_is_tampering_detected(at: int | None, expected: bool) -> None: sensor = PublicSensor.from_unifi_dict( api=Mock(), **{**SENSOR_PAYLOAD, "tamperingDetectedAt": at} ) assert sensor.is_tampering_detected is expected @pytest.mark.parametrize( ("mount_type", "temp_en", "leak_en", "contact_en"), [ ("door", True, False, True), ("leak", False, True, False), ("none", True, False, False), ], ) def test_public_sensor_enabled_properties_respect_mount_type( mount_type: str, temp_en: bool, leak_en: bool, contact_en: bool ) -> None: """Environmental metrics are suppressed on leak mounts; leak only on leak mounts.""" data = dict(SENSOR_PAYLOAD) data["mountType"] = mount_type for key in ( "temperatureSettings", "humiditySettings", "lightSettings", "motionSettings", "alarmSettings", ): data[key] = {"isEnabled": True} sensor = PublicSensor.from_unifi_dict(api=Mock(), **data) # The five environmental/alarm metrics share the same gate (enabled unless # leak-mounted), so they all track ``temp_en``. assert sensor.is_temperature_sensor_enabled is temp_en assert sensor.is_humidity_sensor_enabled is temp_en assert sensor.is_light_sensor_enabled is temp_en assert sensor.is_motion_sensor_enabled is temp_en assert sensor.is_alarm_sensor_enabled is temp_en assert sensor.is_leak_sensor_enabled is leak_en assert sensor.is_contact_sensor_enabled is contact_en def test_public_sensor_old_shape_has_no_capabilities() -> None: """Older firmware omits the new fields; they default and the capability helpers degrade safely.""" sensor = PublicSensor.from_unifi_dict(api=Mock(), **dict(SENSOR_PAYLOAD)) assert sensor.device_type is None assert sensor.device_guid is None assert sensor.feature_flags is None assert sensor.has_feature_flags is False assert sensor.supports(SensorFeatureCapability.TEMPERATURE) is False def test_public_nvr_device_identity_round_trips() -> None: """``type`` / ``guid`` map onto the NVR and round-trip; absent → ``None``.""" nvr = PublicNVR.from_unifi_dict( api=Mock(), id="nvr1", modelKey="nvr", type="Example-Model", guid="00000000-0000-0000-0000-000000000000", ) assert nvr.device_type == "Example-Model" assert nvr.device_guid == "00000000-0000-0000-0000-000000000000" dumped = nvr.unifi_dict() assert dumped["type"] == "Example-Model" assert dumped["guid"] == "00000000-0000-0000-0000-000000000000" bare = PublicNVR.from_unifi_dict(api=Mock(), id="nvr1", modelKey="nvr") assert bare.device_type is None assert bare.device_guid is None @pytest.mark.parametrize( ("cls", "payload"), [ (PublicCamera, CAMERA_PAYLOAD), (PublicLight, LIGHT_PAYLOAD), (PublicSensor, SENSOR_PAYLOAD), (PublicChime, CHIME_PAYLOAD), ], ) def test_public_type_aliases_device_type(cls: type, payload: dict[str, Any]) -> None: """``type`` mirrors ``device_type`` (the private tree's ``type`` field).""" data = dict(payload) data["type"] = "Example-Model" obj = cls.from_unifi_dict(api=Mock(), **data) assert obj.type == "Example-Model" assert obj.type == obj.device_type bare = cls.from_unifi_dict(api=Mock(), **dict(payload)) assert bare.type is None @pytest.mark.parametrize( ("name", "device_type", "expected"), [ ("Front Door", "Example-Model", "Front Door"), (None, "Example-Model", "Example-Model"), (None, None, ""), ], ) def test_public_display_name_fallback( name: str | None, device_type: str | None, expected: str ) -> None: """``display_name`` falls back ``name -> type -> ""`` like the private tree.""" nvr = PublicNVR.from_unifi_dict( api=Mock(), id="nvr1", modelKey="nvr", name=name, type=device_type ) assert nvr.display_name == expected @pytest.mark.asyncio async def test_shared_identity_protocol_spans_both_trees( nvr_obj: Any, camera_obj: Any ) -> None: """One ``ProtectDeviceIdentity`` variable accepts either tree without ``cast()``.""" if camera_obj is None: pytest.skip("No camera_obj found") public_nvr = PublicNVR.from_unifi_dict( api=Mock(), id="nvr1", modelKey="nvr", name="Console", type="UNVR" ) public_camera = PublicCamera.from_unifi_dict(api=Mock(), **dict(CAMERA_PAYLOAD)) def identity_line(device: ProtectDeviceIdentity) -> str: return f"{device.display_name} ({device.type}) [{device.mac}] {device.id}" for device in (nvr_obj, camera_obj, public_nvr, public_camera): assert isinstance(device, ProtectDeviceIdentity) assert identity_line(device) assert public_nvr.display_name == "Console" assert public_nvr.type == "UNVR" @pytest.mark.parametrize( ("model_type", "payload", "cls", "store_attr"), [ (ModelType.CAMERA, CAMERA_PAYLOAD, PublicCamera, "cameras"), (ModelType.LIGHT, LIGHT_PAYLOAD, PublicLight, "lights"), (ModelType.SENSOR, SENSOR_PAYLOAD, PublicSensor, "sensors"), (ModelType.CHIME, CHIME_PAYLOAD, PublicChime, "chimes"), ], ) def test_ws_add_update_remove_routes_to_public_model( model_type: ModelType, payload: dict[str, Any], cls: type, store_attr: str, ) -> None: """WS add/update/remove caches the dedicated public model, not the private one.""" pb = PublicBootstrap() api = Mock() mt, new, _old = pb.process_devices_ws_message( api, {"type": "add", "item": dict(payload)} ) assert mt is model_type assert isinstance(new, cls) store = getattr(pb, store_attr) assert isinstance(store[payload["id"]], cls) # update diff that omits ``name`` must preserve it. mt, merged, _old = pb.process_devices_ws_message( api, { "type": "update", "item": {"id": payload["id"], "modelKey": payload["modelKey"], "mac": "X"}, }, ) assert isinstance(merged, cls) assert merged.name == payload["name"] assert merged.mac == "X" pb.process_devices_ws_message( api, { "type": "remove", "item": {"id": payload["id"], "modelKey": payload["modelKey"]}, }, ) assert payload["id"] not in getattr(pb, store_attr) def test_get_device_mac_resolves_public_devices() -> None: """Routing the four types via factory slots keeps them mac-resolvable.""" pb = PublicBootstrap() pb.process_devices_ws_message(Mock(), {"type": "add", "item": dict(SENSOR_PAYLOAD)}) assert pb.get_device_mac("sensor1") == "AABBCCDDEE02" @pytest.mark.parametrize("cls", [PublicCamera, PublicLight, PublicSensor, PublicChime]) @pytest.mark.asyncio() async def test_public_model_api_update_blocked(cls: type) -> None: """The generic mutation path is blocked in favor of the public helpers.""" obj = cls.model_construct() with pytest.raises(BadRequest): await obj._api_update({}) def _seed_camera(pb: PublicBootstrap, state: str) -> None: pb.process_devices_ws_message( Mock(), {"type": "add", "item": {**CAMERA_PAYLOAD, "state": state}} ) def test_camera_connect_schedules_rtsps_refresh_keeping_cache() -> None: """A camera transitioning to CONNECTED schedules a refresh, keeping its streams.""" pb = PublicBootstrap() api = Mock() _seed_camera(pb, "CONNECTING") streams = RTSPSStreams(high="rtsps://example.com/high") pb.cameras["cam1"].rtsps_streams = streams pb.process_devices_ws_message( api, { "type": "update", "item": {"id": "cam1", "modelKey": "camera", "state": "CONNECTED"}, }, ) # The field is never emptied — the old URLs stay until the background # refresh overwrites them in place. assert pb.cameras["cam1"].rtsps_streams is streams api._schedule_rtsps_refresh.assert_called_once_with("cam1") def test_camera_connect_schedules_rtsps_prime_when_streamless() -> None: """A streamless camera coming online schedules a prime, not just a refresh.""" pb = PublicBootstrap() api = Mock() _seed_camera(pb, "DISCONNECTED") assert pb.cameras["cam1"].rtsps_streams is None pb.process_devices_ws_message( api, { "type": "update", "item": {"id": "cam1", "modelKey": "camera", "state": "CONNECTED"}, }, ) api._schedule_rtsps_refresh.assert_called_once_with("cam1") def test_camera_steady_connected_update_keeps_cached_rtsps_streams() -> None: """An update on an already-connected camera leaves the RTSPS field intact.""" pb = PublicBootstrap() api = Mock() _seed_camera(pb, "CONNECTED") streams = RTSPSStreams(high="rtsps://example.com/high") pb.cameras["cam1"].rtsps_streams = streams pb.process_devices_ws_message( api, { "type": "update", "item": {"id": "cam1", "modelKey": "camera", "name": "Renamed"}, }, ) assert pb.cameras["cam1"].rtsps_streams is streams api._schedule_rtsps_refresh.assert_not_called() def test_camera_remove_cancels_pending_rtsps_refresh() -> None: """A camera-remove WS frame drops the camera and cancels any pending refresh.""" pb = PublicBootstrap() api = Mock() _seed_camera(pb, "CONNECTED") pb.cameras["cam1"].rtsps_streams = RTSPSStreams(high="rtsps://example.com/high") pb.process_devices_ws_message( api, {"type": "remove", "item": {"id": "cam1", "modelKey": "camera"}}, ) # The streams ride with the removed camera object. assert "cam1" not in pb.cameras api._cancel_rtsps_refresh.assert_called_once_with("cam1") def test_non_camera_remove_leaves_camera_rtsps_untouched() -> None: """A non-camera remove frame does not cancel a camera's refresh or streams.""" pb = PublicBootstrap() api = Mock() _seed_camera(pb, "CONNECTED") streams = RTSPSStreams(high="rtsps://example.com/high") pb.cameras["cam1"].rtsps_streams = streams pb.process_devices_ws_message( api, {"type": "add", "item": {**LIGHT_PAYLOAD, "state": "CONNECTED"}} ) pb.process_devices_ws_message( api, {"type": "remove", "item": {"id": "light1", "modelKey": "light"}}, ) assert pb.cameras["cam1"].rtsps_streams is streams api._cancel_rtsps_refresh.assert_not_called() def test_non_camera_connect_leaves_camera_rtsps_untouched() -> None: """A non-camera device reaching CONNECTED does not schedule a camera refresh.""" pb = PublicBootstrap() api = Mock() _seed_camera(pb, "CONNECTED") streams = RTSPSStreams(high="rtsps://example.com/high") pb.cameras["cam1"].rtsps_streams = streams pb.process_devices_ws_message( api, {"type": "add", "item": {**LIGHT_PAYLOAD, "state": "CONNECTING"}} ) pb.process_devices_ws_message( api, { "type": "update", "item": {"id": "light1", "modelKey": "light", "state": "CONNECTED"}, }, ) assert pb.cameras["cam1"].rtsps_streams is streams api._schedule_rtsps_refresh.assert_not_called() EPOCH_MS = 1_700_000_000_123 EPOCH_DT = datetime(2023, 11, 14, 22, 13, 20, 123000, tzinfo=UTC) def _light_with(last_motion: int | None) -> PublicLight: return PublicLight.from_unifi_dict( api=Mock(), **{**LIGHT_PAYLOAD, "lastMotion": last_motion} ) def _sensor_with(wire_key: str, value: int | None) -> PublicSensor: return PublicSensor.from_unifi_dict( api=Mock(), **{**SENSOR_PAYLOAD, wire_key: value} ) @pytest.mark.parametrize( ("build", "prop"), [ (lambda v: PublicLcdMessage(reset_at=v), "reset_at_dt"), (_light_with, "last_motion_dt"), ( lambda v: _sensor_with("openStatusChangedAt", v), "open_status_changed_at_dt", ), (lambda v: _sensor_with("motionDetectedAt", v), "motion_detected_at_dt"), (lambda v: _sensor_with("alarmTriggeredAt", v), "alarm_triggered_at_dt"), (lambda v: _sensor_with("leakDetectedAt", v), "leak_detected_at_dt"), ( lambda v: _sensor_with("externalLeakDetectedAt", v), "external_leak_detected_at_dt", ), ( lambda v: _sensor_with("tamperingDetectedAt", v), "tampering_detected_at_dt", ), ( lambda v: PublicSirenStatus(is_active=True, activated_at=v, duration=5000), "activated_at_dt", ), ( lambda v: AlarmHubInput( enable=OnOffState.ON, type=AlarmHubInputContactType.NO, status=AlarmHubInputStatus.NORMAL, last_triggered_at=v, ), "last_triggered_at_dt", ), ( lambda v: NvrArmMode(status=NvrArmModeStatus.ARMED, armed_at=v), "armed_at_dt", ), ( lambda v: NvrArmMode(status=NvrArmModeStatus.ARMING, will_be_armed_at=v), "will_be_armed_at_dt", ), ( lambda v: NvrArmMode(status=NvrArmModeStatus.BREACH, breach_detected_at=v), "breach_detected_at_dt", ), ], ) def test_public_epoch_ms_datetime_accessors( build: Callable[[int | None], Any], prop: str ) -> None: """Convert epoch-ms fields to UTC datetimes and preserve ``None``.""" assert getattr(build(EPOCH_MS), prop) == EPOCH_DT assert getattr(build(None), prop) is None def test_public_siren_turn_off_at_matches_activated_at_dt() -> None: """``turn_off_at`` is ``activated_at_dt`` plus the run duration.""" # ``duration`` is milliseconds on the status object (the play request takes # seconds), so a 5000 ms run ends five seconds after activation. status = PublicSirenStatus(is_active=True, activated_at=EPOCH_MS, duration=5000) assert status.turn_off_at == EPOCH_DT + timedelta(seconds=5) SIREN_PAYLOAD: dict[str, Any] = { "id": "siren1", "modelKey": "siren", "state": "CONNECTED", "name": "Hallway Siren", "mac": "AABBCCDDEE03", "volume": 80, "ledSettings": {"isEnabled": True}, "sirenStatus": {"isActive": False, "activatedAt": None, "duration": None}, "connectionType": "lora", } def test_siren_add_arms_expiry_announcement() -> None: """A siren add frame arms the timer that announces its run ending.""" pb = PublicBootstrap() api = Mock() pb.process_devices_ws_message(api, {"type": "add", "item": dict(SIREN_PAYLOAD)}) api._schedule_siren_off.assert_called_once_with("siren1") api._cancel_siren_off.assert_not_called() def test_siren_update_rearms_expiry_announcement() -> None: """Every siren update re-derives the deadline from the merged status.""" pb = PublicBootstrap() api = Mock() pb.process_devices_ws_message(api, {"type": "add", "item": dict(SIREN_PAYLOAD)}) api.reset_mock() pb.process_devices_ws_message( api, { "type": "update", "item": { "id": "siren1", "modelKey": "siren", "sirenStatus": { "isActive": True, "activatedAt": 1_700_000_000_000, "duration": 5000, }, }, }, ) api._schedule_siren_off.assert_called_once_with("siren1") def test_siren_remove_drops_expiry_announcement() -> None: """A removed siren takes its pending expiry timer with it.""" pb = PublicBootstrap() api = Mock() pb.process_devices_ws_message(api, {"type": "add", "item": dict(SIREN_PAYLOAD)}) api.reset_mock() pb.process_devices_ws_message( api, {"type": "remove", "item": {"id": "siren1", "modelKey": "siren"}} ) assert "siren1" not in pb.sirens api._cancel_siren_off.assert_called_once_with("siren1") api._schedule_siren_off.assert_not_called() def test_non_siren_frame_leaves_expiry_announcement_alone() -> None: """Frames for other device types never touch the siren timers.""" pb = PublicBootstrap() api = Mock() pb.process_devices_ws_message(api, {"type": "add", "item": dict(LIGHT_PAYLOAD)}) pb.process_devices_ws_message( api, {"type": "remove", "item": {"id": "light1", "modelKey": "light"}} ) api._schedule_siren_off.assert_not_called() api._cancel_siren_off.assert_not_called() uilibs-uiprotect-317a52c/tests/test_public_event.py000066400000000000000000000275651524637705700226350ustar00rootroot00000000000000"""Spec-fixture tests for the public-API ``PublicEvent`` model.""" from __future__ import annotations from datetime import datetime import pytest from uiprotect.data.public_event import ( PUBLIC_EVENT_TYPES, PublicEvent, PublicEventMetadata, PublicFingerprintMetadata, PublicNfcMetadata, ) from uiprotect.data.types import ( AlarmHubTamperStatus, EventButtonType, EventType, ModelType, MountType, RelayInputCircuitState, SensorAlarmType, SensorExtremeMetricType, SensorStatusType, SmartDetectObjectType, SmokeTestSource, ) # The wire values of the spec's ``event`` ``oneOf``, in spec order. Committed as # a literal on purpose: ``scripts/validate_spec.py::check_event_types`` compares # ``PUBLIC_EVENT_TYPES`` against a fetched spec, but the spec is gitignored and # only the nightly cron fetches it, so this list is the only pin CI ever sees. _ALL_EVENT_TYPES = [ "ring", "sensorExtremeValues", "sensorWaterLeak", "sensorTamper", "sensorBatteryLow", "sensorAlarm", "sensorVape", "sensorOpened", "sensorClosed", "sensorSmokeTest", "sensorSmokeBatteryLow", "sensorSmokeNeedsCleaning", "sensorSmokeFault", "sensorCoFault", "sensorSmokeEndOfLife", "sensorMotion", "sensorButtonPressed", "lightMotion", "motion", "smartAudioDetect", "smartDetectZone", "smartDetectLine", "smartDetectLoiterZone", "relayInputChanged", "cameraDigitalInputChanged", "alarmHubMotion", "alarmHubEntryOpened", "alarmHubEntryClosed", "alarmHubSmoke", "alarmHubGlassBreak", "alarmHubButtonPress", "alarmHubTamper", "alarmHubDeviceTamper", "alarmHubRelaySwitched", "alarmHubBatteryLow", "alarmHubBatteryConnected", "nfcCardScanned", "fingerprintIdentified", ] def test_public_event_types_matches_wire_pin() -> None: assert {t.value for t in PUBLIC_EVENT_TYPES} == set(_ALL_EVENT_TYPES) assert len(_ALL_EVENT_TYPES) == len(set(_ALL_EVENT_TYPES)) == 38 def _minimal(type_str: str, **extra: object) -> dict[str, object]: return { "id": "evt-1", "modelKey": "event", "type": type_str, "start": 1735689600000, "device": "aabbccddeeff00112233aabb", **extra, } @pytest.mark.parametrize("type_str", _ALL_EVENT_TYPES) def test_every_public_event_type_parses(type_str: str) -> None: event = PublicEvent.from_unifi_dict(**_minimal(type_str)) assert event.id == "evt-1" assert event.model is ModelType.EVENT assert event.type.value == type_str assert event.device_id == "aabbccddeeff00112233aabb" assert isinstance(event.start, datetime) def test_event_envelope_fields() -> None: event = PublicEvent.from_unifi_dict( **_minimal( "smartDetectZone", end=1735689601000, smartDetectTypes=["person"], ) ) assert event.end is not None and isinstance(event.end, datetime) assert event.smart_detect_types == [SmartDetectObjectType.PERSON] def test_unknown_smart_detect_type_dropped() -> None: """A runtime-generated smart detect type in an event payload is dropped, not fatal.""" event = PublicEvent.from_unifi_dict( **_minimal( "smartDetectZone", smartDetectTypes=["person", "linecrossing_basic"], ) ) assert event.smart_detect_types == [SmartDetectObjectType.PERSON] def test_sensor_extreme_metric_enum_resolves() -> None: event = PublicEvent.from_unifi_dict( **_minimal( "sensorExtremeValues", metadata={ "sensorType": {"text": "co2"}, "sensorValue": {"text": 22.5}, "status": {"text": "high"}, }, ) ) assert event.metadata is not None assert event.metadata.sensor_type is SensorExtremeMetricType.CO2 assert event.metadata.sensor_value == 22.5 assert event.metadata.status is SensorStatusType.HIGH def test_alarm_type_enum_resolves() -> None: event = PublicEvent.from_unifi_dict( **_minimal("sensorAlarm", metadata={"alarmType": {"text": "glassBreak"}}) ) assert event.metadata is not None assert event.metadata.alarm_type is SensorAlarmType.GLASS_BREAK def test_relay_input_state_enum_resolves() -> None: event = PublicEvent.from_unifi_dict( **_minimal( "relayInputChanged", metadata={ "inputState": {"text": "circuitOpen"}, "inputChannel": {"text": "0"}, }, ) ) assert event.metadata is not None assert event.metadata.input_state is RelayInputCircuitState.CIRCUIT_OPEN assert event.metadata.input_channel == "0" def test_camera_digital_input_token_round_trips() -> None: """``cameraDigitalInputChanged`` carries ``inputToken`` in a text envelope.""" event = PublicEvent.from_unifi_dict( **_minimal( "cameraDigitalInputChanged", metadata={ "inputState": {"text": "circuitClosed"}, "inputToken": {"text": "AlarmIn_0"}, "inputChannel": {"text": "0"}, }, ) ) assert event.type is EventType.CAMERA_DIGITAL_INPUT_CHANGED assert event.metadata is not None assert event.metadata.input_token == "AlarmIn_0" # noqa: S105 — ONVIF token assert event.metadata.input_state is RelayInputCircuitState.CIRCUIT_CLOSED assert event.unifi_dict()["metadata"] == { "inputState": {"text": "circuitClosed"}, "inputToken": {"text": "AlarmIn_0"}, "inputChannel": {"text": "0"}, } def test_alarm_hub_device_tamper_parses() -> None: event = PublicEvent.from_unifi_dict( **_minimal("alarmHubDeviceTamper", metadata={"status": {"text": "tampered"}}) ) assert event.type is EventType.ALARM_HUB_DEVICE_TAMPER def test_smoke_test_source_enum_resolves_flat() -> None: """``sensorSmokeTest`` ``metadata.source`` is a flat string, not enveloped.""" event = PublicEvent.from_unifi_dict( **_minimal("sensorSmokeTest", metadata={"source": "local"}) ) assert event.metadata is not None assert event.metadata.source is SmokeTestSource.LOCAL @pytest.mark.parametrize( ("type_str", "metadata", "field", "enum_cls"), [ ( "sensorExtremeValues", {"sensorType": {"text": "unobtanium"}}, "sensor_type", SensorExtremeMetricType, ), ( "sensorAlarm", {"alarmType": {"text": "unobtanium"}}, "alarm_type", SensorAlarmType, ), ( "relayInputChanged", {"inputState": {"text": "unobtanium"}}, "input_state", RelayInputCircuitState, ), ( "sensorSmokeTest", {"source": "unobtanium"}, "source", SmokeTestSource, ), ], ) def test_unmodelled_enum_value_coerces_to_unknown( type_str: str, metadata: dict[str, object], field: str, enum_cls: type, ) -> None: event = PublicEvent.from_unifi_dict(**_minimal(type_str, metadata=metadata)) assert event.metadata is not None assert getattr(event.metadata, field) is enum_cls.UNKNOWN def test_button_enum_resolves() -> None: event = PublicEvent.from_unifi_dict( **_minimal("sensorButtonPressed", metadata={"button": {"text": "panic"}}) ) assert event.metadata is not None assert event.metadata.button is EventButtonType.PANIC @pytest.mark.parametrize("type_str", ["sensorButtonPressed", "alarmHubButtonPress"]) def test_main_button_enum_resolves(type_str: str) -> None: event = PublicEvent.from_unifi_dict( **_minimal(type_str, metadata={"button": {"text": "main"}}) ) assert event.metadata is not None assert event.metadata.button is EventButtonType.MAIN def test_nox_sensor_extreme_metric_enum_resolves() -> None: event = PublicEvent.from_unifi_dict( **_minimal( "sensorExtremeValues", metadata={ "sensorType": {"text": "nox"}, "sensorValue": {"text": 1.5}, "status": {"text": "high"}, }, ) ) assert event.metadata is not None assert event.metadata.sensor_type is SensorExtremeMetricType.NOX @pytest.mark.parametrize("status", ["tampered", "restored"]) def test_alarm_hub_tamper_status_modelled(status: str) -> None: """``AlarmHubTamperStatus`` models the tamper states, unwired from the model.""" assert AlarmHubTamperStatus(status) is not AlarmHubTamperStatus.UNKNOWN metadata = PublicEventMetadata.from_unifi_dict( status={"text": status}, deviceId={"text": "aabbccddeeff00112233aabb"}, deviceName={"text": "Alarm Hub"}, ) # The ``status`` wire key is shared with ``sensorExtremeValues``, so the field # stays typed as ``SensorStatusType``; retyping it is a breaking change. assert metadata.status is SensorStatusType.UNKNOWN def test_alarm_hub_tamper_status_unknown_value() -> None: assert AlarmHubTamperStatus("unobtanium") is AlarmHubTamperStatus.UNKNOWN def test_mount_type_enum_resolves() -> None: event = PublicEvent.from_unifi_dict( **_minimal("sensorOpened", metadata={"sensorMountType": {"text": "garage"}}) ) assert event.metadata is not None assert event.metadata.sensor_mount_type is MountType.GARAGE def test_sensor_battery_percentage_number_envelope() -> None: """``sensorBatteryPercentage`` uses a ``{"number": ...}`` envelope, not ``text``.""" event = PublicEvent.from_unifi_dict( **_minimal( "sensorBatteryLow", metadata={"sensorBatteryPercentage": {"number": 95}}, ) ) assert event.metadata is not None assert event.metadata.sensor_battery_percentage == 95 def test_alarm_hub_string_metadata_collapses() -> None: event = PublicEvent.from_unifi_dict( **_minimal( "alarmHubMotion", metadata={ "pin": {"text": "0"}, "deviceId": {"text": "hub-1"}, "deviceName": {"text": "Front Door"}, }, ) ) assert event.metadata is not None assert event.metadata.pin == "0" assert event.metadata.device_id == "hub-1" assert event.metadata.device_name == "Front Door" def test_nfc_and_fingerprint_submodels() -> None: nfc = PublicEvent.from_unifi_dict( **_minimal("nfcCardScanned", metadata={"nfc": {"ulpId": "ulp-1"}}) ) assert nfc.metadata is not None assert isinstance(nfc.metadata.nfc, PublicNfcMetadata) assert nfc.metadata.nfc.ulp_id == "ulp-1" fp = PublicEvent.from_unifi_dict( **_minimal("fingerprintIdentified", metadata={"fingerprint": {"ulpId": None}}) ) assert fp.metadata is not None assert isinstance(fp.metadata.fingerprint, PublicFingerprintMetadata) assert fp.metadata.fingerprint.ulp_id is None def test_malformed_envelope_without_text_is_dropped() -> None: """A ``{...}`` value missing its ``text``/``number`` key is discarded, not raised.""" event = PublicEvent.from_unifi_dict( **_minimal("sensorAlarm", metadata={"alarmType": {"unexpected": "x"}}) ) assert event.metadata is not None assert event.metadata.alarm_type is None def test_metadata_round_trips_and_strips_none() -> None: md = PublicEventMetadata( sensor_type=SensorExtremeMetricType.CO2, sensor_value=22.5, status=SensorStatusType.HIGH, sensor_battery_percentage=95, ) wire = md.unifi_dict() assert wire == { "sensorType": {"text": "co2"}, "sensorValue": {"text": 22.5}, "status": {"text": "high"}, "sensorBatteryPercentage": {"number": 95}, } def test_event_round_trips_metadata() -> None: event = PublicEvent.from_unifi_dict( **_minimal("sensorAlarm", metadata={"alarmType": {"text": "smoke"}}) ) assert event.unifi_dict()["metadata"]["alarmType"] == {"text": "smoke"} uilibs-uiprotect-317a52c/tests/test_public_schema_conformance.py000066400000000000000000000106421524637705700253120ustar00rootroot00000000000000"""Opt-in guard: public device models must not declare fields absent from the spec.""" # The spec (``openapi/integration.json``) is gitignored and not fetched in CI, so # this test skips cleanly when it is absent. Run ``python scripts/fetch_openapi.py`` # (defaults to the latest release) locally to enable it. # # The check is a subset assertion (model fields ⊆ spec fields), NOT strict # equality: it catches phantom fields — model fields with no counterpart in the # public contract, the regression this guard exists to prevent — while staying # robust across spec releases. The public spec is append-only in practice, so a # model targeting the latest shape stays a subset of any newer spec; and fields # added in a release after our minimum-supported Protect version are modelled as # optional, so "spec has a field the model doesn't" is expected, not a failure. # # The resolution helpers and the four check functions live in # ``scripts/validate_spec.py`` (the source of truth shared with the # spec-validation workflow); this module imports them so the local hook runs the # full validation for free when a contributor has fetched the spec. from __future__ import annotations from typing import TYPE_CHECKING, Any import orjson import pytest from validate_spec import ( # local import via conftest sys.path insert _EXTRA_MODEL_FIELDS, _LIBRARY_OWNED_FIELDS, _MODEL_SCHEMAS, SPEC_PATH, _leaf_model, _resolve_object_props, _spec_field_name, check_completeness, check_enum_coverage, run_checks, ) if TYPE_CHECKING: from uiprotect.data.base import ProtectBaseObject _SPEC_PATH = SPEC_PATH def _assert_matches( cls: type[ProtectBaseObject], node: dict[str, Any], schemas: dict[str, Any], path: str, ) -> None: """Assert ``cls.model_fields`` is a subset of the spec property set (no phantom fields), recursively.""" props = _resolve_object_props(node, schemas) assert props is not None, f"{path}: spec schema is not object-shaped" remaps = cls._get_unifi_remaps() spec_fields = {_spec_field_name(key, remaps) for key in props} phantom = ( set(cls.model_fields) - spec_fields - _LIBRARY_OWNED_FIELDS.get(path, set()) - _EXTRA_MODEL_FIELDS.get(path, set()) ) assert not phantom, ( f"{path}: model declares field(s) absent from the spec: {sorted(phantom)}" ) for key, prop_schema in props.items(): if _resolve_object_props(prop_schema, schemas) is None: continue # scalar / enum leaf — nothing nested to compare field = cls.model_fields.get(_spec_field_name(key, remaps)) if field is None: continue leaf = _leaf_model(field.annotation) if leaf is not None: _assert_matches(leaf, prop_schema, schemas, f"{path}.{key}") @pytest.mark.skipif(not _SPEC_PATH.exists(), reason="openapi/integration.json absent") @pytest.mark.parametrize( ("cls", "schema_name"), _MODEL_SCHEMAS, ids=[schema_name for _cls, schema_name in _MODEL_SCHEMAS], ) def test_public_model_matches_spec( cls: type[ProtectBaseObject], schema_name: str ) -> None: """``model_fields`` is a subset of the resolved spec property set, including nested leaves.""" schemas = orjson.loads(_SPEC_PATH.read_bytes())["components"]["schemas"] _assert_matches( cls, {"$ref": f"#/components/schemas/{schema_name}"}, schemas, schema_name ) @pytest.mark.skipif(not _SPEC_PATH.exists(), reason="openapi/integration.json absent") def test_spec_validation_has_no_errors() -> None: """The full spec-validation check suite reports no errors against the on-disk spec.""" spec = orjson.loads(_SPEC_PATH.read_bytes()) errors, _warnings = run_checks(spec) assert not errors, "spec drift:\n" + "\n".join(errors) @pytest.mark.skipif(not _SPEC_PATH.exists(), reason="openapi/integration.json absent") def test_every_inbound_spec_enum_is_modelled_or_waived() -> None: """Every inbound spec enum is exact-matched, pinned to a superset, or waived.""" spec = orjson.loads(_SPEC_PATH.read_bytes()) errors, _warnings = check_enum_coverage(spec) assert not errors, "unmodelled, unwaived spec enums:\n" + "\n".join(errors) def test_every_public_coroutine_is_covered() -> None: """No public-API coroutine is left out of the derived coverage set (spec-free).""" gaps = check_completeness() assert not gaps, "uncovered public-API coroutines:\n" + "\n".join(gaps) uilibs-uiprotect-317a52c/tests/test_public_setters.py000066400000000000000000001133001524637705700231640ustar00rootroot00000000000000"""Convenience setters on the public device model tree (public-only writes).""" from __future__ import annotations import asyncio from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from uiprotect.data.public_bootstrap import PublicBootstrap from uiprotect.data.public_devices import ( PublicCamera, PublicChime, PublicLight, PublicSensor, RTSPSStreams, Siren, ) from uiprotect.data.types import ( DoorbellMessageType, LightModeEnableType, LightModeType, OsdOverlayLocation, PublicHdrMode, SensorScheduleMode, SmartDetectAudioType, SmartDetectObjectType, VideoMode, ) from uiprotect.exceptions import BadRequest def _camera(api: Any) -> PublicCamera: return PublicCamera.from_unifi_dict( api=api, id="cam-1", modelKey="camera", state="CONNECTED", name="Cam", mac="AABBCCDDEEFF", isMicEnabled=True, osdSettings={ "isNameEnabled": False, "isDateEnabled": False, "isLogoEnabled": False, "isDebugEnabled": False, "overlayLocation": "topLeft", }, ledSettings={"isEnabled": False, "welcomeLed": None, "floodLed": None}, lcdMessage={}, micVolume=50, activePatrolSlot=None, videoMode="default", hdrType="auto", featureFlags={ "supportFullHdSnapshot": True, "hasHdr": True, "hasMic": True, "hasLedStatus": True, "hasSpeaker": False, "videoModes": ["default", "highFps"], "smartDetectTypes": ["person", "vehicle", "animal", "package", "face"], "smartDetectAudioTypes": ["alrmSmoke", "alrmCmonx", "alrmSiren"], }, smartDetectSettings={"objectTypes": ["person"], "audioTypes": []}, hasPackageCamera=False, ) def _light(api: Any) -> PublicLight: return PublicLight.from_unifi_dict( api=api, id="light-1", modelKey="light", state="CONNECTED", mac="AABBCCDDEE01", name="Light", lightModeSettings={"mode": "motion", "enableAt": "fulltime"}, lightDeviceSettings={ "isIndicatorEnabled": True, "pirDuration": 30000, "pirSensitivity": 50, "ledLevel": 3, }, isDark=True, isLightOn=False, isLightForceEnabled=False, isPirMotionDetected=False, camera=None, ) def _sensor(api: Any) -> PublicSensor: return PublicSensor.from_unifi_dict( api=api, id="sensor-1", modelKey="sensor", state="CONNECTED", mac="AABBCCDDEE02", name="Sensor", mountType="door", batteryStatus={}, stats={}, lightSettings={}, humiditySettings={}, temperatureSettings={}, isMotionDetected=False, motionSettings={"isEnabled": True, "sensitivity": 40}, alarmSettings={}, leakSettings={}, wirelessConnectionState={}, ) def _chime(api: Any) -> PublicChime: return PublicChime.from_unifi_dict( api=api, id="chime-1", modelKey="chime", state="CONNECTED", mac="AABBCCDDEE03", name="Chime", cameraIds=["cam-a", "cam-b"], ringSettings=[ {"cameraId": "cam-a", "volume": 50, "repeatTimes": 1}, {"cameraId": "cam-b", "volume": 60, "repeatTimes": 2}, ], ) # --------------------------------------------------------------------------- # Write-through helper # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_apply_from_response_merges_and_skips_rtsps() -> None: api = MagicMock() cam = _camera(api) cam.rtsps_streams = RTSPSStreams(high="rtsps://sentinel") fresh = _camera(api) fresh.mic_volume = 42 fresh.rtsps_streams = None # a real PATCH response never carries this cam._apply_from_response(fresh) assert cam.mic_volume == 42 assert cam.rtsps_streams is not None assert cam.rtsps_streams.get_stream_url("high") == "rtsps://sentinel" # --------------------------------------------------------------------------- # Camera # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_public_camera_set_mic_volume_writes_through() -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock( return_value=cam.model_copy(update={"mic_volume": 55}) ) result = await cam.set_mic_volume(55) assert result is cam assert cam.mic_volume == 55 api.update_camera_public.assert_awaited_once_with(cam.id, mic_volume=55) @pytest.mark.asyncio async def test_public_camera_set_mic_volume_no_mic() -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.has_mic = False with pytest.raises(BadRequest, match="does not have mic"): await cam.set_mic_volume(55) @pytest.mark.asyncio async def test_public_camera_setters_preserve_rtsps() -> None: api = MagicMock() cam = _camera(api) cam.rtsps_streams = RTSPSStreams(high="rtsps://keepme") api.update_camera_public = AsyncMock( return_value=cam.model_copy(update={"led_settings": cam.led_settings}) ) await cam.set_status_light(True) assert cam.rtsps_streams is not None assert cam.rtsps_streams.get_stream_url("high") == "rtsps://keepme" @pytest.mark.asyncio async def test_public_camera_led_setters() -> None: api = MagicMock() cam = _camera(api) cam.led_settings.welcome_led = False cam.led_settings.flood_led = False api.update_camera_public = AsyncMock(return_value=cam) assert await cam.set_status_light(True) is cam assert await cam.set_welcome_led(True) is cam assert await cam.set_flood_led(True) is cam @pytest.mark.parametrize( "method", ["set_status_light", "set_welcome_led", "set_flood_led"], ) @pytest.mark.asyncio async def test_public_camera_led_no_status(method: str) -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.has_led_status = False with pytest.raises(BadRequest, match="does not have status light"): await getattr(cam, method)(True) @pytest.mark.asyncio async def test_public_camera_welcome_led_absent() -> None: api = MagicMock() cam = _camera(api) cam.led_settings.welcome_led = None with pytest.raises(BadRequest, match="welcome LED"): await cam.set_welcome_led(True) @pytest.mark.asyncio async def test_public_camera_flood_led_absent() -> None: api = MagicMock() cam = _camera(api) cam.led_settings.flood_led = None with pytest.raises(BadRequest, match="flood LED"): await cam.set_flood_led(True) @pytest.mark.asyncio async def test_public_camera_set_hdr_mode() -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock( return_value=cam.model_copy(update={"hdr_type": PublicHdrMode.ON}) ) result = await cam.set_hdr_mode(PublicHdrMode.ON) assert result is cam assert cam.hdr_type is PublicHdrMode.ON @pytest.mark.asyncio async def test_public_camera_set_hdr_mode_no_hdr() -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.has_hdr = False with pytest.raises(BadRequest, match="does not have HDR"): await cam.set_hdr_mode(PublicHdrMode.ON) @pytest.mark.asyncio async def test_public_camera_set_video_mode() -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock( return_value=cam.model_copy(update={"video_mode": VideoMode.HIGH_FPS}) ) result = await cam.set_video_mode(VideoMode.HIGH_FPS) assert result is cam assert cam.video_mode is VideoMode.HIGH_FPS @pytest.mark.asyncio async def test_public_camera_set_video_mode_unsupported() -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.video_modes = [VideoMode.DEFAULT] with pytest.raises(BadRequest): await cam.set_video_mode(VideoMode.HIGH_FPS) @pytest.mark.asyncio async def test_public_camera_lcd_message_custom() -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock(return_value=cam) await cam.set_lcd_message( DoorbellMessageType.CUSTOM_MESSAGE, "hello", reset_at=datetime(2026, 1, 1, tzinfo=UTC), ) sent = api.update_camera_public.call_args.kwargs["lcd_message"] assert sent["type"] is DoorbellMessageType.CUSTOM_MESSAGE assert sent["text"] == "hello" assert isinstance(sent["resetAt"], int) @pytest.mark.asyncio async def test_public_camera_lcd_message_forever() -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock(return_value=cam) await cam.set_lcd_message(DoorbellMessageType.DO_NOT_DISTURB, reset_at=None) sent = api.update_camera_public.call_args.kwargs["lcd_message"] assert sent["resetAt"] is None @pytest.mark.asyncio async def test_public_camera_lcd_message_clear() -> None: """A ``None`` text type clears the message with an already-passed resetAt.""" api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock(return_value=cam) result = await cam.set_lcd_message(None) assert result is cam api.update_camera_public.assert_awaited_once_with( cam.id, lcd_message={"type": DoorbellMessageType.DO_NOT_DISTURB, "resetAt": 0}, ) @pytest.mark.asyncio async def test_public_camera_lcd_message_clear_rejects_text() -> None: api = MagicMock() cam = _camera(api) with pytest.raises(BadRequest, match="Clearing the LCD message"): await cam.set_lcd_message(None, "nope") @pytest.mark.asyncio async def test_public_camera_lcd_message_clear_rejects_reset_at() -> None: api = MagicMock() cam = _camera(api) with pytest.raises(BadRequest, match="Clearing the LCD message"): await cam.set_lcd_message(None, reset_at=datetime(2026, 1, 1, tzinfo=UTC)) @pytest.mark.asyncio async def test_public_camera_lcd_message_requires_text() -> None: api = MagicMock() cam = _camera(api) with pytest.raises(BadRequest, match="requires text"): await cam.set_lcd_message(DoorbellMessageType.CUSTOM_MESSAGE) @pytest.mark.asyncio async def test_public_camera_lcd_message_rejects_text() -> None: api = MagicMock() cam = _camera(api) with pytest.raises(BadRequest, match="does not accept text"): await cam.set_lcd_message(DoorbellMessageType.DO_NOT_DISTURB, "nope") @pytest.mark.parametrize( ("method", "kwarg"), [ ("set_osd_name", "osd_name_enabled"), ("set_osd_date", "osd_date_enabled"), ("set_osd_logo", "osd_logo_enabled"), ("set_osd_nerd_mode", "osd_nerd_mode_enabled"), ], ) @pytest.mark.asyncio async def test_public_camera_osd_toggles(method: str, kwarg: str) -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock(return_value=cam) result = await getattr(cam, method)(True) assert result is cam api.update_camera_public.assert_awaited_once_with(cam.id, **{kwarg: True}) @pytest.mark.asyncio async def test_public_camera_osd_overlay_location() -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock(return_value=cam) await cam.set_osd_overlay_location(OsdOverlayLocation.TOP_RIGHT) api.update_camera_public.assert_awaited_once_with( cam.id, osd_overlay_location=OsdOverlayLocation.TOP_RIGHT ) _OBJECT_DETECTION = [ ("set_person_detection", SmartDetectObjectType.PERSON), ("set_vehicle_detection", SmartDetectObjectType.VEHICLE), ("set_animal_detection", SmartDetectObjectType.ANIMAL), ("set_package_detection", SmartDetectObjectType.PACKAGE), ("set_face_detection", SmartDetectObjectType.FACE), ("set_license_plate_detection", SmartDetectObjectType.LICENSE_PLATE), ] @pytest.mark.parametrize(("method", "obj_type"), _OBJECT_DETECTION) @pytest.mark.asyncio async def test_public_camera_object_detection_enable( method: str, obj_type: SmartDetectObjectType ) -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.smart_detect_types = list(SmartDetectObjectType) new_types = [*cam.smart_detect_settings.object_types, obj_type] api.update_camera_public = AsyncMock( return_value=cam.model_copy( update={ "smart_detect_settings": cam.smart_detect_settings.model_copy( update={"object_types": new_types} ) } ) ) result = await getattr(cam, method)(True) assert result is cam assert obj_type in cam.smart_detect_settings.object_types @pytest.mark.asyncio async def test_public_camera_object_detection_disable() -> None: api = MagicMock() cam = _camera(api) api.update_camera_public = AsyncMock( return_value=cam.model_copy( update={ "smart_detect_settings": cam.smart_detect_settings.model_copy( update={"object_types": []} ) } ) ) await cam.set_person_detection(False) assert SmartDetectObjectType.PERSON not in cam.smart_detect_settings.object_types body = api.update_camera_public.call_args.kwargs["smart_detect_object_types"] assert SmartDetectObjectType.PERSON not in body @pytest.mark.asyncio async def test_public_camera_object_detection_unsupported() -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.smart_detect_types = [] with pytest.raises(BadRequest, match="does not support"): await cam.set_person_detection(True) _AUDIO_DETECTION = [ ("set_smoke_detection", SmartDetectAudioType.SMOKE), ("set_co_detection", SmartDetectAudioType.CMONX), ("set_siren_detection", SmartDetectAudioType.SIREN), ("set_baby_cry_detection", SmartDetectAudioType.BABY_CRY), ("set_speaking_detection", SmartDetectAudioType.SPEAK), ("set_bark_detection", SmartDetectAudioType.BARK), ("set_burglar_detection", SmartDetectAudioType.BURGLAR), ("set_car_horn_detection", SmartDetectAudioType.CAR_HORN), ("set_glass_break_detection", SmartDetectAudioType.GLASS_BREAK), ] @pytest.mark.parametrize(("method", "audio_type"), _AUDIO_DETECTION) @pytest.mark.asyncio async def test_public_camera_audio_detection_enable( method: str, audio_type: SmartDetectAudioType ) -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.smart_detect_audio_types = list(SmartDetectAudioType) new_types = [*cam.smart_detect_settings.audio_types, audio_type] api.update_camera_public = AsyncMock( return_value=cam.model_copy( update={ "smart_detect_settings": cam.smart_detect_settings.model_copy( update={"audio_types": new_types} ) } ) ) result = await getattr(cam, method)(True) assert result is cam assert audio_type in cam.smart_detect_settings.audio_types @pytest.mark.asyncio async def test_public_camera_audio_detection_disable() -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.smart_detect_audio_types = list(SmartDetectAudioType) cam.smart_detect_settings.audio_types = [SmartDetectAudioType.SMOKE] api.update_camera_public = AsyncMock( return_value=cam.model_copy( update={ "smart_detect_settings": cam.smart_detect_settings.model_copy( update={"audio_types": []} ) } ) ) await cam.set_smoke_detection(False) assert cam.smart_detect_settings.audio_types == [] @pytest.mark.asyncio async def test_public_camera_audio_detection_unsupported() -> None: api = MagicMock() cam = _camera(api) cam.feature_flags.smart_detect_audio_types = [] with pytest.raises(BadRequest, match="does not support"): await cam.set_smoke_detection(True) # --------------------------------------------------------------------------- # Light # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_public_light_set_name() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock( return_value=light.model_copy(update={"name": "Renamed"}) ) result = await light.set_name("Renamed") assert result is light assert light.name == "Renamed" @pytest.mark.asyncio async def test_public_light_set_flood_light() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock( return_value=light.model_copy(update={"is_light_force_enabled": True}) ) await light.set_flood_light(True) assert light.is_light_force_enabled is True api.update_light_public.assert_awaited_once_with( light.id, is_light_force_enabled=True ) @pytest.mark.asyncio async def test_public_light_set_led_level() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock( return_value=light.model_copy( update={ "light_device_settings": light.light_device_settings.model_copy( update={"led_level": 4} ) } ) ) result = await light.set_led_level(4) assert result is light assert light.light_device_settings.led_level == 4 sent = api.update_light_public.call_args.kwargs["light_device_settings"] assert sent.led_level == 4 @pytest.mark.parametrize("bad", [0, 7]) @pytest.mark.asyncio async def test_public_light_led_level_out_of_range(bad: int) -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock() with pytest.raises(BadRequest): await light.set_led_level(bad) assert not api.update_light_public.called @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.asyncio async def test_public_light_set_light_no_level(enabled: bool) -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock( return_value=light.model_copy(update={"is_light_force_enabled": enabled}) ) result = await light.set_light(enabled) assert result is light assert light.is_light_force_enabled is enabled api.update_light_public.assert_awaited_once_with( light.id, is_light_force_enabled=enabled ) @pytest.mark.parametrize("enabled", [True, False]) @pytest.mark.asyncio async def test_public_light_set_light_with_level(enabled: bool) -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock( return_value=light.model_copy( update={ "is_light_force_enabled": enabled, "light_device_settings": light.light_device_settings.model_copy( update={"led_level": 5} ), } ) ) result = await light.set_light(enabled, 5) assert result is light assert light.is_light_force_enabled is enabled assert light.light_device_settings.led_level == 5 kwargs = api.update_light_public.call_args.kwargs assert kwargs["is_light_force_enabled"] is enabled assert kwargs["light_device_settings"].led_level == 5 @pytest.mark.parametrize("bad", [0, 7]) @pytest.mark.asyncio async def test_public_light_set_light_out_of_range(bad: int) -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock() with pytest.raises(BadRequest): await light.set_light(True, bad) assert not api.update_light_public.called @pytest.mark.asyncio async def test_public_light_set_light_coerces_float() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock( return_value=light.model_copy( update={ "is_light_force_enabled": True, "light_device_settings": light.light_device_settings.model_copy( update={"led_level": 3} ), } ) ) await light.set_light(True, 3.0) sent = api.update_light_public.call_args.kwargs["light_device_settings"] assert type(sent.led_level) is int assert sent.led_level == 3 @pytest.mark.asyncio async def test_public_light_set_status_light() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock(return_value=light) await light.set_status_light(False) sent = api.update_light_public.call_args.kwargs["light_device_settings"] assert sent.is_indicator_enabled is False @pytest.mark.asyncio async def test_public_light_set_sensitivity() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock(return_value=light) await light.set_sensitivity(80) sent = api.update_light_public.call_args.kwargs["light_device_settings"] assert sent.pir_sensitivity == 80 @pytest.mark.asyncio async def test_public_light_set_duration() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock(return_value=light) await light.set_duration(timedelta(seconds=60)) sent = api.update_light_public.call_args.kwargs["light_device_settings"] assert sent.pir_duration == 60000 @pytest.mark.asyncio async def test_public_light_set_duration_out_of_range() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock() with pytest.raises(BadRequest, match="15s to 900s"): await light.set_duration(timedelta(seconds=5)) assert not api.update_light_public.called @pytest.mark.asyncio async def test_public_light_set_light_mode() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock(return_value=light) await light.set_light_mode(LightModeType.MANUAL, LightModeEnableType.DARK) sent = api.update_light_public.call_args.kwargs["light_mode_settings"] assert sent.mode is LightModeType.MANUAL assert sent.enable_at is LightModeEnableType.DARK @pytest.mark.asyncio async def test_public_light_set_light_settings() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock(return_value=light) await light.set_light_settings( LightModeType.MOTION, enable_at=LightModeEnableType.DARK, duration=timedelta(seconds=30), sensitivity=45, ) kwargs = api.update_light_public.call_args.kwargs assert kwargs["light_mode_settings"].mode is LightModeType.MOTION assert kwargs["light_mode_settings"].enable_at is LightModeEnableType.DARK assert kwargs["light_device_settings"].pir_duration == 30000 assert kwargs["light_device_settings"].pir_sensitivity == 45 @pytest.mark.asyncio async def test_public_light_set_light_settings_bad_duration() -> None: api = MagicMock() light = _light(api) api.update_light_public = AsyncMock() with pytest.raises(BadRequest, match="15s to 900s"): await light.set_light_settings( LightModeType.MOTION, duration=timedelta(seconds=1000) ) assert not api.update_light_public.called # --------------------------------------------------------------------------- # Sensor # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_public_sensor_set_name() -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock( return_value=sensor.model_copy(update={"name": "New"}) ) result = await sensor.set_name("New") assert result is sensor assert sensor.name == "New" @pytest.mark.asyncio async def test_public_sensor_set_motion_settings_retains_when_armed() -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock( return_value=sensor.model_copy( update={ "motion_settings": sensor.motion_settings.model_copy( update={"sensitivity": 30, "sensitivity_when_armed": 80} ) } ) ) result = await sensor.set_motion_settings(sensitivity=30, sensitivity_when_armed=80) assert result is sensor assert sensor.motion_settings.sensitivity == 30 assert sensor.motion_settings.sensitivity_when_armed == 80 sent = api.update_sensor_public.call_args.kwargs["motion_settings"] assert sent == {"sensitivity": 30, "sensitivityWhenArmed": 80} @pytest.mark.asyncio async def test_public_sensor_motion_settings_requires_arg() -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock() with pytest.raises(BadRequest): await sensor.set_motion_settings() assert not api.update_sensor_public.called @pytest.mark.parametrize( ("method", "kwargs", "key"), [ ("set_temperature_settings", {"is_enabled": True}, "temperature_settings"), ("set_humidity_settings", {"is_enabled": True}, "humidity_settings"), ("set_light_settings", {"is_enabled": True}, "light_settings"), ("set_glass_break_settings", {"is_enabled": True}, "glass_break_settings"), ], ) @pytest.mark.asyncio async def test_public_sensor_settings_setters( method: str, kwargs: dict[str, Any], key: str ) -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock(return_value=sensor) result = await getattr(sensor, method)(**kwargs) assert result is sensor assert key in api.update_sensor_public.call_args.kwargs @pytest.mark.parametrize( "method", [ "set_temperature_settings", "set_humidity_settings", "set_light_settings", "set_glass_break_settings", ], ) @pytest.mark.asyncio async def test_public_sensor_settings_require_arg(method: str) -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock() with pytest.raises(BadRequest): await getattr(sensor, method)() assert not api.update_sensor_public.called @pytest.mark.parametrize( ("method", "field"), [ ("set_temperature_settings", "temperature"), ("set_humidity_settings", "humidity"), ("set_light_settings", "light"), ], ) @pytest.mark.asyncio async def test_public_sensor_threshold_low_out_of_range( method: str, field: str ) -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock() with pytest.raises(BadRequest): await getattr(sensor, method)(low_threshold=-9999) assert not api.update_sensor_public.called @pytest.mark.asyncio async def test_public_sensor_threshold_full_payload() -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock(return_value=sensor) await sensor.set_temperature_settings( is_enabled=True, low_threshold=10.5, high_threshold=40.5, margin=2.0 ) sent = api.update_sensor_public.call_args.kwargs["temperature_settings"] assert sent == { "isEnabled": True, "lowThreshold": 10.5, "highThreshold": 40.5, "margin": 2.0, } @pytest.mark.parametrize( ("method", "key", "low"), [ ("set_humidity_settings", "humidity_settings", 10.0), ("set_light_settings", "light_settings", 10.0), ], ) @pytest.mark.asyncio async def test_public_sensor_threshold_full_payload_variants( method: str, key: str, low: float ) -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock(return_value=sensor) await getattr(sensor, method)( is_enabled=True, low_threshold=low, high_threshold=40, margin=3 ) sent = api.update_sensor_public.call_args.kwargs[key] assert sent == { "isEnabled": True, "lowThreshold": low, "highThreshold": 40, "margin": 3, } @pytest.mark.asyncio async def test_public_sensor_glass_break_full() -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock(return_value=sensor) await sensor.set_glass_break_settings( is_enabled=True, sensitivity=50, sensitivity_when_armed=70 ) sent = api.update_sensor_public.call_args.kwargs["glass_break_settings"] assert sent == { "isEnabled": True, "sensitivity": 50, "sensitivityWhenArmed": 70, } @pytest.mark.asyncio async def test_public_sensor_set_alarm_and_scalars() -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock(return_value=sensor) await sensor.set_alarm(True) assert api.update_sensor_public.call_args.kwargs["alarm_settings"] == { "isEnabled": True } await sensor.set_schedule_mode(SensorScheduleMode.WHEN_ARMED) assert ( api.update_sensor_public.call_args.kwargs["schedule_mode"] is SensorScheduleMode.WHEN_ARMED ) await sensor.set_arm_profile_ids(["p1"]) assert api.update_sensor_public.call_args.kwargs["arm_profile_ids"] == ["p1"] await sensor.set_custom_sensitivity_when_armed(True) assert ( api.update_sensor_public.call_args.kwargs["has_custom_sensitivity_when_armed"] is True ) @pytest.mark.parametrize( "method", [ "set_motion_status", "set_temperature_status", "set_humidity_status", "set_light_status", "set_glass_break_status", ], ) @pytest.mark.asyncio async def test_public_sensor_status_delegators(method: str) -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock(return_value=sensor) result = await getattr(sensor, method)(True) assert result is sensor assert api.update_sensor_public.called @pytest.mark.asyncio async def test_public_sensor_set_motion_sensitivity() -> None: api = MagicMock() sensor = _sensor(api) api.update_sensor_public = AsyncMock(return_value=sensor) await sensor.set_motion_sensitivity(66) assert api.update_sensor_public.call_args.kwargs["motion_settings"] == { "sensitivity": 66 } # --------------------------------------------------------------------------- # Chime # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_public_chime_set_ring_settings() -> None: api = MagicMock() chime = _chime(api) body = [{"cameraId": "cam-a", "volume": 20, "repeatTimes": 3}] api.update_chime_public = AsyncMock( return_value=chime.model_copy( update={ "ring_settings": [ r.model_copy(update={"volume": 20, "repeat_times": 3}) for r in chime.ring_settings if r.camera_id == "cam-a" ] } ) ) result = await chime.set_ring_settings(body) assert result is chime api.update_chime_public.assert_awaited_once_with(chime.id, ring_settings=body) @pytest.mark.asyncio async def test_public_chime_set_volume_for_camera() -> None: api = MagicMock() chime = _chime(api) async def fake_update(cid: str, *, ring_settings: list[Any]) -> PublicChime: return chime.model_copy( update={ "ring_settings": [ r.model_copy(update={"volume": rs["volume"]}) for r, rs in zip(chime.ring_settings, ring_settings, strict=True) ] } ) api.update_chime_public = AsyncMock(side_effect=fake_update) result = await chime.set_volume_for_camera("cam-a", 15) assert result is chime body = api.update_chime_public.call_args.kwargs["ring_settings"] assert body[0] == {"cameraId": "cam-a", "volume": 15, "repeatTimes": 1} assert body[1]["volume"] == 60 assert chime.ring_settings[0].volume == 15 @pytest.mark.asyncio async def test_public_chime_set_repeat_times_for_camera() -> None: api = MagicMock() chime = _chime(api) api.update_chime_public = AsyncMock(return_value=chime) await chime.set_repeat_times_for_camera("cam-b", 4) body = api.update_chime_public.call_args.kwargs["ring_settings"] assert body[1] == {"cameraId": "cam-b", "volume": 60, "repeatTimes": 4} @pytest.mark.asyncio async def test_public_chime_unpaired_camera() -> None: api = MagicMock() chime = _chime(api) api.update_chime_public = AsyncMock() with pytest.raises(BadRequest, match="not paired"): await chime.set_volume_for_camera("nope", 10) assert not api.update_chime_public.called @pytest.mark.asyncio async def test_public_chime_volume_out_of_range() -> None: api = MagicMock() chime = _chime(api) api.update_chime_public = AsyncMock() with pytest.raises(BadRequest): await chime.set_volume_for_camera("cam-a", 200) assert not api.update_chime_public.called @pytest.mark.asyncio async def test_public_chime_ringtone_preserved() -> None: api = MagicMock() chime = PublicChime.from_unifi_dict( api=api, id="chime-2", modelKey="chime", state="CONNECTED", mac="AABBCCDDEE04", name="Chime", cameraIds=["cam-a"], ringSettings=[ { "cameraId": "cam-a", "volume": 50, "repeatTimes": 1, "ringtoneId": "tone-1", } ], ) api.update_chime_public = AsyncMock(return_value=chime) await chime.set_volume_for_camera("cam-a", 25) body = api.update_chime_public.call_args.kwargs["ring_settings"] assert body[0]["ringtoneId"] == "tone-1" @pytest.mark.asyncio async def test_public_chime_concurrent_volume_no_lost_update() -> None: api = MagicMock() chime = _chime(api) async def fake_update(cid: str, *, ring_settings: list[Any]) -> PublicChime: # Yield mid-flight so a second unlocked caller could interleave; the # per-object lock must prevent that. The response is the full list, so # an interleaved caller reading a stale list would clobber the other's # write on write-through (lost update). await asyncio.sleep(0) return chime.model_copy( update={ "ring_settings": [ r.model_copy(update={"volume": rs["volume"]}) for r, rs in zip(chime.ring_settings, ring_settings, strict=True) ] } ) api.update_chime_public = AsyncMock(side_effect=fake_update) await asyncio.gather( chime.set_volume_for_camera("cam-a", 10), chime.set_volume_for_camera("cam-b", 90), ) volumes = {rs.camera_id: rs.volume for rs in chime.ring_settings} assert volumes == {"cam-a": 10, "cam-b": 90} @pytest.mark.asyncio async def test_public_light_concurrent_settings_no_lost_update() -> None: api = MagicMock() light = _light(api) async def fake_update(cid: str, *, light_device_settings: Any) -> PublicLight: # Yield mid-flight so a second unlocked caller could interleave; the # per-object lock must serialise the whole-object PATCH so neither # caller reads a stale ``light_device_settings`` and clobbers the other. await asyncio.sleep(0) return light.model_copy(update={"light_device_settings": light_device_settings}) api.update_light_public = AsyncMock(side_effect=fake_update) await asyncio.gather( light.set_led_level(4), light.set_sensitivity(80), ) assert light.light_device_settings.led_level == 4 assert light.light_device_settings.pir_sensitivity == 80 # --------------------------------------------------------------------------- # Central write-through (private-with-public-bootstrap keeps the twin fresh) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_write_through_refreshes_cached_twin( protect_client_no_debug: Any, ) -> None: client = protect_client_no_debug client._public_bootstrap = PublicBootstrap() cam = _camera(client) client._public_bootstrap.cameras[cam.id] = cam client.api_request_obj = AsyncMock( return_value={ "id": cam.id, "modelKey": "camera", "state": "CONNECTED", "name": "Cam", "mac": cam.mac, "isMicEnabled": True, "osdSettings": {}, "ledSettings": {"isEnabled": False}, "lcdMessage": {}, "micVolume": 77, "videoMode": "default", "hdrType": "auto", "featureFlags": {}, "smartDetectSettings": {}, "hasPackageCamera": False, } ) result = await client.update_camera_public(cam.id, mic_volume=77) assert result is not cam assert cam.mic_volume == 77 @pytest.mark.asyncio async def test_write_through_no_bootstrap_is_noop(protect_client_no_debug: Any) -> None: client = protect_client_no_debug client._public_bootstrap = None cam = _camera(client) client._write_through_public_twin(cam) # must not raise @pytest.mark.asyncio async def test_write_through_uncached_is_noop(protect_client_no_debug: Any) -> None: client = protect_client_no_debug client._public_bootstrap = PublicBootstrap() cam = _camera(client) client._write_through_public_twin(cam) # camera not in cache -> no-op assert cam.id not in client._public_bootstrap.cameras @pytest.mark.asyncio async def test_write_through_wrong_store_is_noop(protect_client_no_debug: Any) -> None: client = protect_client_no_debug client._public_bootstrap = PublicBootstrap() siren = Siren.from_unifi_dict( api=client, id="siren-1", modelKey="siren", state="CONNECTED", mac="AABBCCDDEE09", name="Siren", volume=50, ledSettings={"isEnabled": True}, sirenStatus={"isActive": False}, connectionType="wired", ) client._public_bootstrap.sirens[siren.id] = siren # Not a camera/light/sensor/chime write-through path, but resolves a store; # applying to a same-typed cached twin is still a no-op change here. client._write_through_public_twin(siren) uilibs-uiprotect-317a52c/tests/test_rate_limit.py000066400000000000000000000173551524637705700223030ustar00rootroot00000000000000"""Tests for the public-API rate limiter.""" from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from uiprotect import _rate_limit as rl from uiprotect._rate_limit import ( DEFAULT_RATE, SAFETY_MARGIN, PublicApiRateLimiter, _parse_policy_rate, _to_float, ) from uiprotect.api import BaseApiClient, ProtectApiClient def _public_client() -> BaseApiClient: client = BaseApiClient( host="192.168.1.1", port=443, api_key="key", verify_ssl=False ) client.get_public_api_session = AsyncMock(return_value=AsyncMock()) client.get_session = AsyncMock(return_value=AsyncMock()) return client def _mock_response(headers: dict[str, str] | None = None) -> MagicMock: response = MagicMock() response.status = 200 response.headers = headers or {} response.release = MagicMock() response.content_type = "application/json" return response @pytest.fixture def virtual_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: """Replace loop.time/asyncio.sleep with a deterministic virtual clock.""" clock = [0.0] fake_loop = Mock() fake_loop.time = lambda: clock[0] async def fake_sleep(delay: float) -> None: clock[0] += delay monkeypatch.setattr(rl, "get_running_loop", lambda: fake_loop) monkeypatch.setattr(rl, "sleep", fake_sleep) return clock # --------------------------------------------------------------------------- # _parse_policy_rate / _to_float # --------------------------------------------------------------------------- @pytest.mark.parametrize( ("policy", "expected"), [ ('"10-in-1sec"; q=10; w=1; pk=abc', 10.0), ("q=20; w=2", 10.0), ('"5"; q=5; w=1, "100"; q=100; w=60', 5.0), # only first member used (None, None), ("", None), ("q=10", None), # missing w ("w=1", None), # missing q ("q=oops; w=1", None), # unparsable quota ("q=10; w=bad", None), # unparsable window ("q=0; w=1", None), # zero quota ("q=10; w=0", None), # zero window ("q=nan; w=1", None), # non-finite quota ("q=inf; w=1", None), # non-finite quota ("q=10; w=nan", None), # non-finite window ], ) def test_parse_policy_rate(policy: str | None, expected: float | None) -> None: assert _parse_policy_rate(policy) == expected def test_to_float() -> None: assert _to_float("3.5") == 3.5 assert _to_float("nope") is None assert _to_float("nan") is None assert _to_float("inf") is None # --------------------------------------------------------------------------- # PublicApiRateLimiter pacing # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_acquire_paces_at_fixed_interval(virtual_clock: list[float]) -> None: limiter = PublicApiRateLimiter(rate=10.0) # 0.1s spacing for _ in range(5): await limiter.acquire() # First acquire returns immediately; the next four are paced 0.1s apart. assert virtual_clock[0] == pytest.approx(0.4) @pytest.mark.asyncio async def test_concurrent_acquires_do_not_burst( monkeypatch: pytest.MonkeyPatch, ) -> None: limiter = PublicApiRateLimiter(rate=8.0) # 0.125s spacing clock = [0.0] fake_loop = Mock() fake_loop.time = lambda: clock[0] inside = 0 max_inside = 0 async def fake_sleep(delay: float) -> None: # Yield to the loop *while still holding the limiter lock* so any other # gathered acquire could run if the lock did not serialize them. nonlocal inside, max_inside inside += 1 max_inside = max(max_inside, inside) await asyncio.sleep(0) clock[0] += delay inside -= 1 monkeypatch.setattr(rl, "get_running_loop", lambda: fake_loop) monkeypatch.setattr(rl, "sleep", fake_sleep) await asyncio.gather(*(limiter.acquire() for _ in range(20))) # Lock serializes: no two acquires ever pace concurrently (no burst). assert max_inside == 1 # 20 requests serialized at 0.125s → 19 gaps. assert clock[0] == pytest.approx(19 * (1 / 8)) @pytest.mark.asyncio async def test_idle_does_not_accumulate_burst_credit( virtual_clock: list[float], ) -> None: limiter = PublicApiRateLimiter(rate=10.0) await limiter.acquire() virtual_clock[0] = 100.0 # long idle gap await limiter.acquire() # no backlog credit: returns without sleeping assert virtual_clock[0] == pytest.approx(100.0) # --------------------------------------------------------------------------- # Seeding from RateLimit-Policy # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_seed_changes_pacing(virtual_clock: list[float]) -> None: limiter = PublicApiRateLimiter() limiter.seed_from_policy('"20-in-1sec"; q=20; w=1') # 20 * 0.8 = 16/s await limiter.acquire() await limiter.acquire() assert virtual_clock[0] == pytest.approx(1 / (20 * SAFETY_MARGIN)) @pytest.mark.asyncio async def test_seed_is_one_shot(virtual_clock: list[float]) -> None: limiter = PublicApiRateLimiter() limiter.seed_from_policy("q=20; w=1") # 16/s limiter.seed_from_policy("q=2; w=1") # ignored — already seeded await limiter.acquire() await limiter.acquire() assert virtual_clock[0] == pytest.approx(1 / (20 * SAFETY_MARGIN)) @pytest.mark.asyncio async def test_seed_slower_rate_clamps_schedule(virtual_clock: list[float]) -> None: limiter = PublicApiRateLimiter(rate=10.0) # 0.1s spacing await limiter.acquire() # arms _next_allowed at 0.1 limiter.seed_from_policy("q=2; w=1") # 2 * 0.8 = 1.6/s → 0.625s spacing await limiter.acquire() # Next slot honors the new, slower interval rather than the old 0.1s. assert virtual_clock[0] == pytest.approx(1 / (2 * SAFETY_MARGIN)) @pytest.mark.asyncio async def test_absent_policy_keeps_default(virtual_clock: list[float]) -> None: limiter = PublicApiRateLimiter() limiter.seed_from_policy(None) # unparsable → stays at fallback rate await limiter.acquire() await limiter.acquire() assert virtual_clock[0] == pytest.approx(1 / DEFAULT_RATE) # --------------------------------------------------------------------------- # Integration with BaseApiClient.request # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_public_request_is_paced_and_seeded() -> None: client = _public_client() limiter = Mock() limiter.acquire = AsyncMock() limiter.seed_from_policy = Mock() client._public_rate_limiter = limiter response = _mock_response(headers={"RateLimit-Policy": "q=10; w=1"}) with patch.object(client, "_do_request", AsyncMock(return_value=response)): await client.request("get", "/v1/cameras", public_api=True) limiter.acquire.assert_awaited_once() limiter.seed_from_policy.assert_called_once_with("q=10; w=1") @pytest.mark.asyncio async def test_private_request_is_not_paced() -> None: client = _public_client() limiter = Mock() limiter.acquire = AsyncMock() limiter.seed_from_policy = Mock() client._public_rate_limiter = limiter response = _mock_response() with patch.object(client, "_do_request", AsyncMock(return_value=response)): await client.request("get", "/api/cameras", public_api=False) limiter.acquire.assert_not_awaited() limiter.seed_from_policy.assert_not_called() def test_set_api_key_resets_limiter() -> None: client = ProtectApiClient("192.168.1.1", 443, api_key="key", verify_ssl=False) first = client._public_rate_limiter client.set_api_key("rotated-key") assert isinstance(client._public_rate_limiter, PublicApiRateLimiter) assert client._public_rate_limiter is not first uilibs-uiprotect-317a52c/tests/test_retry.py000066400000000000000000000551011524637705700213060ustar00rootroot00000000000000"""Tests for retry functionality.""" from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import aiohttp import pytest from aiohttp import ClientResponse, client_exceptions from yarl import URL from uiprotect.api import ( RETRY_BASE_DELAY, RETRY_DEFAULT_ATTEMPTS, RETRY_EXPONENTIAL_BASE, RETRY_MAX_DELAY, RETRY_STATUS_CODES, BaseApiClient, ProtectApiClient, calculate_retry_delay, parse_retry_after, ) from uiprotect.exceptions import NvrError # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def mock_response() -> MagicMock: """Create a mock aiohttp ClientResponse.""" response = MagicMock(spec=ClientResponse) response.status = 200 response.headers = {} response.release = MagicMock() return response @pytest.fixture def base_client() -> BaseApiClient: """Create a BaseApiClient for testing.""" return BaseApiClient( host="192.168.1.1", port=443, username="test", password="test", # noqa: S106 ) @pytest.fixture def protect_client_factory(): """Factory to create ProtectApiClient with custom max_retries.""" def _create(max_retries: int = RETRY_DEFAULT_ATTEMPTS) -> ProtectApiClient: client = ProtectApiClient( "127.0.0.1", 0, "user", "pass", verify_ssl=False, max_retries=max_retries, ) client.get_session = AsyncMock(return_value=AsyncMock()) return client return _create def _mock_response(status: int = 200, headers: dict | None = None) -> MagicMock: """Create a mock response with given status and headers.""" response = MagicMock() response.status = status response.headers = headers or {} response.release = MagicMock() response.content_type = "application/json" return response def _create_mock_request_context( side_effect: Exception | None = None, return_value: AsyncMock | None = None, fail_count: int = 0, ): """ Create a mock request context for testing _do_request. Args: side_effect: Exception to raise on every call. return_value: Value to return after fail_count failures. fail_count: Number of times to raise side_effect before returning. """ call_count = 0 class MockRequestContext: async def __aenter__(self): nonlocal call_count call_count += 1 if side_effect and (fail_count == 0 or call_count <= fail_count): raise side_effect return return_value async def __aexit__(self, *args): pass @property def calls(self) -> int: return call_count return MockRequestContext() # ============================================================================= # Retry Constants Tests # ============================================================================= def test_default_attempts() -> None: """Test default retry attempts value.""" assert RETRY_DEFAULT_ATTEMPTS == 3 def test_base_delay() -> None: """Test base delay value.""" assert RETRY_BASE_DELAY == 1.0 def test_max_delay() -> None: """Test max delay value.""" assert RETRY_MAX_DELAY == 30.0 def test_exponential_base() -> None: """Test exponential base value.""" assert RETRY_EXPONENTIAL_BASE == 2.0 def test_status_codes() -> None: """Test retry status codes include 408, 429, 500, 502, 503, 504.""" assert frozenset({408, 429, 500, 502, 503, 504}) == RETRY_STATUS_CODES # ============================================================================= # calculate_retry_delay Tests # ============================================================================= def test_exponential_backoff() -> None: """Test that delays follow exponential backoff pattern.""" # With jitter, values will vary, but should be around expected values delays = [calculate_retry_delay(i) for i in range(4)] # Check rough ranges (accounting for ±25% jitter) assert 0.75 <= delays[0] <= 1.25 # ~1.0 assert 1.5 <= delays[1] <= 2.5 # ~2.0 assert 3.0 <= delays[2] <= 5.0 # ~4.0 assert 6.0 <= delays[3] <= 10.0 # ~8.0 def test_max_delay_cap() -> None: """Test that delay is capped at max_delay.""" # Very high attempt number should still cap at RETRY_MAX_DELAY delay = calculate_retry_delay(100) assert delay <= RETRY_MAX_DELAY def test_retry_after_header_respected() -> None: """Test that Retry-After header value is used when provided.""" delay = calculate_retry_delay(0, retry_after=5.0) # With only positive jitter (0-25%), should be between 5.0 and 6.25 assert 5.0 <= delay <= 6.25 def test_retry_after_only_adds_positive_jitter() -> None: """Test that Retry-After only adds positive jitter (never subtracts).""" retry_after = 5.0 delays = [calculate_retry_delay(0, retry_after=retry_after) for _ in range(100)] # All delays should be >= retry_after (only positive jitter) assert all(d >= retry_after for d in delays) # But at least some should be > retry_after (jitter is being added) assert any(d > retry_after for d in delays) def test_retry_after_capped_at_max_delay() -> None: """Test that Retry-After is capped at max_delay.""" delay = calculate_retry_delay(0, retry_after=100.0) # Final delay is capped at RETRY_MAX_DELAY regardless of jitter assert delay <= RETRY_MAX_DELAY def test_jitter_adds_variation() -> None: """Test that jitter adds variation to delay.""" delays = [calculate_retry_delay(0) for _ in range(100)] # With jitter, we should see variation assert len(set(delays)) > 1 def test_minimum_delay() -> None: """Test that delay never goes below 0.1s.""" delays = [calculate_retry_delay(0) for _ in range(100)] assert all(d >= 0.1 for d in delays) def test_calculated_delay_can_subtract_jitter() -> None: """Test that calculated delays (without retry_after) can subtract jitter.""" # For calculated delays, jitter should be ±25% base_delay = RETRY_BASE_DELAY # 1.0 delays = [calculate_retry_delay(0) for _ in range(100)] # Some delays should be below base_delay (negative jitter) assert any(d < base_delay for d in delays) # Some delays should be above base_delay (positive jitter) assert any(d > base_delay for d in delays) # ============================================================================= # parse_retry_after Tests # ============================================================================= def test_parse_valid_integer(mock_response: MagicMock) -> None: """Test parsing integer Retry-After value.""" mock_response.headers = {"Retry-After": "5"} assert parse_retry_after(mock_response) == 5.0 def test_parse_valid_float(mock_response: MagicMock) -> None: """Test parsing float Retry-After value.""" mock_response.headers = {"Retry-After": "2.5"} assert parse_retry_after(mock_response) == 2.5 def test_parse_missing_header(mock_response: MagicMock) -> None: """Test handling missing Retry-After header.""" mock_response.headers = {} assert parse_retry_after(mock_response) is None def test_parse_invalid_value(mock_response: MagicMock) -> None: """Test handling invalid Retry-After value.""" mock_response.headers = {"Retry-After": "not-a-number"} assert parse_retry_after(mock_response) is None def test_parse_http_date_not_supported(mock_response: MagicMock) -> None: """Test that HTTP-date format returns None.""" mock_response.headers = {"Retry-After": "Wed, 21 Oct 2025 07:28:00 GMT"} assert parse_retry_after(mock_response) is None # ============================================================================= # API Client Integration Tests # ============================================================================= def test_default_max_retries_applied(base_client: BaseApiClient) -> None: """Test that default max_retries is applied to new clients.""" assert base_client._max_retries == RETRY_DEFAULT_ATTEMPTS def test_custom_max_retries() -> None: """Test that custom max_retries can be provided.""" client = BaseApiClient( host="192.168.1.1", port=443, username="test", password="test", # noqa: S106 max_retries=5, ) assert client._max_retries == 5 def test_disable_retry() -> None: """Test that retry can be disabled by passing 0.""" client = BaseApiClient( host="192.168.1.1", port=443, username="test", password="test", # noqa: S106 max_retries=0, ) assert client._max_retries == 0 @pytest.mark.asyncio() @pytest.mark.parametrize( ("status_code", "headers"), [ (408, None), (429, {"Retry-After": "0.01"}), (500, None), (502, None), (503, None), (504, None), ], ids=[ "408-timeout", "429-rate-limit", "500-server-error", "502-bad-gateway", "503-unavailable", "504-gateway-timeout", ], ) async def test_retry_loop_on_retryable_status( protect_client_factory, status_code: int, headers: dict[str, str] | None ) -> None: """Test that request retries on retryable status codes.""" client = protect_client_factory(max_retries=1) response_error = _mock_response(status_code, headers) response_200 = _mock_response(200) call_count = 0 async def mock_do_request(*args, **kwargs): nonlocal call_count call_count += 1 return response_error if call_count == 1 else response_200 with patch.object(client, "_do_request", side_effect=mock_do_request): result = await client.request("get", "/test", auto_close=False) assert result is response_200 assert call_count == 2 @pytest.mark.asyncio() async def test_retry_loop_exhausted_returns_last_response( protect_client_factory, ) -> None: """Test that request returns last response when retries exhausted.""" client = protect_client_factory(max_retries=2) response_502 = _mock_response(502) with patch.object(client, "_do_request", return_value=response_502) as mock_request: result = await client.request("get", "/test", auto_close=False) assert result is response_502 assert mock_request.await_count == 3 # 1 initial + 2 retries @pytest.mark.asyncio() async def test_no_retry_on_success_status(protect_client_factory) -> None: """Test that request does not retry on success status codes.""" client = protect_client_factory(max_retries=3) response_200 = _mock_response(200) with patch.object(client, "_do_request", return_value=response_200) as mock_request: result = await client.request("get", "/test", auto_close=False) assert result is response_200 assert mock_request.await_count == 1 # No retries @pytest.mark.asyncio() async def test_auto_close_releases_response(protect_client_factory) -> None: """Test that auto_close=True releases response.""" client = protect_client_factory(max_retries=0) response_200 = _mock_response(200) response_200.content_type = "application/json" with patch.object(client, "_do_request", return_value=response_200): result = await client.request("get", "/test", auto_close=True) assert result is response_200 response_200.release.assert_called() @pytest.mark.asyncio() async def test_auto_close_releases_on_exception(protect_client_factory) -> None: """Test that auto_close=True releases response even on exception.""" client = protect_client_factory(max_retries=0) response_200 = _mock_response(200) type(response_200).content_type = PropertyMock(side_effect=ValueError("test error")) with ( patch.object(client, "_do_request", return_value=response_200), pytest.raises(ValueError, match="test error"), ): await client.request("get", "/test", auto_close=True) response_200.release.assert_called() @pytest.mark.asyncio() async def test_no_retry_when_max_retries_zero(protect_client_factory) -> None: """Test that request does not retry when max_retries is 0.""" client = protect_client_factory(max_retries=0) response_503 = _mock_response(503) with patch.object(client, "_do_request", return_value=response_503) as mock_request: result = await client.request("get", "/test", auto_close=False) assert result is response_503 assert mock_request.await_count == 1 # No retries # ============================================================================= # 401 Re-auth-and-retry Tests # ============================================================================= @pytest.mark.asyncio() async def test_private_401_reauths_and_retries_once(protect_client_factory) -> None: """A 401 on a private authed request triggers one re-auth + retry.""" client = protect_client_factory(max_retries=0) client.ensure_authenticated = AsyncMock() client._reauthenticate = AsyncMock() response_401 = _mock_response(401) response_200 = _mock_response(200) call_count = 0 async def mock_do_request(*args, **kwargs): nonlocal call_count call_count += 1 return response_401 if call_count == 1 else response_200 with patch.object(client, "_do_request", side_effect=mock_do_request): result = await client.request( "get", "/test", require_auth=True, auto_close=False ) assert result is response_200 assert call_count == 2 client._reauthenticate.assert_awaited_once() @pytest.mark.asyncio() async def test_private_401_persists_returns_401(protect_client_factory) -> None: """A persistent 401 retries exactly once then returns the 401 response.""" client = protect_client_factory(max_retries=0) client.ensure_authenticated = AsyncMock() client._reauthenticate = AsyncMock() response_401 = _mock_response(401) with patch.object(client, "_do_request", return_value=response_401) as mock_request: result = await client.request( "get", "/test", require_auth=True, auto_close=False ) assert result is response_401 assert mock_request.await_count == 2 # 1 initial + 1 re-auth retry client._reauthenticate.assert_awaited_once() @pytest.mark.asyncio() async def test_public_401_not_reauthed(protect_client_factory) -> None: """A 401 on the public-API path is propagated without re-auth.""" client = protect_client_factory(max_retries=0) client._api_key = "key" client.get_public_api_session = AsyncMock(return_value=AsyncMock()) client._reauthenticate = AsyncMock() response_401 = _mock_response(401) with patch.object(client, "_do_request", return_value=response_401) as mock_request: result = await client.request( "get", "/test", require_auth=True, public_api=True, auto_close=False ) assert result is response_401 assert mock_request.await_count == 1 client._reauthenticate.assert_not_awaited() @pytest.mark.asyncio() async def test_unauthed_401_not_reauthed(protect_client_factory) -> None: """A 401 without require_auth does not trigger re-auth.""" client = protect_client_factory(max_retries=0) client._reauthenticate = AsyncMock() response_401 = _mock_response(401) with patch.object(client, "_do_request", return_value=response_401) as mock_request: result = await client.request("get", "/test", auto_close=False) assert result is response_401 assert mock_request.await_count == 1 client._reauthenticate.assert_not_awaited() @pytest.mark.asyncio() async def test_reauthenticate_clears_state_and_reauths( protect_client_factory, ) -> None: """_reauthenticate drops the dead session then re-authenticates.""" client = protect_client_factory(max_retries=0) client._session = MagicMock() client.headers = {"cookie": "dead", "x-csrf-token": "old"} client._is_authenticated = True client.ensure_authenticated = AsyncMock() await client._reauthenticate() client._session.cookie_jar.clear.assert_called_once() assert "cookie" not in client.headers assert "x-csrf-token" not in client.headers assert client._is_authenticated is False client.ensure_authenticated.assert_awaited_once() @pytest.mark.asyncio() async def test_concurrent_authenticate_logs_in_once(protect_client_factory) -> None: """Concurrent authenticate() calls serialize and POST login only once.""" client = protect_client_factory(max_retries=0) login_posts = 0 async def fake_request(method, url=None, **kwargs): nonlocal login_posts login_posts += 1 await asyncio.sleep(0) return _mock_response(200, {"set-cookie": "new-cookie"}) with patch.object(client, "request", side_effect=fake_request): await asyncio.gather(*(client.authenticate() for _ in range(5))) assert login_posts == 1 @pytest.mark.asyncio() async def test_private_401_retry_sends_refreshed_cookie( protect_client_factory, ) -> None: """ The retried request carries the fresh cookie minted by the real re-auth. Runs ``_reauthenticate`` for real (only the login transport is mocked) so the in-place ``self.headers`` refresh the guard relies on is exercised end-to-end. """ client = protect_client_factory(max_retries=0) client.store_sessions = False client._load_session = AsyncMock() client._session = MagicMock() client._is_authenticated = True client._last_token_cookie = MagicMock() client._last_token_cookie_decode = {"exp": 9999999999} client.headers = {"cookie": "stale-cookie"} sent_cookies: list[str | None] = [] async def mock_do_request(session, method, request_url, headers, **kwargs): if method == "post": # the real re-auth login mints a fresh session cookie return _mock_response(200, {"set-cookie": "fresh-cookie"}) sent_cookies.append(headers.get("cookie")) return _mock_response(401) if len(sent_cookies) == 1 else _mock_response(200) with patch.object(client, "_do_request", side_effect=mock_do_request): result = await client.request( "get", "/test", require_auth=True, auto_close=False ) assert result.status == 200 assert sent_cookies == ["stale-cookie", "fresh-cookie"] assert client.headers["cookie"] == "fresh-cookie" # ============================================================================= # _do_request Exception Handling Tests # ============================================================================= @pytest.fixture def do_request_client() -> ProtectApiClient: """Create a ProtectApiClient for _do_request testing.""" return ProtectApiClient( "127.0.0.1", 0, "user", "pass", verify_ssl=False, ) @pytest.mark.asyncio() async def test_server_disconnected_error_retries_once( do_request_client: ProtectApiClient, ) -> None: """Test that ServerDisconnectedError triggers one retry.""" mock_session = AsyncMock() response_200 = AsyncMock() response_200.status = 200 response_200.headers = {} mock_ctx = _create_mock_request_context( side_effect=aiohttp.ServerDisconnectedError(), return_value=response_200, fail_count=1, ) mock_session.request = MagicMock(return_value=mock_ctx) do_request_client._update_last_token_cookie = AsyncMock() result = await do_request_client._do_request( mock_session, "GET", URL("http://test/api"), {} ) assert result is response_200 assert mock_ctx.calls == 2 # 1 retry after disconnect @pytest.mark.asyncio() async def test_server_disconnected_error_raises_after_retry( do_request_client: ProtectApiClient, ) -> None: """Test that ServerDisconnectedError raises NvrError after retry fails.""" mock_session = AsyncMock() mock_ctx = _create_mock_request_context( side_effect=aiohttp.ServerDisconnectedError() ) mock_session.request = MagicMock(return_value=mock_ctx) with pytest.raises(NvrError, match="Error requesting data"): await do_request_client._do_request( mock_session, "GET", URL("http://test/api"), {} ) @pytest.mark.asyncio() async def test_client_error_raises_nvr_error( do_request_client: ProtectApiClient, ) -> None: """Test that ClientError raises NvrError immediately.""" mock_session = AsyncMock() mock_ctx = _create_mock_request_context( side_effect=client_exceptions.ClientConnectionError("Connection failed") ) mock_session.request = MagicMock(return_value=mock_ctx) with pytest.raises(NvrError, match="Error requesting data"): await do_request_client._do_request( mock_session, "GET", URL("http://test/api"), {} ) @pytest.mark.asyncio() async def test_update_token_cookie_failure_releases_response( do_request_client: ProtectApiClient, ) -> None: """Test that response is released if _update_last_token_cookie fails.""" mock_session = AsyncMock() response = MagicMock() response.status = 200 response.headers = {} response.release = MagicMock() class MockRequestContext: async def __aenter__(self): return response async def __aexit__(self, *args): pass mock_session.request = MagicMock(return_value=MockRequestContext()) do_request_client._update_last_token_cookie = AsyncMock( side_effect=ValueError("Cookie parsing failed") ) with pytest.raises(ValueError, match="Cookie parsing failed"): await do_request_client._do_request( mock_session, "GET", URL("http://test/api"), {} ) response.release.assert_called_once() # ============================================================================= # api_request_raw body-read failure Tests # ============================================================================= @pytest.mark.parametrize( "read_error", [ aiohttp.ServerDisconnectedError(), client_exceptions.ClientPayloadError("payload truncated"), TimeoutError(), ], ) @pytest.mark.asyncio() async def test_api_request_raw_body_read_error_wrapped( do_request_client: ProtectApiClient, read_error: BaseException, ) -> None: """A mid-body disconnect/payload error surfaces as NvrError, not raw aiohttp.""" response = MagicMock() response.status = 200 response.release = MagicMock() response.read = AsyncMock(side_effect=read_error) do_request_client.request = AsyncMock(return_value=response) with pytest.raises(NvrError, match="Error reading response"): await do_request_client.api_request_raw("/test") response.release.assert_called_once() uilibs-uiprotect-317a52c/tests/test_stream.py000066400000000000000000000620561524637705700214430ustar00rootroot00000000000000"""Tests for uiprotect.stream module.""" from __future__ import annotations import asyncio import threading from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock, Mock, patch import av import pytest from uiprotect.data.types import AudioCodecs from uiprotect.exceptions import BadRequest, StreamError from uiprotect.stream import ( CODEC_MAP, DEFAULT_TALKBACK_PORT, INPUT_TIMEOUT, OUTPUT_TIMEOUT, CodecConfig, TalkbackSession, TalkbackStream, ) if TYPE_CHECKING: from collections.abc import Callable, Generator from pathlib import Path # --- Fixtures --- @pytest.fixture def mock_camera() -> Mock: """Create a mock camera with speaker support.""" camera = Mock() camera.feature_flags.has_speaker = True camera.host = "192.168.1.100" camera.talkback_settings.bind_port = 7004 camera.talkback_settings.type_fmt = AudioCodecs.OPUS camera.talkback_settings.sampling_rate = 24000 camera.talkback_settings.bits_per_sample = 16 return camera @pytest.fixture def mock_camera_no_speaker() -> Mock: """Create a mock camera without speaker support.""" camera = Mock() camera.feature_flags.has_speaker = False return camera @pytest.fixture def talkback_session() -> TalkbackSession: """Create a test talkback session.""" return TalkbackSession( url="rtp://192.168.1.100:7004", codec="opus", sampling_rate=24000, ) @pytest.fixture def talkback_session_ipv6() -> TalkbackSession: """Create a test talkback session with IPv6 address.""" return TalkbackSession( url="rtp://[2001:db8::1]:7004", codec="opus", sampling_rate=24000, ) @pytest.fixture def audio_file(tmp_path: Path) -> str: """Create a temporary audio file for testing (auto-cleaned by pytest).""" filepath = tmp_path / "test_audio.wav" with av.open(str(filepath), "w") as container: audio_stream = container.add_stream("pcm_s16le", rate=24000) audio_stream.layout = "mono" frame = av.AudioFrame(format="s16", layout="mono", samples=2400) frame.planes[0].update(b"\x00" * 4800) frame.rate = 24000 frame.pts = 0 for packet in audio_stream.encode(frame): container.mux(packet) for packet in audio_stream.encode(None): container.mux(packet) return str(filepath) # --- Helper Functions --- def _create_mock_av_containers( with_frames: bool = False, ) -> tuple[MagicMock, MagicMock, MagicMock]: """Create mock av input/output containers for testing.""" mock_input = MagicMock() mock_input.streams.audio = [MagicMock()] # Make it work as a context manager mock_input.__enter__ = MagicMock(return_value=mock_input) mock_input.__exit__ = MagicMock(return_value=False) mock_output = MagicMock() mock_output_stream = MagicMock() mock_output.add_stream.return_value = mock_output_stream # Make it work as a context manager mock_output.__enter__ = MagicMock(return_value=mock_output) mock_output.__exit__ = MagicMock(return_value=False) mock_resampler = MagicMock() if with_frames: # Create mock frames that go through the full processing path mock_frame = MagicMock() mock_input.decode.return_value = [mock_frame] mock_resampled = MagicMock() mock_resampled.samples = 1024 # Needed for real-time pacing calculation mock_resampler.resample.return_value = [mock_resampled] mock_packet = MagicMock() mock_output_stream.encode.return_value = [mock_packet] else: mock_input.decode.return_value = [] mock_resampler.resample.return_value = [] mock_output_stream.encode.return_value = [] return mock_input, mock_output, mock_resampler # --- TalkbackSession Tests --- @pytest.mark.parametrize( ("data", "expected_url", "expected_codec", "expected_rate"), [ ( {"url": "rtp://192.168.1.100:7004", "codec": "opus", "samplingRate": 24000}, "rtp://192.168.1.100:7004", "opus", 24000, ), ( {"url": "udp://10.0.0.1:8000", "codec": "aac", "samplingRate": 48000}, "udp://10.0.0.1:8000", "aac", 48000, ), ({}, "", "", 0), # Missing fields use defaults ], ) def test_talkback_session_from_unifi_dict( data: dict[str, Any], expected_url: str, expected_codec: str, expected_rate: int, ): session = TalkbackSession.from_unifi_dict(**data) assert session.url == expected_url assert session.codec == expected_codec assert session.sampling_rate == expected_rate @pytest.mark.parametrize( ("url", "expected_host", "expected_port"), [ ("rtp://192.168.1.100:7004", "192.168.1.100", 7004), ("udp://10.0.0.1:8000", "10.0.0.1", 8000), ("rtp://192.168.1.100", "192.168.1.100", DEFAULT_TALKBACK_PORT), ("", "", DEFAULT_TALKBACK_PORT), ], ) def test_talkback_session_host_port(url: str, expected_host: str, expected_port: int): session = TalkbackSession(url=url, codec="opus", sampling_rate=24000) assert session.host == expected_host assert session.port == expected_port # --- CodecConfig and Constants Tests --- def test_codec_config(): config = CodecConfig(encoder="aac", format="adts") assert config.encoder == "aac" assert config.format == "adts" @pytest.mark.parametrize( ("codec", "expected_encoder", "expected_format"), [ ("aac", "aac", "adts"), ("opus", "libopus", "rtp"), ("vorbis", "libvorbis", "ogg"), ], ) def test_codec_map(codec: str, expected_encoder: str, expected_format: str): assert codec in CODEC_MAP assert CODEC_MAP[codec].encoder == expected_encoder assert CODEC_MAP[codec].format == expected_format @pytest.fixture def talkback_session_aac() -> TalkbackSession: """Create a test talkback session with AAC codec.""" return TalkbackSession( url="rtp://192.168.1.100:7004", codec="aac", sampling_rate=48000, ) def test_constants(): assert DEFAULT_TALKBACK_PORT == 7004 assert INPUT_TIMEOUT == 5.0 assert OUTPUT_TIMEOUT == (5.0, None) # --- TalkbackStream Initialization Tests --- def test_talkback_stream_init(mock_camera: Mock, talkback_session: TalkbackSession): stream = TalkbackStream(mock_camera, "/path/to/audio.wav", talkback_session) assert stream.camera is mock_camera assert stream.content_url == "/path/to/audio.wav" assert stream.session is talkback_session assert stream.is_running is False def test_talkback_stream_init_no_session(mock_camera: Mock): stream = TalkbackStream(mock_camera, "/path/to/audio.wav") assert stream.session is None def test_talkback_stream_init_no_speaker_raises(mock_camera_no_speaker: Mock): with pytest.raises(BadRequest, match="does not have a speaker"): TalkbackStream(mock_camera_no_speaker, "/path/to/audio.wav") # --- TalkbackStream Start/Stop Tests --- def _blocking_mock(stop_event: threading.Event) -> Callable[[], None]: """Create a mock that blocks until stop_event is set.""" def mock_stream_audio_sync(self: TalkbackStream) -> None: stop_event.wait() return mock_stream_audio_sync @pytest.mark.asyncio async def test_start_stop(mock_camera: Mock, audio_file: str): stop_event = threading.Event() with patch.object(TalkbackStream, "_stream_audio_sync", _blocking_mock(stop_event)): stream = TalkbackStream(mock_camera, audio_file) await stream.start() assert stream.is_running is True stop_event.set() # Allow thread to finish await stream.stop() assert stream.is_running is False @pytest.mark.asyncio async def test_start_twice_raises(mock_camera: Mock, audio_file: str): stop_event = threading.Event() with patch.object(TalkbackStream, "_stream_audio_sync", _blocking_mock(stop_event)): stream = TalkbackStream(mock_camera, audio_file) await stream.start() with pytest.raises(StreamError, match="already started"): await stream.start() stop_event.set() await stream.stop() @pytest.mark.asyncio async def test_stop_when_not_running(mock_camera: Mock, audio_file: str): stream = TalkbackStream(mock_camera, audio_file) await stream.stop() # Should not raise assert stream.is_running is False @pytest.mark.asyncio async def test_multiple_start_stop_cycles(mock_camera: Mock, audio_file: str): for _ in range(3): stop_event = threading.Event() with patch.object( TalkbackStream, "_stream_audio_sync", _blocking_mock(stop_event) ): stream = TalkbackStream(mock_camera, audio_file) await stream.start() assert stream.is_running is True stop_event.set() await stream.stop() assert stream.is_running is False # --- TalkbackStream Context Manager Tests --- @pytest.mark.asyncio async def test_context_manager(mock_camera: Mock, audio_file: str): stop_event = threading.Event() with patch.object(TalkbackStream, "_stream_audio_sync", _blocking_mock(stop_event)): stream = TalkbackStream(mock_camera, audio_file) async with stream as s: assert s is stream assert stream.is_running is True stop_event.set() assert stream.is_running is False # --- IPv6 URL Construction Tests --- @pytest.mark.asyncio async def test_ipv6_url_construction( mock_camera: Mock, audio_file: str, talkback_session_ipv6: TalkbackSession ): """Test that IPv6 addresses are properly bracketed in UDP URLs.""" mock_input, mock_output, mock_resampler = _create_mock_av_containers() with ( patch("uiprotect.stream.av.open") as mock_av_open, patch("uiprotect.stream.av.AudioResampler", return_value=mock_resampler), ): mock_av_open.side_effect = [mock_input, mock_output] stream = TalkbackStream(mock_camera, audio_file, talkback_session_ipv6) await stream.run_until_complete() # Verify the session URL was used (now uses session URL directly) calls = mock_av_open.call_args_list assert len(calls) == 2 output_call = calls[1] output_url = output_call[0][0] assert output_url == "rtp://[2001:db8::1]:7004" # --- TalkbackStream Audio Processing Tests --- @pytest.mark.asyncio async def test_run_until_complete( mock_camera: Mock, audio_file: str, talkback_session: TalkbackSession ): mock_input, mock_output, mock_resampler = _create_mock_av_containers() with ( patch("uiprotect.stream.av.open") as mock_av_open, patch("uiprotect.stream.av.AudioResampler", return_value=mock_resampler), ): mock_av_open.side_effect = [mock_input, mock_output] stream = TalkbackStream(mock_camera, audio_file, talkback_session) await stream.run_until_complete() @pytest.mark.asyncio async def test_run_until_complete_with_frames( mock_camera: Mock, audio_file: str, talkback_session: TalkbackSession ): """Test full audio processing path with frames (OPUS codec).""" mock_input, mock_output, mock_resampler = _create_mock_av_containers( with_frames=True ) with ( patch("uiprotect.stream.av.open") as mock_av_open, patch("uiprotect.stream.av.AudioResampler", return_value=mock_resampler), ): mock_av_open.side_effect = [mock_input, mock_output] stream = TalkbackStream(mock_camera, audio_file, talkback_session) await stream.run_until_complete() # Verify the full processing path was called mock_input.decode.assert_called() mock_resampler.resample.assert_called() mock_output.add_stream.return_value.encode.assert_called() mock_output.mux.assert_called() def test_stream_audio_sync_direct_coverage( mock_camera: Mock, audio_file: str, talkback_session: TalkbackSession ): """Test _stream_audio_sync directly for 100% coverage (bypasses executor).""" mock_input, mock_output, mock_resampler = _create_mock_av_containers( with_frames=True ) with ( patch("uiprotect.stream.av.open") as mock_av_open, patch("uiprotect.stream.av.AudioResampler", return_value=mock_resampler), patch("uiprotect.stream.time.monotonic", return_value=0.0), ): mock_av_open.side_effect = [mock_input, mock_output] stream = TalkbackStream(mock_camera, audio_file, talkback_session) stream._stream_audio_sync() # Normal run mock_output.mux.assert_called() # Test outer loop break: stop_event set before iteration mock_av_open.side_effect = [mock_input, mock_output] stream._stop_event.set() stream._stream_audio_sync() # Hits break at outer loop check # Test inner loop break: stop_event set during encode (before wait) mock_av_open.side_effect = [mock_input, mock_output] stream._stop_event.clear() mock_output_stream = mock_output.add_stream.return_value def set_stop_during_encode(frame): stream._stop_event.set() # Set stop so wait() returns True return [MagicMock()] mock_output_stream.encode.side_effect = set_stop_during_encode stream._stream_audio_sync() # Hits break at pacing wait def test_stream_audio_sync_camera_fallback(mock_camera: Mock, audio_file: str): """Test _stream_audio_sync uses camera settings when no session provided.""" mock_input, mock_output, mock_resampler = _create_mock_av_containers() with ( patch("uiprotect.stream.av.open") as mock_av_open, patch("uiprotect.stream.av.AudioResampler", return_value=mock_resampler), ): mock_av_open.side_effect = [mock_input, mock_output] stream = TalkbackStream(mock_camera, audio_file) # No session stream._stream_audio_sync() # Verify output was opened with camera settings calls = mock_av_open.call_args_list assert len(calls) == 2 output_call = calls[1] udp_url = output_call[0][0] assert "192.168.1.100" in udp_url assert "7004" in udp_url @pytest.mark.asyncio async def test_run_until_complete_while_running(mock_camera: Mock, audio_file: str): """Test run_until_complete waits for existing stream instead of starting new one.""" stop_event = threading.Event() with patch.object(TalkbackStream, "_stream_audio_sync", _blocking_mock(stop_event)): stream = TalkbackStream(mock_camera, audio_file) await stream.start() assert stream.is_running # Call run_until_complete while already running - should wait, not start new stop_event.set() await stream.run_until_complete() assert not stream.is_running @pytest.mark.asyncio async def test_run_until_complete_aac_codec( mock_camera: Mock, audio_file: str, talkback_session_aac: TalkbackSession ): """Test audio processing with AAC codec.""" mock_input, mock_output, mock_resampler = _create_mock_av_containers( with_frames=True ) with ( patch("uiprotect.stream.av.open") as mock_av_open, patch("uiprotect.stream.av.AudioResampler", return_value=mock_resampler), ): mock_av_open.side_effect = [mock_input, mock_output] stream = TalkbackStream(mock_camera, audio_file, talkback_session_aac) await stream.run_until_complete() # Verify AAC encoder was requested (adts format) mock_output.add_stream.assert_called_once_with("aac", rate=48000) mock_output.mux.assert_called() @pytest.mark.asyncio async def test_run_until_complete_no_audio_stream( mock_camera: Mock, audio_file: str, talkback_session: TalkbackSession ): mock_input = MagicMock() mock_input.streams.audio = [] mock_input.__enter__ = MagicMock(return_value=mock_input) mock_input.__exit__ = MagicMock(return_value=False) with patch("uiprotect.stream.av.open") as mock_av_open: mock_av_open.return_value = mock_input stream = TalkbackStream(mock_camera, audio_file, talkback_session) with pytest.raises(StreamError, match="No audio stream"): await stream.run_until_complete() @pytest.mark.asyncio async def test_run_until_complete_unsupported_codec(mock_camera: Mock, audio_file: str): session = TalkbackSession( url="rtp://192.168.1.100:7004", codec="unsupported_codec", sampling_rate=24000, ) stream = TalkbackStream(mock_camera, audio_file, session) with pytest.raises(StreamError, match="Unsupported codec"): await stream.run_until_complete() @pytest.mark.asyncio async def test_run_until_complete_av_error( mock_camera: Mock, audio_file: str, talkback_session: TalkbackSession ): with patch("uiprotect.stream.av.open") as mock_av_open: mock_av_open.side_effect = av.FFmpegError(0, "Test error") stream = TalkbackStream(mock_camera, audio_file, talkback_session) with pytest.raises(StreamError, match="Audio streaming failed"): await stream.run_until_complete() # --- TalkbackStream Stop Signal Tests --- @pytest.mark.asyncio async def test_stop_signal_interrupts_streaming(mock_camera: Mock, audio_file: str): """Test that stop signal can interrupt streaming.""" stream: TalkbackStream stop_was_checked = False def mock_decode(*_args: object) -> Generator[MagicMock, None, None]: nonlocal stop_was_checked for _i in range(10): # Verify the stop_event is accessible and can be checked _ = stream._stop_event.is_set() stop_was_checked = True yield MagicMock() mock_input, mock_output, mock_resampler = _create_mock_av_containers() mock_input.decode = mock_decode with ( patch("uiprotect.stream.av.open") as mock_av_open, patch("uiprotect.stream.av.AudioResampler", return_value=mock_resampler), ): mock_av_open.side_effect = [mock_input, mock_output] stream = TalkbackStream(mock_camera, audio_file) stream.session = TalkbackSession( url="rtp://192.168.1.100:7004", codec="opus", sampling_rate=24000, ) # Test that stop_event starts unset assert not stream._stop_event.is_set() await stream.run_until_complete() # Verify decode was called and stop_event was checked assert stop_was_checked @pytest.mark.asyncio async def test_stop_event_is_set_on_stop(mock_camera: Mock, audio_file: str): """Test that calling stop() sets the stop event.""" def wait_for_stop(self: TalkbackStream) -> None: self._stop_event.wait() with patch.object(TalkbackStream, "_stream_audio_sync", wait_for_stop): stream = TalkbackStream(mock_camera, audio_file) assert not stream._stop_event.is_set() await stream.start() assert stream.is_running await stream.stop() assert not stream.is_running # stop_event is cleared after cleanup so the stream can be restarted assert not stream._stop_event.is_set() assert not stream.is_running @pytest.mark.asyncio async def test_run_until_complete_unexpected_error(mock_camera: Mock, audio_file: str): """Test that run_until_complete handles unexpected non-exception errors.""" def set_string_error(self: TalkbackStream) -> None: self._error = "unexpected string error" # type: ignore[assignment] with patch.object(TalkbackStream, "_stream_audio_sync", set_string_error): stream = TalkbackStream(mock_camera, audio_file) stream.session = TalkbackSession( url="rtp://192.168.1.100:7004", codec="opus", sampling_rate=24000 ) with pytest.raises(StreamError, match="Unexpected error"): await stream.run_until_complete() @pytest.mark.asyncio async def test_stop_during_run_until_complete(mock_camera: Mock, audio_file: str): """ Test that stop() works while run_until_complete() is awaiting the thread. Regression test: run_until_complete() holds the asyncio lock while waiting for the thread to join. If stop() also needs the lock before setting the stop event, a deadlock occurs (the thread never exits because stop_event is never set). The fix: stop() sets _stop_event before acquiring the lock. """ def wait_for_stop(self: TalkbackStream) -> None: self._stop_event.wait() with patch.object(TalkbackStream, "_stream_audio_sync", wait_for_stop): stream = TalkbackStream(mock_camera, audio_file) await stream.start() assert stream.is_running # run_until_complete acquires the lock and awaits thread.join() in the # executor. A single event-loop yield is enough for it to reach that # state because the lock is uncontested and _start_thread_if_needed is # synchronous, but we sleep a bit longer for robustness on slow CI. run_task = asyncio.create_task(stream.run_until_complete()) await asyncio.sleep(0.1) # stop() must signal the thread even though run_until_complete holds the lock await asyncio.wait_for(stream.stop(), timeout=5.0) await asyncio.wait_for(run_task, timeout=5.0) assert not stream.is_running @pytest.mark.asyncio async def test_start_does_not_clobber_pending_stop(mock_camera: Mock, audio_file: str): """ Test that start() does not clear a pending stop signal. Regression test: stop() sets _stop_event before acquiring the lock. If a concurrent start() acquires the lock first and clears the event, the thread would never exit and stop() would hang waiting for thread.join(). The fix: _start_thread_if_needed() returns without clearing/starting when _stop_event is already set. """ stream = TalkbackStream(mock_camera, audio_file) # Simulate a pending stop signal (as if stop() was called but hasn't # acquired the lock yet). stream._stop_event.set() # start() should not clobber the event or start a new thread. await stream.start() assert not stream.is_running assert stream._stop_event.is_set() @pytest.mark.asyncio async def test_concurrent_start_during_stop_does_not_deadlock( mock_camera: Mock, audio_file: str ) -> None: """ Prove the race condition: stop() sets _stop_event, then a concurrent start() acquires the lock first and clears _stop_event via the old _start_thread_if_needed code path. The streaming thread never sees the stop signal, so run_until_complete() never returns and stop() deadlocks on thread.join(). Timeline of the bug: 1. run_until_complete() holds the lock, thread waits on _stop_event 2. stop() sets _stop_event (before lock) 3. start() races in, acquires lock, clears _stop_event, starts new thread 4. stop() finally gets lock, calls thread.join() on new thread that never exits → deadlock The fix makes _start_thread_if_needed() bail out when _stop_event is set. """ # Gate that lets us control exactly when the thread finishes def wait_for_stop(self: TalkbackStream) -> None: self._stop_event.wait() with patch.object(TalkbackStream, "_stream_audio_sync", wait_for_stop): stream = TalkbackStream(mock_camera, audio_file) await stream.start() assert stream.is_running # Kick off run_until_complete which holds the lock while joining thread run_task = asyncio.create_task(stream.run_until_complete()) await asyncio.sleep(0.1) # let it acquire the lock and block on join # Simulate the race: stop() sets _stop_event before getting the lock, # but a concurrent start() sneaks in after the event is set. # We do this manually to deterministically reproduce the ordering. stream._stop_event.set() # stop() step 1: signal thread # The thread will now exit because _stop_event is set, which unblocks # run_until_complete. Wait for it to release the lock. await asyncio.wait_for(run_task, timeout=5.0) # Now start() races in while _stop_event is still set. # With the bug: start() would clear _stop_event and start a new thread. # With the fix: start() sees _stop_event is set and bails out. await stream.start() # With the fix: no new thread was started assert not stream.is_running assert stream._stop_event.is_set() # Now complete stop() — should not deadlock await asyncio.wait_for(stream.stop(), timeout=5.0) assert not stream.is_running @pytest.mark.asyncio async def test_restart_after_stop(mock_camera: Mock, audio_file: str) -> None: """Test that a stream can be started again after being stopped.""" def wait_for_stop(self: TalkbackStream) -> None: self._stop_event.wait() with patch.object(TalkbackStream, "_stream_audio_sync", wait_for_stop): stream = TalkbackStream(mock_camera, audio_file) # First cycle: start then stop await stream.start() assert stream.is_running await asyncio.wait_for(stream.stop(), timeout=5.0) assert not stream.is_running # Second cycle: start must work again after stop await stream.start() assert stream.is_running await asyncio.wait_for(stream.stop(), timeout=5.0) assert not stream.is_running uilibs-uiprotect-317a52c/tests/test_utils.py000066400000000000000000000524501524637705700213050ustar00rootroot00000000000000from __future__ import annotations import sys import time as time_module import zoneinfo from datetime import UTC, datetime, timedelta from decimal import Decimal from enum import Enum from http.cookies import Morsel from ipaddress import IPv4Address, IPv6Address from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock from uuid import UUID import jwt import pytest from aiofiles import os as aos from pydantic.fields import FieldInfo import uiprotect.utils as utils_module from tests.conftest import async_read_text from uiprotect.data import EventType from uiprotect.data.bootstrap import WSStat from uiprotect.data.types import ( Color, ModelType, SmartDetectAudioType, SmartDetectObjectType, Version, VideoMode, ) from uiprotect.utils import ( _cached_ip_address, clamp_value, convert_smart_audio_types, convert_smart_types, convert_to_datetime, convert_unifi_data, convert_video_modes, decode_token_cookie, dict_diff, format_datetime, format_duration, format_host_for_url, from_js_time, get_local_timezone, get_nested_attr, get_nested_attr_as_bool, get_response_reason, get_top_level_attr_as_bool, ip_from_host, is_debug, is_doorbell, is_online, local_datetime, log_event, make_enabled_getter, make_required_getter, make_value_getter, normalize_mac, print_ws_stat_summary, pybool_to_json_bool, run_async, serialize_coord, serialize_dict, serialize_list, serialize_point, serialize_unifi_obj, set_debug, set_no_debug, timedelta_total_seconds, to_camel_case, to_js_time, to_ms, to_snake_case, utc_now, write_json, ws_stat_summmary, ) class _MockEnum(Enum): A = 1 B = 2 C = 3 # --- dict_diff tests --- def test_dict_diff(): """Test dict_diff with equal, new keys, and changed values.""" obj = {"a": 1, "b": 2, "c": None, "d": 2.5, "e": "test", "f": [], "g": [1, 2, 3]} nested = { "a": 1, "b": {"b": 2, "c": None, "d": 2.5, "e": "test", "f": [], "g": [1, 2, 3]}, } # Equal assert dict_diff({}, {}) == {} assert dict_diff(obj, obj) == {} assert dict_diff(nested, nested) == {} assert dict_diff(None, {"a": 1}) == {"a": 1} # New keys assert dict_diff({}, obj) == obj assert dict_diff({"a": 1, "b": {}}, {"a": 1, "b": obj}) == {"b": obj} # Changed values assert dict_diff( {"a": 1, "b": 2, "c": None, "d": 2.5, "e": "test", "f": [], "g": [1, 2, 3]}, { "a": 3, "b": 2, "c": "test", "d": "test", "e": "test6", "f": [1], "g": [1, 2, 3], }, ) == {"a": 3, "c": "test", "d": "test", "e": "test6", "f": [1]} # --- String conversion tests --- @pytest.mark.parametrize( ("input_val", "expected"), [ ("CamelCase", "camel_case"), ("CamelCamelCase", "camel_camel_case"), ("getHTTPResponseCode", "get_http_response_code"), ("HTTPResponseCode", "http_response_code"), ], ) def test_to_snake_case(input_val, expected): assert to_snake_case(input_val) == expected @pytest.mark.parametrize( ("input_val", "expected"), [ ("snake_case", "snakeCase"), ("another_test_case", "anotherTestCase"), ("already", "already"), ], ) def test_to_camel_case(input_val, expected): assert to_camel_case(input_val) == expected # --- convert_unifi_data tests --- @pytest.mark.parametrize( ("value", "annotation", "expected"), [ ( "00000000-0000-00 0- 000-000000000000", UUID, UUID("00000000-0000-0000-0000-000000000000"), ), ("", UUID, None), ( UUID("00000000-0000-0000-0000-000000000000"), UUID, UUID("00000000-0000-0000-0000-000000000000"), ), ("192.168.1.1", IPv4Address | str, IPv4Address("192.168.1.1")), ("invalid-ip", IPv4Address | str, "invalid-ip"), ("", IPv4Address | None, None), ("::1", IPv6Address | str, IPv6Address("::1")), # IPv6 support tests - ensure IPv6 addresses work in IPv4-typed fields ("fd00:1:1:1::64", IPv4Address | None, IPv6Address("fd00:1:1:1::64")), ( "fd00:1:1:1::64", IPv4Address | IPv6Address | None, IPv6Address("fd00:1:1:1::64"), ), ("192.168.1.1", IPv4Address | IPv6Address | None, IPv4Address("192.168.1.1")), ("", IPv6Address | None, None), ( "", IPv4Address | IPv6Address | None, None, ), # empty string becomes None when str is not in union ( "", IPv4Address | IPv6Address | str | None, "", ), # empty string kept when str is in union, ( "2001:db8::1", IPv4Address | IPv6Address | str | None, IPv6Address("2001:db8::1"), ), ("10.0.0.1", IPv4Address | IPv6Address | str | None, IPv4Address("10.0.0.1")), ("hostname.local", IPv4Address | IPv6Address | str | None, "hostname.local"), (1705320000000, datetime, datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)), (["a", "b"], list[str], ["a", "b"]), (["a", "b"], set[str], {"a", "b"}), ({"k": "v"}, dict[str, str], {"k": "v"}), ({"test": 123}, Any, {"test": 123}), ("123.45", Decimal, Decimal("123.45")), ("/test", Path, Path("/test")), (None, str | None, None), (True, bool, True), ("camera", ModelType, ModelType.CAMERA), ], ) def test_convert_unifi_data(value, annotation, expected): result = convert_unifi_data(value, FieldInfo(annotation=annotation)) assert result == expected # --- convert_to_datetime tests --- @pytest.mark.parametrize( ("input_val", "expected"), [ (1715563200000.0, datetime(2024, 5, 13, 1, 20, tzinfo=UTC)), ("1715563200000", datetime(2024, 5, 13, 1, 20, tzinfo=UTC)), ( datetime(2024, 6, 11, 12, 0, tzinfo=UTC), datetime(2024, 6, 11, 12, 0, tzinfo=UTC), ), (None, None), ], ) def test_convert_to_datetime(input_val, expected): assert convert_to_datetime(input_val) == expected def test_convert_to_datetime_invalid(): with pytest.raises(ValueError): convert_to_datetime("invalid-date") def test_convert_to_datetime_caching(): result1 = convert_to_datetime(1715563200.0) result2 = convert_to_datetime(1715563200.0) assert result1 is result2 # --- Attribute getter tests --- def test_get_nested_attr(): data = Mock(a=Mock(b=Mock(c=1)), d=3, f=_MockEnum.C) assert get_nested_attr(("a", "b", "c"), data) == 1 assert get_nested_attr(("d",), data) == 3 assert get_nested_attr(("f",), data) == _MockEnum.C def test_get_nested_attr_as_bool(): data = Mock(a=Mock(b=Mock(c=True)), d=False, f=_MockEnum.C) assert get_nested_attr_as_bool(("a", "b", "c"), data) is True assert get_nested_attr_as_bool(("d",), data) is False assert get_nested_attr_as_bool(("f",), data) is True assert get_nested_attr_as_bool(("missing",), Mock(spec=[])) is False def test_get_top_level_attr_as_bool(): data = Mock(a=True, b=False, c=None) assert get_top_level_attr_as_bool("a", data) is True assert get_top_level_attr_as_bool("b", data) is False assert get_top_level_attr_as_bool("c", data) is False def test_make_getters(): data = Mock(a=1, b=True, c=Mock(q="x"), d=None) assert make_value_getter("a")(data) == 1 assert make_value_getter("c.q")(data) == "x" assert make_value_getter("a.x")(data) is None assert make_enabled_getter("b")(data) is True assert make_required_getter("a")(data) is True assert make_required_getter("d")(data) is False # --- Host/IP tests --- @pytest.mark.parametrize( ("host", "expected"), [ (IPv4Address("192.168.1.1"), "192.168.1.1"), ("192.168.1.1", "192.168.1.1"), (IPv6Address("fe80::1"), "[fe80::1]"), ("::1", "[::1]"), ("example.com", "example.com"), ], ) def test_format_host_for_url(host, expected): assert format_host_for_url(host) == expected @pytest.mark.asyncio() @pytest.mark.parametrize( ("host", "expected_type"), [ ("192.168.1.1", IPv4Address), ("::1", IPv6Address), ], ) async def test_ip_from_host(host, expected_type): result = await ip_from_host(host) assert isinstance(result, expected_type) @pytest.mark.asyncio() async def test_ip_from_host_invalid(): with pytest.raises(ValueError, match="Cannot resolve hostname"): await ip_from_host("this-does-not-exist-12345.invalid") @pytest.mark.parametrize( ("ip", "expected_type"), [ ("192.168.1.1", IPv4Address), ("::1", IPv6Address), ("not-an-ip", str), ], ) def test_cached_ip_address(ip, expected_type): assert isinstance(_cached_ip_address(ip), expected_type) # --- Time conversion tests --- @pytest.mark.parametrize( ("dt", "expected"), [ (None, None), (1234567890000, 1234567890000), (datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC), 1705320000000), ], ) def test_to_js_time(dt, expected): assert to_js_time(dt) == expected def test_to_js_time_naive(): assert isinstance(to_js_time(datetime(2024, 1, 15, 12, 0, 0)), int) @pytest.mark.parametrize( ("duration", "expected"), [ (None, None), (timedelta(seconds=1), 1000), (timedelta(minutes=1), 60000), (timedelta(hours=1), 3600000), ], ) def test_to_ms(duration, expected): assert to_ms(duration) == expected def test_from_js_time(): assert from_js_time(1705320000000) == datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) dt = datetime(2024, 1, 1, tzinfo=UTC) assert from_js_time(dt) == dt def test_utc_now(): result = utc_now() assert result.tzinfo == UTC assert (datetime.now(tz=UTC) - result).total_seconds() < 1 def test_timedelta_total_seconds(): assert timedelta_total_seconds(timedelta(hours=1, minutes=30)) == 5400.0 # --- Format tests --- @pytest.mark.parametrize( ("dt", "default", "expected"), [ (None, None, None), (None, "N/A", "N/A"), (datetime(2024, 1, 15, 12, 30, 45), None, "2024-01-15 12:30:45"), ], ) def test_format_datetime(dt, default, expected): assert format_datetime(dt, default) == expected @pytest.mark.parametrize( ("duration", "expected"), [ (timedelta(seconds=30), "30s"), (timedelta(minutes=5), "5m0s"), (timedelta(hours=2), "2h0s"), (timedelta(hours=1, minutes=30, seconds=45), "1h30m45s"), (timedelta(days=1, hours=2), "26h0s"), ], ) def test_format_duration(duration, expected): assert format_duration(duration) == expected # --- Data check tests --- @pytest.mark.parametrize( ("data", "expected"), [ ({"state": "CONNECTED"}, True), ({"state": "DISCONNECTED"}, False), ], ) def test_is_online(data, expected): assert is_online(data) == expected @pytest.mark.parametrize( ("data", "expected"), [ ({"type": "doorbell"}, True), ({"type": "UVC G4 Doorbell"}, True), ({"type": "camera"}, False), ], ) def test_is_doorbell(data, expected): assert is_doorbell(data) == expected # --- Serialization tests --- @pytest.mark.parametrize( ("coord", "expected"), [ (0.0, 0), (1.0, 1), (0.5, 0.5), ], ) def test_serialize_coord(coord, expected): assert serialize_coord(coord) == expected def test_serialize_point(): assert serialize_point((0.0, 1.0)) == [0, 1] def test_serialize_list(): assert serialize_list([1, 2, 3]) == [1, 2, 3] assert ( serialize_list([1, "s", UUID("12345678-1234-5678-1234-567812345678")])[2] == "12345678-1234-5678-1234-567812345678" ) def test_serialize_dict(): assert serialize_dict({"test_key": "v"})["testKey"] == "v" assert "life_span" in serialize_dict({"life_span": 100}) @pytest.mark.parametrize( ("obj", "expected"), [ (_MockEnum.A, 1), (IPv4Address("192.168.1.1"), "192.168.1.1"), ( UUID("12345678-1234-5678-1234-567812345678"), "12345678-1234-5678-1234-567812345678", ), (Path("/test"), "/test"), (datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC), 1705320000000), (timedelta(seconds=1), 1000), (Version("1.2.3"), "1.2.3"), (zoneinfo.ZoneInfo("UTC"), "UTC"), (ModelType.CAMERA, "camera"), ], ) def test_serialize_unifi_obj(obj, expected): assert serialize_unifi_obj(obj) == expected def test_serialize_unifi_obj_color(): assert serialize_unifi_obj(Color("#FF0000")) in ("#FF0000", "#F00") def test_serialize_unifi_obj_with_unifi_dict(): obj = Mock() obj.unifi_dict = Mock(return_value={"key": "value"}) assert serialize_unifi_obj(obj) == {"key": "value"} # --- Utility function tests --- @pytest.mark.parametrize( ("value", "step", "expected"), [ (0.5, 0.1, 0.5), (0.55, 0.1, 0.5), (1.3, 0.25, 1.25), ], ) def test_clamp_value(value, step, expected): assert clamp_value(value, step) == expected @pytest.mark.parametrize( ("mac", "expected"), [ ("AA:BB:CC:DD:EE:FF", "aabbccddeeff"), ("AA-BB-CC-DD-EE-FF", "aabbccddeeff"), ("AABBCCDDEEFF", "aabbccddeeff"), ], ) def test_normalize_mac(mac, expected): assert normalize_mac(mac) == expected def test_pybool_to_json_bool(): assert pybool_to_json_bool(True) == "true" assert pybool_to_json_bool(False) == "false" # --- Debug flag tests --- def test_debug_flags(): set_no_debug() assert is_debug() is False set_debug() assert is_debug() is True set_no_debug() # --- run_async tests --- def test_run_async(): async def coro() -> int: return 42 assert run_async(coro()) == 42 # --- Smart type conversion tests --- def test_convert_smart_types(): result = convert_smart_types(["person", "vehicle", "invalid"]) assert SmartDetectObjectType.PERSON in result assert SmartDetectObjectType.VEHICLE in result assert len(result) == 2 def test_convert_smart_audio_types(): result = convert_smart_audio_types(["alrmSmoke", "invalid"]) assert SmartDetectAudioType.SMOKE in result assert len(result) == 1 def test_convert_video_modes(): result = convert_video_modes(["default", "highFps", "invalid"]) assert VideoMode.DEFAULT in result assert VideoMode.HIGH_FPS in result assert len(result) == 2 # --- get_response_reason tests --- @pytest.mark.asyncio() async def test_get_response_reason(): # JSON with error key resp = AsyncMock(reason="Bad", json=AsyncMock(return_value={"error": "Invalid"})) assert await get_response_reason(resp) == "Invalid" # JSON without error key resp = AsyncMock(reason="Bad", json=AsyncMock(return_value={"msg": "err"})) assert "msg" in await get_response_reason(resp) # Text fallback resp = AsyncMock( reason="Bad", json=AsyncMock(side_effect=Exception()), text=AsyncMock(return_value="Text"), ) assert await get_response_reason(resp) == "Text" # Reason fallback resp = AsyncMock( reason="Error", json=AsyncMock(side_effect=Exception()), text=AsyncMock(side_effect=Exception()), ) assert await get_response_reason(resp) == "Error" # --- decode_token_cookie tests --- def test_decode_token_cookie(): # HMAC key must be >=32 bytes to satisfy PyJWT's SHA256 minimum hmac_key = "0123456789abcdef0123456789abcdef" # Valid token payload: dict[str, Any] = {"sub": "user", "exp": int(time_module.time()) + 3600} token = jwt.encode(payload, hmac_key, algorithm="HS256") morsel: Morsel[str] = Morsel() morsel.set("token", token, token) assert decode_token_cookie(morsel)["sub"] == "user" # Expired token payload = {"sub": "user", "exp": int(time_module.time()) - 3600} token = jwt.encode(payload, hmac_key, algorithm="HS256") morsel.set("token", token, token) assert decode_token_cookie(morsel) is None # Invalid token morsel.set("token", "invalid", "invalid") assert decode_token_cookie(morsel) is None # --- local_datetime tests --- def test_local_datetime(): assert local_datetime(None).tzinfo is not None assert ( local_datetime(datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)).tzinfo is not None ) assert local_datetime(datetime(2024, 1, 15, 12, 0, 0)).tzinfo is not None def test_get_local_timezone(monkeypatch): tz = get_local_timezone() assert tz is not None monkeypatch.setattr(utils_module, "TIMEZONE_GLOBAL", None) monkeypatch.setenv("TZ", "Europe/Berlin") assert get_local_timezone() is not None monkeypatch.setattr(utils_module, "TIMEZONE_GLOBAL", None) # --- WS stat tests --- def test_ws_stat_summary(): with pytest.raises(ValueError, match="No stats"): ws_stat_summmary([]) stats = [ WSStat( model="camera", action="update", keys=["name"], keys_set=["name"], size=100, filtered=False, ), WSStat( model="camera", action="update", keys=["name"], keys_set=["name"], size=50, filtered=False, ), WSStat( model="light", action="add", keys=["id"], keys_set=["id"], size=30, filtered=True, ), ] unfiltered, _percent, _keys, models, _actions = ws_stat_summmary(stats) assert len(unfiltered) == 2 assert models["camera"] == 2 def test_print_ws_stat_summary(): stats = [ WSStat( model="camera", action="update", keys=["name"], keys_set=["name"], size=100, filtered=False, ) ] lines: list[str] = [] print_ws_stat_summary(stats, output=lines.append) assert "camera: 1" in lines[0] def test_print_ws_stat_summary_default_output(capsys): """Uses typer.echo (stdout) as the default output when no callable is given.""" stats = [ WSStat( model="camera", action="update", keys=["name"], keys_set=["name"], size=100, filtered=False, ) ] print_ws_stat_summary(stats) assert "camera: 1" in capsys.readouterr().out def test_print_ws_stat_summary_without_typer(monkeypatch, capsys): """Falls back to print when the optional typer (cli extra) is absent.""" monkeypatch.setitem(sys.modules, "typer", None) stats = [ WSStat( model="camera", action="update", keys=["name"], keys_set=["name"], size=100, filtered=False, ) ] print_ws_stat_summary(stats) assert "camera: 1" in capsys.readouterr().out # --- write_json tests --- @pytest.mark.asyncio() async def test_write_json(tmp_path: Path): path = tmp_path / "test.json" await write_json(path, {"key": "value"}) assert await aos.path.exists(path) assert '"key": "value"' in await async_read_text(path) # --- log_event tests --- def test_log_event(): # Non-smart event event = Mock(type=EventType.MOTION, model_dump=Mock(return_value={})) log_event(event) # Smart event without camera event = Mock( type=EventType.SMART_DETECT, model_dump=Mock(return_value={}), camera=None ) log_event(event) # Smart event with camera and end camera = Mock(mac="AA:BB:CC:DD:EE:FF") camera.name = "Cam" event = Mock( type=EventType.SMART_DETECT, model_dump=Mock(return_value={}), camera=camera, end=datetime.now(tz=UTC), smart_detect_types=[SmartDetectObjectType.PERSON], id="e1", ) log_event(event) # Smart detect event (new) camera = Mock( mac="AA:BB:CC:DD:EE:FF", is_smart_detected=True, is_recording_enabled=True, smart_detect_settings=Mock(object_types=[SmartDetectObjectType.PERSON]), get_last_smart_detect_event=Mock(return_value=None), ) camera.name = "Cam" event = Mock( type=EventType.SMART_DETECT, model_dump=Mock(return_value={}), camera=camera, end=None, smart_detect_types=[SmartDetectObjectType.PERSON], id="e2", ) log_event(event) # Smart audio detect camera = Mock( mac="AA:BB:CC:DD:EE:FF", is_smart_detected=True, is_recording_enabled=True, smart_detect_settings=Mock(audio_types=[SmartDetectAudioType.SMOKE]), get_last_smart_audio_detect_event=Mock(return_value=None), ) camera.name = "Cam" smart_type = Mock(audio_type=SmartDetectAudioType.SMOKE) event = Mock( type=EventType.SMART_AUDIO_DETECT, model_dump=Mock(return_value={}), camera=camera, end=None, smart_detect_types=[smart_type], id="e3", ) log_event(event) # Smart audio detect without audio_type smart_type = Mock(audio_type=None) camera = Mock(mac="AA:BB:CC:DD:EE:FF") camera.name = "Cam" event = Mock( type=EventType.SMART_AUDIO_DETECT, model_dump=Mock(return_value={}), camera=camera, end=None, smart_detect_types=[smart_type], id="e4", ) log_event(event) uilibs-uiprotect-317a52c/tests/test_validate_spec.py000066400000000000000000000747361524637705700227630ustar00rootroot00000000000000""" Network-free unit tests for the spec-validation check functions. Each test feeds a hand-built in-memory spec ``dict`` (never the 74 MB fetched spec, absent in CI) to one check function and pins its ``(errors, warnings)``. """ from __future__ import annotations from typing import TYPE_CHECKING, Any import orjson import validate_spec # local import via conftest sys.path insert from validate_spec import ( _CHECKS, _ENUM_COVERAGE_WAIVERS, _EXAMPLE_CALLS, _MODELLED_AS_SUBSET, _inbound_enum_ids, _iter_spec_consts, _iter_spec_enums, _leaf_model, _library_enums_by_name, _normalize_path, _public_api_coroutines, _resolve_const, _resolve_object_props, _spec_field_name, _union_variants, check_completeness, check_endpoints, check_enum_coverage, check_enums, check_event_types, check_model_fields, covered_endpoints, format_summary, main, run_checks, ) from uiprotect._public_api import registry from uiprotect.api import ProtectApiClient from uiprotect.data import PUBLIC_EVENT_TYPES, PublicChime if TYPE_CHECKING: import pytest def _model_props(cls: Any, name: str) -> dict[str, dict[str, Any]]: """Build green spec ``properties`` for one tracked model schema.""" inv = {v: k for k, v in cls._get_unifi_remaps().items()} owned = validate_spec._LIBRARY_OWNED_FIELDS.get(name, set()) return { inv.get(f, f): {"type": "string"} for f in cls.model_fields if f not in owned } def _event_union(props: dict[str, dict[str, Any]]) -> dict[str, Any]: """Build the ``event`` ``oneOf``: one variant per modelled public event type.""" return { "oneOf": [ { "type": "object", "properties": {**props, "type": {"type": "string", "const": value}}, } for value in sorted(event_type.value for event_type in PUBLIC_EVENT_TYPES) ] } def _chime_spec( *, extra: str | None = None, drop: str | None = None, ) -> dict[str, Any]: """Build a full green spec; ``extra``/``drop`` perturb only the ``chime`` schema.""" schemas: dict[str, Any] = {} for cls, name in validate_spec._MODEL_SCHEMAS: props = _model_props(cls, name) if name == "chime": if drop is not None: props.pop(drop, None) if extra is not None: props[extra] = {"type": "string"} if name == "event": schemas[name] = _event_union(props) continue schemas[name] = {"type": "object", "properties": props} for enum_cls, name in validate_spec._ENUM_SCHEMAS: schemas[name] = {"enum": [m.value for m in enum_cls]} return {"components": {"schemas": schemas}} # --------------------------------------------------------------------------- # # check_endpoints # --------------------------------------------------------------------------- # def test_check_endpoints_all_covered() -> None: spec = {"paths": {"/v1/cameras": {"get": {}, "parameters": []}}} errors, warnings = check_endpoints(spec) assert errors == [] assert warnings == [] def test_check_endpoints_new_endpoint_warns() -> None: spec = {"paths": {"/v1/teleporter": {"post": {}}}} errors, warnings = check_endpoints(spec) assert errors == [] assert len(warnings) == 1 assert "POST /v1/teleporter" in warnings[0] def test_check_endpoints_parametrized_path_normalized() -> None: """A declarative endpoint covers the spec path despite differing param names.""" # Registry template is ``/v1/cameras/{camera_id}``; spec uses ``{id}``. spec = {"paths": {"/v1/cameras/{id}": {"get": {}, "patch": {}}}} errors, warnings = check_endpoints(spec) assert errors == [] assert warnings == [] def test_check_endpoints_recorded_exception_method_covers() -> None: """A hand-written (non-declarative) method's path is covered via its example call.""" # ``update_camera_public`` is not decorated; the alarm-hub alias GET is only # reachable through a recorded example call, never the registry. spec = {"paths": {"/v1/alarm-hubs/{id}": {"get": {}}}} errors, warnings = check_endpoints(spec) assert errors == [] assert warnings == [] def test_check_endpoints_subscribe_paths_covered() -> None: spec = { "paths": { "/v1/subscribe/events": {"get": {}}, "/v1/subscribe/devices": {"get": {}}, } } _errors, warnings = check_endpoints(spec) assert warnings == [] # --------------------------------------------------------------------------- # # Derived coverage: normalization, completeness, example-call table # --------------------------------------------------------------------------- # def test_normalize_path_collapses_params_and_sentinel() -> None: assert _normalize_path("/v1/cameras/{camera_id}") == "/v1/cameras/{}" assert _normalize_path("/v1/cameras/{id}/snapshot") == "/v1/cameras/{}/snapshot" assert ( _normalize_path(f"/v1/files/{validate_spec._RECORD_SENTINEL}") == "/v1/files/{}" ) def test_covered_endpoints_union_sources() -> None: covered = covered_endpoints() # Declarative (registry), recorded example call, and websocket subscription. assert ("GET", "/v1/cameras/{}") in covered assert ("PATCH", "/v1/cameras/{}") in covered # update_camera_public (recorded) assert ("GET", "/v1/alarm-hubs/{}") in covered # get_alarm_hub_public (recorded) assert ("GET", "/v1/subscribe/events") in covered assert ("GET", "/v1/subscribe/devices") in covered def test_example_calls_match_nondeclarative_coroutines() -> None: """The example-call table is exactly the set of non-declarative public coroutines.""" declarative = set(registry.for_class(ProtectApiClient.__name__).values()) expected = _public_api_coroutines() - declarative assert set(_EXAMPLE_CALLS) == expected def test_check_completeness_clean() -> None: assert check_completeness() == [] def test_check_completeness_flags_unwired_method( monkeypatch: pytest.MonkeyPatch, ) -> None: """Dropping an example call for a non-declarative coroutine surfaces a gap.""" trimmed = dict(_EXAMPLE_CALLS) trimmed.pop("update_camera_public") monkeypatch.setattr(validate_spec, "_EXAMPLE_CALLS", trimmed) errors = check_completeness() assert any("update_camera_public" in e for e in errors) # --------------------------------------------------------------------------- # # check_model_fields # --------------------------------------------------------------------------- # def test_check_model_fields_all_green() -> None: errors, warnings = check_model_fields(_chime_spec()) assert errors == [] assert warnings == [] def test_check_model_fields_removed_field_errors() -> None: errors, warnings = check_model_fields(_chime_spec(drop="ring_settings")) assert any("ring_settings" in e for e in errors) assert warnings == [] def test_check_model_fields_added_field_warns() -> None: errors, warnings = check_model_fields(_chime_spec(extra="newServerField")) assert errors == [] assert any("new_server_field" in w for w in warnings) def test_check_model_fields_missing_schema_errors() -> None: errors, _warnings = check_model_fields({"components": {"schemas": {}}}) assert any("tracked schema absent from spec" in e for e in errors) def test_check_model_fields_non_object_schema_errors() -> None: spec = {"components": {"schemas": {"chime": {"type": "string"}}}} errors, _warnings = check_model_fields(spec) assert any("not object-shaped" in e for e in errors) # --------------------------------------------------------------------------- # # check_enums # --------------------------------------------------------------------------- # def test_check_enums_new_value_warns() -> None: spec = { "components": { "schemas": {"deviceState": {"enum": ["CONNECTED", "HIBERNATING"]}} } } errors, warnings = check_enums(spec) assert errors == [] assert any("HIBERNATING" in w for w in warnings) def test_check_enums_known_values_ok() -> None: spec = { "components": { "schemas": { "deviceState": {"enum": ["CONNECTED", "CONNECTING", "DISCONNECTED"]} } } } errors, warnings = check_enums(spec) assert errors == [] assert warnings == [] def test_check_enums_missing_schema_errors() -> None: errors, _warnings = check_enums({"components": {"schemas": {}}}) assert any("tracked schema absent from spec" in e for e in errors) def test_check_enums_non_enum_schema_errors() -> None: spec = {"components": {"schemas": {"deviceState": {"type": "string"}}}} errors, _warnings = check_enums(spec) assert any("no longer declares `enum`" in e for e in errors) # --------------------------------------------------------------------------- # # check_enum_coverage (exact-match + inbound/outbound classification) # --------------------------------------------------------------------------- # def _response_spec(schema_name: str, schema: dict[str, Any]) -> dict[str, Any]: """Wrap a component schema and reference it from a GET response (inbound).""" return { "components": {"schemas": {schema_name: schema}}, "paths": { "/v1/things": { "get": { "responses": { "200": { "content": { "application/json": { "schema": { "$ref": f"#/components/schemas/{schema_name}" } } } } } } } }, } def test_iter_spec_enums_collects_named_and_inline() -> None: spec = { "components": { "schemas": { "deviceState": {"enum": ["CONNECTED"]}, "someEvent": { "properties": { "metadata": { "properties": {"text": {"enum": ["zorp", "unknown"]}} } } }, } }, "paths": { "/v1/widgets": {"get": {"parameters": [{"schema": {"enum": ["quux"]}}]}} }, } found = dict(_iter_spec_enums(spec)) assert frozenset({"zorp"}) in found # ``unknown`` stripped assert frozenset({"CONNECTED"}) in found assert frozenset({"quux"}) in found # reached through the list branch assert found[frozenset({"zorp"})].endswith("metadata.properties.text") def test_iter_spec_enums_records_each_owning_schema() -> None: """The same value-set under two schemas yields one occurrence per schema.""" spec = { "components": { "schemas": { "alpha": {"properties": {"s": {"enum": ["zorp"]}}}, "beta": {"properties": {"s": {"enum": ["zorp"]}}}, } } } owners = { validate_spec._enum_owner(path) for value_set, path in _iter_spec_enums(spec) if value_set == frozenset({"zorp"}) } assert owners == {"alpha", "beta"} def test_library_enums_by_name_excludes_sentinel() -> None: by_name = _library_enums_by_name() assert by_name["DeviceState"] == frozenset( {"CONNECTED", "CONNECTING", "DISCONNECTED"} ) assert all("unknown" not in value_set for value_set in by_name.values()) def test_check_enum_coverage_exact_match_passes() -> None: """A spec enum whose value-set exactly equals a library enum raises nothing.""" spec = _response_spec( "deviceState", {"enum": ["CONNECTED", "CONNECTING", "DISCONNECTED"]} ) assert check_enum_coverage(spec) == ([], []) def test_check_enum_coverage_subset_collision_flagged() -> None: """A value-set that is a coincidental subset of a larger enum is NOT covered.""" # ``{high}`` is a subset of several library enums (ChannelQuality, LowMedHigh, # …) yet equals none of them; exact-match must reject it rather than treat the # collision as coverage. It carries no waiver, so the error is unambiguous. spec = _response_spec("thing", {"enum": ["high"]}) errors, warnings = check_enum_coverage(spec) assert warnings == [] assert len(errors) == 1 assert "['high']" in errors[0] def test_check_enum_coverage_modelled_as_subset_passes() -> None: """A spec enum pinned in ``_MODELLED_AS_SUBSET`` to a superset enum passes.""" video = next(k for k, v in _MODELLED_AS_SUBSET.items() if v == "VideoMode") spec = _response_spec("videoMode", {"enum": sorted(video)}) assert check_enum_coverage(spec) == ([], []) def test_check_enum_coverage_mapping_target_shrunk_flagged( monkeypatch: pytest.MonkeyPatch, ) -> None: """A pinned lib enum that lost its members (or was renamed) re-surfaces.""" video = next(k for k, v in _MODELLED_AS_SUBSET.items() if v == "VideoMode") monkeypatch.setattr( validate_spec, "_library_enums_by_name", lambda: {"VideoMode": frozenset()} ) spec = _response_spec("videoMode", {"enum": sorted(video)}) errors, warnings = check_enum_coverage(spec) assert warnings == [] assert len(errors) == 1 assert "enum renamed or members removed" in errors[0] def test_check_enum_coverage_inbound_unmodelled_flagged() -> None: spec = _response_spec( "weirdEvent", { "properties": { "metadata": {"properties": {"text": {"enum": ["frob", "baz"]}}} } }, ) errors, warnings = check_enum_coverage(spec) assert warnings == [] assert len(errors) == 1 assert "frob" in errors[0] assert "metadata.properties.text" in errors[0] def test_check_enum_coverage_outbound_only_not_flagged() -> None: """An unmodelled enum reachable only from a request param is waived by direction.""" spec = { "components": {"schemas": {}}, "paths": { "/v1/things": { "get": {"parameters": [{"schema": {"enum": ["onlyrequest"]}}]} } }, } assert check_enum_coverage(spec) == ([], []) def test_check_enum_coverage_waiver_respected() -> None: owner, waived = next(iter(_ENUM_COVERAGE_WAIVERS)) spec = _response_spec(owner, {"enum": sorted(waived)}) assert check_enum_coverage(spec) == ([], []) def test_check_enum_coverage_waiver_does_not_travel_to_another_schema() -> None: """A waived value-set on a different owning schema is still flagged.""" _owner, waived = next(iter(_ENUM_COVERAGE_WAIVERS)) spec = _response_spec("someOtherSchema", {"enum": sorted(waived)}) errors, warnings = check_enum_coverage(spec) assert warnings == [] assert len(errors) == 1 assert "someOtherSchema" in errors[0] def test_check_enum_coverage_sentinel_only_ignored() -> None: spec = _response_spec("thing", {"enum": ["unknown"]}) assert check_enum_coverage(spec) == ([], []) def test_inbound_enum_ids_exclude_request_only() -> None: """Response-reachable enums are inbound; request-only enums are not.""" spec = { "components": {"schemas": {"resp": {"properties": {"s": {"enum": ["inb"]}}}}}, "paths": { "/v1/things": { "post": { "parameters": [{"schema": {"enum": ["outb"]}}], "responses": { "200": { "content": { "application/json": { "schema": {"$ref": "#/components/schemas/resp"} } } } }, } } }, } inbound = _inbound_enum_ids(spec) assert ("resp", frozenset({"inb"})) in inbound assert not any(value_set == frozenset({"outb"}) for _owner, value_set in inbound) def test_check_enum_coverage_outbound_sharing_a_waived_value_set_not_flagged() -> None: """An outbound enum whose values collide with a waived inbound one stays waived.""" owner, waived = next(iter(_ENUM_COVERAGE_WAIVERS)) spec = _response_spec(owner, {"enum": sorted(waived)}) spec["paths"]["/v1/things"]["get"]["parameters"] = [ {"schema": {"enum": sorted(waived)}} ] assert check_enum_coverage(spec) == ([], []) def test_check_enum_coverage_component_responses_flagged() -> None: spec = _response_spec("thing", {"enum": ["unknown"]}) spec["components"]["responses"] = {"Thing": {"description": "reused"}} errors, warnings = check_enum_coverage(spec) assert warnings == [] assert errors == [ ( "spec declares `components.responses`; the inbound enum " "classification does not cover reusable responses" ) ] def test_check_enum_coverage_response_ref_flagged() -> None: spec = _response_spec("thing", {"enum": ["unknown"]}) spec["paths"]["/v1/things"]["get"]["responses"]["200"] = { "$ref": "#/components/responses/Thing" } errors, warnings = check_enum_coverage(spec) assert warnings == [] assert len(errors) == 1 assert "paths./v1/things.get.responses.200" in errors[0] assert "does not cover reusable responses" in errors[0] # --------------------------------------------------------------------------- # # const coverage # --------------------------------------------------------------------------- # def test_iter_spec_consts_collects_string_consts_only() -> None: """String consts are collected per owning schema; numeric consts are skipped.""" spec = { "components": { "schemas": { "eventModelKey": {"type": "string", "const": "event"}, "sirenDuration": {"anyOf": [{"type": "number", "const": 5000}]}, } } } found = dict(_iter_spec_consts(spec)) assert list(found) == [frozenset({"event"})] assert all(isinstance(value, str) for value_set in found for value in value_set) def test_iter_spec_consts_keeps_the_unknown_const() -> None: """``unknown`` in a spec const is a real wire value, not the library sentinel.""" spec = {"components": {"schemas": {"k": {"type": "string", "const": "unknown"}}}} assert list(dict(_iter_spec_consts(spec))) == [frozenset({"unknown"})] assert check_enum_coverage(_response_spec("k", {"const": "unknown"})) == ([], []) def test_check_enum_coverage_const_defined_by_library_enum_passes() -> None: """A const whose value some library enum defines needs no exact match.""" spec = _response_spec("cameraModelKey", {"type": "string", "const": "camera"}) assert check_enum_coverage(spec) == ([], []) def test_check_enum_coverage_unmodelled_inbound_const_flagged() -> None: spec = _response_spec( "teleporterModelKey", {"type": "string", "const": "teleporter"} ) errors, warnings = check_enum_coverage(spec) assert warnings == [] assert len(errors) == 1 assert "'teleporter'" in errors[0] assert "teleporterModelKey" in errors[0] def test_check_enum_coverage_outbound_only_const_not_flagged() -> None: """An unmodelled const reachable only from a request param is waived by direction.""" spec = { "components": {"schemas": {}}, "paths": { "/v1/things": { "get": {"parameters": [{"schema": {"const": "onlyrequest"}}]} } }, } assert check_enum_coverage(spec) == ([], []) def test_check_enum_coverage_const_waiver_respected( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setitem( _ENUM_COVERAGE_WAIVERS, ("teleporter", frozenset({"teleporter"})), "deferred" ) spec = _response_spec("teleporter", {"type": "string", "const": "teleporter"}) assert check_enum_coverage(spec) == ([], []) # --------------------------------------------------------------------------- # # check_event_types # --------------------------------------------------------------------------- # def _event_spec(**overrides: Any) -> dict[str, Any]: """Green ``event`` union spec, optionally overriding the ``event`` schema.""" schema = overrides.pop("event", _event_union({})) return {"components": {"schemas": {"event": schema, **overrides}}} def test_check_event_types_green() -> None: assert check_event_types(_event_spec()) == ([], []) def test_check_event_types_spec_type_not_modelled() -> None: spec = _event_spec() spec["components"]["schemas"]["event"]["oneOf"].append( { "type": "object", "properties": {"type": {"type": "string", "const": "teleport"}}, } ) errors, warnings = check_event_types(spec) assert warnings == [] assert errors == [ "event: spec event type `teleport` is absent from `PUBLIC_EVENT_TYPES`" ] def test_check_event_types_modelled_type_absent_from_spec() -> None: spec = _event_spec() dropped = spec["components"]["schemas"]["event"]["oneOf"].pop() missing = dropped["properties"]["type"]["const"] errors, _warnings = check_event_types(spec) assert len(errors) == 1 assert f"`PUBLIC_EVENT_TYPES` models `{missing}`" in errors[0] def test_resolve_const_follows_ref_and_all_of() -> None: schemas: dict[str, Any] = {"ringType": {"type": "string", "const": "ring"}} assert _resolve_const({"$ref": "#/c/ringType"}, schemas) == "ring" assert _resolve_const( {"allOf": [{"type": "string"}, {"const": "ring"}]}, schemas ) == ("ring") assert _resolve_const({"allOf": [{"type": "string"}]}, schemas) is None assert _resolve_const({"const": 5000}, schemas) is None assert _resolve_const({"$ref": "#/c/absent"}, schemas) is None def test_resolve_const_one_of_resolves_only_on_agreement() -> None: """A union of consts resolves when the variants agree, ``None`` when they differ.""" schemas: dict[str, Any] = {"ringType": {"type": "string", "const": "ring"}} agreeing = { "oneOf": [ {"const": "ring", "description": "left"}, {"$ref": "#/c/ringType"}, ] } assert _resolve_const(agreeing, schemas) == "ring" assert ( _resolve_const({"oneOf": [{"const": "ring"}, {"const": "motion"}]}, schemas) is None ) assert ( _resolve_const({"oneOf": [{"const": "ring"}, {"type": "string"}]}, schemas) is None ) def test_union_variants_flattens_and_rejects_non_unions() -> None: schemas: dict[str, Any] = { "inner": {"oneOf": [{"const": "a"}, {"const": "b"}]}, } assert _union_variants( {"oneOf": [{"$ref": "#/c/inner"}, {"const": "c"}]}, schemas ) == [{"const": "a"}, {"const": "b"}, {"const": "c"}] assert _union_variants({"type": "object"}, schemas) is None assert _union_variants({"$ref": "#/c/absent"}, schemas) is None def test_check_event_types_resolves_referenced_type_const() -> None: """A variant whose ``type`` discriminator is behind a ``$ref`` still resolves.""" value = min(event_type.value for event_type in PUBLIC_EVENT_TYPES) spec = { "components": { "schemas": { "event": { "oneOf": [ { "type": "object", "properties": { "type": {"$ref": "#/components/schemas/ringType"} }, } ] }, "ringType": {"type": "string", "const": value}, } } } errors, _warnings = check_event_types(spec) assert not any("declares no `type` const" in e for e in errors) def test_check_event_types_flattens_a_nested_union() -> None: """Grouping a family of event variants behind their own ``oneOf`` is not drift.""" spec = _event_spec() variants = spec["components"]["schemas"]["event"]["oneOf"] grouped, rest = variants[:5], variants[5:] spec["components"]["schemas"]["sensorEvent"] = {"oneOf": grouped} spec["components"]["schemas"]["event"]["oneOf"] = [ {"$ref": "#/components/schemas/sensorEvent"}, *rest, ] assert check_event_types(spec) == ([], []) def test_check_event_types_missing_schema() -> None: errors, _warnings = check_event_types({"components": {"schemas": {}}}) assert errors == ["event: tracked schema absent from spec (server removed it)"] def test_check_event_types_not_a_union() -> None: errors, _warnings = check_event_types(_event_spec(event={"type": "object"})) assert errors == ["event: spec schema no longer declares a `oneOf` union"] def test_check_event_types_variant_without_const() -> None: spec = _event_spec(event={"oneOf": [{"type": "object", "properties": {}}]}) errors, _warnings = check_event_types(spec) assert any("declares no `type` const" in e for e in errors) def test_check_model_fields_event_union_merges_variants() -> None: """A field only ONE variant carries still covers the model; dropping it errors.""" spec = _chime_spec() variants = spec["components"]["schemas"]["event"]["oneOf"] for variant in variants[:-1]: variant["properties"].pop("metadata") assert sum("metadata" in v["properties"] for v in variants) == 1 assert check_model_fields(spec) == ([], []) variants[-1]["properties"].pop("metadata") errors, _warnings = check_model_fields(spec) assert any("event: model field `metadata`" in e for e in errors) # --------------------------------------------------------------------------- # # run_checks / format_summary / resolution helpers / main # --------------------------------------------------------------------------- # def test_every_check_is_wired_into_run_checks() -> None: """Every check function is reachable from ``run_checks``; nothing is orphaned.""" wired = { check_endpoints, check_model_fields, check_enums, check_enum_coverage, check_event_types, } assert set(_CHECKS) == wired assert len(_CHECKS) == len(wired) def test_run_checks_aggregates() -> None: spec = { "paths": {"/v1/teleporter": {"post": {}}}, "components": {"schemas": _chime_spec()["components"]["schemas"]}, } errors, warnings = run_checks(spec) assert errors == [] assert any("teleporter" in w for w in warnings) def test_format_summary_no_drift() -> None: out = format_summary([], [], version="7.1.77") assert "No drift" in out assert "7.1.77" in out def test_format_summary_errors_and_warnings() -> None: out = format_summary(["boom"], ["heads up"]) assert "Errors (1)" in out assert "Warnings (1)" in out assert "boom" in out assert "heads up" in out def test_spec_field_name_remap() -> None: remaps = {"modelKey": "model"} assert _spec_field_name("modelKey", remaps) == "model" assert _spec_field_name("cameraIds", remaps) == "camera_ids" def test_resolve_object_props_branches() -> None: schemas: dict[str, Any] = { "thing": {"type": "object", "properties": {"a": {"type": "string"}}}, } assert _resolve_object_props({"$ref": "#/c/thing"}, schemas) == { "a": {"type": "string"} } assert _resolve_object_props( {"allOf": [{"$ref": "#/c/thing"}, {"type": "string"}]}, schemas ) == {"a": {"type": "string"}} assert _resolve_object_props({"allOf": [{"type": "string"}]}, schemas) is None assert _resolve_object_props( {"oneOf": [{"type": "string"}, {"$ref": "#/c/thing"}]}, schemas ) == {"a": {"type": "string"}} assert _resolve_object_props({"oneOf": [{"type": "string"}]}, schemas) is None assert _resolve_object_props( {"type": "array", "items": {"$ref": "#/c/thing"}}, schemas ) == {"a": {"type": "string"}} assert _resolve_object_props({"type": "string"}, schemas) is None def test_resolve_object_props_merges_union_variants() -> None: """``oneOf`` variants union their properties instead of picking the first.""" schemas: dict[str, Any] = { "alpha": {"type": "object", "properties": {"a": {"type": "string"}}}, "beta": {"type": "object", "properties": {"b": {"type": "string"}}}, } merged = _resolve_object_props( {"oneOf": [{"$ref": "#/c/alpha"}, {"$ref": "#/c/beta"}]}, schemas ) assert merged is not None assert set(merged) == {"a", "b"} def test_resolve_object_props_merges_conflicting_property_recursively() -> None: """A property the variants disagree on resolves to the union of both shapes.""" schemas: dict[str, Any] = {} node = { "oneOf": [ {"properties": {"metadata": {"properties": {"a": {"type": "string"}}}}}, {"properties": {"metadata": {"properties": {"b": {"type": "string"}}}}}, ] } merged = _resolve_object_props(node, schemas) assert merged is not None nested = _resolve_object_props(merged["metadata"], schemas) assert nested is not None assert set(nested) == {"a", "b"} def test_leaf_model_unwraps_optional_and_list() -> None: assert _leaf_model(PublicChime) is PublicChime assert _leaf_model(list[PublicChime]) is PublicChime assert _leaf_model(PublicChime | None) is PublicChime assert _leaf_model(int) is None def test_main_missing_spec(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: monkeypatch.setattr(validate_spec, "SPEC_PATH", tmp_path / "absent.json") assert main() == 2 def test_main_green_spec( monkeypatch: pytest.MonkeyPatch, tmp_path: Any, capsys: Any ) -> None: spec = _chime_spec() spec["info"] = {"version": "7.1.77"} spec_file = tmp_path / "spec.json" spec_file.write_bytes(orjson.dumps(spec)) monkeypatch.setattr(validate_spec, "SPEC_PATH", spec_file) assert main() == 0 assert "7.1.77" in capsys.readouterr().out def test_main_error_spec( monkeypatch: pytest.MonkeyPatch, tmp_path: Any, capsys: Any ) -> None: spec = _chime_spec(drop="ring_settings") spec_file = tmp_path / "spec.json" spec_file.write_bytes(orjson.dumps(spec)) monkeypatch.setattr(validate_spec, "SPEC_PATH", spec_file) assert main() == 1 assert "ring_settings" in capsys.readouterr().out def test_main_reports_completeness_gap( monkeypatch: pytest.MonkeyPatch, tmp_path: Any, capsys: Any ) -> None: """A non-declarative coroutine with no example call fails the run via main().""" trimmed = dict(_EXAMPLE_CALLS) trimmed.pop("update_camera_public") monkeypatch.setattr(validate_spec, "_EXAMPLE_CALLS", trimmed) spec_file = tmp_path / "spec.json" spec_file.write_bytes(orjson.dumps(_chime_spec())) monkeypatch.setattr(validate_spec, "SPEC_PATH", spec_file) assert main() == 1 assert "update_camera_public" in capsys.readouterr().out