mirror of
https://github.com/koush/scrypted.git
synced 2026-02-05 23:22:13 +00:00
Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6560fbbe4 | ||
|
|
7205583104 | ||
|
|
8479a16d3d | ||
|
|
409aad4794 | ||
|
|
a29d009e5c | ||
|
|
6772419ccf | ||
|
|
38746ee743 | ||
|
|
c5cb3ffa90 | ||
|
|
e119056267 | ||
|
|
590ad3de37 | ||
|
|
6cd412de88 | ||
|
|
33ca0242b1 | ||
|
|
68d3f10888 | ||
|
|
7a844aac84 | ||
|
|
6f2bb9fd9e | ||
|
|
12e47993a4 | ||
|
|
b0396b77bd | ||
|
|
07c2314376 | ||
|
|
cee140e49f | ||
|
|
a3963af6e7 | ||
|
|
8ff28418b3 | ||
|
|
08a5c2f2b3 | ||
|
|
286bd5b19e | ||
|
|
59f3c2e3ad | ||
|
|
ea1b394061 | ||
|
|
5dc1af76e8 | ||
|
|
771bbd834b | ||
|
|
418724f860 | ||
|
|
2ecf48bc60 | ||
|
|
d19b942d2c | ||
|
|
08e724759d | ||
|
|
80031bc80b | ||
|
|
beb53c672c | ||
|
|
0dc75bf737 | ||
|
|
59008fb964 | ||
|
|
b119e5ee00 | ||
|
|
01d0f4c72a | ||
|
|
9fe3f1a4db | ||
|
|
60bf112ebd | ||
|
|
45aa443889 | ||
|
|
08f4922860 | ||
|
|
899970405a | ||
|
|
b4a3960e43 | ||
|
|
0514e62d78 | ||
|
|
3621e58d4c | ||
|
|
506b24026f | ||
|
|
98b67f5d56 | ||
|
|
33c95aa0e8 | ||
|
|
7d8f86bb6c | ||
|
|
d6717cc58b | ||
|
|
673f8e3b2a | ||
|
|
cae87ba414 | ||
|
|
13362fd53e | ||
|
|
d9f2ba0665 | ||
|
|
64a0f90a9a | ||
|
|
88300910a2 | ||
|
|
7face43d54 | ||
|
|
6a9f35ce2a | ||
|
|
effe76f251 | ||
|
|
58d5539cb8 | ||
|
|
d956ee06d0 | ||
|
|
8ddf91d13b | ||
|
|
3f65cd4f6d | ||
|
|
3ffdbf9d2b | ||
|
|
a51754b0e3 | ||
|
|
e8ee21e567 | ||
|
|
420f070035 | ||
|
|
c78cbc04d3 | ||
|
|
dddf565fbe | ||
|
|
0516ca810d | ||
|
|
fac67696a9 | ||
|
|
c62d4bd3fd | ||
|
|
877e1d4992 | ||
|
|
35b5cddd95 | ||
|
|
a86fb128d9 | ||
|
|
983daae971 | ||
|
|
9b687e3286 | ||
|
|
abfd0ffe35 | ||
|
|
407afa1d8c | ||
|
|
9bafe97ef6 | ||
|
|
cb151e79d8 | ||
|
|
7e6230d7b0 | ||
|
|
7d95de389a | ||
|
|
2ce187bc98 | ||
|
|
100671265e | ||
|
|
965d5af631 | ||
|
|
a19f356a66 | ||
|
|
a520357a23 | ||
|
|
d92d130a7c | ||
|
|
c8dd7d2f04 | ||
|
|
b85b589675 | ||
|
|
9b4cbed28f | ||
|
|
6b1794d32f | ||
|
|
aefe4b6849 | ||
|
|
a68395174a | ||
|
|
8a56e789b7 | ||
|
|
06ef146c5b | ||
|
|
21eeab6c3c | ||
|
|
18c6edd310 | ||
|
|
a1d7a0d9ca | ||
|
|
5d5078534d | ||
|
|
537a968e2e | ||
|
|
4520d1d29f |
60
.github/workflows/test.yml
vendored
Normal file
60
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths: ["docker/**", ".github/workflows/test.yml"]
|
||||
pull_request:
|
||||
paths: ["docker/**", ".github/workflows/test.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test_linux_local:
|
||||
name: Test Linux local installation
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run install script
|
||||
run: |
|
||||
cat ./docker/install-scrypted-dependencies-linux.sh | sudo SERVICE_USER=$USER bash
|
||||
|
||||
- name: Test server is running
|
||||
run: |
|
||||
systemctl status scrypted.service
|
||||
curl -k --retry 20 --retry-all-errors --retry-max-time 600 https://localhost:10443/
|
||||
|
||||
test_mac_local:
|
||||
name: Test Mac local installation
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run install script
|
||||
run: |
|
||||
mkdir -p ~/.scrypted
|
||||
bash ./docker/install-scrypted-dependencies-mac.sh
|
||||
|
||||
- name: Test server is running
|
||||
run: |
|
||||
curl -k --retry 20 --retry-all-errors --retry-max-time 600 https://localhost:10443/
|
||||
|
||||
test_windows_local:
|
||||
name: Test Windows local installation
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run install script
|
||||
run: |
|
||||
.\docker\install-scrypted-dependencies-win.ps1
|
||||
|
||||
- name: Test server is running
|
||||
run: |
|
||||
curl -k --retry 20 --retry-all-errors --retry-max-time 600 https://localhost:10443/
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,6 +1,3 @@
|
||||
[submodule "plugins/homekit/HAP-NodeJS"]
|
||||
path = external/HAP-NodeJS
|
||||
url = ../../koush/HAP-NodeJS
|
||||
[submodule "plugins/unifi-protect/src/unifi-protect"]
|
||||
path = external/unifi-protect
|
||||
url = ../../koush/unifi-protect.git
|
||||
|
||||
@@ -13,6 +13,7 @@ import { readLength, readLine } from './read-stream';
|
||||
import { MSection, parseSdp } from './sdp-utils';
|
||||
import { sleep } from './sleep';
|
||||
import { StreamChunk, StreamParser, StreamParserOptions } from './stream-parser';
|
||||
import { URL } from 'url';
|
||||
|
||||
const REQUIRED_WWW_AUTHENTICATE_KEYS = ['realm', 'nonce'];
|
||||
|
||||
@@ -580,7 +581,7 @@ export class RtspClient extends RtspBase {
|
||||
const username = decodeURIComponent(authedUrl.username);
|
||||
const password = decodeURIComponent(authedUrl.password);
|
||||
|
||||
const strippedUrl = new URL(url);
|
||||
const strippedUrl = new URL(url.toString());
|
||||
strippedUrl.username = '';
|
||||
strippedUrl.password = '';
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
# install script.
|
||||
################################################################
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
FROM debian:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
RUN apt-get update && apt-get -y install curl wget
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
@@ -31,41 +33,48 @@ RUN apt-get -y install \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libcairo2-dev \
|
||||
libgirepository1.0-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config \
|
||||
libvips
|
||||
libvips \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
# these are necessary for pillow-simd, additional on disk size is small
|
||||
# but could consider removing this.
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
libjpeg-dev zlib1g-dev
|
||||
|
||||
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
|
||||
RUN apt-get -y install \
|
||||
gstreamer1.0-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-vaapi
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-gi \
|
||||
python3-gst-1.0 \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-pip \
|
||||
python3-setuptools \
|
||||
python3-skimage \
|
||||
python3-wheel
|
||||
|
||||
# armv7l does not have wheels for any of these
|
||||
# and compile times would forever, if it works at all.
|
||||
RUN if [ "$(uname -m)" = "armv7l" ]; \
|
||||
then \
|
||||
apt-get -y install \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-skimage; \
|
||||
fi
|
||||
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
# pyvips is broken on x86 due to mismatch ffi
|
||||
# https://stackoverflow.com/questions/62658237/it-seems-that-the-version-of-the-libffi-library-seen-at-runtime-is-different-fro
|
||||
RUN pip install --force-reinstall --no-binary :all: cffi
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions typing psutil
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions psutil
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.header
|
||||
@@ -80,6 +89,10 @@ ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230322
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
FROM debian:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
RUN apt-get update && apt-get -y install curl wget
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
@@ -15,31 +17,29 @@ RUN apt-get -y update
|
||||
# base development stuff
|
||||
RUN apt-get -y install \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libcairo2-dev \
|
||||
libgirepository1.0-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
ENV SCRYPTED_FFMPEG_PATH=ffmpeg
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-gi \
|
||||
python3-pip \
|
||||
python3-setuptools \
|
||||
python3-wheel
|
||||
|
||||
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions typing psutil
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions psutil
|
||||
|
||||
ENV SCRYPTED_DOCKER_SERVE="true"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230322
|
||||
|
||||
22
docker/Dockerfile.nvidia
Normal file
22
docker/Dockerfile.nvidia
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM koush/18-bullseye-full.s6
|
||||
|
||||
WORKDIR /
|
||||
|
||||
# Install miniconda
|
||||
ENV CONDA_DIR /opt/conda
|
||||
RUN wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda.sh && \
|
||||
/bin/bash ~/miniconda.sh -b -p /opt/conda
|
||||
# Put conda in path so we can use conda activate
|
||||
ENV PATH=$CONDA_DIR/bin:$PATH
|
||||
|
||||
RUN conda install -c conda-forge cudatoolkit=11.2.2 cudnn=8.1.0
|
||||
ENV CONDA_PREFIX=/opt/conda
|
||||
ENV LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$CONDA_PREFIX/lib/
|
||||
|
||||
# this is a copy pasta, seems to need a reinstall.
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
# pyvips is broken on x86 due to mismatch ffi
|
||||
# https://stackoverflow.com/questions/62658237/it-seems-that-the-version-of-the-libffi-library-seen-at-runtime-is-different-fro
|
||||
RUN python3 -m pip install --force-reinstall --no-binary :all: cffi
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions psutil
|
||||
@@ -1,5 +1,7 @@
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
FROM debian:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
RUN apt-get update && apt-get -y install curl wget
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
@@ -12,19 +14,11 @@ RUN apt-get -y upgrade
|
||||
RUN apt-get -y install software-properties-common apt-utils
|
||||
RUN apt-get -y update
|
||||
|
||||
# base development stuff
|
||||
RUN apt-get -y install \
|
||||
build-essential \
|
||||
gcc \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
ENV SCRYPTED_FFMPEG_PATH=ffmpeg
|
||||
|
||||
ENV SCRYPTED_DOCKER_SERVE="true"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230322
|
||||
|
||||
3
docker/docker-build-nvidia.sh
Executable file
3
docker/docker-build-nvidia.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
./docker-build.sh
|
||||
|
||||
docker build -t koush/scrypted:18-bullseye-full.nvidia -f Dockerfile.nvidia
|
||||
@@ -54,6 +54,11 @@ services:
|
||||
# target: /nvr
|
||||
# volume:
|
||||
# nocopy: true
|
||||
|
||||
# uncomment the following lines to expose Avahi, an mDNS advertiser.
|
||||
# make sure Avahi is running on the host machine, otherwise this will not work.
|
||||
# - /var/run/dbus:/var/run/dbus
|
||||
# - /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket
|
||||
# logging is noisy and will unnecessarily wear on flash storage.
|
||||
# scrypted has per device in memory logging that is preferred.
|
||||
logging:
|
||||
|
||||
@@ -42,6 +42,8 @@ RUN brew update
|
||||
RUN_IGNORE brew install node@18
|
||||
# snapshot plugin and others
|
||||
RUN brew install libvips
|
||||
# dlib
|
||||
RUN brew install cmake
|
||||
# gstreamer plugins
|
||||
RUN_IGNORE brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly
|
||||
# gst python bindings
|
||||
@@ -59,6 +61,7 @@ brew unpin gst-plugins-ugly
|
||||
brew unpin gst-plugins-good
|
||||
brew unpin gst-plugins-base
|
||||
brew unpin gst-plugins-bad
|
||||
brew unpin gst-libav
|
||||
|
||||
brew unlink gstreamer
|
||||
brew unlink gst-python
|
||||
@@ -66,6 +69,7 @@ brew unlink gst-plugins-ugly
|
||||
brew unlink gst-plugins-good
|
||||
brew unlink gst-plugins-base
|
||||
brew unlink gst-plugins-bad
|
||||
brew unlink gst-libav
|
||||
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gstreamer.rb && brew install ./gstreamer.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-python.rb && brew install ./gst-python.rb
|
||||
@@ -73,6 +77,7 @@ curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a657
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-good.rb && brew install ./gst-plugins-good.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-base.rb && brew install ./gst-plugins-base.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-bad.rb && brew install ./gst-plugins-bad.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-libav.rb && brew install ./gst-libav.rb
|
||||
|
||||
brew pin gstreamer
|
||||
brew pin gst-python
|
||||
@@ -80,6 +85,7 @@ brew pin gst-plugins-ugly
|
||||
brew pin gst-plugins-good
|
||||
brew pin gst-plugins-base
|
||||
brew pin gst-plugins-bad
|
||||
brew pin gst-libav
|
||||
|
||||
### END HACK WORKAROUND
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230322
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
# install script.
|
||||
################################################################
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
FROM debian:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
RUN apt-get update && apt-get -y install curl wget
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
@@ -28,41 +30,48 @@ RUN apt-get -y install \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libcairo2-dev \
|
||||
libgirepository1.0-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config \
|
||||
libvips
|
||||
libvips \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
# these are necessary for pillow-simd, additional on disk size is small
|
||||
# but could consider removing this.
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
libjpeg-dev zlib1g-dev
|
||||
|
||||
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
|
||||
RUN apt-get -y install \
|
||||
gstreamer1.0-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-vaapi
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-gi \
|
||||
python3-gst-1.0 \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-pip \
|
||||
python3-setuptools \
|
||||
python3-skimage \
|
||||
python3-wheel
|
||||
|
||||
# armv7l does not have wheels for any of these
|
||||
# and compile times would forever, if it works at all.
|
||||
RUN if [ "$(uname -m)" = "armv7l" ]; \
|
||||
then \
|
||||
apt-get -y install \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-skimage; \
|
||||
fi
|
||||
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
# pyvips is broken on x86 due to mismatch ffi
|
||||
# https://stackoverflow.com/questions/62658237/it-seems-that-the-version-of-the-libffi-library-seen-at-runtime-is-different-fro
|
||||
RUN pip install --force-reinstall --no-binary :all: cffi
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions typing psutil
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions psutil
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.header
|
||||
|
||||
1
external/HAP-NodeJS
vendored
1
external/HAP-NodeJS
vendored
Submodule external/HAP-NodeJS deleted from 3fe1f920f5
6
plugins/arlo/package-lock.json
generated
6
plugins/arlo/package-lock.json
generated
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "@scrypted/arlo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/arlo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.7.0",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
},
|
||||
"../../sdk": {
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.2.78",
|
||||
"version": "0.2.85",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/arlo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.7.0",
|
||||
"description": "Arlo Plugin for Scrypted",
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
|
||||
@@ -709,3 +709,29 @@ class Arlo(object):
|
||||
trigger,
|
||||
callback,
|
||||
)
|
||||
|
||||
def SirenOn(self, basestation):
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": "siren",
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "on",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
|
||||
def SirenOff(self, basestation):
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": "siren",
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "off",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
|
||||
44
plugins/arlo/src/arlo_plugin/basestation.py
Normal file
44
plugins/arlo/src/arlo_plugin/basestation.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import DeviceProvider, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .device_base import ArloDeviceBase
|
||||
from .siren import ArloSiren
|
||||
|
||||
|
||||
class ArloBasestation(ArloDeviceBase, DeviceProvider):
|
||||
siren: ArloSiren = None
|
||||
|
||||
def get_applicable_interfaces(self) -> list:
|
||||
return [ScryptedInterface.DeviceProvider.value]
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.DeviceProvider.value
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> list:
|
||||
return [
|
||||
{
|
||||
"info": {
|
||||
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": self.arlo_device.get("firmwareVersion"),
|
||||
"serialNumber": self.arlo_device["deviceId"],
|
||||
},
|
||||
"nativeId": f'{self.arlo_device["deviceId"]}.siren',
|
||||
"name": f'{self.arlo_device["deviceName"]} Siren',
|
||||
"interfaces": [ScryptedInterface.OnOff.value],
|
||||
"type": ScryptedDeviceType.Siren.value,
|
||||
"providerNativeId": self.nativeId,
|
||||
}
|
||||
]
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ScryptedDeviceBase:
|
||||
if not nativeId.startswith(self.nativeId):
|
||||
# must be a camera, so get it from the provider
|
||||
return await self.provider.getDevice(nativeId)
|
||||
|
||||
if nativeId.endswith("siren"):
|
||||
if not self.siren:
|
||||
self.siren = ArloSiren(nativeId, self.arlo_device, self.arlo_basestation, self.provider)
|
||||
return self.siren
|
||||
|
||||
return None
|
||||
@@ -1,47 +1,30 @@
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import scrypted_arlo_go
|
||||
|
||||
import scrypted_sdk
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Settings, Camera, VideoCamera, MotionSensor, Battery, ScryptedMimeTypes, ScryptedInterface
|
||||
from scrypted_sdk.types import Settings, Camera, VideoCamera, MotionSensor, Battery, MediaObject, ScryptedMimeTypes, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .device_base import ArloDeviceBase
|
||||
from .provider import ArloProvider
|
||||
from .child_process import HeartbeatChildProcess
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
|
||||
|
||||
class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor, Battery, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
timeout = 30
|
||||
nativeId = None
|
||||
arlo_device = None
|
||||
arlo_basestation = None
|
||||
provider = None
|
||||
class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, MotionSensor, Battery):
|
||||
timeout: int = 30
|
||||
intercom_session = None
|
||||
|
||||
def __init__(self, nativeId, arlo_device, arlo_basestation, provider):
|
||||
super().__init__(nativeId=nativeId)
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
|
||||
|
||||
self.logger_name = nativeId
|
||||
|
||||
self.nativeId = nativeId
|
||||
self.arlo_device = arlo_device
|
||||
self.arlo_basestation = arlo_basestation
|
||||
self.provider = provider
|
||||
self.logger.setLevel(self.provider.get_current_log_level())
|
||||
|
||||
self.intercom_session = None
|
||||
|
||||
self.stop_subscriptions = False
|
||||
self.start_motion_subscription()
|
||||
self.start_battery_subscription()
|
||||
|
||||
def __del__(self):
|
||||
self.stop_subscriptions = True
|
||||
self.cancel_pending_tasks()
|
||||
|
||||
def start_motion_subscription(self):
|
||||
def start_motion_subscription(self) -> None:
|
||||
def callback(motionDetected):
|
||||
self.motionDetected = motionDetected
|
||||
return self.stop_subscriptions
|
||||
@@ -50,7 +33,7 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
self.provider.arlo.SubscribeToMotionEvents(self.arlo_basestation, self.arlo_device, callback)
|
||||
)
|
||||
|
||||
def start_battery_subscription(self):
|
||||
def start_battery_subscription(self) -> None:
|
||||
def callback(batteryLevel):
|
||||
self.batteryLevel = batteryLevel
|
||||
return self.stop_subscriptions
|
||||
@@ -82,15 +65,18 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
|
||||
return list(results)
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.Camera.value
|
||||
|
||||
@property
|
||||
def webrtc_emulation(self):
|
||||
def webrtc_emulation(self) -> bool:
|
||||
if self.storage:
|
||||
return self.storage.getItem("webrtc_emulation")
|
||||
return True if self.storage.getItem("webrtc_emulation") else False
|
||||
else:
|
||||
return False
|
||||
|
||||
@property
|
||||
def two_way_audio(self):
|
||||
def two_way_audio(self) -> bool:
|
||||
if self.storage:
|
||||
val = self.storage.getItem("two_way_audio")
|
||||
if val is None:
|
||||
@@ -99,7 +85,7 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
else:
|
||||
return True
|
||||
|
||||
async def getSettings(self):
|
||||
async def getSettings(self) -> list:
|
||||
if self._can_push_to_talk():
|
||||
return [
|
||||
{
|
||||
@@ -120,15 +106,15 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
]
|
||||
return []
|
||||
|
||||
async def putSetting(self, key, value):
|
||||
async def putSetting(self, key, value) -> None:
|
||||
if key in ["webrtc_emulation", "two_way_audio"]:
|
||||
self.storage.setItem(key, value == "true")
|
||||
await self.provider.discoverDevices()
|
||||
|
||||
async def getPictureOptions(self):
|
||||
async def getPictureOptions(self) -> list:
|
||||
return []
|
||||
|
||||
async def takePicture(self, options=None):
|
||||
async def takePicture(self, options: dict = None) -> MediaObject:
|
||||
self.logger.info("Taking picture")
|
||||
|
||||
real_device = await scrypted_sdk.systemManager.api.getDeviceById(self.getScryptedProperty("id"))
|
||||
@@ -145,7 +131,7 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
|
||||
return await scrypted_sdk.mediaManager.createMediaObject(str.encode(pic_url), ScryptedMimeTypes.Url.value)
|
||||
|
||||
async def getVideoStreamOptions(self):
|
||||
async def getVideoStreamOptions(self) -> list:
|
||||
return [
|
||||
{
|
||||
"id": 'default',
|
||||
@@ -163,16 +149,29 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
}
|
||||
]
|
||||
|
||||
async def _getVideoStreamURL(self):
|
||||
async def _getVideoStreamURL(self) -> str:
|
||||
self.logger.info("Requesting stream")
|
||||
rtsp_url = await asyncio.wait_for(self.provider.arlo.StartStream(self.arlo_basestation, self.arlo_device), timeout=self.timeout)
|
||||
self.logger.debug(f"Got stream URL at {rtsp_url}")
|
||||
return rtsp_url
|
||||
|
||||
async def getVideoStream(self, options=None):
|
||||
async def getVideoStream(self, options: dict = None) -> MediaObject:
|
||||
self.logger.debug("Entered getVideoStream")
|
||||
rtsp_url = await self._getVideoStreamURL()
|
||||
return await scrypted_sdk.mediaManager.createMediaObject(str.encode(rtsp_url), ScryptedMimeTypes.Url.value)
|
||||
|
||||
mso = (await self.getVideoStreamOptions())[0]
|
||||
mso['refreshAt'] = round(time.time() * 1000) + 30 * 60 * 1000
|
||||
|
||||
ffmpeg_input = {
|
||||
'url': rtsp_url,
|
||||
'container': 'rtsp',
|
||||
'mediaStreamOptions': mso,
|
||||
'inputArguments': [
|
||||
'-f', 'rtsp',
|
||||
'-i', rtsp_url,
|
||||
]
|
||||
}
|
||||
return await scrypted_sdk.mediaManager.createFFmpegMediaObject(ffmpeg_input)
|
||||
|
||||
async def startRTCSignalingSession(self, scrypted_session):
|
||||
try:
|
||||
@@ -330,7 +329,7 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
|
||||
self.logger.info("Initializing push to talk")
|
||||
|
||||
session_id, ice_servers = self.provider.arlo.StartPushToTalk(self.arlo_basestation, self.arlo_device)
|
||||
self.logger.debug(f"Received ice servers: {[ice['url'] for ice in ice_servers]}")
|
||||
self.logger.debug(f"Received ice servers: {[ice['url'] for ice in ice_servers]}")
|
||||
|
||||
cfg = scrypted_arlo_go.WebRTCConfiguration(
|
||||
ICEServers=scrypted_arlo_go.Slice_webrtc_ICEServer([
|
||||
@@ -372,7 +371,7 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
|
||||
self.logger.debug("Starting audio track forwarder")
|
||||
self.scrypted_pc.ForwardAudioTo(self.arlo_pc)
|
||||
self.logger.debug("Started audio track forwarder")
|
||||
|
||||
|
||||
self.sdp_answered = False
|
||||
|
||||
offer = self.arlo_pc.CreateOffer()
|
||||
|
||||
59
plugins/arlo/src/arlo_plugin/device_base.py
Normal file
59
plugins/arlo/src/arlo_plugin/device_base.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
from .provider import ArloProvider
|
||||
|
||||
class ArloDeviceBase(ScryptedDeviceBase, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
nativeId: str = None
|
||||
arlo_device: dict = None
|
||||
arlo_basestation: dict = None
|
||||
provider: ArloProvider = None
|
||||
stop_subscriptions: bool = False
|
||||
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None:
|
||||
super().__init__(nativeId=nativeId)
|
||||
|
||||
self.logger_name = nativeId
|
||||
|
||||
self.nativeId = nativeId
|
||||
self.arlo_device = arlo_device
|
||||
self.arlo_basestation = arlo_basestation
|
||||
self.provider = provider
|
||||
self.logger.setLevel(self.provider.get_current_log_level())
|
||||
|
||||
def __del__(self):
|
||||
self.stop_subscriptions = True
|
||||
self.cancel_pending_tasks()
|
||||
|
||||
def get_applicable_interfaces(self) -> list:
|
||||
"""Returns the list of Scrypted interfaces that applies to this device."""
|
||||
return []
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
"""Returns the Scrypted device type that applies to this device."""
|
||||
return ""
|
||||
|
||||
def get_device_manifest(self) -> dict:
|
||||
"""Returns the Scrypted device manifest representing this device."""
|
||||
parent = None
|
||||
if self.arlo_device.get("parentId") and self.arlo_device["parentId"] != self.arlo_device["deviceId"]:
|
||||
parent = self.arlo_device["parentId"]
|
||||
|
||||
return {
|
||||
"info": {
|
||||
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": self.arlo_device.get("firmwareVersion"),
|
||||
"serialNumber": self.arlo_device["deviceId"],
|
||||
},
|
||||
"nativeId": self.arlo_device["deviceId"],
|
||||
"name": self.arlo_device["deviceName"],
|
||||
"interfaces": self.get_applicable_interfaces(),
|
||||
"type": self.get_device_type(),
|
||||
"providerNativeId": parent,
|
||||
}
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> list:
|
||||
"""Returns the list of child device manifests representing hardware features built into this device."""
|
||||
return []
|
||||
@@ -1,29 +1,29 @@
|
||||
from scrypted_sdk.types import BinarySensor, ScryptedInterface
|
||||
|
||||
from .camera import ArloCamera
|
||||
from .provider import ArloProvider
|
||||
|
||||
|
||||
class ArloDoorbell(ArloCamera, BinarySensor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
|
||||
|
||||
self.start_doorbell_subscription()
|
||||
|
||||
def start_doorbell_subscription(self):
|
||||
def start_doorbell_subscription(self) -> None:
|
||||
def callback(doorbellPressed):
|
||||
self.binaryState = doorbellPressed
|
||||
return self.stop_subscriptions
|
||||
|
||||
|
||||
self.register_task(
|
||||
self.provider.arlo.SubscribeToDoorbellEvents(self.arlo_basestation, self.arlo_device, callback)
|
||||
)
|
||||
|
||||
def get_applicable_interfaces(self):
|
||||
def get_applicable_interfaces(self) -> list:
|
||||
camera_interfaces = super().get_applicable_interfaces()
|
||||
camera_interfaces.append(ScryptedInterface.BinarySensor.value)
|
||||
|
||||
model_id = self.arlo_device['properties']['modelId'].lower()
|
||||
model_id = self.arlo_device['modelId'].lower()
|
||||
if model_id.startswith("avd1001"):
|
||||
camera_interfaces.remove(ScryptedInterface.Battery.value)
|
||||
return camera_interfaces
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
class ScryptedDeviceLoggingWrapper(logging.Handler):
|
||||
@@ -20,7 +19,7 @@ def createScryptedLogger(scrypted_device, name):
|
||||
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# configure logger to output to scrypted's log stream
|
||||
# configure logger to output to scrypted's log stream
|
||||
sh = ScryptedDeviceLoggingWrapper(scrypted_device)
|
||||
|
||||
# log formatting
|
||||
|
||||
@@ -14,9 +14,7 @@ from scrypted_sdk.types import Settings, DeviceProvider, DeviceDiscovery, Scrypt
|
||||
from .arlo import Arlo
|
||||
from .arlo.arlo_async import change_stream_class
|
||||
from .arlo.logging import logger as arlo_lib_logger
|
||||
from .camera import ArloCamera
|
||||
from .doorbell import ArloDoorbell
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
|
||||
|
||||
@@ -37,7 +35,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
|
||||
mfa_strategy_choices = ["Manual", "IMAP"]
|
||||
|
||||
def __init__(self, nativeId=None):
|
||||
def __init__(self, nativeId: str = None) -> None:
|
||||
super().__init__(nativeId=nativeId)
|
||||
self.logger_name = "provider"
|
||||
|
||||
@@ -60,28 +58,28 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
asyncio.get_event_loop().call_soon(load, self)
|
||||
self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None))
|
||||
|
||||
def print(self, *args, **kwargs):
|
||||
def print(self, *args, **kwargs) -> None:
|
||||
"""Overrides the print() from ScryptedDeviceBase to avoid double-printing in the main plugin console."""
|
||||
print(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def arlo_username(self):
|
||||
def arlo_username(self) -> str:
|
||||
return self.storage.getItem("arlo_username")
|
||||
|
||||
@property
|
||||
def arlo_password(self):
|
||||
def arlo_password(self) -> str:
|
||||
return self.storage.getItem("arlo_password")
|
||||
|
||||
@property
|
||||
def arlo_auth_headers(self):
|
||||
def arlo_auth_headers(self) -> str:
|
||||
return self.storage.getItem("arlo_auth_headers")
|
||||
|
||||
@property
|
||||
def arlo_user_id(self):
|
||||
def arlo_user_id(self) -> str:
|
||||
return self.storage.getItem("arlo_user_id")
|
||||
|
||||
@property
|
||||
def arlo_transport(self):
|
||||
def arlo_transport(self) -> str:
|
||||
transport = self.storage.getItem("arlo_transport")
|
||||
if transport is None or transport not in ArloProvider.arlo_transport_choices:
|
||||
transport = "SSE"
|
||||
@@ -89,7 +87,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return transport
|
||||
|
||||
@property
|
||||
def plugin_verbosity(self):
|
||||
def plugin_verbosity(self) -> str:
|
||||
verbosity = self.storage.getItem("plugin_verbosity")
|
||||
if verbosity is None or verbosity not in ArloProvider.plugin_verbosity_choices:
|
||||
verbosity = "Normal"
|
||||
@@ -97,7 +95,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return verbosity
|
||||
|
||||
@property
|
||||
def mfa_strategy(self):
|
||||
def mfa_strategy(self) -> str:
|
||||
strategy = self.storage.getItem("mfa_strategy")
|
||||
if strategy is None or strategy not in ArloProvider.mfa_strategy_choices:
|
||||
strategy = "Manual"
|
||||
@@ -105,7 +103,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return strategy
|
||||
|
||||
@property
|
||||
def refresh_interval(self):
|
||||
def refresh_interval(self) -> int:
|
||||
interval = self.storage.getItem("refresh_interval")
|
||||
if interval is None:
|
||||
interval = 90
|
||||
@@ -113,11 +111,11 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return int(interval)
|
||||
|
||||
@property
|
||||
def imap_mfa_host(self):
|
||||
def imap_mfa_host(self) -> str:
|
||||
return self.storage.getItem("imap_mfa_host")
|
||||
|
||||
@property
|
||||
def imap_mfa_port(self):
|
||||
def imap_mfa_port(self) -> int:
|
||||
port = self.storage.getItem("imap_mfa_port")
|
||||
if port is None:
|
||||
port = 993
|
||||
@@ -125,23 +123,23 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return int(port)
|
||||
|
||||
@property
|
||||
def imap_mfa_username(self):
|
||||
def imap_mfa_username(self) -> str:
|
||||
return self.storage.getItem("imap_mfa_username")
|
||||
|
||||
@property
|
||||
def imap_mfa_password(self):
|
||||
def imap_mfa_password(self) -> str:
|
||||
return self.storage.getItem("imap_mfa_password")
|
||||
|
||||
@property
|
||||
def imap_mfa_interval(self):
|
||||
def imap_mfa_interval(self) -> int:
|
||||
interval = self.storage.getItem("imap_mfa_interval")
|
||||
if interval is None:
|
||||
interval = 7
|
||||
interval = 7
|
||||
self.storage.setItem("imap_mfa_interval", interval)
|
||||
return int(interval)
|
||||
|
||||
@property
|
||||
def arlo(self):
|
||||
def arlo(self) -> Arlo:
|
||||
if self._arlo is not None:
|
||||
if self._arlo_mfa_complete_auth is not None:
|
||||
if self._arlo_mfa_code == "":
|
||||
@@ -149,7 +147,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
|
||||
self.logger.info("Completing Arlo MFA...")
|
||||
self._arlo_mfa_complete_auth(self._arlo_mfa_code)
|
||||
self._arlo_mfa_complete_auth = None
|
||||
self._arlo_mfa_complete_auth = None
|
||||
self._arlo_mfa_code = None
|
||||
self.logger.info("Arlo MFA done")
|
||||
|
||||
@@ -162,7 +160,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
|
||||
if not self.arlo_username or not self.arlo_password:
|
||||
return None
|
||||
|
||||
|
||||
self.logger.info("Trying to initialize Arlo client...")
|
||||
try:
|
||||
self._arlo = Arlo(self.arlo_username, self.arlo_password)
|
||||
@@ -183,7 +181,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self._arlo_mfa_code = None
|
||||
return None
|
||||
|
||||
async def do_arlo_setup(self):
|
||||
async def do_arlo_setup(self) -> None:
|
||||
try:
|
||||
await self.discoverDevices()
|
||||
await self.arlo.Subscribe([
|
||||
@@ -204,7 +202,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
def invalidate_arlo_client(self):
|
||||
def invalidate_arlo_client(self) -> None:
|
||||
if self._arlo is not None:
|
||||
self._arlo.Unsubscribe()
|
||||
self._arlo = None
|
||||
@@ -213,10 +211,10 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.storage.setItem("arlo_auth_headers", "")
|
||||
self.storage.setItem("arlo_user_id", "")
|
||||
|
||||
def get_current_log_level(self):
|
||||
def get_current_log_level(self) -> int:
|
||||
return ArloProvider.plugin_verbosity_choices[self.plugin_verbosity]
|
||||
|
||||
def propagate_verbosity(self):
|
||||
def propagate_verbosity(self) -> None:
|
||||
self.print(f"Setting plugin verbosity to {self.plugin_verbosity}")
|
||||
log_level = self.get_current_log_level()
|
||||
self.logger.setLevel(log_level)
|
||||
@@ -224,11 +222,11 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
device.logger.setLevel(log_level)
|
||||
arlo_lib_logger.setLevel(log_level)
|
||||
|
||||
def propagate_transport(self):
|
||||
def propagate_transport(self) -> None:
|
||||
self.print(f"Setting plugin transport to {self.arlo_transport}")
|
||||
change_stream_class(self.arlo_transport)
|
||||
|
||||
def initialize_imap(self):
|
||||
def initialize_imap(self) -> None:
|
||||
if not self.imap_mfa_host or not self.imap_mfa_port or \
|
||||
not self.imap_mfa_username or not self.imap_mfa_password or \
|
||||
not self.imap_mfa_interval:
|
||||
@@ -245,7 +243,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
res, _ = self.imap.select(mailbox="INBOX", readonly=True)
|
||||
if res.lower() != "ok":
|
||||
raise Exception(f"IMAP failed to fetch INBOX: {res}")
|
||||
|
||||
|
||||
# fetch existing arlo emails so we skip them going forward
|
||||
res, self.imap_skip_emails = self.imap.search(None, "FROM", "do_not_reply@arlo.com")
|
||||
if res.lower() != "ok":
|
||||
@@ -258,14 +256,14 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.imap_signal = asyncio.Queue()
|
||||
self.create_task(self.imap_relogin_loop())
|
||||
|
||||
def exit_imap(self):
|
||||
def exit_imap(self) -> None:
|
||||
if self.imap_signal:
|
||||
self.imap_signal.put_nowait(None)
|
||||
self.imap_signal = None
|
||||
self.imap_skip_emails = None
|
||||
self.imap = None
|
||||
|
||||
async def imap_relogin_loop(self):
|
||||
async def imap_relogin_loop(self) -> None:
|
||||
imap_signal = self.imap_signal
|
||||
self.logger.info(f"Starting IMAP refresh loop {id(imap_signal)}")
|
||||
while True:
|
||||
@@ -368,7 +366,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.logger.info(f"Exiting IMAP refresh loop {id(imap_signal)}")
|
||||
return
|
||||
|
||||
async def getSettings(self):
|
||||
async def getSettings(self) -> list:
|
||||
results = [
|
||||
{
|
||||
"group": "General",
|
||||
@@ -447,7 +445,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
"value": self.imap_mfa_interval,
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
results.extend([
|
||||
{
|
||||
"group": "General",
|
||||
@@ -479,7 +477,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
|
||||
return results
|
||||
|
||||
async def putSetting(self, key, value):
|
||||
async def putSetting(self, key, value) -> None:
|
||||
if not self.validate_setting(key, value):
|
||||
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
|
||||
return
|
||||
@@ -525,7 +523,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
_ = self.arlo
|
||||
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
|
||||
|
||||
def validate_setting(self, key, val):
|
||||
def validate_setting(self, key: str, val: str) -> bool:
|
||||
if key == "refresh_interval":
|
||||
try:
|
||||
val = int(val)
|
||||
@@ -555,7 +553,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return False
|
||||
return True
|
||||
|
||||
async def discoverDevices(self, duration=0):
|
||||
async def discoverDevices(self, duration: int = 0) -> None:
|
||||
if not self.arlo:
|
||||
raise Exception("Arlo client not connected, cannot discover devices")
|
||||
|
||||
@@ -564,70 +562,109 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.arlo_basestations = {}
|
||||
self.scrypted_devices = {}
|
||||
|
||||
camera_devices = []
|
||||
provider_to_device_map = {}
|
||||
|
||||
basestations = self.arlo.GetDevices(['basestation', 'siren'])
|
||||
for basestation in basestations:
|
||||
self.arlo_basestations[basestation["deviceId"]] = basestation
|
||||
nativeId = basestation["deviceId"]
|
||||
|
||||
if nativeId in self.arlo_basestations:
|
||||
self.logger.info(f"Skipping basestation {nativeId} as it already exists")
|
||||
continue
|
||||
self.arlo_basestations[nativeId] = basestation
|
||||
|
||||
device = await self.getDevice(nativeId)
|
||||
scrypted_interfaces = device.get_applicable_interfaces()
|
||||
manifest = device.get_device_manifest()
|
||||
self.logger.debug(f"Interfaces for {nativeId} ({basestation['modelId']}): {scrypted_interfaces}")
|
||||
|
||||
# for basestations, we want to add them to the top level DeviceProvider
|
||||
provider_to_device_map.setdefault(None, []).append(manifest)
|
||||
|
||||
# add any builtin child devices
|
||||
provider_to_device_map.setdefault(nativeId, []).extend(device.get_builtin_child_device_manifests())
|
||||
|
||||
# we also want to trickle discover them so they are added without deleting all existing
|
||||
# root level devices - this is for backward compatibility
|
||||
await scrypted_sdk.deviceManager.onDeviceDiscovered(manifest)
|
||||
self.logger.info(f"Discovered {len(basestations)} basestations")
|
||||
|
||||
devices = []
|
||||
cameras = self.arlo.GetDevices(['camera', "arloq", "arloqs", "doorbell"])
|
||||
for camera in cameras:
|
||||
if camera["deviceId"] != camera["parentId"] and camera["parentId"] not in self.arlo_basestations:
|
||||
self.logger.info(f"Skipping camera {camera['deviceId']} because its basestation was not found")
|
||||
continue
|
||||
|
||||
if camera["deviceId"] == camera["parentId"]:
|
||||
self.arlo_basestations[camera["deviceId"]] = camera
|
||||
|
||||
nativeId = camera["deviceId"]
|
||||
if nativeId in self.arlo_cameras:
|
||||
self.logger.info(f"Skipping camera {nativeId} as it already exists")
|
||||
continue
|
||||
self.arlo_cameras[nativeId] = camera
|
||||
|
||||
scrypted_interfaces = (await self.getDevice(nativeId)).get_applicable_interfaces()
|
||||
device = await self.getDevice(nativeId)
|
||||
scrypted_interfaces = device.get_applicable_interfaces()
|
||||
manifest = device.get_device_manifest()
|
||||
self.logger.debug(f"Interfaces for {nativeId} ({camera['modelId']}): {scrypted_interfaces}")
|
||||
|
||||
device = {
|
||||
"info": {
|
||||
"model": f"{camera['properties']['modelId']} ({camera['properties'].get('hwVersion', '')})".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": camera.get("firmwareVersion"),
|
||||
"serialNumber": camera["deviceId"],
|
||||
},
|
||||
"nativeId": camera["deviceId"],
|
||||
"name": camera["deviceName"],
|
||||
"interfaces": scrypted_interfaces,
|
||||
"type": ScryptedDeviceType.Camera.value,
|
||||
"providerNativeId": self.nativeId,
|
||||
}
|
||||
if camera["deviceId"] == camera["parentId"]:
|
||||
# these are standalone cameras with no basestation, so they act as their
|
||||
# own basestation
|
||||
self.arlo_basestations[camera["deviceId"]] = camera
|
||||
provider_to_device_map.setdefault(None, []).append(manifest)
|
||||
else:
|
||||
provider_to_device_map.setdefault(camera["parentId"], []).append(manifest)
|
||||
|
||||
devices.append(device)
|
||||
# add any builtin child devices
|
||||
provider_to_device_map.setdefault(nativeId, []).extend(device.get_builtin_child_device_manifests())
|
||||
|
||||
await scrypted_sdk.deviceManager.onDevicesChanged({
|
||||
"devices": devices,
|
||||
})
|
||||
camera_devices.append(manifest)
|
||||
|
||||
if len(cameras) != len(devices):
|
||||
self.logger.info(f"Discovered {len(cameras)} cameras, but only {len(devices)} are usable")
|
||||
if len(cameras) != len(camera_devices):
|
||||
self.logger.info(f"Discovered {len(cameras)} cameras, but only {len(camera_devices)} are usable")
|
||||
else:
|
||||
self.logger.info(f"Discovered {len(cameras)} cameras")
|
||||
|
||||
async def getDevice(self, nativeId):
|
||||
for provider_id in provider_to_device_map.keys():
|
||||
if provider_id is None:
|
||||
continue
|
||||
await scrypted_sdk.deviceManager.onDevicesChanged({
|
||||
"devices": provider_to_device_map[provider_id],
|
||||
"providerNativeId": provider_id,
|
||||
})
|
||||
|
||||
# ensure devices at the root match all that was discovered
|
||||
await scrypted_sdk.deviceManager.onDevicesChanged({
|
||||
"devices": provider_to_device_map[None]
|
||||
})
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ScryptedDeviceBase:
|
||||
ret = self.scrypted_devices.get(nativeId, None)
|
||||
if ret is None:
|
||||
ret = self.create_camera(nativeId)
|
||||
ret = self.create_device(nativeId)
|
||||
if ret is not None:
|
||||
self.scrypted_devices[nativeId] = ret
|
||||
return ret
|
||||
|
||||
def create_camera(self, nativeId):
|
||||
if nativeId not in self.arlo_cameras:
|
||||
return None
|
||||
arlo_camera = self.arlo_cameras[nativeId]
|
||||
def create_device(self, nativeId: str) -> ScryptedDeviceBase:
|
||||
from .camera import ArloCamera
|
||||
from .doorbell import ArloDoorbell
|
||||
from .basestation import ArloBasestation
|
||||
|
||||
if arlo_camera["parentId"] not in self.arlo_basestations:
|
||||
if nativeId not in self.arlo_cameras and nativeId not in self.arlo_basestations:
|
||||
return None
|
||||
arlo_basestation = self.arlo_basestations[arlo_camera["parentId"]]
|
||||
|
||||
if arlo_camera["deviceType"] == "doorbell":
|
||||
return ArloDoorbell(nativeId, arlo_camera, arlo_basestation, self)
|
||||
arlo_device = self.arlo_cameras.get(nativeId)
|
||||
if not arlo_device:
|
||||
# this is a basestation, so build the basestation object
|
||||
arlo_device = self.arlo_basestations[nativeId]
|
||||
return ArloBasestation(nativeId, arlo_device, arlo_device, self)
|
||||
|
||||
if arlo_device["parentId"] not in self.arlo_basestations:
|
||||
return None
|
||||
arlo_basestation = self.arlo_basestations[arlo_device["parentId"]]
|
||||
|
||||
if arlo_device["deviceType"] == "doorbell":
|
||||
return ArloDoorbell(nativeId, arlo_device, arlo_basestation, self)
|
||||
else:
|
||||
return ArloCamera(nativeId, arlo_camera, arlo_basestation, self)
|
||||
return ArloCamera(nativeId, arlo_device, arlo_basestation, self)
|
||||
17
plugins/arlo/src/arlo_plugin/siren.py
Normal file
17
plugins/arlo/src/arlo_plugin/siren.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from scrypted_sdk.types import OnOff, ScryptedInterface
|
||||
|
||||
from .device_base import ArloDeviceBase
|
||||
|
||||
|
||||
class ArloSiren(ArloDeviceBase, OnOff):
|
||||
|
||||
def get_applicable_interfaces(self) -> list:
|
||||
return [ScryptedInterface.OnOff.value]
|
||||
|
||||
async def turnOn(self) -> None:
|
||||
self.logger.info("Turning on")
|
||||
self.provider.arlo.SirenOn(self.arlo_device)
|
||||
|
||||
async def turnOff(self) -> None:
|
||||
self.logger.info("Turning off")
|
||||
self.provider.arlo.SirenOff(self.arlo_device)
|
||||
4680
plugins/bticino/package-lock.json
generated
4680
plugins/bticino/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,49 @@
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.5",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
|
||||
"prescrypted-vscode-launch": "scrypted-webpack",
|
||||
"scrypted-vscode-launch": "scrypted-deploy-debug",
|
||||
"scrypted-deploy-debug": "scrypted-deploy-debug",
|
||||
"scrypted-debug": "scrypted-debug",
|
||||
"scrypted-deploy": "scrypted-deploy",
|
||||
"scrypted-readme": "scrypted-readme",
|
||||
"scrypted-package-json": "scrypted-package-json"
|
||||
},
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
"plugin",
|
||||
"sip"
|
||||
],
|
||||
"scrypted": {
|
||||
"name": "BTicino SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@homebridge/camera-utils": "^2.0.4",
|
||||
"rxjs": "^7.5.5",
|
||||
"sdp": "^3.0.3",
|
||||
"sip": "0.0.6",
|
||||
"stun": "^2.1.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.7",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "cross-env NODE_ENV=production scrypted-webpack",
|
||||
"prescrypted-vscode-launch": "scrypted-webpack",
|
||||
"scrypted-vscode-launch": "scrypted-deploy-debug",
|
||||
"scrypted-deploy-debug": "scrypted-deploy-debug",
|
||||
"scrypted-debug": "scrypted-debug",
|
||||
"scrypted-deploy": "scrypted-deploy",
|
||||
"scrypted-readme": "scrypted-readme",
|
||||
"scrypted-package-json": "scrypted-package-json"
|
||||
},
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
"plugin",
|
||||
"sip"
|
||||
],
|
||||
"scrypted": {
|
||||
"name": "BTicino SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@slyoldfox/sip": "^0.0.6-1",
|
||||
"sdp": "^3.0.3",
|
||||
"stun": "^2.1.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
407
plugins/bticino/src/bticino-camera.ts
Normal file
407
plugins/bticino/src/bticino-camera.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { closeQuiet, createBindZero, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { sleep } from '@scrypted/common/src/sleep';
|
||||
import { RtspServer } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls } from '@scrypted/common/src/sdp-utils';
|
||||
import sdk, { BinarySensor, Camera, DeviceProvider, FFmpegInput, HttpRequest, HttpRequestHandler, HttpResponse, Intercom, MediaObject, MediaStreamUrl, PictureOptions, ResponseMediaStreamOptions, ScryptedDevice, ScryptedDeviceBase, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoClip, VideoClipOptions, VideoClips } from '@scrypted/sdk';
|
||||
import { SipCallSession } from '../../sip/src/sip-call-session';
|
||||
import { RtpDescription } from '../../sip/src/rtp-utils';
|
||||
import { VoicemailHandler } from './bticino-voicemailHandler';
|
||||
import { CompositeSipMessageHandler } from '../../sip/src/compositeSipMessageHandler';
|
||||
import { SipHelper } from './sip-helper';
|
||||
import child_process, { ChildProcess } from 'child_process';
|
||||
import dgram from 'dgram';
|
||||
import { BticinoStorageSettings } from './storage-settings';
|
||||
import { BticinoSipPlugin } from './main';
|
||||
import { BticinoSipLock } from './bticino-lock';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from '@scrypted/common/src/media-helpers';
|
||||
import { PersistentSipManager } from './persistent-sip-manager';
|
||||
import { InviteHandler } from './bticino-inviteHandler';
|
||||
import { SipRequest } from '../../sip/src/sip-manager';
|
||||
|
||||
import { get } from 'http'
|
||||
|
||||
const STREAM_TIMEOUT = 65000;
|
||||
const { mediaManager } = sdk;
|
||||
|
||||
export class BticinoSipCamera extends ScryptedDeviceBase implements DeviceProvider, Intercom, Camera, VideoCamera, Settings, BinarySensor, HttpRequestHandler, VideoClips {
|
||||
|
||||
private session: SipCallSession
|
||||
private remoteRtpDescription: RtpDescription
|
||||
private audioOutForwarder: dgram.Socket
|
||||
private audioOutProcess: ChildProcess
|
||||
private currentMedia: FFmpegInput | MediaStreamUrl
|
||||
private currentMediaMimeType: string
|
||||
private refreshTimeout: NodeJS.Timeout
|
||||
public requestHandlers: CompositeSipMessageHandler = new CompositeSipMessageHandler()
|
||||
public incomingCallRequest : SipRequest
|
||||
private settingsStorage: BticinoStorageSettings = new BticinoStorageSettings( this )
|
||||
public voicemailHandler : VoicemailHandler = new VoicemailHandler(this)
|
||||
private inviteHandler : InviteHandler = new InviteHandler(this)
|
||||
//TODO: randomize this
|
||||
private keyAndSalt : string = "/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X"
|
||||
//private decodedSrtpOptions : SrtpOptions = decodeSrtpOptions( this.keyAndSalt )
|
||||
private persistentSipManager : PersistentSipManager
|
||||
public doorbellWebhookUrl : string
|
||||
public doorbellLockWebhookUrl : string
|
||||
|
||||
constructor(nativeId: string, public provider: BticinoSipPlugin) {
|
||||
super(nativeId)
|
||||
|
||||
this.requestHandlers.add( this.voicemailHandler ).add( this.inviteHandler )
|
||||
this.persistentSipManager = new PersistentSipManager( this );
|
||||
(async() => {
|
||||
this.doorbellWebhookUrl = await this.doorbellWebhookEndpoint()
|
||||
this.doorbellLockWebhookUrl = await this.doorbellLockWebhookEndpoint()
|
||||
})();
|
||||
}
|
||||
|
||||
getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
|
||||
return new Promise<VideoClip[]>( (resolve,reject ) => {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
if( !c300x ) return []
|
||||
get(`http://${c300x}:8080/videoclips?raw=true&startTime=${options.startTime/1000}&endTime=${options.endTime/1000}`, (res) => {
|
||||
let rawData = '';
|
||||
res.on('data', (chunk) => { rawData += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsedData : [] = JSON.parse(rawData);
|
||||
let videoClips : VideoClip[] = []
|
||||
parsedData.forEach( (item) => {
|
||||
let videoClip : VideoClip = {
|
||||
id: item['file'],
|
||||
startTime: parseInt(item['info']['UnixTime']) * 1000,
|
||||
duration: item['info']['Duration'] * 1000,
|
||||
//description: item['info']['Date'],
|
||||
thumbnailId: item['file']
|
||||
|
||||
}
|
||||
videoClips.push( videoClip )
|
||||
} )
|
||||
return resolve(videoClips)
|
||||
} catch (e) {
|
||||
reject(e.message)
|
||||
console.error(e.message);
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getVideoClip(videoId: string): Promise<MediaObject> {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
const url = `http://${c300x}:8080/voicemail?msg=${videoId}/aswm.avi&raw=true`;
|
||||
return mediaManager.createMediaObjectFromUrl(url);
|
||||
}
|
||||
getVideoClipThumbnail(thumbnailId: string): Promise<MediaObject> {
|
||||
let c300x = SipHelper.sipOptions(this)
|
||||
const url = `http://${c300x}:8080/voicemail?msg=${thumbnailId}/aswm.jpg&raw=true`;
|
||||
return mediaManager.createMediaObjectFromUrl(url);
|
||||
}
|
||||
|
||||
removeVideoClips(...videoClipIds: string[]): Promise<void> {
|
||||
//TODO
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
sipUnlock(): Promise<void> {
|
||||
this.log.i("unlocking C300X door ")
|
||||
return this.persistentSipManager.enable().then( (sipCall) => {
|
||||
sipCall.message( '*8*19*20##' )
|
||||
.then( () =>
|
||||
sleep(1000)
|
||||
.then( () => sipCall.message( '*8*20*20##' ) )
|
||||
)
|
||||
} )
|
||||
}
|
||||
|
||||
getAswmStatus() : Promise<void> {
|
||||
return this.persistentSipManager.enable().then( (sipCall) => {
|
||||
sipCall.message( "GetAswmStatus!" )
|
||||
} )
|
||||
}
|
||||
|
||||
async takePicture(option?: PictureOptions): Promise<MediaObject> {
|
||||
throw new Error("The SIP doorbell camera does not provide snapshots. Install the Snapshot Plugin if snapshots are available via an URL.");
|
||||
}
|
||||
|
||||
async getPictureOptions(): Promise<PictureOptions[]> {
|
||||
return
|
||||
}
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.settingsStorage.getSettings()
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.settingsStorage.putSetting(key, value)
|
||||
}
|
||||
|
||||
async startIntercom(media: MediaObject): Promise<void> {
|
||||
if (!this.session)
|
||||
throw new Error("not in call");
|
||||
|
||||
this.stopIntercom();
|
||||
|
||||
const ffmpegInput: FFmpegInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput)).toString());
|
||||
|
||||
const audioOutForwarder = await createBindZero()
|
||||
this.audioOutForwarder = audioOutForwarder.server
|
||||
audioOutForwarder.server.on('message', message => {
|
||||
if( this.session )
|
||||
this.session.audioSplitter.send(message, 40004, this.remoteRtpDescription.address)
|
||||
return null
|
||||
});
|
||||
|
||||
const args = ffmpegInput.inputArguments.slice();
|
||||
args.push(
|
||||
'-vn', '-dn', '-sn',
|
||||
'-acodec', 'speex',
|
||||
'-flags', '+global_header',
|
||||
'-ac', '1',
|
||||
'-ar', '8k',
|
||||
'-f', 'rtp',
|
||||
//'-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80',
|
||||
//'-srtp_out_params', encodeSrtpOptions(this.decodedSrtpOptions),
|
||||
`rtp://127.0.0.1:${audioOutForwarder.port}?pkt_size=188`,
|
||||
);
|
||||
|
||||
this.console.log("===========================================")
|
||||
safePrintFFmpegArguments( this.console, args )
|
||||
this.console.log("===========================================")
|
||||
|
||||
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), args);
|
||||
ffmpegLogInitialOutput(this.console, cp)
|
||||
this.audioOutProcess = cp;
|
||||
cp.on('exit', () => this.console.log('two way audio ended'));
|
||||
this.session.onCallEnded.subscribe(() => {
|
||||
closeQuiet(audioOutForwarder.server);
|
||||
safeKillFFmpeg(cp)
|
||||
});
|
||||
}
|
||||
|
||||
async stopIntercom(): Promise<void> {
|
||||
closeQuiet(this.audioOutForwarder)
|
||||
this.audioOutProcess?.kill('SIGKILL')
|
||||
this.audioOutProcess = undefined
|
||||
this.audioOutForwarder = undefined
|
||||
}
|
||||
|
||||
resetStreamTimeout() {
|
||||
this.log.d('starting/refreshing stream')
|
||||
clearTimeout(this.refreshTimeout)
|
||||
this.refreshTimeout = setTimeout(() => this.stopSession(), STREAM_TIMEOUT)
|
||||
}
|
||||
|
||||
hasActiveCall() {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
if (this.session) {
|
||||
this.log.d('ending sip session')
|
||||
this.session.stop()
|
||||
this.session = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
|
||||
if( !SipHelper.sipOptions( this ) ) {
|
||||
// Bail out fast when no options are set and someone enables prebuffering
|
||||
throw new Error('Please configure from/to/domain settings')
|
||||
}
|
||||
|
||||
if (options?.metadata?.refreshAt) {
|
||||
if (!this.currentMedia?.mediaStreamOptions)
|
||||
throw new Error("no stream to refresh");
|
||||
|
||||
const currentMedia = this.currentMedia
|
||||
currentMedia.mediaStreamOptions.refreshAt = Date.now() + STREAM_TIMEOUT;
|
||||
currentMedia.mediaStreamOptions.metadata = {
|
||||
refreshAt: currentMedia.mediaStreamOptions.refreshAt
|
||||
};
|
||||
this.resetStreamTimeout()
|
||||
return mediaManager.createMediaObject(currentMedia, this.currentMediaMimeType)
|
||||
}
|
||||
|
||||
this.stopSession();
|
||||
|
||||
|
||||
const { clientPromise: playbackPromise, port: playbackPort, url: clientUrl } = await listenZeroSingleClient()
|
||||
|
||||
const playbackUrl = clientUrl
|
||||
|
||||
playbackPromise.then(async (client) => {
|
||||
client.setKeepAlive(true, 10000)
|
||||
let sip: SipCallSession
|
||||
try {
|
||||
let rtsp: RtspServer;
|
||||
const cleanup = () => {
|
||||
client.destroy();
|
||||
if (this.session === sip)
|
||||
this.session = undefined
|
||||
try {
|
||||
this.log.d('cleanup(): stopping sip session.')
|
||||
sip.stop()
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
rtsp?.destroy()
|
||||
}
|
||||
|
||||
client.on('close', cleanup)
|
||||
client.on('error', cleanup)
|
||||
|
||||
let sipOptions = SipHelper.sipOptions( this )
|
||||
|
||||
sip = await this.persistentSipManager.session( sipOptions );
|
||||
// Validate this sooner
|
||||
if( !sip ) return Promise.reject("Cannot create session")
|
||||
|
||||
sip.onCallEnded.subscribe(cleanup)
|
||||
|
||||
// Call the C300X
|
||||
this.remoteRtpDescription = await sip.callOrAcceptInvite(
|
||||
( audio ) => {
|
||||
return [
|
||||
//TODO: Payload types are hardcoded
|
||||
`m=audio 65000 RTP/SAVP 110`,
|
||||
`a=rtpmap:110 speex/8000`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${this.keyAndSalt}`,
|
||||
]
|
||||
}, ( video ) => {
|
||||
if( false ) {
|
||||
//TODO: implement later
|
||||
return [
|
||||
`m=video 0 RTP/SAVP 0`
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
//TODO: Payload types are hardcoded
|
||||
`m=video 65002 RTP/SAVP 96`,
|
||||
`a=rtpmap:96 H264/90000`,
|
||||
`a=fmtp:96 profile-level-id=42801F`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${this.keyAndSalt}`,
|
||||
'a=recvonly'
|
||||
]
|
||||
}
|
||||
}, this.incomingCallRequest );
|
||||
|
||||
this.incomingCallRequest = undefined
|
||||
|
||||
//let sdp: string = replacePorts(this.remoteRtpDescription.sdp, 0, 0 )
|
||||
let sdp : string = [
|
||||
"v=0",
|
||||
"m=audio 5000 RTP/AVP 110",
|
||||
"c=IN IP4 127.0.0.1",
|
||||
"a=rtpmap:110 speex/8000/1",
|
||||
"m=video 5002 RTP/AVP 96",
|
||||
"c=IN IP4 127.0.0.1",
|
||||
"a=rtpmap:96 H264/90000",
|
||||
].join('\r\n')
|
||||
//sdp = sdp.replaceAll(/a=crypto\:1.*/g, '')
|
||||
//sdp = sdp.replaceAll(/RTP\/SAVP/g, 'RTP\/AVP')
|
||||
//sdp = sdp.replaceAll('\r\n\r\n', '\r\n')
|
||||
sdp = addTrackControls(sdp)
|
||||
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n')
|
||||
if( sipOptions.debugSip )
|
||||
this.log.d('SIP: Updated SDP:\n' + sdp);
|
||||
|
||||
client.write(sdp)
|
||||
client.end()
|
||||
|
||||
this.session = sip
|
||||
}
|
||||
catch (e) {
|
||||
this.console.error(e)
|
||||
sip?.stop()
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
this.resetStreamTimeout();
|
||||
|
||||
const mediaStreamOptions = Object.assign(this.getSipMediaStreamOptions(), {
|
||||
refreshAt: Date.now() + STREAM_TIMEOUT,
|
||||
});
|
||||
|
||||
const ffmpegInput: FFmpegInput = {
|
||||
url: undefined,
|
||||
container: 'sdp',
|
||||
mediaStreamOptions,
|
||||
inputArguments: [
|
||||
'-f', 'sdp',
|
||||
'-i', playbackUrl,
|
||||
],
|
||||
};
|
||||
this.currentMedia = ffmpegInput;
|
||||
this.currentMediaMimeType = ScryptedMimeTypes.FFmpegInput;
|
||||
|
||||
return mediaManager.createFFmpegMediaObject(ffmpegInput);
|
||||
}
|
||||
|
||||
getSipMediaStreamOptions(): ResponseMediaStreamOptions {
|
||||
return {
|
||||
id: 'sip',
|
||||
name: 'SIP',
|
||||
// this stream is NOT scrypted blessed due to wackiness in the h264 stream.
|
||||
// tool: "scrypted",
|
||||
container: 'sdp',
|
||||
audio: {
|
||||
// this is a hint to let homekit, et al, know that it's speex audio and needs transcoding.
|
||||
codec: 'speex',
|
||||
},
|
||||
source: 'cloud', // to disable prebuffering
|
||||
userConfigurable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
||||
return [
|
||||
this.getSipMediaStreamOptions(),
|
||||
]
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string) : Promise<BticinoSipLock> {
|
||||
return new BticinoSipLock(this)
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.console.log("Reset the incoming call request")
|
||||
this.incomingCallRequest = undefined
|
||||
this.binaryState = false
|
||||
}
|
||||
|
||||
public async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
||||
if (request.url.endsWith('/pressed')) {
|
||||
this.binaryState = true
|
||||
setTimeout( () => {
|
||||
// Assumption that flexisip only holds this call active for 20 seconds ... might be revised
|
||||
this.reset()
|
||||
}, 20 * 1000 )
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else {
|
||||
response.send('Unsupported operation', {
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async doorbellWebhookEndpoint(): Promise<string> {
|
||||
let webhookUrl = await sdk.endpointManager.getLocalEndpoint( this.nativeId, { insecure: false, public: true });
|
||||
let endpoints = ["/pressed"]
|
||||
this.console.log( webhookUrl + " , endpoints: " + endpoints.join(' - ') )
|
||||
return `${webhookUrl}`;
|
||||
}
|
||||
|
||||
private async doorbellLockWebhookEndpoint(): Promise<string> {
|
||||
let webhookUrl = await sdk.endpointManager.getLocalEndpoint(this.nativeId + '-lock', { insecure: false, public: true });
|
||||
let endpoints = ["/lock", "/unlock", "/unlocked", "/locked"]
|
||||
this.console.log( webhookUrl + " -> endpoints: " + endpoints.join(' - ') )
|
||||
return `${webhookUrl}`;
|
||||
}
|
||||
}
|
||||
32
plugins/bticino/src/bticino-inviteHandler.ts
Normal file
32
plugins/bticino/src/bticino-inviteHandler.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SipRequestHandler, SipRequest } from "../../sip/src/sip-manager"
|
||||
import { BticinoSipCamera } from "./bticino-camera"
|
||||
import { stringifyUri } from '@slyoldfox/sip'
|
||||
|
||||
export class InviteHandler extends SipRequestHandler {
|
||||
constructor( private sipCamera : BticinoSipCamera ) {
|
||||
super()
|
||||
this.sipCamera.binaryState = false
|
||||
}
|
||||
|
||||
handle(request: SipRequest) {
|
||||
//TODO: restrict this to call from:c300x@ AND to:alluser@ ?
|
||||
if( request.method == 'CANCEL' ) {
|
||||
let reason = request.headers["reason"] ? ( ' - ' + request.headers["reason"] ) : ''
|
||||
this.sipCamera.console.log('CANCEL voice call from: ' + stringifyUri( request.headers.from.uri ) + ' to: ' + stringifyUri( request.headers.to.uri ) + reason )
|
||||
this.sipCamera?.reset()
|
||||
}
|
||||
if( request.method === 'INVITE' ) {
|
||||
this.sipCamera.console.log("INCOMING voice call from: " + stringifyUri( request.headers.from.uri ) + ' to: ' + stringifyUri( request.headers.to.uri ) )
|
||||
|
||||
this.sipCamera.binaryState = true
|
||||
this.sipCamera.incomingCallRequest = request
|
||||
|
||||
setTimeout( () => {
|
||||
// Assumption that flexisip only holds this call active for 20 seconds ... might be revised
|
||||
this.sipCamera?.reset()
|
||||
}, 20 * 1000 )
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
56
plugins/bticino/src/bticino-lock.ts
Normal file
56
plugins/bticino/src/bticino-lock.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import sdk, { ScryptedDeviceBase, Lock, LockState, HttpRequest, HttpResponse, HttpRequestHandler } from "@scrypted/sdk";
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
|
||||
export class BticinoSipLock extends ScryptedDeviceBase implements Lock, HttpRequestHandler {
|
||||
private timeout : NodeJS.Timeout
|
||||
|
||||
constructor(public camera: BticinoSipCamera) {
|
||||
super( camera.nativeId + "-lock")
|
||||
}
|
||||
|
||||
lock(): Promise<void> {
|
||||
if( !this.timeout ) {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.lockState = LockState.Locked
|
||||
this.timeout = undefined
|
||||
} , 3000);
|
||||
} else {
|
||||
this.camera.console.log("Still attempting previous locking ...")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
unlock(): Promise<void> {
|
||||
this.lockState = LockState.Unlocked
|
||||
this.lock()
|
||||
return this.camera.sipUnlock()
|
||||
}
|
||||
|
||||
public async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
||||
if (request.url.endsWith('/unlocked')) {
|
||||
this.lockState = LockState.Unlocked
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/locked') ) {
|
||||
this.lockState = LockState.Locked
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/lock') ) {
|
||||
this.lock();
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/unlock') ) {
|
||||
this.unlock();
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else {
|
||||
response.send('Unsupported operation', {
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
69
plugins/bticino/src/bticino-voicemailHandler.ts
Normal file
69
plugins/bticino/src/bticino-voicemailHandler.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { SipRequestHandler, SipRequest } from "../../sip/src/sip-manager"
|
||||
import { BticinoSipCamera } from "./bticino-camera"
|
||||
|
||||
export class VoicemailHandler extends SipRequestHandler {
|
||||
private timeout : NodeJS.Timeout
|
||||
|
||||
constructor( private sipCamera : BticinoSipCamera ) {
|
||||
super()
|
||||
setTimeout( () => {
|
||||
// Delay a bit an run in a different thread in case this fails
|
||||
this.checkVoicemail()
|
||||
}, 10000 )
|
||||
}
|
||||
|
||||
checkVoicemail() {
|
||||
if( !this.sipCamera )
|
||||
return
|
||||
if( this.isEnabled() ) {
|
||||
this.sipCamera.console.debug("Checking answering machine, cameraId: " + this.sipCamera.id )
|
||||
this.sipCamera.getAswmStatus().catch( e => this.sipCamera.console.error(e) )
|
||||
} else {
|
||||
this.sipCamera.console.debug("Answering machine check not enabled, cameraId: " + this.sipCamera.id )
|
||||
}
|
||||
//TODO: make interval customizable, now every 5 minutes
|
||||
this.timeout = setTimeout( () => this.checkVoicemail() , 5 * 60 * 1000 )
|
||||
}
|
||||
|
||||
cancelVoicemailCheck() {
|
||||
if( this.timeout ) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
handle(request: SipRequest) {
|
||||
if( this.isEnabled() ) {
|
||||
const lastVoicemailMessageTimestamp : number = Number.parseInt( this.sipCamera.storage.getItem('lastVoicemailMessageTimestamp') ) || -1
|
||||
const message : string = request.content.toString()
|
||||
if( message.startsWith('*#8**40*0*0*1176*0*2##') ) {
|
||||
this.sipCamera.console.debug("Handling incoming answering machine reply")
|
||||
const messages : string[] = message.split(';')
|
||||
let lastMessageTimestamp : number = 0
|
||||
let countNewMessages : number = 0
|
||||
messages.forEach( (message, index) => {
|
||||
if( index > 0 ) {
|
||||
const parts = message.split('|')
|
||||
if( parts.length == 4 ) {
|
||||
let messageTimestamp = Number.parseInt( parts[2] )
|
||||
if( messageTimestamp > lastVoicemailMessageTimestamp )
|
||||
countNewMessages++
|
||||
if( index == messages.length-2 )
|
||||
lastMessageTimestamp = messageTimestamp
|
||||
}
|
||||
}
|
||||
} )
|
||||
if( (lastVoicemailMessageTimestamp == null && lastMessageTimestamp > 0) ||
|
||||
( lastVoicemailMessageTimestamp != null && lastMessageTimestamp > lastVoicemailMessageTimestamp ) ) {
|
||||
this.sipCamera.log.a(`You have ${countNewMessages} new voicemail messages.`)
|
||||
this.sipCamera.storage.setItem('lastVoicemailMessageTimestamp', lastMessageTimestamp.toString())
|
||||
} else {
|
||||
this.sipCamera.console.debug("No new messages since: " + lastVoicemailMessageTimestamp + " lastMessage: " + lastMessageTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() : boolean {
|
||||
return this.sipCamera?.storage?.getItem('notifyVoicemail')?.toLocaleLowerCase() === 'true' || false
|
||||
}
|
||||
}
|
||||
@@ -1,379 +1,97 @@
|
||||
import { listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { SipMessageHandler, SipCall, SipOptions, SipRequest } from '../../sip/src/sip-call';
|
||||
import { RtspServer } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls, parseSdp, replacePorts } from '@scrypted/common/src/sdp-utils';
|
||||
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
||||
import sdk, { BinarySensor, Camera, DeviceCreator, DeviceCreatorSettings, DeviceProvider, FFmpegInput, Intercom, MediaObject, MediaStreamUrl, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk';
|
||||
import { SipSession } from '../../sip/src/sip-session';
|
||||
import { isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from '../../sip/src/rtp-utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const STREAM_TIMEOUT = 50000;
|
||||
const SIP_EXPIRATION_DEFAULT = 3600;
|
||||
const { deviceManager, mediaManager } = sdk;
|
||||
|
||||
export class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
|
||||
session: SipSession;
|
||||
currentMedia: FFmpegInput | MediaStreamUrl;
|
||||
currentMediaMimeType: string;
|
||||
refreshTimeout: NodeJS.Timeout;
|
||||
messageHandler: SipMessageHandler;
|
||||
|
||||
constructor(nativeId: string, public provider: SipCamProvider) {
|
||||
super(nativeId);
|
||||
let logger = this.log;
|
||||
this.messageHandler = new class extends SipMessageHandler {
|
||||
handle( request: SipRequest ) {
|
||||
// TODO: implement netatmo.onPresence handling?
|
||||
// {"jsonrpc":"2.0","method":"netatmo.onPresence","params":[{"persons":[]}]}
|
||||
logger.d("remote message: " + request.content );
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
async takePicture(option?: PictureOptions): Promise<MediaObject> {
|
||||
throw new Error("The SIP doorbell camera does not provide snapshots. Install the Snapshot Plugin if snapshots are available via an URL.");
|
||||
}
|
||||
|
||||
async getPictureOptions(): Promise<PictureOptions[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsStorage = new StorageSettings(this, {
|
||||
sipfrom: {
|
||||
title: 'SIP From: URI',
|
||||
type: 'string',
|
||||
value: this.storage.getItem('sipfrom'),
|
||||
description: 'SIP URI From field: Using the IP address of your server you will be calling from. Also the user and IP you added in /etc/flexisip/users/route_ext.conf on the intercom.',
|
||||
placeholder: 'user@192.168.0.111',
|
||||
multiple: false,
|
||||
},
|
||||
sipto: {
|
||||
title: 'SIP To: URI',
|
||||
type: 'string',
|
||||
description: 'SIP URI To field: Must look like c300x@IP;transport=udp;rport and UDP transport is the only one supported right now.',
|
||||
placeholder: 'c300x@192.168.0.2[:5060];transport=udp;rport',
|
||||
},
|
||||
sipdomain: {
|
||||
title: 'SIP domain',
|
||||
type: 'string',
|
||||
description: 'SIP domain: The internal BTicino domain, usually has the following format: 2048362.bs.iotleg.com',
|
||||
placeholder: '2048362.bs.iotleg.com',
|
||||
},
|
||||
sipexpiration: {
|
||||
title: 'SIP UA expiration',
|
||||
type: 'number',
|
||||
range: [60, SIP_EXPIRATION_DEFAULT],
|
||||
description: 'SIP UA expiration: How long the UA should remain active before expiring. Use 3600.',
|
||||
placeholder: '3600',
|
||||
},
|
||||
sipdebug: {
|
||||
title: 'SIP debug logging',
|
||||
type: 'boolean',
|
||||
description: 'Enable SIP debugging',
|
||||
placeholder: 'true or false',
|
||||
},
|
||||
});
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.settingsStorage.getSettings();
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.settingsStorage.putSetting(key, value);
|
||||
}
|
||||
|
||||
async startIntercom(media: MediaObject): Promise<void> {
|
||||
this.log.d( "TODO: startIntercom" + media );
|
||||
}
|
||||
|
||||
async stopIntercom(): Promise<void> {
|
||||
this.log.d( "TODO: stopIntercom" );
|
||||
}
|
||||
|
||||
resetStreamTimeout() {
|
||||
this.log.d('starting/refreshing stream');
|
||||
clearTimeout(this.refreshTimeout);
|
||||
this.refreshTimeout = setTimeout(() => this.stopSession(), STREAM_TIMEOUT);
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
if (this.session) {
|
||||
this.log.d('ending sip session');
|
||||
this.session.stop();
|
||||
this.session = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
|
||||
if (options?.metadata?.refreshAt) {
|
||||
if (!this.currentMedia?.mediaStreamOptions)
|
||||
throw new Error("no stream to refresh");
|
||||
|
||||
const currentMedia = this.currentMedia;
|
||||
currentMedia.mediaStreamOptions.refreshAt = Date.now() + STREAM_TIMEOUT;
|
||||
currentMedia.mediaStreamOptions.metadata = {
|
||||
refreshAt: currentMedia.mediaStreamOptions.refreshAt
|
||||
};
|
||||
this.resetStreamTimeout();
|
||||
return mediaManager.createMediaObject(currentMedia, this.currentMediaMimeType);
|
||||
}
|
||||
|
||||
this.stopSession();
|
||||
|
||||
|
||||
const { clientPromise: playbackPromise, port: playbackPort, url: clientUrl } = await listenZeroSingleClient();
|
||||
|
||||
const playbackUrl = `rtsp://127.0.0.1:${playbackPort}`;
|
||||
|
||||
playbackPromise.then(async (client) => {
|
||||
client.setKeepAlive(true, 10000);
|
||||
let sip: SipSession;
|
||||
try {
|
||||
let rtsp: RtspServer;
|
||||
const cleanup = () => {
|
||||
client.destroy();
|
||||
if (this.session === sip)
|
||||
this.session = undefined;
|
||||
try {
|
||||
this.log.d('cleanup(): stopping sip session.');
|
||||
sip.stop();
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
rtsp?.destroy();
|
||||
}
|
||||
|
||||
client.on('close', cleanup);
|
||||
client.on('error', cleanup);
|
||||
|
||||
const from = this.storage.getItem('sipfrom')?.trim();
|
||||
const to = this.storage.getItem('sipto')?.trim();
|
||||
const localIp = from?.split(':')[0].split('@')[1];
|
||||
const localPort = parseInt(from?.split(':')[1]) || 5060;
|
||||
const domain = this.storage.getItem('sipdomain')?.trim();
|
||||
const expiration : string = this.storage.getItem('sipuaexpiration')?.trim() || '3600';
|
||||
const sipdebug : boolean = this.storage.getItem('sipdebug')?.toLocaleLowerCase() === 'true' || false;
|
||||
|
||||
if (!from || !to || !localIp || !localPort || !domain || !expiration ) {
|
||||
this.log.e('Error: SIP From/To/Domain URIs not specified!');
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO settings
|
||||
let sipOptions : SipOptions = {
|
||||
from: "sip:" + from,
|
||||
to: "sip:" + to,
|
||||
domain: domain,
|
||||
expire: Number.parseInt( expiration ),
|
||||
localIp,
|
||||
localPort,
|
||||
shouldRegister: true,
|
||||
debugSip: sipdebug,
|
||||
messageHandler: this.messageHandler
|
||||
};
|
||||
sip = await SipSession.createSipSession(console, "Bticino", sipOptions);
|
||||
|
||||
sip.onCallEnded.subscribe(cleanup);
|
||||
|
||||
// Call the C300X
|
||||
let remoteRtpDescription = await sip.call(
|
||||
( audio ) => {
|
||||
return [
|
||||
'a=DEVADDR:20', // Needed for bt_answering_machine (bticino specific)
|
||||
`m=audio ${audio.port} RTP/SAVP 97`,
|
||||
`a=rtpmap:97 speex/8000`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
|
||||
]
|
||||
}, ( video ) => {
|
||||
return [
|
||||
`m=video ${video.port} RTP/SAVP 97`,
|
||||
`a=rtpmap:97 H264/90000`,
|
||||
`a=fmtp:97 profile-level-id=42801F`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
|
||||
'a=recvonly'
|
||||
]
|
||||
} );
|
||||
if( sipOptions.debugSip )
|
||||
this.log.d('SIP: Received remote SDP:\n' + remoteRtpDescription.sdp)
|
||||
|
||||
let sdp: string = replacePorts( remoteRtpDescription.sdp, 0, 0 );
|
||||
sdp = addTrackControls(sdp);
|
||||
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n');
|
||||
if( sipOptions.debugSip )
|
||||
this.log.d('SIP: Updated SDP:\n' + sdp);
|
||||
|
||||
let vseq = 0;
|
||||
let vseen = 0;
|
||||
let vlost = 0;
|
||||
let aseq = 0;
|
||||
let aseen = 0;
|
||||
let alost = 0;
|
||||
|
||||
rtsp = new RtspServer(client, sdp, true);
|
||||
const parsedSdp = parseSdp(rtsp.sdp);
|
||||
const videoTrack = parsedSdp.msections.find(msection => msection.type === 'video').control;
|
||||
const audioTrack = parsedSdp.msections.find(msection => msection.type === 'audio').control;
|
||||
if( sipOptions.debugSip ) {
|
||||
rtsp.console = this.console;
|
||||
}
|
||||
|
||||
await rtsp.handlePlayback();
|
||||
sip.videoSplitter.on('message', message => {
|
||||
if (!isStunMessage(message)) {
|
||||
const isRtpMessage = isRtpMessagePayloadType(getPayloadType(message));
|
||||
if (!isRtpMessage)
|
||||
return;
|
||||
vseen++;
|
||||
rtsp.sendTrack(videoTrack, message, !isRtpMessage);
|
||||
const seq = getSequenceNumber(message);
|
||||
if (seq !== (vseq + 1) % 0x0FFFF)
|
||||
vlost++;
|
||||
vseq = seq;
|
||||
}
|
||||
});
|
||||
|
||||
sip.videoRtcpSplitter.on('message', message => {
|
||||
rtsp.sendTrack(videoTrack, message, true);
|
||||
});
|
||||
|
||||
sip.audioSplitter.on('message', message => {
|
||||
if (!isStunMessage(message)) {
|
||||
const isRtpMessage = isRtpMessagePayloadType(getPayloadType(message));
|
||||
if (!isRtpMessage)
|
||||
return;
|
||||
aseen++;
|
||||
rtsp.sendTrack(audioTrack, message, !isRtpMessage);
|
||||
const seq = getSequenceNumber(message);
|
||||
if (seq !== (aseq + 1) % 0x0FFFF)
|
||||
alost++;
|
||||
aseq = seq;
|
||||
}
|
||||
});
|
||||
|
||||
sip.audioRtcpSplitter.on('message', message => {
|
||||
rtsp.sendTrack(audioTrack, message, true);
|
||||
});
|
||||
|
||||
this.session = sip;
|
||||
|
||||
try {
|
||||
await rtsp.handleTeardown();
|
||||
this.log.d('rtsp client ended');
|
||||
}
|
||||
catch (e) {
|
||||
this.log.e('rtsp client ended ungracefully' + e);
|
||||
}
|
||||
finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
sip?.stop();
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
this.resetStreamTimeout();
|
||||
|
||||
const mediaStreamOptions = Object.assign(this.getSipMediaStreamOptions(), {
|
||||
refreshAt: Date.now() + STREAM_TIMEOUT,
|
||||
});
|
||||
|
||||
const mediaStreamUrl: MediaStreamUrl = {
|
||||
url: playbackUrl,
|
||||
mediaStreamOptions,
|
||||
};
|
||||
this.currentMedia = mediaStreamUrl;
|
||||
this.currentMediaMimeType = ScryptedMimeTypes.MediaStreamUrl;
|
||||
|
||||
return mediaManager.createMediaObject(mediaStreamUrl, ScryptedMimeTypes.MediaStreamUrl);
|
||||
}
|
||||
|
||||
getSipMediaStreamOptions(): ResponseMediaStreamOptions {
|
||||
return {
|
||||
id: 'sip',
|
||||
name: 'SIP',
|
||||
// this stream is NOT scrypted blessed due to wackiness in the h264 stream.
|
||||
// tool: "scrypted",
|
||||
container: 'sdp',
|
||||
audio: {
|
||||
// this is a hint to let homekit, et al, know that it's speex audio and needs transcoding.
|
||||
codec: 'speex',
|
||||
},
|
||||
source: 'local',
|
||||
userConfigurable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
||||
return [
|
||||
this.getSipMediaStreamOptions(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
||||
|
||||
devices = new Map<string, any>();
|
||||
|
||||
constructor(nativeId?: string) {
|
||||
super(nativeId);
|
||||
|
||||
for (const camId of deviceManager.getNativeIds()) {
|
||||
if (camId)
|
||||
this.getDevice(camId);
|
||||
}
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
}
|
||||
|
||||
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
||||
const nativeId = randomBytes(4).toString('hex');
|
||||
const name = settings.newCamera.toString();
|
||||
await this.updateDevice(nativeId, name);
|
||||
return nativeId;
|
||||
}
|
||||
|
||||
async getCreateDeviceSettings(): Promise<Setting[]> {
|
||||
return [
|
||||
{
|
||||
key: 'newCamera',
|
||||
title: 'Add Camera',
|
||||
placeholder: 'Camera name, e.g.: Back Yard Camera, Baby Camera, etc',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
updateDevice(nativeId: string, name: string) {
|
||||
return deviceManager.onDeviceDiscovered({
|
||||
nativeId,
|
||||
name,
|
||||
interfaces: [
|
||||
ScryptedInterface.Camera,
|
||||
ScryptedInterface.VideoCamera,
|
||||
ScryptedInterface.Settings,
|
||||
ScryptedInterface.Intercom,
|
||||
ScryptedInterface.BinarySensor
|
||||
],
|
||||
type: ScryptedDeviceType.Doorbell,
|
||||
});
|
||||
}
|
||||
|
||||
getDevice(nativeId: string) {
|
||||
let ret = this.devices.get(nativeId);
|
||||
if (!ret) {
|
||||
ret = this.createCamera(nativeId);
|
||||
if (ret)
|
||||
this.devices.set(nativeId, ret);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
createCamera(nativeId: string): SipCamera {
|
||||
return new SipCamera(nativeId, this);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SipCamProvider();
|
||||
import sdk, { Device, DeviceCreator, DeviceCreatorSettings, DeviceProvider, LockState, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting } from '@scrypted/sdk'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { BticinoSipCamera } from './bticino-camera'
|
||||
|
||||
const { systemManager, deviceManager } = sdk
|
||||
|
||||
export class BticinoSipPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
||||
|
||||
devices = new Map<string, BticinoSipCamera>()
|
||||
|
||||
async getCreateDeviceSettings(): Promise<Setting[]> {
|
||||
return [
|
||||
{
|
||||
key: 'newCamera',
|
||||
title: 'Add Camera',
|
||||
placeholder: 'Camera name, e.g.: Back Yard Camera, Baby Camera, etc',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
||||
const nativeId = randomBytes(4).toString('hex')
|
||||
const name = settings.newCamera?.toString()
|
||||
const camera = await this.updateDevice(nativeId, name)
|
||||
|
||||
const device: Device = {
|
||||
providerNativeId: nativeId,
|
||||
info: {
|
||||
//model: `${camera.model} (${camera.data.kind})`,
|
||||
manufacturer: 'BticinoPlugin',
|
||||
//firmware: camera.data.firmware_version,
|
||||
//serialNumber: camera.data.device_id
|
||||
},
|
||||
nativeId: nativeId + '-lock',
|
||||
name: name + ' Lock',
|
||||
type: ScryptedDeviceType.Lock,
|
||||
interfaces: [ScryptedInterface.Lock, ScryptedInterface.HttpRequestHandler],
|
||||
}
|
||||
|
||||
const ret = await deviceManager.onDevicesChanged({
|
||||
providerNativeId: nativeId,
|
||||
devices: [device],
|
||||
})
|
||||
|
||||
let sipCamera : BticinoSipCamera = await this.getDevice(nativeId)
|
||||
let foo : BticinoSipCamera = systemManager.getDeviceById<BticinoSipCamera>(sipCamera.id)
|
||||
|
||||
let lock = await sipCamera.getDevice(undefined)
|
||||
lock.lockState = LockState.Locked
|
||||
|
||||
return nativeId
|
||||
}
|
||||
|
||||
updateDevice(nativeId: string, name: string) {
|
||||
return deviceManager.onDeviceDiscovered({
|
||||
nativeId,
|
||||
info: {
|
||||
//model: `${camera.model} (${camera.data.kind})`,
|
||||
manufacturer: 'BticinoSipPlugin',
|
||||
//firmware: camera.data.firmware_version,
|
||||
//serialNumber: camera.data.device_id
|
||||
},
|
||||
name,
|
||||
interfaces: [
|
||||
ScryptedInterface.Camera,
|
||||
ScryptedInterface.VideoCamera,
|
||||
ScryptedInterface.Settings,
|
||||
ScryptedInterface.Intercom,
|
||||
ScryptedInterface.BinarySensor,
|
||||
ScryptedDeviceType.DeviceProvider,
|
||||
ScryptedInterface.HttpRequestHandler,
|
||||
ScryptedInterface.VideoClips
|
||||
],
|
||||
type: ScryptedDeviceType.Doorbell,
|
||||
})
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string): Promise<any> {
|
||||
if (!this.devices.has(nativeId)) {
|
||||
const camera = new BticinoSipCamera(nativeId, this)
|
||||
this.devices.set(nativeId, camera)
|
||||
}
|
||||
return this.devices.get(nativeId)
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
let camera = this.devices.get(nativeId)
|
||||
if( camera ) {
|
||||
camera.voicemailHandler.cancelVoicemailCheck()
|
||||
if( this.devices.delete( nativeId ) ) {
|
||||
this.console.log("Removed device from list: " + id + " / " + nativeId )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BticinoSipPlugin()
|
||||
71
plugins/bticino/src/persistent-sip-manager.ts
Normal file
71
plugins/bticino/src/persistent-sip-manager.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { SipCallSession } from "../../sip/src/sip-call-session";
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
import { SipHelper } from "./sip-helper";
|
||||
import { SipManager, SipOptions } from "../../sip/src/sip-manager";
|
||||
|
||||
/**
|
||||
* This class registers itself with the SIP server as a contact for a user account.
|
||||
* The registration expires after the expires time in sipOptions is reached.
|
||||
* The sip session will re-register itself after the expires time is reached.
|
||||
*/
|
||||
const CHECK_INTERVAL : number = 10 * 1000
|
||||
export class PersistentSipManager {
|
||||
|
||||
private sipManager : SipManager
|
||||
private lastRegistration : number = 0
|
||||
private expireInterval : number = 0
|
||||
|
||||
constructor( private camera : BticinoSipCamera ) {
|
||||
// Give it a second and run in seperate thread to avoid failure on creation for from/to/domain check
|
||||
setTimeout( () => this.enable() , CHECK_INTERVAL )
|
||||
}
|
||||
|
||||
async enable() : Promise<SipManager> {
|
||||
if( this.sipManager ) {
|
||||
return this.sipManager
|
||||
} else {
|
||||
return this.register()
|
||||
}
|
||||
}
|
||||
|
||||
private async register() : Promise<SipManager> {
|
||||
let now = Date.now()
|
||||
try {
|
||||
let sipOptions : SipOptions = SipHelper.sipOptions( this.camera )
|
||||
if( Number.isNaN( sipOptions.expire ) || sipOptions.expire <= 0 || sipOptions.expire > 3600 ) {
|
||||
sipOptions.expire = 300
|
||||
}
|
||||
if( this.expireInterval == 0 ) {
|
||||
this.expireInterval = (sipOptions.expire * 1000) - 10000
|
||||
}
|
||||
|
||||
if( !this.camera.hasActiveCall() && now - this.lastRegistration >= this.expireInterval ) {
|
||||
let sipOptions : SipOptions = SipHelper.sipOptions( this.camera )
|
||||
|
||||
this.sipManager?.destroy()
|
||||
this.sipManager = new SipManager(this.camera.console, sipOptions )
|
||||
await this.sipManager.register()
|
||||
|
||||
this.lastRegistration = now
|
||||
|
||||
return this.sipManager;
|
||||
}
|
||||
} catch(e) {
|
||||
this.camera.console.error("Error enabling persistent SIP manager: " + e )
|
||||
// Try again in a minute
|
||||
this.lastRegistration = now + (60 * 1000) - this.expireInterval
|
||||
throw e
|
||||
} finally {
|
||||
setTimeout( () => this.register(), CHECK_INTERVAL )
|
||||
}
|
||||
}
|
||||
|
||||
async session( sipOptions: SipOptions ) : Promise<SipCallSession> {
|
||||
let sm = await this.enable()
|
||||
return SipCallSession.createCallSession(this.camera.console, "Bticino", sipOptions, sm )
|
||||
}
|
||||
|
||||
reloadSipOptions() {
|
||||
this.sipManager?.setSipOptions( null )
|
||||
}
|
||||
}
|
||||
59
plugins/bticino/src/sip-helper.ts
Normal file
59
plugins/bticino/src/sip-helper.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { SipOptions } from "../../sip/src/sip-manager";
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class SipHelper {
|
||||
public static sipOptions( camera : BticinoSipCamera ) : SipOptions {
|
||||
// Might be removed soon?
|
||||
if( camera.storage.getItem('sipto') && camera.storage.getItem('sipto').toString().indexOf(';') > 0 ) {
|
||||
camera.storage.setItem('sipto', camera.storage.getItem('sipto').toString().split(';')[0] )
|
||||
}
|
||||
const from = camera.storage.getItem('sipfrom')?.trim()
|
||||
const to = camera.storage.getItem('sipto')?.trim()
|
||||
const localIp = from?.split(':')[0].split('@')[1]
|
||||
// Although this might not occur directly, each camera should run on its own port
|
||||
// Might need to use a random free port here (?)
|
||||
const localPort = parseInt(from?.split(':')[1]) || 5060
|
||||
const domain = camera.storage.getItem('sipdomain')?.trim()
|
||||
const expiration : string = camera.storage.getItem('sipexpiration')?.trim() || '600'
|
||||
const sipdebug : boolean = camera.storage.getItem('sipdebug')?.toLocaleLowerCase() === 'true' || false
|
||||
|
||||
if (!from || !to || !localIp || !localPort || !domain || !expiration ) {
|
||||
camera.log.e('Error: SIP From/To/Domain URIs not specified!')
|
||||
throw new Error('SIP From/To/Domain URIs not specified!')
|
||||
}
|
||||
|
||||
return {
|
||||
from: "sip:" + from,
|
||||
//TCP is more reliable for large messages, also see useTcp=true below
|
||||
to: "sip:" + to + ";transport=tcp",
|
||||
domain: domain,
|
||||
expire: Number.parseInt( expiration ),
|
||||
localIp,
|
||||
localPort,
|
||||
debugSip: sipdebug,
|
||||
gruuInstanceId: SipHelper.getGruuInstanceId(camera),
|
||||
useTcp: true,
|
||||
sipRequestHandler: camera.requestHandlers
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public static getIntercomIp( camera : BticinoSipCamera ): string {
|
||||
let to = camera.storage.getItem('sipto')?.trim();
|
||||
if( to ) {
|
||||
return to.split('@')[1];
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
public static getGruuInstanceId( camera : BticinoSipCamera ): string {
|
||||
let md5 = camera.storage.getItem('md5hash')
|
||||
if( !md5 ) {
|
||||
md5 = crypto.createHash('md5').update( camera.nativeId ).digest("hex")
|
||||
md5 = md5.substring(0, 8) + '-' + md5.substring(8, 12) + '-' + md5.substring(12,16) + '-' + md5.substring(16, 32)
|
||||
camera.storage.setItem('md5has', md5)
|
||||
}
|
||||
return md5
|
||||
}
|
||||
}
|
||||
78
plugins/bticino/src/storage-settings.ts
Normal file
78
plugins/bticino/src/storage-settings.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Setting, SettingValue } from '@scrypted/sdk';
|
||||
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
||||
import { BticinoSipCamera } from './bticino-camera';
|
||||
|
||||
export class BticinoStorageSettings {
|
||||
private storageSettings
|
||||
|
||||
constructor(camera : BticinoSipCamera) {
|
||||
this.storageSettings = new StorageSettings( camera, {
|
||||
sipfrom: {
|
||||
title: 'SIP From: URI',
|
||||
type: 'string',
|
||||
value: camera.storage.getItem('sipfrom'),
|
||||
description: 'SIP URI From field: Using the IP address of your server you will be calling from.',
|
||||
placeholder: 'user@192.168.0.111',
|
||||
multiple: false,
|
||||
},
|
||||
sipto: {
|
||||
title: 'SIP To: URI',
|
||||
type: 'string',
|
||||
description: 'SIP URI To field: Must look like c300x@192.168.0.2',
|
||||
placeholder: 'c300x@192.168.0.2',
|
||||
},
|
||||
sipdomain: {
|
||||
title: 'SIP domain',
|
||||
type: 'string',
|
||||
description: 'SIP domain - tshe internal BTicino domain, usually has the following format: 2048362.bs.iotleg.com',
|
||||
placeholder: '2048362.bs.iotleg.com',
|
||||
},
|
||||
sipexpiration: {
|
||||
title: 'SIP UA expiration',
|
||||
type: 'number',
|
||||
range: [60, 3600],
|
||||
description: 'How long the UA should remain active before expiring and having to re-register (in seconds)',
|
||||
defaultValue: 600,
|
||||
placeholder: '600',
|
||||
},
|
||||
sipdebug: {
|
||||
title: 'SIP debug logging',
|
||||
type: 'boolean',
|
||||
description: 'Enable SIP debugging',
|
||||
placeholder: 'true or false',
|
||||
},
|
||||
notifyVoicemail: {
|
||||
title: 'Notify on new voicemail messages',
|
||||
type: 'boolean',
|
||||
description: 'Enable voicemail alerts',
|
||||
placeholder: 'true or false',
|
||||
},
|
||||
doorbellWebhookUrl: {
|
||||
title: 'Doorbell Sensor Webhook',
|
||||
type: 'string',
|
||||
readonly: true,
|
||||
mapGet: () => {
|
||||
return camera.doorbellWebhookUrl;
|
||||
},
|
||||
description: 'Incoming doorbell sensor webhook url.',
|
||||
},
|
||||
doorbellLockWebhookUrl: {
|
||||
title: 'Doorbell Lock Webhook',
|
||||
type: 'string',
|
||||
readonly: true,
|
||||
mapGet: () => {
|
||||
return camera.doorbellLockWebhookUrl;
|
||||
},
|
||||
description: 'Incoming doorbell sensor webhook url.',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.storageSettings.getSettings();
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.storageSettings.putSetting(key, value);
|
||||
}
|
||||
}
|
||||
4
plugins/coreml/package-lock.json
generated
4
plugins/coreml/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/coreml",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/coreml",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.1.2"
|
||||
"version": "0.1.5"
|
||||
}
|
||||
|
||||
@@ -49,3 +49,36 @@ This is always a issue with the network setup.
|
||||
This almost always due to your camera bitrate being too high for remote streaming through Apple's servers. Workarounds:
|
||||
1) Use a lower bitrate substream for Remote Streaming.
|
||||
2) Enable Transcoding on Remote Streaming.
|
||||
|
||||
|
||||
### mDNS Advertiser Options
|
||||
|
||||
Scypted uses mDNS-based to make your accessories discoverable by Apple devices.
|
||||
There are 3 mDNS Advertisers that Scrypted can interface with to advertise itself on the local network.
|
||||
|
||||
|
||||
#### Ciao
|
||||
|
||||
Ciao is the default advertiser that scypted uses.
|
||||
|
||||
For non-Linux users, `ciao` should provide the best experience. It fixes a lot of the deficiencies of the `bonjour` advertiser. However, you might experience issues in the following two scenarios.
|
||||
|
||||
##### Network Interface Selection
|
||||
Ciao tries to be aware of multiple network interfaces.
|
||||
On startup, it tries to evaluate which network interfaces to advertise on by default.
|
||||
In certain circumstances, ciao is unable to properly determine the set of valid network interfaces (e.g., when dealing with virtual network interfaces on containerised environments).
|
||||
|
||||
##### Multiple Advertisers
|
||||
On some systems, there is already a mDNS advertiser stack running (e.g. avahi on linux). There might be issues running multiple mDNS advertisers on the same host.
|
||||
|
||||
#### Bonjour
|
||||
|
||||
Bonjour is a legacy advertiser. It is not as efficient in terms of system resource usage and network traffic when compared to the other options.
|
||||
|
||||
#### Avahi (Linux Only)
|
||||
Avahi is a mDNS advertiser that is installed by default on many linux distributions.
|
||||
|
||||
For Linux users, Avahi should provide the best experience.
|
||||
Note that your system must have the `avahi-daemon` and `dbus` services installed and running for this option to work.
|
||||
|
||||
If using Scypted through `docker compose`, be sure to uncomment the volume mounts to expose Avahi to the container.
|
||||
|
||||
1687
plugins/homekit/package-lock.json
generated
1687
plugins/homekit/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/homekit",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.20",
|
||||
"description": "HomeKit Plugin for Scrypted",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
@@ -35,18 +35,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@koush/werift-src": "file:../../external/werift",
|
||||
"check-disk-space": "^3.3.0",
|
||||
"hap-nodejs": "file:../../external/HAP-NodeJS",
|
||||
"check-disk-space": "^3.3.1",
|
||||
"hap-nodejs": "^0.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mkdirp": "^1.0.4"
|
||||
"mkdirp": "^2.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/mkdirp": "^1.0.2",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/url-parse": "^1.4.3"
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/node": "^18.15.5",
|
||||
"@types/url-parse": "^1.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,34 +8,6 @@ import { Categories, EventedHTTPServer, HAPStorage } from './hap';
|
||||
import { randomPinCode } from './pincode';
|
||||
import './types';
|
||||
|
||||
class HAPLocalStorage {
|
||||
initSync() {
|
||||
|
||||
}
|
||||
getItem(key: string): any {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data)
|
||||
return;
|
||||
return JSON.parse(data);
|
||||
}
|
||||
setItemSync(key: string, value: any) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
removeItemSync(key: string) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
persistSync() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// HAP storage seems to be global?
|
||||
export function initializeHapStorage() {
|
||||
HAPStorage.setStorage(new HAPLocalStorage());
|
||||
}
|
||||
|
||||
|
||||
export function createHAPUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * from 'hap-nodejs/src/lib/definitions'; // must be loaded before Characteristic and Service class
|
||||
export * from 'hap-nodejs/src/lib/Accessory';
|
||||
export * as uuid from 'hap-nodejs/src/lib/util/uuid';
|
||||
export * from 'hap-nodejs/src/lib/Characteristic';
|
||||
export * from 'hap-nodejs/src/lib/camera';
|
||||
export * from 'hap-nodejs/src/lib/camera/RecordingManagement';
|
||||
export * from 'hap-nodejs/src/lib/model/ControllerStorage';
|
||||
export * from 'hap-nodejs/src/lib/util/eventedhttp';
|
||||
export * from 'hap-nodejs/src/lib/controller/CameraController';
|
||||
export * from 'hap-nodejs/src/lib/datastream/DataStreamServer';
|
||||
export * from 'hap-nodejs/src/lib/Service';
|
||||
export * from 'hap-nodejs/src/types';
|
||||
export * from 'hap-nodejs/src/lib/model/HAPStorage';
|
||||
export * from 'hap-nodejs/src/lib/Bridge';
|
||||
export * from 'hap-nodejs/dist/lib/definitions'; // must be loaded before Characteristic and Service class
|
||||
export * from 'hap-nodejs/dist/lib/Accessory';
|
||||
export * as uuid from 'hap-nodejs/dist/lib/util/uuid';
|
||||
export * from 'hap-nodejs/dist/lib/Characteristic';
|
||||
export * from 'hap-nodejs/dist/lib/camera';
|
||||
export * from 'hap-nodejs/dist/lib/camera/RecordingManagement';
|
||||
export * from 'hap-nodejs/dist/lib/model/ControllerStorage';
|
||||
export * from 'hap-nodejs/dist/lib/util/eventedhttp';
|
||||
export * from 'hap-nodejs/dist/lib/controller/CameraController';
|
||||
export * from 'hap-nodejs/dist/lib/datastream/DataStreamServer';
|
||||
export * from 'hap-nodejs/dist/lib/Service';
|
||||
export * from 'hap-nodejs/dist/types';
|
||||
export * from 'hap-nodejs/dist/lib/model/HAPStorage';
|
||||
export * from 'hap-nodejs/dist/lib/Bridge';
|
||||
|
||||
@@ -6,18 +6,53 @@ import { getAddressOverride } from "./address-override";
|
||||
import { maybeAddBatteryService } from './battery';
|
||||
import { CameraMixin, canCameraMixin } from './camera-mixin';
|
||||
import { SnapshotThrottle, supportedTypes } from './common';
|
||||
import { Accessory, Bridge, Categories, Characteristic, ControllerStorage, MDNSAdvertiser, PublishInfo, Service } from './hap';
|
||||
import { createHAPUsernameStorageSettingsDict, getHAPUUID, getRandomPort as createRandomPort, initializeHapStorage, logConnections, typeToCategory } from './hap-utils';
|
||||
import { HAPStorage, Accessory, Bridge, Categories, Characteristic, ControllerStorage, MDNSAdvertiser, PublishInfo, Service } from './hap';
|
||||
import { createHAPUsernameStorageSettingsDict, getHAPUUID, getRandomPort as createRandomPort, logConnections, typeToCategory } from './hap-utils';
|
||||
import { HomekitMixin, HOMEKIT_MIXIN } from './homekit-mixin';
|
||||
import { addAccessoryDeviceInfo } from './info';
|
||||
import { randomPinCode } from './pincode';
|
||||
import './types';
|
||||
import { VIDEO_CLIPS_NATIVE_ID } from './types/camera/camera-recording-files';
|
||||
import { reorderDevicesByProvider } from './util';
|
||||
import { VideoClipsMixinProvider } from './video-clips-provider';
|
||||
|
||||
const hapStorage: Storage = {
|
||||
get length() {
|
||||
return localStorage.length;
|
||||
},
|
||||
clear: function (): void {
|
||||
return localStorage.clear();
|
||||
},
|
||||
key: function (index: number): string {
|
||||
return localStorage.key(index);
|
||||
},
|
||||
removeItem: function (key: string): void {
|
||||
return localStorage.removeItem(key);
|
||||
},
|
||||
getItem(key: string): any {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data)
|
||||
return;
|
||||
return JSON.parse(data);
|
||||
},
|
||||
setItem(key: string, value: any) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
setItemSync(key: string, value: any) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
removeItemSync(key: string) {
|
||||
localStorage.removeItem(key);
|
||||
},
|
||||
persistSync() {
|
||||
}
|
||||
}
|
||||
HAPStorage.storage = () => {
|
||||
return hapStorage;
|
||||
}
|
||||
|
||||
const { systemManager, deviceManager } = sdk;
|
||||
|
||||
initializeHapStorage();
|
||||
const includeToken = 4;
|
||||
|
||||
export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider, Settings, DeviceProvider {
|
||||
@@ -75,6 +110,7 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
|
||||
description: 'The last home hub to request a recording. Internally used to determine if a streaming request is coming from remote wifi.',
|
||||
},
|
||||
});
|
||||
mergedDevices = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -137,6 +173,7 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
|
||||
|
||||
async start() {
|
||||
this.log.clearAlerts();
|
||||
this.mergedDevices = new Set<string>();
|
||||
|
||||
let defaultIncluded: any;
|
||||
try {
|
||||
@@ -147,10 +184,27 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
|
||||
}
|
||||
|
||||
const plugins = await systemManager.getComponent('plugins');
|
||||
|
||||
const accessoryIds = new Set<string>();
|
||||
const deviceIds = Object.keys(systemManager.getSystemState());
|
||||
|
||||
for (const id of Object.keys(systemManager.getSystemState())) {
|
||||
// when creating accessories in order, some DeviceProviders may merge in
|
||||
// their child devices (and report back which devices are merged via
|
||||
// this.mergedDevices)
|
||||
// we need to ensure that the iteration processes DeviceProviders before
|
||||
// their children, so a reordering is necessary
|
||||
const reorderedDeviceIds = reorderDevicesByProvider(deviceIds);
|
||||
|
||||
// safety checks in case something went wrong
|
||||
if (deviceIds.length !== reorderedDeviceIds.length) {
|
||||
throw Error(`error in device reordering, expected ${deviceIds.length} devices but only got ${reorderedDeviceIds.length}!`);
|
||||
}
|
||||
const uniqueDeviceIds = new Set<string>(deviceIds);
|
||||
const uniqueReorderedIds = new Set<string>(reorderedDeviceIds);
|
||||
if (uniqueDeviceIds.size !== uniqueReorderedIds.size) {
|
||||
throw Error(`error in device reordering, expected ${uniqueDeviceIds.size} unique devices but only got ${uniqueReorderedIds.size} entries!`);
|
||||
}
|
||||
|
||||
for (const id of reorderedDeviceIds) {
|
||||
const device = systemManager.getDeviceById<Online>(id);
|
||||
const supportedType = supportedTypes[device.type];
|
||||
if (!supportedType?.probe(device))
|
||||
@@ -172,6 +226,11 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.mergedDevices.has(device.id)) {
|
||||
this.console.log(`${device.name} was merged into an existing Homekit accessory and will not be exposed independently`)
|
||||
continue;
|
||||
}
|
||||
|
||||
this.console.log('adding', device.name);
|
||||
|
||||
const accessory = await supportedType.getAccessory(device, this);
|
||||
@@ -294,9 +353,10 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
|
||||
bind,
|
||||
};
|
||||
|
||||
this.bridge.publish(publishInfo, true);
|
||||
this.storageSettings.values.qrCode = this.bridge.setupURI();
|
||||
logConnections(this.console, this.bridge, this.seenConnections);
|
||||
this.bridge.publish(publishInfo, true).then(() => {
|
||||
this.storageSettings.values.qrCode = this.bridge.setupURI();
|
||||
logConnections(this.console, this.bridge, this.seenConnections);
|
||||
});
|
||||
|
||||
systemManager.listen(async (eventSource, eventDetails, eventData) => {
|
||||
if (eventDetails.eventInterface !== ScryptedInterface.ScryptedDevice)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Deferred } from '@scrypted/common/src/deferred';
|
||||
import sdk, { AudioSensor, Camera, Intercom, MotionSensor, ObjectsDetected, OnOff, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, VideoCamera, VideoCameraConfiguration } from '@scrypted/sdk';
|
||||
import { defaultObjectDetectionContactSensorTimeout } from '../camera-mixin';
|
||||
import { addSupportedType, bindCharacteristic, DummyDevice, } from '../common';
|
||||
import { AudioRecordingCodec, AudioRecordingCodecType, AudioRecordingSamplerate, AudioStreamingCodec, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, CameraRecordingDelegate, CameraRecordingOptions, CameraStreamingOptions, Characteristic, CharacteristicEventTypes, DataStreamConnection, H264Level, H264Profile, OccupancySensor, RecordingManagement, Service, SRTPCryptoSuites, VideoCodecType, WithUUID } from '../hap';
|
||||
import { addSupportedType, bindCharacteristic, DummyDevice } from '../common';
|
||||
import { AudioRecordingCodec, AudioRecordingCodecType, AudioRecordingSamplerate, AudioStreamingCodec, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, CameraRecordingConfiguration, CameraRecordingDelegate, CameraRecordingOptions, CameraStreamingOptions, Characteristic, CharacteristicEventTypes, H264Level, H264Profile, MediaContainerType, OccupancySensor, RecordingPacket, Service, SRTPCryptoSuites, VideoCodecType, WithUUID } from '../hap';
|
||||
import type { HomeKitPlugin } from '../main';
|
||||
import { handleFragmentsRequests, iframeIntervalSeconds } from './camera/camera-recording';
|
||||
import { createCameraStreamingDelegate } from './camera/camera-streaming';
|
||||
import { FORCE_OPUS } from './camera/camera-utils';
|
||||
import { makeAccessory } from './common';
|
||||
import type { HomeKitPlugin } from '../main';
|
||||
|
||||
const { deviceManager, systemManager } = sdk;
|
||||
|
||||
@@ -62,7 +63,6 @@ addSupportedType({
|
||||
const streamingOptions: CameraStreamingOptions = {
|
||||
video: {
|
||||
codec: {
|
||||
type: VideoCodecType.H264,
|
||||
levels: [H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0],
|
||||
profiles: [H264Profile.MAIN],
|
||||
},
|
||||
@@ -98,15 +98,30 @@ addSupportedType({
|
||||
const detectAudio = storage.getItem('detectAudio') === 'true';
|
||||
const needAudioMotionService = device.interfaces.includes(ScryptedInterface.AudioSensor) && detectAudio;
|
||||
const linkedMotionSensor = storage.getItem('linkedMotionSensor');
|
||||
const isRecordingEnabled = !!linkedMotionSensor || device.interfaces.includes(ScryptedInterface.MotionSensor) || needAudioMotionService
|
||||
|
||||
const storageKeySelectedRecordingConfiguration = 'selectedRecordingConfiguration';
|
||||
|
||||
if (linkedMotionSensor || device.interfaces.includes(ScryptedInterface.MotionSensor) || needAudioMotionService) {
|
||||
let configuration: CameraRecordingConfiguration;
|
||||
const openRecordingStreams = new Map<number, Deferred<any>>();
|
||||
if (isRecordingEnabled) {
|
||||
recordingDelegate = {
|
||||
handleFragmentsRequests(connection: DataStreamConnection): AsyncGenerator<Buffer, void, unknown> {
|
||||
const configuration = RecordingManagement.parseSelectedConfiguration(storage.getItem(storageKeySelectedRecordingConfiguration))
|
||||
return handleFragmentsRequests(connection, device, configuration, console, homekitPlugin)
|
||||
}
|
||||
updateRecordingConfiguration(newConfiguration: CameraRecordingConfiguration ) {
|
||||
configuration = newConfiguration;
|
||||
},
|
||||
handleRecordingStreamRequest(streamId: number): AsyncGenerator<RecordingPacket> {
|
||||
const ret = handleFragmentsRequests(streamId, device, configuration, console, homekitPlugin);
|
||||
const d = new Deferred<any>();
|
||||
d.promise.then(reason => {
|
||||
ret.throw(new Error(reason.toString()));
|
||||
openRecordingStreams.delete(streamId);
|
||||
});
|
||||
openRecordingStreams.set(streamId, d);
|
||||
return ret;
|
||||
},
|
||||
closeRecordingStream(streamId, reason) {
|
||||
openRecordingStreams.get(streamId)?.resolve(reason);
|
||||
},
|
||||
updateRecordingActive(active) {
|
||||
},
|
||||
};
|
||||
|
||||
const recordingCodecs: AudioRecordingCodec[] = [];
|
||||
@@ -146,23 +161,17 @@ addSupportedType({
|
||||
// ensureHasWidthResolution(recordingResolutions, 1280, 720);
|
||||
// ensureHasWidthResolution(recordingResolutions, 1920, 1080);
|
||||
|
||||
const h265Support = storage.getItem('h265Support') === 'true';
|
||||
const codecType = h265Support ? VideoCodecType.H265 : VideoCodecType.H264
|
||||
|
||||
recordingOptions = {
|
||||
motionService: true,
|
||||
prebufferLength: numberPrebufferSegments * iframeIntervalSeconds * 1000,
|
||||
eventTriggerOptions: 0x01,
|
||||
mediaContainerConfigurations: [
|
||||
mediaContainerConfiguration: [
|
||||
{
|
||||
type: 0,
|
||||
type: MediaContainerType.FRAGMENTED_MP4,
|
||||
fragmentLength: iframeIntervalSeconds * 1000,
|
||||
}
|
||||
],
|
||||
|
||||
video: {
|
||||
codec: {
|
||||
type: codecType,
|
||||
type: VideoCodecType.H264,
|
||||
parameters: {
|
||||
levels: [H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0],
|
||||
profiles: [H264Profile.BASELINE, H264Profile.MAIN, H264Profile.HIGH],
|
||||
},
|
||||
@@ -183,10 +192,13 @@ addSupportedType({
|
||||
cameraStreamCount: 8,
|
||||
delegate,
|
||||
streamingOptions,
|
||||
recording: {
|
||||
recording: !isRecordingEnabled ? undefined : {
|
||||
options: recordingOptions,
|
||||
delegate: recordingDelegate,
|
||||
}
|
||||
},
|
||||
sensors: {
|
||||
motion: isRecordingEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
accessory.configureController(controller);
|
||||
@@ -214,26 +226,21 @@ addSupportedType({
|
||||
const property = `characteristic-v2-${characteristic.UUID}`
|
||||
service.getCharacteristic(characteristic)
|
||||
.on(CharacteristicEventTypes.GET, callback => callback(null, storage.getItem(property) === 'true' ? 1 : 0))
|
||||
.removeOnSet()
|
||||
.on(CharacteristicEventTypes.SET, (value, callback) => {
|
||||
callback();
|
||||
storage.setItem(property, (!!value).toString());
|
||||
});
|
||||
}
|
||||
|
||||
persistBooleanCharacteristic(recordingManagement.getService(), Characteristic.Active);
|
||||
persistBooleanCharacteristic(recordingManagement.getService(), Characteristic.RecordingAudioActive);
|
||||
persistBooleanCharacteristic(controller.cameraOperatingModeService, Characteristic.EventSnapshotsActive);
|
||||
persistBooleanCharacteristic(controller.cameraOperatingModeService, Characteristic.HomeKitCameraActive);
|
||||
persistBooleanCharacteristic(controller.cameraOperatingModeService, Characteristic.PeriodicSnapshotsActive);
|
||||
|
||||
if (!device.interfaces.includes(ScryptedInterface.OnOff)) {
|
||||
persistBooleanCharacteristic(controller.cameraOperatingModeService, Characteristic.CameraOperatingModeIndicator);
|
||||
persistBooleanCharacteristic(recordingManagement.operatingModeService, Characteristic.CameraOperatingModeIndicator);
|
||||
}
|
||||
else {
|
||||
const indicator = controller.cameraOperatingModeService.getCharacteristic(Characteristic.CameraOperatingModeIndicator);
|
||||
const indicator = recordingManagement.operatingModeService.getCharacteristic(Characteristic.CameraOperatingModeIndicator);
|
||||
const linkStatusIndicator = storage.getItem('statusIndicator') === 'true';
|
||||
const property = `characteristic-v2-${Characteristic.CameraOperatingModeIndicator.UUID}`
|
||||
bindCharacteristic(device, ScryptedInterface.OnOff, controller.cameraOperatingModeService, Characteristic.CameraOperatingModeIndicator, () => {
|
||||
bindCharacteristic(device, ScryptedInterface.OnOff, recordingManagement.operatingModeService, Characteristic.CameraOperatingModeIndicator, () => {
|
||||
if (!linkStatusIndicator)
|
||||
return storage.getItem(property) === 'true' ? 1 : 0;
|
||||
|
||||
@@ -250,16 +257,6 @@ addSupportedType({
|
||||
device.turnOff();
|
||||
});
|
||||
}
|
||||
|
||||
recordingManagement.getService().getCharacteristic(Characteristic.SelectedCameraRecordingConfiguration)
|
||||
.on(CharacteristicEventTypes.GET, callback => {
|
||||
callback(null, storage.getItem(storageKeySelectedRecordingConfiguration) || '');
|
||||
})
|
||||
.on(CharacteristicEventTypes.SET, (value, callback) => {
|
||||
// prepare recording here if necessary.
|
||||
storage.setItem(storageKeySelectedRecordingConfiguration, value.toString());
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import mkdirp from 'mkdirp';
|
||||
import net from 'net';
|
||||
import { Duplex, Readable, Writable } from 'stream';
|
||||
import { } from '../../common';
|
||||
import { AudioRecordingCodecType, AudioRecordingSamplerateValues, CameraRecordingConfiguration, DataStreamConnection } from '../../hap';
|
||||
import { AudioRecordingCodecType, CameraRecordingConfiguration, DataStreamConnection, RecordingPacket } from '../../hap';
|
||||
import type { HomeKitPlugin } from "../../main";
|
||||
import { getCameraRecordingFiles, HksvVideoClip, VIDEO_CLIPS_NATIVE_ID } from './camera-recording-files';
|
||||
import { checkCompatibleCodec, FORCE_OPUS, transcodingDebugModeWarning } from './camera-utils';
|
||||
@@ -33,6 +33,14 @@ const allowedNaluTypes = [
|
||||
NAL_TYPE_DELIMITER,
|
||||
];
|
||||
|
||||
const AudioRecordingSamplerateValues = {
|
||||
0: 8,
|
||||
1: 16,
|
||||
2: 24,
|
||||
3: 32,
|
||||
4: 44.1,
|
||||
5: 48,
|
||||
};
|
||||
|
||||
async function checkMp4StartsWithKeyFrame(console: Console, mp4: Buffer) {
|
||||
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), [
|
||||
@@ -90,19 +98,19 @@ async function checkMp4StartsWithKeyFrame(console: Console, mp4: Buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function* handleFragmentsRequests(connection: DataStreamConnection, device: ScryptedDevice & VideoCamera & MotionSensor & AudioSensor,
|
||||
configuration: CameraRecordingConfiguration, console: Console, homekitPlugin: HomeKitPlugin): AsyncGenerator<Buffer, void, unknown> {
|
||||
export async function* handleFragmentsRequests(streamId: number, device: ScryptedDevice & VideoCamera & MotionSensor & AudioSensor,
|
||||
configuration: CameraRecordingConfiguration, console: Console, homekitPlugin: HomeKitPlugin): AsyncGenerator<RecordingPacket> {
|
||||
|
||||
homekitPlugin.storageSettings.values.lastKnownHomeHub = connection.remoteAddress;
|
||||
// homekitPlugin.storageSettings.values.lastKnownHomeHub = connection.remoteAddress;
|
||||
|
||||
console.log(device.name, 'recording session starting', connection.remoteAddress, configuration);
|
||||
// console.log(device.name, 'recording session starting', connection.remoteAddress, configuration);
|
||||
|
||||
const storage = deviceManager.getMixinStorage(device.id, undefined);
|
||||
const debugMode = getDebugMode(storage);
|
||||
const saveRecordings = debugMode.recording;
|
||||
|
||||
// request more than needed, and determine what to do with the fragments after receiving them.
|
||||
const prebuffer = configuration.mediaContainerConfiguration.prebufferLength * 2.5;
|
||||
const prebuffer = configuration.prebufferLength * 2.5;
|
||||
|
||||
const media = await device.getVideoStream({
|
||||
destination: 'remote-recorder',
|
||||
@@ -153,7 +161,7 @@ export async function* handleFragmentsRequests(connection: DataStreamConnection,
|
||||
width: configuration.videoCodec.resolution[0],
|
||||
height: configuration.videoCodec.resolution[1],
|
||||
fps: configuration.videoCodec.resolution[2],
|
||||
max_bit_rate: configuration.videoCodec.bitrate,
|
||||
max_bit_rate: configuration.videoCodec.parameters.bitRate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +218,7 @@ export async function* handleFragmentsRequests(connection: DataStreamConnection,
|
||||
const videoRecordingFilter = `scale=w='min(${configuration.videoCodec.resolution[0]},iw)':h=-2`;
|
||||
addVideoFilterArguments(videoArgs, videoRecordingFilter);
|
||||
videoArgs.push(
|
||||
'-b:v', `${configuration.videoCodec.bitrate}k`,
|
||||
'-b:v', `${configuration.videoCodec.parameters.bitRate}k`,
|
||||
"-bufsize", (2 * request.video.max_bit_rate).toString() + "k",
|
||||
"-maxrate", request.video.max_bit_rate.toString() + "k",
|
||||
// used to use this but switched to group of picture (gop) instead.
|
||||
@@ -267,11 +275,13 @@ export async function* handleFragmentsRequests(connection: DataStreamConnection,
|
||||
safeKillFFmpeg(cp);
|
||||
}
|
||||
|
||||
let isLast = false;
|
||||
console.log(`motion recording started`);
|
||||
const { socket, cp, generator } = session;
|
||||
const videoTimeout = setTimeout(() => {
|
||||
console.error('homekit secure video max duration reached');
|
||||
cleanupPipes();
|
||||
isLast = true;
|
||||
setTimeout(cleanupPipes, 10000);
|
||||
}, maxVideoDuration);
|
||||
|
||||
let pending: Buffer[] = [];
|
||||
@@ -328,7 +338,14 @@ export async function* handleFragmentsRequests(connection: DataStreamConnection,
|
||||
saveFragment(i, fragment);
|
||||
pending = [];
|
||||
console.log(`motion fragment #${++i} sent. size:`, fragment.length);
|
||||
yield fragment;
|
||||
const wasLast = isLast;
|
||||
const recordingPacket: RecordingPacket = {
|
||||
data: fragment,
|
||||
isLast,
|
||||
}
|
||||
yield recordingPacket;
|
||||
if (wasLast)
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log(`motion recording finished`);
|
||||
|
||||
@@ -158,33 +158,33 @@ export function createCameraStreamingDelegate(device: ScryptedDevice & VideoCame
|
||||
// may not be reachable.
|
||||
// Return the incoming address, assuming the sanity checks pass. Otherwise, fall through
|
||||
// to the HAP-NodeJS implementation.
|
||||
let check: string;
|
||||
if (request.addressVersion === 'ipv4') {
|
||||
const localAddress = request.connection.localAddress;
|
||||
if (v4Regex.exec(localAddress)) {
|
||||
check = localAddress;
|
||||
}
|
||||
else if (v4v6Regex.exec(localAddress)) {
|
||||
// if this is a v4 over v6 address, parse it out.
|
||||
check = localAddress.substring('::ffff:'.length);
|
||||
}
|
||||
}
|
||||
else if (request.addressVersion === 'ipv6' && !v4Regex.exec(request.connection.localAddress)) {
|
||||
check = request.connection.localAddress;
|
||||
}
|
||||
// let check: string;
|
||||
// if (request.addressVersion === 'ipv4') {
|
||||
// const localAddress = request.connection.localAddress;
|
||||
// if (v4Regex.exec(localAddress)) {
|
||||
// check = localAddress;
|
||||
// }
|
||||
// else if (v4v6Regex.exec(localAddress)) {
|
||||
// // if this is a v4 over v6 address, parse it out.
|
||||
// check = localAddress.substring('::ffff:'.length);
|
||||
// }
|
||||
// }
|
||||
// else if (request.addressVersion === 'ipv6' && !v4Regex.exec(request.connection.localAddress)) {
|
||||
// check = request.connection.localAddress;
|
||||
// }
|
||||
|
||||
// ignore the IP if it is APIPA (Automatic Private IP Addressing)
|
||||
if (check?.startsWith('169.')) {
|
||||
check = undefined;
|
||||
}
|
||||
// // ignore the IP if it is APIPA (Automatic Private IP Addressing)
|
||||
// if (check?.startsWith('169.')) {
|
||||
// check = undefined;
|
||||
// }
|
||||
|
||||
// sanity check this address.
|
||||
if (check) {
|
||||
const infos = os.networkInterfaces()[request.connection.networkInterface];
|
||||
if (infos && infos.find(info => info.address === check)) {
|
||||
response.addressOverride = check;
|
||||
}
|
||||
}
|
||||
// // sanity check this address.
|
||||
// if (check) {
|
||||
// const infos = os.networkInterfaces()[request.connection.networkInterface];
|
||||
// if (infos && infos.find(info => info.address === check)) {
|
||||
// response.addressOverride = check;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
console.log('source address', response.addressOverride, videoPort, audioPort);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import sdk, { Fan, AirQuality, AirQualitySensor, CO2Sensor, NOXSensor, PM10Sensor, PM25Sensor, ScryptedDevice, ScryptedInterface, VOCSensor, FanMode, OnOff } from "@scrypted/sdk";
|
||||
import sdk, { Fan, AirQuality, AirQualitySensor, CO2Sensor, NOXSensor, PM10Sensor, PM25Sensor, ScryptedDevice, ScryptedInterface, VOCSensor, FanMode, OnOff, DeviceProvider, ScryptedDeviceType } from "@scrypted/sdk";
|
||||
import { bindCharacteristic } from "../common";
|
||||
import { Accessory, Characteristic, CharacteristicEventTypes, Service, uuid } from '../hap';
|
||||
import type { HomeKitPlugin } from "../main";
|
||||
import { getService as getOnOffService } from "./onoff-base";
|
||||
|
||||
const { deviceManager } = sdk;
|
||||
const { deviceManager, systemManager } = sdk;
|
||||
|
||||
export function makeAccessory(device: ScryptedDevice, homekitPlugin: HomeKitPlugin, suffix?: string): Accessory {
|
||||
const mixinStorage = deviceManager.getMixinStorage(device.id, homekitPlugin.nativeId);
|
||||
@@ -11,6 +12,12 @@ export function makeAccessory(device: ScryptedDevice, homekitPlugin: HomeKitPlug
|
||||
return new Accessory(device.name, uuid.generate(resetId + device.id + (suffix ? '-' + suffix : '')));
|
||||
}
|
||||
|
||||
export function getChildDevices(device: ScryptedDevice & DeviceProvider): ScryptedDevice[] {
|
||||
const ids = Object.keys(systemManager.getSystemState());
|
||||
const allDevices = ids.map(id => systemManager.getDeviceById(id));
|
||||
return allDevices.filter(d => d.providerId == device.id);
|
||||
}
|
||||
|
||||
export function addAirQualitySensor(device: ScryptedDevice & AirQualitySensor & PM10Sensor & PM25Sensor & VOCSensor & NOXSensor, accessory: Accessory): Service {
|
||||
if (!device.interfaces.includes(ScryptedInterface.AirQualitySensor))
|
||||
return undefined;
|
||||
@@ -34,7 +41,7 @@ export function addAirQualitySensor(device: ScryptedDevice & AirQualitySensor &
|
||||
const airQualityService = accessory.addService(Service.AirQualitySensor);
|
||||
bindCharacteristic(device, ScryptedInterface.AirQualitySensor, airQualityService, Characteristic.AirQuality,
|
||||
() => airQualityToHomekit(device.airQuality));
|
||||
|
||||
|
||||
if (device.interfaces.includes(ScryptedInterface.PM10Sensor)) {
|
||||
bindCharacteristic(device, ScryptedInterface.PM10Sensor, airQualityService, Characteristic.PM10Density,
|
||||
() => device.pm10Density || 0);
|
||||
@@ -157,4 +164,32 @@ export function addFan(device: ScryptedDevice & Fan & OnOff, accessory: Accessor
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
/*
|
||||
* addChildSirens looks for siren-type child devices of the given device provider
|
||||
* and merges them as switches to the accessory represented by the device provider.
|
||||
*
|
||||
* Returns the services created as well as all of the child siren devices which have
|
||||
* been merged.
|
||||
*/
|
||||
export function addChildSirens(device: ScryptedDevice & DeviceProvider, accessory: Accessory): { services: Service[], devices: (ScryptedDevice & OnOff)[] } {
|
||||
if (!device.interfaces.includes(ScryptedInterface.DeviceProvider))
|
||||
return undefined;
|
||||
|
||||
const children = getChildDevices(device);
|
||||
const sirenDevices = [];
|
||||
const services = children.map((child: ScryptedDevice & OnOff) => {
|
||||
if (child.type !== ScryptedDeviceType.Siren || !child.interfaces.includes(ScryptedInterface.OnOff))
|
||||
return undefined;
|
||||
|
||||
const onOffService = getOnOffService(child, accessory, Service.Switch)
|
||||
sirenDevices.push(child);
|
||||
return onOffService;
|
||||
});
|
||||
|
||||
return {
|
||||
services: services.filter(service => !!service),
|
||||
devices: sirenDevices,
|
||||
};
|
||||
}
|
||||
@@ -8,9 +8,7 @@ export function probe(device: DummyDevice): boolean {
|
||||
return device.interfaces.includes(ScryptedInterface.OnOff);
|
||||
}
|
||||
|
||||
export function getAccessory(device: ScryptedDevice & OnOff, homekitPlugin: HomeKitPlugin, serviceType: any): { accessory: Accessory, service: Service } | undefined {
|
||||
const accessory = makeAccessory(device, homekitPlugin);
|
||||
|
||||
export function getService(device: ScryptedDevice & OnOff, accessory: Accessory, serviceType: any): Service {
|
||||
const service = accessory.addService(serviceType, device.name);
|
||||
service.getCharacteristic(Characteristic.On)
|
||||
.on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
|
||||
@@ -22,7 +20,12 @@ export function getAccessory(device: ScryptedDevice & OnOff, homekitPlugin: Home
|
||||
})
|
||||
|
||||
bindCharacteristic(device, ScryptedInterface.OnOff, service, Characteristic.On, () => !!device.on);
|
||||
return service;
|
||||
}
|
||||
|
||||
export function getAccessory(device: ScryptedDevice & OnOff, homekitPlugin: HomeKitPlugin, serviceType: any): { accessory: Accessory, service: Service } | undefined {
|
||||
const accessory = makeAccessory(device, homekitPlugin);
|
||||
const service = getService(device, accessory, serviceType);
|
||||
return {
|
||||
accessory,
|
||||
service,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SecuritySystem, SecuritySystemMode, SecuritySystemObstruction, ScryptedDevice, ScryptedDeviceType, ScryptedInterface } from '@scrypted/sdk';
|
||||
import { SecuritySystem, SecuritySystemMode, SecuritySystemObstruction, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, DeviceProvider } from '@scrypted/sdk';
|
||||
import { addSupportedType, bindCharacteristic, DummyDevice } from '../common';
|
||||
import { Characteristic, CharacteristicEventTypes, CharacteristicSetCallback, CharacteristicValue, Service } from '../hap';
|
||||
import { makeAccessory } from './common';
|
||||
import { makeAccessory, addChildSirens } from './common';
|
||||
import type { HomeKitPlugin } from "../main";
|
||||
|
||||
addSupportedType({
|
||||
@@ -44,7 +44,7 @@ addSupportedType({
|
||||
|
||||
return Characteristic.SecuritySystemCurrentState.DISARMED;
|
||||
}
|
||||
|
||||
|
||||
function toTargetState(mode: SecuritySystemMode) {
|
||||
switch(mode) {
|
||||
case SecuritySystemMode.AwayArmed:
|
||||
@@ -71,7 +71,7 @@ addSupportedType({
|
||||
|
||||
bindCharacteristic(device, ScryptedInterface.SecuritySystem, service, Characteristic.SecuritySystemCurrentState,
|
||||
() => toCurrentState(device.securitySystemState?.mode, device.securitySystemState?.triggered));
|
||||
|
||||
|
||||
bindCharacteristic(device, ScryptedInterface.SecuritySystem, service, Characteristic.SecuritySystemTargetState,
|
||||
() => toTargetState(device.securitySystemState?.mode));
|
||||
|
||||
@@ -89,6 +89,14 @@ addSupportedType({
|
||||
bindCharacteristic(device, ScryptedInterface.SecuritySystem, service, Characteristic.SecuritySystemAlarmType,
|
||||
() => !!device.securitySystemState?.triggered);
|
||||
|
||||
if (device.interfaces.includes(ScryptedInterface.DeviceProvider)) {
|
||||
const { devices } = addChildSirens(device as ScryptedDevice as ScryptedDevice & DeviceProvider, accessory);
|
||||
|
||||
// ensure child devices are skipped by the rest of homekit by
|
||||
// reporting that they've been merged
|
||||
devices.map(device => homekitPlugin.mergedDevices.add(device.id));
|
||||
}
|
||||
|
||||
return accessory;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,18 +108,6 @@ addSupportedType({
|
||||
() => Math.max(device.thermostatSetpoint || 0, 10));
|
||||
|
||||
if (device.thermostatAvailableModes.includes(ThermostatMode.HeatCool)) {
|
||||
service.getCharacteristic(Characteristic.CoolingThresholdTemperature).setProps({
|
||||
minStep: minStep, // 0.1
|
||||
minValue: minSetTemp, // default = 10, change to 9C or 50F (10C)
|
||||
maxValue: maxSetTemp // default = 35, change to 32C or 90F (32.2222C)
|
||||
});
|
||||
|
||||
service.getCharacteristic(Characteristic.HeatingThresholdTemperature).setProps({
|
||||
minStep: minStep, // 0.1
|
||||
minValue: minSetTemp, // default = 0, change to 9C or 50F (10C)
|
||||
maxValue: maxSetTemp // default = 25, change to 32C or 90F (32.2222C)
|
||||
});
|
||||
|
||||
service.getCharacteristic(Characteristic.HeatingThresholdTemperature)
|
||||
.on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
|
||||
callback();
|
||||
@@ -137,6 +125,19 @@ addSupportedType({
|
||||
|
||||
bindCharacteristic(device, ScryptedInterface.TemperatureSetting, service, Characteristic.CoolingThresholdTemperature,
|
||||
() => Math.max(device.thermostatSetpointHigh || 0, 10));
|
||||
|
||||
// sets props after binding initial state to avoid warnings in logs
|
||||
service.getCharacteristic(Characteristic.CoolingThresholdTemperature).setProps({
|
||||
minStep: minStep, // 0.1
|
||||
minValue: minSetTemp, // default = 10, change to 9C or 50F (10C)
|
||||
maxValue: maxSetTemp // default = 35, change to 32C or 90F (32.2222C)
|
||||
});
|
||||
|
||||
service.getCharacteristic(Characteristic.HeatingThresholdTemperature).setProps({
|
||||
minStep: minStep, // 0.1
|
||||
minValue: minSetTemp, // default = 0, change to 9C or 50F (10C)
|
||||
maxValue: maxSetTemp // default = 25, change to 32C or 90F (32.2222C)
|
||||
});
|
||||
}
|
||||
|
||||
bindCharacteristic(device, ScryptedInterface.Thermometer, service, Characteristic.TemperatureDisplayUnits,
|
||||
|
||||
42
plugins/homekit/src/util.ts
Normal file
42
plugins/homekit/src/util.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import sdk, { ScryptedInterface } from '@scrypted/sdk';
|
||||
|
||||
const { systemManager } = sdk;
|
||||
|
||||
/*
|
||||
* flattenDeviceTree performs a modified DFS tree traversal of the given
|
||||
* device mapping to produce a list of device ids. deviceId is the node
|
||||
* of the tree currently being processed, where null is the root of the
|
||||
* tree.
|
||||
*/
|
||||
function flattenDeviceTree(deviceMap: Map<string, string[]>, deviceId: string): string[] {
|
||||
const result: string[] = [];
|
||||
if (!deviceMap.has(deviceId)) // no children
|
||||
return result;
|
||||
|
||||
const children = deviceMap.get(deviceId);
|
||||
result.push(...children);
|
||||
children.map(child => result.push(...flattenDeviceTree(deviceMap, child)))
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* reorderDevicesByProvider returns a new ordering of the provided deviceIds
|
||||
* where it is guaranteed that DeviceProviders are listed before their children.
|
||||
*/
|
||||
export function reorderDevicesByProvider(deviceIds: string[]): string[] {
|
||||
const providerDeviceIdMap = new Map<string, string[]>();
|
||||
|
||||
deviceIds.map(deviceId => {
|
||||
const device = systemManager.getDeviceById(deviceId);
|
||||
|
||||
// when provider id is equal to device id, this is a root-level device/plugin
|
||||
const providerId = device.providerId !== device.id ? device.providerId : null;
|
||||
if (providerDeviceIdMap.has(providerId)) {
|
||||
providerDeviceIdMap.get(providerId).push(device.id);
|
||||
} else {
|
||||
providerDeviceIdMap.set(providerId, [device.id]);
|
||||
}
|
||||
});
|
||||
|
||||
return flattenDeviceTree(providerDeviceIdMap, null);
|
||||
}
|
||||
@@ -1,24 +1,19 @@
|
||||
// https://developer.scrypted.app/#getting-started
|
||||
// package.json contains the metadata (name, interfaces) about this device
|
||||
// under the "scrypted" key.
|
||||
import { Settings, Setting, DeviceProvider, ScryptedDeviceBase, ScryptedInterface, ScryptedDeviceType, Scriptable, ScriptSource, ScryptedInterfaceDescriptors, MixinProvider, ScryptedDevice, EventListenerRegister, DeviceCreator, DeviceCreatorSettings } from '@scrypted/sdk';
|
||||
import sdk from '@scrypted/sdk';
|
||||
import { monacoEvalDefaults } from './monaco';
|
||||
import { scryptedEval } from './scrypted-eval';
|
||||
import { MqttClient, MqttClientPublishOptions, MqttSubscriptions } from './api/mqtt-client';
|
||||
import { createScriptDevice, ScriptDeviceImpl, tsCompile } from '@scrypted/common/src/eval/scrypted-eval';
|
||||
import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, EventListenerRegister, MixinProvider, Scriptable, ScriptSource, ScryptedDevice, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedInterfaceDescriptors, Setting, Settings } from '@scrypted/sdk';
|
||||
import aedes, { AedesOptions } from 'aedes';
|
||||
import net from 'net';
|
||||
import ws from 'websocket-stream';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import { Client, connect } from 'mqtt';
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
import ws from 'websocket-stream';
|
||||
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "../../../common/src/settings-mixin";
|
||||
import { MqttClient, MqttClientPublishOptions, MqttSubscriptions } from './api/mqtt-client';
|
||||
import { MqttDeviceBase } from './api/mqtt-device-base';
|
||||
import { MqttAutoDiscoveryProvider } from './autodiscovery/autodiscovery';
|
||||
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "../../../common/src/settings-mixin";
|
||||
import { connect, Client } from 'mqtt';
|
||||
import { monacoEvalDefaults } from './monaco';
|
||||
import { isPublishable } from './publishable-types';
|
||||
import { createScriptDevice, ScriptDeviceImpl } from '@scrypted/common/src/eval/scrypted-eval';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { scryptedEval } from './scrypted-eval';
|
||||
|
||||
export function filterExample(filename: string) {
|
||||
return fs.readFileSync(`examples/${filename}`).toString()
|
||||
@@ -533,4 +528,11 @@ class MqttProvider extends ScryptedDeviceBase implements DeviceProvider, Setting
|
||||
}
|
||||
}
|
||||
|
||||
export default new MqttProvider();
|
||||
export default MqttProvider;
|
||||
|
||||
|
||||
export async function fork() {
|
||||
return {
|
||||
tsCompile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ScryptedDeviceType, ScryptedInterface } from "@scrypted/sdk";
|
||||
|
||||
export function isPublishable(type: ScryptedDeviceType, interfaces: string[]): boolean {
|
||||
switch (type) {
|
||||
case ScryptedDeviceType.API:
|
||||
case ScryptedDeviceType.Builtin:
|
||||
case ScryptedDeviceType.DataSource:
|
||||
case ScryptedDeviceType.Unknown:
|
||||
return false;
|
||||
}
|
||||
const set = new Set(interfaces);
|
||||
set.delete(ScryptedInterface.ObjectDetection);
|
||||
set.delete(ScryptedInterface.DeviceDiscovery);
|
||||
@@ -15,6 +22,7 @@ export function isPublishable(type: ScryptedDeviceType, interfaces: string[]): b
|
||||
set.delete(ScryptedInterface.BufferConverter);
|
||||
set.delete(ScryptedInterface.ScryptedPlugin);
|
||||
set.delete(ScryptedInterface.OauthClient);
|
||||
set.delete(ScryptedInterface.OauthClient);
|
||||
set.delete(ScryptedInterface.LauncherApplication);
|
||||
return !!set.size;
|
||||
}
|
||||
|
||||
1131
plugins/objectdetector/package-lock.json
generated
1131
plugins/objectdetector/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/objectdetector",
|
||||
"version": "0.0.108",
|
||||
"version": "0.0.113",
|
||||
"description": "Scrypted Video Analysis Plugin. Installed alongside a detection service like OpenCV or TensorFlow.",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache-2.0",
|
||||
@@ -36,13 +36,17 @@
|
||||
"type": "API",
|
||||
"interfaces": [
|
||||
"Settings",
|
||||
"MixinProvider"
|
||||
"MixinProvider",
|
||||
"DeviceProvider"
|
||||
],
|
||||
"realfs": true,
|
||||
"pluginDependencies": [
|
||||
"@scrypted/python-codecs"
|
||||
]
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.31.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
@@ -55,6 +59,6 @@
|
||||
"@types/lodash": "^4.14.175",
|
||||
"@types/node": "^14.17.11",
|
||||
"@types/semver": "^7.3.13",
|
||||
"ts-node": "^10.9.1"
|
||||
"@types/sharp": "^0.31.1"
|
||||
}
|
||||
}
|
||||
|
||||
193
plugins/objectdetector/src/ffmpeg-videoframes.ts
Normal file
193
plugins/objectdetector/src/ffmpeg-videoframes.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Deferred } from "@scrypted/common/src/deferred";
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg } from "@scrypted/common/src/media-helpers";
|
||||
import { readLength, readLine } from "@scrypted/common/src/read-stream";
|
||||
import sdk, { FFmpegInput, Image, ImageOptions, MediaObject, ScryptedDeviceBase, ScryptedMimeTypes, VideoFrame, VideoFrameGenerator, VideoFrameGeneratorOptions } from "@scrypted/sdk";
|
||||
import child_process from 'child_process';
|
||||
import sharp from 'sharp';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
async function createVipsMediaObject(image: VipsImage): Promise<VideoFrame & MediaObject> {
|
||||
const ret = await sdk.mediaManager.createMediaObject(image, ScryptedMimeTypes.Image, {
|
||||
format: null,
|
||||
timestamp: 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
toBuffer: (options: ImageOptions) => image.toBuffer(options),
|
||||
toImage: async (options: ImageOptions) => {
|
||||
const newImage = await image.toVipsImage(options);
|
||||
return createVipsMediaObject(newImage);
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
interface RawFrame {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
class VipsImage implements Image {
|
||||
constructor(public image: sharp.Sharp, public width: number, public height: number) {
|
||||
}
|
||||
|
||||
toImageInternal(options: ImageOptions) {
|
||||
const transformed = this.image.clone();
|
||||
if (options?.crop) {
|
||||
transformed.extract({
|
||||
left: Math.floor(options.crop.left),
|
||||
top: Math.floor(options.crop.top),
|
||||
width: Math.floor(options.crop.width),
|
||||
height: Math.floor(options.crop.height),
|
||||
});
|
||||
}
|
||||
if (options?.resize) {
|
||||
transformed.resize(typeof options.resize.width === 'number' ? Math.floor(options.resize.width) : undefined, typeof options.resize.height === 'number' ? Math.floor(options.resize.height) : undefined, {
|
||||
fit: "fill",
|
||||
kernel: 'cubic',
|
||||
});
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
async toBuffer(options: ImageOptions) {
|
||||
const transformed = this.toImageInternal(options);
|
||||
if (options?.format === 'rgb') {
|
||||
transformed.removeAlpha().toFormat('raw');
|
||||
}
|
||||
else if (options?.format === 'jpg') {
|
||||
transformed.toFormat('jpg');
|
||||
}
|
||||
return transformed.toBuffer();
|
||||
}
|
||||
|
||||
async toVipsImage(options: ImageOptions) {
|
||||
const transformed = this.toImageInternal(options);
|
||||
const { info, data } = await transformed.raw().toBuffer({
|
||||
resolveWithObject: true,
|
||||
});
|
||||
|
||||
const newImage = sharp(data, {
|
||||
raw: info,
|
||||
});
|
||||
|
||||
const newMetadata = await newImage.metadata();
|
||||
const newVipsImage = new VipsImage(newImage, newMetadata.width, newMetadata.height);
|
||||
return newVipsImage;
|
||||
}
|
||||
|
||||
async toImage(options: ImageOptions) {
|
||||
if (options.format)
|
||||
throw new Error('format can only be used with toBuffer');
|
||||
const newVipsImage = await this.toVipsImage(options);
|
||||
return createVipsMediaObject(newVipsImage);
|
||||
}
|
||||
}
|
||||
|
||||
export class FFmpegVideoFrameGenerator extends ScryptedDeviceBase implements VideoFrameGenerator {
|
||||
async *generateVideoFramesInternal(mediaObject: MediaObject, options?: VideoFrameGeneratorOptions, filter?: (videoFrame: VideoFrame & MediaObject) => Promise<boolean>): AsyncGenerator<VideoFrame & MediaObject, any, unknown> {
|
||||
const ffmpegInput = await sdk.mediaManager.convertMediaObjectToJSON<FFmpegInput>(mediaObject, ScryptedMimeTypes.FFmpegInput);
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
//'-hwaccel', 'auto',
|
||||
...ffmpegInput.inputArguments,
|
||||
'-vcodec', 'pam',
|
||||
'-pix_fmt', 'rgb24',
|
||||
'-f', 'image2pipe',
|
||||
'pipe:3',
|
||||
];
|
||||
|
||||
const cp = child_process.spawn(await sdk.mediaManager.getFFmpegPath(), args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
ffmpegLogInitialOutput(this.console, cp);
|
||||
|
||||
let finished = false;
|
||||
let frameDeferred: Deferred<RawFrame>;
|
||||
|
||||
const reader = async () => {
|
||||
try {
|
||||
|
||||
const readable = cp.stdio[3] as Readable;
|
||||
const headers = new Map<string, string>();
|
||||
while (!finished) {
|
||||
const line = await readLine(readable);
|
||||
if (line !== 'ENDHDR') {
|
||||
const [key, value] = line.split(' ');
|
||||
headers[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (headers['TUPLTYPE'] !== 'RGB')
|
||||
throw new Error(`Unexpected TUPLTYPE in PAM stream: ${headers['TUPLTYPE']}`);
|
||||
|
||||
const width = parseInt(headers['WIDTH']);
|
||||
const height = parseInt(headers['HEIGHT']);
|
||||
if (!width || !height)
|
||||
throw new Error('Invalid dimensions in PAM stream');
|
||||
|
||||
const length = width * height * 3;
|
||||
headers.clear();
|
||||
const data = await readLength(readable, length);
|
||||
|
||||
if (frameDeferred) {
|
||||
const f = frameDeferred;
|
||||
frameDeferred = undefined;
|
||||
f.resolve({
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
});
|
||||
}
|
||||
else {
|
||||
// this.console.warn('skipped frame');
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
finally {
|
||||
this.console.log('finished reader');
|
||||
finished = true;
|
||||
frameDeferred?.reject(new Error('frame generator finished'));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
reader();
|
||||
while (!finished) {
|
||||
frameDeferred = new Deferred();
|
||||
const raw = await frameDeferred.promise;
|
||||
const { width, height, data } = raw;
|
||||
|
||||
const image = sharp(data, {
|
||||
raw: {
|
||||
width,
|
||||
height,
|
||||
channels: 3,
|
||||
}
|
||||
});
|
||||
const vipsImage = new VipsImage(image, width, height);
|
||||
const mo = await createVipsMediaObject(vipsImage);
|
||||
yield mo;
|
||||
vipsImage.image.destroy();
|
||||
vipsImage.image = undefined;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
finally {
|
||||
this.console.log('finished generator');
|
||||
finished = true;
|
||||
safeKillFFmpeg(cp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async generateVideoFrames(mediaObject: MediaObject, options?: VideoFrameGeneratorOptions, filter?: (videoFrame: VideoFrame & MediaObject) => Promise<boolean>): Promise<AsyncGenerator<VideoFrame & MediaObject, any, unknown>> {
|
||||
return this.generateVideoFramesInternal(mediaObject, options, filter);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import sdk, { ScryptedMimeTypes, Image, VideoFrame, VideoFrameGenerator, Camera, DeviceState, EventListenerRegister, MediaObject, MixinDeviceBase, MixinProvider, MotionSensor, ObjectDetection, ObjectDetectionCallbacks, ObjectDetectionModel, ObjectDetectionResult, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting, Settings, SettingValue, VideoCamera, MediaStreamDestination } from '@scrypted/sdk';
|
||||
import sdk, { Camera, DeviceProvider, DeviceState, EventListenerRegister, MediaObject, MediaStreamDestination, MixinDeviceBase, MixinProvider, MotionSensor, ObjectDetection, ObjectDetectionCallbacks, ObjectDetectionModel, ObjectDetectionResult, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, ScryptedNativeId, Setting, Settings, SettingValue, VideoCamera, VideoFrame, VideoFrameGenerator } from '@scrypted/sdk';
|
||||
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
||||
import crypto from 'crypto';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { AutoenableMixinProvider } from "../../../common/src/autoenable-mixin-provider";
|
||||
import { SettingsMixinDeviceBase } from "../../../common/src/settings-mixin";
|
||||
import { DenoisedDetectionEntry, DenoisedDetectionState, denoiseDetections } from './denoise';
|
||||
import { FFmpegVideoFrameGenerator } from './ffmpeg-videoframes';
|
||||
import { serverSupportsMixinEventMasking } from './server-version';
|
||||
import { sleep } from './sleep';
|
||||
import { getAllDevices, safeParseJson } from './util';
|
||||
@@ -428,7 +429,6 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
|
||||
if (retainImage && mediaObject) {
|
||||
this.lastDetectionInput = now;
|
||||
this.console.log('retaining detection image');
|
||||
this.setDetection(detection, mediaObject);
|
||||
}
|
||||
|
||||
@@ -536,6 +536,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
else {
|
||||
const destination: MediaStreamDestination = this.hasMotionType ? 'low-resolution' : 'local-recorder';
|
||||
const videoFrameGenerator = systemManager.getDeviceById<VideoFrameGenerator>(newPipeline);
|
||||
this.console.log('decoder:', videoFrameGenerator.name);
|
||||
if (!videoFrameGenerator)
|
||||
throw new Error('invalid VideoFrameGenerator');
|
||||
const stream = await this.cameraDevice.getVideoStream({
|
||||
@@ -553,11 +554,13 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
});
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let detections = 0;
|
||||
try {
|
||||
let detections = 0;
|
||||
for await (const detected
|
||||
of await this.objectDetection.generateObjectDetections(await generator(), {
|
||||
settings: this.getCurrentSettings(),
|
||||
sourceId: this.id,
|
||||
})) {
|
||||
if (!this.detectorRunning) {
|
||||
break;
|
||||
@@ -607,6 +610,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
this.console.error('video pipeline ended with error', e);
|
||||
}
|
||||
finally {
|
||||
this.console.log('video pipeline analysis ended, dps:', detections / (Date.now() - start) * 1000);
|
||||
this.endObjectDetection();
|
||||
}
|
||||
}
|
||||
@@ -888,6 +892,8 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
if (!detection.detectionId)
|
||||
detection.detectionId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
this.console.log('retaining detection image');
|
||||
|
||||
const { detectionId } = detection;
|
||||
this.detections.set(detectionId, detectionInput);
|
||||
setTimeout(() => {
|
||||
@@ -945,8 +951,12 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
if (newPipeline === 'Snapshot')
|
||||
return newPipeline;
|
||||
const pipelines = getAllDevices().filter(d => d.interfaces.includes(ScryptedInterface.VideoFrameGenerator));
|
||||
const found = pipelines.find(p => p.name === newPipeline);
|
||||
return found?.id || pipelines[0]?.id;
|
||||
const webcodec = pipelines.find(p => p.nativeId === 'webcodec');
|
||||
const gstreamer = pipelines.find(p => p.nativeId === 'gstreamer');
|
||||
const libav = pipelines.find(p => p.nativeId === 'libav');
|
||||
const ffmpeg = pipelines.find(p => p.nativeId === 'ffmpeg');
|
||||
const use = pipelines.find(p => p.name === newPipeline) || webcodec || gstreamer || libav || ffmpeg;
|
||||
return use.id;
|
||||
}
|
||||
|
||||
async getMixinSettings(): Promise<Setting[]> {
|
||||
@@ -1232,7 +1242,7 @@ class ObjectDetectorMixin extends MixinDeviceBase<ObjectDetection> implements Mi
|
||||
}
|
||||
}
|
||||
|
||||
class ObjectDetectionPlugin extends AutoenableMixinProvider implements Settings {
|
||||
class ObjectDetectionPlugin extends AutoenableMixinProvider implements Settings, DeviceProvider {
|
||||
currentMixins = new Set<ObjectDetectorMixin>();
|
||||
|
||||
storageSettings = new StorageSettings(this, {
|
||||
@@ -1263,6 +1273,29 @@ class ObjectDetectionPlugin extends AutoenableMixinProvider implements Settings
|
||||
|
||||
constructor(nativeId?: ScryptedNativeId) {
|
||||
super(nativeId);
|
||||
|
||||
process.nextTick(() => {
|
||||
sdk.deviceManager.onDevicesChanged({
|
||||
devices: [
|
||||
{
|
||||
name: 'FFmpeg Frame Generator',
|
||||
type: ScryptedDeviceType.Builtin,
|
||||
interfaces: [
|
||||
ScryptedInterface.VideoFrameGenerator,
|
||||
],
|
||||
nativeId: 'ffmpeg',
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string): Promise<any> {
|
||||
if (nativeId === 'ffmpeg')
|
||||
return new FFmpegVideoFrameGenerator('ffmpeg');
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
}
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
|
||||
4
plugins/onvif/package-lock.json
generated
4
plugins/onvif/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/onvif",
|
||||
"version": "0.0.117",
|
||||
"version": "0.0.118",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/onvif",
|
||||
"version": "0.0.117",
|
||||
"version": "0.0.118",
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/onvif",
|
||||
"version": "0.0.117",
|
||||
"version": "0.0.118",
|
||||
"description": "ONVIF Camera Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeviceState, MixinDeviceBase, MixinDeviceOptions, MixinProvider, PanTiltZoom, PanTiltZoomCommand, PanTiltZoomMovement, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
|
||||
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
||||
import { connectCameraAPI } from "./onvif-api";
|
||||
import {SettingsMixinDeviceBase, SettingsMixinDeviceOptions} from '../../../common/src/settings-mixin';
|
||||
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from '../../../common/src/settings-mixin';
|
||||
|
||||
export class OnvifPtzMixin extends SettingsMixinDeviceBase<Settings> implements PanTiltZoom, Settings {
|
||||
storageSettings = new StorageSettings(this, {
|
||||
@@ -55,9 +55,9 @@ export class OnvifPtzMixin extends SettingsMixinDeviceBase<Settings> implements
|
||||
};
|
||||
}
|
||||
|
||||
if (command.movement === PanTiltZoomMovement.Relative) {
|
||||
if (command.movement === PanTiltZoomMovement.Absolute) {
|
||||
return new Promise<void>((r, f) => {
|
||||
client.cam.relativeMove({
|
||||
client.cam.absoluteMove({
|
||||
x: command.pan,
|
||||
y: command.tilt,
|
||||
zoom: command.zoom,
|
||||
@@ -69,9 +69,10 @@ export class OnvifPtzMixin extends SettingsMixinDeviceBase<Settings> implements
|
||||
})
|
||||
})
|
||||
}
|
||||
else if (command.movement === PanTiltZoomMovement.Absolute) {
|
||||
else {
|
||||
// relative movement is default.
|
||||
return new Promise<void>((r, f) => {
|
||||
client.cam.absoluteMove({
|
||||
client.cam.relativeMove({
|
||||
x: command.pan,
|
||||
y: command.tilt,
|
||||
zoom: command.zoom,
|
||||
|
||||
4
plugins/opencv/package-lock.json
generated
4
plugins/opencv/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/opencv",
|
||||
"version": "0.0.66",
|
||||
"version": "0.0.69",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/opencv",
|
||||
"version": "0.0.66",
|
||||
"version": "0.0.69",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.0.66"
|
||||
"version": "0.0.69"
|
||||
}
|
||||
|
||||
@@ -220,17 +220,18 @@ class OpenCVPlugin(DetectPlugin):
|
||||
if width <= 640 and height < 640:
|
||||
scale = 1
|
||||
resize = None
|
||||
elif aspectRatio > 1:
|
||||
scale = height / 300
|
||||
resize = {
|
||||
'height': 300,
|
||||
'width': int(300 * aspectRatio)
|
||||
}
|
||||
else:
|
||||
scale = width / 300
|
||||
if aspectRatio > 1:
|
||||
scale = height / 300
|
||||
height = 300
|
||||
width = int(300 * aspectRatio)
|
||||
else:
|
||||
width = 300
|
||||
height = int(300 / aspectRatio)
|
||||
scale = width / 300
|
||||
resize = {
|
||||
'width': 300,
|
||||
'height': int(300 / aspectRatio)
|
||||
'width': width,
|
||||
'height': height,
|
||||
}
|
||||
|
||||
buffer = await videoFrame.toBuffer({
|
||||
@@ -239,7 +240,7 @@ class OpenCVPlugin(DetectPlugin):
|
||||
|
||||
def convert_to_src_size(point, normalize = False):
|
||||
return point[0] * scale, point[1] * scale, True
|
||||
mat = np.ndarray((videoFrame.height, videoFrame.width, self.pixelFormatChannelCount), buffer=buffer, dtype=np.uint8)
|
||||
mat = np.ndarray((height, width, self.pixelFormatChannelCount), buffer=buffer, dtype=np.uint8)
|
||||
detections = self.detect(
|
||||
detection_session, mat, (width, height), convert_to_src_size)
|
||||
return detections
|
||||
@@ -250,7 +251,7 @@ class OpenCVPlugin(DetectPlugin):
|
||||
else:
|
||||
mat = np.ndarray((avframe.height, avframe.width, self.pixelFormatChannelCount), buffer=avframe.planes[0], dtype=np.uint8)
|
||||
detections = self.detect(
|
||||
detection_session, mat, settings, src_size, convert_to_src_size)
|
||||
detection_session, mat, src_size, convert_to_src_size)
|
||||
if not detections or not len(detections['detections']):
|
||||
await self.detection_sleep(settings)
|
||||
return None, None
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# plugin
|
||||
numpy>=1.16.2
|
||||
Pillow>=5.4.1
|
||||
# pillow for anything not intel linux
|
||||
Pillow>=5.4.1; sys_platform != 'linux' or platform_machine != 'x86_64'
|
||||
pillow-simd; sys_platform == 'linux' and platform_machine == 'x86_64'
|
||||
PyGObject>=3.30.4; sys_platform != 'win32'
|
||||
av>=10.0.0; sys_platform != 'linux' or platform_machine == 'x86_64' or platform_machine == 'aarch64'
|
||||
imutils>=0.5.0
|
||||
# not available on armhf
|
||||
av>=10.0.0; sys_platform != 'linux' or platform_machine == 'x86_64' or platform_machine == 'aarch64'
|
||||
# not available on armhf
|
||||
opencv-python; sys_platform != 'linux' or platform_machine == 'x86_64' or platform_machine == 'aarch64'
|
||||
|
||||
4
plugins/pam-diff/package-lock.json
generated
4
plugins/pam-diff/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/pam-diff",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.17",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/pam-diff",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.17",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^16.6.1",
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.0.16"
|
||||
"version": "0.0.17"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ObjectDetectionResult, FFmpegInput, MediaObject, ObjectDetection, ObjectDetectionCallbacks, ObjectDetectionModel, ObjectDetectionSession, ObjectsDetected, ScryptedDeviceBase, ScryptedInterface, ScryptedMimeTypes } from '@scrypted/sdk';
|
||||
import sdk from '@scrypted/sdk';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from "../../../common/src/media-helpers";
|
||||
|
||||
import sdk, { FFmpegInput, MediaObject, ObjectDetection, ObjectDetectionCallbacks, ObjectDetectionGeneratorResult, ObjectDetectionGeneratorSession, ObjectDetectionModel, ObjectDetectionResult, ObjectDetectionSession, ObjectsDetected, ScryptedDeviceBase, ScryptedInterface, ScryptedMimeTypes, VideoFrame } from '@scrypted/sdk';
|
||||
import child_process, { ChildProcess } from 'child_process';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from "../../../common/src/media-helpers";
|
||||
|
||||
import PD from 'pam-diff';
|
||||
import P2P from 'pipe2pam';
|
||||
import { PassThrough, Writable } from 'stream';
|
||||
|
||||
const { mediaManager } = sdk;
|
||||
|
||||
@@ -51,6 +50,101 @@ class PamDiff extends ScryptedDeviceBase implements ObjectDetection {
|
||||
pds.timeout = setTimeout(() => this.endSession(id), duration);
|
||||
}
|
||||
|
||||
async * generateObjectDetectionsInternal(videoFrames: AsyncGenerator<VideoFrame, any, unknown>, session: ObjectDetectionGeneratorSession): AsyncGenerator<ObjectDetectionGeneratorResult, any, unknown> {
|
||||
videoFrames = await sdk.connectRPCObject(videoFrames);
|
||||
|
||||
const width = 640;
|
||||
const height = 360;
|
||||
const p2p: Writable = new P2P();
|
||||
const pt = new PassThrough();
|
||||
const pamDiff = new PD({
|
||||
difference: parseInt(session.settings?.difference) || defaultDifference,
|
||||
percent: parseInt(session.settings?.percent) || defaultPercentage,
|
||||
response: session?.settings?.motionAsObjects ? 'blobs' : 'percent',
|
||||
});
|
||||
pt.pipe(p2p).pipe(pamDiff);
|
||||
|
||||
const queued: ObjectsDetected[] = [];
|
||||
pamDiff.on('diff', async (data: any) => {
|
||||
const trigger = data.trigger[0];
|
||||
// console.log(trigger.blobs.length);
|
||||
const { blobs } = trigger;
|
||||
|
||||
const detections: ObjectDetectionResult[] = [];
|
||||
if (blobs?.length) {
|
||||
for (const blob of blobs) {
|
||||
detections.push(
|
||||
{
|
||||
className: 'motion',
|
||||
score: 1,
|
||||
boundingBox: [blob.minX, blob.minY, blob.maxX - blob.minX, blob.maxY - blob.minY],
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
else {
|
||||
detections.push(
|
||||
{
|
||||
className: 'motion',
|
||||
score: trigger.percent / 100,
|
||||
}
|
||||
)
|
||||
}
|
||||
const event: ObjectsDetected = {
|
||||
timestamp: Date.now(),
|
||||
running: true,
|
||||
inputDimensions: [width, height],
|
||||
detections,
|
||||
}
|
||||
queued.push(event);
|
||||
});
|
||||
|
||||
|
||||
for await (const videoFrame of videoFrames) {
|
||||
const header = `P7
|
||||
WIDTH ${width}
|
||||
HEIGHT ${height}
|
||||
DEPTH 3
|
||||
MAXVAL 255
|
||||
TUPLTYPE RGB
|
||||
ENDHDR
|
||||
`;
|
||||
|
||||
const buffer = await videoFrame.toBuffer({
|
||||
resize: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
format: 'rgb',
|
||||
});
|
||||
pt.write(Buffer.from(header));
|
||||
pt.write(buffer);
|
||||
|
||||
if (!queued.length) {
|
||||
yield {
|
||||
__json_copy_serialize_children: true,
|
||||
videoFrame,
|
||||
detected: {
|
||||
timestamp: Date.now(),
|
||||
detections: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
while (queued.length) {
|
||||
yield {
|
||||
__json_copy_serialize_children: true,
|
||||
detected: queued.pop(),
|
||||
videoFrame,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async generateObjectDetections(videoFrames: AsyncGenerator<VideoFrame, any, unknown>, session: ObjectDetectionGeneratorSession): Promise<AsyncGenerator<ObjectDetectionGeneratorResult, any, unknown>> {
|
||||
return this.generateObjectDetectionsInternal(videoFrames, session);
|
||||
}
|
||||
|
||||
async detectObjects(mediaObject: MediaObject, session?: ObjectDetectionSession, callbacks?: ObjectDetectionCallbacks): Promise<ObjectsDetected> {
|
||||
if (mediaObject && mediaObject.mimeType?.startsWith('image/'))
|
||||
throw new Error('can not run motion detection on image')
|
||||
@@ -209,6 +303,8 @@ class PamDiff extends ScryptedDeviceBase implements ObjectDetection {
|
||||
return {
|
||||
name: '@scrypted/pam-diff',
|
||||
classes: ['motion'],
|
||||
inputFormat: 'rgb',
|
||||
inputSize: [640, 360],
|
||||
settings: [
|
||||
{
|
||||
title: 'Motion Difference',
|
||||
@@ -229,4 +325,4 @@ class PamDiff extends ScryptedDeviceBase implements ObjectDetection {
|
||||
}
|
||||
}
|
||||
|
||||
export default new PamDiff();
|
||||
export default PamDiff;
|
||||
|
||||
4
plugins/python-codecs/.vscode/settings.json
vendored
4
plugins/python-codecs/.vscode/settings.json
vendored
@@ -4,6 +4,10 @@
|
||||
// "scrypted.debugHost": "koushik-ubuntu",
|
||||
// "scrypted.serverRoot": "/server",
|
||||
|
||||
// windows installation
|
||||
// "scrypted.debugHost": "koushik-windows",
|
||||
// "scrypted.serverRoot": "C:\\Users\\koush\\.scrypted",
|
||||
|
||||
// pi local installation
|
||||
// "scrypted.debugHost": "192.168.2.119",
|
||||
// "scrypted.serverRoot": "/home/pi/.scrypted",
|
||||
|
||||
4
plugins/python-codecs/package-lock.json
generated
4
plugins/python-codecs/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/python-codecs",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/python-codecs",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.18",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/python-codecs",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.18",
|
||||
"description": "Python Codecs for Scrypted",
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
|
||||
@@ -3,8 +3,8 @@ from util import optional_chain
|
||||
import scrypted_sdk
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
import pyvips
|
||||
from vips import createVipsMediaObject, VipsImage
|
||||
import vipsimage
|
||||
import pilimage
|
||||
import platform
|
||||
|
||||
Gst = None
|
||||
@@ -53,14 +53,30 @@ async def generateVideoFramesGstreamer(mediaObject: scrypted_sdk.MediaObject, op
|
||||
|
||||
videocaps += ',format={format}'.format(format=format)
|
||||
|
||||
decoder = 'decodebin'
|
||||
if videoCodec == 'h264':
|
||||
decoder = h264Decoder or 'Default'
|
||||
decoder = None
|
||||
def setDecoderClearDefault(value: str):
|
||||
nonlocal decoder
|
||||
decoder = value
|
||||
if decoder == 'Default':
|
||||
decoder = None
|
||||
|
||||
setDecoderClearDefault(None)
|
||||
|
||||
if videoCodec == 'h264':
|
||||
setDecoderClearDefault(h264Decoder)
|
||||
|
||||
if not decoder:
|
||||
# hw acceleration is "safe" to use on mac, but not
|
||||
# on other hosts where it may crash.
|
||||
# defaults must be safe.
|
||||
if platform.system() == 'Darwin':
|
||||
decoder = 'vtdec_hw'
|
||||
else:
|
||||
decoder = 'decodebin'
|
||||
decoder = 'avdec_h264'
|
||||
else:
|
||||
# decodebin may pick a hardware accelerated decoder, which isn't ideal
|
||||
# so use a known software decoder for h264 and decodebin for anything else.
|
||||
decoder = 'decodebin'
|
||||
|
||||
videosrc += ' ! {decoder} ! queue leaky=downstream max-size-buffers=0 ! videoconvert ! {videocaps}'.format(decoder=decoder, videocaps=videocaps)
|
||||
|
||||
@@ -75,13 +91,23 @@ async def generateVideoFramesGstreamer(mediaObject: scrypted_sdk.MediaObject, op
|
||||
continue
|
||||
|
||||
try:
|
||||
vips = pyvips.Image.new_from_memory(info.data, width, height, bands, pyvips.BandFormat.UCHAR)
|
||||
vipsImage = VipsImage(vips)
|
||||
try:
|
||||
mo = await createVipsMediaObject(VipsImage(vips))
|
||||
yield mo
|
||||
finally:
|
||||
vipsImage.vipsImage.invalidate()
|
||||
vipsImage.vipsImage = None
|
||||
if vipsimage.pyvips:
|
||||
vips = vipsimage.new_from_memory(info.data, width, height, bands)
|
||||
vipsImage = vipsimage.VipsImage(vips)
|
||||
try:
|
||||
mo = await vipsimage.createVipsMediaObject(vipsImage)
|
||||
yield mo
|
||||
finally:
|
||||
vipsImage.vipsImage = None
|
||||
vips.invalidate()
|
||||
else:
|
||||
pil = pilimage.new_from_memory(info.data, width, height, bands)
|
||||
pilImage = pilimage.PILImage(pil)
|
||||
try:
|
||||
mo = await pilimage.createPILMediaObject(pilImage)
|
||||
yield mo
|
||||
finally:
|
||||
pilImage.pilImage = None
|
||||
pil.close()
|
||||
finally:
|
||||
gst_buffer.unmap(info)
|
||||
|
||||
@@ -2,8 +2,8 @@ import time
|
||||
from gst_generator import createPipelineIterator
|
||||
import scrypted_sdk
|
||||
from typing import Any
|
||||
import pyvips
|
||||
from vips import createVipsMediaObject, VipsImage
|
||||
import vipsimage
|
||||
import pilimage
|
||||
|
||||
av = None
|
||||
try:
|
||||
@@ -38,14 +38,23 @@ async def generateVideoFramesLibav(mediaObject: scrypted_sdk.MediaObject, option
|
||||
# print('too slow, skipping frame')
|
||||
continue
|
||||
# print(frame)
|
||||
vips = pyvips.Image.new_from_array(frame.to_ndarray(format='rgb24'))
|
||||
vipsImage = VipsImage(vips)
|
||||
try:
|
||||
mo = await createVipsMediaObject(VipsImage(vips))
|
||||
yield mo
|
||||
finally:
|
||||
vipsImage.vipsImage.invalidate()
|
||||
vipsImage.vipsImage = None
|
||||
|
||||
if vipsimage.pyvips:
|
||||
vips = vipsimage.pyvips.Image.new_from_array(frame.to_ndarray(format='rgb24'))
|
||||
vipsImage = vipsimage.VipsImage(vips)
|
||||
try:
|
||||
mo = await vipsimage.createVipsMediaObject(vipsImage)
|
||||
yield mo
|
||||
finally:
|
||||
vipsImage.vipsImage = None
|
||||
vips.invalidate()
|
||||
else:
|
||||
pil = frame.to_image()
|
||||
pilImage = pilimage.PILImage(pil)
|
||||
try:
|
||||
mo = await pilimage.createPILMediaObject(pilImage)
|
||||
yield mo
|
||||
finally:
|
||||
pilImage.pilImage = None
|
||||
pil.close()
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
@@ -4,7 +4,8 @@ from scrypted_sdk import Setting, SettingValue
|
||||
from typing import Any, List
|
||||
import gstreamer
|
||||
import libav
|
||||
import vips
|
||||
import vipsimage
|
||||
import pilimage
|
||||
|
||||
Gst = None
|
||||
try:
|
||||
@@ -110,10 +111,17 @@ class PythonCodecs(scrypted_sdk.ScryptedDeviceBase, scrypted_sdk.DeviceProvider)
|
||||
return GstreamerGenerator('gstreamer')
|
||||
if nativeId == 'libav':
|
||||
return LibavGenerator('libav')
|
||||
if nativeId == 'reader':
|
||||
return vips.ImageReader('reader')
|
||||
if nativeId == 'writer':
|
||||
return vips.ImageWriter('writer')
|
||||
|
||||
if vipsimage.pyvips:
|
||||
if nativeId == 'reader':
|
||||
return vipsimage.ImageReader('reader')
|
||||
if nativeId == 'writer':
|
||||
return vipsimage.ImageWriter('writer')
|
||||
else:
|
||||
if nativeId == 'reader':
|
||||
return pilimage.ImageReader('reader')
|
||||
if nativeId == 'writer':
|
||||
return pilimage.ImageWriter('writer')
|
||||
|
||||
def create_scrypted_plugin():
|
||||
return PythonCodecs()
|
||||
|
||||
114
plugins/python-codecs/src/pilimage.py
Normal file
114
plugins/python-codecs/src/pilimage.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import scrypted_sdk
|
||||
from typing import Any
|
||||
from thread import to_thread
|
||||
import io
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except:
|
||||
# Image = None
|
||||
pass
|
||||
|
||||
class PILImage(scrypted_sdk.VideoFrame):
|
||||
def __init__(self, pilImage: Image.Image) -> None:
|
||||
super().__init__()
|
||||
self.pilImage = pilImage
|
||||
self.width = pilImage.width
|
||||
self.height = pilImage.height
|
||||
|
||||
async def toBuffer(self, options: scrypted_sdk.ImageOptions = None) -> bytearray:
|
||||
pilImage: PILImage = await self.toPILImage(options)
|
||||
|
||||
if not options or not options.get('format', None):
|
||||
def format():
|
||||
bytesArray = io.BytesIO()
|
||||
pilImage.pilImage.save(bytesArray, format='JPEG')
|
||||
return bytesArray.getvalue()
|
||||
return await to_thread(format)
|
||||
elif options['format'] == 'rgb':
|
||||
def format():
|
||||
rgb = pilImage.pilImage
|
||||
if rgb.format == 'RGBA':
|
||||
rgb = rgb.convert('RGB')
|
||||
return rgb.tobytes()
|
||||
return await to_thread(format)
|
||||
|
||||
return await to_thread(lambda: pilImage.pilImage.write_to_buffer('.' + options['format']))
|
||||
|
||||
async def toPILImage(self, options: scrypted_sdk.ImageOptions = None):
|
||||
return await to_thread(lambda: toPILImage(self, options))
|
||||
|
||||
async def toImage(self, options: scrypted_sdk.ImageOptions = None) -> Any:
|
||||
if options and options.get('format', None):
|
||||
raise Exception('format can only be used with toBuffer')
|
||||
newPILImage = await self.toPILImage(options)
|
||||
return await createPILMediaObject(newPILImage)
|
||||
|
||||
def toPILImage(pilImageWrapper: PILImage, options: scrypted_sdk.ImageOptions = None) -> PILImage:
|
||||
pilImage = pilImageWrapper.pilImage
|
||||
if not pilImage:
|
||||
raise Exception('Video Frame has been invalidated')
|
||||
options = options or {}
|
||||
crop = options.get('crop')
|
||||
if crop:
|
||||
pilImage = pilImage.crop((int(crop['left']), int(crop['top']), int(crop['left']) + int(crop['width']), int(crop['top']) + int(crop['height'])))
|
||||
|
||||
resize = options.get('resize')
|
||||
if resize:
|
||||
width = resize.get('width')
|
||||
if width:
|
||||
xscale = resize['width'] / pilImage.width
|
||||
height = pilImage.height * xscale
|
||||
|
||||
height = resize.get('height')
|
||||
if height:
|
||||
yscale = resize['height'] / pilImage.height
|
||||
if not width:
|
||||
width = pilImage.width * yscale
|
||||
|
||||
pilImage = pilImage.resize((width, height), resample=Image.Resampling.BILINEAR)
|
||||
|
||||
return PILImage(pilImage)
|
||||
|
||||
async def createPILMediaObject(image: PILImage):
|
||||
ret = await scrypted_sdk.mediaManager.createMediaObject(image, scrypted_sdk.ScryptedMimeTypes.Image.value, {
|
||||
'format': None,
|
||||
'width': image.width,
|
||||
'height': image.height,
|
||||
'toBuffer': lambda options = None: image.toBuffer(options),
|
||||
'toImage': lambda options = None: image.toImage(options),
|
||||
})
|
||||
return ret
|
||||
|
||||
class ImageReader(scrypted_sdk.ScryptedDeviceBase, scrypted_sdk.BufferConverter):
|
||||
def __init__(self, nativeId: str):
|
||||
super().__init__(nativeId)
|
||||
|
||||
self.fromMimeType = 'image/*'
|
||||
self.toMimeType = scrypted_sdk.ScryptedMimeTypes.Image.value
|
||||
|
||||
async def convert(self, data: Any, fromMimeType: str, toMimeType: str, options: scrypted_sdk.MediaObjectOptions = None) -> Any:
|
||||
pil = Image.open(io.BytesIO(data))
|
||||
return await createPILMediaObject(PILImage(pil))
|
||||
|
||||
class ImageWriter(scrypted_sdk.ScryptedDeviceBase, scrypted_sdk.BufferConverter):
|
||||
def __init__(self, nativeId: str):
|
||||
super().__init__(nativeId)
|
||||
|
||||
self.fromMimeType = scrypted_sdk.ScryptedMimeTypes.Image.value
|
||||
self.toMimeType = 'image/*'
|
||||
|
||||
async def convert(self, data: scrypted_sdk.VideoFrame, fromMimeType: str, toMimeType: str, options: scrypted_sdk.MediaObjectOptions = None) -> Any:
|
||||
return await data.toBuffer({
|
||||
format: 'jpg',
|
||||
})
|
||||
|
||||
def new_from_memory(data, width: int, height: int, bands: int):
|
||||
data = bytes(data)
|
||||
if bands == 4:
|
||||
return Image.frombuffer('RGBA', (width, height), data)
|
||||
if bands == 3:
|
||||
return Image.frombuffer('RGB', (width, height), data)
|
||||
if bands == 1:
|
||||
return Image.frombuffer('L', (width, height), data)
|
||||
raise Exception('cant handle bands')
|
||||
@@ -2,4 +2,9 @@
|
||||
PyGObject>=3.30.4; sys_platform != 'win32'
|
||||
# libav doesnt work on arm7
|
||||
av>=10.0.0; sys_platform != 'linux' or platform_machine == 'x86_64' or platform_machine == 'aarch64'
|
||||
pyvips
|
||||
pyvips; sys_platform != 'win32'
|
||||
|
||||
# in case pyvips fails to load, use a pillow fallback.
|
||||
# pillow for anything not intel linux, pillow-simd is available on x64 linux
|
||||
Pillow>=5.4.1; sys_platform != 'linux' or platform_machine != 'x86_64'
|
||||
pillow-simd; sys_platform == 'linux' and platform_machine == 'x86_64'
|
||||
|
||||
10
plugins/python-codecs/src/thread.py
Normal file
10
plugins/python-codecs/src/thread.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
import concurrent.futures
|
||||
|
||||
# vips is already multithreaded, but needs to be kicked off the python asyncio thread.
|
||||
toThreadExecutor = concurrent.futures.ThreadPoolExecutor(max_workers=2, thread_name_prefix="image")
|
||||
|
||||
async def to_thread(f):
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(toThreadExecutor, f)
|
||||
@@ -1,22 +1,16 @@
|
||||
import time
|
||||
from gst_generator import createPipelineIterator
|
||||
import asyncio
|
||||
from util import optional_chain
|
||||
import scrypted_sdk
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
import pyvips
|
||||
import concurrent.futures
|
||||
|
||||
# vips is already multithreaded, but needs to be kicked off the python asyncio thread.
|
||||
vipsExecutor = concurrent.futures.ThreadPoolExecutor(max_workers=2, thread_name_prefix="vips")
|
||||
|
||||
async def to_thread(f):
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(vipsExecutor, f)
|
||||
try:
|
||||
import pyvips
|
||||
from pyvips import Image
|
||||
except:
|
||||
Image = None
|
||||
pyvips = None
|
||||
pass
|
||||
from thread import to_thread
|
||||
|
||||
class VipsImage(scrypted_sdk.VideoFrame):
|
||||
def __init__(self, vipsImage: pyvips.Image) -> None:
|
||||
def __init__(self, vipsImage: Image) -> None:
|
||||
super().__init__()
|
||||
self.vipsImage = vipsImage
|
||||
self.width = vipsImage.width
|
||||
@@ -32,7 +26,7 @@ class VipsImage(scrypted_sdk.VideoFrame):
|
||||
elif options['format'] == 'rgb':
|
||||
def format():
|
||||
if vipsImage.vipsImage.hasalpha():
|
||||
rgb = vipsImage.vipsImage.extract_band(0, vipsImage.vipsImage.bands - 1)
|
||||
rgb = vipsImage.vipsImage.extract_band(0, n=vipsImage.vipsImage.bands - 1)
|
||||
else:
|
||||
rgb = vipsImage.vipsImage
|
||||
mem = memoryview(rgb.write_to_memory())
|
||||
@@ -81,6 +75,7 @@ def toVipsImage(vipsImageWrapper: VipsImage, options: scrypted_sdk.ImageOptions
|
||||
|
||||
async def createVipsMediaObject(image: VipsImage):
|
||||
ret = await scrypted_sdk.mediaManager.createMediaObject(image, scrypted_sdk.ScryptedMimeTypes.Image.value, {
|
||||
'format': None,
|
||||
'width': image.width,
|
||||
'height': image.height,
|
||||
'toBuffer': lambda options = None: image.toBuffer(options),
|
||||
@@ -96,7 +91,7 @@ class ImageReader(scrypted_sdk.ScryptedDeviceBase, scrypted_sdk.BufferConverter)
|
||||
self.toMimeType = scrypted_sdk.ScryptedMimeTypes.Image.value
|
||||
|
||||
async def convert(self, data: Any, fromMimeType: str, toMimeType: str, options: scrypted_sdk.MediaObjectOptions = None) -> Any:
|
||||
vips = pyvips.Image.new_from_buffer(data, '')
|
||||
vips = Image.new_from_buffer(data, '')
|
||||
return await createVipsMediaObject(VipsImage(vips))
|
||||
|
||||
class ImageWriter(scrypted_sdk.ScryptedDeviceBase, scrypted_sdk.BufferConverter):
|
||||
@@ -110,3 +105,6 @@ class ImageWriter(scrypted_sdk.ScryptedDeviceBase, scrypted_sdk.BufferConverter)
|
||||
return await data.toBuffer({
|
||||
format: 'jpg',
|
||||
})
|
||||
|
||||
def new_from_memory(data, width: int, height: int, bands: int):
|
||||
return Image.new_from_memory(data, width, height, bands, pyvips.BandFormat.UCHAR)
|
||||
12
plugins/remote/package-lock.json
generated
12
plugins/remote/package-lock.json
generated
@@ -19,23 +19,23 @@
|
||||
},
|
||||
"../../packages/client": {
|
||||
"name": "@scrypted/client",
|
||||
"version": "1.1.39",
|
||||
"version": "1.1.43",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@scrypted/types": "^0.2.65",
|
||||
"@scrypted/types": "^0.2.76",
|
||||
"axios": "^0.25.0",
|
||||
"engine.io-client": "^6.2.2",
|
||||
"engine.io-client": "^6.4.0",
|
||||
"rimraf": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ip": "^1.1.0",
|
||||
"@types/node": "^17.0.17",
|
||||
"typescript": "^4.7.4"
|
||||
"@types/node": "^18.14.2",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
},
|
||||
"../../sdk": {
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.2.70",
|
||||
"version": "0.2.85",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
|
||||
@@ -55,13 +55,17 @@ class ScryptedRemoteInstance extends ScryptedDeviceBase implements DeviceProvide
|
||||
},
|
||||
});
|
||||
|
||||
fromMimeType: string = ""
|
||||
toMimeType: string = ""
|
||||
|
||||
constructor(nativeId: string) {
|
||||
super(nativeId);
|
||||
this.clearTryDiscoverDevices();
|
||||
|
||||
|
||||
this.fromMimeType = 'x-scrypted-remote/x-media-object-' + this.id;
|
||||
this.toMimeType = ScryptedMimeTypes.MediaObject;
|
||||
this.toMimeType = '*';
|
||||
sdk.mediaManager.addConverter(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,9 +197,11 @@ class ScryptedRemoteInstance extends ScryptedDeviceBase implements DeviceProvide
|
||||
});
|
||||
|
||||
this.client.onClose = () => {
|
||||
this.console.log('client killed')
|
||||
this.console.log('client killed, reconnecting in 60s');
|
||||
setTimeout(async () => await this.clearTryDiscoverDevices(), 60000);
|
||||
}
|
||||
|
||||
/* bjia56: since the MediaObject conversion isn't completely implemented, disable this for now
|
||||
const { rpcPeer } = this.client;
|
||||
const map = new WeakMap<RemoteMediaObject, MediaObject>();
|
||||
rpcPeer.nameDeserializerMap.set('MediaObject', {
|
||||
@@ -215,11 +221,13 @@ class ScryptedRemoteInstance extends ScryptedDeviceBase implements DeviceProvide
|
||||
return rmo;
|
||||
},
|
||||
});
|
||||
*/
|
||||
|
||||
this.console.log(`Connected to remote Scrypted server. Remote server version: ${this.client.serverVersion}`)
|
||||
}
|
||||
|
||||
async convert(data: RemoteMediaObject, fromMimeType: string, toMimeType: string, options?: MediaObjectOptions): Promise<any> {
|
||||
if (toMimeType === 'x-scrypted-remote/x-media-object')
|
||||
if (toMimeType.startsWith('x-scrypted-remote/x-media-object'))
|
||||
return data;
|
||||
let ret = await this.client.mediaManager.convertMediaObject(data, toMimeType);
|
||||
if (toMimeType === ScryptedMimeTypes.FFmpegInput) {
|
||||
@@ -298,9 +306,9 @@ class ScryptedRemoteInstance extends ScryptedDeviceBase implements DeviceProvide
|
||||
|
||||
// first register the top level devices, then register the remaining
|
||||
// devices by provider id
|
||||
await deviceManager.onDevicesChanged(<DeviceManifest>{
|
||||
devices: providerDeviceMap.get(this.nativeId),
|
||||
providerNativeId: this.nativeId,
|
||||
// top level devices are discovered one by one to avoid clobbering
|
||||
providerDeviceMap.get(this.nativeId).map(async device => {
|
||||
await deviceManager.onDeviceDiscovered(device);
|
||||
});
|
||||
for (let [providerNativeId, devices] of providerDeviceMap) {
|
||||
await deviceManager.onDevicesChanged(<DeviceManifest>{
|
||||
|
||||
4
plugins/reolink/package-lock.json
generated
4
plugins/reolink/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/reolink",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.17",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/reolink",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.17",
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/reolink",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.17",
|
||||
"description": "Reolink Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -196,7 +196,7 @@ class ReolinkProider extends RtspProvider {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
settings.newCamera ||= 'Hikvision Camera';
|
||||
settings.newCamera ||= 'Reolink Camera';
|
||||
|
||||
nativeId = await super.createDevice(settings, nativeId);
|
||||
|
||||
|
||||
@@ -22,11 +22,18 @@ Do not enable prebuffer on Ring cameras and doorbells.
|
||||
### Other Devices
|
||||
- Security Panel
|
||||
- Location Modes
|
||||
- Contact Sensor / Retrofit Alarm Zones / Tilt Sensor
|
||||
- Contact Sensor
|
||||
- Retrofit Alarm Zones
|
||||
- Tilt Sensor
|
||||
- Glassbreak Sensor
|
||||
- Motion Sensor
|
||||
- Outdoor Motion Sensor
|
||||
- Flood / Freeze Sensor
|
||||
- Water Sensor
|
||||
- Mailbox Sensor
|
||||
- Smart Locks
|
||||
- Ring Smart Lights (Flood/Path/Step/Spot Lights, Bulbs, Transformer)
|
||||
- Lights, Switches & Outlets
|
||||
|
||||
## Problems and Solutions
|
||||
|
||||
|
||||
4
plugins/ring/package-lock.json
generated
4
plugins/ring/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/ring",
|
||||
"version": "0.0.100",
|
||||
"version": "0.0.106",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/ring",
|
||||
"version": "0.0.100",
|
||||
"version": "0.0.106",
|
||||
"dependencies": {
|
||||
"@koush/ring-client-api": "file:../../external/ring-client-api",
|
||||
"@scrypted/common": "file:../../common",
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"got": "11.8.6",
|
||||
"socket.io-client": "^2.5.0"
|
||||
},
|
||||
"version": "0.0.100"
|
||||
"version": "0.0.106"
|
||||
}
|
||||
|
||||
758
plugins/ring/src/camera.ts
Normal file
758
plugins/ring/src/camera.ts
Normal file
@@ -0,0 +1,758 @@
|
||||
import { closeQuiet, createBindZero, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { RefreshPromise } from "@scrypted/common/src/promise-utils";
|
||||
import { connectRTCSignalingClients } from '@scrypted/common/src/rtc-signaling';
|
||||
import { RtspServer } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls, parseSdp, replacePorts } from '@scrypted/common/src/sdp-utils';
|
||||
import sdk, { BinarySensor, Camera, Device, DeviceProvider, FFmpegInput, MediaObject, MediaStreamUrl, MotionSensor, OnOff, PictureOptions, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, RTCAVSignalingSetup, RTCSessionControl, RTCSignalingChannel, RTCSignalingSendIceCandidate, RTCSignalingSession, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, VideoCamera, VideoClip, VideoClipOptions, VideoClips } from '@scrypted/sdk';
|
||||
import child_process, { ChildProcess } from 'child_process';
|
||||
import dgram from 'dgram';
|
||||
import { RtcpReceiverInfo, RtcpRrPacket } from '../../../external/werift/packages/rtp/src/rtcp/rr';
|
||||
import { RtpPacket } from '../../../external/werift/packages/rtp/src/rtp/rtp';
|
||||
import { ProtectionProfileAes128CmHmacSha1_80 } from '../../../external/werift/packages/rtp/src/srtp/const';
|
||||
import { SrtcpSession } from '../../../external/werift/packages/rtp/src/srtp/srtcp';
|
||||
import { BasicPeerConnection, CameraData, clientApi, isStunMessage, RingBaseApi, RingCamera, RtpDescription, rxjs, SimpleWebRtcSession, SipSession, StreamingSession } from './ring-client-api';
|
||||
import { encodeSrtpOptions, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from './srtp-utils';
|
||||
|
||||
const STREAM_TIMEOUT = 120000;
|
||||
const { deviceManager, mediaManager, systemManager } = sdk;
|
||||
|
||||
class RingWebSocketRTCSessionControl implements RTCSessionControl {
|
||||
constructor(public streamingSession: StreamingSession, public onConnectionState: rxjs.Subject<RTCPeerConnectionState>) {
|
||||
}
|
||||
|
||||
async setPlayback(options: { audio: boolean; video: boolean; }) {
|
||||
if (this.streamingSession.cameraSpeakerActivated !== options.audio)
|
||||
this.streamingSession.setCameraSpeaker(options.audio);
|
||||
}
|
||||
|
||||
async getRefreshAt() {}
|
||||
|
||||
async extendSession() {}
|
||||
|
||||
async endSession() {
|
||||
this.streamingSession.stop();
|
||||
}
|
||||
}
|
||||
|
||||
class RingBrowserRTCSessionControl implements RTCSessionControl {
|
||||
constructor(public ringCamera: RingCameraDevice, public simpleSession: SimpleWebRtcSession) {}
|
||||
|
||||
async setPlayback(options: { audio: boolean; video: boolean; }) {}
|
||||
|
||||
async getRefreshAt() {}
|
||||
|
||||
async extendSession() {}
|
||||
|
||||
async endSession() {
|
||||
await this.simpleSession.end();
|
||||
}
|
||||
}
|
||||
|
||||
class RingCameraLight extends ScryptedDeviceBase implements OnOff {
|
||||
constructor(public device: RingCameraDevice) {
|
||||
super(device.nativeId + '-light');
|
||||
}
|
||||
|
||||
async turnOff(): Promise<void> {
|
||||
await this.device.camera.setLight(false);
|
||||
}
|
||||
|
||||
async turnOn(): Promise<void> {
|
||||
await this.device.camera.setLight(true);
|
||||
}
|
||||
}
|
||||
|
||||
class RingCameraSiren extends ScryptedDeviceBase implements OnOff {
|
||||
constructor(public device: RingCameraDevice) {
|
||||
super(device.nativeId + '-siren');
|
||||
}
|
||||
|
||||
async turnOff(): Promise<void> {
|
||||
await this.device.camera.setSiren(false);
|
||||
}
|
||||
|
||||
async turnOn(): Promise<void> {
|
||||
await this.device.camera.setSiren(true);
|
||||
}
|
||||
}
|
||||
|
||||
export class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Camera, MotionSensor, BinarySensor, RTCSignalingChannel, VideoClips {
|
||||
camera: RingCamera;
|
||||
buttonTimeout: NodeJS.Timeout;
|
||||
session: SipSession;
|
||||
rtpDescription: RtpDescription;
|
||||
audioOutForwarder: dgram.Socket;
|
||||
audioOutProcess: ChildProcess;
|
||||
currentMedia: FFmpegInput | MediaStreamUrl;
|
||||
currentMediaMimeType: string;
|
||||
refreshTimeout: NodeJS.Timeout;
|
||||
picturePromise: RefreshPromise<Buffer>;
|
||||
videoClips = new Map<string, VideoClip>();
|
||||
|
||||
constructor(public api: RingBaseApi, nativeId: string, camera: RingCamera) {
|
||||
super(nativeId);
|
||||
this.camera = camera;
|
||||
this.motionDetected = false;
|
||||
this.binaryState = false;
|
||||
if (this.interfaces.includes(ScryptedInterface.Battery))
|
||||
this.batteryLevel = this.camera.batteryLevel;
|
||||
|
||||
camera.onDoorbellPressed?.subscribe(async e => {
|
||||
this.console.log(camera.name, 'onDoorbellPressed', e);
|
||||
this.triggerBinaryState();
|
||||
});
|
||||
camera.onMotionDetected?.subscribe(async motionDetected => {
|
||||
if (motionDetected)
|
||||
this.console.log(camera.name, 'onMotionDetected');
|
||||
this.motionDetected = motionDetected;
|
||||
});
|
||||
camera.onMotionDetectedPolling?.subscribe(async motionDetected => {
|
||||
if (motionDetected)
|
||||
this.console.log(camera.name, 'onMotionDetected');
|
||||
this.motionDetected = motionDetected;
|
||||
});
|
||||
camera.onBatteryLevel?.subscribe(async () => {
|
||||
this.batteryLevel = camera.batteryLevel;
|
||||
});
|
||||
camera.onData.subscribe(async data => {
|
||||
this.updateState(data)
|
||||
});
|
||||
|
||||
this.discoverDevices();
|
||||
}
|
||||
|
||||
async discoverDevices() {
|
||||
if (this.camera.hasSiren || this.camera.hasLight) {
|
||||
let devices = [];
|
||||
if (this.camera.hasLight) {
|
||||
const device: Device = {
|
||||
providerNativeId: this.nativeId,
|
||||
info: {
|
||||
model: `${this.camera.model} (${this.camera.data.kind})`,
|
||||
manufacturer: 'Ring',
|
||||
firmware: this.camera.data.firmware_version,
|
||||
serialNumber: this.camera.data.device_id
|
||||
},
|
||||
nativeId: this.nativeId + '-light',
|
||||
name: this.camera.name + ' Light',
|
||||
type: ScryptedDeviceType.Light,
|
||||
interfaces: [ScryptedInterface.OnOff],
|
||||
};
|
||||
devices.push(device);
|
||||
}
|
||||
if (this.camera.hasSiren) {
|
||||
const device: Device = {
|
||||
providerNativeId: this.nativeId,
|
||||
info: {
|
||||
model: `${this.camera.model} (${this.camera.data.kind})`,
|
||||
manufacturer: 'Ring',
|
||||
firmware: this.camera.data.firmware_version,
|
||||
serialNumber: this.camera.data.device_id
|
||||
},
|
||||
nativeId: this.nativeId + '-siren',
|
||||
name: this.camera.name + ' Siren',
|
||||
type: ScryptedDeviceType.Siren,
|
||||
interfaces: [ScryptedInterface.OnOff],
|
||||
};
|
||||
devices.push(device);
|
||||
}
|
||||
deviceManager.onDevicesChanged({
|
||||
providerNativeId: this.nativeId,
|
||||
devices: devices,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string) {
|
||||
if (nativeId.endsWith('-siren')) {
|
||||
return new RingCameraSiren(this);
|
||||
}
|
||||
return new RingCameraLight(this);
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {}
|
||||
|
||||
async startIntercom(media: MediaObject): Promise<void> {
|
||||
if (!this.session)
|
||||
throw new Error("not in call");
|
||||
|
||||
this.stopIntercom();
|
||||
|
||||
const ffmpegInput: FFmpegInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput)).toString());
|
||||
|
||||
const ringRtpOptions = this.rtpDescription;
|
||||
let cameraSpeakerActive = false;
|
||||
const audioOutForwarder = await createBindZero();
|
||||
this.audioOutForwarder = audioOutForwarder.server;
|
||||
audioOutForwarder.server.on('message', message => {
|
||||
if (!cameraSpeakerActive) {
|
||||
cameraSpeakerActive = true;
|
||||
this.session.activateCameraSpeaker().catch(e => this.console.error('camera speaker activation error', e))
|
||||
}
|
||||
|
||||
this.session.audioSplitter.send(message, ringRtpOptions.audio.port, ringRtpOptions.address);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
const args = ffmpegInput.inputArguments.slice();
|
||||
args.push(
|
||||
'-vn', '-dn', '-sn',
|
||||
'-acodec', 'pcm_mulaw',
|
||||
'-flags', '+global_header',
|
||||
'-ac', '1',
|
||||
'-ar', '8k',
|
||||
'-f', 'rtp',
|
||||
'-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80',
|
||||
'-srtp_out_params', encodeSrtpOptions(this.session.rtpOptions.audio),
|
||||
`srtp://127.0.0.1:${audioOutForwarder.port}?pkt_size=188`,
|
||||
);
|
||||
|
||||
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), args);
|
||||
this.audioOutProcess = cp;
|
||||
cp.on('exit', () => this.console.log('two way audio ended'));
|
||||
this.session.onCallEnded.subscribe(() => {
|
||||
closeQuiet(audioOutForwarder.server);
|
||||
cp.kill('SIGKILL');
|
||||
});
|
||||
}
|
||||
|
||||
async stopIntercom(): Promise<void> {
|
||||
closeQuiet(this.audioOutForwarder);
|
||||
this.audioOutProcess?.kill('SIGKILL');
|
||||
this.audioOutProcess = undefined;
|
||||
this.audioOutForwarder = undefined;
|
||||
}
|
||||
|
||||
resetStreamTimeout() {
|
||||
this.console.log('starting/refreshing stream');
|
||||
clearTimeout(this.refreshTimeout);
|
||||
this.refreshTimeout = setTimeout(() => this.stopSession(), STREAM_TIMEOUT);
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
if (this.session) {
|
||||
this.console.log('ending sip session');
|
||||
this.session.stop();
|
||||
this.session = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
get useRtsp() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getVideoStream(options?: RequestMediaStreamOptions): Promise<MediaObject> {
|
||||
|
||||
if (options?.metadata?.refreshAt) {
|
||||
if (!this.currentMedia?.mediaStreamOptions)
|
||||
throw new Error("no stream to refresh");
|
||||
|
||||
const currentMedia = this.currentMedia;
|
||||
currentMedia.mediaStreamOptions.refreshAt = Date.now() + STREAM_TIMEOUT;
|
||||
currentMedia.mediaStreamOptions.metadata = {
|
||||
refreshAt: currentMedia.mediaStreamOptions.refreshAt
|
||||
};
|
||||
this.resetStreamTimeout();
|
||||
return mediaManager.createMediaObject(currentMedia, this.currentMediaMimeType);
|
||||
}
|
||||
|
||||
this.stopSession();
|
||||
|
||||
|
||||
const { clientPromise: playbackPromise, port: playbackPort, url: clientUrl } = await listenZeroSingleClient();
|
||||
|
||||
const useRtsp = this.useRtsp;
|
||||
|
||||
const playbackUrl = useRtsp ? `rtsp://127.0.0.1:${playbackPort}` : clientUrl;
|
||||
|
||||
playbackPromise.then(async (client) => {
|
||||
client.setKeepAlive(true, 10000);
|
||||
let sip: SipSession;
|
||||
try {
|
||||
let rtsp: RtspServer;
|
||||
const cleanup = () => {
|
||||
client.destroy();
|
||||
if (this.session === sip)
|
||||
this.session = undefined;
|
||||
try {
|
||||
this.console.log('stopping ring sip session.');
|
||||
sip.stop();
|
||||
} catch (e) {}
|
||||
rtsp?.destroy();
|
||||
}
|
||||
|
||||
client.on('close', cleanup);
|
||||
client.on('error', cleanup);
|
||||
sip = await this.camera.createSipSession(undefined);
|
||||
sip.onCallEnded.subscribe(cleanup);
|
||||
this.rtpDescription = await sip.start();
|
||||
this.console.log('ring sdp', this.rtpDescription.sdp)
|
||||
|
||||
const videoPort = useRtsp ? 0 : sip.videoSplitter.address().port;
|
||||
const audioPort = useRtsp ? 0 : sip.audioSplitter.address().port;
|
||||
|
||||
let sdp = replacePorts(this.rtpDescription.sdp, audioPort, videoPort);
|
||||
sdp = addTrackControls(sdp);
|
||||
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n');
|
||||
this.console.log('proposed sdp', sdp);
|
||||
|
||||
let vseq = 0;
|
||||
let vseen = 0;
|
||||
let vlost = 0;
|
||||
let aseq = 0;
|
||||
let aseen = 0;
|
||||
let alost = 0;
|
||||
|
||||
if (useRtsp) {
|
||||
rtsp = new RtspServer(client, sdp, true);
|
||||
const parsedSdp = parseSdp(rtsp.sdp);
|
||||
const videoTrack = parsedSdp.msections.find(msection => msection.type === 'video').control;
|
||||
const audioTrack = parsedSdp.msections.find(msection => msection.type === 'audio').control;
|
||||
rtsp.console = this.console;
|
||||
|
||||
await rtsp.handlePlayback();
|
||||
sip.videoSplitter.on('message', message => {
|
||||
if (!isStunMessage(message)) {
|
||||
const isRtpMessage = isRtpMessagePayloadType(getPayloadType(message));
|
||||
if (!isRtpMessage)
|
||||
return;
|
||||
vseen++;
|
||||
rtsp.sendTrack(videoTrack, message, !isRtpMessage);
|
||||
const seq = getSequenceNumber(message);
|
||||
if (seq !== (vseq + 1) % 0x0FFFF)
|
||||
vlost++;
|
||||
vseq = seq;
|
||||
}
|
||||
});
|
||||
|
||||
sip.videoRtcpSplitter.on('message', message => {
|
||||
rtsp.sendTrack(videoTrack, message, true);
|
||||
});
|
||||
|
||||
sip.videoSplitter.once('message', message => {
|
||||
const srtcp = new SrtcpSession({
|
||||
profile: ProtectionProfileAes128CmHmacSha1_80,
|
||||
keys: {
|
||||
localMasterKey: this.rtpDescription.video.srtpKey,
|
||||
localMasterSalt: this.rtpDescription.video.srtpSalt,
|
||||
remoteMasterKey: this.rtpDescription.video.srtpKey,
|
||||
remoteMasterSalt: this.rtpDescription.video.srtpSalt,
|
||||
},
|
||||
});
|
||||
const first = srtcp.decrypt(message);
|
||||
const rtp = RtpPacket.deSerialize(first);
|
||||
|
||||
const report = new RtcpReceiverInfo({
|
||||
ssrc: rtp.header.ssrc,
|
||||
fractionLost: 0,
|
||||
packetsLost: 0,
|
||||
highestSequence: rtp.header.sequenceNumber,
|
||||
jitter: 0,
|
||||
lsr: 0,
|
||||
dlsr: 0,
|
||||
})
|
||||
|
||||
const rr = new RtcpRrPacket({
|
||||
ssrc: rtp.header.ssrc,
|
||||
reports: [
|
||||
report,
|
||||
],
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
report.highestSequence = vseq;
|
||||
report.packetsLost = vlost;
|
||||
report.fractionLost = Math.round(vlost * 100 / vseen);
|
||||
const packet = srtcp.encrypt(rr.serialize());
|
||||
sip.videoSplitter.send(packet, this.rtpDescription.video.rtcpPort, this.rtpDescription.address)
|
||||
}, 500);
|
||||
sip.videoSplitter.on('close', () => clearInterval(interval))
|
||||
});
|
||||
|
||||
sip.audioSplitter.on('message', message => {
|
||||
if (!isStunMessage(message)) {
|
||||
const isRtpMessage = isRtpMessagePayloadType(getPayloadType(message));
|
||||
if (!isRtpMessage)
|
||||
return;
|
||||
aseen++;
|
||||
rtsp.sendTrack(audioTrack, message, !isRtpMessage);
|
||||
const seq = getSequenceNumber(message);
|
||||
if (seq !== (aseq + 1) % 0x0FFFF)
|
||||
alost++;
|
||||
aseq = seq;
|
||||
}
|
||||
});
|
||||
|
||||
sip.audioRtcpSplitter.on('message', message => {
|
||||
rtsp.sendTrack(audioTrack, message, true);
|
||||
});
|
||||
|
||||
sip.requestKeyFrame();
|
||||
this.session = sip;
|
||||
|
||||
try {
|
||||
await rtsp.handleTeardown();
|
||||
this.console.log('rtsp client ended');
|
||||
} catch (e) {
|
||||
this.console.log('rtsp client ended ungracefully', e);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
} else {
|
||||
this.session = sip;
|
||||
|
||||
const packetWaiter = new Promise(resolve => sip.videoSplitter.once('message', resolve));
|
||||
|
||||
await packetWaiter;
|
||||
|
||||
await new Promise(resolve => sip.videoSplitter.close(() => resolve(undefined)));
|
||||
await new Promise(resolve => sip.audioSplitter.close(() => resolve(undefined)));
|
||||
await new Promise(resolve => sip.videoRtcpSplitter.close(() => resolve(undefined)));
|
||||
await new Promise(resolve => sip.audioRtcpSplitter.close(() => resolve(undefined)));
|
||||
|
||||
client.write(sdp + '\r\n');
|
||||
client.end();
|
||||
|
||||
sip.requestKeyFrame();
|
||||
}
|
||||
} catch (e) {
|
||||
sip?.stop();
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
this.resetStreamTimeout();
|
||||
|
||||
const mediaStreamOptions = Object.assign(this.getSipMediaStreamOptions(), {
|
||||
refreshAt: Date.now() + STREAM_TIMEOUT,
|
||||
});
|
||||
if (useRtsp) {
|
||||
const mediaStreamUrl: MediaStreamUrl = {
|
||||
url: playbackUrl,
|
||||
mediaStreamOptions,
|
||||
};
|
||||
this.currentMedia = mediaStreamUrl;
|
||||
this.currentMediaMimeType = ScryptedMimeTypes.MediaStreamUrl;
|
||||
|
||||
return mediaManager.createMediaObject(mediaStreamUrl, ScryptedMimeTypes.MediaStreamUrl);
|
||||
}
|
||||
|
||||
const ffmpegInput: FFmpegInput = {
|
||||
url: undefined,
|
||||
container: 'sdp',
|
||||
mediaStreamOptions,
|
||||
inputArguments: [
|
||||
'-f', 'sdp',
|
||||
'-i', playbackUrl,
|
||||
],
|
||||
};
|
||||
this.currentMedia = ffmpegInput;
|
||||
this.currentMediaMimeType = ScryptedMimeTypes.FFmpegInput;
|
||||
|
||||
return mediaManager.createFFmpegMediaObject(ffmpegInput);
|
||||
}
|
||||
|
||||
getSipMediaStreamOptions(): ResponseMediaStreamOptions {
|
||||
const useRtsp = this.useRtsp;
|
||||
|
||||
return {
|
||||
id: 'sip',
|
||||
name: 'SIP',
|
||||
// this stream is NOT scrypted blessed due to wackiness in the h264 stream.
|
||||
// tool: "scrypted",
|
||||
container: useRtsp ? 'rtsp' : 'sdp',
|
||||
video: {
|
||||
codec: 'h264',
|
||||
h264Info: {
|
||||
sei: true,
|
||||
stapb: true,
|
||||
mtap16: true,
|
||||
mtap32: true,
|
||||
fuab: true,
|
||||
reserved0: true,
|
||||
reserved30: true,
|
||||
reserved31: true,
|
||||
}
|
||||
},
|
||||
audio: {
|
||||
// this is a hint to let homekit, et al, know that it's PCM audio and needs transcoding.
|
||||
codec: 'pcm_mulaw',
|
||||
},
|
||||
source: 'cloud',
|
||||
userConfigurable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
||||
return [
|
||||
this.getSipMediaStreamOptions(),
|
||||
]
|
||||
}
|
||||
|
||||
async startRTCSignalingSession(session: RTCSignalingSession): Promise<RTCSessionControl> {
|
||||
const options = await session.getOptions();
|
||||
|
||||
let sessionControl: RTCSessionControl;
|
||||
|
||||
// ring has two webrtc endpoints. one is for the android/ios clients, wherein the ring server
|
||||
// sends an offer, which only has h264 high in it, which causes some browsers
|
||||
// like Safari (and probably Chromecast) to fail on codec negotiation.
|
||||
// if any video capabilities are offered, use the browser endpoint for safety.
|
||||
// this should be improved further in the future by inspecting the capabilities
|
||||
// since this currently defaults to using the baseline profile on Chrome when high is supported.
|
||||
if (options?.capabilities?.video
|
||||
// this endpoint does not work on ring edge.
|
||||
&& !this.camera.isRingEdgeEnabled) {
|
||||
// the browser path will automatically activate the speaker on the ring.
|
||||
let answerSdp: string;
|
||||
const simple = this.camera.createSimpleWebRtcSession();
|
||||
|
||||
await connectRTCSignalingClients(this.console, session, {
|
||||
type: 'offer',
|
||||
audio: {
|
||||
direction: 'sendrecv',
|
||||
},
|
||||
video: {
|
||||
direction: 'recvonly',
|
||||
},
|
||||
getUserMediaSafariHack: true,
|
||||
}, {
|
||||
createLocalDescription: async (type: 'offer' | 'answer', setup: RTCAVSignalingSetup, sendIceCandidate: RTCSignalingSendIceCandidate) => {
|
||||
if (type !== 'answer')
|
||||
throw new Error('Ring Camera default endpoint only supports RTC answer');
|
||||
|
||||
return {
|
||||
type: 'answer',
|
||||
sdp: answerSdp,
|
||||
};
|
||||
},
|
||||
setRemoteDescription: async (description: RTCSessionDescriptionInit, setup: RTCAVSignalingSetup) => {
|
||||
if (description.type !== 'offer')
|
||||
throw new Error('Ring Camera default endpoint only supports RTC answer');
|
||||
answerSdp = await simple.start(description.sdp);
|
||||
},
|
||||
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
|
||||
throw new Error("Ring Camera default endpoint does not support trickle ICE");
|
||||
},
|
||||
getOptions: async () => {
|
||||
return {
|
||||
requiresOffer: true,
|
||||
disableTrickle: true,
|
||||
};
|
||||
},
|
||||
}, {});
|
||||
|
||||
sessionControl = new RingBrowserRTCSessionControl(this, simple);
|
||||
} else {
|
||||
const onIceCandidate = new rxjs.ReplaySubject<RTCIceCandidateInit>();
|
||||
const onConnectionState = new rxjs.Subject<RTCPeerConnectionState>();
|
||||
|
||||
const configuration: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{
|
||||
urls: [
|
||||
'stun:stun.kinesisvideo.us-east-1.amazonaws.com:443',
|
||||
'stun:stun.kinesisvideo.us-east-2.amazonaws.com:443',
|
||||
'stun:stun.kinesisvideo.us-west-2.amazonaws.com:443',
|
||||
'stun:stun.l.google.com:19302',
|
||||
'stun:stun1.l.google.com:19302',
|
||||
'stun:stun2.l.google.com:19302',
|
||||
'stun:stun3.l.google.com:19302',
|
||||
'stun:stun4.l.google.com:19302',
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const offerSetup: RTCAVSignalingSetup = {
|
||||
type: 'offer',
|
||||
audio: {
|
||||
direction: 'sendrecv',
|
||||
},
|
||||
video: {
|
||||
direction: 'recvonly',
|
||||
},
|
||||
configuration,
|
||||
};
|
||||
const answerSetup: RTCAVSignalingSetup = {
|
||||
type: 'answer',
|
||||
audio: undefined,
|
||||
video: undefined,
|
||||
configuration,
|
||||
};
|
||||
|
||||
const basicPc: BasicPeerConnection = {
|
||||
createOffer: async () => {
|
||||
const local = await session.createLocalDescription('offer', offerSetup, async (candidate) => {
|
||||
onIceCandidate.next(candidate)
|
||||
});
|
||||
|
||||
return {
|
||||
sdp: local.sdp,
|
||||
}
|
||||
},
|
||||
createAnswer: async (offer: RTCSessionDescriptionInit) => {
|
||||
await session.setRemoteDescription(offer, answerSetup);
|
||||
const local = await session.createLocalDescription('answer', answerSetup, async (candidate) => {
|
||||
onIceCandidate.next(candidate)
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'answer',
|
||||
sdp: local.sdp,
|
||||
}
|
||||
},
|
||||
acceptAnswer: async (answer: RTCSessionDescriptionInit) => {
|
||||
await session.setRemoteDescription(answer, offerSetup);
|
||||
},
|
||||
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
|
||||
await session.addIceCandidate(candidate);
|
||||
},
|
||||
close: () => {
|
||||
sessionControl.endSession();
|
||||
},
|
||||
onIceCandidate,
|
||||
onConnectionState,
|
||||
};
|
||||
|
||||
const ringSession = await this.camera.startLiveCall({
|
||||
createPeerConnection: () => basicPc,
|
||||
});
|
||||
ringSession.connection.onMessage.subscribe(message => this.console.log('incoming message', message));
|
||||
ringSession.onCallEnded.subscribe(() => this.console.error('call ended', ringSession.sessionId));
|
||||
|
||||
sessionControl = new RingWebSocketRTCSessionControl(ringSession, onConnectionState);
|
||||
|
||||
// todo: fix this in sdk
|
||||
// setTimeout(() => {
|
||||
// this.console.log('activating connected');
|
||||
// onConnectionState.next('connected');
|
||||
// }, 5000);
|
||||
}
|
||||
|
||||
return sessionControl;
|
||||
}
|
||||
|
||||
async takePicture(options?: RequestPictureOptions): Promise<MediaObject> {
|
||||
// if this stream is prebuffered, its safe to use the prebuffer to generate an image
|
||||
const realDevice = systemManager.getDeviceById<VideoCamera>(this.id);
|
||||
try {
|
||||
if (realDevice.interfaces.includes(ScryptedInterface.VideoCamera)) {
|
||||
const msos = await realDevice.getVideoStreamOptions();
|
||||
const prebuffered: RequestMediaStreamOptions = msos.find(mso => mso.prebuffer);
|
||||
if (prebuffered) {
|
||||
prebuffered.refresh = false;
|
||||
return realDevice.getVideoStream(prebuffered);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let buffer: Buffer;
|
||||
|
||||
// watch for snapshot being blocked due to live stream
|
||||
if (!this.camera.snapshotsAreBlocked) {
|
||||
try {
|
||||
buffer = await this.api.restClient.request({
|
||||
url: `https://app-snaps.ring.com/snapshots/next/${this.camera.id}`,
|
||||
responseType: 'buffer',
|
||||
searchParams: {
|
||||
extras: 'force',
|
||||
},
|
||||
headers: {
|
||||
accept: 'image/jpeg',
|
||||
},
|
||||
allowNoResponse: true,
|
||||
});
|
||||
} catch (e) {
|
||||
this.console.error('snapshot failed, falling back to cache');
|
||||
}
|
||||
}
|
||||
if (!buffer) {
|
||||
buffer = await this.api.restClient.request({
|
||||
url: clientApi(`snapshots/image/${this.camera.id}`),
|
||||
responseType: 'buffer',
|
||||
allowNoResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
return mediaManager.createMediaObject(buffer, 'image/jpeg');
|
||||
}
|
||||
|
||||
async getPictureOptions(): Promise<PictureOptions[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerBinaryState() {
|
||||
this.binaryState = true;
|
||||
clearTimeout(this.buttonTimeout);
|
||||
this.buttonTimeout = setTimeout(() => this.binaryState = false, 10000);
|
||||
}
|
||||
|
||||
async updateState(data: CameraData) {
|
||||
if (this.camera.hasLight && data.led_status) {
|
||||
const light = await this.getDevice('light');
|
||||
light.on = data.led_status === 'on';
|
||||
}
|
||||
|
||||
if (this.camera.hasSiren && data.siren_status) {
|
||||
const siren = await this.getDevice('-siren');
|
||||
siren.on = data.siren_status.seconds_remaining > 0 ? true : false;
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
|
||||
this.videoClips = new Map<string, VideoClip>;
|
||||
const response = await this.camera.videoSearch({
|
||||
dateFrom: options.startTime,
|
||||
dateTo: options.endTime,
|
||||
});
|
||||
|
||||
return response.video_search.map((result) => {
|
||||
const videoClip = {
|
||||
id: result.ding_id,
|
||||
startTime: result.created_at,
|
||||
duration: Math.round(result.duration * 1000),
|
||||
event: result.kind.toString(),
|
||||
description: result.kind.toString(),
|
||||
thumbnailId: result.ding_id,
|
||||
resources: {
|
||||
thumbnail: {
|
||||
href: result.thumbnail_url
|
||||
},
|
||||
video: {
|
||||
href: result.hq_url
|
||||
}
|
||||
}
|
||||
}
|
||||
this.videoClips.set(result.ding_id, videoClip)
|
||||
return videoClip;
|
||||
});
|
||||
}
|
||||
|
||||
async getVideoClip(videoId: string): Promise<MediaObject> {
|
||||
if (!this.videoClips.has(videoId)) {
|
||||
throw new Error('Failed to get video clip.');
|
||||
}
|
||||
return mediaManager.createMediaObjectFromUrl(this.videoClips.get(videoId).resources.video.href);
|
||||
}
|
||||
|
||||
async getVideoClipThumbnail(thumbnailId: string): Promise<MediaObject> {
|
||||
if (!this.videoClips.has(thumbnailId)) {
|
||||
throw new Error('Failed to get video clip thumbnail.');
|
||||
}
|
||||
const ffmpegInput: FFmpegInput = {
|
||||
inputArguments: [
|
||||
'-f', 'h264',
|
||||
'-i', this.videoClips.get(thumbnailId).resources.thumbnail.href,
|
||||
]
|
||||
};
|
||||
const input = await mediaManager.createFFmpegMediaObject(ffmpegInput);
|
||||
const jpeg = await mediaManager.convertMediaObjectToBuffer(input, 'image/jpeg');
|
||||
return await mediaManager.createMediaObject(jpeg, 'image/jpeg');
|
||||
}
|
||||
|
||||
async removeVideoClips(...videoClipIds: string[]): Promise<void> {
|
||||
throw new Error('Removing video clips not supported.');
|
||||
}
|
||||
}
|
||||
397
plugins/ring/src/location.ts
Normal file
397
plugins/ring/src/location.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import sdk, { Battery, Brightness, Device, DeviceProvider, EntrySensor, FloodSensor, Lock, LockState, MotionSensor, OnOff, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, SecuritySystem, SecuritySystemMode, TamperSensor } from '@scrypted/sdk';
|
||||
import { RingCameraDevice } from './camera';
|
||||
import RingPlugin from './main';
|
||||
import { Location, LocationMode, RingCamera, RingDevice, RingDeviceCategory, RingDeviceData, RingDeviceType } from './ring-client-api';
|
||||
|
||||
const { deviceManager } = sdk;
|
||||
|
||||
class RingLock extends ScryptedDeviceBase implements Battery, Lock {
|
||||
device: RingDevice
|
||||
|
||||
constructor(nativeId: string, device: RingDevice) {
|
||||
super(nativeId);
|
||||
this.device = device;
|
||||
this.updateState(device.data);
|
||||
|
||||
device.onData.subscribe(async (data: RingDeviceData) => {
|
||||
this.updateState(data);
|
||||
});
|
||||
}
|
||||
|
||||
async lock(): Promise<void> {
|
||||
return this.device.sendCommand('lock.lock');
|
||||
}
|
||||
|
||||
async unlock(): Promise<void> {
|
||||
return this.device.sendCommand('lock.unlock');
|
||||
}
|
||||
|
||||
updateState(data: RingDeviceData) {
|
||||
this.batteryLevel = data.batteryLevel;
|
||||
switch (data.locked) {
|
||||
case 'locked':
|
||||
this.lockState = LockState.Locked;
|
||||
break;
|
||||
case 'unlocked':
|
||||
this.lockState = LockState.Unlocked;
|
||||
break;
|
||||
case 'jammed':
|
||||
this.lockState = LockState.Jammed;
|
||||
break;
|
||||
default:
|
||||
this.lockState = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RingLight extends ScryptedDeviceBase implements Battery, TamperSensor, MotionSensor, OnOff, Brightness {
|
||||
device: RingDevice;
|
||||
data: RingDeviceData;
|
||||
|
||||
constructor(nativeId: string, device: RingDevice) {
|
||||
super(nativeId);
|
||||
this.device = device;
|
||||
this.data = device.data;
|
||||
this.updateState(device.data);
|
||||
|
||||
device.onData.subscribe(async (data: RingDeviceData) => {
|
||||
this.data = data;
|
||||
this.updateState(data);
|
||||
});
|
||||
}
|
||||
|
||||
private isBeamDevice() {
|
||||
return [RingDeviceType.BeamsMultiLevelSwitch, RingDeviceType.BeamsSwitch, RingDeviceType.BeamsTransformerSwitch].includes(this.device.deviceType);
|
||||
}
|
||||
|
||||
updateState(data: RingDeviceData) {
|
||||
this.batteryLevel = data.batteryLevel;
|
||||
this.tampered = data.tamperStatus === 'tamper';
|
||||
this.motionDetected = data.motionStatus === 'faulted';
|
||||
this.on = data.on;
|
||||
this.brightness = data.level && !isNaN(data.level) ? 100 * data.level : 0;
|
||||
}
|
||||
|
||||
turnOff(): Promise<void> {
|
||||
if (this.isBeamDevice()) {
|
||||
this.device.sendCommand('light-mode.set', { lightMode: 'default' });
|
||||
return;
|
||||
} else {
|
||||
return this.device.setInfo({ device: { v1: { on: false } } });
|
||||
}
|
||||
}
|
||||
|
||||
turnOn(): Promise<void> {
|
||||
if (this.isBeamDevice()) {
|
||||
this.device.sendCommand('light-mode.set', { lightMode: 'on' });
|
||||
return;
|
||||
} else {
|
||||
return this.device.setInfo({ device: { v1: { on: true } } });
|
||||
}
|
||||
}
|
||||
|
||||
setBrightness(brightness: number): Promise<void> {
|
||||
return this.device.setInfo({
|
||||
device: { v1: { level: brightness / 100 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RingSwitch extends ScryptedDeviceBase implements OnOff {
|
||||
device: RingDevice;
|
||||
data: RingDeviceData;
|
||||
|
||||
constructor(nativeId: string, device: RingDevice) {
|
||||
super(nativeId);
|
||||
this.device = device;
|
||||
this.updateState(device.data);
|
||||
|
||||
device.onData.subscribe(async (data: RingDeviceData) => {
|
||||
this.updateState(data);
|
||||
});
|
||||
}
|
||||
|
||||
updateState(data: RingDeviceData) {
|
||||
this.on = data.on;
|
||||
}
|
||||
|
||||
turnOff(): Promise<void> {
|
||||
return this.device.setInfo({ device: { v1: { on: false } } });
|
||||
}
|
||||
|
||||
turnOn(): Promise<void> {
|
||||
return this.device.setInfo({ device: { v1: { on: true } } });
|
||||
}
|
||||
}
|
||||
|
||||
class RingSensor extends ScryptedDeviceBase implements TamperSensor, Battery, EntrySensor, MotionSensor, FloodSensor {
|
||||
device: RingDevice;
|
||||
|
||||
constructor(nativeId: string, device: RingDevice) {
|
||||
super(nativeId);
|
||||
this.device = device;
|
||||
this.updateState(device.data);
|
||||
|
||||
device.onData.subscribe(async (data: RingDeviceData) => {
|
||||
this.updateState(data);
|
||||
});
|
||||
}
|
||||
|
||||
updateState(data: RingDeviceData) {
|
||||
this.batteryLevel = data.batteryLevel;
|
||||
this.tampered = data.tamperStatus === 'tamper';
|
||||
this.entryOpen = data.faulted;
|
||||
this.motionDetected = this.device.deviceType === RingDeviceType.BeamsMotionSensor ? data.motionStatus === 'faulted' : data.faulted;
|
||||
this.flooded = data.flood?.faulted || data.faulted;
|
||||
}
|
||||
|
||||
isBypassable() {
|
||||
return (this.device.deviceType === RingDeviceType.ContactSensor || this.device.deviceType === RingDeviceType.RetrofitZone) && this.device.data.faulted;
|
||||
}
|
||||
}
|
||||
|
||||
export class RingLocationDevice extends ScryptedDeviceBase implements DeviceProvider, SecuritySystem {
|
||||
plugin: RingPlugin;
|
||||
location: Location;
|
||||
devices = new Map<string, any>();
|
||||
locationDevices = new Map<string, RingDevice | RingCamera>();
|
||||
|
||||
constructor(plugin: RingPlugin, nativeId: string, location: Location) {
|
||||
super(nativeId);
|
||||
this.plugin = plugin;
|
||||
this.location = location;
|
||||
|
||||
this.location.onLocationMode.subscribe(mode => this.updateLocationMode(mode));
|
||||
|
||||
// if the location has a base station, updates when arming/disarming are not sent to the `onLocationMode` subscription
|
||||
// instead we subscribe to the security panel, which is updated during arming actions
|
||||
this.location.getSecurityPanel().then(panel => {
|
||||
panel.onData.subscribe(_ => {
|
||||
this.location.getLocationMode();
|
||||
});
|
||||
}).catch(error => {
|
||||
// could not find a security panel for location
|
||||
// not logging this error as it is a valid case to not have a security panel
|
||||
});
|
||||
|
||||
if (this.location.hasAlarmBaseStation) {
|
||||
this.location.getLocationMode();
|
||||
}
|
||||
|
||||
this.discoverDevices();
|
||||
}
|
||||
|
||||
async discoverDevices() {
|
||||
this.locationDevices.clear();
|
||||
const devices: Device[] = [];
|
||||
const cameras = this.location.cameras;
|
||||
for (const camera of cameras) {
|
||||
const nativeId = camera.id.toString();
|
||||
const interfaces = [
|
||||
ScryptedInterface.Camera,
|
||||
ScryptedInterface.MotionSensor,
|
||||
ScryptedInterface.RTCSignalingChannel,
|
||||
];
|
||||
if (!camera.isRingEdgeEnabled) {
|
||||
interfaces.push(
|
||||
ScryptedInterface.VideoCamera,
|
||||
ScryptedInterface.Intercom,
|
||||
ScryptedInterface.VideoClips,
|
||||
);
|
||||
}
|
||||
if (camera.operatingOnBattery)
|
||||
interfaces.push(ScryptedInterface.Battery);
|
||||
if (camera.isDoorbot)
|
||||
interfaces.push(ScryptedInterface.BinarySensor);
|
||||
if (camera.hasLight)
|
||||
interfaces.push(ScryptedInterface.DeviceProvider);
|
||||
if (camera.hasSiren)
|
||||
interfaces.push(ScryptedInterface.DeviceProvider);
|
||||
const device: Device = {
|
||||
info: {
|
||||
model: `${camera.model} (${camera.data.kind})`,
|
||||
manufacturer: 'Ring',
|
||||
firmware: camera.data.firmware_version,
|
||||
serialNumber: camera.data.device_id
|
||||
},
|
||||
providerNativeId: this.location.id,
|
||||
nativeId,
|
||||
name: camera.name,
|
||||
type: camera.isDoorbot ? ScryptedDeviceType.Doorbell : ScryptedDeviceType.Camera,
|
||||
interfaces,
|
||||
};
|
||||
devices.push(device);
|
||||
this.locationDevices.set(nativeId, camera);
|
||||
}
|
||||
|
||||
const locationDevices = await this.location.getDevices();
|
||||
for (const locationDevice of locationDevices) {
|
||||
const data: RingDeviceData = locationDevice.data;
|
||||
let nativeId: string;
|
||||
let type: ScryptedDeviceType;
|
||||
let interfaces: ScryptedInterface[] = [];
|
||||
|
||||
if (data.status === 'disabled') {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (data.deviceType) {
|
||||
case RingDeviceType.ContactSensor:
|
||||
case RingDeviceType.RetrofitZone:
|
||||
case RingDeviceType.TiltSensor:
|
||||
case RingDeviceType.GlassbreakSensor:
|
||||
nativeId = locationDevice.id.toString() + '-sensor';
|
||||
type = ScryptedDeviceType.Sensor;
|
||||
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.EntrySensor);
|
||||
break;
|
||||
case RingDeviceType.MotionSensor:
|
||||
case RingDeviceType.BeamsMotionSensor:
|
||||
nativeId = locationDevice.id.toString() + '-sensor';
|
||||
type = ScryptedDeviceType.Sensor;
|
||||
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.MotionSensor);
|
||||
break;
|
||||
case RingDeviceType.FloodFreezeSensor:
|
||||
case RingDeviceType.WaterSensor:
|
||||
nativeId = locationDevice.id.toString() + '-sensor';
|
||||
type = ScryptedDeviceType.Sensor;
|
||||
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.FloodSensor);
|
||||
break;
|
||||
case RingDeviceType.BeamsMultiLevelSwitch:
|
||||
case RingDeviceType.BeamsSwitch:
|
||||
case RingDeviceType.BeamsTransformerSwitch:
|
||||
case RingDeviceType.MultiLevelBulb:
|
||||
nativeId = locationDevice.id.toString() + '-light';
|
||||
type = ScryptedDeviceType.Light;
|
||||
interfaces.push(ScryptedInterface.OnOff);
|
||||
if (data.level !== undefined)
|
||||
interfaces.push(ScryptedInterface.Brightness)
|
||||
if (data.motionStatus !== undefined && !!data.motionSensorEnabled)
|
||||
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.MotionSensor);
|
||||
break;
|
||||
case RingDeviceType.MultiLevelSwitch:
|
||||
if (data.categoryId === RingDeviceCategory.Lights) {
|
||||
nativeId = locationDevice.id.toString() + '-light';
|
||||
type = ScryptedDeviceType.Light;
|
||||
interfaces.push(ScryptedInterface.OnOff);
|
||||
if (data.level !== undefined)
|
||||
interfaces.push(ScryptedInterface.Brightness)
|
||||
break;
|
||||
}
|
||||
case RingDeviceType.Switch:
|
||||
nativeId = locationDevice.id.toString() + '-switch';
|
||||
type = data.categoryId === RingDeviceCategory.Outlets ? ScryptedDeviceType.Outlet : ScryptedDeviceType.Switch;
|
||||
interfaces.push(ScryptedInterface.OnOff);
|
||||
break;
|
||||
default:
|
||||
if (/^lock($|\.)/.test(data.deviceType)) {
|
||||
nativeId = locationDevice.id.toString() + '-lock';
|
||||
type = ScryptedDeviceType.Lock;
|
||||
interfaces.push(ScryptedInterface.Lock);
|
||||
break;
|
||||
} else {
|
||||
this.console.debug(`discovered and ignoring unsupported '${locationDevice.deviceType}' device: '${locationDevice.name}'`)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.batteryStatus !== 'none')
|
||||
interfaces.push(ScryptedInterface.Battery);
|
||||
|
||||
const device: Device = {
|
||||
info: {
|
||||
model: data.deviceType,
|
||||
manufacturer: 'Ring',
|
||||
serialNumber: data.serialNumber ?? 'Unknown'
|
||||
},
|
||||
providerNativeId: this.location.id,
|
||||
nativeId: nativeId,
|
||||
name: locationDevice.name,
|
||||
type: type,
|
||||
interfaces,
|
||||
};
|
||||
devices.push(device);
|
||||
this.locationDevices.set(nativeId, locationDevice);
|
||||
}
|
||||
|
||||
await deviceManager.onDevicesChanged({
|
||||
providerNativeId: this.location.id,
|
||||
devices: devices,
|
||||
});
|
||||
|
||||
// probe to intiailize location devices
|
||||
for (const device of devices) {
|
||||
await this.getDevice(device.nativeId);
|
||||
};
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string) {
|
||||
if (!this.devices.has(nativeId)) {
|
||||
if (nativeId.endsWith('-sensor')) {
|
||||
const device = new RingSensor(nativeId, this.locationDevices.get(nativeId) as RingDevice);
|
||||
this.devices.set(nativeId, device);
|
||||
} else if (nativeId.endsWith('-lock')) {
|
||||
const device = new RingLock(nativeId, this.locationDevices.get(nativeId) as RingDevice);
|
||||
this.devices.set(nativeId, device);
|
||||
} else if (nativeId.endsWith('-light')) {
|
||||
const device = new RingLight(nativeId, this.locationDevices.get(nativeId) as RingDevice);
|
||||
this.devices.set(nativeId, device);
|
||||
} else if (nativeId.endsWith('-switch')) {
|
||||
const device = new RingSwitch(nativeId, this.locationDevices.get(nativeId) as RingDevice);
|
||||
this.devices.set(nativeId, device);
|
||||
} else {
|
||||
const device = new RingCameraDevice(this.plugin.api, nativeId, this.locationDevices.get(nativeId) as RingCamera);
|
||||
this.devices.set(nativeId, device);
|
||||
}
|
||||
}
|
||||
return this.devices.get(nativeId);
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {}
|
||||
|
||||
updateLocationMode(locationMode: LocationMode) {
|
||||
let mode: SecuritySystemMode;
|
||||
if (locationMode === 'away')
|
||||
mode = SecuritySystemMode.AwayArmed;
|
||||
else if (locationMode === 'home')
|
||||
mode = SecuritySystemMode.HomeArmed;
|
||||
else
|
||||
mode = SecuritySystemMode.Disarmed;
|
||||
|
||||
let supportedModes = [
|
||||
SecuritySystemMode.Disarmed,
|
||||
SecuritySystemMode.AwayArmed,
|
||||
SecuritySystemMode.HomeArmed
|
||||
]
|
||||
if (this.plugin.settingsStorage.values.nightModeBypassAlarmState !== 'Disabled') {
|
||||
supportedModes.push(SecuritySystemMode.NightArmed)
|
||||
}
|
||||
|
||||
this.securitySystemState = {
|
||||
mode,
|
||||
// how to get this?
|
||||
triggered: false,
|
||||
supportedModes
|
||||
}
|
||||
}
|
||||
|
||||
async armSecuritySystem(mode: SecuritySystemMode): Promise<void> {
|
||||
if (mode === SecuritySystemMode.AwayArmed) {
|
||||
await this.location.armAway();
|
||||
} else if (mode === SecuritySystemMode.HomeArmed) {
|
||||
await this.location.armHome();
|
||||
} else if (mode === SecuritySystemMode.NightArmed) {
|
||||
const bypassContactSensors = Object.values(this.locationDevices).filter(device => device.isBypassable()).map(sensor => sensor.id);
|
||||
if (this.plugin.settingsStorage.values.nightModeBypassAlarmState === 'Away') {
|
||||
await this.location.armAway(bypassContactSensors);
|
||||
} else {
|
||||
await this.location.armHome(bypassContactSensors);
|
||||
}
|
||||
} else if (mode === SecuritySystemMode.Disarmed) {
|
||||
await this.location.disarm();
|
||||
}
|
||||
}
|
||||
|
||||
async disarmSecuritySystem(): Promise<void> {
|
||||
await this.location.disarm();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,6 @@ export { BasicPeerConnection } from '@koush/ring-client-api/packages/ring-client
|
||||
export { SimpleWebRtcSession } from '@koush/ring-client-api/packages/ring-client-api/streaming/simple-webrtc-session';
|
||||
export { StreamingSession } from '@koush/ring-client-api/packages/ring-client-api/streaming/streaming-session';
|
||||
export { generateUuid } from '@koush/ring-client-api/packages/ring-client-api/util';
|
||||
export { RingDeviceType, RingDeviceData } from '@koush/ring-client-api/packages/ring-client-api/ring-types';
|
||||
export { RingDeviceType, RingDeviceData, RingDeviceCategory } from '@koush/ring-client-api/packages/ring-client-api/ring-types';
|
||||
export { RingDevice } from '@koush/ring-client-api/packages/ring-client-api/ring-device';
|
||||
export * as rxjs from '@koush/ring-client-api/node_modules/rxjs';
|
||||
4671
plugins/sip/package-lock.json
generated
4671
plugins/sip/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,49 @@
|
||||
{
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.5",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
|
||||
"prescrypted-vscode-launch": "scrypted-webpack",
|
||||
"scrypted-vscode-launch": "scrypted-deploy-debug",
|
||||
"scrypted-deploy-debug": "scrypted-deploy-debug",
|
||||
"scrypted-debug": "scrypted-debug",
|
||||
"scrypted-deploy": "scrypted-deploy",
|
||||
"scrypted-readme": "scrypted-readme",
|
||||
"scrypted-package-json": "scrypted-package-json"
|
||||
},
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
"plugin",
|
||||
"sip"
|
||||
],
|
||||
"scrypted": {
|
||||
"name": "SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@homebridge/camera-utils": "^2.0.4",
|
||||
"rxjs": "^7.5.5",
|
||||
"sdp": "^3.0.3",
|
||||
"sip": "0.0.6",
|
||||
"stun": "^2.1.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.6",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "cross-env NODE_ENV=production scrypted-webpack",
|
||||
"prescrypted-vscode-launch": "scrypted-webpack",
|
||||
"scrypted-vscode-launch": "scrypted-deploy-debug",
|
||||
"scrypted-deploy-debug": "scrypted-deploy-debug",
|
||||
"scrypted-debug": "scrypted-debug",
|
||||
"scrypted-deploy": "scrypted-deploy",
|
||||
"scrypted-readme": "scrypted-readme",
|
||||
"scrypted-package-json": "scrypted-package-json"
|
||||
},
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
"plugin",
|
||||
"sip"
|
||||
],
|
||||
"scrypted": {
|
||||
"name": "SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@homebridge/camera-utils": "^2.0.4",
|
||||
"@slyoldfox/sip": "^0.0.6-1",
|
||||
"sdp": "^3.0.3",
|
||||
"stun": "^2.1.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
15
plugins/sip/src/compositeSipMessageHandler.ts
Normal file
15
plugins/sip/src/compositeSipMessageHandler.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SipRequestHandler, SipRequest } from "./sip-manager";
|
||||
|
||||
export class CompositeSipMessageHandler extends SipRequestHandler {
|
||||
private handlers : SipRequestHandler[] = []
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
handle(request: SipRequest) {
|
||||
this.handlers.forEach( (handler) => handler.handle( request ) )
|
||||
}
|
||||
add( handler : SipRequestHandler ) {
|
||||
this.handlers.push( handler )
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import child_process, { ChildProcess } from 'child_process';
|
||||
import { ffmpegLogInitialOutput, safePrintFFmpegArguments } from "@scrypted/common/src/media-helpers";
|
||||
import dgram from 'dgram';
|
||||
import net from 'net';
|
||||
import { SipSession } from './sip-session';
|
||||
import { SipOptions } from './sip-call';
|
||||
import { SipCallSession } from './sip-call-session';
|
||||
import { SipOptions } from './sip-manager';
|
||||
import { RtpDescription, isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from './rtp-utils';
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
@@ -14,7 +14,7 @@ const { deviceManager, mediaManager } = sdk;
|
||||
|
||||
class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
|
||||
buttonTimeout: NodeJS.Timeout;
|
||||
session: SipSession;
|
||||
callSession: SipCallSession;
|
||||
remoteRtpDescription: RtpDescription;
|
||||
audioOutForwarder: dgram.Socket;
|
||||
audioOutProcess: ChildProcess;
|
||||
@@ -107,7 +107,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
|
||||
await this.callDoorbell();
|
||||
|
||||
if (!this.session)
|
||||
if (!this.callSession)
|
||||
throw new Error("not in call");
|
||||
|
||||
this.stopAudioOut();
|
||||
@@ -118,7 +118,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
const audioOutForwarder = await createBindZero();
|
||||
this.audioOutForwarder = audioOutForwarder.server;
|
||||
audioOutForwarder.server.on('message', message => {
|
||||
this.session.audioSplitter.send(message, remoteRtpDescription.audio.port, remoteRtpDescription.address);
|
||||
this.callSession.audioSplitter.send(message, remoteRtpDescription.audio.port, remoteRtpDescription.address);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), args);
|
||||
this.audioOutProcess = cp;
|
||||
cp.on('exit', () => this.console.log('two way audio ended'));
|
||||
this.session.onCallEnded.subscribe(() => {
|
||||
this.callSession.onCallEnded.subscribe(() => {
|
||||
closeQuiet(audioOutForwarder.server);
|
||||
cp.kill('SIGKILL');
|
||||
});
|
||||
@@ -157,19 +157,19 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
stopSession() {
|
||||
this.doorbellAudioActive = false;
|
||||
this.audioInProcess?.kill('SIGKILL');
|
||||
if (this.session) {
|
||||
if (this.callSession) {
|
||||
this.console.log('ending sip session');
|
||||
this.session.stop();
|
||||
this.session = undefined;
|
||||
this.callSession.stop();
|
||||
this.callSession = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async callDoorbell(): Promise<void> {
|
||||
let sip: SipSession;
|
||||
let sip: SipCallSession;
|
||||
|
||||
const cleanup = () => {
|
||||
if (this.session === sip)
|
||||
this.session = undefined;
|
||||
if (this.callSession === sip)
|
||||
this.callSession = undefined;
|
||||
try {
|
||||
this.console.log('stopping sip session.');
|
||||
sip.stop();
|
||||
@@ -193,7 +193,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
|
||||
let sipOptions: SipOptions = { from: "sip:" + from, to: "sip:" + to, localIp, localPort };
|
||||
|
||||
sip = await SipSession.createSipSession(this.console, this.name, sipOptions);
|
||||
sip = await SipCallSession.createCallSession(this.console, this.name, sipOptions);
|
||||
sip.onCallEnded.subscribe(cleanup);
|
||||
this.remoteRtpDescription = await sip.call(
|
||||
( audio ) => {
|
||||
@@ -206,7 +206,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
);
|
||||
this.console.log('SIP: Received remote SDP:\n', this.remoteRtpDescription.sdp)
|
||||
|
||||
let [rtpPort, rtcpPort] = await SipSession.reserveRtpRtcpPorts()
|
||||
let [rtpPort, rtcpPort] = await SipCallSession.reserveRtpRtcpPorts()
|
||||
this.console.log(`Reserved RTP port ${rtpPort} and RTCP port ${rtcpPort} for incoming SIP audio`);
|
||||
|
||||
const ffmpegPath = await mediaManager.getFFmpegPath();
|
||||
@@ -260,7 +260,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
sip.audioRtcpSplitter.send(message, rtcpPort, "127.0.0.1");
|
||||
});
|
||||
|
||||
this.session = sip;
|
||||
this.callSession = sip;
|
||||
}
|
||||
|
||||
getRawVideoStreamOptions(): ResponseMediaStreamOptions[] {
|
||||
@@ -437,8 +437,7 @@ export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider
|
||||
}
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
||||
const nativeId = randomBytes(4).toString('hex');
|
||||
@@ -482,6 +481,12 @@ export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider
|
||||
return ret;
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
if( this.devices.delete( nativeId ) ) {
|
||||
this.console.log("Removed device from list: " + id + " / " + nativeId )
|
||||
}
|
||||
}
|
||||
|
||||
createCamera(nativeId: string): SipCamera {
|
||||
return new SipCamera(nativeId, this);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// by @dgrief from @homebridge/camera-utils
|
||||
import { SrtpOptions } from '@homebridge/camera-utils'
|
||||
import dgram from 'dgram'
|
||||
const stun = require('stun')
|
||||
|
||||
const stunMagicCookie = 0x2112a442 // https://tools.ietf.org/html/rfc5389#section-6
|
||||
|
||||
export interface RtpStreamOptions {
|
||||
export interface RtpStreamOptions extends SrtpOptions {
|
||||
port: number
|
||||
rtcpPort: number
|
||||
}
|
||||
|
||||
@@ -1,187 +1,206 @@
|
||||
import { reservePorts } from '@homebridge/camera-utils';
|
||||
import { createBindUdp, createBindZero } from '@scrypted/common/src/listen-cluster';
|
||||
import dgram from 'dgram';
|
||||
import { ReplaySubject, timer } from 'rxjs';
|
||||
import { createStunResponder, RtpDescription, RtpOptions, sendStunBindingRequest } from './rtp-utils';
|
||||
import { SipCall, SipOptions } from './sip-call';
|
||||
import { Subscribed } from './subscribed';
|
||||
|
||||
export class SipSession extends Subscribed {
|
||||
private hasStarted = false
|
||||
private hasCallEnded = false
|
||||
private onCallEndedSubject = new ReplaySubject(1)
|
||||
private sipCall: SipCall
|
||||
onCallEnded = this.onCallEndedSubject.asObservable()
|
||||
|
||||
constructor(
|
||||
public readonly console: Console,
|
||||
public readonly sipOptions: SipOptions,
|
||||
public readonly rtpOptions: RtpOptions,
|
||||
public readonly audioSplitter: dgram.Socket,
|
||||
public audioRtcpSplitter: dgram.Socket,
|
||||
public readonly videoSplitter: dgram.Socket,
|
||||
public videoRtcpSplitter: dgram.Socket,
|
||||
public readonly cameraName: string
|
||||
) {
|
||||
super()
|
||||
|
||||
this.sipCall = this.createSipCall(this.sipOptions)
|
||||
}
|
||||
|
||||
static async createSipSession(console: any, cameraName: string, sipOptions: SipOptions) {
|
||||
const audioSplitter = await createBindZero(),
|
||||
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
|
||||
videoSplitter = await createBindZero(),
|
||||
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
|
||||
rtpOptions : RtpOptions = {
|
||||
audio: {
|
||||
port: audioSplitter.port,
|
||||
rtcpPort: audioRtcpSplitter.port
|
||||
},
|
||||
video: {
|
||||
port: videoSplitter.port,
|
||||
rtcpPort: videoRtcpSplitter.port
|
||||
}
|
||||
}
|
||||
|
||||
return new SipSession(
|
||||
console,
|
||||
sipOptions,
|
||||
rtpOptions,
|
||||
audioSplitter.server,
|
||||
audioRtcpSplitter.server,
|
||||
videoSplitter.server,
|
||||
videoRtcpSplitter.server,
|
||||
cameraName
|
||||
)
|
||||
}
|
||||
|
||||
createSipCall(sipOptions: SipOptions) {
|
||||
if (this.sipCall) {
|
||||
this.sipCall.destroy()
|
||||
}
|
||||
|
||||
const call = (this.sipCall = new SipCall(
|
||||
this.console,
|
||||
sipOptions,
|
||||
this.rtpOptions
|
||||
))
|
||||
|
||||
this.addSubscriptions(
|
||||
call.onEndedByRemote.subscribe(() => this.callEnded(false))
|
||||
)
|
||||
|
||||
return this.sipCall
|
||||
}
|
||||
|
||||
async call( audioSection, videoSection? ): Promise<RtpDescription> {
|
||||
this.console.log(`SipSession::start()`);
|
||||
|
||||
if (this.hasStarted) {
|
||||
throw new Error('SIP Session has already been started')
|
||||
}
|
||||
this.hasStarted = true
|
||||
|
||||
if (this.hasCallEnded) {
|
||||
throw new Error('SIP Session has already ended')
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if( this.sipOptions.shouldRegister )
|
||||
await this.sipCall.register()
|
||||
const rtpDescription = await this.sipCall.invite( audioSection, videoSection ),
|
||||
sendStunRequests = () => {
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.audioSplitter,
|
||||
rtcpSplitter: this.audioRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipCall.audioUfrag,
|
||||
type: 'audio',
|
||||
})
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.videoSplitter,
|
||||
rtcpSplitter: this.videoRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipCall.videoUfrag,
|
||||
type: 'video',
|
||||
})
|
||||
}
|
||||
|
||||
// if rtcp-mux is supported, rtp splitter will be used for both rtp and rtcp
|
||||
if ( rtpDescription.audio.port > 0 && rtpDescription.audio.port === rtpDescription.audio.rtcpPort) {
|
||||
this.audioRtcpSplitter.close()
|
||||
this.audioRtcpSplitter = this.audioSplitter
|
||||
}
|
||||
|
||||
if ( rtpDescription.video.port > 0 && rtpDescription.video.port === rtpDescription.video.rtcpPort) {
|
||||
this.videoRtcpSplitter.close()
|
||||
this.videoRtcpSplitter = this.videoSplitter
|
||||
}
|
||||
|
||||
if ( (rtpDescription.audio.port > 0 && rtpDescription.audio.iceUFrag)|| (rtpDescription.video.port > 0 && rtpDescription.video.iceUFrag ) ) {
|
||||
// ICE is supported
|
||||
this.console.log(`Connecting to ${this.cameraName} using ICE`)
|
||||
if( rtpDescription.audio.port > 0 ) {
|
||||
createStunResponder(this.audioSplitter)
|
||||
}
|
||||
if( rtpDescription.video.port > 0 ) {
|
||||
createStunResponder(this.videoSplitter)
|
||||
}
|
||||
|
||||
sendStunRequests()
|
||||
} else {
|
||||
// ICE is not supported, use stun as keep alive
|
||||
this.console.log(`Connecting to ${this.cameraName} using STUN`)
|
||||
this.addSubscriptions(
|
||||
// hole punch every .5 seconds to keep stream alive and port open (matches behavior from Ring app)
|
||||
timer(0, 500).subscribe(sendStunRequests)
|
||||
)
|
||||
}
|
||||
|
||||
this.audioSplitter.once('message', () => {
|
||||
this.console.log(`Audio stream latched for ${this.cameraName}, port: ${this.rtpOptions.audio.port}`)
|
||||
})
|
||||
|
||||
this.videoSplitter.once('message', () => {
|
||||
this.console.log(`Video stream latched for ${this.cameraName}, port: ${this.rtpOptions.video.port}`)
|
||||
})
|
||||
|
||||
return rtpDescription
|
||||
} catch (e) {
|
||||
this.callEnded(true)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
static async reserveRtpRtcpPorts() {
|
||||
const ports = await reservePorts({ count: 4, type: 'udp' })
|
||||
return ports
|
||||
}
|
||||
|
||||
private async callEnded(sendBye: boolean) {
|
||||
if (this.hasCallEnded) {
|
||||
return
|
||||
}
|
||||
this.hasCallEnded = true
|
||||
|
||||
if (sendBye) {
|
||||
await this.sipCall.sendBye().catch(this.console.error)
|
||||
}
|
||||
|
||||
// clean up
|
||||
this.console.log("sip-session callEnded")
|
||||
this.onCallEndedSubject.next(null)
|
||||
this.sipCall.destroy()
|
||||
this.audioSplitter.close()
|
||||
this.audioRtcpSplitter.close()
|
||||
this.videoSplitter.close()
|
||||
this.videoRtcpSplitter.close()
|
||||
this.unsubscribe()
|
||||
this.console.log("sip-session callEnded: done")
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.callEnded(true)
|
||||
}
|
||||
import { reservePorts } from '@homebridge/camera-utils';
|
||||
import { createBindUdp, createBindZero } from '@scrypted/common/src/listen-cluster';
|
||||
import dgram from 'dgram';
|
||||
import { ReplaySubject, timer } from 'rxjs';
|
||||
import { createStunResponder, RtpDescription, RtpOptions, sendStunBindingRequest } from './rtp-utils';
|
||||
import { SipManager, SipOptions, SipRequest } from './sip-manager';
|
||||
import { Subscribed } from './subscribed';
|
||||
|
||||
/*
|
||||
A SipCallSession
|
||||
*/
|
||||
export class SipCallSession extends Subscribed {
|
||||
private hasStarted = false
|
||||
private hasCallEnded = false
|
||||
private onCallEndedSubject = new ReplaySubject(1)
|
||||
onCallEnded = this.onCallEndedSubject.asObservable()
|
||||
|
||||
constructor(
|
||||
private readonly console: Console,
|
||||
private readonly sipOptions: SipOptions,
|
||||
private readonly rtpOptions: RtpOptions,
|
||||
public readonly audioSplitter: dgram.Socket,
|
||||
public audioRtcpSplitter: dgram.Socket,
|
||||
public readonly videoSplitter: dgram.Socket,
|
||||
public videoRtcpSplitter: dgram.Socket,
|
||||
public readonly cameraName: string,
|
||||
private sipManager: SipManager
|
||||
) {
|
||||
super()
|
||||
if( !sipManager ) {
|
||||
this.sipManager = this.createSipManager( sipOptions )
|
||||
}
|
||||
//TODO: make this more clean
|
||||
this.addSubscriptions( this.sipManager.onEndedByRemote.subscribe(() => {
|
||||
this.callEnded(false)
|
||||
} ))
|
||||
|
||||
sipManager.setSipOptions( sipOptions )
|
||||
}
|
||||
|
||||
static async createCallSession(console: Console, cameraName: string, sipOptions: SipOptions, sipManager?: SipManager ) {
|
||||
const audioSplitter = await createBindZero(),
|
||||
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
|
||||
videoSplitter = await createBindZero(),
|
||||
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
|
||||
rtpOptions : RtpOptions = {
|
||||
audio: {
|
||||
port: audioSplitter.port,
|
||||
rtcpPort: audioRtcpSplitter.port,
|
||||
//TODO: make this cleaner
|
||||
srtpKey: undefined,
|
||||
srtpSalt: undefined
|
||||
},
|
||||
video: {
|
||||
port: videoSplitter.port,
|
||||
rtcpPort: videoRtcpSplitter.port,
|
||||
//TODO: make this cleaner
|
||||
srtpKey: undefined,
|
||||
srtpSalt: undefined
|
||||
}
|
||||
}
|
||||
|
||||
return new SipCallSession(
|
||||
console,
|
||||
sipOptions,
|
||||
rtpOptions,
|
||||
audioSplitter.server,
|
||||
audioRtcpSplitter.server,
|
||||
videoSplitter.server,
|
||||
videoRtcpSplitter.server,
|
||||
cameraName,
|
||||
sipManager
|
||||
)
|
||||
}
|
||||
|
||||
createSipManager(sipOptions: SipOptions) {
|
||||
if (this.sipManager) {
|
||||
this.sipManager.destroy()
|
||||
}
|
||||
|
||||
const call = (this.sipManager = new SipManager(
|
||||
this.console,
|
||||
sipOptions
|
||||
))
|
||||
|
||||
this.addSubscriptions(
|
||||
call.onEndedByRemote.subscribe(() => {
|
||||
this.callEnded(false)
|
||||
} )
|
||||
)
|
||||
|
||||
return this.sipManager
|
||||
}
|
||||
|
||||
async call( audioSection, videoSection? ): Promise<RtpDescription> {
|
||||
return this.callOrAcceptInvite(audioSection, videoSection)
|
||||
}
|
||||
|
||||
async callOrAcceptInvite( audioSection, videoSection?, incomingCallRequest? : SipRequest ): Promise<RtpDescription> {
|
||||
this.console.log(`SipSession::start()`);
|
||||
|
||||
if (this.hasStarted) {
|
||||
throw new Error('SIP Session has already been started')
|
||||
}
|
||||
this.hasStarted = true
|
||||
|
||||
if (this.hasCallEnded) {
|
||||
throw new Error('SIP Session has already ended')
|
||||
}
|
||||
|
||||
try {
|
||||
const rtpDescription : RtpDescription = await this.sipManager.invite( this.rtpOptions, audioSection, videoSection, incomingCallRequest ),
|
||||
sendStunRequests = () => {
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.audioSplitter,
|
||||
rtcpSplitter: this.audioRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipManager.audioUfrag,
|
||||
type: 'audio',
|
||||
})
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.videoSplitter,
|
||||
rtcpSplitter: this.videoRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipManager.videoUfrag,
|
||||
type: 'video',
|
||||
})
|
||||
}
|
||||
|
||||
// if rtcp-mux is supported, rtp splitter will be used for both rtp and rtcp
|
||||
if ( rtpDescription.audio.port > 0 && rtpDescription.audio.port === rtpDescription.audio.rtcpPort) {
|
||||
this.audioRtcpSplitter.close()
|
||||
this.audioRtcpSplitter = this.audioSplitter
|
||||
}
|
||||
|
||||
if ( rtpDescription.video.port > 0 && rtpDescription.video.port === rtpDescription.video.rtcpPort) {
|
||||
this.videoRtcpSplitter.close()
|
||||
this.videoRtcpSplitter = this.videoSplitter
|
||||
}
|
||||
|
||||
if ( (rtpDescription.audio.port > 0 && rtpDescription.audio.iceUFrag)|| (rtpDescription.video.port > 0 && rtpDescription.video.iceUFrag ) ) {
|
||||
// ICE is supported
|
||||
this.console.log(`Connecting to ${this.cameraName} using ICE`)
|
||||
if( rtpDescription.audio.port > 0 ) {
|
||||
createStunResponder(this.audioSplitter)
|
||||
}
|
||||
if( rtpDescription.video.port > 0 ) {
|
||||
createStunResponder(this.videoSplitter)
|
||||
}
|
||||
|
||||
sendStunRequests()
|
||||
} else {
|
||||
// ICE is not supported, use stun as keep alive
|
||||
this.console.log(`Connecting to ${this.cameraName} using STUN`)
|
||||
this.addSubscriptions(
|
||||
// hole punch every .5 seconds to keep stream alive and port open (matches behavior from Ring app)
|
||||
timer(0, 500).subscribe(sendStunRequests)
|
||||
)
|
||||
}
|
||||
|
||||
this.audioSplitter.once('message', () => {
|
||||
this.console.log(`Audio stream latched for ${this.cameraName}, port: ${this.rtpOptions.audio.port}`)
|
||||
})
|
||||
|
||||
this.videoSplitter.once('message', () => {
|
||||
this.console.log(`Video stream latched for ${this.cameraName}, port: ${this.rtpOptions.video.port}`)
|
||||
})
|
||||
|
||||
return rtpDescription
|
||||
} catch (e) {
|
||||
this.callEnded(true)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
static async reserveRtpRtcpPorts() {
|
||||
const ports = await reservePorts({ count: 4, type: 'udp' })
|
||||
return ports
|
||||
}
|
||||
|
||||
private async callEnded(sendBye: boolean) {
|
||||
if (this.hasCallEnded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.hasCallEnded = true
|
||||
if (sendBye) {
|
||||
await this.sipManager.sendBye().catch(this.console.error)
|
||||
}
|
||||
|
||||
// clean up
|
||||
this.console.log("sip-call-session callEnded")
|
||||
this.onCallEndedSubject.next(null)
|
||||
//this.sipManager.destroy()
|
||||
this.audioSplitter.close()
|
||||
this.audioRtcpSplitter.close()
|
||||
this.videoSplitter.close()
|
||||
this.videoRtcpSplitter.close()
|
||||
this.unsubscribe()
|
||||
this.console.log("sip-call-session callEnded: done")
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.callEnded(true)
|
||||
}
|
||||
}
|
||||
@@ -1,426 +1,532 @@
|
||||
import { noop, Subject } from 'rxjs'
|
||||
import { randomInteger, randomString } from './util'
|
||||
import { RtpDescription, RtpOptions, RtpStreamDescription } from './rtp-utils'
|
||||
import { decodeSrtpOptions } from '@homebridge/camera-utils'
|
||||
import { stringify } from 'sip/sip'
|
||||
import { timeoutPromise } from '@scrypted/common/src/promise-utils';
|
||||
|
||||
const sip = require('sip'),
|
||||
sdp = require('sdp')
|
||||
|
||||
export interface SipOptions {
|
||||
to: string
|
||||
from: string
|
||||
domain?: string
|
||||
expire?: number
|
||||
localIp: string
|
||||
localPort: number
|
||||
debugSip?: boolean
|
||||
messageHandler?: SipMessageHandler
|
||||
shouldRegister?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows handling of SIP messages
|
||||
*/
|
||||
export abstract class SipMessageHandler {
|
||||
abstract handle( request: SipRequest )
|
||||
}
|
||||
|
||||
interface UriOptions {
|
||||
name?: string
|
||||
uri: string
|
||||
params?: {
|
||||
tag?: string
|
||||
expires?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface SipHeaders {
|
||||
[name: string]: string | any
|
||||
cseq: { seq: number; method: string }
|
||||
to: UriOptions
|
||||
from: UriOptions
|
||||
contact?: UriOptions[]
|
||||
via?: UriOptions[]
|
||||
}
|
||||
|
||||
export interface SipRequest {
|
||||
uri: UriOptions | string
|
||||
method: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SipResponse {
|
||||
status: number
|
||||
reason: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SipStack {
|
||||
send: (
|
||||
request: SipRequest | SipResponse,
|
||||
handler?: (response: SipResponse) => void
|
||||
) => void
|
||||
destroy: () => void
|
||||
makeResponse: (
|
||||
response: SipRequest,
|
||||
status: number,
|
||||
method: string
|
||||
) => SipResponse
|
||||
}
|
||||
|
||||
function getRandomId() {
|
||||
return Math.floor(Math.random() * 1e6).toString()
|
||||
}
|
||||
|
||||
function getRtpDescription(
|
||||
console: any,
|
||||
sections: string[],
|
||||
mediaType: 'audio' | 'video'
|
||||
): RtpStreamDescription {
|
||||
try {
|
||||
const section = sections.find((s) => s.startsWith('m=' + mediaType));
|
||||
if( section === undefined ) {
|
||||
return {
|
||||
port: 0,
|
||||
rtcpPort: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { port } = sdp.parseMLine(section),
|
||||
lines: string[] = sdp.splitLines(section),
|
||||
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
|
||||
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
|
||||
rtcpMuxLine = lines.find((l: string) => l.startsWith('a=rtcp-mux')),
|
||||
ssrcLine = lines.find((l: string) => l.startsWith('a=ssrc')),
|
||||
iceUFragLine = lines.find((l: string) => l.startsWith('a=ice-ufrag')),
|
||||
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd')),
|
||||
encodedCrypto = cryptoLine?.match(/inline:(\S*)/)![1] || undefined
|
||||
|
||||
let rtcpPort: number;
|
||||
if (rtcpMuxLine) {
|
||||
rtcpPort = port; // rtcp-mux would cause rtcpLine to not be present
|
||||
}
|
||||
else {
|
||||
rtcpPort = (rtcpLine && Number(rtcpLine.match(/rtcp:(\S*)/)?.[1])) || port + 1; // if there is no explicit RTCP port, then use RTP port + 1
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
rtcpPort,
|
||||
ssrc: (ssrcLine && Number(ssrcLine.match(/ssrc:(\S*)/)?.[1])) || undefined,
|
||||
iceUFrag: (iceUFragLine && iceUFragLine.match(/ice-ufrag:(\S*)/)?.[1]) || undefined,
|
||||
icePwd: (icePwdLine && icePwdLine.match(/ice-pwd:(\S*)/)?.[1]) || undefined,
|
||||
...(encodedCrypto? decodeSrtpOptions(encodedCrypto) : {})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SDP from remote end')
|
||||
console.error(sections.join('\r\n'))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function parseRtpDescription(console: any, inviteResponse: {
|
||||
content: string
|
||||
}): RtpDescription {
|
||||
const sections: string[] = sdp.splitSections(inviteResponse.content),
|
||||
lines: string[] = sdp.splitLines(sections[0]),
|
||||
cLine = lines.find((line: string) => line.startsWith('c='))!
|
||||
|
||||
return {
|
||||
sdp: inviteResponse.content,
|
||||
address: cLine.match(/c=IN IP4 (\S*)/)![1],
|
||||
audio: getRtpDescription(console, sections, 'audio'),
|
||||
video: getRtpDescription(console, sections, 'video')
|
||||
}
|
||||
}
|
||||
|
||||
export class SipCall {
|
||||
private seq = 20
|
||||
private fromParams = { tag: getRandomId() }
|
||||
private toParams: { tag?: string } = {}
|
||||
private callId = getRandomId()
|
||||
private sipStack: SipStack
|
||||
public readonly onEndedByRemote = new Subject()
|
||||
private destroyed = false
|
||||
private readonly console: Console
|
||||
|
||||
public readonly audioUfrag = randomString(16)
|
||||
public readonly videoUfrag = randomString(16)
|
||||
|
||||
constructor(
|
||||
console: Console,
|
||||
private sipOptions: SipOptions,
|
||||
private rtpOptions: RtpOptions,
|
||||
//tlsPort: number
|
||||
) {
|
||||
this.console = console;
|
||||
|
||||
const host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort,
|
||||
contactId = randomInteger()
|
||||
|
||||
this.sipStack = {
|
||||
makeResponse: sip.makeResponse,
|
||||
...sip.create({
|
||||
host,
|
||||
hostname: host,
|
||||
port: port,
|
||||
udp: true,
|
||||
tcp: false,
|
||||
tls: false,
|
||||
// tls_port: tlsPort,
|
||||
// tls: {
|
||||
// rejectUnauthorized: false,
|
||||
// },
|
||||
ws: false,
|
||||
logger: {
|
||||
recv: function(m, remote) {
|
||||
if( m.status == '200' && m.reason =='Ok' && m.headers.contact ) {
|
||||
// ACK for INVITE and BYE must use the registrar contact uri
|
||||
this.registrarContact = m.headers.contact[0].uri;
|
||||
}
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
console.log(stringify( m ));
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
}
|
||||
},
|
||||
send: function(m, remote) {
|
||||
/*
|
||||
Some door bells run an embedded SIP server with an unresolvable public domain
|
||||
Due to bugs in the DNS resolution in sip/sip we abuse the 'send' logger to modify some headers
|
||||
just before they get sent to the SIP server.
|
||||
*/
|
||||
if( sipOptions.domain && sipOptions.domain.length > 0 ) {
|
||||
// Bticino CX300 specific: runs on an internet 2048362.bs.iotleg.com domain
|
||||
// While underlying UDP socket is bound to the IP, the header is rewritten to match the domain
|
||||
let toWithDomain: string = (sipOptions.to.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
let fromWithDomain: string = (sipOptions.from.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
|
||||
if( m.method == 'REGISTER' ) {
|
||||
m.uri = "sip:" + sipOptions.domain
|
||||
} else if( m.method == 'INVITE' ) {
|
||||
m.uri = toWithDomain
|
||||
} else if( m.method == 'ACK' || m.method == 'BYE' ) {
|
||||
m.uri = this.registrarContact
|
||||
} else {
|
||||
throw new Error("Error: Method construct for uri not implemented: " + m.method)
|
||||
}
|
||||
|
||||
m.headers.to.uri = toWithDomain
|
||||
m.headers.from.uri = fromWithDomain
|
||||
if( m.headers.contact && m.headers.contact[0].uri.split('@')[0].indexOf('-') < 0 ) {
|
||||
m.headers.contact[0].uri = m.headers.contact[0].uri.replace("@", "-" + contactId + "@");
|
||||
}
|
||||
}
|
||||
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
console.log(stringify( m ));
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
(request: SipRequest) => {
|
||||
if (request.method === 'BYE') {
|
||||
this.console.info('received BYE from remote end')
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
|
||||
if (this.destroyed) {
|
||||
this.onEndedByRemote.next(null)
|
||||
}
|
||||
} else if( request.method === 'MESSAGE' && sipOptions.messageHandler ) {
|
||||
sipOptions.messageHandler.handle( request )
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
} else {
|
||||
if( sipOptions.debugSip ) {
|
||||
this.console.warn("unimplemented method received from remote: " + request.method)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
request({
|
||||
method,
|
||||
headers,
|
||||
content,
|
||||
seq,
|
||||
}: {
|
||||
method: string
|
||||
headers?: Partial<SipHeaders>
|
||||
content?: string
|
||||
seq?: number
|
||||
}) {
|
||||
if (this.destroyed) {
|
||||
return Promise.reject(
|
||||
new Error('SIP request made after call was destroyed')
|
||||
)
|
||||
}
|
||||
|
||||
return new Promise<SipResponse>((resolve, reject) => {
|
||||
seq = seq || this.seq++
|
||||
this.sipStack.send(
|
||||
{
|
||||
method,
|
||||
uri: this.sipOptions.to,
|
||||
headers: {
|
||||
to: {
|
||||
//name: '"Scrypted SIP Plugin Client"',
|
||||
uri: this.sipOptions.to,
|
||||
params: (method == 'REGISTER' || method == 'INVITE' ? null : this.toParams),
|
||||
},
|
||||
from: {
|
||||
uri: this.sipOptions.from,
|
||||
params: this.fromParams,
|
||||
},
|
||||
'max-forwards': 70,
|
||||
'call-id': this.callId,
|
||||
cseq: { seq, method },
|
||||
...headers,
|
||||
},
|
||||
content: content || '',
|
||||
},
|
||||
(response: SipResponse) => {
|
||||
if (response.headers.to.params && response.headers.to.params.tag) {
|
||||
this.toParams.tag = response.headers.to.params.tag
|
||||
}
|
||||
|
||||
if (response.status >= 300) {
|
||||
if (response.status !== 408 || method !== 'BYE') {
|
||||
this.console.error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
)
|
||||
} else if (response.status < 200) {
|
||||
// call made progress, do nothing and wait for another response
|
||||
// console.log('call progress status ' + response.status)
|
||||
} else {
|
||||
if (method === 'INVITE') {
|
||||
// The ACK must be sent with every OK to keep the connection alive.
|
||||
this.acknowledge(seq!).catch((e) => {
|
||||
this.console.error('Failed to send SDP ACK')
|
||||
this.console.error(e)
|
||||
})
|
||||
}
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async acknowledge(seq: number) {
|
||||
// Don't wait for ack, it won't ever come back.
|
||||
this.request({
|
||||
method: 'ACK',
|
||||
seq, // The ACK must have the original sequence number.
|
||||
}).catch(noop)
|
||||
}
|
||||
|
||||
sendDtmf(key: string) {
|
||||
return this.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'Content-Type': 'application/dtmf-relay',
|
||||
},
|
||||
content: `Signal=${key}\r\nDuration=250`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a call by sending a SIP INVITE request
|
||||
*/
|
||||
async invite( audioSection, videoSection? ) {
|
||||
let ssrc = randomInteger()
|
||||
let audio = audioSection ? audioSection( this.rtpOptions.audio, ssrc ).concat( ...[`a=ssrc:${ssrc}`, `a=rtcp:${this.rtpOptions.audio.rtcpPort}`] ) : []
|
||||
let video = videoSection ? videoSection( this.rtpOptions.video, ssrc ).concat( ...[`a=ssrc:${ssrc}`, `a=rtcp:${this.rtpOptions.video.rtcpPort}`] ) : []
|
||||
const { from, localIp } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'INVITE',
|
||||
headers: {
|
||||
supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from }],
|
||||
},
|
||||
content: ([
|
||||
'v=0',
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${localIp}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${this.sipOptions.localIp}`,
|
||||
't=0 0',
|
||||
...audio,
|
||||
...video
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n'
|
||||
})
|
||||
|
||||
return parseRtpDescription(this.console, inviteResponse)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user agent with a Registrar
|
||||
*/
|
||||
async register() {
|
||||
const { from } = this.sipOptions;
|
||||
await timeoutPromise( 500,
|
||||
this.request({
|
||||
method: 'REGISTER',
|
||||
headers: {
|
||||
//supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
|
||||
},
|
||||
}).catch(() => {
|
||||
// Don't care if we get an exception here.
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the current call contact
|
||||
*/
|
||||
async message( content: string ) {
|
||||
const { from } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'MESSAGE',
|
||||
headers: {
|
||||
//supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
|
||||
},
|
||||
content: content
|
||||
});
|
||||
}
|
||||
|
||||
async sendBye() {
|
||||
this.console.log('Sending BYE...')
|
||||
return await timeoutPromise( 3000, this.request({ method: 'BYE' }).catch(() => {
|
||||
// Don't care if we get an exception here.
|
||||
}));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.console.debug("detroying sip-call")
|
||||
this.destroyed = true
|
||||
this.sipStack.destroy()
|
||||
this.console.debug("detroying sip-call: done")
|
||||
}
|
||||
}
|
||||
import { noop, Subject } from 'rxjs'
|
||||
import { randomInteger, randomString } from './util'
|
||||
import { RtpDescription, RtpOptions, RtpStreamDescription } from './rtp-utils'
|
||||
import { decodeSrtpOptions } from '../../ring/src/srtp-utils'
|
||||
import { stringify, stringifyUri } from '@slyoldfox/sip'
|
||||
import { timeoutPromise } from '@scrypted/common/src/promise-utils';
|
||||
import sdp from 'sdp'
|
||||
|
||||
const sip = require('@slyoldfox/sip')
|
||||
|
||||
export interface SipOptions {
|
||||
to: string
|
||||
from: string
|
||||
domain?: string
|
||||
expire?: number
|
||||
localIp: string
|
||||
localPort: number
|
||||
debugSip?: boolean
|
||||
useTcp?: boolean
|
||||
gruuInstanceId?: string
|
||||
sipRequestHandler?: SipRequestHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows handling of SIP messages
|
||||
*/
|
||||
export abstract class SipRequestHandler {
|
||||
abstract handle( request: SipRequest )
|
||||
}
|
||||
|
||||
interface UriOptions {
|
||||
name?: string
|
||||
uri: string
|
||||
params?: {
|
||||
tag?: string
|
||||
expires?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface SipHeaders {
|
||||
[name: string]: string | any
|
||||
cseq: { seq: number; method: string }
|
||||
to: UriOptions
|
||||
from: UriOptions
|
||||
contact?: UriOptions[]
|
||||
via?: UriOptions[]
|
||||
}
|
||||
|
||||
export interface SipRequest {
|
||||
uri: UriOptions | string
|
||||
method: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SipResponse {
|
||||
status: number
|
||||
reason: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SipStack {
|
||||
send: (
|
||||
request: SipRequest | SipResponse,
|
||||
handler?: (response: SipResponse) => void
|
||||
) => void
|
||||
destroy: () => void
|
||||
makeResponse: (
|
||||
response: SipRequest,
|
||||
status: number,
|
||||
method: string,
|
||||
extension?: {
|
||||
headers: Partial<SipHeaders>,
|
||||
content
|
||||
}
|
||||
) => SipResponse
|
||||
}
|
||||
|
||||
function getRandomId() {
|
||||
return Math.floor(Math.random() * 1e6).toString()
|
||||
}
|
||||
|
||||
function getRtpDescription(
|
||||
console: any,
|
||||
sections: string[],
|
||||
mediaType: 'audio' | 'video'
|
||||
): RtpStreamDescription {
|
||||
try {
|
||||
const section = sections.find((s) => s.startsWith('m=' + mediaType));
|
||||
if( section === undefined ) {
|
||||
return {
|
||||
port: 0,
|
||||
rtcpPort: 0,
|
||||
srtpKey: undefined,
|
||||
srtpSalt: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const { port } = sdp.parseMLine(section),
|
||||
lines: string[] = sdp.splitLines(section),
|
||||
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
|
||||
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
|
||||
rtcpMuxLine = lines.find((l: string) => l.startsWith('a=rtcp-mux')),
|
||||
ssrcLine = lines.find((l: string) => l.startsWith('a=ssrc')),
|
||||
iceUFragLine = lines.find((l: string) => l.startsWith('a=ice-ufrag')),
|
||||
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd')),
|
||||
encodedCrypto = cryptoLine?.match(/inline:(\S*)/)![1] || undefined
|
||||
|
||||
let rtcpPort: number;
|
||||
if (rtcpMuxLine) {
|
||||
rtcpPort = port; // rtcp-mux would cause rtcpLine to not be present
|
||||
}
|
||||
else {
|
||||
rtcpPort = (rtcpLine && Number(rtcpLine.match(/rtcp:(\S*)/)?.[1])) || port + 1; // if there is no explicit RTCP port, then use RTP port + 1
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
rtcpPort,
|
||||
ssrc: (ssrcLine && Number(ssrcLine.match(/ssrc:(\S*)/)?.[1])) || undefined,
|
||||
iceUFrag: (iceUFragLine && iceUFragLine.match(/ice-ufrag:(\S*)/)?.[1]) || undefined,
|
||||
icePwd: (icePwdLine && icePwdLine.match(/ice-pwd:(\S*)/)?.[1]) || undefined,
|
||||
...(encodedCrypto? decodeSrtpOptions(encodedCrypto) : { srtpKey: undefined, srtpSalt: undefined })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SDP from remote end')
|
||||
console.error(sections.join('\r\n'))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function parseRtpDescription(console: any, inviteResponse: {
|
||||
content: string
|
||||
}): RtpDescription {
|
||||
const sections: string[] = sdp.splitSections(inviteResponse.content),
|
||||
lines: string[] = sdp.splitLines(sections[0]),
|
||||
cLine = lines.find((line: string) => line.startsWith('c='))!
|
||||
|
||||
return {
|
||||
sdp: inviteResponse.content,
|
||||
address: cLine.match(/c=IN IP4 (\S*)/)![1],
|
||||
audio: getRtpDescription(console, sections, 'audio'),
|
||||
video: getRtpDescription(console, sections, 'video')
|
||||
}
|
||||
}
|
||||
|
||||
export class SipManager {
|
||||
private seq = 20
|
||||
private fromParams = { tag: getRandomId() }
|
||||
private toParams: { tag?: string } = {}
|
||||
private callId = getRandomId()
|
||||
private sipStack: SipStack
|
||||
public readonly onEndedByRemote = new Subject()
|
||||
private destroyed = false
|
||||
private readonly console: Console
|
||||
|
||||
public readonly audioUfrag = randomString(16)
|
||||
public readonly videoUfrag = randomString(16)
|
||||
|
||||
constructor(
|
||||
console: Console,
|
||||
private sipOptions: SipOptions,
|
||||
) {
|
||||
this.console = console;
|
||||
const host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort
|
||||
|
||||
this.sipStack = {
|
||||
makeResponse: sip.makeResponse,
|
||||
...sip.create({
|
||||
host,
|
||||
hostname: host,
|
||||
port: port,
|
||||
udp: !this.sipOptions.useTcp,
|
||||
tcp: this.sipOptions.useTcp,
|
||||
tls: false,
|
||||
// tls_port: tlsPort,
|
||||
// tls: {
|
||||
// rejectUnauthorized: false,
|
||||
// },
|
||||
ws: false,
|
||||
logger: {
|
||||
recv: function(m, remote) {
|
||||
if( (m.status == '200' || m.method === 'INVITE' ) && m.headers && m.headers.cseq && m.headers.cseq.method === 'INVITE' && m.headers.contact && m.headers.contact[0] ) {
|
||||
// ACK for INVITE and BYE must use the registrar contact uri
|
||||
this.registrarContact = m.headers.contact[0].uri;
|
||||
}
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
console.log(stringify( m ));
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
}
|
||||
},
|
||||
appendGruu: function( contact, gruuUrn ) {
|
||||
if( sipOptions.gruuInstanceId ) {
|
||||
if( contact && contact[0] ) {
|
||||
if( !contact[0].params ) {
|
||||
contact[0].params = {}
|
||||
}
|
||||
contact[0].params['+sip.instance'] = '"<urn:uuid:' + sipOptions.gruuInstanceId + '>"'
|
||||
if( gruuUrn ) {
|
||||
contact[0].uri = contact[0].uri + ';gr=urn:uuid:' + sipOptions.gruuInstanceId
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
send: function(m, remote) {
|
||||
/*
|
||||
Some door bells run an embedded SIP server with an unresolvable public domain
|
||||
Due to bugs in the DNS resolution in sip/sip we abuse the 'send' logger to modify some headers
|
||||
just before they get sent to the SIP server.
|
||||
*/
|
||||
if( sipOptions.domain && sipOptions.domain.length > 0 ) {
|
||||
// Bticino CX300 specific: runs on an internet 2048362.bs.iotleg.com domain
|
||||
// While underlying UDP socket is bound to the IP, the header is rewritten to match the domain
|
||||
let toWithDomain: string = (sipOptions.to.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
let fromWithDomain: string = (sipOptions.from.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
|
||||
if( m.method == 'REGISTER' ) {
|
||||
m.uri = "sip:" + sipOptions.domain
|
||||
m.headers.to.uri = fromWithDomain
|
||||
this.appendGruu( m.headers.contact )
|
||||
} else if( m.method == 'INVITE' || m.method == 'MESSAGE' ) {
|
||||
m.uri = toWithDomain
|
||||
m.headers.to.uri = toWithDomain
|
||||
if( m.method == 'MESSAGE' && m.headers.to ) {
|
||||
m.headers.to.params = null;
|
||||
}
|
||||
} else if( m.method == 'ACK' || m.method == 'BYE' ) {
|
||||
m.headers.to.uri = toWithDomain
|
||||
m.uri = this.registrarContact
|
||||
} else if( (m.method == undefined && m.status) && m.headers.cseq ) {
|
||||
if( m.status == '200' ) {
|
||||
// Response on invite
|
||||
this.appendGruu( m.headers.contact, true )
|
||||
}
|
||||
|
||||
// 183, 200, OK, CSeq: INVITE
|
||||
} else {
|
||||
console.error("Error: Method construct for uri not implemented: " + m.method)
|
||||
}
|
||||
|
||||
if( m.method ) {
|
||||
m.headers.from.uri = fromWithDomain
|
||||
if( m.headers.contact && m.headers.contact[0].uri.split('@')[0].lastIndexOf('-') < 0 ) {
|
||||
// Also a bug in SIP.js ? append the transport for the contact if the transport is udp (according to RFC)
|
||||
if( remote.protocol != 'udp' && m.headers.contact[0].uri.indexOf( "transport=" ) < 0 ) {
|
||||
m.headers.contact[0].uri = m.headers.contact[0].uri + ";transport=" + remote.protocol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
if( m.uri ) {
|
||||
console.log(stringify( m ));
|
||||
} else {
|
||||
m.uri = '';
|
||||
console.log( stringify( m ) )
|
||||
}
|
||||
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
(request: SipRequest) => {
|
||||
if (request.method === 'BYE') {
|
||||
this.console.info('received BYE from remote end')
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
|
||||
if (!this.destroyed) {
|
||||
this.onEndedByRemote.next(null)
|
||||
}
|
||||
} else if( request.method === 'MESSAGE' && sipOptions.sipRequestHandler ) {
|
||||
sipOptions.sipRequestHandler.handle( request )
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
} else if( request.method === 'INVITE' && sipOptions.sipRequestHandler ) {
|
||||
//let tryingResponse = this.sipStack.makeResponse( request, 100, 'Trying' )
|
||||
//this.sipStack.send(tryingResponse)
|
||||
//TODO: sporadic re-INVITEs are possible and should reply with 486 Busy here if already being handled
|
||||
let ringResponse = this.sipStack.makeResponse(request, 180, 'Ringing')
|
||||
this.toParams.tag = getRandomId()
|
||||
ringResponse.headers.to.params.tag = this.toParams.tag
|
||||
ringResponse.headers["record-route"] = request.headers["record-route"];
|
||||
ringResponse.headers["supported"] = "replaces, outbound, gruu"
|
||||
// Can include SDP and could send 183 here for early media
|
||||
this.sipStack.send(ringResponse)
|
||||
|
||||
sipOptions.sipRequestHandler.handle( request )
|
||||
// }, 100 )
|
||||
} else if( request.method === 'CANCEL' || request.method === 'ACK' ) {
|
||||
sipOptions.sipRequestHandler.handle( request )
|
||||
} else {
|
||||
if( sipOptions.debugSip ) {
|
||||
this.console.warn("unimplemented method received from remote: " + request.method)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
request({
|
||||
method,
|
||||
headers,
|
||||
content,
|
||||
seq,
|
||||
}: {
|
||||
method: string
|
||||
headers?: Partial<SipHeaders>
|
||||
content?: string
|
||||
seq?: number
|
||||
}) {
|
||||
if (this.destroyed) {
|
||||
return Promise.reject(
|
||||
new Error('SIP request made after call was destroyed')
|
||||
)
|
||||
}
|
||||
|
||||
return new Promise<SipResponse>((resolve, reject) => {
|
||||
seq = seq || this.seq++
|
||||
this.sipStack.send(
|
||||
{
|
||||
method,
|
||||
uri: this.sipOptions.to,
|
||||
headers: {
|
||||
to: {
|
||||
//name: '"Scrypted SIP Plugin Client"',
|
||||
uri: this.sipOptions.to,
|
||||
params: (method == 'REGISTER' || method == 'INVITE' ? null : this.toParams),
|
||||
},
|
||||
from: {
|
||||
uri: this.sipOptions.from,
|
||||
params: this.fromParams,
|
||||
},
|
||||
'max-forwards': 70,
|
||||
'call-id': this.callId,
|
||||
cseq: { seq, method },
|
||||
...headers,
|
||||
},
|
||||
content: content || '',
|
||||
},
|
||||
(response: SipResponse) => {
|
||||
if (response.headers.to.params && response.headers.to.params.tag) {
|
||||
this.toParams.tag = response.headers.to.params.tag
|
||||
}
|
||||
|
||||
if (response.headers.from.params && response.headers.from.params.tag) {
|
||||
this.fromParams.tag = response.headers.from.params.tag
|
||||
}
|
||||
|
||||
if (response.status >= 300) {
|
||||
if (response.status !== 408 || method !== 'BYE') {
|
||||
this.console.error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
)
|
||||
} else if (response.status < 200) {
|
||||
// call made progress, do nothing and wait for another response
|
||||
// console.log('call progress status ' + response.status)
|
||||
} else {
|
||||
if (method === 'INVITE') {
|
||||
// The ACK must be sent with every OK to keep the connection alive.
|
||||
this.acknowledge(seq!).catch((e) => {
|
||||
this.console.error('Failed to send SDP ACK')
|
||||
this.console.error(e)
|
||||
})
|
||||
}
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async acknowledge(seq: number) {
|
||||
// Don't wait for ack, it won't ever come back.
|
||||
this.request({
|
||||
method: 'ACK',
|
||||
seq, // The ACK must have the original sequence number.
|
||||
}).catch(noop)
|
||||
}
|
||||
|
||||
public setSipOptions( sipOptions : SipOptions ) {
|
||||
this.sipOptions = sipOptions
|
||||
}
|
||||
|
||||
sendDtmf(key: string) {
|
||||
return this.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'Content-Type': 'application/dtmf-relay',
|
||||
},
|
||||
content: `Signal=${key}\r\nDuration=250`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a call by sending a SIP INVITE request
|
||||
*/
|
||||
async invite( rtpOptions : RtpOptions, audioSection, videoSection?, incomingCallRequest? ) : Promise<RtpDescription> {
|
||||
let ssrc = randomInteger()
|
||||
let audio = audioSection ? audioSection( rtpOptions.audio, ssrc ).concat( ...[`a=rtcp:${rtpOptions.audio.rtcpPort}`] ) : []
|
||||
let video = videoSection ? videoSection( rtpOptions.video, ssrc ).concat( ...[`a=rtcp:${rtpOptions.video.rtcpPort}`] ) : []
|
||||
const { from, localIp } = this.sipOptions;
|
||||
|
||||
|
||||
if( incomingCallRequest ) {
|
||||
let callResponse = this.sipStack.makeResponse(incomingCallRequest, 200, 'Ok', {
|
||||
headers: {
|
||||
supported: 'replaces, outbound, gruu',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
},
|
||||
content: ([
|
||||
'v=0',
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${localIp}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${this.sipOptions.localIp}`,
|
||||
't=0 0',
|
||||
...audio,
|
||||
...video
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n'
|
||||
} )
|
||||
if( incomingCallRequest.headers["record-route"] )
|
||||
callResponse.headers["record-route"] = incomingCallRequest.headers["record-route"];
|
||||
let fromWithDomain: string = (from.split('@')[0] + '@' + this.sipOptions.domain).trim()
|
||||
callResponse.headers.contact = [{ uri: fromWithDomain }]
|
||||
|
||||
// Invert the params if the request comes from the server
|
||||
this.fromParams.tag = incomingCallRequest.headers.to.params.tag
|
||||
this.toParams.tag = incomingCallRequest.headers.from.params.tag
|
||||
this.callId = incomingCallRequest.headers["call-id"]
|
||||
|
||||
await this.sipStack.send(callResponse)
|
||||
|
||||
return parseRtpDescription(this.console, incomingCallRequest)
|
||||
} else {
|
||||
if( this.sipOptions.to.toLocaleLowerCase().indexOf('c300x') >= 0 ) {
|
||||
// Needed for bt_answering_machine (bticino specific)
|
||||
audio.unshift('a=DEVADDR:20')
|
||||
}
|
||||
let inviteResponse = await this.request({
|
||||
method: 'INVITE',
|
||||
headers: {
|
||||
supported: 'replaces, outbound, gruu',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from }],
|
||||
},
|
||||
content: ([
|
||||
'v=0',
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${localIp}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${this.sipOptions.localIp}`,
|
||||
't=0 0',
|
||||
...audio,
|
||||
...video
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n'
|
||||
})
|
||||
|
||||
return parseRtpDescription(this.console, inviteResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user agent with a Registrar
|
||||
*/
|
||||
async register() : Promise<void> {
|
||||
const { from } = this.sipOptions;
|
||||
await timeoutPromise( 3000,
|
||||
this.request({
|
||||
method: 'REGISTER',
|
||||
headers: {
|
||||
supported: 'replaces, outbound, gruu',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
contact: [{ uri: from }],
|
||||
expires: this.sipOptions.expire // as seen in tcpdump for Door Entry app
|
||||
},
|
||||
}).catch(noop));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the current call contact
|
||||
*/
|
||||
async message( content: string ) : Promise<SipResponse> {
|
||||
const { from } = this.sipOptions,
|
||||
messageResponse = await this.request({
|
||||
method: 'MESSAGE',
|
||||
headers: {
|
||||
//supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'text/plain',
|
||||
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
|
||||
},
|
||||
content: content
|
||||
});
|
||||
return messageResponse;
|
||||
}
|
||||
|
||||
async sendBye() : Promise<void | SipResponse> {
|
||||
this.console.log('Sending BYE...')
|
||||
return await timeoutPromise( 3000, this.request({ method: 'BYE' }).catch(() => {
|
||||
// Don't care if we get an exception here.
|
||||
}));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.console.debug("detroying sip-manager")
|
||||
this.destroyed = true
|
||||
this.sipStack.destroy()
|
||||
this.console.debug("detroying sip-manager: done")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
plugins/snapshot/package-lock.json
generated
4
plugins/snapshot/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.0.52",
|
||||
"version": "0.0.53",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.0.52",
|
||||
"version": "0.0.53",
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
"@types/node": "^16.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.0.52",
|
||||
"version": "0.0.53",
|
||||
"description": "Snapshot Plugin for Scrypted",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
|
||||
@@ -300,9 +300,10 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
picture = await this.cropAndScale(picture);
|
||||
if (needSoftwareResize) {
|
||||
picture = await ffmpegFilterImageBuffer(picture, {
|
||||
ffmpegPath: await mediaManager.getFFmpegPath(),
|
||||
console: this.debugConsole,
|
||||
ffmpegPath: await mediaManager.getFFmpegPath(),
|
||||
resize: options?.picture,
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
this.clearCachedPictures();
|
||||
@@ -353,8 +354,8 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
const ymax = Math.max(...this.storageSettings.values.snapshotCropScale.map(([x, y]) => y)) / 100;
|
||||
|
||||
return ffmpegFilterImageBuffer(buffer, {
|
||||
ffmpegPath: await mediaManager.getFFmpegPath(),
|
||||
console: this.debugConsole,
|
||||
ffmpegPath: await mediaManager.getFFmpegPath(),
|
||||
crop: {
|
||||
fractional: true,
|
||||
left: xmin,
|
||||
@@ -362,6 +363,7 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
width: xmax - xmin,
|
||||
height: ymax - ymin,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -446,14 +448,15 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
|
||||
}
|
||||
else {
|
||||
return ffmpegFilterImageBuffer(errorBackground, {
|
||||
ffmpegPath: await mediaManager.getFFmpegPath(),
|
||||
console: this.debugConsole,
|
||||
ffmpegPath: await mediaManager.getFFmpegPath(),
|
||||
blur: true,
|
||||
brightness: -.2,
|
||||
text: {
|
||||
fontFile,
|
||||
text,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
4
plugins/sort-tracker/package-lock.json
generated
4
plugins/sort-tracker/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/tensorflow-lite",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/tensorflow-lite",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.0.2"
|
||||
"version": "0.0.3"
|
||||
}
|
||||
|
||||
Submodule plugins/sort-tracker/sort_oh updated: 16a46b77f7...3db0328cd3
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user