bob
Some checks failed
📚 Deploy Documentation / deploy (push) Has been cancelled

This commit is contained in:
Lee 2024-03-02 11:37:04 +00:00
commit e8f72ea949
65 changed files with 11610 additions and 0 deletions

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 .

@ -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.

@ -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')],
};

@ -0,0 +1,4 @@
{
"label": "Camera-Specific Configuration",
"position": 1
}

@ -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"'
```

@ -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"
```

@ -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
```

@ -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}
```

@ -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'
```

@ -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}
```

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
docs/static/img/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

1
docs/static/img/logo.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@ -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

@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

7683
docs/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

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"