8
.devcontainer/Dockerfile
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ARG VARIANT="3.9"
|
||||||
|
FROM mcr.microsoft.com/vscode/devcontainers/python:dev-${VARIANT}-buster
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg netcat-openbsd
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pip install .
|
56
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||||
|
// https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/docker-existing-dockerfile
|
||||||
|
{
|
||||||
|
"name": "unifi-cam-proxy",
|
||||||
|
|
||||||
|
// Sets the run context to one level up instead of the .devcontainer folder.
|
||||||
|
"context": "..",
|
||||||
|
|
||||||
|
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
|
||||||
|
"dockerFile": "Dockerfile",
|
||||||
|
|
||||||
|
// Set *default* container specific settings.json values on container create.
|
||||||
|
"settings": {
|
||||||
|
"terminal.integrated.defaultProfile.linux": "bash",
|
||||||
|
"python.pythonPath": "/usr/local/bin/python",
|
||||||
|
"python.linting.enabled": true,
|
||||||
|
"python.linting.pylintEnabled": false,
|
||||||
|
"python.linting.flake8Enabled": true,
|
||||||
|
"python.formatting.provider": "black",
|
||||||
|
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||||
|
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||||
|
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||||
|
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
|
||||||
|
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
|
||||||
|
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
|
||||||
|
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
|
||||||
|
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
|
||||||
|
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
],
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/python:1": {
|
||||||
|
"installTools": true,
|
||||||
|
"version": "3.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
|
||||||
|
// Uncomment the next line to run commands after the container is created - for example installing curl.
|
||||||
|
// "postCreateCommand": "apt-get update && apt-get install -y curl",
|
||||||
|
|
||||||
|
// Uncomment when using a ptrace-based debugger like C++, Go, and Rust
|
||||||
|
// "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
|
||||||
|
|
||||||
|
// Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker.
|
||||||
|
// "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ],
|
||||||
|
|
||||||
|
// Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
|
||||||
|
// "remoteUser": "vscode"
|
||||||
|
}
|
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
*.pyc
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
docs
|
||||||
|
*.egg-info
|
||||||
|
venv
|
||||||
|
client.pem
|
||||||
|
run/
|
||||||
|
MANIFEST
|
||||||
|
MANIFEST.in
|
||||||
|
.pyre/
|
5
.flake8
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[flake8]
|
||||||
|
max-line-length = 88
|
||||||
|
select = C,E,F,W,B,B950
|
||||||
|
ignore = E203,E501,W503
|
||||||
|
exclude = .git,__pycache__,venv,build,dist
|
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: keshavdv
|
89
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a bug report to help us improve
|
||||||
|
labels:
|
||||||
|
- "Type: Bug"
|
||||||
|
- "Status: Waiting triage"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
# :warning: **Please read before raising the issue** :warning:
|
||||||
|
|
||||||
|
If you have a **question**, need clarification on something, need help on a particular situation or want to start a discussion, **DO NOT** open an issue here. _It will be automatically closed!_
|
||||||
|
|
||||||
|
Ask the question on one of our [Discord channels](https://discord.gg/Bxk9uGT6MW)
|
||||||
|
If you're not keen to Discord, you can also use [GitHub Discussions](https://github.com/keshavdv/unifi-cam-proxy/discussions).
|
||||||
|
If you really want to raise an issue, please make sure to follow the template and provide the required information. Failing to do so will most likely end up on the issue being close. Don't take offense at this. It is simply a time management decision. Whenever an issue is raised without following the template and the required information is not provided, very often too much time has to be spent going back and forth to obtain the details that are outlined below.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: camera_model
|
||||||
|
attributes:
|
||||||
|
label: Camera
|
||||||
|
description: The make/model/hardware revision of the camera you are attempting to use.
|
||||||
|
placeholder: camera model
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: fw_version
|
||||||
|
attributes:
|
||||||
|
label: Firmware version of the camera
|
||||||
|
description: What's the firmware version where you're seeing this happening. If applicable.
|
||||||
|
placeholder: ex. 1.2.3.456, N/A
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: A clear and concise description of what the problem is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro_steps
|
||||||
|
attributes:
|
||||||
|
label: How to reproduce
|
||||||
|
description: Detailed repro steps so we can see the same problem. If not already explained above.
|
||||||
|
placeholder: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected_behaviour
|
||||||
|
attributes:
|
||||||
|
label: Expected behaviour
|
||||||
|
description: A clear and concise description of what you expected to happen. If applicable.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: Very helpful if you send along a few screenshots to help visualize the issue!
|
||||||
|
placeholder: drag and drop here, if applicable
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other_things
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Other suggested things. If applicable/relevant.
|
||||||
|
placeholder: |
|
||||||
|
for example link to the repository with sample code
|
||||||
|
code snippets
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Make an effort to fix the bug
|
||||||
|
|
||||||
|
Attempt to submit a [Pull Request (PR)](https://help.github.com/articles/about-pull-requests/) that fixes the bug. Include in this PR a test that verifies the fix. If you were not able to fix the bug, a PR that illustrates your partial progress will suffice.
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: unifi-cam-proxy Discord Community
|
||||||
|
url: https://discord.gg/Bxk9uGT6MW
|
||||||
|
about: Please join for QUESTIONS, conversations or discussions.
|
||||||
|
- name: GitHub Discussions
|
||||||
|
url: https://github.com/keshavdv/unifi-cam-proxy/discussions
|
||||||
|
about: Alternative channel for asking QUESTIONS.
|
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an idea to help us improve.
|
||||||
|
labels:
|
||||||
|
- "Type: Feature request"
|
||||||
|
- "Status: waiting feedback"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
# :warning: **Please read before raising the issue** :warning:
|
||||||
|
|
||||||
|
If you have a **question**, need clarification on something, need help on a particular situation or want to start a discussion, **DO NOT** open an issue here. _It will be automatically closed!_
|
||||||
|
|
||||||
|
Ask the question on one of our [Discord channels](https://discord.gg/Bxk9uGT6MW).
|
||||||
|
If you're not keen to Discord, you can also use [GitHub Discussions](https://github.com/keshavdv/unifi-cam-proxy/discussions).
|
||||||
|
|
||||||
|
If you really want to raise an issue, please make sure to follow the template and provide the required information. Failing to do so will most likely end up on the issue being close. Don't take offense at this. It is simply a time management decision. Whenever an issue is raised without following the template and the required information is not provided, very often too much time has to be spent going back and forth to obtain the details that are outlined below.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Is your feature request related to a problem? Please describe.
|
||||||
|
placeholder: A clear and concise description of what the problem is. E.g. I'm always frustrated when [...]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: How to solve the problem
|
||||||
|
description: Describe the solution you'd like
|
||||||
|
placeholder: A clear and concise description of what you would like to happen/exist.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Describe alternatives you've considered
|
||||||
|
description: What alternatives you've considered and/or tested.
|
||||||
|
placeholder: A clear and concise description of any alternative solutions, features or tools that you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other_context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
|
placeholder: |
|
||||||
|
code snnipets
|
||||||
|
screenshots
|
||||||
|
mockups
|
||||||
|
validations:
|
||||||
|
required: false
|
16
.github/stale.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Number of days of inactivity before an issue becomes stale
|
||||||
|
daysUntilStale: 30
|
||||||
|
# Number of days of inactivity before a stale issue is closed
|
||||||
|
daysUntilClose: 3
|
||||||
|
# Issues with these labels will never be considered stale
|
||||||
|
exemptLabels:
|
||||||
|
- pinned
|
||||||
|
# Label to use when marking an issue as stale
|
||||||
|
staleLabel: stale
|
||||||
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
|
markComment: >
|
||||||
|
This issue has been automatically marked as stale because it has not had
|
||||||
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
|
for your contributions.
|
||||||
|
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||||
|
closeComment: false
|
39
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
name: Test
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
paths-ignore:
|
||||||
|
- docs/**
|
||||||
|
- .github/workflows/docs.yml
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
paths-ignore:
|
||||||
|
- docs/**
|
||||||
|
- .github/workflows/docs.yml
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: pyproject.toml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -e .[test]
|
||||||
|
|
||||||
|
- name: Lint with pre-commit
|
||||||
|
run: pre-commit run --all-files
|
40
.github/workflows/docs.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
name: 📚 Deploy Documentation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- docs/**
|
||||||
|
- .github/workflows/docs.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📂 Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🔵 Setup NodeJS
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
cache: yarn
|
||||||
|
cache-dependency-path: ./docs/yarn.lock
|
||||||
|
|
||||||
|
- name: ⏬ Install Dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
working-directory: docs
|
||||||
|
|
||||||
|
- name: 🏗 Build
|
||||||
|
run: yarn build
|
||||||
|
working-directory: docs
|
||||||
|
|
||||||
|
- name: 🚀 Deploy
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: docs/build
|
||||||
|
cname: unifi-cam-proxy.com
|
||||||
|
if: github.ref == 'refs/heads/main'
|
61
.github/workflows/image.yml
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
name: Build Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: main
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
buildx:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
id: prep
|
||||||
|
run: |
|
||||||
|
DOCKER_IMAGE=keshavdv/unifi-cam-proxy
|
||||||
|
VERSION=dev
|
||||||
|
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
fi
|
||||||
|
TAGS="${DOCKER_IMAGE}:${VERSION}"
|
||||||
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
TAGS="$TAGS,${DOCKER_IMAGE}:sha-${GITHUB_SHA::8}"
|
||||||
|
fi
|
||||||
|
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||||
|
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
|
echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.prep.outputs.tags }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=${{ github.event.repository.html_url }}
|
||||||
|
org.opencontainers.image.created=${{ steps.prep.outputs.created }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
*.pyc
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
*.egg-info
|
||||||
|
venv
|
||||||
|
client.pem
|
||||||
|
run/
|
||||||
|
MANIFEST
|
||||||
|
MANIFEST.in
|
||||||
|
.pyre/
|
||||||
|
.vscode/settings.json
|
||||||
|
.vscode/launch.json
|
8
.markdownlint.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
default: true
|
||||||
|
|
||||||
|
# line-length
|
||||||
|
MD013:
|
||||||
|
line_length: 100
|
||||||
|
code_block_line_length: 120
|
||||||
|
|
||||||
|
MD041: false
|
48
.pre-commit-config.yaml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.4.0
|
||||||
|
hooks:
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-yaml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: mixed-line-ending
|
||||||
|
- id: trailing-whitespace
|
||||||
|
args:
|
||||||
|
- --markdown-linebreak-ext=md
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.12.0
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.2.5
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
args:
|
||||||
|
- --skip=docs/yarn.lock
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 23.7.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
|
||||||
|
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||||
|
rev: v0.35.0
|
||||||
|
hooks:
|
||||||
|
- id: markdownlint-fix
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/flake8
|
||||||
|
rev: 6.1.0
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: pyre
|
||||||
|
name: pyre
|
||||||
|
entry: pyre check
|
||||||
|
pass_filenames: false
|
||||||
|
language: python
|
||||||
|
types:
|
||||||
|
- python
|
29
.pyre_configuration
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"source_directories": [
|
||||||
|
"./"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"build/"
|
||||||
|
],
|
||||||
|
"ignore_all_errors": [
|
||||||
|
"build/",
|
||||||
|
"venv/",
|
||||||
|
"run/"
|
||||||
|
],
|
||||||
|
"search_path": [
|
||||||
|
{ "site-package": "aiohttp" },
|
||||||
|
{ "site-package": "amcrest" },
|
||||||
|
{ "site-package": "asyncio_mqtt" },
|
||||||
|
{ "site-package": "backoff" },
|
||||||
|
{ "site-package": "coloredlogs" },
|
||||||
|
{ "site-package": "flvlib3" },
|
||||||
|
{ "site-package": "hikvisionapi" },
|
||||||
|
{ "site-package": "httpx" },
|
||||||
|
{ "site-package": "packaging" },
|
||||||
|
{ "site-package": "pyunifiprotect" },
|
||||||
|
{ "site-package": "reolinkapi" },
|
||||||
|
{ "site-package": "websockets" },
|
||||||
|
{ "site-package": "yarl" }
|
||||||
|
],
|
||||||
|
"taint_models_path": "stubs/taint"
|
||||||
|
}
|
43
Dockerfile
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
ARG version=3.9
|
||||||
|
ARG tag=${version}-alpine3.17
|
||||||
|
|
||||||
|
FROM python:${tag} as builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true
|
||||||
|
|
||||||
|
RUN apk add --update \
|
||||||
|
cargo \
|
||||||
|
git \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
jpeg-dev \
|
||||||
|
libc-dev \
|
||||||
|
linux-headers \
|
||||||
|
musl-dev \
|
||||||
|
patchelf \
|
||||||
|
rust \
|
||||||
|
zlib-dev
|
||||||
|
|
||||||
|
RUN pip install -U pip wheel setuptools maturin
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt --no-build-isolation
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:${tag}
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG version
|
||||||
|
|
||||||
|
COPY --from=builder \
|
||||||
|
/usr/local/lib/python${version}/site-packages \
|
||||||
|
/usr/local/lib/python${version}/site-packages
|
||||||
|
|
||||||
|
RUN apk add --update ffmpeg netcat-openbsd libusb-dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pip install . --no-cache-dir
|
||||||
|
|
||||||
|
COPY ./docker/entrypoint.sh /
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["unifi-cam-proxy"]
|
7
HISTORY.rst
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
History
|
||||||
|
-------
|
||||||
|
|
||||||
|
0.0.0
|
||||||
|
+++++
|
||||||
|
|
||||||
|
* Nothing
|
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Keshav Varma
|
||||||
|
|
||||||
|
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.
|
24
README.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
[![unifi-cam-proxy Discord](https://img.shields.io/discord/937237037466124330?color=0559C9&label=Discord&logo=discord&logoColor=%23FFFFFF&style=for-the-badge)](https://discord.gg/Bxk9uGT6MW)
|
||||||
|
|
||||||
|
# UniFi Camera Proxy
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
This enables using non-Ubiquiti cameras within the UniFi Protect ecosystem. This is
|
||||||
|
particularly useful to use existing RTSP-enabled cameras in the same UI and
|
||||||
|
mobile app as your other Unifi devices.
|
||||||
|
|
||||||
|
Things that work:
|
||||||
|
|
||||||
|
* Live streaming
|
||||||
|
* Full-time recording
|
||||||
|
* Motion detection with certain cameras
|
||||||
|
* Smart Detections using [Frigate](https://github.com/blakeblackshear/frigate)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
View the documentation at <https://unifi-cam-proxy.com>
|
||||||
|
|
||||||
|
## Donations
|
||||||
|
|
||||||
|
If you would like to make a donation to support development, please use [Github Sponsors](https://github.com/sponsors/keshavdv).
|
70
docker-compose.yaml
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
unifi-cam-proxy:
|
||||||
|
build: .
|
||||||
|
container_name: unifi-cam-proxy
|
||||||
|
volumes:
|
||||||
|
- './client.pem:/client.pem'
|
||||||
|
environment:
|
||||||
|
- "HOST=192.168.103.180"
|
||||||
|
- "TOKEN=UojOceUtnvtaxpiY"
|
||||||
|
- "RTSP_URL=rtsp://freja.hiof.no:1935/rtplive/definst/hessdalen03.stream"
|
||||||
|
restart: always
|
||||||
|
unifi-video-controller:
|
||||||
|
image: pducharme/unifi-video-controller
|
||||||
|
container_name: unifi-video-controller
|
||||||
|
ports:
|
||||||
|
- 1935:1935
|
||||||
|
- 6666:6666
|
||||||
|
- 7004:7004
|
||||||
|
- 7080:7080
|
||||||
|
- 7442:7442
|
||||||
|
- 7443:7443
|
||||||
|
- 7444:7444
|
||||||
|
- 7445:7445
|
||||||
|
- 7446:7446
|
||||||
|
- 7447:7447
|
||||||
|
volumes:
|
||||||
|
- ./run/data:/var/lib/unifi-video
|
||||||
|
- ./run/videos:/var/lib/unifi-video/videos
|
||||||
|
environment:
|
||||||
|
- TZ=America/Los_Angeles
|
||||||
|
- DEBUG=1
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
- DAC_READ_SEARCH
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined
|
||||||
|
unifi-protect:
|
||||||
|
container_name: unifi-protect
|
||||||
|
ports:
|
||||||
|
- '7080:7080'
|
||||||
|
- '7442:7442'
|
||||||
|
- '7443:7443'
|
||||||
|
- '7444:7444'
|
||||||
|
- '7447:7447'
|
||||||
|
- '7550:7550'
|
||||||
|
volumes:
|
||||||
|
- './run/protect/data:/srv/unifi-protect'
|
||||||
|
- './run/protect/db:/var/lib/postgresql/10/main'
|
||||||
|
- './run/protect/db_config:/etc/postgresql/10/main'
|
||||||
|
- './run/protect/config.json:/usr/share/unifi-protect/app/config/config.json'
|
||||||
|
environment:
|
||||||
|
- TZ=America/Los_Angeles
|
||||||
|
- PUID=999
|
||||||
|
- PGID=999
|
||||||
|
- PUID_POSTGRES=102
|
||||||
|
- PGID_POSTGRES=104
|
||||||
|
image: fryfrog/unifi-protect
|
||||||
|
frigate:
|
||||||
|
image: blakeblackshear/frigate:stable-amd64
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- type: tmpfs
|
||||||
|
target: /tmp/cache
|
||||||
|
tmpfs:
|
||||||
|
size: 1000000000
|
||||||
|
- ./run/frigate:/config
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
8
docker/entrypoint.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ ! -z "${RTSP_URL:-}" ] && [ ! -z "${HOST}" ] && [ ! -z "${TOKEN}" ]; then
|
||||||
|
echo "Using RTSP stream from $RTSP_URL"
|
||||||
|
exec unifi-cam-proxy --host "$HOST" --name "${NAME:-unifi-cam-proxy}" --mac "${MAC:-'AA:BB:CC:00:11:22'}" --cert /client.pem --token "$TOKEN" rtsp -s "$RTSP_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
20
docs/.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.docusaurus
|
||||||
|
.cache-loader
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
43
docs/README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Website
|
||||||
|
|
||||||
|
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
This command starts a local development server and opens up a browser window.
|
||||||
|
Most changes are reflected live without having to restart the server.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
This command generates static content in the `build` directory.
|
||||||
|
It can then be served using any static content hosting service.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Using SSH:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
USE_SSH=true yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Not using SSH:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GIT_USER=<Your GitHub username> yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using GitHub pages, this command is a convenient way to build and push to the `gh-pages` branch.
|
3
docs/babel.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||||
|
};
|
4
docs/docs/configuration/_category_.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"label": "Camera-Specific Configuration",
|
||||||
|
"position": 1
|
||||||
|
}
|
54
docs/docs/configuration/amcrest.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Amcrest
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
optional arguments:
|
||||||
|
--ffmpeg-args FFMPEG_ARGS, -f FFMPEG_ARGS
|
||||||
|
Transcoding args for `ffmpeg -i <src> <args> <dst>`
|
||||||
|
--rtsp-transport {tcp,udp,http,udp_multicast}
|
||||||
|
RTSP transport protocol used by stream
|
||||||
|
--username USERNAME, -u USERNAME
|
||||||
|
Camera username
|
||||||
|
--password PASSWORD, -p PASSWORD
|
||||||
|
Camera password
|
||||||
|
--channel CHANNEL, -c CHANNEL
|
||||||
|
Camera channel
|
||||||
|
--snapshot-channel SNAPSHOT_CHANNEL
|
||||||
|
Snapshot channel
|
||||||
|
--main-stream MAIN_STREAM
|
||||||
|
Main Stream subtype index
|
||||||
|
--sub-stream SUB_STREAM
|
||||||
|
Sub Stream subtype index
|
||||||
|
--motion-index MOTION_INDEX
|
||||||
|
VideoMotion event index
|
||||||
|
```
|
||||||
|
|
||||||
|
## Amcrest IP8M-T2599E
|
||||||
|
|
||||||
|
- [x] Supports full time recording
|
||||||
|
- [x] Supports motion events
|
||||||
|
- [ ] Supports smart detection
|
||||||
|
- Notes:
|
||||||
|
- Camera configuration:
|
||||||
|
- Video codec must be H.264 (H.265/HEVC is not supported).
|
||||||
|
- Audio codec should be AAC. If not, adjust the ffmpeg args to re-encode to AAC.
|
||||||
|
- Ensure the sub stream is enabled.
|
||||||
|
- If desired, ensure motion detection is enabled with the desired anti-dither and detection area.
|
||||||
|
- The `-bsf:v` parameter is needed to make live video work.
|
||||||
|
The first `tick_rate` value should be `fps * 2000`.
|
||||||
|
See [this comment](https://github.com/keshavdv/unifi-cam-proxy/issues/31#issuecomment-841914363).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy --mac '{unique MAC}' -H {NVR IP} -i {camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
amcrest \
|
||||||
|
-u {username} \
|
||||||
|
-p {password} \
|
||||||
|
--motion-index 0 \
|
||||||
|
--snapshot-channel 1 \
|
||||||
|
--ffmpeg-args='-c:a copy -c:v copy -bsf:v "h264_metadata=tick_rate=30000/1001"'
|
||||||
|
```
|
43
docs/docs/configuration/dahua.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dahua/Lorex
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
optional arguments:
|
||||||
|
--ffmpeg-args FFMPEG_ARGS, -f FFMPEG_ARGS
|
||||||
|
Transcoding args for `ffmpeg -i <src> <args> <dst>`
|
||||||
|
--rtsp-transport {tcp,udp,http,udp_multicast}
|
||||||
|
RTSP transport protocol used by stream
|
||||||
|
--username USERNAME, -u USERNAME
|
||||||
|
Camera username
|
||||||
|
--password PASSWORD, -p PASSWORD
|
||||||
|
Camera password
|
||||||
|
--channel CHANNEL, -c CHANNEL
|
||||||
|
Camera channel
|
||||||
|
--snapshot-channel SNAPSHOT_CHANNEL
|
||||||
|
Snapshot channel
|
||||||
|
--main-stream MAIN_STREAM
|
||||||
|
Main Stream subtype index
|
||||||
|
--sub-stream SUB_STREAM
|
||||||
|
Sub Stream subtype index
|
||||||
|
--motion-index MOTION_INDEX
|
||||||
|
VideoMotion event index
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lorex LNB4321B
|
||||||
|
|
||||||
|
- [x] Supports full time recording
|
||||||
|
- [x] Supports motion events
|
||||||
|
- [ ] Supports smart detection
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy --mac '{unique MAC}' -H {NVR IP} -i {camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
dahua \
|
||||||
|
-u {username} \
|
||||||
|
-p {password} \
|
||||||
|
--ffmpeg-args="-f lavfi -i anullsrc -c:v copy -ar 32000 -ac 1 -codec:a aac -b:a 32k"
|
||||||
|
```
|
42
docs/docs/configuration/frigate.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frigate
|
||||||
|
|
||||||
|
If your camera model is not listed specifically below, try the following:
|
||||||
|
|
||||||
|
- [x] Supports full time recording
|
||||||
|
- [ ] Supports motion events
|
||||||
|
- [x] Supports smart detection
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy --mac '{unique MAC}' -H {NVR IP} -i {camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
frigate \
|
||||||
|
-s {rtsp source} \
|
||||||
|
--mqtt-host {mqtt host} \
|
||||||
|
--frigate-camera {Name of camera in frigate}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
optional arguments:
|
||||||
|
--ffmpeg-args FFMPEG_ARGS, -f FFMPEG_ARGS
|
||||||
|
Transcoding args for `ffmpeg -i <src> <args> <dst>`
|
||||||
|
--rtsp-transport {tcp,udp,http,udp_multicast}
|
||||||
|
RTSP transport protocol used by stream
|
||||||
|
--source SOURCE, -s SOURCE
|
||||||
|
Stream source
|
||||||
|
--http-api HTTP_API Specify a port number to enable the HTTP API (default: disabled)
|
||||||
|
--snapshot-url SNAPSHOT_URL, -i SNAPSHOT_URL
|
||||||
|
HTTP endpoint to fetch snapshot image from
|
||||||
|
--mqtt-host MQTT_HOST
|
||||||
|
MQTT server
|
||||||
|
--mqtt-port MQTT_PORT
|
||||||
|
MQTT server
|
||||||
|
--mqtt-prefix MQTT_PREFIX
|
||||||
|
Topic prefix
|
||||||
|
--frigate-camera FRIGATE_CAMERA
|
||||||
|
Name of camera in frigate
|
||||||
|
```
|
40
docs/docs/configuration/hikvision.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hikvision
|
||||||
|
|
||||||
|
## Generic
|
||||||
|
|
||||||
|
If your camera model is not listed specifically below, try the following:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy -H {NVR IP} -i {camera IP} -c /client.pem -t {Adoption token} hikvision -u {username} -p {password}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
optional arguments:
|
||||||
|
--ffmpeg-args FFMPEG_ARGS, -f FFMPEG_ARGS
|
||||||
|
Transcoding args for `ffmpeg -i <src> <args> <dst>`
|
||||||
|
--rtsp-transport {tcp,udp,http,udp_multicast}
|
||||||
|
RTSP transport protocol used by stream
|
||||||
|
--username USERNAME, -u USERNAME
|
||||||
|
Camera username
|
||||||
|
--password PASSWORD, -p PASSWORD
|
||||||
|
Camera password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hikvision DS-2DE3304W-DE
|
||||||
|
|
||||||
|
- [x] Supports full time recording
|
||||||
|
- [ ] Supports motion events
|
||||||
|
- [ ] Supports smart detection
|
||||||
|
- Notes:
|
||||||
|
- Change Pan/Tilt/Zoom via brightness/saturation/hue camera setting
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy --mac '{unique MAC}' -H {NVR IP} -i {camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
hikvision -u {username} -p {password}
|
||||||
|
```
|
52
docs/docs/configuration/reolink.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Reolink
|
||||||
|
|
||||||
|
## Generic
|
||||||
|
|
||||||
|
If your camera model is not listed specifically below, try the following:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy -H {NVR IP} -i {camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
reolink \
|
||||||
|
-u {username} \
|
||||||
|
-p {password} \
|
||||||
|
-s "main" \
|
||||||
|
--ffmpeg-args='-c:v copy -bsf:v "h264_metadata=tick_rate=60000/1001" -ar 32000 -ac 1 -codec:a aac -b:a 32k'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--ffmpeg-args FFMPEG_ARGS, -f FFMPEG_ARGS
|
||||||
|
Transcoding args for `ffmpeg -i <src> <args> <dst>`
|
||||||
|
--rtsp-transport {tcp,udp,http,udp_multicast}
|
||||||
|
RTSP transport protocol used by stream
|
||||||
|
--username USERNAME, -u USERNAME
|
||||||
|
Camera username
|
||||||
|
--password PASSWORD, -p PASSWORD
|
||||||
|
Camera password
|
||||||
|
--substream SUBSTREAM, -s CHANNEL
|
||||||
|
Camera rtsp url substream index main, or sub
|
||||||
|
```
|
||||||
|
|
||||||
|
## RLC-410-5MP
|
||||||
|
|
||||||
|
- [x] Supports full time recording
|
||||||
|
- [x] Supports motion events
|
||||||
|
- [ ] Supports smart detection
|
||||||
|
- Notes:
|
||||||
|
- When using 'sub' substream, set `tick_rate=30000/1001` since the stream is limited to a max of `15fps`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy --mac '{unique MAC}' -H {NVR IP} -i {camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
reolink \
|
||||||
|
-u {username} \
|
||||||
|
-p {password} \
|
||||||
|
-s "main" \
|
||||||
|
--ffmpeg-args='-c:v copy -bsf:v "h264_metadata=tick_rate=60000/1001" -ar 32000 -ac 1 -codec:a aac -b:a 32k'
|
||||||
|
```
|
38
docs/docs/configuration/reolink_nvr.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Reolink NVR
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--ffmpeg-args FFMPEG_ARGS, -f FFMPEG_ARGS
|
||||||
|
Transcoding args for `ffmpeg -i <src> <args> <dst>`
|
||||||
|
--rtsp-transport {tcp,udp,http,udp_multicast}
|
||||||
|
RTSP transport protocol used by stream
|
||||||
|
--username USERNAME, -u USERNAME
|
||||||
|
NVR username
|
||||||
|
--password PASSWORD, -p PASSWORD
|
||||||
|
NVR password
|
||||||
|
--channel CHANNEL, -c CHANNEL
|
||||||
|
NVR camera channel
|
||||||
|
```
|
||||||
|
|
||||||
|
## NVR (Reolink RLN16-410)
|
||||||
|
|
||||||
|
- [x] Supports full time recording
|
||||||
|
- [x] Supports motion events
|
||||||
|
- [ ] Supports smart detection
|
||||||
|
- Notes:
|
||||||
|
- Camera/channel IDs are zero-based
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy --mac '{unique MAC}' -H {Protect IP} -i {Reolink NVR IP} -c /client.pem -t {Adoption token} \
|
||||||
|
reolink_nvr \
|
||||||
|
-u {username} \
|
||||||
|
-p {password} \
|
||||||
|
-c {Camera channel}
|
||||||
|
```
|
24
docs/docs/configuration/rtsp.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# RTSP
|
||||||
|
|
||||||
|
Most generic cameras are supported via the RTSP integration.
|
||||||
|
Depending on your camera, you might need specific flags to make live-streaming smoother.
|
||||||
|
Check for your specific camera model in the docs before trying this.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
unifi-cam-proxy -H {NVR IP} -i {Camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
rtsp \
|
||||||
|
-s {rtsp stream}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hardware Acceleration
|
||||||
|
|
||||||
|
```sg
|
||||||
|
unifi-cam-proxy -H {NVR IP} -i {Camera IP} -c /client.pem -t {Adoption token} \
|
||||||
|
rtsp \
|
||||||
|
-s {rtsp stream} \
|
||||||
|
--ffmpeg-args='-hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format yuv420p'
|
||||||
|
```
|
103
docs/docs/intro.md
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
slug: /
|
||||||
|
sidebar_position: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Certificate
|
||||||
|
|
||||||
|
Generate a certificate by performing one of the following:
|
||||||
|
|
||||||
|
1. If you have a UniFi camera:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scp ubnt@<your-unifi-cam>:/var/etc/persistent/server.pem client.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create your own client certificate via:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl ecparam -out /tmp/private.key -name prime256v1 -genkey -noout
|
||||||
|
openssl req -new -sha256 -key /tmp/private.key -out /tmp/server.csr -subj "/C=TW/L=Taipei/O=Ubiquiti Networks Inc./OU=devint/CN=camera.ubnt.dev/emailAddress=support@ubnt.com"
|
||||||
|
openssl x509 -req -sha256 -days 36500 -in /tmp/server.csr -signkey /tmp/private.key -out /tmp/public.key
|
||||||
|
cat /tmp/private.key /tmp/public.key > client.pem
|
||||||
|
rm -f /tmp/private.key /tmp/public.key /tmp/server.csr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adoption Token
|
||||||
|
|
||||||
|
In order to add a camera to Protect, you must first generate an adoption token.
|
||||||
|
The token is only valid for 60 minutes.
|
||||||
|
You will need to re-generate a new one if it expires during your initial setup.
|
||||||
|
|
||||||
|
Open https://{NVR IP}/proxy/protect/api/cameras/manage-payload and copy the token field.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Using Docker is the recommended installation method.
|
||||||
|
The sample docker-compose file below is the recommended deployment for most users.
|
||||||
|
Note, the generated certificate must be in the same directory as the `docker-compose.yaml` file.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
unifi-cam-proxy:
|
||||||
|
restart: unless-stopped
|
||||||
|
image: keshavdv/unifi-cam-proxy
|
||||||
|
volumes:
|
||||||
|
- "./client.pem:/client.pem"
|
||||||
|
command: unifi-cam-proxy --host {NVR IP} --cert /client.pem --token {Adoption token} rtsp -s rtsp://192.168.201.15:8554/cam'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple cameras
|
||||||
|
|
||||||
|
To use multiple cameras, start an instance of the proxy for each, with a unique MAC address argument.
|
||||||
|
Using docker-compose, your setup might look like the following:
|
||||||
|
|
||||||
|
***Note: This conforms to MAC randomization rules, so should not cause issues with real devices.***
|
||||||
|
***See here for more details: <https://www.mist.com/get-to-know-mac-address-randomization-in-2020/>***
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.5"
|
||||||
|
services:
|
||||||
|
proxy-1:
|
||||||
|
restart: unless-stopped
|
||||||
|
image: keshavdv/unifi-cam-proxy
|
||||||
|
volumes:
|
||||||
|
- "./client.pem:/client.pem"
|
||||||
|
command: >-
|
||||||
|
unifi-cam-proxy
|
||||||
|
--host {NVR IP}
|
||||||
|
--mac 'AA:BB:CC:00:11:22'
|
||||||
|
--cert /client.pem
|
||||||
|
--token {Adoption token}
|
||||||
|
rtsp -s rtsp://192.168.201.15:8554/cam
|
||||||
|
proxy-2:
|
||||||
|
restart: unless-stopped
|
||||||
|
image: keshavdv/unifi-cam-proxy
|
||||||
|
volumes:
|
||||||
|
- "./client.pem:/client.pem"
|
||||||
|
command: >-
|
||||||
|
unifi-cam-proxy
|
||||||
|
--host {NVR IP}
|
||||||
|
--mac 'AA:BB:CC:33:44:55'
|
||||||
|
--cert /client.pem
|
||||||
|
--token {Adoption token}
|
||||||
|
rtsp -s rtsp://192.168.201.15:8554/cam
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bare Metal
|
||||||
|
|
||||||
|
If you cannot use Docker, you may install the proxy on most Linux distros, but support is not guaranteed.
|
||||||
|
Find instructions for your distro below:
|
||||||
|
|
||||||
|
### Ubuntu/Debian
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apt install ffmpeg netcat python3 python3-pip
|
||||||
|
pip3 install unifi-cam-proxy
|
||||||
|
unifi-cam-proxy --host {NVR IP} --cert /client.pem --token {Adoption token} rtsp -s rtsp://192.168.201.15:8554/cam'
|
||||||
|
```
|
78
docs/docusaurus.config.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Note: type annotations allow type checking and IDEs autocompletion
|
||||||
|
|
||||||
|
const lightCodeTheme = require('prism-react-renderer/themes/github');
|
||||||
|
const darkCodeTheme = require('prism-react-renderer/themes/dracula');
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/types').Config} */
|
||||||
|
const config = {
|
||||||
|
title: 'unifi-cam-proxy',
|
||||||
|
tagline: 'Dinosaurs are cool',
|
||||||
|
url: 'https://unifi-cam-proxy.com',
|
||||||
|
baseUrl: '/',
|
||||||
|
onBrokenLinks: 'throw',
|
||||||
|
onBrokenMarkdownLinks: 'warn',
|
||||||
|
favicon: 'img/favicon.ico',
|
||||||
|
organizationName: 'keshavdv', // Usually your GitHub org/user name.
|
||||||
|
projectName: 'unifi-cam-proxy', // Usually your repo name.
|
||||||
|
trailingSlash: false,
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
'classic',
|
||||||
|
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||||
|
({
|
||||||
|
docs: {
|
||||||
|
routeBasePath: '/',
|
||||||
|
sidebarPath: require.resolve('./sidebars.js'),
|
||||||
|
// Please change this to your repo.
|
||||||
|
editUrl: 'https://github.com/keshavdv/unifi-cam-proxy/tree/main/docs/',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
customCss: require.resolve('./src/css/custom.css'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
themeConfig:
|
||||||
|
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||||
|
({
|
||||||
|
navbar: {
|
||||||
|
title: 'unifi-cam-proxy',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'doc',
|
||||||
|
docId: 'intro',
|
||||||
|
position: 'left',
|
||||||
|
label: 'Docs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://github.com/keshavdv/unifi-cam-proxy',
|
||||||
|
label: 'GitHub',
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
style: 'dark',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'More',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'GitHub',
|
||||||
|
href: 'https://github.com/keshavdv/unifi-cam-proxy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
copyright: `Copyright © ${new Date().getFullYear()} Keshav Varma. Built with Docusaurus.`,
|
||||||
|
},
|
||||||
|
prism: {
|
||||||
|
theme: lightCodeTheme,
|
||||||
|
darkTheme: darkCodeTheme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
37
docs/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"docusaurus": "docusaurus",
|
||||||
|
"start": "docusaurus start",
|
||||||
|
"build": "docusaurus build",
|
||||||
|
"swizzle": "docusaurus swizzle",
|
||||||
|
"deploy": "docusaurus deploy",
|
||||||
|
"clear": "docusaurus clear",
|
||||||
|
"serve": "docusaurus serve",
|
||||||
|
"write-translations": "docusaurus write-translations",
|
||||||
|
"write-heading-ids": "docusaurus write-heading-ids"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@docusaurus/core": "2.0.0-beta.14",
|
||||||
|
"@docusaurus/preset-classic": "2.0.0-beta.14",
|
||||||
|
"@mdx-js/react": "^1.6.21",
|
||||||
|
"clsx": "^1.1.1",
|
||||||
|
"prism-react-renderer": "^1.2.1",
|
||||||
|
"react": "^17.0.1",
|
||||||
|
"react-dom": "^17.0.1"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.5%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
31
docs/sidebars.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Creating a sidebar enables you to:
|
||||||
|
- create an ordered group of docs
|
||||||
|
- render a sidebar for each doc of that group
|
||||||
|
- provide next/previous navigation
|
||||||
|
|
||||||
|
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||||
|
|
||||||
|
Create as many sidebars as you want.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||||
|
const sidebars = {
|
||||||
|
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||||
|
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||||
|
|
||||||
|
// But you can create a sidebar manually
|
||||||
|
/*
|
||||||
|
tutorialSidebar: [
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
label: 'Tutorial',
|
||||||
|
items: ['hello'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = sidebars;
|
28
docs/src/css/custom.css
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Any CSS included here will be global. The classic template
|
||||||
|
* bundles Infima by default. Infima is a CSS framework designed to
|
||||||
|
* work well for content-centric websites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* You can override the default Infima variables here. */
|
||||||
|
:root {
|
||||||
|
--ifm-color-primary: #25c2a0;
|
||||||
|
--ifm-color-primary-dark: rgb(33, 175, 144);
|
||||||
|
--ifm-color-primary-darker: rgb(31, 165, 136);
|
||||||
|
--ifm-color-primary-darkest: rgb(26, 136, 112);
|
||||||
|
--ifm-color-primary-light: rgb(70, 203, 174);
|
||||||
|
--ifm-color-primary-lighter: rgb(102, 212, 189);
|
||||||
|
--ifm-color-primary-lightest: rgb(146, 224, 208);
|
||||||
|
--ifm-code-font-size: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docusaurus-highlight-code-line {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
display: block;
|
||||||
|
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||||
|
padding: 0 var(--ifm-pre-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .docusaurus-highlight-code-line {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
0
docs/static/.nojekyll
vendored
Normal file
1
docs/static/CNAME
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
unifi-cam-proxy.com
|
BIN
docs/static/img/docusaurus.png
vendored
Normal file
After Width: | Height: | Size: 5.0 KiB |
BIN
docs/static/img/favicon.ico
vendored
Normal file
After Width: | Height: | Size: 3.5 KiB |
1
docs/static/img/logo.svg
vendored
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
docs/static/img/tutorial/docsVersionDropdown.png
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
docs/static/img/tutorial/localeDropdown.png
vendored
Normal file
After Width: | Height: | Size: 29 KiB |
170
docs/static/img/undraw_docusaurus_mountain.svg
vendored
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1088" height="687.962" viewBox="0 0 1088 687.962">
|
||||||
|
<g id="Group_12" data-name="Group 12" transform="translate(-57 -56)">
|
||||||
|
<g id="Group_11" data-name="Group 11" transform="translate(57 56)">
|
||||||
|
<path id="Path_83" data-name="Path 83" d="M1017.81,560.461c-5.27,45.15-16.22,81.4-31.25,110.31-20,38.52-54.21,54.04-84.77,70.28a193.275,193.275,0,0,1-27.46,11.94c-55.61,19.3-117.85,14.18-166.74,3.99a657.282,657.282,0,0,0-104.09-13.16q-14.97-.675-29.97-.67c-15.42.02-293.07,5.29-360.67-131.57-16.69-33.76-28.13-75-32.24-125.27-11.63-142.12,52.29-235.46,134.74-296.47,155.97-115.41,369.76-110.57,523.43,7.88C941.15,276.621,1036.99,396.031,1017.81,560.461Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_84" data-name="Path 84" d="M986.56,670.771c-20,38.52-47.21,64.04-77.77,80.28a193.272,193.272,0,0,1-27.46,11.94c-55.61,19.3-117.85,14.18-166.74,3.99a657.3,657.3,0,0,0-104.09-13.16q-14.97-.675-29.97-.67-23.13.03-46.25,1.72c-100.17,7.36-253.82-6.43-321.42-143.29L382,283.981,444.95,445.6l20.09,51.59,55.37-75.98L549,381.981l130.2,149.27,36.8-81.27L970.78,657.9l14.21,11.59Z" transform="translate(-56 -106.019)" fill="#f2f2f2"/>
|
||||||
|
<path id="Path_85" data-name="Path 85" d="M302,282.962l26-57,36,83-31-60Z" opacity="0.1"/>
|
||||||
|
<path id="Path_86" data-name="Path 86" d="M610.5,753.821q-14.97-.675-29.97-.67L465.04,497.191Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||||
|
<path id="Path_87" data-name="Path 87" d="M464.411,315.191,493,292.962l130,150-132-128Z" opacity="0.1"/>
|
||||||
|
<path id="Path_88" data-name="Path 88" d="M908.79,751.051a193.265,193.265,0,0,1-27.46,11.94L679.2,531.251Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||||
|
<circle id="Ellipse_11" data-name="Ellipse 11" cx="3" cy="3" r="3" transform="translate(479 98.962)" fill="#f2f2f2"/>
|
||||||
|
<circle id="Ellipse_12" data-name="Ellipse 12" cx="3" cy="3" r="3" transform="translate(396 201.962)" fill="#f2f2f2"/>
|
||||||
|
<circle id="Ellipse_13" data-name="Ellipse 13" cx="2" cy="2" r="2" transform="translate(600 220.962)" fill="#f2f2f2"/>
|
||||||
|
<circle id="Ellipse_14" data-name="Ellipse 14" cx="2" cy="2" r="2" transform="translate(180 265.962)" fill="#f2f2f2"/>
|
||||||
|
<circle id="Ellipse_15" data-name="Ellipse 15" cx="2" cy="2" r="2" transform="translate(612 96.962)" fill="#f2f2f2"/>
|
||||||
|
<circle id="Ellipse_16" data-name="Ellipse 16" cx="2" cy="2" r="2" transform="translate(736 192.962)" fill="#f2f2f2"/>
|
||||||
|
<circle id="Ellipse_17" data-name="Ellipse 17" cx="2" cy="2" r="2" transform="translate(858 344.962)" fill="#f2f2f2"/>
|
||||||
|
<path id="Path_89" data-name="Path 89" d="M306,121.222h-2.76v-2.76h-1.48v2.76H299V122.7h2.76v2.759h1.48V122.7H306Z" fill="#f2f2f2"/>
|
||||||
|
<path id="Path_90" data-name="Path 90" d="M848,424.222h-2.76v-2.76h-1.48v2.76H841V425.7h2.76v2.759h1.48V425.7H848Z" fill="#f2f2f2"/>
|
||||||
|
<path id="Path_91" data-name="Path 91" d="M1144,719.981c0,16.569-243.557,74-544,74s-544-57.431-544-74,243.557,14,544,14S1144,703.413,1144,719.981Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_92" data-name="Path 92" d="M1144,719.981c0,16.569-243.557,74-544,74s-544-57.431-544-74,243.557,14,544,14S1144,703.413,1144,719.981Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||||
|
<ellipse id="Ellipse_18" data-name="Ellipse 18" cx="544" cy="30" rx="544" ry="30" transform="translate(0 583.962)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_93" data-name="Path 93" d="M624,677.981c0,33.137-14.775,24-33,24s-33,9.137-33-24,33-96,33-96S624,644.844,624,677.981Z" transform="translate(-56 -106.019)" fill="#ff6584"/>
|
||||||
|
<path id="Path_94" data-name="Path 94" d="M606,690.66c0,15.062-6.716,10.909-15,10.909s-15,4.153-15-10.909,15-43.636,15-43.636S606,675.6,606,690.66Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||||
|
<rect id="Rectangle_97" data-name="Rectangle 97" width="92" height="18" rx="9" transform="translate(489 604.962)" fill="#2f2e41"/>
|
||||||
|
<rect id="Rectangle_98" data-name="Rectangle 98" width="92" height="18" rx="9" transform="translate(489 586.962)" fill="#2f2e41"/>
|
||||||
|
<path id="Path_95" data-name="Path 95" d="M193,596.547c0,55.343,34.719,100.126,77.626,100.126" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_96" data-name="Path 96" d="M270.626,696.673c0-55.965,38.745-101.251,86.626-101.251" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||||
|
<path id="Path_97" data-name="Path 97" d="M221.125,601.564c0,52.57,22.14,95.109,49.5,95.109" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||||
|
<path id="Path_98" data-name="Path 98" d="M270.626,696.673c0-71.511,44.783-129.377,100.126-129.377" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_99" data-name="Path 99" d="M254.3,697.379s11.009-.339,14.326-2.7,16.934-5.183,17.757-1.395,16.544,18.844,4.115,18.945-28.879-1.936-32.19-3.953S254.3,697.379,254.3,697.379Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||||
|
<path id="Path_100" data-name="Path 100" d="M290.716,710.909c-12.429.1-28.879-1.936-32.19-3.953-2.522-1.536-3.527-7.048-3.863-9.591l-.368.014s.7,8.879,4.009,10.9,19.761,4.053,32.19,3.953c3.588-.029,4.827-1.305,4.759-3.2C294.755,710.174,293.386,710.887,290.716,710.909Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||||
|
<path id="Path_101" data-name="Path 101" d="M777.429,633.081c0,38.029,23.857,68.8,53.341,68.8" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_102" data-name="Path 102" d="M830.769,701.882c0-38.456,26.623-69.575,59.525-69.575" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||||
|
<path id="Path_103" data-name="Path 103" d="M796.755,636.528c0,36.124,15.213,65.354,34.014,65.354" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||||
|
<path id="Path_104" data-name="Path 104" d="M830.769,701.882c0-49.139,30.773-88.9,68.8-88.9" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_105" data-name="Path 105" d="M819.548,702.367s7.565-.233,9.844-1.856,11.636-3.562,12.2-.958,11.368,12.949,2.828,13.018-19.844-1.33-22.119-2.716S819.548,702.367,819.548,702.367Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||||
|
<path id="Path_106" data-name="Path 106" d="M844.574,711.664c-8.54.069-19.844-1.33-22.119-2.716-1.733-1.056-2.423-4.843-2.654-6.59l-.253.01s.479,6.1,2.755,7.487,13.579,2.785,22.119,2.716c2.465-.02,3.317-.9,3.27-2.2C847.349,711.159,846.409,711.649,844.574,711.664Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||||
|
<path id="Path_107" data-name="Path 107" d="M949.813,724.718s11.36-1.729,14.5-4.591,16.89-7.488,18.217-3.667,19.494,17.447,6.633,19.107-30.153,1.609-33.835-.065S949.813,724.718,949.813,724.718Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||||
|
<path id="Path_108" data-name="Path 108" d="M989.228,734.173c-12.86,1.659-30.153,1.609-33.835-.065-2.8-1.275-4.535-6.858-5.2-9.45l-.379.061s1.833,9.109,5.516,10.783,20.975,1.725,33.835.065c3.712-.479,4.836-1.956,4.529-3.906C993.319,732.907,991.991,733.817,989.228,734.173Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||||
|
<path id="Path_109" data-name="Path 109" d="M670.26,723.9s9.587-1.459,12.237-3.875,14.255-6.32,15.374-3.095,16.452,14.725,5.6,16.125-25.448,1.358-28.555-.055S670.26,723.9,670.26,723.9Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||||
|
<path id="Path_110" data-name="Path 110" d="M703.524,731.875c-10.853,1.4-25.448,1.358-28.555-.055-2.367-1.076-3.827-5.788-4.39-7.976l-.32.051s1.547,7.687,4.655,9.1,17.7,1.456,28.555.055c3.133-.4,4.081-1.651,3.822-3.3C706.977,730.807,705.856,731.575,703.524,731.875Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||||
|
<path id="Path_111" data-name="Path 111" d="M178.389,719.109s7.463-1.136,9.527-3.016,11.1-4.92,11.969-2.409,12.808,11.463,4.358,12.553-19.811,1.057-22.23-.043S178.389,719.109,178.389,719.109Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||||
|
<path id="Path_112" data-name="Path 112" d="M204.285,725.321c-8.449,1.09-19.811,1.057-22.23-.043-1.842-.838-2.979-4.506-3.417-6.209l-.249.04s1.2,5.984,3.624,7.085,13.781,1.133,22.23.043c2.439-.315,3.177-1.285,2.976-2.566C206.973,724.489,206.1,725.087,204.285,725.321Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||||
|
<path id="Path_113" data-name="Path 113" d="M439.7,707.337c0,30.22-42.124,20.873-93.7,20.873s-93.074,9.347-93.074-20.873,42.118-36.793,93.694-36.793S439.7,677.117,439.7,707.337Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||||
|
<path id="Path_114" data-name="Path 114" d="M439.7,699.9c0,30.22-42.124,20.873-93.7,20.873s-93.074,9.347-93.074-20.873S295.04,663.1,346.616,663.1,439.7,669.676,439.7,699.9Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||||
|
</g>
|
||||||
|
<g id="docusaurus_keytar" transform="translate(312.271 493.733)">
|
||||||
|
<path id="Path_40" data-name="Path 40" d="M99,52h91.791V89.153H99Z" transform="translate(5.904 -14.001)" fill="#fff" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_41" data-name="Path 41" d="M24.855,163.927A21.828,21.828,0,0,1,5.947,153a21.829,21.829,0,0,0,18.908,32.782H46.71V163.927Z" transform="translate(-3 -4.634)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_42" data-name="Path 42" d="M121.861,61.1l76.514-4.782V45.39A21.854,21.854,0,0,0,176.52,23.535H78.173L75.441,18.8a3.154,3.154,0,0,0-5.464,0l-2.732,4.732L64.513,18.8a3.154,3.154,0,0,0-5.464,0l-2.732,4.732L53.586,18.8a3.154,3.154,0,0,0-5.464,0L45.39,23.535c-.024,0-.046,0-.071,0l-4.526-4.525a3.153,3.153,0,0,0-5.276,1.414l-1.5,5.577-5.674-1.521a3.154,3.154,0,0,0-3.863,3.864L26,34.023l-5.575,1.494a3.155,3.155,0,0,0-1.416,5.278l4.526,4.526c0,.023,0,.046,0,.07L18.8,48.122a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,59.05a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,69.977a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,80.9a3.154,3.154,0,0,0,0,5.464L23.535,89.1,18.8,91.832a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,102.76a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,113.687a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,124.615a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,135.542a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,146.469a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,157.4a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,168.324a3.154,3.154,0,0,0,0,5.464l4.732,2.732A21.854,21.854,0,0,0,45.39,198.375H176.52a21.854,21.854,0,0,0,21.855-21.855V89.1l-76.514-4.782a11.632,11.632,0,0,1,0-23.219" transform="translate(-1.681 -17.226)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_43" data-name="Path 43" d="M143,186.71h32.782V143H143Z" transform="translate(9.984 -5.561)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_44" data-name="Path 44" d="M196.71,159.855a5.438,5.438,0,0,0-.7.07c-.042-.164-.081-.329-.127-.493a5.457,5.457,0,1,0-5.4-9.372q-.181-.185-.366-.367a5.454,5.454,0,1,0-9.384-5.4c-.162-.046-.325-.084-.486-.126a5.467,5.467,0,1,0-10.788,0c-.162.042-.325.08-.486.126a5.457,5.457,0,1,0-9.384,5.4,21.843,21.843,0,1,0,36.421,21.02,5.452,5.452,0,1,0,.7-10.858" transform="translate(10.912 -6.025)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_45" data-name="Path 45" d="M153,124.855h32.782V103H153Z" transform="translate(10.912 -9.271)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_46" data-name="Path 46" d="M194.855,116.765a2.732,2.732,0,1,0,0-5.464,2.811,2.811,0,0,0-.349.035c-.022-.082-.04-.164-.063-.246a2.733,2.733,0,0,0-1.052-5.253,2.7,2.7,0,0,0-1.648.566q-.09-.093-.184-.184a2.7,2.7,0,0,0,.553-1.633,2.732,2.732,0,0,0-5.245-1.07,10.928,10.928,0,1,0,0,21.031,2.732,2.732,0,0,0,5.245-1.07,2.7,2.7,0,0,0-.553-1.633q.093-.09.184-.184a2.7,2.7,0,0,0,1.648.566,2.732,2.732,0,0,0,1.052-5.253c.023-.081.042-.164.063-.246a2.814,2.814,0,0,0,.349.035" transform="translate(12.767 -9.377)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_47" data-name="Path 47" d="M65.087,56.891a2.732,2.732,0,0,1-2.732-2.732,8.2,8.2,0,0,0-16.391,0,2.732,2.732,0,0,1-5.464,0,13.659,13.659,0,0,1,27.319,0,2.732,2.732,0,0,1-2.732,2.732" transform="translate(0.478 -15.068)" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_48" data-name="Path 48" d="M103,191.347h65.565a21.854,21.854,0,0,0,21.855-21.855V93H124.855A21.854,21.854,0,0,0,103,114.855Z" transform="translate(6.275 -10.199)" fill="#ffff50" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_49" data-name="Path 49" d="M173.216,129.787H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0,21.855H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186m0,21.855H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0-54.434H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0,21.652H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186m0,21.855H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186M189.585,61.611c-.013,0-.024-.007-.037-.005-3.377.115-4.974,3.492-6.384,6.472-1.471,3.114-2.608,5.139-4.473,5.078-2.064-.074-3.244-2.406-4.494-4.874-1.436-2.835-3.075-6.049-6.516-5.929-3.329.114-4.932,3.053-6.346,5.646-1.5,2.762-2.529,4.442-4.5,4.364-2.106-.076-3.225-1.972-4.52-4.167-1.444-2.443-3.112-5.191-6.487-5.1-3.272.113-4.879,2.606-6.3,4.808-1.5,2.328-2.552,3.746-4.551,3.662-2.156-.076-3.27-1.65-4.558-3.472-1.447-2.047-3.077-4.363-6.442-4.251-3.2.109-4.807,2.153-6.224,3.954-1.346,1.709-2.4,3.062-4.621,2.977a1.093,1.093,0,0,0-.079,2.186c3.3.11,4.967-1.967,6.417-3.81,1.286-1.635,2.4-3.045,4.582-3.12,2.1-.09,3.091,1.218,4.584,3.327,1.417,2,3.026,4.277,6.263,4.394,3.391.114,5.022-2.42,6.467-4.663,1.292-2,2.406-3.734,4.535-3.807,1.959-.073,3.026,1.475,4.529,4.022,1.417,2.4,3.023,5.121,6.324,5.241,3.415.118,5.064-2.863,6.5-5.5,1.245-2.282,2.419-4.437,4.5-4.509,1.959-.046,2.981,1.743,4.492,4.732,1.412,2.79,3.013,5.95,6.365,6.071l.185,0c3.348,0,4.937-3.36,6.343-6.331,1.245-2.634,2.423-5.114,4.444-5.216Z" transform="translate(7.109 -13.11)" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_50" data-name="Path 50" d="M83,186.71h43.71V143H83Z" transform="translate(4.42 -5.561)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<g id="Group_8" data-name="Group 8" transform="matrix(0.966, -0.259, 0.259, 0.966, 109.327, 91.085)">
|
||||||
|
<rect id="Rectangle_3" data-name="Rectangle 3" width="92.361" height="36.462" rx="2" transform="translate(0 0)" fill="#d8d8d8"/>
|
||||||
|
<g id="Group_2" data-name="Group 2" transform="translate(1.531 23.03)">
|
||||||
|
<rect id="Rectangle_4" data-name="Rectangle 4" width="5.336" height="5.336" rx="1" transform="translate(16.797 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_5" data-name="Rectangle 5" width="5.336" height="5.336" rx="1" transform="translate(23.12 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_6" data-name="Rectangle 6" width="5.336" height="5.336" rx="1" transform="translate(29.444 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_7" data-name="Rectangle 7" width="5.336" height="5.336" rx="1" transform="translate(35.768 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_8" data-name="Rectangle 8" width="5.336" height="5.336" rx="1" transform="translate(42.091 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_9" data-name="Rectangle 9" width="5.336" height="5.336" rx="1" transform="translate(48.415 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_10" data-name="Rectangle 10" width="5.336" height="5.336" rx="1" transform="translate(54.739 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_11" data-name="Rectangle 11" width="5.336" height="5.336" rx="1" transform="translate(61.063 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_12" data-name="Rectangle 12" width="5.336" height="5.336" rx="1" transform="translate(67.386 0)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_51" data-name="Path 51" d="M1.093,0H14.518a1.093,1.093,0,0,1,1.093,1.093V4.243a1.093,1.093,0,0,1-1.093,1.093H1.093A1.093,1.093,0,0,1,0,4.243V1.093A1.093,1.093,0,0,1,1.093,0ZM75,0H88.426a1.093,1.093,0,0,1,1.093,1.093V4.243a1.093,1.093,0,0,1-1.093,1.093H75a1.093,1.093,0,0,1-1.093-1.093V1.093A1.093,1.093,0,0,1,75,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_3" data-name="Group 3" transform="translate(1.531 10.261)">
|
||||||
|
<path id="Path_52" data-name="Path 52" d="M1.093,0H6.218A1.093,1.093,0,0,1,7.31,1.093V4.242A1.093,1.093,0,0,1,6.218,5.335H1.093A1.093,1.093,0,0,1,0,4.242V1.093A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<rect id="Rectangle_13" data-name="Rectangle 13" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_14" data-name="Rectangle 14" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_15" data-name="Rectangle 15" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_16" data-name="Rectangle 16" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_17" data-name="Rectangle 17" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_18" data-name="Rectangle 18" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_19" data-name="Rectangle 19" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_20" data-name="Rectangle 20" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_21" data-name="Rectangle 21" width="5.336" height="5.336" rx="1" transform="translate(58.888 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_22" data-name="Rectangle 22" width="5.336" height="5.336" rx="1" transform="translate(65.212 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_23" data-name="Rectangle 23" width="5.336" height="5.336" rx="1" transform="translate(71.536 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_24" data-name="Rectangle 24" width="5.336" height="5.336" rx="1" transform="translate(77.859 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_25" data-name="Rectangle 25" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_4" data-name="Group 4" transform="translate(91.05 9.546) rotate(180)">
|
||||||
|
<path id="Path_53" data-name="Path 53" d="M1.093,0H6.219A1.093,1.093,0,0,1,7.312,1.093v3.15A1.093,1.093,0,0,1,6.219,5.336H1.093A1.093,1.093,0,0,1,0,4.243V1.093A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<rect id="Rectangle_26" data-name="Rectangle 26" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_27" data-name="Rectangle 27" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_28" data-name="Rectangle 28" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_29" data-name="Rectangle 29" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_30" data-name="Rectangle 30" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_31" data-name="Rectangle 31" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_32" data-name="Rectangle 32" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_33" data-name="Rectangle 33" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_34" data-name="Rectangle 34" width="5.336" height="5.336" rx="1" transform="translate(58.889 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_35" data-name="Rectangle 35" width="5.336" height="5.336" rx="1" transform="translate(65.213 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_36" data-name="Rectangle 36" width="5.336" height="5.336" rx="1" transform="translate(71.537 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_37" data-name="Rectangle 37" width="5.336" height="5.336" rx="1" transform="translate(77.86 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_38" data-name="Rectangle 38" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_39" data-name="Rectangle 39" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_40" data-name="Rectangle 40" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_41" data-name="Rectangle 41" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_42" data-name="Rectangle 42" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_43" data-name="Rectangle 43" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_44" data-name="Rectangle 44" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_45" data-name="Rectangle 45" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_46" data-name="Rectangle 46" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_47" data-name="Rectangle 47" width="5.336" height="5.336" rx="1" transform="translate(58.889 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_48" data-name="Rectangle 48" width="5.336" height="5.336" rx="1" transform="translate(65.213 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_49" data-name="Rectangle 49" width="5.336" height="5.336" rx="1" transform="translate(71.537 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_50" data-name="Rectangle 50" width="5.336" height="5.336" rx="1" transform="translate(77.86 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_51" data-name="Rectangle 51" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_6" data-name="Group 6" transform="translate(1.531 16.584)">
|
||||||
|
<path id="Path_54" data-name="Path 54" d="M1.093,0h7.3A1.093,1.093,0,0,1,9.485,1.093v3.15A1.093,1.093,0,0,1,8.392,5.336h-7.3A1.093,1.093,0,0,1,0,4.243V1.094A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<g id="Group_5" data-name="Group 5" transform="translate(10.671 0)">
|
||||||
|
<rect id="Rectangle_52" data-name="Rectangle 52" width="5.336" height="5.336" rx="1" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_53" data-name="Rectangle 53" width="5.336" height="5.336" rx="1" transform="translate(6.324 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_54" data-name="Rectangle 54" width="5.336" height="5.336" rx="1" transform="translate(12.647 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_55" data-name="Rectangle 55" width="5.336" height="5.336" rx="1" transform="translate(18.971 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_56" data-name="Rectangle 56" width="5.336" height="5.336" rx="1" transform="translate(25.295 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_57" data-name="Rectangle 57" width="5.336" height="5.336" rx="1" transform="translate(31.619 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_58" data-name="Rectangle 58" width="5.336" height="5.336" rx="1" transform="translate(37.942 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_59" data-name="Rectangle 59" width="5.336" height="5.336" rx="1" transform="translate(44.265 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_60" data-name="Rectangle 60" width="5.336" height="5.336" rx="1" transform="translate(50.589 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_61" data-name="Rectangle 61" width="5.336" height="5.336" rx="1" transform="translate(56.912 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_62" data-name="Rectangle 62" width="5.336" height="5.336" rx="1" transform="translate(63.236 0)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<path id="Path_55" data-name="Path 55" d="M1.094,0H8A1.093,1.093,0,0,1,9.091,1.093v3.15A1.093,1.093,0,0,1,8,5.336H1.093A1.093,1.093,0,0,1,0,4.243V1.094A1.093,1.093,0,0,1,1.093,0Z" transform="translate(80.428 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_7" data-name="Group 7" transform="translate(1.531 29.627)">
|
||||||
|
<rect id="Rectangle_63" data-name="Rectangle 63" width="5.336" height="5.336" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_64" data-name="Rectangle 64" width="5.336" height="5.336" rx="1" transform="translate(6.324 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_65" data-name="Rectangle 65" width="5.336" height="5.336" rx="1" transform="translate(12.647 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_66" data-name="Rectangle 66" width="5.336" height="5.336" rx="1" transform="translate(18.971 0)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_56" data-name="Path 56" d="M1.093,0H31.515a1.093,1.093,0,0,1,1.093,1.093V4.244a1.093,1.093,0,0,1-1.093,1.093H1.093A1.093,1.093,0,0,1,0,4.244V1.093A1.093,1.093,0,0,1,1.093,0ZM34.687,0h3.942a1.093,1.093,0,0,1,1.093,1.093V4.244a1.093,1.093,0,0,1-1.093,1.093H34.687a1.093,1.093,0,0,1-1.093-1.093V1.093A1.093,1.093,0,0,1,34.687,0Z" transform="translate(25.294 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<rect id="Rectangle_67" data-name="Rectangle 67" width="5.336" height="5.336" rx="1" transform="translate(66.003 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_68" data-name="Rectangle 68" width="5.336" height="5.336" rx="1" transform="translate(72.327 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_69" data-name="Rectangle 69" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_57" data-name="Path 57" d="M5.336,0V1.18A1.093,1.093,0,0,1,4.243,2.273H1.093A1.093,1.093,0,0,1,0,1.18V0Z" transform="translate(83.59 2.273) rotate(180)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_58" data-name="Path 58" d="M5.336,0V1.18A1.093,1.093,0,0,1,4.243,2.273H1.093A1.093,1.093,0,0,1,0,1.18V0Z" transform="translate(78.255 3.063)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<rect id="Rectangle_70" data-name="Rectangle 70" width="88.927" height="2.371" rx="1.085" transform="translate(1.925 1.17)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_71" data-name="Rectangle 71" width="4.986" height="1.581" rx="0.723" transform="translate(4.1 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_72" data-name="Rectangle 72" width="4.986" height="1.581" rx="0.723" transform="translate(10.923 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_73" data-name="Rectangle 73" width="4.986" height="1.581" rx="0.723" transform="translate(16.173 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_74" data-name="Rectangle 74" width="4.986" height="1.581" rx="0.723" transform="translate(21.421 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_75" data-name="Rectangle 75" width="4.986" height="1.581" rx="0.723" transform="translate(26.671 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_76" data-name="Rectangle 76" width="4.986" height="1.581" rx="0.723" transform="translate(33.232 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_77" data-name="Rectangle 77" width="4.986" height="1.581" rx="0.723" transform="translate(38.48 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_78" data-name="Rectangle 78" width="4.986" height="1.581" rx="0.723" transform="translate(43.73 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_79" data-name="Rectangle 79" width="4.986" height="1.581" rx="0.723" transform="translate(48.978 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_80" data-name="Rectangle 80" width="4.986" height="1.581" rx="0.723" transform="translate(55.54 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_81" data-name="Rectangle 81" width="4.986" height="1.581" rx="0.723" transform="translate(60.788 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_82" data-name="Rectangle 82" width="4.986" height="1.581" rx="0.723" transform="translate(66.038 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_83" data-name="Rectangle 83" width="4.986" height="1.581" rx="0.723" transform="translate(72.599 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_84" data-name="Rectangle 84" width="4.986" height="1.581" rx="0.723" transform="translate(77.847 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_85" data-name="Rectangle 85" width="4.986" height="1.581" rx="0.723" transform="translate(83.097 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
</g>
|
||||||
|
<path id="Path_59" data-name="Path 59" d="M146.71,159.855a5.439,5.439,0,0,0-.7.07c-.042-.164-.081-.329-.127-.493a5.457,5.457,0,1,0-5.4-9.372q-.181-.185-.366-.367a5.454,5.454,0,1,0-9.384-5.4c-.162-.046-.325-.084-.486-.126a5.467,5.467,0,1,0-10.788,0c-.162.042-.325.08-.486.126a5.457,5.457,0,1,0-9.384,5.4,21.843,21.843,0,1,0,36.421,21.02,5.452,5.452,0,1,0,.7-10.858" transform="translate(6.275 -6.025)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_60" data-name="Path 60" d="M83,124.855h43.71V103H83Z" transform="translate(4.42 -9.271)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_61" data-name="Path 61" d="M134.855,116.765a2.732,2.732,0,1,0,0-5.464,2.811,2.811,0,0,0-.349.035c-.022-.082-.04-.164-.063-.246a2.733,2.733,0,0,0-1.052-5.253,2.7,2.7,0,0,0-1.648.566q-.09-.093-.184-.184a2.7,2.7,0,0,0,.553-1.633,2.732,2.732,0,0,0-5.245-1.07,10.928,10.928,0,1,0,0,21.031,2.732,2.732,0,0,0,5.245-1.07,2.7,2.7,0,0,0-.553-1.633q.093-.09.184-.184a2.7,2.7,0,0,0,1.648.566,2.732,2.732,0,0,0,1.052-5.253c.023-.081.042-.164.063-.246a2.811,2.811,0,0,0,.349.035" transform="translate(7.202 -9.377)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_62" data-name="Path 62" d="M143.232,42.33a2.967,2.967,0,0,1-.535-.055,2.754,2.754,0,0,1-.514-.153,2.838,2.838,0,0,1-.471-.251,4.139,4.139,0,0,1-.415-.339,3.2,3.2,0,0,1-.338-.415A2.7,2.7,0,0,1,140.5,39.6a2.968,2.968,0,0,1,.055-.535,3.152,3.152,0,0,1,.152-.514,2.874,2.874,0,0,1,.252-.47,2.633,2.633,0,0,1,.753-.754,2.837,2.837,0,0,1,.471-.251,2.753,2.753,0,0,1,.514-.153,2.527,2.527,0,0,1,1.071,0,2.654,2.654,0,0,1,.983.4,4.139,4.139,0,0,1,.415.339,4.019,4.019,0,0,1,.339.415,2.786,2.786,0,0,1,.251.47,2.864,2.864,0,0,1,.208,1.049,2.77,2.77,0,0,1-.8,1.934,4.139,4.139,0,0,1-.415.339,2.722,2.722,0,0,1-1.519.459m21.855-1.366a2.789,2.789,0,0,1-1.935-.8,4.162,4.162,0,0,1-.338-.415,2.7,2.7,0,0,1-.459-1.519,2.789,2.789,0,0,1,.8-1.934,4.139,4.139,0,0,1,.415-.339,2.838,2.838,0,0,1,.471-.251,2.752,2.752,0,0,1,.514-.153,2.527,2.527,0,0,1,1.071,0,2.654,2.654,0,0,1,.983.4,4.139,4.139,0,0,1,.415.339,2.79,2.79,0,0,1,.8,1.934,3.069,3.069,0,0,1-.055.535,2.779,2.779,0,0,1-.153.514,3.885,3.885,0,0,1-.251.47,4.02,4.02,0,0,1-.339.415,4.138,4.138,0,0,1-.415.339,2.722,2.722,0,0,1-1.519.459" transform="translate(9.753 -15.532)" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 31 KiB |
169
docs/static/img/undraw_docusaurus_react.svg
vendored
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1041.277" height="554.141" viewBox="0 0 1041.277 554.141">
|
||||||
|
<g id="Group_24" data-name="Group 24" transform="translate(-440 -263)">
|
||||||
|
<g id="Group_23" data-name="Group 23" transform="translate(439.989 262.965)">
|
||||||
|
<path id="Path_299" data-name="Path 299" d="M1040.82,611.12q-1.74,3.75-3.47,7.4-2.7,5.67-5.33,11.12c-.78,1.61-1.56,3.19-2.32,4.77-8.6,17.57-16.63,33.11-23.45,45.89A73.21,73.21,0,0,1,942.44,719l-151.65,1.65h-1.6l-13,.14-11.12.12-34.1.37h-1.38l-17.36.19h-.53l-107,1.16-95.51,1-11.11.12-69,.75H429l-44.75.48h-.48l-141.5,1.53-42.33.46a87.991,87.991,0,0,1-10.79-.54h0c-1.22-.14-2.44-.3-3.65-.49a87.38,87.38,0,0,1-51.29-27.54C116,678.37,102.75,655,93.85,629.64q-1.93-5.49-3.6-11.12C59.44,514.37,97,380,164.6,290.08q4.25-5.64,8.64-11l.07-.08c20.79-25.52,44.1-46.84,68.93-62,44-26.91,92.75-34.49,140.7-11.9,40.57,19.12,78.45,28.11,115.17,30.55,3.71.24,7.42.42,11.11.53,84.23,2.65,163.17-27.7,255.87-47.29,3.69-.78,7.39-1.55,11.12-2.28,66.13-13.16,139.49-20.1,226.73-5.51a189.089,189.089,0,0,1,26.76,6.4q5.77,1.86,11.12,4c41.64,16.94,64.35,48.24,74,87.46q1.37,5.46,2.37,11.11C1134.3,384.41,1084.19,518.23,1040.82,611.12Z" transform="translate(-79.34 -172.91)" fill="#f2f2f2"/>
|
||||||
|
<path id="Path_300" data-name="Path 300" d="M576.36,618.52a95.21,95.21,0,0,1-1.87,11.12h93.7V618.52Zm-78.25,62.81,11.11-.09V653.77c-3.81-.17-7.52-.34-11.11-.52ZM265.19,618.52v11.12h198.5V618.52ZM1114.87,279h-74V191.51q-5.35-2.17-11.12-4V279H776.21V186.58c-3.73.73-7.43,1.5-11.12,2.28V279H509.22V236.15c-3.69-.11-7.4-.29-11.11-.53V279H242.24V217c-24.83,15.16-48.14,36.48-68.93,62h-.07v.08q-4.4,5.4-8.64,11h8.64V618.52h-83q1.66,5.63,3.6,11.12h79.39v93.62a87,87,0,0,0,12.2,2.79c1.21.19,2.43.35,3.65.49h0a87.991,87.991,0,0,0,10.79.54l42.33-.46v-97H498.11v94.21l11.11-.12V629.64H765.09V721l11.12-.12V629.64H1029.7v4.77c.76-1.58,1.54-3.16,2.32-4.77q2.63-5.45,5.33-11.12,1.73-3.64,3.47-7.4v-321h76.42Q1116.23,284.43,1114.87,279ZM242.24,618.52V290.08H498.11V618.52Zm267,0V290.08H765.09V618.52Zm520.48,0H776.21V290.08H1029.7Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_301" data-name="Path 301" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l46.65-28,93.6-.78,2-.01.66-.01,2-.03,44.94-.37,2.01-.01.64-.01,2-.01L315,509.3l.38-.01,35.55-.3h.29l277.4-2.34,6.79-.05h.68l5.18-.05,37.65-.31,2-.03,1.85-.02h.96l11.71-.09,2.32-.03,3.11-.02,9.75-.09,15.47-.13,2-.02,3.48-.02h.65l74.71-.64Z" fill="#65617d"/>
|
||||||
|
<path id="Path_302" data-name="Path 302" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l46.65-28,93.6-.78,2-.01.66-.01,2-.03,44.94-.37,2.01-.01.64-.01,2-.01L315,509.3l.38-.01,35.55-.3h.29l277.4-2.34,6.79-.05h.68l5.18-.05,37.65-.31,2-.03,1.85-.02h.96l11.71-.09,2.32-.03,3.11-.02,9.75-.09,15.47-.13,2-.02,3.48-.02h.65l74.71-.64Z" opacity="0.2"/>
|
||||||
|
<path id="Path_303" data-name="Path 303" d="M375.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_304" data-name="Path 304" d="M375.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_305" data-name="Path 305" d="M377.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||||
|
<rect id="Rectangle_137" data-name="Rectangle 137" width="47.17" height="31.5" transform="translate(680.92 483.65)" fill="#3f3d56"/>
|
||||||
|
<rect id="Rectangle_138" data-name="Rectangle 138" width="47.17" height="31.5" transform="translate(680.92 483.65)" opacity="0.1"/>
|
||||||
|
<rect id="Rectangle_139" data-name="Rectangle 139" width="47.17" height="31.5" transform="translate(678.92 483.65)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_306" data-name="Path 306" d="M298.09,483.65v4.97l-47.17,1.26v-6.23Z" opacity="0.1"/>
|
||||||
|
<path id="Path_307" data-name="Path 307" d="M460.69,485.27v168.2a4,4,0,0,1-3.85,3.95l-191.65,5.1h-.05a4,4,0,0,1-3.95-3.95V485.27a4,4,0,0,1,3.95-3.95h191.6a4,4,0,0,1,3.95,3.95Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||||
|
<path id="Path_308" data-name="Path 308" d="M265.19,481.32v181.2h-.05a4,4,0,0,1-3.95-3.95V485.27a4,4,0,0,1,3.95-3.95Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_309" data-name="Path 309" d="M194.59,319.15h177.5V467.4l-177.5,4Z" fill="#39374d"/>
|
||||||
|
<path id="Path_310" data-name="Path 310" d="M726.09,483.65v6.41l-47.17-1.26v-5.15Z" opacity="0.1"/>
|
||||||
|
<path id="Path_311" data-name="Path 311" d="M867.69,485.27v173.3a4,4,0,0,1-4,3.95h0L672,657.42a4,4,0,0,1-3.85-3.95V485.27a4,4,0,0,1,3.95-3.95H863.7a4,4,0,0,1,3.99,3.95Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||||
|
<path id="Path_312" data-name="Path 312" d="M867.69,485.27v173.3a4,4,0,0,1-4,3.95h0V481.32h0a4,4,0,0,1,4,3.95Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_313" data-name="Path 313" d="M775.59,319.15H598.09V467.4l177.5,4Z" fill="#39374d"/>
|
||||||
|
<path id="Path_314" data-name="Path 314" d="M663.19,485.27v168.2a4,4,0,0,1-3.85,3.95l-191.65,5.1h0a4,4,0,0,1-4-3.95V485.27a4,4,0,0,1,3.95-3.95h191.6A4,4,0,0,1,663.19,485.27Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||||
|
<path id="Path_315" data-name="Path 315" d="M397.09,319.15h177.5V467.4l-177.5,4Z" fill="#4267b2"/>
|
||||||
|
<path id="Path_316" data-name="Path 316" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l202.51-1.33h.48l40.99-.28h.19l283.08-1.87h.29l.17-.01h.47l4.79-.03h1.46l74.49-.5,4.4-.02.98-.01Z" opacity="0.1"/>
|
||||||
|
<circle id="Ellipse_111" data-name="Ellipse 111" cx="51.33" cy="51.33" r="51.33" transform="translate(435.93 246.82)" fill="#fbbebe"/>
|
||||||
|
<path id="Path_317" data-name="Path 317" d="M617.94,550.07s-99.5,12-90,0c3.44-4.34,4.39-17.2,4.2-31.85-.06-4.45-.22-9.06-.45-13.65-1.1-22-3.75-43.5-3.75-43.5s87-41,77-8.5c-4,13.13-2.69,31.57.35,48.88.89,5.05,1.92,10,3,14.7a344.66,344.66,0,0,0,9.65,33.92Z" transform="translate(-79.34 -172.91)" fill="#fbbebe"/>
|
||||||
|
<path id="Path_318" data-name="Path 318" d="M585.47,546c11.51-2.13,23.7-6,34.53-1.54,2.85,1.17,5.47,2.88,8.39,3.86s6.12,1.22,9.16,1.91c10.68,2.42,19.34,10.55,24.9,20s8.44,20.14,11.26,30.72l6.9,25.83c6,22.45,12,45.09,13.39,68.3a2437.506,2437.506,0,0,1-250.84,1.43c5.44-10.34,11-21.31,10.54-33s-7.19-23.22-4.76-34.74c1.55-7.34,6.57-13.39,9.64-20.22,8.75-19.52,1.94-45.79,17.32-60.65,6.92-6.68,17-9.21,26.63-8.89,12.28.41,24.85,4.24,37,6.11C555.09,547.48,569.79,548.88,585.47,546Z" transform="translate(-79.34 -172.91)" fill="#ff6584"/>
|
||||||
|
<path id="Path_319" data-name="Path 319" d="M716.37,657.17l-.1,1.43v.1l-.17,2.3-1.33,18.51-1.61,22.3-.46,6.28-1,13.44v.17l-107,1-175.59,1.9v.84h-.14v-1.12l.45-14.36.86-28.06.74-23.79.07-2.37a10.53,10.53,0,0,1,11.42-10.17c4.72.4,10.85.89,18.18,1.41l3,.22c42.33,2.94,120.56,6.74,199.5,2,1.66-.09,3.33-.19,5-.31,12.24-.77,24.47-1.76,36.58-3a10.53,10.53,0,0,1,11.6,11.23Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_320" data-name="Path 320" d="M429.08,725.44v-.84l175.62-1.91,107-1h.3v-.17l1-13.44.43-6,1.64-22.61,1.29-17.9v-.44a10.617,10.617,0,0,0-.11-2.47.3.3,0,0,0,0-.1,10.391,10.391,0,0,0-2-4.64,10.54,10.54,0,0,0-9.42-4c-12.11,1.24-24.34,2.23-36.58,3-1.67.12-3.34.22-5,.31-78.94,4.69-157.17.89-199.5-2l-3-.22c-7.33-.52-13.46-1-18.18-1.41a10.54,10.54,0,0,0-11.24,8.53,11,11,0,0,0-.18,1.64l-.68,22.16L429.54,710l-.44,14.36v1.12Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||||
|
<path id="Path_321" data-name="Path 321" d="M716.67,664.18l-1.23,15.33-1.83,22.85-.46,5.72-1,12.81-.06.64v.17h0l-.15,1.48.11-1.48h-.29l-107,1-175.65,1.9v-.28l.49-14.36,1-28.06.64-18.65A6.36,6.36,0,0,1,434.3,658a6.25,6.25,0,0,1,3.78-.9c2.1.17,4.68.37,7.69.59,4.89.36,10.92.78,17.94,1.22,13,.82,29.31,1.7,48,2.42,52,2,122.2,2.67,188.88-3.17,3-.26,6.1-.55,9.13-.84a6.26,6.26,0,0,1,3.48.66,5.159,5.159,0,0,1,.86.54,6.14,6.14,0,0,1,2,2.46,3.564,3.564,0,0,1,.25.61A6.279,6.279,0,0,1,716.67,664.18Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_322" data-name="Path 322" d="M377.44,677.87v3.19a6.13,6.13,0,0,1-3.5,5.54l-40.1.77a6.12,6.12,0,0,1-3.57-5.57v-3Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_323" data-name="Path 323" d="M298.59,515.57l-52.25,1V507.9l52.25-1Z" fill="#3f3d56"/>
|
||||||
|
<path id="Path_324" data-name="Path 324" d="M298.59,515.57l-52.25,1V507.9l52.25-1Z" opacity="0.1"/>
|
||||||
|
<path id="Path_325" data-name="Path 325" d="M300.59,515.57l-52.25,1V507.9l52.25-1Z" fill="#3f3d56"/>
|
||||||
|
<path id="Path_326" data-name="Path 326" d="M758.56,679.87v3.19a6.13,6.13,0,0,0,3.5,5.54l40.1.77a6.12,6.12,0,0,0,3.57-5.57v-3Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||||
|
<path id="Path_327" data-name="Path 327" d="M678.72,517.57l52.25,1V509.9l-52.25-1Z" opacity="0.1"/>
|
||||||
|
<path id="Path_328" data-name="Path 328" d="M676.72,517.57l52.25,1V509.9l-52.25-1Z" fill="#3f3d56"/>
|
||||||
|
<path id="Path_329" data-name="Path 329" d="M534.13,486.79c.08,7-3.16,13.6-5.91,20.07a163.491,163.491,0,0,0-12.66,74.71c.73,11,2.58,22,.73,32.9s-8.43,21.77-19,24.9c17.53,10.45,41.26,9.35,57.76-2.66,8.79-6.4,15.34-15.33,21.75-24.11a97.86,97.86,0,0,1-13.31,44.75A103.43,103.43,0,0,0,637,616.53c4.31-5.81,8.06-12.19,9.72-19.23,3.09-13-1.22-26.51-4.51-39.5a266.055,266.055,0,0,1-6.17-33c-.43-3.56-.78-7.22.1-10.7,1-4.07,3.67-7.51,5.64-11.22,5.6-10.54,5.73-23.3,2.86-34.88s-8.49-22.26-14.06-32.81c-4.46-8.46-9.3-17.31-17.46-22.28-5.1-3.1-11-4.39-16.88-5.64l-25.37-5.43c-5.55-1.19-11.26-2.38-16.87-1.51-9.47,1.48-16.14,8.32-22,15.34-4.59,5.46-15.81,15.71-16.6,22.86-.72,6.59,5.1,17.63,6.09,24.58,1.3,9,2.22,6,7.3,11.52C532,478.05,534.07,482,534.13,486.79Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||||
|
</g>
|
||||||
|
<g id="docusaurus_keytar" transform="translate(670.271 615.768)">
|
||||||
|
<path id="Path_40" data-name="Path 40" d="M99,52h43.635V69.662H99Z" transform="translate(-49.132 -33.936)" fill="#fff" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_41" data-name="Path 41" d="M13.389,158.195A10.377,10.377,0,0,1,4.4,153a10.377,10.377,0,0,0,8.988,15.584H23.779V158.195Z" transform="translate(-3 -82.47)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_42" data-name="Path 42" d="M66.967,38.083l36.373-2.273V30.615A10.389,10.389,0,0,0,92.95,20.226H46.2l-1.3-2.249a1.5,1.5,0,0,0-2.6,0L41,20.226l-1.3-2.249a1.5,1.5,0,0,0-2.6,0l-1.3,2.249-1.3-2.249a1.5,1.5,0,0,0-2.6,0l-1.3,2.249-.034,0-2.152-2.151a1.5,1.5,0,0,0-2.508.672L25.21,21.4l-2.7-.723a1.5,1.5,0,0,0-1.836,1.837l.722,2.7-2.65.71a1.5,1.5,0,0,0-.673,2.509l2.152,2.152c0,.011,0,.022,0,.033l-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6L20.226,41l-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3A10.389,10.389,0,0,0,30.615,103.34H92.95A10.389,10.389,0,0,0,103.34,92.95V51.393L66.967,49.12a5.53,5.53,0,0,1,0-11.038" transform="translate(-9.836 -17.226)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_43" data-name="Path 43" d="M143,163.779h15.584V143H143Z" transform="translate(-70.275 -77.665)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_44" data-name="Path 44" d="M173.779,148.389a2.582,2.582,0,0,0-.332.033c-.02-.078-.038-.156-.06-.234a2.594,2.594,0,1,0-2.567-4.455q-.086-.088-.174-.175a2.593,2.593,0,1,0-4.461-2.569c-.077-.022-.154-.04-.231-.06a2.6,2.6,0,1,0-5.128,0c-.077.02-.154.038-.231.06a2.594,2.594,0,1,0-4.461,2.569,10.384,10.384,0,1,0,17.314,9.992,2.592,2.592,0,1,0,.332-5.161" transform="translate(-75.08 -75.262)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_45" data-name="Path 45" d="M153,113.389h15.584V103H153Z" transform="translate(-75.08 -58.444)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_46" data-name="Path 46" d="M183.389,108.944a1.3,1.3,0,1,0,0-2.6,1.336,1.336,0,0,0-.166.017c-.01-.039-.019-.078-.03-.117a1.3,1.3,0,0,0-.5-2.5,1.285,1.285,0,0,0-.783.269q-.043-.044-.087-.087a1.285,1.285,0,0,0,.263-.776,1.3,1.3,0,0,0-2.493-.509,5.195,5.195,0,1,0,0,10,1.3,1.3,0,0,0,2.493-.509,1.285,1.285,0,0,0-.263-.776q.044-.043.087-.087a1.285,1.285,0,0,0,.783.269,1.3,1.3,0,0,0,.5-2.5c.011-.038.02-.078.03-.117a1.337,1.337,0,0,0,.166.017" transform="translate(-84.691 -57.894)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_47" data-name="Path 47" d="M52.188,48.292a1.3,1.3,0,0,1-1.3-1.3,3.9,3.9,0,0,0-7.792,0,1.3,1.3,0,1,1-2.6,0,6.493,6.493,0,0,1,12.987,0,1.3,1.3,0,0,1-1.3,1.3" transform="translate(-21.02 -28.41)" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_48" data-name="Path 48" d="M103,139.752h31.168a10.389,10.389,0,0,0,10.389-10.389V93H113.389A10.389,10.389,0,0,0,103,103.389Z" transform="translate(-51.054 -53.638)" fill="#ffff50" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_49" data-name="Path 49" d="M141.1,94.017H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0-25.877H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.293H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m7.782-47.993c-.006,0-.011,0-.018,0-1.605.055-2.365,1.66-3.035,3.077-.7,1.48-1.24,2.443-2.126,2.414-.981-.035-1.542-1.144-2.137-2.317-.683-1.347-1.462-2.876-3.1-2.819-1.582.054-2.344,1.451-3.017,2.684-.715,1.313-1.2,2.112-2.141,2.075-1-.036-1.533-.938-2.149-1.981-.686-1.162-1.479-2.467-3.084-2.423-1.555.053-2.319,1.239-2.994,2.286-.713,1.106-1.213,1.781-2.164,1.741-1.025-.036-1.554-.784-2.167-1.65-.688-.973-1.463-2.074-3.062-2.021a3.815,3.815,0,0,0-2.959,1.879c-.64.812-1.14,1.456-2.2,1.415a.52.52,0,0,0-.037,1.039,3.588,3.588,0,0,0,3.05-1.811c.611-.777,1.139-1.448,2.178-1.483,1-.043,1.47.579,2.179,1.582.674.953,1.438,2.033,2.977,2.089,1.612.054,2.387-1.151,3.074-2.217.614-.953,1.144-1.775,2.156-1.81.931-.035,1.438.7,2.153,1.912.674,1.141,1.437,2.434,3.006,2.491,1.623.056,2.407-1.361,3.09-2.616.592-1.085,1.15-2.109,2.14-2.143.931-.022,1.417.829,2.135,2.249.671,1.326,1.432,2.828,3.026,2.886l.088,0c1.592,0,2.347-1.6,3.015-3.01.592-1.252,1.152-2.431,2.113-2.479Z" transform="translate(-55.378 -38.552)" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_50" data-name="Path 50" d="M83,163.779h20.779V143H83Z" transform="translate(-41.443 -77.665)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<g id="Group_8" data-name="Group 8" transform="matrix(0.966, -0.259, 0.259, 0.966, 51.971, 43.3)">
|
||||||
|
<rect id="Rectangle_3" data-name="Rectangle 3" width="43.906" height="17.333" rx="2" transform="translate(0 0)" fill="#d8d8d8"/>
|
||||||
|
<g id="Group_2" data-name="Group 2" transform="translate(0.728 10.948)">
|
||||||
|
<rect id="Rectangle_4" data-name="Rectangle 4" width="2.537" height="2.537" rx="1" transform="translate(7.985 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_5" data-name="Rectangle 5" width="2.537" height="2.537" rx="1" transform="translate(10.991 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_6" data-name="Rectangle 6" width="2.537" height="2.537" rx="1" transform="translate(13.997 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_7" data-name="Rectangle 7" width="2.537" height="2.537" rx="1" transform="translate(17.003 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_8" data-name="Rectangle 8" width="2.537" height="2.537" rx="1" transform="translate(20.009 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_9" data-name="Rectangle 9" width="2.537" height="2.537" rx="1" transform="translate(23.015 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_10" data-name="Rectangle 10" width="2.537" height="2.537" rx="1" transform="translate(26.021 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_11" data-name="Rectangle 11" width="2.537" height="2.537" rx="1" transform="translate(29.028 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_12" data-name="Rectangle 12" width="2.537" height="2.537" rx="1" transform="translate(32.034 0)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_51" data-name="Path 51" d="M.519,0H6.9A.519.519,0,0,1,7.421.52v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0ZM35.653,0h6.383a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H35.652a.519.519,0,0,1-.519-.519V.519A.519.519,0,0,1,35.652,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_3" data-name="Group 3" transform="translate(0.728 4.878)">
|
||||||
|
<path id="Path_52" data-name="Path 52" d="M.519,0H2.956a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<rect id="Rectangle_13" data-name="Rectangle 13" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_14" data-name="Rectangle 14" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_15" data-name="Rectangle 15" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_16" data-name="Rectangle 16" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_17" data-name="Rectangle 17" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_18" data-name="Rectangle 18" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_19" data-name="Rectangle 19" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_20" data-name="Rectangle 20" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_21" data-name="Rectangle 21" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_22" data-name="Rectangle 22" width="2.537" height="2.537" rx="1" transform="translate(31 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_23" data-name="Rectangle 23" width="2.537" height="2.537" rx="1" transform="translate(34.006 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_24" data-name="Rectangle 24" width="2.537" height="2.537" rx="1" transform="translate(37.012 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_25" data-name="Rectangle 25" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_4" data-name="Group 4" transform="translate(43.283 4.538) rotate(180)">
|
||||||
|
<path id="Path_53" data-name="Path 53" d="M.519,0H2.956a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<rect id="Rectangle_26" data-name="Rectangle 26" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_27" data-name="Rectangle 27" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_28" data-name="Rectangle 28" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_29" data-name="Rectangle 29" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_30" data-name="Rectangle 30" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_31" data-name="Rectangle 31" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_32" data-name="Rectangle 32" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_33" data-name="Rectangle 33" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_34" data-name="Rectangle 34" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_35" data-name="Rectangle 35" width="2.537" height="2.537" rx="1" transform="translate(31.001 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_36" data-name="Rectangle 36" width="2.537" height="2.537" rx="1" transform="translate(34.007 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_37" data-name="Rectangle 37" width="2.537" height="2.537" rx="1" transform="translate(37.013 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_38" data-name="Rectangle 38" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_39" data-name="Rectangle 39" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_40" data-name="Rectangle 40" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_41" data-name="Rectangle 41" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_42" data-name="Rectangle 42" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_43" data-name="Rectangle 43" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_44" data-name="Rectangle 44" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_45" data-name="Rectangle 45" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_46" data-name="Rectangle 46" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_47" data-name="Rectangle 47" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_48" data-name="Rectangle 48" width="2.537" height="2.537" rx="1" transform="translate(31.001 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_49" data-name="Rectangle 49" width="2.537" height="2.537" rx="1" transform="translate(34.007 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_50" data-name="Rectangle 50" width="2.537" height="2.537" rx="1" transform="translate(37.013 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_51" data-name="Rectangle 51" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_6" data-name="Group 6" transform="translate(0.728 7.883)">
|
||||||
|
<path id="Path_54" data-name="Path 54" d="M.519,0h3.47a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.52A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<g id="Group_5" data-name="Group 5" transform="translate(5.073 0)">
|
||||||
|
<rect id="Rectangle_52" data-name="Rectangle 52" width="2.537" height="2.537" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_53" data-name="Rectangle 53" width="2.537" height="2.537" rx="1" transform="translate(3.006 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_54" data-name="Rectangle 54" width="2.537" height="2.537" rx="1" transform="translate(6.012 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_55" data-name="Rectangle 55" width="2.537" height="2.537" rx="1" transform="translate(9.018 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_56" data-name="Rectangle 56" width="2.537" height="2.537" rx="1" transform="translate(12.025 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_57" data-name="Rectangle 57" width="2.537" height="2.537" rx="1" transform="translate(15.031 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_58" data-name="Rectangle 58" width="2.537" height="2.537" rx="1" transform="translate(18.037 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_59" data-name="Rectangle 59" width="2.537" height="2.537" rx="1" transform="translate(21.042 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_60" data-name="Rectangle 60" width="2.537" height="2.537" rx="1" transform="translate(24.049 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_61" data-name="Rectangle 61" width="2.537" height="2.537" rx="1" transform="translate(27.055 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_62" data-name="Rectangle 62" width="2.537" height="2.537" rx="1" transform="translate(30.061 0)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<path id="Path_55" data-name="Path 55" d="M.52,0H3.8a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.52A.519.519,0,0,1,.519,0Z" transform="translate(38.234 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_7" data-name="Group 7" transform="translate(0.728 14.084)">
|
||||||
|
<rect id="Rectangle_63" data-name="Rectangle 63" width="2.537" height="2.537" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_64" data-name="Rectangle 64" width="2.537" height="2.537" rx="1" transform="translate(3.006 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_65" data-name="Rectangle 65" width="2.537" height="2.537" rx="1" transform="translate(6.012 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_66" data-name="Rectangle 66" width="2.537" height="2.537" rx="1" transform="translate(9.018 0)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_56" data-name="Path 56" d="M.519,0H14.981A.519.519,0,0,1,15.5.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.018V.519A.519.519,0,0,1,.519,0Zm15.97,0h1.874a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H16.489a.519.519,0,0,1-.519-.519V.519A.519.519,0,0,1,16.489,0Z" transform="translate(12.024 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||||
|
<rect id="Rectangle_67" data-name="Rectangle 67" width="2.537" height="2.537" rx="1" transform="translate(31.376 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_68" data-name="Rectangle 68" width="2.537" height="2.537" rx="1" transform="translate(34.382 0)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_69" data-name="Rectangle 69" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_57" data-name="Path 57" d="M2.537,0V.561a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,.561V0Z" transform="translate(39.736 1.08) rotate(180)" fill="#4a4a4a"/>
|
||||||
|
<path id="Path_58" data-name="Path 58" d="M2.537,0V.561a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,.561V0Z" transform="translate(37.2 1.456)" fill="#4a4a4a"/>
|
||||||
|
</g>
|
||||||
|
<rect id="Rectangle_70" data-name="Rectangle 70" width="42.273" height="1.127" rx="0.564" transform="translate(0.915 0.556)" fill="#4a4a4a"/>
|
||||||
|
<rect id="Rectangle_71" data-name="Rectangle 71" width="2.37" height="0.752" rx="0.376" transform="translate(1.949 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_72" data-name="Rectangle 72" width="2.37" height="0.752" rx="0.376" transform="translate(5.193 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_73" data-name="Rectangle 73" width="2.37" height="0.752" rx="0.376" transform="translate(7.688 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_74" data-name="Rectangle 74" width="2.37" height="0.752" rx="0.376" transform="translate(10.183 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_75" data-name="Rectangle 75" width="2.37" height="0.752" rx="0.376" transform="translate(12.679 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_76" data-name="Rectangle 76" width="2.37" height="0.752" rx="0.376" transform="translate(15.797 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_77" data-name="Rectangle 77" width="2.37" height="0.752" rx="0.376" transform="translate(18.292 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_78" data-name="Rectangle 78" width="2.37" height="0.752" rx="0.376" transform="translate(20.788 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_79" data-name="Rectangle 79" width="2.37" height="0.752" rx="0.376" transform="translate(23.283 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_80" data-name="Rectangle 80" width="2.37" height="0.752" rx="0.376" transform="translate(26.402 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_81" data-name="Rectangle 81" width="2.37" height="0.752" rx="0.376" transform="translate(28.897 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_82" data-name="Rectangle 82" width="2.37" height="0.752" rx="0.376" transform="translate(31.393 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_83" data-name="Rectangle 83" width="2.37" height="0.752" rx="0.376" transform="translate(34.512 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_84" data-name="Rectangle 84" width="2.37" height="0.752" rx="0.376" transform="translate(37.007 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
<rect id="Rectangle_85" data-name="Rectangle 85" width="2.37" height="0.752" rx="0.376" transform="translate(39.502 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||||
|
</g>
|
||||||
|
<path id="Path_59" data-name="Path 59" d="M123.779,148.389a2.583,2.583,0,0,0-.332.033c-.02-.078-.038-.156-.06-.234a2.594,2.594,0,1,0-2.567-4.455q-.086-.088-.174-.175a2.593,2.593,0,1,0-4.461-2.569c-.077-.022-.154-.04-.231-.06a2.6,2.6,0,1,0-5.128,0c-.077.02-.154.038-.231.06a2.594,2.594,0,1,0-4.461,2.569,10.384,10.384,0,1,0,17.314,9.992,2.592,2.592,0,1,0,.332-5.161" transform="translate(-51.054 -75.262)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_60" data-name="Path 60" d="M83,113.389h20.779V103H83Z" transform="translate(-41.443 -58.444)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_61" data-name="Path 61" d="M123.389,108.944a1.3,1.3,0,1,0,0-2.6,1.338,1.338,0,0,0-.166.017c-.01-.039-.019-.078-.03-.117a1.3,1.3,0,0,0-.5-2.5,1.285,1.285,0,0,0-.783.269q-.043-.044-.087-.087a1.285,1.285,0,0,0,.263-.776,1.3,1.3,0,0,0-2.493-.509,5.195,5.195,0,1,0,0,10,1.3,1.3,0,0,0,2.493-.509,1.285,1.285,0,0,0-.263-.776q.044-.043.087-.087a1.285,1.285,0,0,0,.783.269,1.3,1.3,0,0,0,.5-2.5c.011-.038.02-.078.03-.117a1.335,1.335,0,0,0,.166.017" transform="translate(-55.859 -57.894)" fill="#44d860" fill-rule="evenodd"/>
|
||||||
|
<path id="Path_62" data-name="Path 62" d="M141.8,38.745a1.41,1.41,0,0,1-.255-.026,1.309,1.309,0,0,1-.244-.073,1.349,1.349,0,0,1-.224-.119,1.967,1.967,0,0,1-.2-.161,1.52,1.52,0,0,1-.161-.2,1.282,1.282,0,0,1-.218-.722,1.41,1.41,0,0,1,.026-.255,1.5,1.5,0,0,1,.072-.244,1.364,1.364,0,0,1,.12-.223,1.252,1.252,0,0,1,.358-.358,1.349,1.349,0,0,1,.224-.119,1.309,1.309,0,0,1,.244-.073,1.2,1.2,0,0,1,.509,0,1.262,1.262,0,0,1,.468.192,1.968,1.968,0,0,1,.2.161,1.908,1.908,0,0,1,.161.2,1.322,1.322,0,0,1,.12.223,1.361,1.361,0,0,1,.1.5,1.317,1.317,0,0,1-.379.919,1.968,1.968,0,0,1-.2.161,1.346,1.346,0,0,1-.223.119,1.332,1.332,0,0,1-.5.1m10.389-.649a1.326,1.326,0,0,1-.92-.379,1.979,1.979,0,0,1-.161-.2,1.282,1.282,0,0,1-.218-.722,1.326,1.326,0,0,1,.379-.919,1.967,1.967,0,0,1,.2-.161,1.351,1.351,0,0,1,.224-.119,1.308,1.308,0,0,1,.244-.073,1.2,1.2,0,0,1,.509,0,1.262,1.262,0,0,1,.468.192,1.967,1.967,0,0,1,.2.161,1.326,1.326,0,0,1,.379.919,1.461,1.461,0,0,1-.026.255,1.323,1.323,0,0,1-.073.244,1.847,1.847,0,0,1-.119.223,1.911,1.911,0,0,1-.161.2,1.967,1.967,0,0,1-.2.161,1.294,1.294,0,0,1-.722.218" transform="translate(-69.074 -26.006)" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
<g id="React-icon" transform="translate(906.3 541.56)">
|
||||||
|
<path id="Path_330" data-name="Path 330" d="M263.668,117.179c0-5.827-7.3-11.35-18.487-14.775,2.582-11.4,1.434-20.477-3.622-23.382a7.861,7.861,0,0,0-4.016-1v4a4.152,4.152,0,0,1,2.044.466c2.439,1.4,3.5,6.724,2.672,13.574-.2,1.685-.52,3.461-.914,5.272a86.9,86.9,0,0,0-11.386-1.954,87.469,87.469,0,0,0-7.459-8.965c5.845-5.433,11.332-8.41,15.062-8.41V78h0c-4.931,0-11.386,3.514-17.913,9.611-6.527-6.061-12.982-9.539-17.913-9.539v4c3.712,0,9.216,2.959,15.062,8.356a84.687,84.687,0,0,0-7.405,8.947,83.732,83.732,0,0,0-11.4,1.972c-.412-1.793-.717-3.532-.932-5.2-.843-6.85.2-12.175,2.618-13.592a3.991,3.991,0,0,1,2.062-.466v-4h0a8,8,0,0,0-4.052,1c-5.039,2.9-6.168,11.96-3.568,23.328-11.153,3.443-18.415,8.947-18.415,14.757,0,5.828,7.3,11.35,18.487,14.775-2.582,11.4-1.434,20.477,3.622,23.382a7.882,7.882,0,0,0,4.034,1c4.931,0,11.386-3.514,17.913-9.611,6.527,6.061,12.982,9.539,17.913,9.539a8,8,0,0,0,4.052-1c5.039-2.9,6.168-11.96,3.568-23.328C256.406,128.511,263.668,122.988,263.668,117.179Zm-23.346-11.96c-.663,2.313-1.488,4.7-2.421,7.083-.735-1.434-1.506-2.869-2.349-4.3-.825-1.434-1.7-2.833-2.582-4.2C235.517,104.179,237.974,104.645,240.323,105.219Zm-8.212,19.1c-1.4,2.421-2.833,4.716-4.321,6.85-2.672.233-5.379.359-8.1.359-2.708,0-5.415-.126-8.069-.341q-2.232-3.2-4.339-6.814-2.044-3.523-3.73-7.136c1.112-2.4,2.367-4.805,3.712-7.154,1.4-2.421,2.833-4.716,4.321-6.85,2.672-.233,5.379-.359,8.1-.359,2.708,0,5.415.126,8.069.341q2.232,3.2,4.339,6.814,2.044,3.523,3.73,7.136C234.692,119.564,233.455,121.966,232.11,124.315Zm5.792-2.331c.968,2.4,1.793,4.805,2.474,7.136-2.349.574-4.823,1.058-7.387,1.434.879-1.381,1.757-2.8,2.582-4.25C236.4,124.871,237.167,123.419,237.9,121.984ZM219.72,141.116a73.921,73.921,0,0,1-4.985-5.738c1.614.072,3.263.126,4.931.126,1.685,0,3.353-.036,4.985-.126A69.993,69.993,0,0,1,219.72,141.116ZM206.38,130.555c-2.546-.377-5-.843-7.352-1.417.663-2.313,1.488-4.7,2.421-7.083.735,1.434,1.506,2.869,2.349,4.3S205.5,129.192,206.38,130.555ZM219.63,93.241a73.924,73.924,0,0,1,4.985,5.738c-1.614-.072-3.263-.126-4.931-.126-1.686,0-3.353.036-4.985.126A69.993,69.993,0,0,1,219.63,93.241ZM206.362,103.8c-.879,1.381-1.757,2.8-2.582,4.25-.825,1.434-1.6,2.869-2.331,4.3-.968-2.4-1.793-4.805-2.474-7.136C201.323,104.663,203.8,104.179,206.362,103.8Zm-16.227,22.449c-6.348-2.708-10.454-6.258-10.454-9.073s4.106-6.383,10.454-9.073c1.542-.663,3.228-1.255,4.967-1.811a86.122,86.122,0,0,0,4.034,10.92,84.9,84.9,0,0,0-3.981,10.866C193.38,127.525,191.694,126.915,190.134,126.252Zm9.647,25.623c-2.439-1.4-3.5-6.724-2.672-13.574.2-1.686.52-3.461.914-5.272a86.9,86.9,0,0,0,11.386,1.954,87.465,87.465,0,0,0,7.459,8.965c-5.845,5.433-11.332,8.41-15.062,8.41A4.279,4.279,0,0,1,199.781,151.875Zm42.532-13.663c.843,6.85-.2,12.175-2.618,13.592a3.99,3.99,0,0,1-2.062.466c-3.712,0-9.216-2.959-15.062-8.356a84.689,84.689,0,0,0,7.405-8.947,83.731,83.731,0,0,0,11.4-1.972A50.194,50.194,0,0,1,242.313,138.212Zm6.9-11.96c-1.542.663-3.228,1.255-4.967,1.811a86.12,86.12,0,0,0-4.034-10.92,84.9,84.9,0,0,0,3.981-10.866c1.775.556,3.461,1.165,5.039,1.829,6.348,2.708,10.454,6.258,10.454,9.073C259.67,119.994,255.564,123.562,249.216,126.252Z" fill="#61dafb"/>
|
||||||
|
<path id="Path_331" data-name="Path 331" d="M320.8,78.4Z" transform="translate(-119.082 -0.328)" fill="#61dafb"/>
|
||||||
|
<circle id="Ellipse_112" data-name="Ellipse 112" cx="8.194" cy="8.194" r="8.194" transform="translate(211.472 108.984)" fill="#61dafb"/>
|
||||||
|
<path id="Path_332" data-name="Path 332" d="M520.5,78.1Z" transform="translate(-282.975 -0.082)" fill="#61dafb"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 35 KiB |
1
docs/static/img/undraw_docusaurus_tree.svg
vendored
Normal file
After Width: | Height: | Size: 12 KiB |
7683
docs/yarn.lock
Normal file
73
pyproject.toml
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "unifi-cam-proxy"
|
||||||
|
description = "Enable non-Ubiquiti cameras to work with Unifi NVR"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
authors = [
|
||||||
|
{ name = "Keshav Varma", email = "keshavdv@gmail.com" },
|
||||||
|
]
|
||||||
|
license = { text = "MIT" }
|
||||||
|
dynamic = ["dependencies", "readme", "version"]
|
||||||
|
classifiers = [
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
]
|
||||||
|
keywords = [
|
||||||
|
"unifi",
|
||||||
|
"unifi-protect",
|
||||||
|
"nvr"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://github.com/keshavdv/unifi-cam-proxy"
|
||||||
|
"Bug Tracker" = "https://github.com/keshavdv/unifi-cam-proxy/issues"
|
||||||
|
"Source Code" = "https://github.com/keshavdv/unifi-cam-proxy"
|
||||||
|
"Documentation" = "https://unifi-cam-proxy.com/"
|
||||||
|
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"black",
|
||||||
|
"isort",
|
||||||
|
"flake8",
|
||||||
|
"flake8-bugbear",
|
||||||
|
"pre-commit",
|
||||||
|
"pyre-check",
|
||||||
|
"pytest",
|
||||||
|
"wheel"
|
||||||
|
]
|
||||||
|
all = ["unifi-cam-proxy[test]"]
|
||||||
|
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
unifi-cam-proxy = "unifi.main:main"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = { "unifi" = "unifi" }
|
||||||
|
|
||||||
|
|
||||||
|
[tool.setuptools.dynamic]
|
||||||
|
dependencies = {file = ["requirements.txt"]}
|
||||||
|
readme = { file = ["README.md"] }
|
||||||
|
version = { attr = "unifi.version.__version__" }
|
||||||
|
|
||||||
|
|
||||||
|
[tool.distutils.bdist_wheel]
|
||||||
|
universal = true
|
||||||
|
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
multi_line_output = 3
|
||||||
|
include_trailing_comma = true
|
||||||
|
force_grid_wrap = 0
|
||||||
|
use_parentheses = true
|
||||||
|
ensure_newline_before_comments = true
|
||||||
|
line_length = 80
|
13
requirements.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
aiohttp
|
||||||
|
amcrest
|
||||||
|
asyncio-mqtt
|
||||||
|
backoff
|
||||||
|
coloredlogs
|
||||||
|
flvlib3@https://github.com/zkonge/flvlib3/archive/master.zip
|
||||||
|
hikvisionapi>=0.3.2
|
||||||
|
semver
|
||||||
|
packaging
|
||||||
|
pyunifiprotect
|
||||||
|
reolinkapi
|
||||||
|
websockets>=9.0.1
|
||||||
|
xmltodict
|
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""TODO: Document your tests."""
|
0
unifi/__init__.py
Normal file
15
unifi/cams/__init__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from unifi.cams.dahua import DahuaCam
|
||||||
|
from unifi.cams.frigate import FrigateCam
|
||||||
|
from unifi.cams.hikvision import HikvisionCam
|
||||||
|
from unifi.cams.reolink import Reolink
|
||||||
|
from unifi.cams.reolink_nvr import ReolinkNVRCam
|
||||||
|
from unifi.cams.rtsp import RTSPCam
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FrigateCam",
|
||||||
|
"HikvisionCam",
|
||||||
|
"DahuaCam",
|
||||||
|
"RTSPCam",
|
||||||
|
"Reolink",
|
||||||
|
"ReolinkNVRCam",
|
||||||
|
]
|
963
unifi/cams/base.py
Normal file
@ -0,0 +1,963 @@
|
|||||||
|
import argparse
|
||||||
|
import atexit
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib
|
||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import packaging
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from unifi.core import RetryableError
|
||||||
|
|
||||||
|
AVClientRequest = AVClientResponse = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class SmartDetectObjectType(Enum):
|
||||||
|
PERSON = "person"
|
||||||
|
VEHICLE = "vehicle"
|
||||||
|
|
||||||
|
|
||||||
|
class UnifiCamBase(metaclass=ABCMeta):
|
||||||
|
def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
|
||||||
|
self.args = args
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
self._msg_id: int = 0
|
||||||
|
self._init_time: float = time.time()
|
||||||
|
self._streams: dict[str, str] = {}
|
||||||
|
self._motion_snapshot: Optional[Path] = None
|
||||||
|
self._motion_event_id: int = 0
|
||||||
|
self._motion_event_ts: Optional[float] = None
|
||||||
|
self._motion_object_type: Optional[SmartDetectObjectType] = None
|
||||||
|
self._ffmpeg_handles: dict[str, subprocess.Popen] = {}
|
||||||
|
|
||||||
|
# Set up ssl context for requests
|
||||||
|
self._ssl_context = ssl.create_default_context()
|
||||||
|
self._ssl_context.check_hostname = False
|
||||||
|
self._ssl_context.verify_mode = ssl.CERT_NONE
|
||||||
|
self._ssl_context.load_cert_chain(args.cert, args.cert)
|
||||||
|
self._session: Optional[websockets.legacy.client.WebSocketClientProtocol] = None
|
||||||
|
atexit.register(self.close_streams)
|
||||||
|
|
||||||
|
self._needs_flv_timestamps: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--ffmpeg-args",
|
||||||
|
"-f",
|
||||||
|
default="-c:v copy -ar 32000 -ac 1 -codec:a aac -b:a 32k",
|
||||||
|
help="Transcoding args for `ffmpeg -i <src> <args> <dst>`",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rtsp-transport",
|
||||||
|
default="tcp",
|
||||||
|
choices=["tcp", "udp", "http", "udp_multicast"],
|
||||||
|
help="RTSP transport protocol used by stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run(self, ws) -> None:
|
||||||
|
self._session = ws
|
||||||
|
await self.init_adoption()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await ws.recv()
|
||||||
|
except websockets.exceptions.ConnectionClosedError:
|
||||||
|
self.logger.info(f"Connection to {self.args.host} was closed.")
|
||||||
|
raise RetryableError()
|
||||||
|
|
||||||
|
if msg is not None:
|
||||||
|
force_reconnect = await self.process(msg)
|
||||||
|
if force_reconnect:
|
||||||
|
self.logger.info("Reconnecting...")
|
||||||
|
raise RetryableError()
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def get_video_settings(self) -> dict[str, Any]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def change_video_settings(self, options) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_snapshot(self) -> Path:
|
||||||
|
raise NotImplementedError("You need to write this!")
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_stream_source(self, stream_index: str) -> str:
|
||||||
|
raise NotImplementedError("You need to write this!")
|
||||||
|
|
||||||
|
def get_extra_ffmpeg_args(self, stream_index: str = "") -> str:
|
||||||
|
return self.args.ffmpeg_args
|
||||||
|
|
||||||
|
async def get_feature_flags(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mic": True,
|
||||||
|
"aec": [],
|
||||||
|
"videoMode": ["default"],
|
||||||
|
"motionDetect": ["enhanced"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# API for subclasses
|
||||||
|
async def trigger_motion_start(
|
||||||
|
self, object_type: Optional[SmartDetectObjectType] = None
|
||||||
|
) -> None:
|
||||||
|
if not self._motion_event_ts:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"clockBestMonotonic": 0,
|
||||||
|
"clockBestWall": 0,
|
||||||
|
"clockMonotonic": int(self.get_uptime()),
|
||||||
|
"clockStream": int(self.get_uptime()),
|
||||||
|
"clockStreamRate": 1000,
|
||||||
|
"clockWall": int(round(time.time() * 1000)),
|
||||||
|
"edgeType": "start",
|
||||||
|
"eventId": self._motion_event_id,
|
||||||
|
"eventType": "motion",
|
||||||
|
"levels": {"0": 47},
|
||||||
|
"motionHeatmap": "",
|
||||||
|
"motionSnapshot": "",
|
||||||
|
}
|
||||||
|
if object_type:
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"objectTypes": [object_type.value],
|
||||||
|
"edgeType": "enter",
|
||||||
|
"zonesStatus": {"0": 48},
|
||||||
|
"smartDetectSnapshot": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Triggering motion start (idx: {self._motion_event_id})"
|
||||||
|
+ f" for {object_type.value}"
|
||||||
|
if object_type
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
await self.send(
|
||||||
|
self.gen_response(
|
||||||
|
"EventSmartDetect" if object_type else "EventAnalytics",
|
||||||
|
payload=payload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._motion_event_ts = time.time()
|
||||||
|
self._motion_object_type = object_type
|
||||||
|
|
||||||
|
# Capture snapshot at beginning of motion event for thumbnail
|
||||||
|
motion_snapshot_path: str = tempfile.NamedTemporaryFile(delete=False).name
|
||||||
|
try:
|
||||||
|
shutil.copyfile(await self.get_snapshot(), motion_snapshot_path)
|
||||||
|
self.logger.debug(f"Captured motion snapshot to {motion_snapshot_path}")
|
||||||
|
self._motion_snapshot = Path(motion_snapshot_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def trigger_motion_stop(self) -> None:
|
||||||
|
motion_start_ts = self._motion_event_ts
|
||||||
|
motion_object_type = self._motion_object_type
|
||||||
|
if motion_start_ts:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"clockBestMonotonic": int(self.get_uptime()),
|
||||||
|
"clockBestWall": int(round(motion_start_ts * 1000)),
|
||||||
|
"clockMonotonic": int(self.get_uptime()),
|
||||||
|
"clockStream": int(self.get_uptime()),
|
||||||
|
"clockStreamRate": 1000,
|
||||||
|
"clockWall": int(round(time.time() * 1000)),
|
||||||
|
"edgeType": "stop",
|
||||||
|
"eventId": self._motion_event_id,
|
||||||
|
"eventType": "motion",
|
||||||
|
"levels": {"0": 49},
|
||||||
|
"motionHeatmap": "heatmap.png",
|
||||||
|
"motionSnapshot": "motionsnap.jpg",
|
||||||
|
}
|
||||||
|
if motion_object_type:
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"objectTypes": [motion_object_type.value],
|
||||||
|
"edgeType": "leave",
|
||||||
|
"zonesStatus": {"0": 48},
|
||||||
|
"smartDetectSnapshot": "motionsnap.jpg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
f"Triggering motion stop (idx: {self._motion_event_id})"
|
||||||
|
+ f" for {motion_object_type.value}"
|
||||||
|
if motion_object_type
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
await self.send(
|
||||||
|
self.gen_response(
|
||||||
|
"EventSmartDetect" if motion_object_type else "EventAnalytics",
|
||||||
|
payload=payload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._motion_event_id += 1
|
||||||
|
self._motion_event_ts = None
|
||||||
|
self._motion_object_type = None
|
||||||
|
|
||||||
|
def update_motion_snapshot(self, path: Path) -> None:
|
||||||
|
self._motion_snapshot = path
|
||||||
|
|
||||||
|
async def fetch_to_file(self, url: str, dst: Path) -> bool:
|
||||||
|
try:
|
||||||
|
async with aiohttp.request("GET", url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
self.logger.error(f"Error retrieving file {resp.status}")
|
||||||
|
return False
|
||||||
|
with dst.open("wb") as f:
|
||||||
|
f.write(await resp.read())
|
||||||
|
return True
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Protocol implementation
|
||||||
|
def gen_msg_id(self) -> int:
|
||||||
|
self._msg_id += 1
|
||||||
|
return self._msg_id
|
||||||
|
|
||||||
|
async def init_adoption(self) -> None:
|
||||||
|
self.logger.info(
|
||||||
|
f"Adopting with token [{self.args.token}] and mac [{self.args.mac}]"
|
||||||
|
)
|
||||||
|
await self.send(
|
||||||
|
self.gen_response(
|
||||||
|
"ubnt_avclient_hello",
|
||||||
|
payload={
|
||||||
|
"adoptionCode": self.args.token,
|
||||||
|
"connectionHost": self.args.host,
|
||||||
|
"connectionSecurePort": 7442,
|
||||||
|
"fwVersion": self.args.fw_version,
|
||||||
|
"hwrev": 19,
|
||||||
|
"idleTime": 191.96,
|
||||||
|
"ip": self.args.ip,
|
||||||
|
"mac": self.args.mac,
|
||||||
|
"model": self.args.model,
|
||||||
|
"name": self.args.name,
|
||||||
|
"protocolVersion": 67,
|
||||||
|
"rebootTimeoutSec": 30,
|
||||||
|
"semver": "v4.4.8",
|
||||||
|
"totalLoad": 0.5474,
|
||||||
|
"upgradeTimeoutSec": 150,
|
||||||
|
"uptime": int(self.get_uptime()),
|
||||||
|
"features": await self.get_feature_flags(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_hello(self, msg: AVClientRequest) -> None:
|
||||||
|
controller_version = packaging.version.parse(
|
||||||
|
msg["payload"].get("controllerVersion")
|
||||||
|
)
|
||||||
|
self._needs_flv_timestamps = controller_version >= packaging.version.parse(
|
||||||
|
"1.21.4"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_param_agreement(self, msg: AVClientRequest) -> AVClientResponse:
|
||||||
|
return self.gen_response(
|
||||||
|
"ubnt_avclient_paramAgreement",
|
||||||
|
msg["messageId"],
|
||||||
|
{
|
||||||
|
"authToken": self.args.token,
|
||||||
|
"features": await self.get_feature_flags(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_upgrade(self, msg: AVClientRequest) -> None:
|
||||||
|
url = msg["payload"]["uri"]
|
||||||
|
headers = {"Range": "bytes=0-100"}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers=headers, ssl=False) as r:
|
||||||
|
# Parse the new version string from the upgrade binary
|
||||||
|
content = await r.content.readexactly(54)
|
||||||
|
version = ""
|
||||||
|
for i in range(0, 50):
|
||||||
|
b = content[4 + i]
|
||||||
|
if b != b"\x00":
|
||||||
|
version += chr(b)
|
||||||
|
self.logger.debug(f"Pretending to upgrade to: {version}")
|
||||||
|
self.args.fw_version = version
|
||||||
|
|
||||||
|
async def process_isp_settings(self, msg: AVClientRequest) -> AVClientResponse:
|
||||||
|
payload = {
|
||||||
|
"aeMode": "auto",
|
||||||
|
"aeTargetPercent": 50,
|
||||||
|
"aggressiveAntiFlicker": 0,
|
||||||
|
"brightness": 50,
|
||||||
|
"contrast": 50,
|
||||||
|
"criticalTmpOfProtect": 40,
|
||||||
|
"darkAreaCompensateLevel": 0,
|
||||||
|
"denoise": 50,
|
||||||
|
"enable3dnr": 1,
|
||||||
|
"enableMicroTmpProtect": 1,
|
||||||
|
"enablePauseMotion": 0,
|
||||||
|
"flip": 0,
|
||||||
|
"focusMode": "ztrig",
|
||||||
|
"focusPosition": 0,
|
||||||
|
"forceFilterIrSwitchEvents": 0,
|
||||||
|
"hue": 50,
|
||||||
|
"icrLightSensorNightThd": 0,
|
||||||
|
"icrSensitivity": 0,
|
||||||
|
"irLedLevel": 215,
|
||||||
|
"irLedMode": "auto",
|
||||||
|
"irOnStsBrightness": 0,
|
||||||
|
"irOnStsContrast": 0,
|
||||||
|
"irOnStsDenoise": 0,
|
||||||
|
"irOnStsHue": 0,
|
||||||
|
"irOnStsSaturation": 0,
|
||||||
|
"irOnStsSharpness": 0,
|
||||||
|
"irOnStsWdr": 0,
|
||||||
|
"irOnValBrightness": 50,
|
||||||
|
"irOnValContrast": 50,
|
||||||
|
"irOnValDenoise": 50,
|
||||||
|
"irOnValHue": 50,
|
||||||
|
"irOnValSaturation": 50,
|
||||||
|
"irOnValSharpness": 50,
|
||||||
|
"irOnValWdr": 1,
|
||||||
|
"mirror": 0,
|
||||||
|
"queryIrLedStatus": 0,
|
||||||
|
"saturation": 50,
|
||||||
|
"sharpness": 50,
|
||||||
|
"touchFocusX": 1001,
|
||||||
|
"touchFocusY": 1001,
|
||||||
|
"wdr": 1,
|
||||||
|
"zoomPosition": 0,
|
||||||
|
}
|
||||||
|
payload.update(await self.get_video_settings())
|
||||||
|
return self.gen_response(
|
||||||
|
"ResetIspSettings",
|
||||||
|
msg["messageId"],
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_video_settings(self, msg: AVClientRequest) -> AVClientResponse:
|
||||||
|
vid_dst = {
|
||||||
|
"video1": ["file:///dev/null"],
|
||||||
|
"video2": ["file:///dev/null"],
|
||||||
|
"video3": ["file:///dev/null"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg["payload"] is not None and "video" in msg["payload"]:
|
||||||
|
for k, v in msg["payload"]["video"].items():
|
||||||
|
if v:
|
||||||
|
if "avSerializer" in v:
|
||||||
|
vid_dst[k] = v["avSerializer"]["destinations"]
|
||||||
|
if "/dev/null" in vid_dst[k]:
|
||||||
|
self.stop_video_stream(k)
|
||||||
|
elif "parameters" in v["avSerializer"]:
|
||||||
|
self._streams[k] = stream = v["avSerializer"]["parameters"][
|
||||||
|
"streamName"
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
host, port = urllib.parse.urlparse(
|
||||||
|
v["avSerializer"]["destinations"][0]
|
||||||
|
).netloc.split(":")
|
||||||
|
await self.start_video_stream(
|
||||||
|
k, stream, destination=(host, int(port))
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self.gen_response(
|
||||||
|
"ChangeVideoSettings",
|
||||||
|
msg["messageId"],
|
||||||
|
{
|
||||||
|
"audio": {
|
||||||
|
"bitRate": 32000,
|
||||||
|
"channels": 1,
|
||||||
|
"description": "audio track",
|
||||||
|
"enableTemporalNoiseShaping": False,
|
||||||
|
"enabled": True,
|
||||||
|
"mode": 0,
|
||||||
|
"quality": 0,
|
||||||
|
"sampleRate": 11025,
|
||||||
|
"type": "aac",
|
||||||
|
"volume": 0,
|
||||||
|
},
|
||||||
|
"firmwarePath": "/lib/firmware/",
|
||||||
|
"video": {
|
||||||
|
"enableHrd": False,
|
||||||
|
"hdrMode": 0,
|
||||||
|
"lowDelay": False,
|
||||||
|
"videoMode": "default",
|
||||||
|
"mjpg": {
|
||||||
|
"avSerializer": {
|
||||||
|
"destinations": [
|
||||||
|
"file:///tmp/snap.jpeg",
|
||||||
|
"file:///tmp/snap_av.jpg",
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"audioId": 1000,
|
||||||
|
"enableTimestampsOverlapAvoidance": False,
|
||||||
|
"suppressAudio": True,
|
||||||
|
"suppressVideo": False,
|
||||||
|
"videoId": 1001,
|
||||||
|
},
|
||||||
|
"type": "mjpg",
|
||||||
|
},
|
||||||
|
"bitRateCbrAvg": 500000,
|
||||||
|
"bitRateVbrMax": 500000,
|
||||||
|
"bitRateVbrMin": None,
|
||||||
|
"description": "JPEG pictures",
|
||||||
|
"enabled": True,
|
||||||
|
"fps": 5,
|
||||||
|
"height": 720,
|
||||||
|
"isCbr": False,
|
||||||
|
"maxFps": 5,
|
||||||
|
"minClientAdaptiveBitRate": 0,
|
||||||
|
"minMotionAdaptiveBitRate": 0,
|
||||||
|
"nMultiplier": None,
|
||||||
|
"name": "mjpg",
|
||||||
|
"quality": 80,
|
||||||
|
"sourceId": 3,
|
||||||
|
"streamId": 8,
|
||||||
|
"streamOrdinal": 3,
|
||||||
|
"type": "mjpg",
|
||||||
|
"validBitrateRangeMax": 6000000,
|
||||||
|
"validBitrateRangeMin": 32000,
|
||||||
|
"width": 1280,
|
||||||
|
},
|
||||||
|
"video1": {
|
||||||
|
"M": 1,
|
||||||
|
"N": 30,
|
||||||
|
"avSerializer": {
|
||||||
|
"destinations": vid_dst["video1"],
|
||||||
|
"parameters": None
|
||||||
|
if "video1" not in self._streams
|
||||||
|
else {
|
||||||
|
"audioId": None,
|
||||||
|
"streamName": self._streams["video1"],
|
||||||
|
"suppressAudio": None,
|
||||||
|
"suppressVideo": None,
|
||||||
|
"videoId": None,
|
||||||
|
},
|
||||||
|
"type": "extendedFlv",
|
||||||
|
},
|
||||||
|
"bitRateCbrAvg": 1400000,
|
||||||
|
"bitRateVbrMax": 2800000,
|
||||||
|
"bitRateVbrMin": 48000,
|
||||||
|
"description": "Hi quality video track",
|
||||||
|
"enabled": True,
|
||||||
|
"fps": 15,
|
||||||
|
"gopModel": 0,
|
||||||
|
"height": 1080,
|
||||||
|
"horizontalFlip": False,
|
||||||
|
"isCbr": False,
|
||||||
|
"maxFps": 30,
|
||||||
|
"minClientAdaptiveBitRate": 0,
|
||||||
|
"minMotionAdaptiveBitRate": 0,
|
||||||
|
"nMultiplier": 6,
|
||||||
|
"name": "video1",
|
||||||
|
"sourceId": 0,
|
||||||
|
"streamId": 1,
|
||||||
|
"streamOrdinal": 0,
|
||||||
|
"type": "h264",
|
||||||
|
"validBitrateRangeMax": 2800000,
|
||||||
|
"validBitrateRangeMin": 32000,
|
||||||
|
"validFpsValues": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
25,
|
||||||
|
30,
|
||||||
|
],
|
||||||
|
"verticalFlip": False,
|
||||||
|
"width": 1920,
|
||||||
|
},
|
||||||
|
"video2": {
|
||||||
|
"M": 1,
|
||||||
|
"N": 30,
|
||||||
|
"avSerializer": {
|
||||||
|
"destinations": vid_dst["video2"],
|
||||||
|
"parameters": None
|
||||||
|
if "video2" not in self._streams
|
||||||
|
else {
|
||||||
|
"audioId": None,
|
||||||
|
"streamName": self._streams["video2"],
|
||||||
|
"suppressAudio": None,
|
||||||
|
"suppressVideo": None,
|
||||||
|
"videoId": None,
|
||||||
|
},
|
||||||
|
"type": "extendedFlv",
|
||||||
|
},
|
||||||
|
"bitRateCbrAvg": 500000,
|
||||||
|
"bitRateVbrMax": 1200000,
|
||||||
|
"bitRateVbrMin": 48000,
|
||||||
|
"currentVbrBitrate": 1200000,
|
||||||
|
"description": "Medium quality video track",
|
||||||
|
"enabled": True,
|
||||||
|
"fps": 15,
|
||||||
|
"gopModel": 0,
|
||||||
|
"height": 720,
|
||||||
|
"horizontalFlip": False,
|
||||||
|
"isCbr": False,
|
||||||
|
"maxFps": 30,
|
||||||
|
"minClientAdaptiveBitRate": 0,
|
||||||
|
"minMotionAdaptiveBitRate": 0,
|
||||||
|
"nMultiplier": 6,
|
||||||
|
"name": "video2",
|
||||||
|
"sourceId": 1,
|
||||||
|
"streamId": 2,
|
||||||
|
"streamOrdinal": 1,
|
||||||
|
"type": "h264",
|
||||||
|
"validBitrateRangeMax": 1500000,
|
||||||
|
"validBitrateRangeMin": 32000,
|
||||||
|
"validFpsValues": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
25,
|
||||||
|
30,
|
||||||
|
],
|
||||||
|
"verticalFlip": False,
|
||||||
|
"width": 1280,
|
||||||
|
},
|
||||||
|
"video3": {
|
||||||
|
"M": 1,
|
||||||
|
"N": 30,
|
||||||
|
"avSerializer": {
|
||||||
|
"destinations": vid_dst["video3"],
|
||||||
|
"parameters": None
|
||||||
|
if "video3" not in self._streams
|
||||||
|
else {
|
||||||
|
"audioId": None,
|
||||||
|
"streamName": self._streams["video3"],
|
||||||
|
"suppressAudio": None,
|
||||||
|
"suppressVideo": None,
|
||||||
|
"videoId": None,
|
||||||
|
},
|
||||||
|
"type": "extendedFlv",
|
||||||
|
},
|
||||||
|
"bitRateCbrAvg": 300000,
|
||||||
|
"bitRateVbrMax": 200000,
|
||||||
|
"bitRateVbrMin": 48000,
|
||||||
|
"currentVbrBitrate": 200000,
|
||||||
|
"description": "Low quality video track",
|
||||||
|
"enabled": True,
|
||||||
|
"fps": 15,
|
||||||
|
"gopModel": 0,
|
||||||
|
"height": 360,
|
||||||
|
"horizontalFlip": False,
|
||||||
|
"isCbr": False,
|
||||||
|
"maxFps": 30,
|
||||||
|
"minClientAdaptiveBitRate": 0,
|
||||||
|
"minMotionAdaptiveBitRate": 0,
|
||||||
|
"nMultiplier": 6,
|
||||||
|
"name": "video3",
|
||||||
|
"sourceId": 2,
|
||||||
|
"streamId": 4,
|
||||||
|
"streamOrdinal": 2,
|
||||||
|
"type": "h264",
|
||||||
|
"validBitrateRangeMax": 750000,
|
||||||
|
"validBitrateRangeMin": 32000,
|
||||||
|
"validFpsValues": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
25,
|
||||||
|
30,
|
||||||
|
],
|
||||||
|
"verticalFlip": False,
|
||||||
|
"width": 640,
|
||||||
|
},
|
||||||
|
"vinFps": 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_device_settings(self, msg: AVClientRequest) -> AVClientResponse:
|
||||||
|
return self.gen_response(
|
||||||
|
"ChangeDeviceSettings",
|
||||||
|
msg["messageId"],
|
||||||
|
{
|
||||||
|
"name": self.args.name,
|
||||||
|
"timezone": "PST8PDT,M3.2.0,M11.1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_osd_settings(self, msg: AVClientRequest) -> AVClientResponse:
|
||||||
|
return self.gen_response(
|
||||||
|
"ChangeOsdSettings",
|
||||||
|
msg["messageId"],
|
||||||
|
{
|
||||||
|
"_1": {
|
||||||
|
"enableDate": 1,
|
||||||
|
"enableLogo": 1,
|
||||||
|
"enableReportdStatsLevel": 0,
|
||||||
|
"enableStreamerStatsLevel": 0,
|
||||||
|
"tag": self.args.name,
|
||||||
|
},
|
||||||
|
"_2": {
|
||||||
|
"enableDate": 1,
|
||||||
|
"enableLogo": 1,
|
||||||
|
"enableReportdStatsLevel": 0,
|
||||||
|
"enableStreamerStatsLevel": 0,
|
||||||
|
"tag": self.args.name,
|
||||||
|
},
|
||||||
|
"_3": {
|
||||||
|
"enableDate": 1,
|
||||||
|
"enableLogo": 1,
|
||||||
|
"enableReportdStatsLevel": 0,
|
||||||
|
"enableStreamerStatsLevel": 0,
|
||||||
|
"tag": self.args.name,
|
||||||
|
},
|
||||||
|
"_4": {
|
||||||
|
"enableDate": 1,
|
||||||
|
"enableLogo": 1,
|
||||||
|
"enableReportdStatsLevel": 0,
|
||||||
|
"enableStreamerStatsLevel": 0,
|
||||||
|
"tag": self.args.name,
|
||||||
|
},
|
||||||
|
"enableOverlay": 1,
|
||||||
|
"logoScale": 50,
|
||||||
|
"overlayColorId": 0,
|
||||||
|
"textScale": 50,
|
||||||
|
"useCustomLogo": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_network_status(self, msg: AVClientRequest) -> AVClientResponse:
|
||||||
|
return self.gen_response(
|
||||||
|
"NetworkStatus",
|
||||||
|
msg["messageId"],
|
||||||
|
{
|
||||||
|
"connectionState": 2,
|
||||||
|
"connectionStateDescription": "CONNECTED",
|
||||||
|
"defaultInterface": "eth0",
|
||||||
|
"dhcpLeasetime": 86400,
|
||||||
|
"dnsServer": "8.8.8.8 4.2.2.2",
|
||||||
|
"gateway": "192.168.103.1",
|
||||||
|
"ipAddress": self.args.ip,
|
||||||
|
"linkDuplex": 1,
|
||||||
|
"linkSpeedMbps": 100,
|
||||||
|
"mode": "dhcp",
|
||||||
|
"networkMask": "255.255.255.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_sound_led_settings(
|
||||||
|
self, msg: AVClientRequest
|
||||||
|
) -> AVClientResponse:
|
||||||
|
return self.gen_response(
|
||||||
|
"ChangeSoundLedSettings",
|
||||||
|
msg["messageId"],
|
||||||
|
{
|
||||||
|
"ledFaceAlwaysOnWhenManaged": 1,
|
||||||
|
"ledFaceEnabled": 1,
|
||||||
|
"speakerEnabled": 1,
|
||||||
|
"speakerVolume": 100,
|
||||||
|
"systemSoundsEnabled": 1,
|
||||||
|
"userLedBlinkPeriodMs": 0,
|
||||||
|
"userLedColorFg": "blue",
|
||||||
|
"userLedOnNoff": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_change_isp_settings(
|
||||||
|
self, msg: AVClientRequest
|
||||||
|
) -> AVClientResponse:
|
||||||
|
payload = {
|
||||||
|
"aeMode": "auto",
|
||||||
|
"aeTargetPercent": 50,
|
||||||
|
"aggressiveAntiFlicker": 0,
|
||||||
|
"brightness": 50,
|
||||||
|
"contrast": 50,
|
||||||
|
"criticalTmpOfProtect": 40,
|
||||||
|
"dZoomCenterX": 50,
|
||||||
|
"dZoomCenterY": 50,
|
||||||
|
"dZoomScale": 0,
|
||||||
|
"dZoomStreamId": 4,
|
||||||
|
"darkAreaCompensateLevel": 0,
|
||||||
|
"denoise": 50,
|
||||||
|
"enable3dnr": 1,
|
||||||
|
"enableExternalIr": 0,
|
||||||
|
"enableMicroTmpProtect": 1,
|
||||||
|
"enablePauseMotion": 0,
|
||||||
|
"flip": 0,
|
||||||
|
"focusMode": "ztrig",
|
||||||
|
"focusPosition": 0,
|
||||||
|
"forceFilterIrSwitchEvents": 0,
|
||||||
|
"hue": 50,
|
||||||
|
"icrLightSensorNightThd": 0,
|
||||||
|
"icrSensitivity": 0,
|
||||||
|
"irLedLevel": 215,
|
||||||
|
"irLedMode": "auto",
|
||||||
|
"irOnStsBrightness": 0,
|
||||||
|
"irOnStsContrast": 0,
|
||||||
|
"irOnStsDenoise": 0,
|
||||||
|
"irOnStsHue": 0,
|
||||||
|
"irOnStsSaturation": 0,
|
||||||
|
"irOnStsSharpness": 0,
|
||||||
|
"irOnStsWdr": 0,
|
||||||
|
"irOnValBrightness": 50,
|
||||||
|
"irOnValContrast": 50,
|
||||||
|
"irOnValDenoise": 50,
|
||||||
|
"irOnValHue": 50,
|
||||||
|
"irOnValSaturation": 50,
|
||||||
|
"irOnValSharpness": 50,
|
||||||
|
"irOnValWdr": 1,
|
||||||
|
"lensDistortionCorrection": 1,
|
||||||
|
"masks": None,
|
||||||
|
"mirror": 0,
|
||||||
|
"queryIrLedStatus": 0,
|
||||||
|
"saturation": 50,
|
||||||
|
"sharpness": 50,
|
||||||
|
"touchFocusX": 1001,
|
||||||
|
"touchFocusY": 1001,
|
||||||
|
"wdr": 1,
|
||||||
|
"zoomPosition": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg["payload"]:
|
||||||
|
await self.change_video_settings(msg["payload"])
|
||||||
|
|
||||||
|
payload.update(await self.get_video_settings())
|
||||||
|
return self.gen_response("ChangeIspSettings", msg["messageId"], payload)
|
||||||
|
|
||||||
|
async def process_analytics_settings(
|
||||||
|
self, msg: AVClientRequest
|
||||||
|
) -> AVClientResponse:
|
||||||
|
return self.gen_response(
|
||||||
|
"ChangeAnalyticsSettings", msg["messageId"], msg["payload"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_snapshot_request(
|
||||||
|
self, msg: AVClientRequest
|
||||||
|
) -> Optional[AVClientResponse]:
|
||||||
|
snapshot_type = msg["payload"]["what"]
|
||||||
|
if snapshot_type in ["motionSnapshot", "smartDetectZoneSnapshot"]:
|
||||||
|
path = self._motion_snapshot
|
||||||
|
else:
|
||||||
|
path = await self.get_snapshot()
|
||||||
|
|
||||||
|
if path and path.exists():
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
files = {"payload": open(path, "rb")}
|
||||||
|
files.update(msg["payload"].get("formFields", {}))
|
||||||
|
try:
|
||||||
|
await session.post(
|
||||||
|
msg["payload"]["uri"],
|
||||||
|
data=files,
|
||||||
|
ssl=self._ssl_context,
|
||||||
|
)
|
||||||
|
self.logger.debug(f"Uploaded {snapshot_type} from {path}")
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
self.logger.exception("Failed to upload snapshot")
|
||||||
|
else:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Snapshot file {path} is not ready yet, skipping upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if msg["responseExpected"]:
|
||||||
|
return self.gen_response("GetRequest", response_to=msg["messageId"])
|
||||||
|
|
||||||
|
async def process_time(self, msg: AVClientRequest) -> AVClientResponse:
|
||||||
|
return self.gen_response(
|
||||||
|
"ubnt_avclient_paramAgreement",
|
||||||
|
msg["messageId"],
|
||||||
|
{
|
||||||
|
"monotonicMs": self.get_uptime(),
|
||||||
|
"wallMs": int(round(time.time() * 1000)),
|
||||||
|
"features": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def gen_response(
|
||||||
|
self, name: str, response_to: int = 0, payload: Optional[dict[str, Any]] = None
|
||||||
|
) -> AVClientResponse:
|
||||||
|
if not payload:
|
||||||
|
payload = {}
|
||||||
|
return {
|
||||||
|
"from": "ubnt_avclient",
|
||||||
|
"functionName": name,
|
||||||
|
"inResponseTo": response_to,
|
||||||
|
"messageId": self.gen_msg_id(),
|
||||||
|
"payload": payload,
|
||||||
|
"responseExpected": False,
|
||||||
|
"to": "UniFiVideo",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_uptime(self) -> float:
|
||||||
|
return time.time() - self._init_time
|
||||||
|
|
||||||
|
async def send(self, msg: AVClientRequest) -> None:
|
||||||
|
self.logger.debug(f"Sending: {msg}")
|
||||||
|
ws = self._session
|
||||||
|
if ws:
|
||||||
|
await ws.send(json.dumps(msg).encode())
|
||||||
|
|
||||||
|
async def process(self, msg: bytes) -> bool:
|
||||||
|
m = json.loads(msg)
|
||||||
|
fn = m["functionName"]
|
||||||
|
|
||||||
|
self.logger.info(f"Processing [{fn}] message")
|
||||||
|
self.logger.debug(f"Message contents: {m}")
|
||||||
|
|
||||||
|
if (("responseExpected" not in m) or (m["responseExpected"] is False)) and (
|
||||||
|
fn
|
||||||
|
not in [
|
||||||
|
"GetRequest",
|
||||||
|
"ChangeVideoSettings",
|
||||||
|
"UpdateFirmwareRequest",
|
||||||
|
"Reboot",
|
||||||
|
"ubnt_avclient_hello",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
res: Optional[AVClientResponse] = None
|
||||||
|
|
||||||
|
if fn == "ubnt_avclient_time":
|
||||||
|
res = await self.process_time(m)
|
||||||
|
elif fn == "ubnt_avclient_hello":
|
||||||
|
await self.process_hello(m)
|
||||||
|
elif fn == "ubnt_avclient_paramAgreement":
|
||||||
|
res = await self.process_param_agreement(m)
|
||||||
|
elif fn == "ResetIspSettings":
|
||||||
|
res = await self.process_isp_settings(m)
|
||||||
|
elif fn == "ChangeVideoSettings":
|
||||||
|
res = await self.process_video_settings(m)
|
||||||
|
elif fn == "ChangeDeviceSettings":
|
||||||
|
res = await self.process_device_settings(m)
|
||||||
|
elif fn == "ChangeOsdSettings":
|
||||||
|
res = await self.process_osd_settings(m)
|
||||||
|
elif fn == "NetworkStatus":
|
||||||
|
res = await self.process_network_status(m)
|
||||||
|
elif fn == "AnalyticsTest":
|
||||||
|
res = self.gen_response("AnalyticsTest", response_to=m["messageId"])
|
||||||
|
elif fn == "ChangeSoundLedSettings":
|
||||||
|
res = await self.process_sound_led_settings(m)
|
||||||
|
elif fn == "ChangeIspSettings":
|
||||||
|
res = await self.process_change_isp_settings(m)
|
||||||
|
elif fn == "ChangeAnalyticsSettings":
|
||||||
|
res = await self.process_analytics_settings(m)
|
||||||
|
elif fn == "GetRequest":
|
||||||
|
res = await self.process_snapshot_request(m)
|
||||||
|
elif fn == "UpdateUsernamePassword":
|
||||||
|
res = self.gen_response(
|
||||||
|
"UpdateUsernamePassword", response_to=m["messageId"]
|
||||||
|
)
|
||||||
|
elif fn == "ChangeSmartDetectSettings":
|
||||||
|
res = self.gen_response(
|
||||||
|
"ChangeSmartDetectSettings", response_to=m["messageId"]
|
||||||
|
)
|
||||||
|
elif fn == "UpdateFirmwareRequest":
|
||||||
|
await self.process_upgrade(m)
|
||||||
|
return True
|
||||||
|
elif fn == "Reboot":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if res is not None:
|
||||||
|
await self.send(res)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_base_ffmpeg_args(self, stream_index: str = "") -> str:
|
||||||
|
base_args = [
|
||||||
|
"-avoid_negative_ts",
|
||||||
|
"make_zero",
|
||||||
|
"-fflags",
|
||||||
|
"+genpts+discardcorrupt",
|
||||||
|
"-use_wallclock_as_timestamps 1",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(["ffmpeg", "-h", "full"])
|
||||||
|
if b"stimeout" in output:
|
||||||
|
base_args.append("-stimeout 15000000")
|
||||||
|
else:
|
||||||
|
base_args.append("-timeout 15000000")
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
self.logger.exception("Could not check for ffmpeg options")
|
||||||
|
|
||||||
|
return " ".join(base_args)
|
||||||
|
|
||||||
|
async def start_video_stream(
|
||||||
|
self, stream_index: str, stream_name: str, destination: tuple[str, int]
|
||||||
|
):
|
||||||
|
has_spawned = stream_index in self._ffmpeg_handles
|
||||||
|
is_dead = has_spawned and self._ffmpeg_handles[stream_index].poll() is not None
|
||||||
|
|
||||||
|
if not has_spawned or is_dead:
|
||||||
|
source = await self.get_stream_source(stream_index)
|
||||||
|
cmd = (
|
||||||
|
"ffmpeg -nostdin -loglevel error -y"
|
||||||
|
f" {self.get_base_ffmpeg_args(stream_index)} -rtsp_transport"
|
||||||
|
f' {self.args.rtsp_transport} -i "{source}"'
|
||||||
|
f" {self.get_extra_ffmpeg_args(stream_index)} -metadata"
|
||||||
|
f" streamName={stream_name} -f flv - | {sys.executable} -m"
|
||||||
|
" unifi.clock_sync"
|
||||||
|
f" {'--write-timestamps' if self._needs_flv_timestamps else ''} | nc"
|
||||||
|
f" {destination[0]} {destination[1]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_dead:
|
||||||
|
self.logger.warn(f"Previous ffmpeg process for {stream_index} died.")
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Spawning ffmpeg for {stream_index} ({stream_name}): {cmd}"
|
||||||
|
)
|
||||||
|
self._ffmpeg_handles[stream_index] = subprocess.Popen(
|
||||||
|
cmd, stdout=subprocess.DEVNULL, shell=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop_video_stream(self, stream_index: str):
|
||||||
|
if stream_index in self._ffmpeg_handles:
|
||||||
|
self.logger.info(f"Stopping stream {stream_index}")
|
||||||
|
self._ffmpeg_handles[stream_index].kill()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.logger.info("Cleaning up instance")
|
||||||
|
await self.trigger_motion_stop()
|
||||||
|
self.close_streams()
|
||||||
|
|
||||||
|
def close_streams(self):
|
||||||
|
for stream in self._ffmpeg_handles:
|
||||||
|
self.stop_video_stream(stream)
|
133
unifi/cams/dahua.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from amcrest import AmcrestCamera
|
||||||
|
from amcrest.exceptions import CommError
|
||||||
|
|
||||||
|
from unifi.cams.base import RetryableError, SmartDetectObjectType, UnifiCamBase
|
||||||
|
|
||||||
|
|
||||||
|
class DahuaCam(UnifiCamBase):
|
||||||
|
def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
|
||||||
|
super().__init__(args, logger)
|
||||||
|
self.snapshot_dir = tempfile.mkdtemp()
|
||||||
|
if self.args.snapshot_channel is None:
|
||||||
|
self.args.snapshot_channel = self.args.channel - 1
|
||||||
|
if self.args.motion_index is None:
|
||||||
|
self.args.motion_index = self.args.snapshot_channel
|
||||||
|
|
||||||
|
self.camera = AmcrestCamera(
|
||||||
|
self.args.ip, 80, self.args.username, self.args.password
|
||||||
|
).camera
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||||
|
super().add_parser(parser)
|
||||||
|
parser.add_argument(
|
||||||
|
"--username",
|
||||||
|
"-u",
|
||||||
|
required=True,
|
||||||
|
help="Camera username",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--password",
|
||||||
|
"-p",
|
||||||
|
required=True,
|
||||||
|
help="Camera password",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel",
|
||||||
|
"-c",
|
||||||
|
required=False,
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Camera channel",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--snapshot-channel",
|
||||||
|
required=False,
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Snapshot channel",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--main-stream",
|
||||||
|
required=False,
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Main Stream subtype index",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sub-stream",
|
||||||
|
required=False,
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Sub Stream subtype index",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--motion-index",
|
||||||
|
required=False,
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="VideoMotion event index",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> Path:
|
||||||
|
img_file = Path(self.snapshot_dir, "screen.jpg")
|
||||||
|
try:
|
||||||
|
snapshot = await self.camera.async_snapshot(
|
||||||
|
channel=self.args.snapshot_channel
|
||||||
|
)
|
||||||
|
with img_file.open("wb") as f:
|
||||||
|
f.write(snapshot)
|
||||||
|
except CommError as e:
|
||||||
|
self.logger.warning("Could not fetch snapshot", exc_info=e)
|
||||||
|
pass
|
||||||
|
return img_file
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
if self.args.motion_index == -1:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
self.logger.info("Connecting to motion events API")
|
||||||
|
try:
|
||||||
|
async for event in self.camera.async_event_actions(
|
||||||
|
eventcodes="VideoMotion,SmartMotionHuman,SmartMotionVehicle"
|
||||||
|
):
|
||||||
|
code = event[0]
|
||||||
|
action = event[1].get("action")
|
||||||
|
index = event[1].get("index")
|
||||||
|
|
||||||
|
if not index or int(index) != self.args.motion_index:
|
||||||
|
self.logger.debug(f"Skipping event {event}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
object_type = None
|
||||||
|
if code == "SmartMotionHuman":
|
||||||
|
object_type = SmartDetectObjectType.PERSON
|
||||||
|
elif code == "SmartMotionVehicle":
|
||||||
|
object_type = SmartDetectObjectType.VEHICLE
|
||||||
|
|
||||||
|
if action == "Start":
|
||||||
|
self.logger.info(f"Trigger motion start for index {index}")
|
||||||
|
await self.trigger_motion_start(object_type)
|
||||||
|
elif action == "Stop":
|
||||||
|
self.logger.info(f"Trigger motion end for index {index}")
|
||||||
|
await self.trigger_motion_stop()
|
||||||
|
except (CommError, httpx.RequestError):
|
||||||
|
self.logger.error("Motion API request failed, retrying")
|
||||||
|
|
||||||
|
async def get_stream_source(self, stream_index: str) -> str:
|
||||||
|
if stream_index == "video1":
|
||||||
|
subtype = self.args.main_stream
|
||||||
|
else:
|
||||||
|
subtype = self.args.sub_stream
|
||||||
|
try:
|
||||||
|
return await self.camera.async_rtsp_url(
|
||||||
|
channel=self.args.channel, typeno=subtype
|
||||||
|
)
|
||||||
|
except (CommError, httpx.RequestError):
|
||||||
|
raise RetryableError("Could not generate RTSP URL")
|
154
unifi/cams/frigate.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import backoff
|
||||||
|
from asyncio_mqtt import Client
|
||||||
|
from asyncio_mqtt.error import MqttError
|
||||||
|
|
||||||
|
from unifi.cams.base import SmartDetectObjectType
|
||||||
|
from unifi.cams.rtsp import RTSPCam
|
||||||
|
|
||||||
|
|
||||||
|
class FrigateCam(RTSPCam):
|
||||||
|
def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
|
||||||
|
super().__init__(args, logger)
|
||||||
|
self.args = args
|
||||||
|
self.event_id: Optional[str] = None
|
||||||
|
self.event_label: Optional[str] = None
|
||||||
|
self.event_snapshot_ready = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||||
|
super().add_parser(parser)
|
||||||
|
parser.add_argument("--mqtt-host", required=True, help="MQTT server")
|
||||||
|
parser.add_argument("--mqtt-port", default=1883, type=int, help="MQTT server")
|
||||||
|
parser.add_argument("--mqtt-username", required=False)
|
||||||
|
parser.add_argument("--mqtt-password", required=False)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mqtt-prefix", default="frigate", type=str, help="Topic prefix"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--frigate-camera",
|
||||||
|
required=True,
|
||||||
|
type=str,
|
||||||
|
help="Name of camera in frigate",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_feature_flags(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
**await super().get_feature_flags(),
|
||||||
|
**{
|
||||||
|
"mic": True,
|
||||||
|
"smartDetect": [
|
||||||
|
"person",
|
||||||
|
"vehicle",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def label_to_object_type(cls, label: str) -> Optional[SmartDetectObjectType]:
|
||||||
|
if label == "person":
|
||||||
|
return SmartDetectObjectType.PERSON
|
||||||
|
elif label in {"vehicle", "car", "motorcycle", "bus"}:
|
||||||
|
return SmartDetectObjectType.VEHICLE
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
has_connected = False
|
||||||
|
|
||||||
|
@backoff.on_predicate(backoff.expo, max_value=60, logger=self.logger)
|
||||||
|
async def mqtt_connect():
|
||||||
|
nonlocal has_connected
|
||||||
|
try:
|
||||||
|
async with Client(
|
||||||
|
self.args.mqtt_host,
|
||||||
|
port=self.args.mqtt_port,
|
||||||
|
username=self.args.mqtt_username,
|
||||||
|
password=self.args.mqtt_password,
|
||||||
|
) as client:
|
||||||
|
has_connected = True
|
||||||
|
self.logger.info(
|
||||||
|
f"Connected to {self.args.mqtt_host}:{self.args.mqtt_port}"
|
||||||
|
)
|
||||||
|
tasks = [
|
||||||
|
self.handle_detection_events(client),
|
||||||
|
self.handle_snapshot_events(client),
|
||||||
|
]
|
||||||
|
await client.subscribe(f"{self.args.mqtt_prefix}/#")
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except MqttError:
|
||||||
|
if not has_connected:
|
||||||
|
raise
|
||||||
|
|
||||||
|
await mqtt_connect()
|
||||||
|
|
||||||
|
async def handle_detection_events(self, client) -> None:
|
||||||
|
async with client.filtered_messages(
|
||||||
|
f"{self.args.mqtt_prefix}/events"
|
||||||
|
) as messages:
|
||||||
|
async for message in messages:
|
||||||
|
msg = message.payload.decode()
|
||||||
|
try:
|
||||||
|
frigate_msg = json.loads(message.payload.decode())
|
||||||
|
if not frigate_msg["after"]["camera"] == self.args.frigate_camera:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = frigate_msg["after"]["label"]
|
||||||
|
object_type = self.label_to_object_type(label)
|
||||||
|
if not object_type:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Received unsupported detection label type: {label}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.event_id and frigate_msg["type"] == "new":
|
||||||
|
self.event_id = frigate_msg["after"]["id"]
|
||||||
|
self.event_label = label
|
||||||
|
self.event_snapshot_ready = asyncio.Event()
|
||||||
|
self.logger.info(
|
||||||
|
f"Starting {self.event_label} motion event"
|
||||||
|
f" (id: {self.event_id})"
|
||||||
|
)
|
||||||
|
await self.trigger_motion_start(object_type)
|
||||||
|
elif (
|
||||||
|
self.event_id == frigate_msg["after"]["id"]
|
||||||
|
and frigate_msg["type"] == "end"
|
||||||
|
):
|
||||||
|
# Wait for the best snapshot to be ready before
|
||||||
|
# ending the motion event
|
||||||
|
self.logger.info(f"Awaiting snapshot (id: {self.event_id})")
|
||||||
|
await self.event_snapshot_ready.wait()
|
||||||
|
self.logger.info(
|
||||||
|
f"Ending {self.event_label} motion event"
|
||||||
|
f" (id: {self.event_id})"
|
||||||
|
)
|
||||||
|
await self.trigger_motion_stop()
|
||||||
|
self.event_id = None
|
||||||
|
self.event_label = None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.logger.exception(f"Could not decode payload: {msg}")
|
||||||
|
|
||||||
|
async def handle_snapshot_events(self, client) -> None:
|
||||||
|
topic_fmt = f"{self.args.mqtt_prefix}/{self.args.frigate_camera}/{{}}/snapshot"
|
||||||
|
async with client.filtered_messages(topic_fmt.format("+")) as messages:
|
||||||
|
async for message in messages:
|
||||||
|
if (
|
||||||
|
self.event_id
|
||||||
|
and not message.retain
|
||||||
|
and message.topic == topic_fmt.format(self.event_label)
|
||||||
|
):
|
||||||
|
f = tempfile.NamedTemporaryFile()
|
||||||
|
f.write(message.payload)
|
||||||
|
self.logger.debug(
|
||||||
|
f"Updating snapshot for {self.event_label} with {f.name}"
|
||||||
|
)
|
||||||
|
self.update_motion_snapshot(Path(f.name))
|
||||||
|
self.event_snapshot_ready.set()
|
||||||
|
else:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Discarding snapshot message ({len(message.payload)})"
|
||||||
|
)
|
148
unifi/cams/hikvision.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Union
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import xmltodict
|
||||||
|
from hikvisionapi import AsyncClient
|
||||||
|
|
||||||
|
from unifi.cams.base import UnifiCamBase
|
||||||
|
|
||||||
|
|
||||||
|
class HikvisionCam(UnifiCamBase):
|
||||||
|
def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
|
||||||
|
super().__init__(args, logger)
|
||||||
|
self.snapshot_dir = tempfile.mkdtemp()
|
||||||
|
self.streams = {}
|
||||||
|
self.cam = AsyncClient(
|
||||||
|
f"http://{self.args.ip}",
|
||||||
|
self.args.username,
|
||||||
|
self.args.password,
|
||||||
|
timeout=None,
|
||||||
|
)
|
||||||
|
self.channel = args.channel
|
||||||
|
self.substream = args.substream
|
||||||
|
self.ptz_supported = False
|
||||||
|
self.motion_in_progress: bool = False
|
||||||
|
self._last_event_timestamp: Union[str, int] = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||||
|
super().add_parser(parser)
|
||||||
|
parser.add_argument("--username", "-u", required=True, help="Camera username")
|
||||||
|
parser.add_argument("--password", "-p", required=True, help="Camera password")
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel", "-c", default=1, type=int, help="Camera channel index"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--substream", "-s", default=3, type=int, help="Camera substream index"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> Path:
|
||||||
|
img_file = Path(self.snapshot_dir, "screen.jpg")
|
||||||
|
source = int(f"{self.channel}01")
|
||||||
|
try:
|
||||||
|
with img_file.open("wb") as f:
|
||||||
|
async for chunk in self.cam.Streaming.channels[source].picture(
|
||||||
|
method="get", type="opaque_data"
|
||||||
|
):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
except httpx.RequestError:
|
||||||
|
pass
|
||||||
|
return img_file
|
||||||
|
|
||||||
|
async def check_ptz_support(self, channel) -> bool:
|
||||||
|
try:
|
||||||
|
await self.cam.PTZCtrl.channels[channel].capabilities(method="get")
|
||||||
|
self.logger.info("Detected PTZ support")
|
||||||
|
return True
|
||||||
|
except (httpx.RequestError, httpx.HTTPStatusError):
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_video_settings(self) -> dict[str, Any]:
|
||||||
|
if self.ptz_supported:
|
||||||
|
r = (await self.cam.PTZCtrl.channels[1].status(method="get"))["PTZStatus"][
|
||||||
|
"AbsoluteHigh"
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
# Tilt/elevation
|
||||||
|
"brightness": int(100 * int(r["azimuth"]) / 3600),
|
||||||
|
# Pan/azimuth
|
||||||
|
"contrast": int(100 * int(r["azimuth"]) / 3600),
|
||||||
|
# Zoom
|
||||||
|
"hue": int(100 * int(r["absoluteZoom"]) / 40),
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def change_video_settings(self, options: dict[str, Any]) -> None:
|
||||||
|
if self.ptz_supported:
|
||||||
|
tilt = int((900 * int(options["brightness"])) / 100)
|
||||||
|
pan = int((3600 * int(options["contrast"])) / 100)
|
||||||
|
zoom = int((40 * int(options["hue"])) / 100)
|
||||||
|
|
||||||
|
self.logger.info("Moving to %s:%s:%s", pan, tilt, zoom)
|
||||||
|
req = {
|
||||||
|
"PTZData": {
|
||||||
|
"@version": "2.0",
|
||||||
|
"@xmlns": "http://www.hikvision.com/ver20/XMLSchema",
|
||||||
|
"AbsoluteHigh": {
|
||||||
|
"absoluteZoom": str(zoom),
|
||||||
|
"azimuth": str(pan),
|
||||||
|
"elevation": str(tilt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.cam.PTZCtrl.channels[1].absolute(
|
||||||
|
method="put", data=xmltodict.unparse(req, pretty=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_stream_source(self, stream_index: str) -> str:
|
||||||
|
substream = 1
|
||||||
|
if stream_index != "video1":
|
||||||
|
substream = self.substream
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"rtsp://{self.args.username}:{self.args.password}@{self.args.ip}:554"
|
||||||
|
f"/Streaming/Channels/{self.channel}0{substream}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def maybe_end_motion_event(self, start_time):
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
if self.motion_in_progress and self._last_event_timestamp == start_time:
|
||||||
|
await self.trigger_motion_stop()
|
||||||
|
self.motion_in_progress = False
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
self.ptz_supported = await self.check_ptz_support(self.channel)
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self.logger.info("Connecting to motion events API")
|
||||||
|
try:
|
||||||
|
async for event in self.cam.Event.notification.alertStream(
|
||||||
|
method="get", type="stream", timeout=None
|
||||||
|
):
|
||||||
|
alert = event.get("EventNotificationAlert")
|
||||||
|
if (
|
||||||
|
alert
|
||||||
|
and alert.get("channelID") == str(self.channel)
|
||||||
|
and alert.get("eventType") == "VMD"
|
||||||
|
):
|
||||||
|
self._last_event_timestamp = alert.get("dateTime", time.time())
|
||||||
|
|
||||||
|
if self.motion_in_progress is False:
|
||||||
|
self.motion_in_progress = True
|
||||||
|
await self.trigger_motion_start()
|
||||||
|
|
||||||
|
# End motion event after 2 seconds of no updates
|
||||||
|
asyncio.ensure_future(
|
||||||
|
self.maybe_end_motion_event(self._last_event_timestamp)
|
||||||
|
)
|
||||||
|
except httpx.RequestError:
|
||||||
|
self.logger.error("Motion API request failed, retrying")
|
148
unifi/cams/reolink.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import reolinkapi
|
||||||
|
from yarl import URL
|
||||||
|
|
||||||
|
from unifi.cams.base import UnifiCamBase
|
||||||
|
|
||||||
|
|
||||||
|
class Reolink(UnifiCamBase):
|
||||||
|
def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
|
||||||
|
super().__init__(args, logger)
|
||||||
|
self.snapshot_dir: str = tempfile.mkdtemp()
|
||||||
|
self.motion_in_progress: bool = False
|
||||||
|
self.substream = args.substream
|
||||||
|
self.cam = reolinkapi.Camera(
|
||||||
|
ip=args.ip,
|
||||||
|
username=args.username,
|
||||||
|
password=args.password,
|
||||||
|
)
|
||||||
|
self.stream_fps = self.get_stream_info(self.cam)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||||
|
super().add_parser(parser)
|
||||||
|
parser.add_argument("--username", "-u", required=True, help="Camera username")
|
||||||
|
parser.add_argument("--password", "-p", required=True, help="Camera password")
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel",
|
||||||
|
"-c",
|
||||||
|
default=0,
|
||||||
|
help="Camera channel (not needed, leaving for possible future)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--stream",
|
||||||
|
"-m",
|
||||||
|
default="main",
|
||||||
|
type=str,
|
||||||
|
choices=["main", "sub"],
|
||||||
|
help="Stream profile to use for the higher quality stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--substream",
|
||||||
|
"-s",
|
||||||
|
default="sub",
|
||||||
|
type=str,
|
||||||
|
choices=["main", "sub"],
|
||||||
|
help="Stream profile to use for the lower quality stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_stream_info(self, camera) -> tuple[int, int]:
|
||||||
|
info = camera.get_recording_encoding()
|
||||||
|
return (
|
||||||
|
info[0]["value"]["Enc"]["mainStream"]["frameRate"],
|
||||||
|
info[0]["value"]["Enc"]["subStream"]["frameRate"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> Path:
|
||||||
|
img_file = Path(self.snapshot_dir, "screen.jpg")
|
||||||
|
url = (
|
||||||
|
f"http://{self.args.ip}"
|
||||||
|
f"/cgi-bin/api.cgi?cmd=Snap&channel={self.args.channel}"
|
||||||
|
f"&rs=6PHVjvf0UntSLbyT&user={self.args.username}"
|
||||||
|
f"&password={self.args.password}"
|
||||||
|
)
|
||||||
|
self.logger.info(f"Grabbing snapshot: {url}")
|
||||||
|
await self.fetch_to_file(url, img_file)
|
||||||
|
return img_file
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
url = (
|
||||||
|
f"http://{self.args.ip}"
|
||||||
|
f"/api.cgi?cmd=GetMdState&user={self.args.username}"
|
||||||
|
f"&password={self.args.password}"
|
||||||
|
)
|
||||||
|
encoded_url = URL(url, encoded=True)
|
||||||
|
|
||||||
|
body = (
|
||||||
|
f'[{{ "cmd":"GetMdState", "param":{{ "channel":{self.args.channel} }} }}]'
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
self.logger.info(f"Connecting to motion events API: {url}")
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(None)
|
||||||
|
) as session:
|
||||||
|
while True:
|
||||||
|
async with session.post(encoded_url, data=body) as resp:
|
||||||
|
data = await resp.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_body = json.loads(data)
|
||||||
|
if "value" in json_body[0]:
|
||||||
|
if json_body[0]["value"]["state"] == 1:
|
||||||
|
if not self.motion_in_progress:
|
||||||
|
self.motion_in_progress = True
|
||||||
|
self.logger.info("Trigger motion start")
|
||||||
|
await self.trigger_motion_start()
|
||||||
|
elif json_body[0]["value"]["state"] == 0:
|
||||||
|
if self.motion_in_progress:
|
||||||
|
self.motion_in_progress = False
|
||||||
|
self.logger.info("Trigger motion end")
|
||||||
|
await self.trigger_motion_stop()
|
||||||
|
else:
|
||||||
|
self.logger.error(
|
||||||
|
"Motion API request responded with "
|
||||||
|
"unexpected JSON, retrying. "
|
||||||
|
f"JSON: {data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as err:
|
||||||
|
self.logger.error(
|
||||||
|
"Motion API request returned invalid "
|
||||||
|
"JSON, retrying. "
|
||||||
|
f"Error: {err}, "
|
||||||
|
f"Response: {data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
self.logger.error(f"Motion API request failed, retrying. Error: {err}")
|
||||||
|
|
||||||
|
def get_extra_ffmpeg_args(self, stream_index: str) -> str:
|
||||||
|
if stream_index == "video1":
|
||||||
|
fps = self.stream_fps[0]
|
||||||
|
else:
|
||||||
|
fps = self.stream_fps[1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
"-ar 32000 -ac 1 -codec:a aac -b:a 32k -c:v copy -vbsf"
|
||||||
|
f' "h264_metadata=tick_rate={fps*2}"'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_stream_source(self, stream_index: str) -> str:
|
||||||
|
if stream_index == "video1":
|
||||||
|
stream = self.args.stream
|
||||||
|
else:
|
||||||
|
stream = self.args.substream
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"rtmp://{self.args.username}:{self.args.password}@{self.args.ip}:1935"
|
||||||
|
f"//h264Preview_{int(self.args.channel) + 1:02}_{stream}"
|
||||||
|
)
|
91
unifi/cams/reolink_nvr.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from yarl import URL
|
||||||
|
|
||||||
|
from unifi.cams.base import UnifiCamBase
|
||||||
|
|
||||||
|
|
||||||
|
class ReolinkNVRCam(UnifiCamBase):
|
||||||
|
def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
|
||||||
|
super().__init__(args, logger)
|
||||||
|
self.snapshot_dir: str = tempfile.mkdtemp()
|
||||||
|
self.motion_in_progress: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||||
|
super().add_parser(parser)
|
||||||
|
parser.add_argument("--username", "-u", required=True, help="NVR username")
|
||||||
|
parser.add_argument("--password", "-p", required=True, help="NVR password")
|
||||||
|
parser.add_argument("--channel", "-c", required=True, help="NVR camera channel")
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> Path:
|
||||||
|
img_file = Path(self.snapshot_dir, "screen.jpg")
|
||||||
|
url = (
|
||||||
|
f"http://{self.args.ip}"
|
||||||
|
f"/api.cgi?cmd=Snap&user={self.args.username}&password={self.args.password}"
|
||||||
|
f"&rs=6PHVjvf0UntSLbyT&channel={self.args.channel}"
|
||||||
|
)
|
||||||
|
await self.fetch_to_file(url, img_file)
|
||||||
|
return img_file
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
url = (
|
||||||
|
f"http://{self.args.ip}"
|
||||||
|
f"/api.cgi?user={self.args.username}&password={self.args.password}"
|
||||||
|
)
|
||||||
|
encoded_url = URL(url, encoded=True)
|
||||||
|
|
||||||
|
body = (
|
||||||
|
f'[{{ "cmd":"GetMdState", "param":{{ "channel":{self.args.channel} }} }}]'
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
self.logger.info(f"Connecting to motion events API: {url}")
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(None)
|
||||||
|
) as session:
|
||||||
|
while True:
|
||||||
|
async with session.post(encoded_url, data=body) as resp:
|
||||||
|
data = await resp.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_body = json.loads(data)
|
||||||
|
if "value" in json_body[0]:
|
||||||
|
if json_body[0]["value"]["state"] == 1:
|
||||||
|
if not self.motion_in_progress:
|
||||||
|
self.motion_in_progress = True
|
||||||
|
self.logger.info("Trigger motion start")
|
||||||
|
await self.trigger_motion_start()
|
||||||
|
elif json_body[0]["value"]["state"] == 0:
|
||||||
|
if self.motion_in_progress:
|
||||||
|
self.motion_in_progress = False
|
||||||
|
self.logger.info("Trigger motion end")
|
||||||
|
await self.trigger_motion_stop()
|
||||||
|
else:
|
||||||
|
self.logger.error(
|
||||||
|
"Motion API request responded with "
|
||||||
|
"unexpected JSON, retrying. "
|
||||||
|
f"JSON: {data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as err:
|
||||||
|
self.logger.error(
|
||||||
|
"Motion API request returned invalid "
|
||||||
|
"JSON, retrying. "
|
||||||
|
f"Error: {err}, "
|
||||||
|
f"Response: {data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
self.logger.error(f"Motion API request failed, retrying. Error: {err}")
|
||||||
|
|
||||||
|
async def get_stream_source(self, stream_index: str) -> str:
|
||||||
|
return (
|
||||||
|
f"rtsp://{self.args.username}:{self.args.password}@{self.args.ip}:554"
|
||||||
|
f"/h264Preview_{int(self.args.channel) + 1:02}_main"
|
||||||
|
)
|
107
unifi/cams/rtsp.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from unifi.cams.base import UnifiCamBase
|
||||||
|
|
||||||
|
|
||||||
|
class RTSPCam(UnifiCamBase):
|
||||||
|
def __init__(self, args: argparse.Namespace, logger: logging.Logger) -> None:
|
||||||
|
super().__init__(args, logger)
|
||||||
|
self.args = args
|
||||||
|
self.event_id = 0
|
||||||
|
self.snapshot_dir = tempfile.mkdtemp()
|
||||||
|
self.snapshot_stream = None
|
||||||
|
self.runner = None
|
||||||
|
self.stream_source = dict()
|
||||||
|
for i, stream_index in enumerate(["video1", "video2", "video3"]):
|
||||||
|
if not i < len(self.args.source):
|
||||||
|
i = -1
|
||||||
|
self.stream_source[stream_index] = self.args.source[i]
|
||||||
|
if not self.args.snapshot_url:
|
||||||
|
self.start_snapshot_stream()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, parser: argparse.ArgumentParser) -> None:
|
||||||
|
super().add_parser(parser)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
"-s",
|
||||||
|
nargs="+",
|
||||||
|
required=True,
|
||||||
|
help="Source(s) for up to three streams in order of descending quality",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--http-api",
|
||||||
|
default=0,
|
||||||
|
type=int,
|
||||||
|
help="Specify a port number to enable the HTTP API (default: disabled)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--snapshot-url",
|
||||||
|
"-i",
|
||||||
|
default=None,
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="HTTP endpoint to fetch snapshot image from",
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_snapshot_stream(self) -> None:
|
||||||
|
if not self.snapshot_stream or self.snapshot_stream.poll() is not None:
|
||||||
|
cmd = (
|
||||||
|
f"ffmpeg -nostdin -y -re -rtsp_transport {self.args.rtsp_transport} "
|
||||||
|
f'-i "{self.args.source[-1]}" '
|
||||||
|
"-r 1 "
|
||||||
|
f"-update 1 {self.snapshot_dir}/screen.jpg"
|
||||||
|
)
|
||||||
|
self.logger.info(f"Spawning stream for snapshots: {cmd}")
|
||||||
|
self.snapshot_stream = subprocess.Popen(
|
||||||
|
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_snapshot(self) -> Path:
|
||||||
|
img_file = Path(self.snapshot_dir, "screen.jpg")
|
||||||
|
if self.args.snapshot_url:
|
||||||
|
await self.fetch_to_file(self.args.snapshot_url, img_file)
|
||||||
|
else:
|
||||||
|
self.start_snapshot_stream()
|
||||||
|
return img_file
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
if self.args.http_api:
|
||||||
|
self.logger.info(f"Enabling HTTP API on port {self.args.http_api}")
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
|
||||||
|
async def start_motion(request):
|
||||||
|
self.logger.debug("Starting motion")
|
||||||
|
await self.trigger_motion_start()
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
async def stop_motion(request):
|
||||||
|
self.logger.debug("Starting motion")
|
||||||
|
await self.trigger_motion_stop()
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
app.add_routes([web.get("/start_motion", start_motion)])
|
||||||
|
app.add_routes([web.get("/stop_motion", stop_motion)])
|
||||||
|
|
||||||
|
self.runner = web.AppRunner(app)
|
||||||
|
await self.runner.setup()
|
||||||
|
site = web.TCPSite(self.runner, port=self.args.http_api)
|
||||||
|
await site.start()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await super().close()
|
||||||
|
if self.runner:
|
||||||
|
await self.runner.cleanup()
|
||||||
|
|
||||||
|
if self.snapshot_stream:
|
||||||
|
self.snapshot_stream.kill()
|
||||||
|
|
||||||
|
async def get_stream_source(self, stream_index: str) -> str:
|
||||||
|
return self.stream_source[stream_index]
|
179
unifi/clock_sync.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
""""
|
||||||
|
Helper program to inject absolute wall clock time into FLV stream for recordings
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flvlib3.astypes import FLVObject
|
||||||
|
from flvlib3.primitives import make_ui8, make_ui32
|
||||||
|
from flvlib3.tags import create_script_tag
|
||||||
|
|
||||||
|
|
||||||
|
def read_bytes(source, num_bytes):
|
||||||
|
read_bytes = 0
|
||||||
|
buf = b""
|
||||||
|
while read_bytes < num_bytes:
|
||||||
|
d_in = source.read(num_bytes - read_bytes)
|
||||||
|
if d_in:
|
||||||
|
read_bytes += len(d_in)
|
||||||
|
buf += d_in
|
||||||
|
else:
|
||||||
|
return buf
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def write(data):
|
||||||
|
sys.stdout.buffer.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def write_log(data):
|
||||||
|
sys.stderr.buffer.write(f"{data}\n".encode())
|
||||||
|
|
||||||
|
|
||||||
|
def write_timestamp_trailer(is_packet, ts):
|
||||||
|
# Write 15 byte trailer
|
||||||
|
write(make_ui8(0))
|
||||||
|
if is_packet:
|
||||||
|
write(bytes([1, 95, 144, 0, 0, 0, 0, 0, 0, 0, 0]))
|
||||||
|
else:
|
||||||
|
write(bytes([0, 43, 17, 0, 0, 0, 0, 0, 0, 0, 0]))
|
||||||
|
|
||||||
|
write(make_ui32(int(ts * 1000 * 100)))
|
||||||
|
|
||||||
|
|
||||||
|
def main(args):
|
||||||
|
source = sys.stdin.buffer
|
||||||
|
|
||||||
|
header = read_bytes(source, 3)
|
||||||
|
|
||||||
|
if header != b"FLV":
|
||||||
|
print("Not a valid FLV file")
|
||||||
|
return
|
||||||
|
write(header)
|
||||||
|
|
||||||
|
# Skip rest of FLV header
|
||||||
|
write(read_bytes(source, 1))
|
||||||
|
read_bytes(source, 1)
|
||||||
|
# Write custom bitmask for FLV type
|
||||||
|
write(make_ui8(7))
|
||||||
|
write(read_bytes(source, 4))
|
||||||
|
|
||||||
|
# Tag 0 previous size
|
||||||
|
write(read_bytes(source, 4))
|
||||||
|
|
||||||
|
last_ts = time.time()
|
||||||
|
start = time.time()
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
# Packet structure from Wikipedia:
|
||||||
|
#
|
||||||
|
# Size of previous packet uint32_be 0 For first packet set to NULL
|
||||||
|
#
|
||||||
|
# Packet Type uint8 18 For first packet set to AMF Metadata
|
||||||
|
# Payload Size uint24_be varies Size of packet data only
|
||||||
|
# Timestamp Lower uint24_be 0 For first packet set to NULL
|
||||||
|
# Timestamp Upper uint8 0 Extension to create a uint32_be value
|
||||||
|
# Stream ID uint24_be 0 For first stream of same type set to NULL
|
||||||
|
#
|
||||||
|
# Payload Data freeform varies Data as defined by packet type
|
||||||
|
|
||||||
|
header = read_bytes(source, 12)
|
||||||
|
if len(header) != 12:
|
||||||
|
write(header)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Packet type
|
||||||
|
packet_type = header[0]
|
||||||
|
|
||||||
|
# Get payload size to know how many bytes to read
|
||||||
|
high, low = struct.unpack(">BH", header[1:4])
|
||||||
|
payload_size = (high << 16) + low
|
||||||
|
|
||||||
|
# Get timestamp to inject into clock sync tag
|
||||||
|
low_high = header[4:8]
|
||||||
|
combined = bytes([low_high[3]]) + low_high[:3]
|
||||||
|
timestamp = struct.unpack(">i", combined)[0]
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if not last_ts or now - last_ts >= 5:
|
||||||
|
last_ts = now
|
||||||
|
# Insert a custom packet every so often for time synchronization
|
||||||
|
data = FLVObject()
|
||||||
|
data["streamClock"] = int(timestamp)
|
||||||
|
data["streamClockBase"] = 0
|
||||||
|
data["wallClock"] = now * 1000
|
||||||
|
packet_to_inject = create_script_tag("onClockSync", data, timestamp)
|
||||||
|
write(packet_to_inject)
|
||||||
|
|
||||||
|
# Write 15 byte trailer
|
||||||
|
write_timestamp_trailer(False, now - start)
|
||||||
|
|
||||||
|
# Write mpma tag
|
||||||
|
# {'cs': {'cur': 1500000.0,
|
||||||
|
# 'max': 1500000.0,
|
||||||
|
# 'min': 32000.0},
|
||||||
|
# 'm': {'cur': 750000.0,
|
||||||
|
# 'max': 1500000.0,
|
||||||
|
# 'min': 750000.0},
|
||||||
|
# 'r': 0.0,
|
||||||
|
# 'sp': {'cur': 1500000.0,
|
||||||
|
# 'max': 1500000.0,
|
||||||
|
# 'min': 150000.0},
|
||||||
|
# 't': 750000.0}
|
||||||
|
|
||||||
|
data = FLVObject()
|
||||||
|
data["cs"] = FLVObject()
|
||||||
|
data["cs"]["cur"] = 1500000
|
||||||
|
data["cs"]["max"] = 1500000
|
||||||
|
data["cs"]["min"] = 1500000
|
||||||
|
|
||||||
|
data["m"] = FLVObject()
|
||||||
|
data["m"]["cur"] = 1500000
|
||||||
|
data["m"]["max"] = 1500000
|
||||||
|
data["m"]["min"] = 1500000
|
||||||
|
data["r"] = 0
|
||||||
|
|
||||||
|
data["sp"] = FLVObject()
|
||||||
|
data["sp"]["cur"] = 1500000
|
||||||
|
data["sp"]["max"] = 1500000
|
||||||
|
data["sp"]["min"] = 1500000
|
||||||
|
data["t"] = 75000.0
|
||||||
|
packet_to_inject = create_script_tag("onMpma", data, 0)
|
||||||
|
|
||||||
|
write(packet_to_inject)
|
||||||
|
|
||||||
|
# Write 15 byte trailer
|
||||||
|
write_timestamp_trailer(False, now - start)
|
||||||
|
|
||||||
|
# Write rest of original packet minus previous packet size
|
||||||
|
write(header)
|
||||||
|
write(read_bytes(source, payload_size))
|
||||||
|
else:
|
||||||
|
# Write the original packet
|
||||||
|
write(header)
|
||||||
|
write(read_bytes(source, payload_size))
|
||||||
|
|
||||||
|
# Write previous packet size
|
||||||
|
write(read_bytes(source, 3))
|
||||||
|
|
||||||
|
# Write 15 byte trailer
|
||||||
|
write_timestamp_trailer(packet_type == 9, now - start)
|
||||||
|
|
||||||
|
# Write mpma tag
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Modify Protect FLV stream")
|
||||||
|
parser.add_argument(
|
||||||
|
"--write-timestamps",
|
||||||
|
action="store_true",
|
||||||
|
help="Indicates we should write timestamp in between packets",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(parse_args())
|
81
unifi/core.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import asyncio
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
import backoff
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
|
||||||
|
class RetryableError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Core(object):
|
||||||
|
def __init__(self, args, camera, logger):
|
||||||
|
self.host = args.host
|
||||||
|
self.token = args.token
|
||||||
|
self.mac = args.mac
|
||||||
|
self.logger = logger
|
||||||
|
self.cam = camera
|
||||||
|
|
||||||
|
# Set up ssl context for requests
|
||||||
|
self.ssl_context = ssl.create_default_context()
|
||||||
|
self.ssl_context.check_hostname = False
|
||||||
|
self.ssl_context.verify_mode = ssl.CERT_NONE
|
||||||
|
self.ssl_context.load_cert_chain(args.cert, args.cert)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
uri = "wss://{}:7442/camera/1.0/ws?token={}".format(self.host, self.token)
|
||||||
|
headers = {"camera-mac": self.mac}
|
||||||
|
has_connected = False
|
||||||
|
|
||||||
|
@backoff.on_predicate(
|
||||||
|
backoff.expo,
|
||||||
|
lambda retryable: retryable,
|
||||||
|
factor=2,
|
||||||
|
jitter=None,
|
||||||
|
max_value=10,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
async def connect():
|
||||||
|
nonlocal has_connected
|
||||||
|
self.logger.info(f"Creating ws connection to {uri}")
|
||||||
|
try:
|
||||||
|
ws = await websockets.connect(
|
||||||
|
uri,
|
||||||
|
extra_headers=headers,
|
||||||
|
ssl=self.ssl_context,
|
||||||
|
subprotocols=["secure_transfer"],
|
||||||
|
)
|
||||||
|
has_connected = True
|
||||||
|
except websockets.exceptions.InvalidStatusCode as e:
|
||||||
|
if e.status_code == 403:
|
||||||
|
self.logger.error(
|
||||||
|
f"The token '{self.token}'"
|
||||||
|
" is invalid. Please generate a new one and try again."
|
||||||
|
)
|
||||||
|
# Hitting rate-limiting
|
||||||
|
elif e.status_code == 429:
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
except asyncio.exceptions.TimeoutError:
|
||||||
|
self.logger.info(f"Connection to {self.host} timed out.")
|
||||||
|
return True
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
self.logger.info(f"Connection to {self.host} refused.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(self.cam._run(ws)),
|
||||||
|
asyncio.create_task(self.cam.run()),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except RetryableError:
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
await self.cam.close()
|
||||||
|
|
||||||
|
await connect()
|
176
unifi/main.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from shutil import which
|
||||||
|
|
||||||
|
import coloredlogs
|
||||||
|
from pyunifiprotect import ProtectApiClient
|
||||||
|
|
||||||
|
from unifi.cams import (
|
||||||
|
DahuaCam,
|
||||||
|
FrigateCam,
|
||||||
|
HikvisionCam,
|
||||||
|
Reolink,
|
||||||
|
ReolinkNVRCam,
|
||||||
|
RTSPCam,
|
||||||
|
)
|
||||||
|
from unifi.core import Core
|
||||||
|
from unifi.version import __version__
|
||||||
|
|
||||||
|
CAMS = {
|
||||||
|
"amcrest": DahuaCam,
|
||||||
|
"dahua": DahuaCam,
|
||||||
|
"frigate": FrigateCam,
|
||||||
|
"hikvision": HikvisionCam,
|
||||||
|
"lorex": DahuaCam,
|
||||||
|
"reolink": Reolink,
|
||||||
|
"reolink_nvr": ReolinkNVRCam,
|
||||||
|
"rtsp": RTSPCam,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--version", action="version", version=__version__)
|
||||||
|
parser.add_argument("--host", "-H", required=True, help="NVR ip address and port")
|
||||||
|
parser.add_argument("--nvr-username", required=False, help="NVR username")
|
||||||
|
parser.add_argument("--nvr-password", required=False, help="NVR password")
|
||||||
|
parser.add_argument(
|
||||||
|
"--cert",
|
||||||
|
"-c",
|
||||||
|
required=True,
|
||||||
|
default="client.pem",
|
||||||
|
help="Client certificate path",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token", "-t", required=False, default=None, help="Adoption token"
|
||||||
|
)
|
||||||
|
parser.add_argument("--mac", "-m", default="AABBCCDDEEFF", help="MAC address")
|
||||||
|
parser.add_argument(
|
||||||
|
"--ip",
|
||||||
|
"-i",
|
||||||
|
default="192.168.1.10",
|
||||||
|
help="IP address of camera (only used to display in UI)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
"-n",
|
||||||
|
default="unifi-cam-proxy",
|
||||||
|
help="Name of camera (only works for UFV)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
default="UVC G3",
|
||||||
|
choices=[
|
||||||
|
"UVC",
|
||||||
|
"UVC AI 360",
|
||||||
|
"UVC AI Bullet",
|
||||||
|
"UVC AI THETA",
|
||||||
|
"UVC AI DSLR",
|
||||||
|
"UVC Pro",
|
||||||
|
"UVC Dome",
|
||||||
|
"UVC Micro",
|
||||||
|
"UVC G3",
|
||||||
|
"UVC G3 Battery",
|
||||||
|
"UVC G3 Dome",
|
||||||
|
"UVC G3 Micro",
|
||||||
|
"UVC G3 Mini",
|
||||||
|
"UVC G3 Instant",
|
||||||
|
"UVC G3 Pro",
|
||||||
|
"UVC G3 Flex",
|
||||||
|
"UVC G4 Bullet",
|
||||||
|
"UVC G4 Pro",
|
||||||
|
"UVC G4 PTZ",
|
||||||
|
"UVC G4 Doorbell",
|
||||||
|
"UVC G4 Doorbell Pro",
|
||||||
|
"UVC G4 Doorbell Pro PoE",
|
||||||
|
"UVC G4 Dome",
|
||||||
|
"UVC G4 Instant",
|
||||||
|
"UVC G5 Bullet",
|
||||||
|
"UVC G5 Dome",
|
||||||
|
"UVC G5 Flex",
|
||||||
|
"UVC G5 Pro",
|
||||||
|
"AFi VC",
|
||||||
|
"Vision Pro",
|
||||||
|
],
|
||||||
|
help="Hardware model to identify as",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fw-version",
|
||||||
|
"-f",
|
||||||
|
default="UVC.S2L.v4.23.8.67.0eba6e3.200526.1046",
|
||||||
|
help="Firmware version to initiate connection with",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", "-v", action="store_true", help="increase output verbosity"
|
||||||
|
)
|
||||||
|
|
||||||
|
sp = parser.add_subparsers(
|
||||||
|
help="Camera implementations",
|
||||||
|
dest="impl",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
for name, impl in CAMS.items():
|
||||||
|
subparser = sp.add_parser(name)
|
||||||
|
impl.add_parser(subparser)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_token(args, logger):
|
||||||
|
try:
|
||||||
|
protect = ProtectApiClient(
|
||||||
|
args.host, 443, args.nvr_username, args.nvr_password, verify_ssl=False
|
||||||
|
)
|
||||||
|
await protect.update()
|
||||||
|
response = await protect.api_request("cameras/manage-payload")
|
||||||
|
return response["mgmt"]["token"]
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Could not automatically fetch token, please see docs at"
|
||||||
|
" https://unifi-cam-proxy.com/"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
await protect.close_session()
|
||||||
|
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
args = parse_args()
|
||||||
|
klass = CAMS[args.impl]
|
||||||
|
|
||||||
|
core_logger = logging.getLogger("Core")
|
||||||
|
class_logger = logging.getLogger(klass.__name__)
|
||||||
|
|
||||||
|
level = logging.INFO
|
||||||
|
if args.verbose:
|
||||||
|
level = logging.DEBUG
|
||||||
|
|
||||||
|
for logger in [core_logger, class_logger]:
|
||||||
|
coloredlogs.install(level=level, logger=logger)
|
||||||
|
|
||||||
|
# Preflight checks
|
||||||
|
for binary in ["ffmpeg", "nc"]:
|
||||||
|
if which(binary) is None:
|
||||||
|
logger.error(f"{binary} is not installed")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not args.token:
|
||||||
|
args.token = await generate_token(args, logger)
|
||||||
|
|
||||||
|
if not args.token:
|
||||||
|
logger.error("A valid token is required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cam = klass(args, logger)
|
||||||
|
c = Core(args, cam, core_logger)
|
||||||
|
await c.run()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.run_until_complete(run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
1
unifi/version.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.3.0"
|