mirror of
https://github.com/koush/scrypted.git
synced 2026-02-03 14:13:28 +00:00
Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db03775530 | ||
|
|
cccbc33f1a | ||
|
|
5f23873366 | ||
|
|
e43accae67 | ||
|
|
b3a0cda6f9 | ||
|
|
58c3348282 | ||
|
|
a9e6d76e99 | ||
|
|
3b58936387 | ||
|
|
3a14ab81c8 | ||
|
|
291178a7b5 | ||
|
|
b65faf1a79 | ||
|
|
9d8a1353c0 | ||
|
|
b29d793178 | ||
|
|
d8e406d415 | ||
|
|
4529872fd6 | ||
|
|
fa86c31340 | ||
|
|
94ded75d40 | ||
|
|
887b61cd7a | ||
|
|
48e3d30987 | ||
|
|
02dba3cd71 | ||
|
|
195769034d | ||
|
|
39c08aa378 | ||
|
|
fa8056d38e | ||
|
|
145f116c68 | ||
|
|
15b6f336e4 | ||
|
|
8b46f0a466 | ||
|
|
a20cc5cd89 | ||
|
|
3d068929fd | ||
|
|
928f9b7579 | ||
|
|
c1c5a42645 | ||
|
|
12643cdde2 | ||
|
|
0bff96a6e6 | ||
|
|
4e7e67de54 | ||
|
|
65c4a30004 | ||
|
|
309a1dc11f | ||
|
|
b7904b73b2 | ||
|
|
9e9ddbc5f3 | ||
|
|
ceda54f91b | ||
|
|
1d4052b839 | ||
|
|
6a5d6e6617 | ||
|
|
f55cc6066f | ||
|
|
527714e434 | ||
|
|
8a1633ffa3 | ||
|
|
56b2ab9c4f | ||
|
|
d330e2eb9d | ||
|
|
b55e7cacb3 | ||
|
|
c70375db06 | ||
|
|
2c23021d40 | ||
|
|
84a4ef4539 | ||
|
|
7f3db0549b | ||
|
|
de0e1784a3 | ||
|
|
5a8798638e | ||
|
|
14da49728c | ||
|
|
55423b2d09 | ||
|
|
596106247b | ||
|
|
5472d90368 | ||
|
|
fcf58413fc | ||
|
|
0d03b91753 | ||
|
|
2fd088e4d6 | ||
|
|
c6933198b2 | ||
|
|
210e684a22 | ||
|
|
53cc4b6ef3 | ||
|
|
d58d138a68 | ||
|
|
c0199a2b76 | ||
|
|
badb1905ce | ||
|
|
735c2dce7b | ||
|
|
ffae3f246f | ||
|
|
31b424f89f | ||
|
|
3b7acc3a90 | ||
|
|
7e66d1ac7f | ||
|
|
a613da069e | ||
|
|
40b73c6589 | ||
|
|
ef16ca83a2 | ||
|
|
76bf1d0d3f | ||
|
|
3d5ccf25d1 | ||
|
|
36fcb713d9 | ||
|
|
e306631850 | ||
|
|
17400fa886 | ||
|
|
c6dc628616 | ||
|
|
f974653e73 | ||
|
|
b83880a8a3 | ||
|
|
ee4d8f52df | ||
|
|
3854b75c6e | ||
|
|
07c3173506 | ||
|
|
2894ab1b96 | ||
|
|
99995ea882 | ||
|
|
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 | ||
|
|
4121cbd400 | ||
|
|
2d3bb8798d | ||
|
|
b7b6ac0f87 | ||
|
|
e5fb65d75e | ||
|
|
290b73f3d9 | ||
|
|
f717e87306 | ||
|
|
b80ac7c60d | ||
|
|
997a4732ec | ||
|
|
6e08f11578 | ||
|
|
87c4814e6f | ||
|
|
2e0e009719 | ||
|
|
77399038e9 | ||
|
|
fae66619fb | ||
|
|
d979b9ec0c | ||
|
|
975319a65d | ||
|
|
7b5aa4ba2d | ||
|
|
670739c82b | ||
|
|
8511bd15a8 | ||
|
|
06d3c89274 | ||
|
|
e13f3eb2f1 | ||
|
|
001918d613 | ||
|
|
c859c3aa40 | ||
|
|
2bce019677 | ||
|
|
6ba3386157 | ||
|
|
51e66d98f9 | ||
|
|
6484804649 | ||
|
|
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
|
||||
|
||||
@@ -361,8 +361,7 @@ export interface RebroadcasterOptions {
|
||||
},
|
||||
}
|
||||
|
||||
export async function handleRebroadcasterClient(duplex: Promise<Duplex> | Duplex, options?: RebroadcasterOptions) {
|
||||
const socket = await duplex;
|
||||
export function handleRebroadcasterClient(socket: Duplex, options?: RebroadcasterOptions) {
|
||||
const firstWriteData = (data: StreamChunk) => {
|
||||
if (data.startStream) {
|
||||
socket.write(data.startStream)
|
||||
|
||||
@@ -62,4 +62,4 @@ export async function bind(server: dgram.Socket, port: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export { listenZero, listenZeroSingleClient } from "@scrypted/server/src/listen-zero";
|
||||
export { listenZero, listenZeroSingleClient, ListenZeroSingleClientTimeoutError } from "@scrypted/server/src/listen-zero";
|
||||
|
||||
@@ -250,7 +250,8 @@ export class BrowserSignalingSession implements RTCSignalingSession {
|
||||
function logSendCandidate(console: Console, type: string, session: RTCSignalingSession): RTCSignalingSendIceCandidate {
|
||||
return async (candidate) => {
|
||||
try {
|
||||
console.log(`${type} trickled candidate:`, candidate.sdpMLineIndex, candidate.candidate);
|
||||
if (localStorage.getItem('debugLog') === 'true')
|
||||
console.log(`${type} trickled candidate:`, candidate.sdpMLineIndex, candidate.candidate);
|
||||
await session.addIceCandidate(candidate);
|
||||
}
|
||||
catch (e) {
|
||||
@@ -308,11 +309,13 @@ export async function connectRTCSignalingClients(
|
||||
|
||||
const offer = await offerClient.createLocalDescription('offer', offerSetup as RTCAVSignalingSetup,
|
||||
disableTrickle ? undefined : answerQueue.queueSendCandidate);
|
||||
console.log('offer sdp', offer.sdp);
|
||||
if (localStorage.getItem('debugLog') === 'true')
|
||||
console.log('offer sdp', offer.sdp);
|
||||
await answerClient.setRemoteDescription(offer, answerSetup as RTCAVSignalingSetup);
|
||||
const answer = await answerClient.createLocalDescription('answer', answerSetup as RTCAVSignalingSetup,
|
||||
disableTrickle ? undefined : offerQueue.queueSendCandidate);
|
||||
console.log('answer sdp', answer.sdp);
|
||||
if (localStorage.getItem('debugLog') === 'true')
|
||||
console.log('answer sdp', answer.sdp);
|
||||
await offerClient.setRemoteDescription(answer, offerSetup as RTCAVSignalingSetup);
|
||||
offerQueue.flush();
|
||||
answerQueue.flush();
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -128,6 +129,16 @@ export function getNaluTypes(streamChunk: StreamChunk) {
|
||||
return getNaluTypesInNalu(streamChunk.chunks[streamChunk.chunks.length - 1].subarray(12))
|
||||
}
|
||||
|
||||
export function getNaluFragmentInformation(nalu: Buffer) {
|
||||
const naluType = nalu[0] & 0x1f;
|
||||
const fua = naluType === H264_NAL_TYPE_FU_A;
|
||||
return {
|
||||
fua,
|
||||
fuaStart: fua && !!(nalu[1] & 0x80),
|
||||
fuaEnd: fua && !!(nalu[1] & 0x40),
|
||||
}
|
||||
}
|
||||
|
||||
export function getNaluTypesInNalu(nalu: Buffer, fuaRequireStart = false, fuaRequireEnd = false) {
|
||||
const ret = new Set<number>();
|
||||
const naluType = nalu[0] & 0x1f;
|
||||
@@ -580,7 +591,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,52 @@ 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.
|
||||
# furthermore, it's possible to run 32bit docker on 64bit arm,
|
||||
# which causes weird behavior in python which looks at the arch version
|
||||
# which still reports 64bit, even if running in 32bit docker.
|
||||
# this scenario is not supported and will be reported at runtime.
|
||||
RUN if [ "$(uname -m)" != "x86_64" ]; \
|
||||
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 --force-reinstall --no-binary :all: cffi
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions psutil
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.header
|
||||
@@ -80,6 +93,11 @@ 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=20230329
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=full
|
||||
|
||||
################################################################
|
||||
# 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,30 @@ 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=20230329
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=lite
|
||||
|
||||
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,12 @@ 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=20230329
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=thin
|
||||
|
||||
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:
|
||||
@@ -85,4 +90,4 @@ services:
|
||||
# Must match the port in the auto update url above.
|
||||
- 10444:8080
|
||||
# check for updates once an hour (interval is in seconds)
|
||||
command: --interval 3600 --cleanup
|
||||
command: --interval 3600 --cleanup --scope scrypted
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[server]
|
||||
#host-name=
|
||||
use-ipv4=yes
|
||||
use-ipv6=no
|
||||
use-ipv6=yes
|
||||
enable-dbus=yes
|
||||
ratelimit-interval-usec=1000000
|
||||
ratelimit-burst=1000
|
||||
@@ -14,4 +14,4 @@ rlimit-core=0
|
||||
rlimit-data=4194304
|
||||
rlimit-fsize=0
|
||||
rlimit-nofile=768
|
||||
rlimit-stack=4194304
|
||||
rlimit-stack=4194304
|
||||
|
||||
@@ -42,47 +42,27 @@ RUN brew update
|
||||
RUN_IGNORE brew install node@18
|
||||
# snapshot plugin and others
|
||||
RUN brew install libvips
|
||||
# gstreamer plugins
|
||||
RUN_IGNORE brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly
|
||||
# gst python bindings
|
||||
RUN_IGNORE brew install gst-python
|
||||
# python image library
|
||||
# todo: consider removing this
|
||||
RUN_IGNORE brew install pillow
|
||||
# dlib
|
||||
RUN brew install cmake
|
||||
|
||||
### HACK WORKAROUND
|
||||
### https://github.com/koush/scrypted/issues/544
|
||||
|
||||
brew unpin gstreamer
|
||||
brew unpin gst-python
|
||||
brew unpin gst-plugins-ugly
|
||||
brew unpin gst-plugins-good
|
||||
brew unpin gst-plugins-base
|
||||
brew unpin gst-plugins-good
|
||||
brew unpin gst-plugins-bad
|
||||
|
||||
brew unlink gstreamer
|
||||
brew unlink gst-python
|
||||
brew unlink gst-plugins-ugly
|
||||
brew unlink gst-plugins-good
|
||||
brew unlink gst-plugins-base
|
||||
brew unlink gst-plugins-bad
|
||||
|
||||
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
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-ugly.rb && brew install ./gst-plugins-ugly.rb
|
||||
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
|
||||
|
||||
brew pin gstreamer
|
||||
brew pin gst-python
|
||||
brew pin gst-plugins-ugly
|
||||
brew pin gst-plugins-good
|
||||
brew pin gst-plugins-base
|
||||
brew pin gst-plugins-bad
|
||||
brew unpin gst-plugins-ugly
|
||||
brew unpin gst-libav
|
||||
brew unpin gst-python
|
||||
|
||||
### END HACK WORKAROUND
|
||||
|
||||
# gstreamer plugins
|
||||
RUN_IGNORE brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
|
||||
# gst python bindings
|
||||
RUN_IGNORE brew install gst-python
|
||||
|
||||
ARCH=$(arch)
|
||||
if [ "$ARCH" = "arm64" ]
|
||||
then
|
||||
|
||||
@@ -42,7 +42,7 @@ fi
|
||||
WATCHTOWER_HTTP_API_TOKEN=$(echo $RANDOM | md5sum)
|
||||
DOCKER_COMPOSE_YML=$SCRYPTED_HOME/docker-compose.yml
|
||||
echo "Created $DOCKER_COMPOSE_YML"
|
||||
curl -s https://raw.githubusercontent.com/koush/scrypted/main/docker/docker-compose.yml | sed s/SET_THIS_TO_SOME_RANDOM_TEXT/"$(echo $RANDOM | md5sum)"/g > $DOCKER_COMPOSE_YML
|
||||
curl -s https://raw.githubusercontent.com/koush/scrypted/main/docker/docker-compose.yml | sed s/SET_THIS_TO_SOME_RANDOM_TEXT/"$(echo $RANDOM | md5sum | head -c 32)"/g > $DOCKER_COMPOSE_YML
|
||||
|
||||
echo "Setting permissions on $SCRYPTED_HOME"
|
||||
chown -R $SERVICE_USER $SCRYPTED_HOME
|
||||
|
||||
@@ -8,6 +8,11 @@ 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=20230329
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=full
|
||||
|
||||
################################################################
|
||||
# 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,52 @@ 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.
|
||||
# furthermore, it's possible to run 32bit docker on 64bit arm,
|
||||
# which causes weird behavior in python which looks at the arch version
|
||||
# which still reports 64bit, even if running in 32bit docker.
|
||||
# this scenario is not supported and will be reported at runtime.
|
||||
RUN if [ "$(uname -m)" != "x86_64" ]; \
|
||||
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 --force-reinstall --no-binary :all: cffi
|
||||
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
8
packages/client/package-lock.json
generated
8
packages/client/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "1.1.43",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@scrypted/types": "^0.2.76",
|
||||
"@scrypted/types": "^0.2.78",
|
||||
"axios": "^0.25.0",
|
||||
"engine.io-client": "^6.4.0",
|
||||
"rimraf": "^3.0.2"
|
||||
@@ -21,9 +21,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@scrypted/types": {
|
||||
"version": "0.2.76",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.76.tgz",
|
||||
"integrity": "sha512-/7n8ICkXj8TGba4cHvckLCgSNsOmOGQ8I+Jd8fX9sxkthgsZhF5At8PHhHdkCDS+yfSmfXHkcqluZZOfYPkpAg=="
|
||||
"version": "0.2.78",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.78.tgz",
|
||||
"integrity": "sha512-SiIUh9ph96aZPjt/oO+W/mlJobrP02ADwFDI9jnvw8/UegUti2x/7JE8Pi3kGXOIkN+cX74Qg4xJEMIpdpO1zw=="
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scrypted/types": "^0.2.76",
|
||||
"@scrypted/types": "^0.2.78",
|
||||
"axios": "^0.25.0",
|
||||
"engine.io-client": "^6.4.0",
|
||||
"rimraf": "^3.0.2"
|
||||
|
||||
4
plugins/amcrest/package-lock.json
generated
4
plugins/amcrest/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.119",
|
||||
"version": "0.0.120",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.119",
|
||||
"version": "0.0.120",
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.119",
|
||||
"version": "0.0.120",
|
||||
"description": "Amcrest Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -616,7 +616,7 @@ class AmcrestProvider extends RtspProvider {
|
||||
this.console.warn('Error probing two way audio', e);
|
||||
}
|
||||
}
|
||||
settings.newCamera ||= 'Hikvision Camera';
|
||||
settings.newCamera ||= 'Amcrest Camera';
|
||||
|
||||
nativeId = await super.createDevice(settings, nativeId);
|
||||
|
||||
|
||||
2
plugins/arlo/.vscode/settings.json
vendored
2
plugins/arlo/.vscode/settings.json
vendored
@@ -22,6 +22,6 @@
|
||||
//"scrypted.volumeRoot": "${config:scrypted.serverRoot}/volume",
|
||||
|
||||
"python.analysis.extraPaths": [
|
||||
"./node_modules/@scrypted/sdk/scrypted_python"
|
||||
"./node_modules/@scrypted/sdk/types/scrypted_python"
|
||||
]
|
||||
}
|
||||
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.7",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/arlo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.7.7",
|
||||
"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.7",
|
||||
"description": "Arlo Plugin for Scrypted",
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
|
||||
@@ -29,7 +29,8 @@ from .sse_stream_async import EventStream
|
||||
from .logging import logger
|
||||
|
||||
# Import all of the other stuff.
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
@@ -709,3 +710,165 @@ class Arlo(object):
|
||||
trigger,
|
||||
callback,
|
||||
)
|
||||
|
||||
def SirenOn(self, basestation, camera=None):
|
||||
if camera is not None:
|
||||
resource = f"siren/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "on",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": "siren",
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "on",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
|
||||
def SirenOff(self, basestation, camera=None):
|
||||
if camera is not None:
|
||||
resource = f"siren/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "off",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": "siren",
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "off",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
|
||||
def SpotlightOn(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"spotlight": {
|
||||
"enabled": True,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def SpotlightOff(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"spotlight": {
|
||||
"enabled": False,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def FloodlightOn(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"floodlight": {
|
||||
"on": True,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def FloodlightOff(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"floodlight": {
|
||||
"on": False,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def GetLibrary(self, device, from_date: datetime, to_date: datetime):
|
||||
"""
|
||||
This call returns the following:
|
||||
presignedContentUrl is a link to the actual video in Amazon AWS.
|
||||
presignedThumbnailUrl is a link to the thumbnail .jpg of the actual video in Amazon AWS.
|
||||
[
|
||||
{
|
||||
"mediaDurationSecond": 30,
|
||||
"contentType": "video/mp4",
|
||||
"name": "XXXXXXXXXXXXX",
|
||||
"presignedContentUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX.mp4?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"lastModified": 1472881430181,
|
||||
"localCreatedDate": XXXXXXXXXXXXX,
|
||||
"presignedThumbnailUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX_thumb.jpg?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"reason": "motionRecord",
|
||||
"deviceId": "XXXXXXXXXXXXX",
|
||||
"createdBy": "XXXXXXXXXXXXX",
|
||||
"createdDate": "20160903",
|
||||
"timeZone": "America/Chicago",
|
||||
"ownerId": "XXX-XXXXXXX",
|
||||
"utcCreatedDate": XXXXXXXXXXXXX,
|
||||
"currentState": "new",
|
||||
"mediaDuration": "00:00:30"
|
||||
}
|
||||
]
|
||||
"""
|
||||
# give the query range a bit of buffer
|
||||
from_date_internal = from_date - timedelta(days=1)
|
||||
to_date_internal = to_date + timedelta(days=1)
|
||||
|
||||
return [
|
||||
result for result in
|
||||
self._getLibraryCached(from_date_internal.strftime("%Y%m%d"), to_date_internal.strftime("%Y%m%d"))
|
||||
if result["deviceId"] == device["deviceId"]
|
||||
and datetime.fromtimestamp(int(result["name"]) / 1000.0) <= to_date
|
||||
and datetime.fromtimestamp(int(result["name"]) / 1000.0) >= from_date
|
||||
]
|
||||
|
||||
@cached(cache=TTLCache(maxsize=512, ttl=60))
|
||||
def _getLibraryCached(self, from_date: str, to_date: str):
|
||||
logger.debug(f"Library cache miss for {from_date}, {to_date}")
|
||||
return self.request.post(
|
||||
f'https://{self.BASE_URL}/hmsweb/users/library',
|
||||
{
|
||||
'dateFrom': from_date,
|
||||
'dateTo': to_date
|
||||
}
|
||||
)
|
||||
|
||||
def GetSmartFeatures(self, device):
|
||||
smart_features = self._getSmartFeaturesCached()
|
||||
key = f"{device['owner']['ownerId']}_{device['deviceId']}"
|
||||
return smart_features["features"].get(key)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=60))
|
||||
def _getSmartFeaturesCached(self):
|
||||
return self.request.get(f'https://{self.BASE_URL}/hmsweb/users/subscription/smart/features')
|
||||
80
plugins/arlo/src/arlo_plugin/base.py
Normal file
80
plugins/arlo/src/arlo_plugin/base.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Device
|
||||
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
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) -> None:
|
||||
self.stop_subscriptions = True
|
||||
self.cancel_pending_tasks()
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
"""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) -> Device:
|
||||
"""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[Device]:
|
||||
"""Returns the list of child device manifests representing hardware features built into this device."""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def async_print_exception_guard(self, fn):
|
||||
"""Decorator to print an exception's stack trace before re-raising the exception."""
|
||||
async def wrapped(*args, **kwargs):
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
raise
|
||||
return wrapped
|
||||
71
plugins/arlo/src/arlo_plugin/basestation.py
Normal file
71
plugins/arlo/src/arlo_plugin/basestation.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Device, DeviceProvider, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .base import ArloDeviceBase
|
||||
from .vss import ArloSirenVirtualSecuritySystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
|
||||
|
||||
class ArloBasestation(ArloDeviceBase, DeviceProvider):
|
||||
MODELS_WITH_SIRENS = [
|
||||
"vmb4000",
|
||||
"vmb4500"
|
||||
]
|
||||
|
||||
vss: ArloSirenVirtualSecuritySystem = None
|
||||
|
||||
def __init__(self, nativeId: str, arlo_basestation: dict, provider: ArloProvider) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_basestation, arlo_basestation=arlo_basestation, provider=provider)
|
||||
|
||||
@property
|
||||
def has_siren(self) -> bool:
|
||||
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloBasestation.MODELS_WITH_SIRENS])
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
return [ScryptedInterface.DeviceProvider.value]
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.DeviceProvider.value
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> List[Device]:
|
||||
if not self.has_siren:
|
||||
# this basestation has no builtin siren, so no manifests to return
|
||||
return []
|
||||
|
||||
vss = self.get_or_create_vss()
|
||||
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": vss.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System',
|
||||
"interfaces": vss.get_applicable_interfaces(),
|
||||
"type": vss.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
},
|
||||
] + vss.get_builtin_child_device_manifests()
|
||||
|
||||
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 not nativeId.endswith("vss"):
|
||||
return None
|
||||
return self.get_or_create_vss()
|
||||
|
||||
def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem:
|
||||
vss_id = f'{self.arlo_device["deviceId"]}.vss'
|
||||
if not self.vss:
|
||||
self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.vss
|
||||
@@ -1,47 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
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 Setting, Settings, Device, Camera, VideoCamera, VideoClips, VideoClip, VideoClipOptions, MotionSensor, Battery, DeviceProvider, MediaObject, ResponsePictureOptions, ResponseMediaStreamOptions, ScryptedMimeTypes, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .base import ArloDeviceBase
|
||||
from .spotlight import ArloSpotlight, ArloFloodlight
|
||||
from .vss import ArloSirenVirtualSecuritySystem
|
||||
from .child_process import HeartbeatChildProcess
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
|
||||
class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor, Battery, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
timeout = 30
|
||||
nativeId = None
|
||||
arlo_device = None
|
||||
arlo_basestation = None
|
||||
provider = None
|
||||
|
||||
def __init__(self, nativeId, arlo_device, arlo_basestation, provider):
|
||||
super().__init__(nativeId=nativeId)
|
||||
class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider, VideoClips, MotionSensor, Battery):
|
||||
MODELS_WITH_SPOTLIGHTS = [
|
||||
"vmc4040p",
|
||||
"vmc2030",
|
||||
"vmc2032",
|
||||
"vmc4041p",
|
||||
"vmc4050p",
|
||||
"vmc5040",
|
||||
"vml2030",
|
||||
"vml4030",
|
||||
]
|
||||
|
||||
self.logger_name = nativeId
|
||||
MODELS_WITH_FLOODLIGHTS = ["fb1001"]
|
||||
|
||||
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())
|
||||
MODELS_WITH_SIRENS = [
|
||||
"vmb4000",
|
||||
"vmb4500",
|
||||
"vmb4540",
|
||||
"vmb5000",
|
||||
"vmc4040p",
|
||||
"fb1001",
|
||||
"vmc2030",
|
||||
"vmc2020",
|
||||
"vmc2032",
|
||||
"vmc4041p",
|
||||
"vmc4050p",
|
||||
"vmc5040",
|
||||
"vml2030",
|
||||
"vmc4030",
|
||||
"vml4030",
|
||||
"vmc4030p",
|
||||
]
|
||||
|
||||
self.intercom_session = None
|
||||
timeout: int = 30
|
||||
intercom_session = None
|
||||
light: ArloSpotlight = None
|
||||
vss: ArloSirenVirtualSecuritySystem = None
|
||||
|
||||
self.stop_subscriptions = False
|
||||
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_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 +75,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
|
||||
@@ -59,7 +84,7 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
self.provider.arlo.SubscribeToBatteryEvents(self.arlo_basestation, self.arlo_device, callback)
|
||||
)
|
||||
|
||||
def get_applicable_interfaces(self) -> list:
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
results = set([
|
||||
ScryptedInterface.VideoCamera.value,
|
||||
ScryptedInterface.Camera.value,
|
||||
@@ -76,21 +101,66 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
results.add(ScryptedInterface.RTCSignalingChannel.value)
|
||||
results.discard(ScryptedInterface.Intercom.value)
|
||||
|
||||
if self.has_siren or self.has_spotlight or self.has_floodlight:
|
||||
results.add(ScryptedInterface.DeviceProvider.value)
|
||||
|
||||
if self.has_cloud_recording:
|
||||
results.add(ScryptedInterface.VideoClips.value)
|
||||
|
||||
if not self._can_push_to_talk():
|
||||
results.discard(ScryptedInterface.RTCSignalingChannel.value)
|
||||
results.discard(ScryptedInterface.Intercom.value)
|
||||
|
||||
return list(results)
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.Camera.value
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> List[Device]:
|
||||
results = []
|
||||
if self.has_spotlight or self.has_floodlight:
|
||||
light = self.get_or_create_spotlight_or_floodlight()
|
||||
results.append({
|
||||
"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": light.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} {"Spotlight" if self.has_spotlight else "Floodlight"}',
|
||||
"interfaces": light.get_applicable_interfaces(),
|
||||
"type": light.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
})
|
||||
if self.has_siren:
|
||||
vss = self.get_or_create_vss()
|
||||
results.extend([
|
||||
{
|
||||
"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": vss.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System',
|
||||
"interfaces": vss.get_applicable_interfaces(),
|
||||
"type": vss.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
},
|
||||
] + vss.get_builtin_child_device_manifests())
|
||||
return results
|
||||
|
||||
@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 +169,23 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
else:
|
||||
return True
|
||||
|
||||
async def getSettings(self):
|
||||
@property
|
||||
def has_cloud_recording(self) -> bool:
|
||||
return self.provider.arlo.GetSmartFeatures(self.arlo_device)["planFeatures"]["eventRecording"]
|
||||
|
||||
@property
|
||||
def has_spotlight(self) -> bool:
|
||||
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_SPOTLIGHTS])
|
||||
|
||||
@property
|
||||
def has_floodlight(self) -> bool:
|
||||
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_FLOODLIGHTS])
|
||||
|
||||
@property
|
||||
def has_siren(self) -> bool:
|
||||
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_SIRENS])
|
||||
|
||||
async def getSettings(self) -> List[Setting]:
|
||||
if self._can_push_to_talk():
|
||||
return [
|
||||
{
|
||||
@@ -120,15 +206,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()
|
||||
await self.provider.discover_devices()
|
||||
|
||||
async def getPictureOptions(self):
|
||||
async def getPictureOptions(self) -> List[ResponsePictureOptions]:
|
||||
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 +231,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[ResponseMediaStreamOptions]:
|
||||
return [
|
||||
{
|
||||
"id": 'default',
|
||||
@@ -163,16 +249,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:
|
||||
@@ -201,21 +300,110 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
|
||||
async def startIntercom(self, media):
|
||||
async def startIntercom(self, media) -> None:
|
||||
self.logger.info("Starting intercom")
|
||||
self.intercom_session = ArloCameraRTCSignalingSession(self)
|
||||
await self.intercom_session.initialize_push_to_talk(media)
|
||||
|
||||
async def stopIntercom(self):
|
||||
async def stopIntercom(self) -> None:
|
||||
self.logger.info("Stopping intercom")
|
||||
if self.intercom_session is not None:
|
||||
await self.intercom_session.shutdown()
|
||||
self.intercom_session = None
|
||||
|
||||
def _can_push_to_talk(self):
|
||||
def _can_push_to_talk(self) -> bool:
|
||||
# Right now, only implement push to talk for basestation cameras
|
||||
return self.arlo_device["deviceId"] != self.arlo_device["parentId"]
|
||||
|
||||
async def getVideoClip(self, videoId: str) -> MediaObject:
|
||||
self.logger.info(f"Getting video clip {videoId}")
|
||||
|
||||
id_as_time = int(videoId) / 1000.0
|
||||
start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10)
|
||||
end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10)
|
||||
|
||||
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
|
||||
for recording in library:
|
||||
if videoId == recording["name"]:
|
||||
return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedContentUrl"])
|
||||
self.logger.warn(f"Clip {videoId} not found")
|
||||
return None
|
||||
|
||||
async def getVideoClipThumbnail(self, thumbnailId: str) -> MediaObject:
|
||||
self.logger.info(f"Getting video clip thumbnail {thumbnailId}")
|
||||
|
||||
id_as_time = int(thumbnailId) / 1000.0
|
||||
start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10)
|
||||
end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10)
|
||||
|
||||
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
|
||||
for recording in library:
|
||||
if thumbnailId == recording["name"]:
|
||||
return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedThumbnailUrl"])
|
||||
self.logger.warn(f"Clip thumbnail {thumbnailId} not found")
|
||||
return None
|
||||
|
||||
async def getVideoClips(self, options: VideoClipOptions = None) -> List[VideoClip]:
|
||||
self.logger.info(f"Fetching remote video clips {options}")
|
||||
|
||||
start = datetime.fromtimestamp(options["startTime"] / 1000.0)
|
||||
end = datetime.fromtimestamp(options["endTime"] / 1000.0)
|
||||
|
||||
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
|
||||
clips = []
|
||||
for recording in library:
|
||||
clip = {
|
||||
"duration": recording["mediaDurationSecond"] * 1000.0,
|
||||
"id": recording["name"],
|
||||
"thumbnailId": recording["name"],
|
||||
"videoId": recording["name"],
|
||||
"startTime": recording["utcCreatedDate"],
|
||||
"description": recording["reason"],
|
||||
"resources": {
|
||||
"thumbnail": {
|
||||
"href": recording["presignedThumbnailUrl"],
|
||||
},
|
||||
"video": {
|
||||
"href": recording["presignedContentUrl"],
|
||||
},
|
||||
},
|
||||
}
|
||||
clips.append(clip)
|
||||
|
||||
if options.get("reverseOrder"):
|
||||
clips.reverse()
|
||||
return clips
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def removeVideoClips(self, videoClipIds: List[str]) -> None:
|
||||
# Arlo does support deleting, but let's be safe and disable that
|
||||
raise Exception("deleting Arlo video clips is not implemented by this plugin")
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
|
||||
if (nativeId.endswith("spotlight") and self.has_spotlight) or (nativeId.endswith("floodlight") and self.has_floodlight):
|
||||
return self.get_or_create_spotlight_or_floodlight()
|
||||
if nativeId.endswith("vss") and self.has_siren:
|
||||
return self.get_or_create_vss()
|
||||
return None
|
||||
|
||||
def get_or_create_spotlight_or_floodlight(self) -> ArloSpotlight:
|
||||
if self.has_spotlight:
|
||||
light_id = f'{self.arlo_device["deviceId"]}.spotlight'
|
||||
if not self.light:
|
||||
self.light = ArloSpotlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
elif self.has_floodlight:
|
||||
light_id = f'{self.arlo_device["deviceId"]}.floodlight'
|
||||
if not self.light:
|
||||
self.light = ArloFloodlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.light
|
||||
|
||||
def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem:
|
||||
if self.has_siren:
|
||||
vss_id = f'{self.arlo_device["deviceId"]}.vss'
|
||||
if not self.vss:
|
||||
self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.vss
|
||||
|
||||
|
||||
class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
|
||||
def __init__(self, camera):
|
||||
@@ -330,7 +518,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 +560,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()
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
from scrypted_sdk.types import BinarySensor, ScryptedInterface
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk.types import BinarySensor, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .camera import ArloCamera
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
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_device_type(self) -> str:
|
||||
return ScryptedDeviceType.Doorbell.value
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
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
|
||||
|
||||
@@ -6,21 +6,24 @@ import logging
|
||||
import re
|
||||
import requests
|
||||
import traceback
|
||||
from typing import List
|
||||
|
||||
import scrypted_sdk
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Settings, DeviceProvider, DeviceDiscovery, ScryptedInterface, ScryptedDeviceType
|
||||
from scrypted_sdk.types import Setting, SettingValue, Settings, DeviceProvider, ScryptedInterface
|
||||
|
||||
from .arlo import Arlo
|
||||
from .arlo.arlo_async import change_stream_class
|
||||
from .arlo.logging import logger as arlo_lib_logger
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
from .camera import ArloCamera
|
||||
from .doorbell import ArloDoorbell
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
from .basestation import ArloBasestation
|
||||
from .base import ArloDeviceBase
|
||||
|
||||
|
||||
class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
arlo_cameras = None
|
||||
arlo_basestations = None
|
||||
_arlo_mfa_code = None
|
||||
@@ -37,7 +40,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 +63,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 +92,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 +100,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 +108,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 +116,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 +128,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 +152,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 +165,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,9 +186,9 @@ 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.discover_devices()
|
||||
await self.arlo.Subscribe([
|
||||
(self.arlo_basestations[camera["parentId"]], camera) for camera in self.arlo_cameras.values()
|
||||
])
|
||||
@@ -204,7 +207,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 +216,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 +227,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 +248,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 +261,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 +371,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[Setting]:
|
||||
results = [
|
||||
{
|
||||
"group": "General",
|
||||
@@ -447,7 +450,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
"value": self.imap_mfa_interval,
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
results.extend([
|
||||
{
|
||||
"group": "General",
|
||||
@@ -469,17 +472,16 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
{
|
||||
"group": "General",
|
||||
"key": "plugin_verbosity",
|
||||
"title": "Plugin Verbosity",
|
||||
"description": "Select the verbosity of this plugin. 'Verbose' will show debugging messages, "
|
||||
"including events received from connected Arlo cameras.",
|
||||
"value": self.plugin_verbosity,
|
||||
"choices": sorted(self.plugin_verbosity_choices.keys()),
|
||||
"title": "Verbose Logging",
|
||||
"description": "Enable this option to show debug messages, including events received from connected Arlo cameras.",
|
||||
"value": self.plugin_verbosity == "Verbose",
|
||||
"type": "boolean",
|
||||
},
|
||||
])
|
||||
|
||||
return results
|
||||
|
||||
async def putSetting(self, key, value):
|
||||
async def putSetting(self, key: str, value: SettingValue) -> None:
|
||||
if not self.validate_setting(key, value):
|
||||
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
|
||||
return
|
||||
@@ -490,13 +492,14 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
elif key == "force_reauth":
|
||||
# force arlo client to be invalidated and reloaded
|
||||
self.invalidate_arlo_client()
|
||||
elif key == "plugin_verbosity":
|
||||
self.storage.setItem(key, "Verbose" if value == "true" else "Normal")
|
||||
self.propagate_verbosity()
|
||||
skip_arlo_client = True
|
||||
else:
|
||||
self.storage.setItem(key, value)
|
||||
|
||||
if key == "plugin_verbosity":
|
||||
self.propagate_verbosity()
|
||||
skip_arlo_client = True
|
||||
elif key == "arlo_transport":
|
||||
if key == "arlo_transport":
|
||||
self.propagate_transport()
|
||||
# force arlo client to be invalidated and reloaded, but
|
||||
# keep any mfa codes
|
||||
@@ -525,7 +528,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: SettingValue) -> bool:
|
||||
if key == "refresh_interval":
|
||||
try:
|
||||
val = int(val)
|
||||
@@ -555,7 +558,8 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return False
|
||||
return True
|
||||
|
||||
async def discoverDevices(self, duration=0):
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def discover_devices(self, duration: int = 0) -> None:
|
||||
if not self.arlo:
|
||||
raise Exception("Arlo client not connected, cannot discover devices")
|
||||
|
||||
@@ -564,70 +568,122 @@ 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"]
|
||||
self.logger.debug(f"Adding {nativeId}")
|
||||
|
||||
if nativeId in self.arlo_basestations:
|
||||
self.logger.info(f"Skipping basestation {nativeId} ({basestation['modelId']}) as it has already been added")
|
||||
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)
|
||||
|
||||
# we 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)
|
||||
|
||||
# add any builtin child devices and trickle discover them
|
||||
child_manifests = device.get_builtin_child_device_manifests()
|
||||
for child_manifest in child_manifests:
|
||||
await scrypted_sdk.deviceManager.onDeviceDiscovered(child_manifest)
|
||||
provider_to_device_map.setdefault(child_manifest["providerNativeId"], []).append(child_manifest)
|
||||
|
||||
self.logger.info(f"Discovered {len(basestations)} basestations")
|
||||
|
||||
devices = []
|
||||
cameras = self.arlo.GetDevices(['camera', "arloq", "arloqs", "doorbell"])
|
||||
for camera in cameras:
|
||||
nativeId = camera["deviceId"]
|
||||
self.logger.debug(f"Adding {nativeId}")
|
||||
|
||||
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")
|
||||
self.logger.info(f"Skipping camera {camera['deviceId']} ({camera['modelId']}) 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} ({camera['modelId']}) as it has already been added")
|
||||
continue
|
||||
self.arlo_cameras[nativeId] = camera
|
||||
|
||||
scrypted_interfaces = (await self.getDevice(nativeId)).get_applicable_interfaces()
|
||||
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
|
||||
|
||||
device: ArloDeviceBase = 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"]:
|
||||
provider_to_device_map.setdefault(None, []).append(manifest)
|
||||
else:
|
||||
provider_to_device_map.setdefault(camera["parentId"], []).append(manifest)
|
||||
|
||||
devices.append(device)
|
||||
# trickle discover this camera so it exists for later steps
|
||||
await scrypted_sdk.deviceManager.onDeviceDiscovered(manifest)
|
||||
|
||||
await scrypted_sdk.deviceManager.onDevicesChanged({
|
||||
"devices": devices,
|
||||
})
|
||||
# add any builtin child devices and trickle discover them
|
||||
child_manifests = device.get_builtin_child_device_manifests()
|
||||
for child_manifest in child_manifests:
|
||||
await scrypted_sdk.deviceManager.onDeviceDiscovered(child_manifest)
|
||||
provider_to_device_map.setdefault(child_manifest["providerNativeId"], []).append(child_manifest)
|
||||
|
||||
if len(cameras) != len(devices):
|
||||
self.logger.info(f"Discovered {len(cameras)} cameras, but only {len(devices)} are usable")
|
||||
camera_devices.append(manifest)
|
||||
|
||||
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) -> ArloDeviceBase:
|
||||
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:
|
||||
def create_device(self, nativeId: str) -> ArloDeviceBase:
|
||||
if nativeId not in self.arlo_cameras and nativeId not in self.arlo_basestations:
|
||||
self.logger.warning(f"Cannot create device for nativeId {nativeId}, maybe it hasn't been loaded yet?")
|
||||
return None
|
||||
arlo_camera = self.arlo_cameras[nativeId]
|
||||
|
||||
if arlo_camera["parentId"] not in self.arlo_basestations:
|
||||
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, self)
|
||||
|
||||
if arlo_device["parentId"] not in self.arlo_basestations:
|
||||
self.logger.warning(f"Cannot create camera with nativeId {nativeId} when {arlo_device['parentId']} is not a valid basestation")
|
||||
return None
|
||||
arlo_basestation = self.arlo_basestations[arlo_camera["parentId"]]
|
||||
arlo_basestation = self.arlo_basestations[arlo_device["parentId"]]
|
||||
|
||||
if arlo_camera["deviceType"] == "doorbell":
|
||||
return ArloDoorbell(nativeId, arlo_camera, arlo_basestation, self)
|
||||
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)
|
||||
71
plugins/arlo/src/arlo_plugin/siren.py
Normal file
71
plugins/arlo/src/arlo_plugin/siren.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk.types import OnOff, SecuritySystemMode, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .base import ArloDeviceBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
from .vss import ArloSirenVirtualSecuritySystem
|
||||
|
||||
|
||||
class ArloSiren(ArloDeviceBase, OnOff):
|
||||
vss: ArloSirenVirtualSecuritySystem = None
|
||||
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, vss: ArloSirenVirtualSecuritySystem) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
|
||||
self.vss = vss
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
return [ScryptedInterface.OnOff.value]
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.Siren.value
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def turnOn(self) -> None:
|
||||
from .basestation import ArloBasestation
|
||||
self.logger.info("Turning on")
|
||||
|
||||
if self.vss.securitySystemState["mode"] == SecuritySystemMode.Disarmed.value:
|
||||
self.logger.info("Virtual security system is disarmed, ignoring trigger")
|
||||
|
||||
# set and unset this property to force homekit to display the
|
||||
# switch as off
|
||||
self.on = True
|
||||
self.on = False
|
||||
self.vss.securitySystemState = {
|
||||
**self.vss.securitySystemState,
|
||||
"triggered": False,
|
||||
}
|
||||
return
|
||||
|
||||
if isinstance(self.vss.parent, ArloBasestation):
|
||||
self.logger.debug("Parent device is a basestation")
|
||||
self.provider.arlo.SirenOn(self.arlo_basestation)
|
||||
else:
|
||||
self.logger.debug("Parent device is a camera")
|
||||
self.provider.arlo.SirenOn(self.arlo_basestation, self.arlo_device)
|
||||
|
||||
self.on = True
|
||||
self.vss.securitySystemState = {
|
||||
**self.vss.securitySystemState,
|
||||
"triggered": True,
|
||||
}
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def turnOff(self) -> None:
|
||||
from .basestation import ArloBasestation
|
||||
self.logger.info("Turning off")
|
||||
if isinstance(self.vss.parent, ArloBasestation):
|
||||
self.provider.arlo.SirenOff(self.arlo_basestation)
|
||||
else:
|
||||
self.provider.arlo.SirenOff(self.arlo_basestation, self.arlo_device)
|
||||
self.on = False
|
||||
self.vss.securitySystemState = {
|
||||
**self.vss.securitySystemState,
|
||||
"triggered": False,
|
||||
}
|
||||
53
plugins/arlo/src/arlo_plugin/spotlight.py
Normal file
53
plugins/arlo/src/arlo_plugin/spotlight.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk.types import OnOff, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .base import ArloDeviceBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
from .camera import ArloCamera
|
||||
|
||||
|
||||
class ArloSpotlight(ArloDeviceBase, OnOff):
|
||||
camera: ArloCamera = None
|
||||
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, camera: ArloCamera) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
|
||||
self.camera = camera
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
return [ScryptedInterface.OnOff.value]
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.Light.value
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def turnOn(self) -> None:
|
||||
self.logger.info("Turning on")
|
||||
self.provider.arlo.SpotlightOn(self.arlo_basestation, self.arlo_device)
|
||||
self.on = True
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def turnOff(self) -> None:
|
||||
self.logger.info("Turning off")
|
||||
self.provider.arlo.SpotlightOff(self.arlo_basestation, self.arlo_device)
|
||||
self.on = False
|
||||
|
||||
|
||||
class ArloFloodlight(ArloSpotlight):
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def turnOn(self) -> None:
|
||||
self.logger.info("Turning on")
|
||||
self.provider.arlo.FloodlightOn(self.arlo_basestation, self.arlo_device)
|
||||
self.on = True
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def turnOff(self) -> None:
|
||||
self.logger.info("Turning off")
|
||||
self.provider.arlo.FloodlightOff(self.arlo_basestation, self.arlo_device)
|
||||
self.on = False
|
||||
@@ -2,12 +2,12 @@ import asyncio
|
||||
|
||||
|
||||
class BackgroundTaskMixin:
|
||||
def create_task(self, coroutine):
|
||||
def create_task(self, coroutine) -> asyncio.Task:
|
||||
task = asyncio.get_event_loop().create_task(coroutine)
|
||||
self.register_task(task)
|
||||
return task
|
||||
|
||||
def register_task(self, task):
|
||||
def register_task(self, task) -> None:
|
||||
if not hasattr(self, "background_tasks"):
|
||||
self.background_tasks = set()
|
||||
|
||||
@@ -21,6 +21,8 @@ class BackgroundTaskMixin:
|
||||
task.add_done_callback(print_exception)
|
||||
task.add_done_callback(self.background_tasks.discard)
|
||||
|
||||
def cancel_pending_tasks(self):
|
||||
def cancel_pending_tasks(self) -> None:
|
||||
if not hasattr(self, "background_tasks"):
|
||||
return
|
||||
for task in self.background_tasks:
|
||||
task.cancel()
|
||||
154
plugins/arlo/src/arlo_plugin/vss.py
Normal file
154
plugins/arlo/src/arlo_plugin/vss.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk.types import Device, DeviceProvider, Setting, Settings, SettingValue, SecuritySystem, SecuritySystemMode, Readme, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .base import ArloDeviceBase
|
||||
from .siren import ArloSiren
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
from .basestation import ArloBasestation
|
||||
from .camera import ArloCamera
|
||||
|
||||
|
||||
class ArloSirenVirtualSecuritySystem(ArloDeviceBase, SecuritySystem, DeviceProvider):
|
||||
"""A virtual, emulated security system that controls when scrypted events can trip the real physical siren."""
|
||||
|
||||
SUPPORTED_MODES = [SecuritySystemMode.AwayArmed.value, SecuritySystemMode.HomeArmed.value, SecuritySystemMode.Disarmed.value]
|
||||
|
||||
siren: ArloSiren = None
|
||||
parent: ArloBasestation | ArloCamera = None
|
||||
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, parent: ArloBasestation | ArloCamera) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
|
||||
self.parent = parent
|
||||
self.create_task(self.delayed_init())
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
mode = self.storage.getItem("mode")
|
||||
if mode is None or mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES:
|
||||
mode = SecuritySystemMode.Disarmed.value
|
||||
return mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, mode: str) -> None:
|
||||
if mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES:
|
||||
raise ValueError(f"invalid mode {mode}")
|
||||
self.storage.setItem("mode", mode)
|
||||
self.securitySystemState = {
|
||||
**self.securitySystemState,
|
||||
"mode": mode,
|
||||
}
|
||||
self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None))
|
||||
|
||||
async def delayed_init(self) -> None:
|
||||
iterations = 1
|
||||
while not self.stop_subscriptions:
|
||||
if iterations > 100:
|
||||
self.logger.error("Delayed init exceeded iteration limit, giving up")
|
||||
return
|
||||
|
||||
try:
|
||||
self.securitySystemState = {
|
||||
"supportedModes": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES,
|
||||
"mode": self.mode,
|
||||
}
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Delayed init failed, will try again: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
iterations += 1
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
return [
|
||||
ScryptedInterface.SecuritySystem.value,
|
||||
ScryptedInterface.DeviceProvider.value,
|
||||
ScryptedInterface.Settings.value,
|
||||
ScryptedInterface.Readme.value,
|
||||
]
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.SecuritySystem.value
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> List[Device]:
|
||||
siren = self.get_or_create_siren()
|
||||
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": siren.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} Siren',
|
||||
"interfaces": siren.get_applicable_interfaces(),
|
||||
"type": siren.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
}
|
||||
]
|
||||
|
||||
async def getSettings(self) -> List[Setting]:
|
||||
return [
|
||||
{
|
||||
"key": "mode",
|
||||
"title": "Arm Mode",
|
||||
"description": "If disarmed, the associated siren will not be physically triggered even if toggled.",
|
||||
"value": self.mode,
|
||||
"choices": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES,
|
||||
},
|
||||
]
|
||||
|
||||
async def putSetting(self, key: str, value: SettingValue) -> None:
|
||||
if key != "mode":
|
||||
raise ValueError(f"invalid setting {key}")
|
||||
self.mode = value
|
||||
if self.mode == SecuritySystemMode.Disarmed.value:
|
||||
await self.get_or_create_siren().turnOff()
|
||||
|
||||
async def getReadmeMarkdown(self) -> str:
|
||||
return """
|
||||
# Virtual Security System for Arlo Sirens
|
||||
|
||||
This security system device is not a real physical device, but a virtual, emulated device provided by the Arlo Scrypted plugin. Its purpose is to grant security system semantics of Arm/Disarm to avoid the accidental, unwanted triggering of the real physical siren through integrations such as Homekit.
|
||||
|
||||
To allow the siren to trigger, set the Arm Mode to any of the Armed options. When Disarmed, any triggers of the siren will be ignored. Switching modes will not perform any changes to Arlo cloud or your Arlo account, but rather only to this Scrypted device.
|
||||
|
||||
If this virtual security system is synced to Homekit, the siren device will be merged into the same security system accessory as a switch. The siren device will not be added as a separate accessory. To access the siren as a switch without the security system, disable syncing of the virtual security system and enable syncing of the siren, then ensure that the virtual security system is armed manually in its settings in Scrypted.
|
||||
""".strip()
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
|
||||
if not nativeId.endswith("siren"):
|
||||
return None
|
||||
return self.get_or_create_siren()
|
||||
|
||||
def get_or_create_siren(self) -> ArloSiren:
|
||||
siren_id = f'{self.arlo_device["deviceId"]}.siren'
|
||||
if not self.siren:
|
||||
self.siren = ArloSiren(siren_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.siren
|
||||
|
||||
async def armSecuritySystem(self, mode: SecuritySystemMode) -> None:
|
||||
self.logger.info(f"Arming {mode}")
|
||||
self.mode = mode
|
||||
self.securitySystemState = {
|
||||
**self.securitySystemState,
|
||||
"mode": mode,
|
||||
}
|
||||
if mode == SecuritySystemMode.Disarmed.value:
|
||||
await self.get_or_create_siren().turnOff()
|
||||
|
||||
@ArloDeviceBase.async_print_exception_guard
|
||||
async def disarmSecuritySystem(self) -> None:
|
||||
self.logger.info(f"Disarming")
|
||||
self.mode = SecuritySystemMode.Disarmed.value
|
||||
self.securitySystemState = {
|
||||
**self.securitySystemState,
|
||||
"mode": SecuritySystemMode.Disarmed.value,
|
||||
}
|
||||
await self.get_or_create_siren().turnOff()
|
||||
@@ -1,6 +1,7 @@
|
||||
paho-mqtt==1.6.1
|
||||
sseclient==0.0.22
|
||||
requests
|
||||
requests==2.28.2
|
||||
cachetools==5.3.0
|
||||
scrypted-arlo-go==0.0.1
|
||||
--extra-index-url=https://www.piwheels.org/simple/
|
||||
--extra-index-url=https://bjia56.github.io/scrypted-arlo-go/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2019 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
rm -rf all_models
|
||||
mkdir -p all_models
|
||||
cd all_models
|
||||
wget https://github.com/koush/coreml-survival-guide/raw/master/MobileNetV2%2BSSDLite/ObjectDetection/ObjectDetection/MobileNetV2_SSDLite.mlmodel
|
||||
wget https://raw.githubusercontent.com/koush/coreml-survival-guide/master/MobileNetV2%2BSSDLite/coco_labels.txt
|
||||
@@ -1 +0,0 @@
|
||||
../all_models/MobileNetV2_SSDLite.mlmodel
|
||||
@@ -1 +0,0 @@
|
||||
../all_models/coco_labels.txt
|
||||
4
plugins/coreml/package-lock.json
generated
4
plugins/coreml/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/coreml",
|
||||
"version": "0.0.27",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/coreml",
|
||||
"version": "0.0.27",
|
||||
"version": "0.1.8",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.0.27"
|
||||
"version": "0.1.8"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from PIL import Image
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
|
||||
predictExecutor = concurrent.futures.ThreadPoolExecutor(2, "CoreML-Predict")
|
||||
predictExecutor = concurrent.futures.ThreadPoolExecutor(8, "CoreML-Predict")
|
||||
|
||||
def parse_label_contents(contents: str):
|
||||
lines = contents.splitlines()
|
||||
@@ -29,16 +29,17 @@ class CoreMLPlugin(PredictPlugin, scrypted_sdk.BufferConverter, scrypted_sdk.Set
|
||||
def __init__(self, nativeId: str | None = None):
|
||||
super().__init__(MIME_TYPE, nativeId=nativeId)
|
||||
|
||||
modelPath = os.path.join(os.environ['SCRYPTED_PLUGIN_VOLUME'], 'zip', 'unzipped', 'fs', 'MobileNetV2_SSDLite.mlmodel')
|
||||
self.model = ct.models.MLModel(modelPath)
|
||||
labelsFile = self.downloadFile('https://raw.githubusercontent.com/koush/coreml-survival-guide/master/MobileNetV2%2BSSDLite/coco_labels.txt', 'coco_labels.txt')
|
||||
modelFile = self.downloadFile('https://github.com/koush/coreml-survival-guide/raw/master/MobileNetV2%2BSSDLite/ObjectDetection/ObjectDetection/MobileNetV2_SSDLite.mlmodel', 'MobileNetV2_SSDLite.mlmodel')
|
||||
|
||||
self.model = ct.models.MLModel(modelFile)
|
||||
|
||||
self.modelspec = self.model.get_spec()
|
||||
self.inputdesc = self.modelspec.description.input[0]
|
||||
self.inputheight = self.inputdesc.type.imageType.height
|
||||
self.inputwidth = self.inputdesc.type.imageType.width
|
||||
|
||||
labels_contents = scrypted_sdk.zip.open(
|
||||
'fs/coco_labels.txt').read().decode('utf8')
|
||||
labels_contents = open(labelsFile, 'r').read()
|
||||
self.labels = parse_label_contents(labels_contents)
|
||||
self.loop = asyncio.get_event_loop()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../tensorflow-lite/src/pipeline
|
||||
@@ -1,10 +1,5 @@
|
||||
# plugin
|
||||
Pillow>=5.4.1
|
||||
PyGObject>=3.30.4
|
||||
coremltools~=6.1
|
||||
av>=10.0.0; sys_platform != 'linux' or platform_machine == 'x86_64' or platform_machine == 'aarch64'
|
||||
coremltools
|
||||
|
||||
# sort_oh
|
||||
scipy
|
||||
filterpy
|
||||
numpy
|
||||
# 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'
|
||||
|
||||
@@ -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.22",
|
||||
"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.6"
|
||||
},
|
||||
"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.192",
|
||||
"@types/node": "^18.15.11",
|
||||
"@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 sdk, { AudioSensor, Camera, Intercom, MotionSensor, ObjectsDetected, OnOff, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, VideoCamera, VideoCameraConfiguration } from '@scrypted/sdk';
|
||||
import { Deferred } from '@scrypted/common/src/deferred';
|
||||
import sdk, { AudioSensor, Camera, Intercom, MotionSensor, ObjectsDetected, OnOff, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, DeviceProvider, 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';
|
||||
import { makeAccessory, mergeOnOffDevicesByType } from './common';
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +303,21 @@ addSupportedType({
|
||||
}
|
||||
}
|
||||
|
||||
// if the camera is a device provider, merge in child devices and
|
||||
// ensure the devices are skipped by the rest of homekit by
|
||||
// reporting that they've been merged
|
||||
if (device.interfaces.includes(ScryptedInterface.DeviceProvider)) {
|
||||
// merge in lights
|
||||
mergeOnOffDevicesByType(device as ScryptedDevice as ScryptedDevice & DeviceProvider, accessory, ScryptedDeviceType.Light).devices.forEach(device => {
|
||||
homekitPlugin.mergedDevices.add(device.id)
|
||||
});
|
||||
|
||||
// merge in sirens
|
||||
mergeOnOffDevicesByType(device as ScryptedDevice as ScryptedDevice & DeviceProvider, accessory, ScryptedDeviceType.Siren).devices.forEach(device => {
|
||||
homekitPlugin.mergedDevices.add(device.id)
|
||||
});
|
||||
}
|
||||
|
||||
return accessory;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -15,9 +15,9 @@ import os from 'os';
|
||||
import { getAddressOverride } from '../../address-override';
|
||||
import { AudioStreamingCodecType, CameraController, CameraStreamingDelegate, PrepareStreamCallback, PrepareStreamRequest, PrepareStreamResponse, StartStreamRequest, StreamingRequest, StreamRequestCallback, StreamRequestTypes } from '../../hap';
|
||||
import type { HomeKitPlugin } from "../../main";
|
||||
import { createReturnAudioSdp } from './camera-return-audio';
|
||||
import { createSnapshotHandler } from '../camera/camera-snapshot';
|
||||
import { getDebugMode } from './camera-debug-mode-storage';
|
||||
import { createReturnAudioSdp } from './camera-return-audio';
|
||||
import { startCameraStreamFfmpeg } from './camera-streaming-ffmpeg';
|
||||
import { CameraStreamingSession } from './camera-streaming-session';
|
||||
import { getStreamingConfiguration } from './camera-utils';
|
||||
@@ -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);
|
||||
@@ -375,6 +375,12 @@ export function createCameraStreamingDelegate(device: ScryptedDevice & VideoCame
|
||||
let playing = false;
|
||||
session.audioReturn.once('message', async buffer => {
|
||||
try {
|
||||
const decrypted = srtpSession.decrypt(buffer);
|
||||
const rtp = RtpPacket.deSerialize(decrypted);
|
||||
|
||||
if (rtp.header.payloadType !== session.startRequest.audio.pt)
|
||||
return;
|
||||
|
||||
const { clientPromise, url } = await listenZeroSingleClient();
|
||||
const rtspUrl = url.replace('tcp', 'rtsp');
|
||||
let sdp = createReturnAudioSdp(session.startRequest.audio);
|
||||
|
||||
@@ -64,6 +64,9 @@ export class H264Repacketizer {
|
||||
extraPackets = 0;
|
||||
fuaMax: number;
|
||||
pendingFuA: RtpPacket[];
|
||||
// log whether a stapa sps/pps has been seen.
|
||||
// resets on every idr frame, to trigger codec information
|
||||
// to be resent.
|
||||
seenStapASps = false;
|
||||
fuaMin: number;
|
||||
|
||||
@@ -402,8 +405,12 @@ export class H264Repacketizer {
|
||||
// if this is an idr frame, but no sps has been sent via a stapa, dummy one up.
|
||||
// the stream may not contain codec information in stapa or may be sending it
|
||||
// in separate sps/pps packets which is not supported by homekit.
|
||||
if (originalNalType === NAL_TYPE_IDR && !this.seenStapASps)
|
||||
this.maybeSendSpsPps(packet, ret);
|
||||
if (originalNalType === NAL_TYPE_IDR) {
|
||||
if (!this.seenStapASps)
|
||||
this.maybeSendSpsPps(packet, ret);
|
||||
this.seenStapASps = false;
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
if (this.pendingFuA) {
|
||||
@@ -486,10 +493,12 @@ export class H264Repacketizer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nalType === NAL_TYPE_IDR && !this.seenStapASps) {
|
||||
if (nalType === NAL_TYPE_IDR) {
|
||||
// if this is an idr frame, but no sps has been sent, dummy one up.
|
||||
// the stream may not contain sps.
|
||||
this.maybeSendSpsPps(packet, ret);
|
||||
if (!this.seenStapASps)
|
||||
this.maybeSendSpsPps(packet, ret);
|
||||
this.seenStapASps = false;
|
||||
}
|
||||
|
||||
this.fragment(packet, ret);
|
||||
|
||||
@@ -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,33 @@ export function addFan(device: ScryptedDevice & Fan & OnOff, accessory: Accessor
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
/*
|
||||
* mergeOnOffDevicesByType looks for the specified type of child devices under 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 OnOff devices which have
|
||||
* been merged.
|
||||
*/
|
||||
export function mergeOnOffDevicesByType(device: ScryptedDevice & DeviceProvider, accessory: Accessory, type: ScryptedDeviceType): { services: Service[], devices: (ScryptedDevice & OnOff)[] } {
|
||||
if (!device.interfaces.includes(ScryptedInterface.DeviceProvider))
|
||||
return undefined;
|
||||
|
||||
const children = getChildDevices(device);
|
||||
const mergedDevices = [];
|
||||
const services = children.map((child: ScryptedDevice & OnOff) => {
|
||||
if (child.type !== type || !child.interfaces.includes(ScryptedInterface.OnOff))
|
||||
return undefined;
|
||||
|
||||
const onOffService = getOnOffService(child, accessory, Service.Switch)
|
||||
mergedDevices.push(child);
|
||||
return onOffService;
|
||||
});
|
||||
|
||||
return {
|
||||
services: services.filter(service => !!service),
|
||||
devices: mergedDevices,
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,8 @@ 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);
|
||||
|
||||
const service = accessory.addService(serviceType, device.name);
|
||||
export function getService(device: ScryptedDevice & OnOff, accessory: Accessory, serviceType: any): Service {
|
||||
const service = accessory.addService(serviceType, device.name, device.nativeId);
|
||||
service.getCharacteristic(Characteristic.On)
|
||||
.on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
|
||||
callback();
|
||||
@@ -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, mergeOnOffDevicesByType } 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,15 @@ addSupportedType({
|
||||
bindCharacteristic(device, ScryptedInterface.SecuritySystem, service, Characteristic.SecuritySystemAlarmType,
|
||||
() => !!device.securitySystemState?.triggered);
|
||||
|
||||
if (device.interfaces.includes(ScryptedInterface.DeviceProvider)) {
|
||||
// merge in sirens
|
||||
const { devices } = mergeOnOffDevicesByType(device as ScryptedDevice as ScryptedDevice & DeviceProvider, accessory, ScryptedDeviceType.Siren);
|
||||
|
||||
// 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.106",
|
||||
"version": "0.0.121",
|
||||
"description": "Scrypted Video Analysis Plugin. Installed alongside a detection service like OpenCV or TensorFlow.",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache-2.0",
|
||||
@@ -36,9 +36,16 @@
|
||||
"type": "API",
|
||||
"interfaces": [
|
||||
"Settings",
|
||||
"MixinProvider"
|
||||
"MixinProvider",
|
||||
"DeviceProvider"
|
||||
],
|
||||
"realfs": true
|
||||
"realfs": true,
|
||||
"pluginDependencies": [
|
||||
"@scrypted/python-codecs"
|
||||
]
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.31.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
@@ -52,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"
|
||||
}
|
||||
}
|
||||
|
||||
244
plugins/objectdetector/src/ffmpeg-videoframes.ts
Normal file
244
plugins/objectdetector/src/ffmpeg-videoframes.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Deferred } from "@scrypted/common/src/deferred";
|
||||
import { addVideoFilterArguments } from "@scrypted/common/src/ffmpeg-helpers";
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } 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 type sharp from 'sharp';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export let sharpLib: (input?:
|
||||
| Buffer
|
||||
| Uint8Array
|
||||
| Uint8ClampedArray
|
||||
| Int8Array
|
||||
| Uint16Array
|
||||
| Int16Array
|
||||
| Uint32Array
|
||||
| Int32Array
|
||||
| Float32Array
|
||||
| Float64Array
|
||||
| string,
|
||||
options?: sharp.SharpOptions) => sharp.Sharp;
|
||||
try {
|
||||
sharpLib = require('sharp');
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Sharp failed to load. FFmpeg Frame Generator will not function properly.')
|
||||
}
|
||||
|
||||
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, public channels: 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 === 'jpg') {
|
||||
transformed.toFormat('jpg');
|
||||
}
|
||||
else {
|
||||
if (this.channels === 1 && (options?.format === 'gray' || !options.format))
|
||||
transformed.extractChannel(0);
|
||||
else if (options?.format === 'gray')
|
||||
transformed.toColorspace('b-w');
|
||||
else if (options?.format === 'rgb')
|
||||
transformed.removeAlpha()
|
||||
transformed.raw();
|
||||
}
|
||||
return transformed.toBuffer();
|
||||
}
|
||||
|
||||
async toVipsImage(options: ImageOptions) {
|
||||
const transformed = this.toImageInternal(options);
|
||||
const { info, data } = await transformed.raw().toBuffer({
|
||||
resolveWithObject: true,
|
||||
});
|
||||
|
||||
const sharpLib = require('sharp') as (input?:
|
||||
| Buffer
|
||||
| Uint8Array
|
||||
| Uint8ClampedArray
|
||||
| Int8Array
|
||||
| Uint16Array
|
||||
| Int16Array
|
||||
| Uint32Array
|
||||
| Int32Array
|
||||
| Float32Array
|
||||
| Float64Array
|
||||
| string,
|
||||
options?) => sharp.Sharp;
|
||||
const newImage = sharpLib(data, {
|
||||
raw: info,
|
||||
});
|
||||
|
||||
const newMetadata = await newImage.metadata();
|
||||
const newVipsImage = new VipsImage(newImage, newMetadata.width, newMetadata.height, newMetadata.channels);
|
||||
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 gray = options?.format === 'gray';
|
||||
const channels = gray ? 1 : 3;
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
//'-hwaccel', 'auto',
|
||||
...ffmpegInput.inputArguments,
|
||||
'-vcodec', 'pam',
|
||||
'-pix_fmt', gray ? 'gray' : 'rgb24',
|
||||
'-f', 'image2pipe',
|
||||
'pipe:3',
|
||||
];
|
||||
|
||||
// this seems to reduce latency.
|
||||
addVideoFilterArguments(args, 'fps=10', 'fps');
|
||||
|
||||
const cp = child_process.spawn(await sdk.mediaManager.getFFmpegPath(), args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const console = mediaObject?.sourceId ? sdk.deviceManager.getMixinConsole(mediaObject.sourceId) : this.console;
|
||||
safePrintFFmpegArguments(console, args);
|
||||
ffmpegLogInitialOutput(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' && headers['TUPLTYPE'] !== 'GRAYSCALE')
|
||||
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 * channels;
|
||||
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 {
|
||||
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 = sharpLib(data, {
|
||||
raw: {
|
||||
width,
|
||||
height,
|
||||
channels,
|
||||
}
|
||||
});
|
||||
const vipsImage = new VipsImage(image, width, height, channels);
|
||||
try {
|
||||
const mo = await createVipsMediaObject(vipsImage);
|
||||
yield mo;
|
||||
}
|
||||
finally {
|
||||
vipsImage.image = undefined;
|
||||
image.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
finally {
|
||||
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,10 @@
|
||||
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, 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 { DenoisedDetectionState } from './denoise';
|
||||
import { FFmpegVideoFrameGenerator, sharpLib } from './ffmpeg-videoframes';
|
||||
import { serverSupportsMixinEventMasking } from './server-version';
|
||||
import { sleep } from './sleep';
|
||||
import { getAllDevices, safeParseJson } from './util';
|
||||
@@ -18,8 +18,6 @@ const defaultDetectionDuration = 20;
|
||||
const defaultDetectionInterval = 60;
|
||||
const defaultDetectionTimeout = 60;
|
||||
const defaultMotionDuration = 10;
|
||||
const defaultScoreThreshold = .2;
|
||||
const defaultSecondScoreThreshold = .7;
|
||||
|
||||
const BUILTIN_MOTION_SENSOR_ASSIST = 'Assist';
|
||||
const BUILTIN_MOTION_SENSOR_REPLACE = 'Replace';
|
||||
@@ -43,9 +41,8 @@ type TrackedDetection = ObjectDetectionResult & {
|
||||
bestSecondPassScore?: number;
|
||||
};
|
||||
|
||||
class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera & MotionSensor & ObjectDetector> implements ObjectDetector, Settings, ObjectDetectionCallbacks {
|
||||
class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera & MotionSensor & ObjectDetector> implements ObjectDetector, Settings {
|
||||
motionListener: EventListenerRegister;
|
||||
detectorListener: EventListenerRegister;
|
||||
motionMixinListener: EventListenerRegister;
|
||||
detections = new Map<string, MediaObject>();
|
||||
cameraDevice: ScryptedDevice & Camera & VideoCamera & MotionSensor & ObjectDetector;
|
||||
@@ -75,16 +72,10 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
BUILTIN_MOTION_SENSOR_REPLACE,
|
||||
],
|
||||
defaultValue: "Default",
|
||||
},
|
||||
captureMode: {
|
||||
title: 'Capture Mode',
|
||||
description: 'The method to capture frames for analysis. Video will require more processing power.',
|
||||
choices: [
|
||||
'Default',
|
||||
'Video',
|
||||
'Snapshot',
|
||||
],
|
||||
defaultValue: 'Default',
|
||||
onPut: () => {
|
||||
this.endObjectDetection();
|
||||
this.maybeStartMotionDetection();
|
||||
}
|
||||
},
|
||||
detectionDuration: {
|
||||
title: 'Detection Duration',
|
||||
@@ -116,23 +107,6 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
defaultValue: defaultDetectionInterval,
|
||||
hide: true,
|
||||
},
|
||||
scoreThreshold: {
|
||||
title: 'Minimum Detection Confidence',
|
||||
subgroup: 'Advanced',
|
||||
description: 'Higher values eliminate false positives and low quality recognition candidates.',
|
||||
type: 'number',
|
||||
placeholder: '.2',
|
||||
defaultValue: defaultScoreThreshold,
|
||||
},
|
||||
secondScoreThreshold: {
|
||||
title: 'Second Pass Confidence',
|
||||
subgroup: 'Advanced',
|
||||
description: 'Crop and reanalyze a result from the initial detection pass to get more accurate results.',
|
||||
key: 'secondScoreThreshold',
|
||||
type: 'number',
|
||||
defaultValue: defaultSecondScoreThreshold,
|
||||
placeholder: '.7',
|
||||
},
|
||||
});
|
||||
motionTimeout: NodeJS.Timeout;
|
||||
zones = this.getZones();
|
||||
@@ -173,7 +147,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
if (this.hasMotionType) {
|
||||
// force a motion detection restart if it quit
|
||||
if (this.motionSensorSupplementation === BUILTIN_MOTION_SENSOR_REPLACE)
|
||||
await this.startStreamAnalysis();
|
||||
await this.startPipelineAnalysis();
|
||||
return;
|
||||
}
|
||||
}, this.storageSettings.values.detectionInterval * 1000);
|
||||
@@ -211,91 +185,46 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
return ret;
|
||||
}
|
||||
|
||||
async snapshotDetection() {
|
||||
const picture = await this.cameraDevice.takePicture();
|
||||
let detections = await this.objectDetection.detectObjects(picture, {
|
||||
detectionId: this.detectionId,
|
||||
settings: this.getCurrentSettings(),
|
||||
});
|
||||
detections = await this.trackObjects(detections, true);
|
||||
this.reportObjectDetections(detections);
|
||||
}
|
||||
|
||||
async maybeStartMotionDetection() {
|
||||
if (!this.hasMotionType)
|
||||
return;
|
||||
if (this.motionSensorSupplementation !== BUILTIN_MOTION_SENSOR_REPLACE)
|
||||
return;
|
||||
await this.startStreamAnalysis();
|
||||
await this.startPipelineAnalysis();
|
||||
}
|
||||
|
||||
endObjectDetection() {
|
||||
this.detectorRunning = false;
|
||||
this.objectDetection?.detectObjects(undefined, {
|
||||
detectionId: this.detectionId,
|
||||
settings: this.getCurrentSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
bindObjectDetection() {
|
||||
if (this.hasMotionType)
|
||||
this.motionDetected = false;
|
||||
|
||||
this.detectorRunning = false;
|
||||
this.detectorListener?.removeListener();
|
||||
this.detectorListener = undefined;
|
||||
this.endObjectDetection();
|
||||
|
||||
this.maybeStartMotionDetection();
|
||||
}
|
||||
|
||||
async register() {
|
||||
const model = await this.objectDetection.getDetectionModel();
|
||||
|
||||
if (!this.hasMotionType) {
|
||||
if (model.triggerClasses?.includes('motion')) {
|
||||
this.motionListener = this.cameraDevice.listen(ScryptedInterface.MotionSensor, async () => {
|
||||
if (!this.cameraDevice.motionDetected) {
|
||||
if (this.detectorRunning) {
|
||||
// allow anaysis due to user request.
|
||||
if (this.analyzeStop > Date.now())
|
||||
return;
|
||||
|
||||
this.console.log('motion stopped, cancelling ongoing detection')
|
||||
this.endObjectDetection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startStreamAnalysis();
|
||||
});
|
||||
}
|
||||
|
||||
const nonMotion = model.triggerClasses?.find(t => t !== 'motion');
|
||||
if (nonMotion) {
|
||||
this.detectorListener = this.cameraDevice.listen(ScryptedInterface.ObjectDetector, async (s, d, data: ObjectsDetected) => {
|
||||
if (!model.triggerClasses)
|
||||
return;
|
||||
if (!data.detectionId)
|
||||
return;
|
||||
const { detections } = data;
|
||||
if (!detections?.length)
|
||||
return;
|
||||
|
||||
const set = new Set(detections.map(d => d.className));
|
||||
for (const trigger of model.triggerClasses) {
|
||||
if (trigger === 'motion')
|
||||
continue;
|
||||
|
||||
if (set.has(trigger)) {
|
||||
const jpeg = await this.cameraDevice.getDetectionInput(data.detectionId, data.eventId);
|
||||
const found = await this.objectDetection.detectObjects(jpeg);
|
||||
found.detectionId = data.detectionId;
|
||||
this.handleDetectionEvent(found, undefined, jpeg);
|
||||
this.motionListener = this.cameraDevice.listen(ScryptedInterface.MotionSensor, async () => {
|
||||
if (!this.cameraDevice.motionDetected) {
|
||||
if (this.detectorRunning) {
|
||||
// allow anaysis due to user request.
|
||||
if (this.analyzeStop > Date.now())
|
||||
return;
|
||||
}
|
||||
|
||||
this.console.log('motion stopped, cancelling ongoing detection')
|
||||
this.endObjectDetection();
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startPipelineAnalysis();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -312,7 +241,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
return;
|
||||
if (!this.detectorRunning)
|
||||
this.console.log('built in motion sensor started motion, starting video detection.');
|
||||
await this.startStreamAnalysis();
|
||||
await this.startPipelineAnalysis();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -327,164 +256,6 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
}
|
||||
}
|
||||
|
||||
async handleDetectionEvent(detection: ObjectsDetected, redetect?: (boundingBox: [number, number, number, number]) => Promise<ObjectDetectionResult[]>, mediaObject?: MediaObject) {
|
||||
this.detectorRunning = detection.running;
|
||||
|
||||
detection = await this.trackObjects(detection);
|
||||
|
||||
// apply the zones to the detections and get a shallow copy list of detections after
|
||||
// exclusion zones have applied
|
||||
const zonedDetections = this.applyZones(detection)
|
||||
.filter(d => {
|
||||
if (!d.zones?.length)
|
||||
return d.bestSecondPassScore >= this.secondScoreThreshold || d.score >= this.scoreThreshold;
|
||||
|
||||
for (const zone of d.zones || []) {
|
||||
const zi = this.zoneInfos[zone];
|
||||
const scoreThreshold = zi?.scoreThreshold || this.scoreThreshold;
|
||||
const secondScoreThreshold = zi?.secondScoreThreshold || this.secondScoreThreshold;
|
||||
// keep the object if it passes the score check, or has already passed a second score check.
|
||||
if (d.bestSecondPassScore >= secondScoreThreshold || d.score >= scoreThreshold)
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
let retainImage = false;
|
||||
|
||||
if (!this.hasMotionType && redetect && this.secondScoreThreshold && detection.detections) {
|
||||
const detections = detection.detections as TrackedDetection[];
|
||||
const newOrBetterDetections = zonedDetections.filter(d => d.newOrBetterDetection);
|
||||
detections?.forEach(d => d.newOrBetterDetection = false);
|
||||
|
||||
// anything with a higher pass initial score should be redetected
|
||||
// as it may yield a better second pass score and thus a better thumbnail.
|
||||
await Promise.allSettled(newOrBetterDetections.map(async d => {
|
||||
const maybeUpdateSecondPassScore = (secondPassScore: number) => {
|
||||
let better = false;
|
||||
// initialize second pass result
|
||||
if (!d.bestSecondPassScore) {
|
||||
better = true;
|
||||
d.bestSecondPassScore = 0;
|
||||
}
|
||||
// retain passing the second pass threshold for first time.
|
||||
if (d.bestSecondPassScore < this.secondScoreThreshold && secondPassScore >= this.secondScoreThreshold) {
|
||||
this.console.log('improved', d.id, secondPassScore, d.score);
|
||||
better = true;
|
||||
retainImage = true;
|
||||
}
|
||||
else if (secondPassScore > d.bestSecondPassScore * 1.1) {
|
||||
this.console.log('improved', d.id, secondPassScore, d.score);
|
||||
better = true;
|
||||
retainImage = true;
|
||||
}
|
||||
if (better)
|
||||
d.bestSecondPassScore = secondPassScore;
|
||||
return better;
|
||||
}
|
||||
|
||||
// the initial score may be sufficient.
|
||||
if (d.score >= this.secondScoreThreshold) {
|
||||
maybeUpdateSecondPassScore(d.score);
|
||||
return;
|
||||
}
|
||||
|
||||
const redetected = await redetect(d.boundingBox);
|
||||
const best = redetected.filter(r => r.className === d.className).sort((a, b) => b.score - a.score)?.[0];
|
||||
if (best) {
|
||||
if (maybeUpdateSecondPassScore(best.score)) {
|
||||
d.boundingBox = best.boundingBox;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const secondPassDetections = zonedDetections.filter(d => d.bestSecondPassScore >= this.secondScoreThreshold)
|
||||
.map(d => ({
|
||||
...d,
|
||||
score: d.bestSecondPassScore,
|
||||
}));
|
||||
detection.detections = secondPassDetections;
|
||||
}
|
||||
else {
|
||||
detection.detections = zonedDetections;
|
||||
}
|
||||
|
||||
if (detection.detections) {
|
||||
const trackedDetections = cloneDeep(detection.detections) as TrackedDetection[];
|
||||
for (const d of trackedDetections) {
|
||||
delete d.bestScore;
|
||||
delete d.bestSecondPassScore;
|
||||
delete d.newOrBetterDetection;
|
||||
}
|
||||
detection.detections = trackedDetections;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (this.lastDetectionInput + this.storageSettings.values.detectionTimeout * 1000 < Date.now())
|
||||
retainImage = true;
|
||||
|
||||
if (retainImage && mediaObject) {
|
||||
this.lastDetectionInput = now;
|
||||
this.console.log('retaining detection image');
|
||||
this.setDetection(detection, mediaObject);
|
||||
}
|
||||
|
||||
this.reportObjectDetections(detection);
|
||||
return retainImage;
|
||||
}
|
||||
|
||||
get scoreThreshold() {
|
||||
return parseFloat(this.storage.getItem('scoreThreshold')) || defaultScoreThreshold;
|
||||
}
|
||||
|
||||
get secondScoreThreshold() {
|
||||
const r = parseFloat(this.storage.getItem('secondScoreThreshold'));
|
||||
if (isNaN(r))
|
||||
return defaultSecondScoreThreshold;
|
||||
return r;
|
||||
}
|
||||
|
||||
async onDetection(detection: ObjectsDetected, redetect?: (boundingBox: [number, number, number, number]) => Promise<ObjectDetectionResult[]>, mediaObject?: MediaObject): Promise<boolean> {
|
||||
// detection.detections = detection.detections?.filter(d => d.score >= this.scoreThreshold);
|
||||
return this.handleDetectionEvent(detection, redetect, mediaObject);
|
||||
}
|
||||
|
||||
async onDetectionEnded(detection: ObjectsDetected): Promise<void> {
|
||||
this.handleDetectionEvent(detection);
|
||||
}
|
||||
|
||||
async startSnapshotAnalysis() {
|
||||
if (this.detectorRunning)
|
||||
return;
|
||||
|
||||
this.detectorRunning = true;
|
||||
this.analyzeStop = Date.now() + this.getDetectionDuration();
|
||||
|
||||
while (this.detectorRunning) {
|
||||
const now = Date.now();
|
||||
if (now > this.analyzeStop)
|
||||
break;
|
||||
try {
|
||||
const mo = await this.mixinDevice.takePicture({
|
||||
reason: 'event',
|
||||
});
|
||||
const found = await this.objectDetection.detectObjects(mo, {
|
||||
detectionId: this.detectionId,
|
||||
duration: this.getDetectionDuration(),
|
||||
settings: this.getCurrentSettings(),
|
||||
}, this);
|
||||
}
|
||||
catch (e) {
|
||||
this.console.error('snapshot detection error', e);
|
||||
}
|
||||
// cameras tend to only refresh every 1s at best.
|
||||
// maybe get this value from somewhere? or sha the jpeg?
|
||||
const diff = now + 1100 - Date.now();
|
||||
if (diff > 0)
|
||||
await sleep(diff);
|
||||
}
|
||||
this.endObjectDetection();
|
||||
}
|
||||
|
||||
async startPipelineAnalysis() {
|
||||
if (this.detectorRunning)
|
||||
return;
|
||||
@@ -532,6 +303,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({
|
||||
@@ -549,37 +321,25 @@ 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;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now > this.analyzeStop) {
|
||||
if (!this.hasMotionType && Date.now() > this.analyzeStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
// apply the zones to the detections and get a shallow copy list of detections after
|
||||
// exclusion zones have applied
|
||||
const zonedDetections = this.applyZones(detected.detected);
|
||||
const filteredDetections = zonedDetections
|
||||
.filter(d => {
|
||||
if (!d.zones?.length)
|
||||
return d.score >= this.scoreThreshold;
|
||||
|
||||
for (const zone of d.zones || []) {
|
||||
const zi = this.zoneInfos[zone];
|
||||
const scoreThreshold = zi?.scoreThreshold || this.scoreThreshold;
|
||||
if (d.score >= scoreThreshold)
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
detected.detected.detections = filteredDetections;
|
||||
detected.detected.detections = zonedDetections;
|
||||
|
||||
detections++;
|
||||
// this.console.warn('dps', detections / (Date.now() - start) * 1000);
|
||||
@@ -593,6 +353,9 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
// this.console.log('image saved', detected.detected.detections);
|
||||
}
|
||||
this.reportObjectDetections(detected.detected);
|
||||
if (this.hasMotionType) {
|
||||
await sleep(250);
|
||||
}
|
||||
// this.handleDetectionEvent(detected.detected);
|
||||
}
|
||||
}
|
||||
@@ -600,81 +363,22 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
async startStreamAnalysis() {
|
||||
if (this.newPipeline) {
|
||||
await this.startPipelineAnalysis();
|
||||
}
|
||||
else if (!this.hasMotionType && this.storageSettings.values.captureMode === 'Snapshot') {
|
||||
await this.startSnapshotAnalysis();
|
||||
}
|
||||
else {
|
||||
await this.startVideoDetection();
|
||||
}
|
||||
}
|
||||
|
||||
async extendedObjectDetect(force?: boolean) {
|
||||
if (!this.hasMotionType && this.storageSettings.values.captureMode === 'Snapshot') {
|
||||
this.analyzeStop = Date.now() + this.getDetectionDuration();
|
||||
}
|
||||
else {
|
||||
try {
|
||||
if (!force && !this.motionDetected)
|
||||
return;
|
||||
await this.objectDetection?.detectObjects(undefined, {
|
||||
detectionId: this.detectionId,
|
||||
duration: this.getDetectionDuration(),
|
||||
settings: this.getCurrentSettings(),
|
||||
}, this);
|
||||
}
|
||||
catch (e) {
|
||||
// ignore any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startVideoDetection() {
|
||||
try {
|
||||
const settings = this.getCurrentSettings();
|
||||
|
||||
// prevent stream retrieval noise until notified that the detection is no longer running.
|
||||
if (this.detectorRunning) {
|
||||
const session = await this.objectDetection?.detectObjects(undefined, {
|
||||
detectionId: this.detectionId,
|
||||
duration: this.getDetectionDuration(),
|
||||
settings,
|
||||
}, this);
|
||||
this.detectorRunning = session.running;
|
||||
if (this.detectorRunning)
|
||||
return;
|
||||
}
|
||||
|
||||
// dummy up the last detection time to prevent the idle timers from purging everything.
|
||||
this.detectionState.lastDetection = Date.now();
|
||||
|
||||
this.detectorRunning = true;
|
||||
let stream: MediaObject;
|
||||
|
||||
stream = await this.cameraDevice.getVideoStream({
|
||||
destination: !this.hasMotionType ? 'local-recorder' : 'low-resolution',
|
||||
// ask rebroadcast to mute audio, not needed.
|
||||
audio: null,
|
||||
});
|
||||
const session = await this.objectDetection?.detectObjects(stream, {
|
||||
detectionId: this.detectionId,
|
||||
duration: this.getDetectionDuration(),
|
||||
settings,
|
||||
}, this);
|
||||
|
||||
this.detectorRunning = session.running;
|
||||
}
|
||||
catch (e) {
|
||||
this.console.log('failure retrieving stream', e);
|
||||
this.detectorRunning = false;
|
||||
}
|
||||
normalizeBox(boundingBox: [number, number, number, number], inputDimensions: [number, number]) {
|
||||
let [x, y, width, height] = boundingBox;
|
||||
let x2 = x + width;
|
||||
let y2 = y + height;
|
||||
// the zones are point paths in percentage format
|
||||
x = x * 100 / inputDimensions[0];
|
||||
y = y * 100 / inputDimensions[1];
|
||||
x2 = x2 * 100 / inputDimensions[0];
|
||||
y2 = y2 * 100 / inputDimensions[1];
|
||||
const box = [[x, y], [x2, y], [x2, y2], [x, y2]];
|
||||
return box;
|
||||
}
|
||||
|
||||
getDetectionDuration() {
|
||||
@@ -693,15 +397,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
continue;
|
||||
|
||||
o.zones = []
|
||||
let [x, y, width, height] = o.boundingBox;
|
||||
let x2 = x + width;
|
||||
let y2 = y + height;
|
||||
// the zones are point paths in percentage format
|
||||
x = x * 100 / detection.inputDimensions[0];
|
||||
y = y * 100 / detection.inputDimensions[1];
|
||||
x2 = x2 * 100 / detection.inputDimensions[0];
|
||||
y2 = y2 * 100 / detection.inputDimensions[1];
|
||||
const box = [[x, y], [x2, y], [x2, y2], [x, y2]];
|
||||
const box = this.normalizeBox(o.boundingBox, detection.inputDimensions);
|
||||
|
||||
let included: boolean;
|
||||
for (const [zone, zoneValue] of Object.entries(this.zones)) {
|
||||
@@ -741,6 +437,14 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
}
|
||||
}
|
||||
|
||||
// if this is a motion sensor and there are no inclusion zones set up,
|
||||
// use a default inclusion zone that crops the top and bottom to
|
||||
// prevents errant motion from the on screen time changing every second.
|
||||
if (this.hasMotionType && included === undefined) {
|
||||
const defaultInclusionZone = [[0, 10], [100, 10], [100, 90], [0, 90]];
|
||||
included = polygonOverlap(box, defaultInclusionZone);
|
||||
}
|
||||
|
||||
// if there are inclusion zones and this object
|
||||
// was not in any of them, filter it out.
|
||||
if (included === false)
|
||||
@@ -782,92 +486,12 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
this.onDeviceEvent(ScryptedInterface.ObjectDetector, detection);
|
||||
}
|
||||
|
||||
async trackObjects(detectionResult: ObjectsDetected, showAll?: boolean) {
|
||||
// do not denoise
|
||||
if (this.hasMotionType) {
|
||||
return detectionResult;
|
||||
}
|
||||
|
||||
if (!detectionResult?.detections) {
|
||||
// detection session ended.
|
||||
return detectionResult;
|
||||
}
|
||||
|
||||
const { detections } = detectionResult;
|
||||
|
||||
const found: DenoisedDetectionEntry<TrackedDetection>[] = [];
|
||||
denoiseDetections<TrackedDetection>(this.detectionState, detections.map(detection => ({
|
||||
get id() {
|
||||
return detection.id;
|
||||
},
|
||||
set id(id) {
|
||||
detection.id = id;
|
||||
},
|
||||
name: detection.className,
|
||||
score: detection.score,
|
||||
detection,
|
||||
get firstSeen() {
|
||||
return detection.history?.firstSeen
|
||||
},
|
||||
set firstSeen(value) {
|
||||
detection.history = detection.history || {
|
||||
firstSeen: value,
|
||||
lastSeen: value,
|
||||
};
|
||||
detection.history.firstSeen = value;
|
||||
},
|
||||
get lastSeen() {
|
||||
return detection.history?.lastSeen
|
||||
},
|
||||
set lastSeen(value) {
|
||||
detection.history = detection.history || {
|
||||
firstSeen: value,
|
||||
lastSeen: value,
|
||||
};
|
||||
detection.history.lastSeen = value;
|
||||
},
|
||||
boundingBox: detection.boundingBox,
|
||||
})), {
|
||||
timeout: this.storageSettings.values.detectionTimeout * 1000,
|
||||
added: d => {
|
||||
found.push(d);
|
||||
d.detection.bestScore = d.detection.score;
|
||||
d.detection.newOrBetterDetection = true;
|
||||
},
|
||||
removed: d => {
|
||||
this.console.log('expired detection:', `${d.detection.className} (${d.detection.score})`);
|
||||
if (detectionResult.running)
|
||||
this.extendedObjectDetect();
|
||||
},
|
||||
retained: (d, o) => {
|
||||
if (d.detection.score > o.detection.bestScore) {
|
||||
d.detection.bestScore = d.detection.score;
|
||||
d.detection.newOrBetterDetection = true;
|
||||
}
|
||||
else {
|
||||
d.detection.bestScore = o.detection.bestScore;
|
||||
}
|
||||
d.detection.bestSecondPassScore = o.detection.bestSecondPassScore;
|
||||
},
|
||||
expiring: (d) => {
|
||||
},
|
||||
});
|
||||
if (found.length) {
|
||||
this.console.log('new detection:', found.map(d => `${d.id} ${d.detection.className} (${d.detection.score})`).join(', '));
|
||||
if (detectionResult.running)
|
||||
this.extendedObjectDetect();
|
||||
}
|
||||
if (found.length || showAll) {
|
||||
this.console.log('current detections:', this.detectionState.previousDetections.map(d => `${d.detection.className} (${d.detection.score}, ${d.detection.boundingBox?.join(', ')})`).join(', '));
|
||||
}
|
||||
|
||||
return detectionResult;
|
||||
}
|
||||
|
||||
setDetection(detection: ObjectsDetected, detectionInput: MediaObject) {
|
||||
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(() => {
|
||||
@@ -916,17 +540,18 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
}
|
||||
|
||||
get newPipeline() {
|
||||
if (!this.plugin.storageSettings.values.newPipeline)
|
||||
return;
|
||||
|
||||
const newPipeline = this.storageSettings.values.newPipeline;
|
||||
if (!newPipeline)
|
||||
return newPipeline;
|
||||
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[]> {
|
||||
@@ -949,8 +574,6 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
}
|
||||
|
||||
this.storageSettings.settings.motionSensorSupplementation.hide = !this.hasMotionType || !this.mixinDeviceInterfaces.includes(ScryptedInterface.MotionSensor);
|
||||
this.storageSettings.settings.captureMode.hide = this.hasMotionType || !!this.plugin.storageSettings.values.newPipeline;
|
||||
this.storageSettings.settings.newPipeline.hide = this.hasMotionType || !this.plugin.storageSettings.values.newPipeline;
|
||||
this.storageSettings.settings.detectionDuration.hide = this.hasMotionType;
|
||||
this.storageSettings.settings.detectionTimeout.hide = this.hasMotionType;
|
||||
this.storageSettings.settings.motionDuration.hide = !this.hasMotionType;
|
||||
@@ -958,23 +581,6 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
|
||||
settings.push(...await this.storageSettings.getSettings());
|
||||
|
||||
let hideThreshold = true;
|
||||
if (!this.hasMotionType) {
|
||||
let hasInclusionZone = false;
|
||||
for (const zone of Object.keys(this.zones)) {
|
||||
const zi = this.zoneInfos[zone];
|
||||
if (!zi?.exclusion) {
|
||||
hasInclusionZone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasInclusionZone) {
|
||||
hideThreshold = false;
|
||||
}
|
||||
}
|
||||
this.storageSettings.settings.scoreThreshold.hide = hideThreshold;
|
||||
this.storageSettings.settings.secondScoreThreshold.hide = hideThreshold;
|
||||
|
||||
settings.push({
|
||||
key: 'zones',
|
||||
title: 'Zones',
|
||||
@@ -1018,38 +624,6 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
],
|
||||
value: zi?.type || 'Intersect',
|
||||
});
|
||||
|
||||
if (!this.hasMotionType) {
|
||||
settings.push(
|
||||
{
|
||||
subgroup,
|
||||
key: `zoneinfo-classes-${name}`,
|
||||
title: `Detection Classes`,
|
||||
description: 'The detection classes to match inside this zone. An empty list will match all classes.',
|
||||
choices: (await this.getObjectTypes())?.classes || [],
|
||||
value: zi?.classes || [],
|
||||
multiple: true,
|
||||
},
|
||||
{
|
||||
subgroup,
|
||||
title: 'Minimum Detection Confidence',
|
||||
description: 'Higher values eliminate false positives and low quality recognition candidates.',
|
||||
key: `zoneinfo-scoreThreshold-${name}`,
|
||||
type: 'number',
|
||||
value: zi?.scoreThreshold || this.scoreThreshold,
|
||||
placeholder: '.2',
|
||||
},
|
||||
{
|
||||
subgroup,
|
||||
title: 'Second Pass Confidence',
|
||||
description: 'Crop and reanalyze a result from the initial detection pass to get more accurate results.',
|
||||
key: `zoneinfo-secondScoreThreshold-${name}`,
|
||||
type: 'number',
|
||||
value: zi?.secondScoreThreshold || this.secondScoreThreshold,
|
||||
placeholder: '.7',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.hasMotionType) {
|
||||
@@ -1127,7 +701,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
if (key === 'analyzeButton') {
|
||||
this.analyzeStop = Date.now() + 60000;
|
||||
// await this.snapshotDetection();
|
||||
await this.startStreamAnalysis();
|
||||
await this.startPipelineAnalysis();
|
||||
}
|
||||
else {
|
||||
const settings = this.getCurrentSettings();
|
||||
@@ -1145,7 +719,6 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
|
||||
this.clearMotionTimeout();
|
||||
this.motionListener?.removeListener();
|
||||
this.motionMixinListener?.removeListener();
|
||||
this.detectorListener?.removeListener();
|
||||
this.endObjectDetection();
|
||||
}
|
||||
}
|
||||
@@ -1212,15 +785,10 @@ 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, {
|
||||
newPipeline: {
|
||||
title: 'New Video Pipeline',
|
||||
description: 'WARNING! DO NOT ENABLE: Use the new video pipeline. Leave blank to use the legacy pipeline.',
|
||||
type: 'boolean',
|
||||
},
|
||||
activeMotionDetections: {
|
||||
title: 'Active Motion Detection Sessions',
|
||||
readonly: true,
|
||||
@@ -1243,6 +811,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: sharpLib ? [
|
||||
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,
|
||||
|
||||
@@ -9,3 +9,4 @@ dist/*.js
|
||||
dist/*.txt
|
||||
__pycache__
|
||||
all_models
|
||||
.venv
|
||||
|
||||
4
plugins/opencv/package-lock.json
generated
4
plugins/opencv/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/opencv",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.74",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/opencv",
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.74",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.0.64"
|
||||
"version": "0.0.74"
|
||||
}
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
from __future__ import annotations
|
||||
from time import sleep
|
||||
from detect import DetectionSession, DetectPlugin
|
||||
from typing import Any, List, Tuple
|
||||
import numpy as np
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import cv2
|
||||
import imutils
|
||||
Gst = None
|
||||
try:
|
||||
from gi.repository import Gst
|
||||
except:
|
||||
pass
|
||||
from scrypted_sdk.types import ObjectDetectionModel, ObjectDetectionResult, ObjectsDetected, Setting, VideoFrame
|
||||
import numpy as np
|
||||
import scrypted_sdk
|
||||
from PIL import Image
|
||||
from scrypted_sdk.types import (ObjectDetectionGeneratorSession,
|
||||
ObjectDetectionResult, ObjectsDetected,
|
||||
Setting, VideoFrame)
|
||||
|
||||
class OpenCVDetectionSession(DetectionSession):
|
||||
from detect import DetectPlugin
|
||||
|
||||
# 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)
|
||||
|
||||
async def ensureGrayData(data: bytes, size: Tuple[int, int], format: str):
|
||||
if format == 'gray':
|
||||
return data
|
||||
|
||||
def convert():
|
||||
if format == 'rgba':
|
||||
image = Image.frombuffer('RGBA', size, data)
|
||||
else:
|
||||
image = Image.frombuffer('RGB', size, data)
|
||||
|
||||
try:
|
||||
return image.convert('L').tobytes()
|
||||
finally:
|
||||
image.close()
|
||||
return await to_thread(convert)
|
||||
|
||||
|
||||
class OpenCVDetectionSession:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.cap: cv2.VideoCapture = None
|
||||
self.previous_frame: Any = None
|
||||
self.curFrame = None
|
||||
@@ -35,21 +59,6 @@ defaultBlur = 5
|
||||
class OpenCVPlugin(DetectPlugin):
|
||||
def __init__(self, nativeId: str | None = None):
|
||||
super().__init__(nativeId=nativeId)
|
||||
self.color2Gray = None
|
||||
self.pixelFormat = "I420"
|
||||
self.pixelFormatChannelCount = 1
|
||||
|
||||
if True:
|
||||
self.retainAspectRatio = False
|
||||
self.color2Gray = None
|
||||
self.pixelFormat = "I420"
|
||||
self.pixelFormatChannelCount = 1
|
||||
else:
|
||||
self.retainAspectRatio = True
|
||||
self.color2Gray = cv2.COLOR_BGRA2GRAY
|
||||
self.pixelFormat = "BGRA"
|
||||
self.pixelFormatChannelCount = 4
|
||||
|
||||
|
||||
def getClasses(self) -> list[str]:
|
||||
return ['motion']
|
||||
@@ -91,9 +100,6 @@ class OpenCVPlugin(DetectPlugin):
|
||||
]
|
||||
|
||||
return settings
|
||||
|
||||
def get_pixel_format(self):
|
||||
return self.pixelFormat
|
||||
|
||||
def get_input_format(self) -> str:
|
||||
return 'gray'
|
||||
@@ -110,17 +116,10 @@ class OpenCVPlugin(DetectPlugin):
|
||||
blur = int(settings.get('blur', blur))
|
||||
return area, threshold, interval, blur
|
||||
|
||||
def detect(self, detection_session: OpenCVDetectionSession, frame, src_size, convert_to_src_size) -> ObjectsDetected:
|
||||
settings = detection_session.settings
|
||||
def detect(self, frame, settings: Any, detection_session: OpenCVDetectionSession, src_size, convert_to_src_size) -> ObjectsDetected:
|
||||
area, threshold, interval, blur = self.parse_settings(settings)
|
||||
|
||||
# see get_detection_input_size on undocumented size requirements for GRAY8
|
||||
if self.color2Gray != None:
|
||||
detection_session.gray = cv2.cvtColor(
|
||||
frame, self.color2Gray, dst=detection_session.gray)
|
||||
gray = detection_session.gray
|
||||
else:
|
||||
gray = frame
|
||||
gray = frame
|
||||
detection_session.curFrame = cv2.GaussianBlur(
|
||||
gray, (blur, blur), 0, dst=detection_session.curFrame)
|
||||
|
||||
@@ -154,8 +153,8 @@ class OpenCVPlugin(DetectPlugin):
|
||||
# if w * h != contour_area:
|
||||
# print("mismatch w/h", contour_area - w * h)
|
||||
|
||||
x2, y2, _ = convert_to_src_size((x + w, y + h))
|
||||
x, y, _ = convert_to_src_size((x, y))
|
||||
x2, y2 = convert_to_src_size((x + w, y + h))
|
||||
x, y = convert_to_src_size((x, y))
|
||||
w = x2 - x + 1
|
||||
h = y2 - y + 1
|
||||
|
||||
@@ -206,11 +205,24 @@ class OpenCVPlugin(DetectPlugin):
|
||||
detection_session.cap = None
|
||||
return super().end_session(detection_session)
|
||||
|
||||
async def run_detection_image(self, detection_session: DetectionSession, image: Image.Image, settings: Any, src_size, convert_to_src_size) -> Tuple[ObjectsDetected, Any]:
|
||||
# todo
|
||||
raise Exception('can not run motion detection on image')
|
||||
|
||||
async def run_detection_videoframe(self, videoFrame: VideoFrame, detection_session: OpenCVDetectionSession) -> ObjectsDetected:
|
||||
async def generateObjectDetections(self, videoFrames: Any, session: ObjectDetectionGeneratorSession = None) -> Any:
|
||||
try:
|
||||
ds = OpenCVDetectionSession()
|
||||
videoFrames = await scrypted_sdk.sdk.connectRPCObject(videoFrames)
|
||||
async for videoFrame in videoFrames:
|
||||
detected = await self.run_detection_videoframe(videoFrame, session and session.get('settings'), ds)
|
||||
yield {
|
||||
'__json_copy_serialize_children': True,
|
||||
'detected': detected,
|
||||
'videoFrame': videoFrame,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
await videoFrames.aclose()
|
||||
except:
|
||||
pass
|
||||
|
||||
async def run_detection_videoframe(self, videoFrame: VideoFrame, settings: Any, detection_session: OpenCVDetectionSession) -> ObjectsDetected:
|
||||
width = videoFrame.width
|
||||
height = videoFrame.height
|
||||
|
||||
@@ -220,77 +232,40 @@ 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,
|
||||
}
|
||||
|
||||
format = videoFrame.format or 'gray'
|
||||
buffer = await videoFrame.toBuffer({
|
||||
'resize': resize,
|
||||
'format': format,
|
||||
})
|
||||
|
||||
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)
|
||||
detections = self.detect(
|
||||
detection_session, mat, (width, height), convert_to_src_size)
|
||||
return detections
|
||||
|
||||
async def run_detection_avframe(self, detection_session: DetectionSession, avframe, settings: Any, src_size, convert_to_src_size) -> Tuple[ObjectsDetected, Any]:
|
||||
if avframe.format.name != 'yuv420p' and avframe.format.name != 'yuvj420p':
|
||||
mat = avframe.to_ndarray(format='gray8')
|
||||
if format == 'gray':
|
||||
expectedLength = width * height
|
||||
# check if resize could not be completed
|
||||
if expectedLength != len(buffer):
|
||||
image = Image.frombuffer('L', (videoFrame.width, videoFrame.height), buffer)
|
||||
try:
|
||||
buffer = image.resize((width, height), Image.BILINEAR).tobytes()
|
||||
finally:
|
||||
image.close()
|
||||
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)
|
||||
if not detections or not len(detections['detections']):
|
||||
await self.detection_sleep(settings)
|
||||
return None, None
|
||||
return detections, None
|
||||
buffer = await ensureGrayData(buffer, (width, height), format)
|
||||
|
||||
async def run_detection_gstsample(self, detection_session: OpenCVDetectionSession, gst_sample, settings: Any, src_size, convert_to_src_size) -> ObjectsDetected:
|
||||
buf = gst_sample.get_buffer()
|
||||
caps = gst_sample.get_caps()
|
||||
# can't trust the width value, compute the stride
|
||||
height = caps.get_structure(0).get_value('height')
|
||||
width = caps.get_structure(0).get_value('width')
|
||||
result, info = buf.map(Gst.MapFlags.READ)
|
||||
if not result:
|
||||
return None, None
|
||||
try:
|
||||
mat = np.ndarray(
|
||||
(height,
|
||||
width,
|
||||
self.pixelFormatChannelCount),
|
||||
buffer=info.data,
|
||||
dtype=np.uint8)
|
||||
detections = self.detect(
|
||||
detection_session, mat, settings, src_size, convert_to_src_size)
|
||||
# no point in triggering empty events.
|
||||
finally:
|
||||
buf.unmap(info)
|
||||
|
||||
if not detections or not len(detections['detections']):
|
||||
await self.detection_sleep(settings)
|
||||
return None, None
|
||||
return detections, None
|
||||
|
||||
def create_detection_session(self):
|
||||
return OpenCVDetectionSession()
|
||||
|
||||
async def detection_sleep(self, settings: Any):
|
||||
area, threshold, interval, blur = self.parse_settings(settings)
|
||||
# it is safe to block here because gstreamer creates a queue thread
|
||||
await asyncio.sleep(interval / 1000)
|
||||
|
||||
async def detection_event_notified(self, settings: Any):
|
||||
await self.detection_sleep(settings)
|
||||
return await super().detection_event_notified(settings)
|
||||
def convert_to_src_size(point):
|
||||
return point[0] * scale, point[1] * scale
|
||||
mat = np.ndarray((height, width, 1), buffer=buffer, dtype=np.uint8)
|
||||
detections = self.detect(mat, settings, detection_session, (videoFrame.width, videoFrame.height), convert_to_src_size)
|
||||
return detections
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../tensorflow-lite/src/pipeline
|
||||
@@ -1,6 +1,8 @@
|
||||
# plugin
|
||||
numpy>=1.16.2
|
||||
Pillow>=5.4.1
|
||||
PyGObject>=3.30.4; sys_platform != 'win32'
|
||||
av>=10.0.0; sys_platform != 'linux' or platform_machine == 'x86_64' or platform_machine == 'aarch64'
|
||||
# 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'
|
||||
imutils>=0.5.0
|
||||
# not available on armhf
|
||||
opencv-python; sys_platform != 'linux' or platform_machine == 'x86_64'
|
||||
|
||||
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.18",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/pam-diff",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.18",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^16.6.1",
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.0.16"
|
||||
"version": "0.0.18"
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -137,7 +231,7 @@ class PamDiff extends ScryptedDeviceBase implements ObjectDetection {
|
||||
detections.push(
|
||||
{
|
||||
className: 'motion',
|
||||
score: trigger.percent / 100,
|
||||
score: 1,
|
||||
boundingBox: [blob.minX, blob.minY, blob.maxX - blob.minX, blob.maxY - blob.minY],
|
||||
}
|
||||
)
|
||||
@@ -147,7 +241,7 @@ class PamDiff extends ScryptedDeviceBase implements ObjectDetection {
|
||||
detections.push(
|
||||
{
|
||||
className: 'motion',
|
||||
score: trigger.percent / 100,
|
||||
score: 1,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
2
plugins/prebuffer-mixin/.vscode/launch.json
vendored
2
plugins/prebuffer-mixin/.vscode/launch.json
vendored
@@ -10,7 +10,7 @@
|
||||
"port": 10081,
|
||||
"request": "attach",
|
||||
"skipFiles": [
|
||||
"**/plugin-remote-worker.*",
|
||||
"**/plugin-console.*",
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"autoAttachChildProcesses": true,
|
||||
|
||||
4
plugins/prebuffer-mixin/package-lock.json
generated
4
plugins/prebuffer-mixin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/prebuffer-mixin",
|
||||
"version": "0.9.77",
|
||||
"version": "0.9.79",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/prebuffer-mixin",
|
||||
"version": "0.9.77",
|
||||
"version": "0.9.79",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/prebuffer-mixin",
|
||||
"version": "0.9.77",
|
||||
"version": "0.9.79",
|
||||
"description": "Video Stream Rebroadcast, Prebuffer, and Management Plugin for Scrypted.",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Deferred } from "@scrypted/common/src/deferred";
|
||||
import { Headers, RtspServer } from "@scrypted/common/src/rtsp-server";
|
||||
import fs from 'fs';
|
||||
import { format } from "path";
|
||||
import { Duplex } from "stream";
|
||||
|
||||
// non standard extension that dumps the rtp payload to a file.
|
||||
@@ -28,17 +30,49 @@ export class FileRtspServer extends RtspServer {
|
||||
ws?.end(() => ws?.destroy());
|
||||
}
|
||||
|
||||
write(url: string, requestHeaders: Headers) {
|
||||
this.cleanup();
|
||||
this.segmentBytesWritten = 0;
|
||||
|
||||
async write(url: string, requestHeaders: Headers) {
|
||||
const file = requestHeaders['x-scrypted-rtsp-file'];
|
||||
|
||||
if (!file)
|
||||
return this.respond(400, 'Bad Request', requestHeaders, {});
|
||||
|
||||
const truncate = requestHeaders['x-scrypted-rtsp-file-truncate'];
|
||||
|
||||
// this.writeConsole?.log('RTSP WRITE file', file);
|
||||
this.writeStream = fs.createWriteStream(file);
|
||||
|
||||
// truncation preparation must happen before cleanup.
|
||||
let truncateWriteStream: fs.WriteStream;
|
||||
if (truncate) {
|
||||
try {
|
||||
const d = new Deferred<number>();
|
||||
fs.open(truncate, 'w', (e, fd) => {
|
||||
if (e)
|
||||
d.reject(e);
|
||||
else
|
||||
d.resolve(fd);
|
||||
});
|
||||
const fd = await d.promise;
|
||||
try {
|
||||
await fs.promises.rename(truncate, file);
|
||||
truncateWriteStream = fs.createWriteStream(undefined, {
|
||||
fd,
|
||||
})
|
||||
// this.writeConsole?.log('truncating', truncate);
|
||||
}
|
||||
catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
this.writeConsole?.error('RTSP WRITE error during truncate file', truncate, e);
|
||||
}
|
||||
}
|
||||
|
||||
// everything after this point must be sync due to cleanup potentially causing dangling state.
|
||||
this.cleanup();
|
||||
this.segmentBytesWritten = 0;
|
||||
|
||||
this.writeStream = truncateWriteStream || fs.createWriteStream(file);
|
||||
this.writeStream.on('error', e => {
|
||||
this.writeConsole?.error('RTSP WRITE error', e);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-p
|
||||
import { getDebugModeH264EncoderArgs, getH264EncoderArgs } from '@scrypted/common/src/ffmpeg-hardware-acceleration';
|
||||
import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers';
|
||||
import { handleRebroadcasterClient, ParserOptions, ParserSession, startParserSession } from '@scrypted/common/src/ffmpeg-rebroadcast';
|
||||
import { closeQuiet, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { closeQuiet, listenZeroSingleClient, ListenZeroSingleClientTimeoutError } from '@scrypted/common/src/listen-cluster';
|
||||
import { readLength } from '@scrypted/common/src/read-stream';
|
||||
import { createRtspParser, findH264NaluType, getNaluTypes, H264_NAL_TYPE_FU_B, H264_NAL_TYPE_IDR, H264_NAL_TYPE_MTAP16, H264_NAL_TYPE_MTAP32, H264_NAL_TYPE_RESERVED0, H264_NAL_TYPE_RESERVED30, H264_NAL_TYPE_RESERVED31, H264_NAL_TYPE_SEI, H264_NAL_TYPE_STAP_B, listenSingleRtspClient, RtspServer, RtspTrack } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls, parseSdp } from '@scrypted/common/src/sdp-utils';
|
||||
@@ -946,23 +946,35 @@ class PrebufferSession {
|
||||
const { isActiveClient, container, session, socketPromise, requestedPrebuffer } = options;
|
||||
this.console.log('sending prebuffer', requestedPrebuffer);
|
||||
|
||||
// in case the client never connects, do an inactivity check.
|
||||
socketPromise.catch(() => this.inactivityCheck(session, false));
|
||||
socketPromise.then(socket => {
|
||||
let socket: Duplex;
|
||||
|
||||
try {
|
||||
socket = await socketPromise;
|
||||
}
|
||||
catch (e) {
|
||||
// in case the client never connects, do an inactivity check.
|
||||
this.inactivityCheck(session, false);
|
||||
if (e instanceof ListenZeroSingleClientTimeoutError)
|
||||
this.console.warn('client connection timed out');
|
||||
else
|
||||
this.console.error('client connection error', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveClient) {
|
||||
this.activeClients++;
|
||||
this.printActiveClients();
|
||||
}
|
||||
|
||||
socket.once('close', () => {
|
||||
if (isActiveClient) {
|
||||
this.activeClients++;
|
||||
this.activeClients--;
|
||||
this.printActiveClients();
|
||||
}
|
||||
socket.once('close', () => {
|
||||
if (isActiveClient) {
|
||||
this.activeClients--;
|
||||
this.printActiveClients();
|
||||
}
|
||||
this.inactivityCheck(session, isActiveClient);
|
||||
})
|
||||
this.inactivityCheck(session, isActiveClient);
|
||||
});
|
||||
|
||||
handleRebroadcasterClient(socketPromise, {
|
||||
handleRebroadcasterClient(socket, {
|
||||
// console: this.console,
|
||||
connect: (connection) => {
|
||||
const now = Date.now();
|
||||
@@ -1138,7 +1150,7 @@ class PrebufferSession {
|
||||
}
|
||||
// server.console = this.console;
|
||||
await server.handlePlayback();
|
||||
server.handleTeardown().finally(() => server.client.destroy());
|
||||
server.handleTeardown().catch(() => {}).finally(() => server.client.destroy());
|
||||
for (const track of Object.values(server.setupTracks)) {
|
||||
if (track.protocol === 'udp') {
|
||||
serverPortMap.set(track.codec, track);
|
||||
|
||||
6
plugins/python-codecs/.vscode/settings.json
vendored
6
plugins/python-codecs/.vscode/settings.json
vendored
@@ -1,9 +1,13 @@
|
||||
|
||||
{
|
||||
// docker installation
|
||||
// "scrypted.debugHost": "koushik-thin",
|
||||
// "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.5",
|
||||
"version": "0.1.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/python-codecs",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.27",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/python-codecs",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.27",
|
||||
"description": "Python Codecs for Scrypted",
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
|
||||
136
plugins/python-codecs/src/gst_generator.py
Normal file
136
plugins/python-codecs/src/gst_generator.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import concurrent.futures
|
||||
import threading
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
|
||||
try:
|
||||
import gi
|
||||
gi.require_version('Gst', '1.0')
|
||||
gi.require_version('GstBase', '1.0')
|
||||
|
||||
from gi.repository import GLib, GObject, Gst
|
||||
GObject.threads_init()
|
||||
Gst.init(None)
|
||||
except:
|
||||
pass
|
||||
|
||||
class Callback:
|
||||
def __init__(self, callback) -> None:
|
||||
if callback:
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.callback = callback
|
||||
else:
|
||||
self.loop = None
|
||||
self.callback = None
|
||||
|
||||
def createPipelineIterator(pipeline: str):
|
||||
pipeline = '{pipeline} ! queue leaky=downstream max-size-buffers=0 ! appsink name=appsink emit-signals=true sync=false max-buffers=-1 drop=true'.format(pipeline=pipeline)
|
||||
print(pipeline)
|
||||
gst = Gst.parse_launch(pipeline)
|
||||
bus = gst.get_bus()
|
||||
|
||||
def on_bus_message(bus, message):
|
||||
t = str(message.type)
|
||||
# print(t)
|
||||
if t == str(Gst.MessageType.EOS):
|
||||
finish()
|
||||
elif t == str(Gst.MessageType.WARNING):
|
||||
err, debug = message.parse_warning()
|
||||
print('Warning: %s: %s\n' % (err, debug))
|
||||
elif t == str(Gst.MessageType.ERROR):
|
||||
err, debug = message.parse_error()
|
||||
print('Error: %s: %s\n' % (err, debug))
|
||||
finish()
|
||||
|
||||
def stopGst():
|
||||
bus.remove_signal_watch()
|
||||
bus.disconnect(watchId)
|
||||
gst.set_state(Gst.State.NULL)
|
||||
|
||||
def finish():
|
||||
nonlocal hasFinished
|
||||
hasFinished = True
|
||||
callback = Callback(None)
|
||||
callbackQueue.put(callback)
|
||||
if not asyncFuture.done():
|
||||
asyncFuture.set_result(None)
|
||||
if not finished.done():
|
||||
finished.set_result(None)
|
||||
|
||||
watchId = bus.connect('message', on_bus_message)
|
||||
bus.add_signal_watch()
|
||||
|
||||
finished = concurrent.futures.Future()
|
||||
finished.add_done_callback(lambda _: threading.Thread(target=stopGst, name="StopGst").start())
|
||||
hasFinished = False
|
||||
|
||||
appsink = gst.get_by_name('appsink')
|
||||
callbackQueue = Queue()
|
||||
asyncFuture = asyncio.Future()
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
while True:
|
||||
nonlocal asyncFuture
|
||||
asyncFuture = asyncio.Future()
|
||||
yieldFuture = asyncio.Future()
|
||||
async def asyncCallback(sample):
|
||||
asyncFuture.set_result(sample)
|
||||
await yieldFuture
|
||||
callbackQueue.put(Callback(asyncCallback))
|
||||
sample = await asyncFuture
|
||||
if not sample:
|
||||
yieldFuture.set_result(None)
|
||||
break
|
||||
try:
|
||||
yield sample
|
||||
finally:
|
||||
yieldFuture.set_result(None)
|
||||
finally:
|
||||
finish()
|
||||
print('gstreamer finished')
|
||||
|
||||
|
||||
def on_new_sample(sink, preroll):
|
||||
nonlocal hasFinished
|
||||
|
||||
sample = sink.emit('pull-preroll' if preroll else 'pull-sample')
|
||||
|
||||
callback: Callback = callbackQueue.get()
|
||||
if not callback.callback or hasFinished:
|
||||
hasFinished = True
|
||||
if callback.callback:
|
||||
asyncio.run_coroutine_threadsafe(callback.callback(None), loop = callback.loop)
|
||||
return Gst.FlowReturn.OK
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(callback.callback(sample), loop = callback.loop)
|
||||
try:
|
||||
future.result()
|
||||
except:
|
||||
pass
|
||||
return Gst.FlowReturn.OK
|
||||
|
||||
appsink.connect('new-preroll', on_new_sample, True)
|
||||
appsink.connect('new-sample', on_new_sample, False)
|
||||
|
||||
gst.set_state(Gst.State.PLAYING)
|
||||
return gst, gen
|
||||
|
||||
def mainThread():
|
||||
async def asyncMain():
|
||||
gst, gen = createPipelineIterator('rtspsrc location=rtsp://localhost:59668/18cc179a814fd5b3 ! rtph264depay ! h264parse ! vtdec_hw ! videoconvert ! video/x-raw')
|
||||
i = 0
|
||||
async for sample in gen():
|
||||
print('sample')
|
||||
i = i + 1
|
||||
if i == 10:
|
||||
break
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.ensure_future(asyncMain(), loop = loop)
|
||||
loop.run_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target = mainThread).start()
|
||||
mainLoop = GLib.MainLoop()
|
||||
mainLoop.run()
|
||||
@@ -1,136 +1,113 @@
|
||||
import concurrent.futures
|
||||
import threading
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
from gst_generator import createPipelineIterator
|
||||
from util import optional_chain
|
||||
import scrypted_sdk
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
import vipsimage
|
||||
import pilimage
|
||||
import platform
|
||||
|
||||
Gst = None
|
||||
try:
|
||||
import gi
|
||||
gi.require_version('Gst', '1.0')
|
||||
gi.require_version('GstBase', '1.0')
|
||||
|
||||
from gi.repository import GLib, GObject, Gst
|
||||
GObject.threads_init()
|
||||
Gst.init(None)
|
||||
from gi.repository import Gst
|
||||
except:
|
||||
pass
|
||||
|
||||
class Callback:
|
||||
def __init__(self, callback) -> None:
|
||||
if callback:
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.callback = callback
|
||||
async def generateVideoFramesGstreamer(mediaObject: scrypted_sdk.MediaObject, options: scrypted_sdk.VideoFrameGeneratorOptions = None, filter: Any = None, h264Decoder: str = None) -> scrypted_sdk.VideoFrame:
|
||||
ffmpegInput: scrypted_sdk.FFmpegInput = await scrypted_sdk.mediaManager.convertMediaObjectToJSON(mediaObject, scrypted_sdk.ScryptedMimeTypes.FFmpegInput.value)
|
||||
container = ffmpegInput.get('container', None)
|
||||
videosrc = ffmpegInput.get('url')
|
||||
videoCodec = optional_chain(ffmpegInput, 'mediaStreamOptions', 'video', 'codec')
|
||||
|
||||
if videosrc.startswith('tcp://'):
|
||||
parsed_url = urlparse(videosrc)
|
||||
videosrc = 'tcpclientsrc port=%s host=%s' % (
|
||||
parsed_url.port, parsed_url.hostname)
|
||||
if container == 'mpegts':
|
||||
videosrc += ' ! tsdemux'
|
||||
elif container == 'sdp':
|
||||
videosrc += ' ! sdpdemux'
|
||||
else:
|
||||
self.loop = None
|
||||
self.callback = None
|
||||
raise Exception('unknown container %s' % container)
|
||||
elif videosrc.startswith('rtsp'):
|
||||
videosrc = 'rtspsrc buffer-mode=0 location=%s protocols=tcp latency=0' % videosrc
|
||||
if videoCodec == 'h264':
|
||||
videosrc += ' ! rtph264depay ! h264parse'
|
||||
|
||||
def createPipelineIterator(pipeline: str):
|
||||
pipeline = '{pipeline} ! queue leaky=downstream max-size-buffers=0 ! appsink name=appsink emit-signals=true sync=false max-buffers=-1 drop=true'.format(pipeline=pipeline)
|
||||
print(pipeline)
|
||||
gst = Gst.parse_launch(pipeline)
|
||||
bus = gst.get_bus()
|
||||
videocaps = 'video/x-raw'
|
||||
# if options and options.get('resize'):
|
||||
# videocaps = 'videoscale ! video/x-raw,width={width},height={height}'.format(width=options['resize']['width'], height=options['resize']['height'])
|
||||
|
||||
def on_bus_message(bus, message):
|
||||
t = str(message.type)
|
||||
# print(t)
|
||||
if t == str(Gst.MessageType.EOS):
|
||||
finish()
|
||||
elif t == str(Gst.MessageType.WARNING):
|
||||
err, debug = message.parse_warning()
|
||||
print('Warning: %s: %s\n' % (err, debug))
|
||||
elif t == str(Gst.MessageType.ERROR):
|
||||
err, debug = message.parse_error()
|
||||
print('Error: %s: %s\n' % (err, debug))
|
||||
finish()
|
||||
format = options and options.get('format')
|
||||
# I420 is a cheap way to get gray out of an h264 stream without color conversion.
|
||||
if format == 'gray':
|
||||
format = 'I420'
|
||||
bands = 1
|
||||
else:
|
||||
format = 'RGB'
|
||||
bands = 3
|
||||
|
||||
videocaps += ',format={format}'.format(format=format)
|
||||
|
||||
def stopGst():
|
||||
bus.remove_signal_watch()
|
||||
bus.disconnect(watchId)
|
||||
gst.set_state(Gst.State.NULL)
|
||||
decoder = None
|
||||
def setDecoderClearDefault(value: str):
|
||||
nonlocal decoder
|
||||
decoder = value
|
||||
if decoder == 'Default':
|
||||
decoder = None
|
||||
|
||||
def finish():
|
||||
nonlocal hasFinished
|
||||
hasFinished = True
|
||||
callback = Callback(None)
|
||||
callbackQueue.put(callback)
|
||||
if not asyncFuture.done():
|
||||
asyncFuture.set_result(None)
|
||||
if not finished.done():
|
||||
finished.set_result(None)
|
||||
setDecoderClearDefault(None)
|
||||
|
||||
watchId = bus.connect('message', on_bus_message)
|
||||
bus.add_signal_watch()
|
||||
if videoCodec == 'h264':
|
||||
setDecoderClearDefault(h264Decoder)
|
||||
|
||||
finished = concurrent.futures.Future()
|
||||
finished.add_done_callback(lambda _: threading.Thread(target=stopGst, name="StopGst").start())
|
||||
hasFinished = False
|
||||
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 = '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'
|
||||
|
||||
appsink = gst.get_by_name('appsink')
|
||||
callbackQueue = Queue()
|
||||
asyncFuture = asyncio.Future()
|
||||
videosrc += ' ! {decoder} ! queue leaky=downstream max-size-buffers=0 ! videoconvert ! {videocaps}'.format(decoder=decoder, videocaps=videocaps)
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
while True:
|
||||
nonlocal asyncFuture
|
||||
asyncFuture = asyncio.Future()
|
||||
yieldFuture = asyncio.Future()
|
||||
async def asyncCallback(sample):
|
||||
asyncFuture.set_result(sample)
|
||||
await yieldFuture
|
||||
callbackQueue.put(Callback(asyncCallback))
|
||||
sample = await asyncFuture
|
||||
if not sample:
|
||||
yieldFuture.set_result(None)
|
||||
break
|
||||
try:
|
||||
yield sample
|
||||
finally:
|
||||
yieldFuture.set_result(None)
|
||||
finally:
|
||||
finish()
|
||||
print('gstreamer finished')
|
||||
gst, gen = createPipelineIterator(videosrc)
|
||||
async for gstsample in gen():
|
||||
caps = gstsample.get_caps()
|
||||
height = caps.get_structure(0).get_value('height')
|
||||
width = caps.get_structure(0).get_value('width')
|
||||
gst_buffer = gstsample.get_buffer()
|
||||
result, info = gst_buffer.map(Gst.MapFlags.READ)
|
||||
if not result:
|
||||
continue
|
||||
|
||||
|
||||
def on_new_sample(sink, preroll):
|
||||
nonlocal hasFinished
|
||||
|
||||
sample = sink.emit('pull-preroll' if preroll else 'pull-sample')
|
||||
|
||||
callback: Callback = callbackQueue.get()
|
||||
if not callback.callback or hasFinished:
|
||||
hasFinished = True
|
||||
if callback.callback:
|
||||
asyncio.run_coroutine_threadsafe(callback.callback(None), loop = callback.loop)
|
||||
return Gst.FlowReturn.OK
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(callback.callback(sample), loop = callback.loop)
|
||||
try:
|
||||
future.result()
|
||||
except:
|
||||
pass
|
||||
return Gst.FlowReturn.OK
|
||||
|
||||
appsink.connect('new-preroll', on_new_sample, True)
|
||||
appsink.connect('new-sample', on_new_sample, False)
|
||||
|
||||
gst.set_state(Gst.State.PLAYING)
|
||||
return gst, gen
|
||||
|
||||
def mainThread():
|
||||
async def asyncMain():
|
||||
gst, gen = createPipelineIterator('rtspsrc location=rtsp://localhost:59668/18cc179a814fd5b3 ! rtph264depay ! h264parse ! vtdec_hw ! videoconvert ! video/x-raw')
|
||||
i = 0
|
||||
async for sample in gen():
|
||||
print('sample')
|
||||
i = i + 1
|
||||
if i == 10:
|
||||
break
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.ensure_future(asyncMain(), loop = loop)
|
||||
loop.run_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target = mainThread).start()
|
||||
mainLoop = GLib.MainLoop()
|
||||
mainLoop.run()
|
||||
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)
|
||||
|
||||
76
plugins/python-codecs/src/libav.py
Normal file
76
plugins/python-codecs/src/libav.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import time
|
||||
from gst_generator import createPipelineIterator
|
||||
import scrypted_sdk
|
||||
from typing import Any
|
||||
import vipsimage
|
||||
import pilimage
|
||||
|
||||
av = None
|
||||
try:
|
||||
import av
|
||||
av.logging.set_level(av.logging.PANIC)
|
||||
except:
|
||||
pass
|
||||
|
||||
async def generateVideoFramesLibav(mediaObject: scrypted_sdk.MediaObject, options: scrypted_sdk.VideoFrameGeneratorOptions = None, filter: Any = None) -> scrypted_sdk.VideoFrame:
|
||||
ffmpegInput: scrypted_sdk.FFmpegInput = await scrypted_sdk.mediaManager.convertMediaObjectToJSON(mediaObject, scrypted_sdk.ScryptedMimeTypes.FFmpegInput.value)
|
||||
videosrc = ffmpegInput.get('url')
|
||||
container = av.open(videosrc)
|
||||
# none of this stuff seems to work. might be libav being slow with rtsp.
|
||||
# container.no_buffer = True
|
||||
# container.gen_pts = False
|
||||
# container.options['-analyzeduration'] = '0'
|
||||
# container.options['-probesize'] = '500000'
|
||||
stream = container.streams.video[0]
|
||||
# stream.codec_context.thread_count = 1
|
||||
# stream.codec_context.low_delay = True
|
||||
# stream.codec_context.options['-analyzeduration'] = '0'
|
||||
# stream.codec_context.options['-probesize'] = '500000'
|
||||
|
||||
gray = options and options.get('format') == 'gray'
|
||||
|
||||
start = 0
|
||||
try:
|
||||
for idx, frame in enumerate(container.decode(stream)):
|
||||
now = time.time()
|
||||
if not start:
|
||||
start = now
|
||||
elapsed = now - start
|
||||
if (frame.time or 0) < elapsed - 0.500:
|
||||
# print('too slow, skipping frame')
|
||||
continue
|
||||
# print(frame)
|
||||
if vipsimage.pyvips:
|
||||
if gray and frame.format.name.startswith('yuv') and frame.planes and len(frame.planes):
|
||||
vips = vipsimage.new_from_memory(memoryview(frame.planes[0]), frame.width, frame.height, 1)
|
||||
elif gray:
|
||||
vips = vipsimage.pyvips.Image.new_from_array(frame.to_ndarray(format='gray'))
|
||||
else:
|
||||
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:
|
||||
if gray and frame.format.name.startswith('yuv') and frame.planes and len(frame.planes):
|
||||
pil = pilimage.new_from_memory(memoryview(frame.planes[0]), frame.width, frame.height, 1)
|
||||
elif gray:
|
||||
rgb = frame.to_image()
|
||||
try:
|
||||
pil = rgb.convert('L')
|
||||
finally:
|
||||
rgb.close()
|
||||
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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user