Compare commits

..

81 Commits

Author SHA1 Message Date
Koushik Dutta
9e0e9bc22a cameras: publish 2023-10-12 10:02:38 -07:00
Koushik Dutta
9e495c74d9 onvif: publish 2023-10-12 10:01:14 -07:00
Koushik Dutta
a9baeafe71 postbeta 2023-10-12 09:14:41 -07:00
Koushik Dutta
ee68fcd7d2 core: fix js redirect 2023-10-12 09:11:59 -07:00
Koushik Dutta
af6e18dc1a reolink: intercom teardown fixes 2023-10-12 09:04:16 -07:00
Koushik Dutta
ddb8c7cf58 webrtc: intercom teardown fixes 2023-10-12 09:04:06 -07:00
Koushik Dutta
2be3c7f3df unifi: publish 2023-10-11 17:19:32 -07:00
Koushik Dutta
274f449e2f reolink: missing file from intercom teardown fix 2023-10-11 17:19:18 -07:00
Koushik Dutta
1109333e0f Merge branch 'main' of github.com:koush/scrypted 2023-10-11 17:18:20 -07:00
Dan Wager
0349977a4d unifi-protect: Use connectionHost to support cameras distributed between stacked nvrs (#1128)
* Use connectionHost to support cameras distributed between stacked nvrs

* Add fallback to user-configured nvr ip

* remove whitespace
2023-10-11 17:18:16 -07:00
Koushik Dutta
48548aafd5 reolink: fix teardown 2023-10-11 15:45:25 -07:00
Koushik Dutta
ab70cce1b5 reolink: fix bug where rtmp does not support 4k 2023-10-11 12:53:24 -07:00
Koushik Dutta
83fe0c2b7a videoanalysis: fix rounding issue on cpu throttle 2023-10-11 12:53:12 -07:00
Koushik Dutta
77676a27c2 Update bug_report.md 2023-10-10 08:16:34 -07:00
Koushik Dutta
015dfab7a6 Update issue templates 2023-10-10 08:15:24 -07:00
Koushik Dutta
7f0f0cb6bd client: cache bust engine io requests 2023-10-09 19:49:00 -07:00
Koushik Dutta
e49e13a167 server: fix potential rce 2023-10-09 11:47:32 -07:00
Koushik Dutta
9fd353236b server: update deps 2023-10-09 11:31:45 -07:00
Koushik Dutta
e006d599d7 client: update and publish 2023-10-09 11:29:42 -07:00
Koushik Dutta
71cbe83a2a cloud: support disabling cloudflare 2023-10-04 09:35:13 -07:00
Koushik Dutta
1438af8aea cloud: preserve port setting when disabled 2023-10-04 09:21:43 -07:00
Koushik Dutta
2237eb3221 cloud: use permanent cloudflare hostname if token is provided 2023-10-04 09:15:18 -07:00
Koushik Dutta
7b56e86383 Merge branch 'main' of github.com:koush/scrypted 2023-10-03 19:22:49 -07:00
Koushik Dutta
3653fb83d3 rebroadcast: watch for nvr overflow 2023-10-03 19:22:43 -07:00
Brett Jia
cd766a603e arlo: migrate to standalone repo (#1102) 2023-09-28 10:40:49 -07:00
Koushik Dutta
3648492299 client: dedupe addresses 2023-09-28 09:26:31 -07:00
Koushik Dutta
88e8530677 cloud: addresses should be urls not hosts 2023-09-28 09:03:38 -07:00
Koushik Dutta
325f84ca7e client: consider external addresses during negotiation 2023-09-28 08:48:21 -07:00
Koushik Dutta
0a4b862fd8 cloud: report external addresses 2023-09-28 08:38:37 -07:00
Koushik Dutta
e7d7fd6a00 openvino: bump openvino version and publish. note: 2023.1.0 is available but currently nonfunctional with yolov8 2023-09-26 10:06:02 -07:00
Koushik Dutta
f9dda8d1ca openvino: use builtin async, log execution unit 2023-09-26 09:56:42 -07:00
Koushik Dutta
8b7decd077 Merge branch 'main' of github.com:koush/scrypted 2023-09-25 07:12:12 -07:00
Koushik Dutta
9dd5e10eba python-codecs: fix float being passed to pil resize args 2023-09-25 07:12:05 -07:00
Brett Jia
475b833508 sdk: configurable webpack entrypoint (#1076) 2023-09-21 20:07:32 -07:00
Koushik Dutta
5f9006148a Merge branch 'main' of github.com:koush/scrypted 2023-09-21 16:04:15 -07:00
Koushik Dutta
b77f1a55c1 client: add missing address on cloud connect 2023-09-21 16:04:11 -07:00
Koushik Dutta
6b9163e84e server: rename fetch helper 2023-09-20 08:07:09 -07:00
Koushik Dutta
bc03bdd235 ha: publish 2023-09-20 00:21:30 -07:00
Koushik Dutta
2592a7c228 postrelease 2023-09-19 23:32:51 -07:00
Koushik Dutta
0a4336879c client: allow direct login on chrome if flag is explicitly true 2023-09-19 19:03:16 -07:00
Koushik Dutta
e5cef3f217 client: fixup alt address usage 2023-09-19 18:57:15 -07:00
Koushik Dutta
d34396afbc postbeta 2023-09-19 18:56:45 -07:00
Koushik Dutta
2622fc9256 postbeta 2023-09-19 16:46:09 -07:00
Koushik Dutta
410b1a4813 client: check token presence before using direct address 2023-09-19 16:25:08 -07:00
Koushik Dutta
403c742be3 server: token comment 2023-09-19 16:21:10 -07:00
Koushik Dutta
50a471b78f client: use long term token for direct connection 2023-09-19 15:26:23 -07:00
Koushik Dutta
9b7ead26e0 postbeta 2023-09-19 15:23:07 -07:00
Koushik Dutta
3127bc38cb server: include token for basic auth login result 2023-09-19 15:22:48 -07:00
Koushik Dutta
fb8b1a893d cloud: fix misleading port forward test error 2023-09-19 13:46:26 -07:00
Koushik Dutta
779d8eaa42 postrelease 2023-09-19 13:39:29 -07:00
Koushik Dutta
5eab99866f server: Force ipv4 for npm usage 2023-09-19 13:39:18 -07:00
Koushik Dutta
e10a4f3c58 client: abnormal login results of any type on the alternate urls should fail 2023-09-19 11:30:13 -07:00
Koushik Dutta
2585b1832e docker: add node 20 base 2023-09-19 10:49:23 -07:00
Koushik Dutta
5e8e0d7773 client: validate results 2023-09-19 10:30:19 -07:00
Koushik Dutta
7c17b478d7 cloud: add cors options 2023-09-19 10:29:44 -07:00
Koushik Dutta
9f5dd55c73 h264: ignore nal delimiter 2023-09-19 10:11:44 -07:00
Koushik Dutta
b6f400382d client: support local checks 2023-09-19 09:49:33 -07:00
Koushik Dutta
024b2166b8 snapshot: publish 2023-09-18 08:25:11 -07:00
Koushik Dutta
b49771840e amcrest: httpsAgent usage fixes 2023-09-17 20:34:48 -07:00
Koushik Dutta
4001fc996f amcrest: publish 2023-09-17 17:59:56 -07:00
Koushik Dutta
0d97010ca8 amcrest: fix audiocodec detection nre 2023-09-17 17:59:22 -07:00
Koushik Dutta
e243d99d12 sdk: unprivatize settings method 2023-09-15 14:59:01 -07:00
Koushik Dutta
86a91dfbe4 webrtc: update from upstream 2023-09-15 09:09:46 -07:00
Koushik Dutta
c86ae752e8 videoanalysis: fixup spurious motion triggering object detection on a lot of cams 2023-09-15 09:02:32 -07:00
Koushik Dutta
b7ca477b98 cloud: show tunnel url 2023-09-14 08:30:57 -07:00
Koushik Dutta
c37f8926b8 onvif: fix 2 way audio logging 2023-09-14 08:15:37 -07:00
Koushik Dutta
4b181a8ac9 videoanalysis: fix migration bug by reenabling mixins 2023-09-14 08:15:18 -07:00
Koushik Dutta
b8439aaec3 server: add axios post shim 2023-09-13 16:17:16 -07:00
Koushik Dutta
77d0c33657 videoanalysis: move object detectors behind developer mode flag to prevent footgunning 2023-09-13 10:45:12 -07:00
Koushik Dutta
0b6d61a801 sdk: fix python generation 2023-09-09 20:45:31 -07:00
Koushik Dutta
71a2d27cbd detect: add ObjectDetection filtering interfaces to prevent footgunning 2023-09-09 20:40:56 -07:00
Koushik Dutta
f8f79f5cc2 sdk: add ObjectDetectionPreview 2023-09-09 20:34:57 -07:00
Koushik Dutta
988f297e32 sdk: add ObjectDetectionGenerator 2023-09-09 20:33:09 -07:00
Koushik Dutta
6e109d89e0 Merge branch 'main' of github.com:koush/scrypted 2023-09-09 13:45:24 -07:00
Koushik Dutta
6ada4854bc python-codecs: reduce jpeg quality for better file sizes 2023-09-09 13:45:20 -07:00
Koushik Dutta
bc5e89668f ha: publish 2023-09-09 10:04:27 -07:00
Brett Jia
4c11def52b core: use webpack bundled map marker (#1049)
* core: use webpack bundled map marker

* document source of marker icon workaround

* disable touch zoom
2023-09-09 09:57:15 -07:00
Koushik Dutta
8890d307f4 docker: add builder secrets 2023-09-09 09:39:03 -07:00
Koushik Dutta
9f8f562dcc docker: fixup template path 2023-09-08 21:34:13 -07:00
Koushik Dutta
2ce798c8c2 server: postrelease 2023-09-08 20:12:08 -07:00
Koushik Dutta
4271ef321f postrelease 2023-09-08 20:11:59 -07:00
117 changed files with 1410 additions and 5081 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is. The issue tracker is only for reporting bugs in Scrypted, for general support check Discord. Hardrware support requests or assistance requests will be immediately closed.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -10,7 +10,7 @@ jobs:
# runs-on: ubuntu-latest
strategy:
matrix:
NODE_VERSION: ["18"]
NODE_VERSION: ["18", "20"]
BASE: ["jammy"]
FLAVOR: ["full", "lite", "thin"]
steps:
@@ -23,13 +23,13 @@ jobs:
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
host: Koushik-MacStudio
host: ${{ secrets.DOCKER_SSH_HOST_ARM64 }}
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
host: raspberrypi
host: ${{ secrets.DOCKER_SSH_HOST_ARM7 }}
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
@@ -37,9 +37,9 @@ jobs:
with:
platforms: linux/arm64,linux/armhf
append: |
- endpoint: ssh://koush@Koushik-MacStudio
- endpoint: ssh://${{ secrets.DOCKER_SSH_USER }}@${{ secrets.DOCKER_SSH_HOST_ARM64 }}
platforms: linux/arm64
- endpoint: ssh://koush@raspberrypi
- endpoint: ssh://${{ secrets.DOCKER_SSH_USER }}@${{ secrets.DOCKER_SSH_HOST_ARM7 }}
platforms: linux/armhf
- name: Login to Docker Hub

View File

@@ -19,7 +19,7 @@ jobs:
# runs-on: ubuntu-latest
strategy:
matrix:
BASE: ["18-jammy-full", "18-jammy-lite", "18-jammy-thin"]
BASE: ["18-jammy-full", "18-jammy-lite", "18-jammy-thin", "20-jammy-full", "20-jammy-lite", "20-jammy-thin"]
SUPERVISOR: ["", ".s6"]
steps:
- name: Check out the repo
@@ -42,13 +42,13 @@ jobs:
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
host: Koushik-MacStudio
host: ${{ secrets.DOCKER_SSH_HOST_ARM64 }}
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
host: raspberrypi
host: ${{ secrets.DOCKER_SSH_HOST_ARM7 }}
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
@@ -56,12 +56,11 @@ jobs:
with:
platforms: linux/arm64,linux/armhf
append: |
- endpoint: ssh://koush@Koushik-MacStudio
# platforms: linux/arm64
- endpoint: ssh://${{ secrets.DOCKER_SSH_USER }}@${{ secrets.DOCKER_SSH_HOST_ARM64 }}
platforms: linux/arm64
- endpoint: ssh://koush@raspberrypi
- endpoint: ssh://${{ secrets.DOCKER_SSH_USER }}@${{ secrets.DOCKER_SSH_HOST_ARM7 }}
platforms: linux/armhf
- name: Login to Docker Hub
uses: docker/login-action@v2
with:

View File

@@ -3,14 +3,13 @@ import sdk from "@scrypted/sdk";
const { systemManager } = sdk;
const autoIncludeToken = 'v4';
export abstract class AutoenableMixinProvider extends ScryptedDeviceBase {
hasEnabledMixin: { [id: string]: string } = {};
pluginsComponent: Promise<any>;
unshiftMixin = false;
constructor(nativeId?: string) {
constructor(nativeId?: string, public autoIncludeToken = 'v4') {
super(nativeId);
try {
@@ -30,10 +29,12 @@ export abstract class AutoenableMixinProvider extends ScryptedDeviceBase {
this.maybeEnableMixin(eventSource);
});
for (const id of Object.keys(systemManager.getSystemState())) {
const device = systemManager.getDeviceById(id);
this.maybeEnableMixin(device);
}
process.nextTick(() => {
for (const id of Object.keys(systemManager.getSystemState())) {
const device = systemManager.getDeviceById(id);
this.maybeEnableMixin(device);
}
});
}
async shouldEnableMixin(device: ScryptedDevice) {
@@ -44,7 +45,7 @@ export abstract class AutoenableMixinProvider extends ScryptedDeviceBase {
if (!device || device.mixins?.includes(this.id))
return;
if (this.hasEnabledMixin[device.id] === autoIncludeToken)
if (this.hasEnabledMixin[device.id] === this.autoIncludeToken)
return;
const match = await this.canMixin(device.type, device.interfaces);
@@ -66,9 +67,9 @@ export abstract class AutoenableMixinProvider extends ScryptedDeviceBase {
}
setHasEnabledMixin(id: string) {
if (this.hasEnabledMixin[id] === autoIncludeToken)
if (this.hasEnabledMixin[id] === this.autoIncludeToken)
return;
this.hasEnabledMixin[id] = autoIncludeToken;
this.hasEnabledMixin[id] = this.autoIncludeToken;
this.storage.setItem('hasEnabledMixin', JSON.stringify(this.hasEnabledMixin));
}

View File

@@ -1,6 +1,6 @@
# Home Assistant Addon Configuration
name: Scrypted
version: "18-jammy-full.s6-v0.41.0"
version: "18-jammy-full.s6-v0.55.0"
slug: scrypted
description: Scrypted is a high performance home video integration and automation platform
url: "https://github.com/koush/scrypted"

View File

@@ -50,7 +50,7 @@ services:
# Modify to add the additional volume for Scrypted NVR.
# The following example would mount the /mnt/sda/video path on the host
# to the /nvr path inside the docker container.
# - /mnt/sda/video:/nvr
# - /mnt/media/video:/nvr
# Or use a network mount from one of the CIFS/NFS examples at the top of this file.
# - type: volume

View File

@@ -1,29 +1,54 @@
{
"name": "@scrypted/client",
"version": "1.1.55",
"version": "1.1.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/client",
"version": "1.1.55",
"version": "1.1.57",
"license": "ISC",
"dependencies": {
"@scrypted/types": "^0.2.94",
"@scrypted/types": "^0.2.95",
"axios": "^0.25.0",
"engine.io-client": "^6.4.0",
"rimraf": "^3.0.2"
"engine.io-client": "^6.5.2",
"rimraf": "^5.0.5"
},
"devDependencies": {
"@types/ip": "^1.1.0",
"@types/node": "^18.14.2",
"typescript": "^4.9.5"
"@types/ip": "^1.1.1",
"@types/node": "^20.8.4",
"typescript": "^5.2.2"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@scrypted/types": {
"version": "0.2.94",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.94.tgz",
"integrity": "sha512-615C6lLnJGk0qhp+Y72B3xeD2CS9p/h8JUmFDjKh4H4IjL6zlV10tZVAXWQt3Q5rmy1WAaS3nScR6NgxZ5woOA=="
"version": "0.2.95",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.95.tgz",
"integrity": "sha512-gdSCsvGp1ZZowLOKP4CaxdTavnrE/bBfcfnvwsrPcxVRjbh+85fiNnXH2nX6L9uikAAPY3cIlcwbw3Dv1wzGQA=="
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.0",
@@ -31,19 +56,44 @@
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
"node_modules/@types/ip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.0.tgz",
"integrity": "sha512-dwNe8gOoF70VdL6WJBwVHtQmAX4RMd62M+mAB9HQFjG1/qiCLM/meRy95Pd14FYBbEDwCq7jgJs89cHpLBu4HQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.1.tgz",
"integrity": "sha512-/v+XZuKNBQHJi3dKeFt9LySLzWNkgmaYRtnFfg27Ag0MO9tQLzHUuAA8zOhPtbDvDGkcnZGr4pVZQPGNft/WYA==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "18.14.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.2.tgz",
"integrity": "sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==",
"dev": true
"version": "20.8.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz",
"integrity": "sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A==",
"dev": true,
"dependencies": {
"undici-types": "~5.25.1"
}
},
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/axios": {
"version": "0.25.0",
@@ -59,18 +109,41 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^1.0.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.3.4",
@@ -88,22 +161,32 @@
}
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
},
"node_modules/engine.io-client": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.4.0.tgz",
"integrity": "sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g==",
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz",
"integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.0.3",
"engine.io-parser": "~5.2.1",
"ws": "~8.11.0",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz",
"integrity": "sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz",
"integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==",
"engines": {
"node": ">=10.0.0"
}
@@ -127,53 +210,100 @@
}
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"node_modules/foreground-child": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
"integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1"
},
"engines": {
"node": "*"
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"node_modules/glob": {
"version": "10.3.10",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
"foreground-child": "^3.1.0",
"jackspeak": "^2.3.5",
"minimatch": "^9.0.1",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
"path-scurry": "^1.10.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "*"
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/jackspeak": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
"integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/lru-cache": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz",
"integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==",
"engines": {
"node": "14 || >=16.14"
}
},
"node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
"integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
@@ -181,53 +311,280 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"engines": {
"node": ">=0.10.0"
"node": ">=8"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"node_modules/path-scurry": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
"integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
"dependencies": {
"glob": "^7.1.3"
"lru-cache": "^9.1.1 || ^10.0.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"bin": {
"rimraf": "bin.js"
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
"integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
"node_modules/undici-types": {
"version": "5.25.3",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz",
"integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==",
"dev": true
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.11.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/client",
"version": "1.1.55",
"version": "1.1.57",
"description": "",
"main": "dist/packages/client/src/index.js",
"scripts": {
@@ -12,14 +12,14 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@types/ip": "^1.1.0",
"@types/node": "^18.14.2",
"typescript": "^4.9.5"
"@types/ip": "^1.1.1",
"@types/node": "^20.8.4",
"typescript": "^5.2.2"
},
"dependencies": {
"@scrypted/types": "^0.2.94",
"@scrypted/types": "^0.2.95",
"axios": "^0.25.0",
"engine.io-client": "^6.4.0",
"rimraf": "^3.0.2"
"engine.io-client": "^6.5.2",
"rimraf": "^5.0.5"
}
}

View File

@@ -1,5 +1,5 @@
import { MediaObjectOptions, RTCConnectionManagement, RTCSignalingSession, ScryptedStatic } from "@scrypted/types";
import axios, { AxiosRequestConfig } from 'axios';
import axios, { AxiosRequestConfig, AxiosRequestHeaders } from 'axios';
import * as eio from 'engine.io-client';
import { SocketOptions } from 'engine.io-client';
import { Deferred } from "../../../common/src/deferred";
@@ -8,7 +8,6 @@ import { BrowserSignalingSession, waitPeerConnectionIceConnected, waitPeerIceCon
import { DataChannelDebouncer } from "../../../plugins/webrtc/src/datachannel-debouncer";
import type { IOSocket } from '../../../server/src/io';
import { MediaObject } from '../../../server/src/plugin/mediaobject';
import type { MediaObjectRemote } from '../../../server/src/plugin/plugin-api';
import { attachPluginRemote } from '../../../server/src/plugin/plugin-remote';
import { RpcPeer } from '../../../server/src/rpc';
import { createRpcDuplexSerializer, createRpcSerializer } from '../../../server/src/rpc-serializer';
@@ -48,9 +47,8 @@ export interface ScryptedClientStatic extends ScryptedStatic {
browserSignalingSession?: BrowserSignalingSession;
address?: string;
connectionType: ScryptedClientConnectionType;
authorization?: string;
queryToken?: { [parameter: string]: string };
rpcPeer: RpcPeer,
rpcPeer: RpcPeer;
loginResult: ScryptedClientLoginResult;
}
export interface ScryptedConnectionOptions {
@@ -59,6 +57,7 @@ export interface ScryptedConnectionOptions {
webrtc?: boolean;
baseUrl?: string;
axiosConfig?: AxiosRequestConfig;
previousLoginResult?: ScryptedClientLoginResult;
}
export interface ScryptedLoginOptions extends ScryptedConnectionOptions {
@@ -133,37 +132,42 @@ export async function loginScryptedClient(options: ScryptedLoginOptions) {
if (response.status !== 200)
throw new Error('status ' + response.status);
const addresses = response.data.addresses as string[] || [];
// the cloud plugin will include this header.
// should maybe move this into the cloud server itself.
const scryptedCloud = response.headers['x-scrypted-cloud'] === 'true';
const directAddress = response.headers['x-scrypted-direct-address'];
const cloudAddress = response.headers['x-scrypted-cloud-address'];
return {
error: response.data.error as string,
authorization: response.data.authorization as string,
queryToken: response.data.queryToken as any,
token: response.data.token as string,
addresses,
scryptedCloud,
directAddress,
cloudAddress,
addresses: response.data.addresses as string[],
externalAddresses: response.data.externalAddresses as string[],
// the cloud plugin will include this header.
// should maybe move this into the cloud server itself.
scryptedCloud: response.headers['x-scrypted-cloud'] === 'true',
directAddress: response.headers['x-scrypted-direct-address'],
cloudAddress: response.headers['x-scrypted-cloud-address'],
};
}
export async function checkScryptedClientLogin(options?: ScryptedConnectionOptions) {
let { baseUrl } = options || {};
const url = combineBaseUrl(baseUrl, 'login');
let url = combineBaseUrl(baseUrl, 'login');
const headers: AxiosRequestHeaders = {};
if (options?.previousLoginResult?.queryToken) {
// headers.Authorization = options?.previousLoginResult?.authorization;
// const search = new URLSearchParams(options.previousLoginResult.queryToken);
// url += '?' + search.toString();
const token = options?.previousLoginResult.username + ":" + options.previousLoginResult.token;
const hash = Buffer.from(token).toString('base64');
headers.Authorization = `Basic ${hash}`;
}
const response = await axios.get(url, {
withCredentials: true,
headers,
...options?.axiosConfig,
});
const scryptedCloud = response.headers['x-scrypted-cloud'] === 'true';
const directAddress = response.headers['x-scrypted-direct-address'];
const cloudAddress = response.headers['x-scrypted-cloud-address'];
return {
baseUrl,
hostname: response.data.hostname as string,
redirect: response.data.redirect as string,
username: response.data.username as string,
@@ -174,12 +178,27 @@ export async function checkScryptedClientLogin(options?: ScryptedConnectionOptio
queryToken: response.data.queryToken as any,
token: response.data.token as string,
addresses: response.data.addresses as string[],
scryptedCloud,
directAddress,
cloudAddress,
externalAddresses: response.data.externalAddresses as string[],
// the cloud plugin will include this header.
// should maybe move this into the cloud server itself.
scryptedCloud: response.headers['x-scrypted-cloud'] === 'true',
directAddress: response.headers['x-scrypted-direct-address'],
cloudAddress: response.headers['x-scrypted-cloud-address'],
};
}
export interface ScryptedClientLoginResult {
username: string;
token: string;
authorization: string;
queryToken: { [parameter: string]: string };
localAddresses: string[];
externalAddresses: string[];
scryptedCloud: boolean;
directAddress: string;
cloudAddress: string;
}
export class ScryptedClientLoginError extends Error {
constructor(public result: Awaited<ReturnType<typeof checkScryptedClientLogin>>) {
super(result.error);
@@ -215,50 +234,119 @@ export async function redirectScryptedLogout(baseUrl?: string) {
export async function connectScryptedClient(options: ScryptedClientOptions): Promise<ScryptedClientStatic> {
const start = Date.now();
let { baseUrl, pluginId, clientName, username, password } = options;
let authorization: string;
let queryToken: any;
const extraHeaders: { [header: string]: string } = {};
let localAddresses: string[];
let externalAddresses: string[];
let scryptedCloud: boolean;
let directAddress: string;
let cloudAddress: string;
let token: string;
console.log('@scrypted/client', packageJson.version);
const extraHeaders: { [header: string]: string } = {};
// Chrome will complain about websites making xhr requests to self signed https sites, even
// if the cert has been accepted. Other browsers seem fine.
// So the default is not to connect to IP addresses on Chrome, but do so on other browsers.
const isChrome = globalThis.navigator?.userAgent.includes('Chrome');
const isNotChromeOrIsInstalledApp = !isChrome || isInstalledApp();
let tryAlternateAddresses = false;
if (username && password) {
const loginResult = await loginScryptedClient(options as ScryptedLoginOptions);
if (loginResult.authorization)
extraHeaders['Authorization'] = loginResult.authorization;
localAddresses = loginResult.addresses;
externalAddresses = loginResult.externalAddresses;
scryptedCloud = loginResult.scryptedCloud;
directAddress = loginResult.directAddress;
cloudAddress = loginResult.cloudAddress;
authorization = loginResult.authorization;
queryToken = loginResult.queryToken;
token = loginResult.token;
console.log('login result', Date.now() - start, loginResult);
}
else {
const loginCheck = await checkScryptedClientLogin({
const urlsToCheck = new Set<string>();
if (options?.previousLoginResult?.token) {
for (const u of [
...options?.previousLoginResult?.localAddresses || [],
options?.previousLoginResult?.directAddress,
]) {
if (u && (isNotChromeOrIsInstalledApp || options.direct))
urlsToCheck.add(u);
}
for (const u of [
...options?.previousLoginResult?.externalAddresses || [],
options?.previousLoginResult?.cloudAddress,
]) {
if (u)
urlsToCheck.add(u);
}
}
// the alternate urls must have a valid response.
const loginCheckPromises = [...urlsToCheck].map(async baseUrl => {
const loginCheck = await checkScryptedClientLogin({
baseUrl,
previousLoginResult: options?.previousLoginResult,
});
if (loginCheck.error || loginCheck.redirect)
throw new Error('login error');
if (!loginCheck.authorization || !loginCheck.username || !loginCheck.queryToken) {
console.error(loginCheck);
throw new Error('malformed login result');
}
return loginCheck;
});
const baseUrlCheck = checkScryptedClientLogin({
baseUrl,
});
loginCheckPromises.push(baseUrlCheck);
let loginCheck: Awaited<ReturnType<typeof checkScryptedClientLogin>>;
try {
loginCheck = await Promise.any(loginCheckPromises);
tryAlternateAddresses ||= loginCheck.baseUrl !== baseUrl;
}
catch (e) {
loginCheck = await baseUrlCheck;
}
if (tryAlternateAddresses)
console.log('Found direct login. Allowing alternate addresses.')
if (loginCheck.error || loginCheck.redirect)
throw new ScryptedClientLoginError(loginCheck);
localAddresses = loginCheck.addresses;
externalAddresses = loginCheck.externalAddresses;
scryptedCloud = loginCheck.scryptedCloud;
directAddress = loginCheck.directAddress;
cloudAddress = loginCheck.cloudAddress;
username = loginCheck.username;
authorization = loginCheck.authorization;
queryToken = loginCheck.queryToken;
token = loginCheck.token;
console.log('login checked', Date.now() - start, loginCheck);
}
let socket: IOClientSocket;
const eioPath = `endpoint/${pluginId}/engine.io/api`;
const eioEndpoint = baseUrl ? new URL(eioPath, baseUrl).pathname : '/' + eioPath;
// https://github.com/socketio/engine.io/issues/690
const cacehBust = Math.random().toString(36).substring(3, 10);
const eioOptions: Partial<SocketOptions> = {
path: eioEndpoint,
query: {
cacehBust,
},
withCredentials: true,
extraHeaders,
rejectUnauthorized: false,
@@ -271,25 +359,26 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
// watch for this flush.
const flush = new Deferred<void>();
// Chrome will complain about websites making xhr requests to self signed https sites, even
// if the cert has been accepted. Other browsers seem fine.
// So the default is not to connect to IP addresses on Chrome, but do so on other browsers.
const isChrome = globalThis.navigator?.userAgent.includes('Chrome');
const isNotChromeOrIsInstalledApp = !isChrome || isInstalledApp();
const addresses: string[] = [];
const localAddressDefault = isNotChromeOrIsInstalledApp;
if (((scryptedCloud && options.local === undefined && localAddressDefault) || options.local) && localAddresses) {
tryAlternateAddresses ||= scryptedCloud;
if (((tryAlternateAddresses && options.local === undefined && localAddressDefault) || options.local) && localAddresses) {
addresses.push(...localAddresses);
}
const directAddressDefault = directAddress && (isNotChromeOrIsInstalledApp || !isIPAddress(directAddress));
if (((scryptedCloud && options.direct === undefined && directAddressDefault) || options.direct) && directAddress) {
if (((tryAlternateAddresses && options.direct === undefined && directAddressDefault) || options.direct) && directAddress) {
addresses.push(directAddress);
}
if (((scryptedCloud && options.direct === undefined) || options.direct) && cloudAddress) {
addresses.push(cloudAddress);
if ((tryAlternateAddresses && options.direct === undefined) || options.direct) {
if (cloudAddress)
addresses.push(cloudAddress);
for (const externalAddress of externalAddresses || []) {
addresses.push(externalAddress);
}
}
const tryAddresses = !!addresses.length;
@@ -336,7 +425,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
// It is probably better to simply prompt and redirect to the LAN address
// if it is reacahble.
for (const address of addresses) {
for (const address of new Set(addresses)) {
console.log('trying', address);
const check = new eio.Socket(address, localEioOptions);
sockets.push(check);
@@ -355,6 +444,9 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
console.log('trying webrtc');
const webrtcEioOptions: Partial<SocketOptions> = {
path: '/endpoint/@scrypted/webrtc/engine.io/',
query: {
cacehBust,
},
withCredentials: true,
extraHeaders,
rejectUnauthorized: false,
@@ -505,6 +597,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
await once(check, 'open');
return {
ready: check,
address: explicitBaseUrl,
connectionType: 'http',
};
})());
@@ -632,9 +725,18 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
pluginHostAPI: undefined,
rtcConnectionManagement,
browserSignalingSession,
authorization,
queryToken,
rpcPeer,
loginResult: {
username,
token,
directAddress,
localAddresses,
externalAddresses,
scryptedCloud,
queryToken,
authorization,
cloudAddress,
}
}
socket.on('close', () => {

View File

@@ -0,0 +1,23 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ts-node",
"type": "node",
"request": "launch",
"args": [
"${workspaceFolder}/test/test.ts"
],
"runtimeArgs": [
"-r",
"ts-node/register"
],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart"
}
]
}

View File

@@ -0,0 +1,93 @@
import { H264Repacketizer, depacketizeStapA } from '../src/index';
import { H264_NAL_TYPE_IDR, H264_NAL_TYPE_PPS, H264_NAL_TYPE_SEI, H264_NAL_TYPE_SPS, H264_NAL_TYPE_STAP_A, RtspServer, getNaluTypesInNalu } from '../../../common/src/rtsp-server';
import fs from 'fs';
import { getNvrSessionStream } from '../../../../nvr/nvr-plugin/src/session-stream';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { RtpPacket } from '../../../external/werift/packages/rtp/src/rtp/rtp';
function parse(parameters: string) {
const spspps = parameters.split(',');
// empty sprop-parameter-sets is apparently a thing:
// a=fmtp:96 profile-level-id=420029; packetization-mode=1; sprop-parameter-sets=
if (spspps?.length !== 2) {
return {
sps: undefined,
pps: undefined,
};
}
const [sps, pps] = spspps;
return {
sps: Buffer.from(sps, 'base64'),
pps: Buffer.from(pps, 'base64'),
}
}
async function main() {
const spspps = parse('Z2QAM6wVFKAoALWQ,aO48sA==');
// Z2QAM6wVFKAoALWQ
// Z00AMpY1QEABg03BQEFQAAADABAAAAMDKEA=
const repacketizer = new H264Repacketizer(console, 1300, undefined);
const stream = fs.createReadStream('/Users/koush/Downloads/rtsp/1692537093973.rtsp', {
start: 0,
highWaterMark: 800000,
});
let rtspParser = new RtspServer(stream as any, '');
rtspParser.setupTracks = {
'0': {
codec: '0',
protocol: 'tcp',
control: '',
destination: 0,
},
'2': {
codec: '2',
protocol: 'tcp',
control: '',
destination: 2,
},
}
for await (const rtspSample of rtspParser.handleRecord()) {
if (rtspSample.type !== '0')
continue;
const rtp = RtpPacket.deSerialize(rtspSample.packet);
const nalus = getNaluTypesInNalu(rtp.payload);
if (nalus.has(H264_NAL_TYPE_SEI)) {
console.warn('SEI', rtp.payload)
}
if (nalus.has(H264_NAL_TYPE_SPS)) {
console.warn('SPS', rtp.payload, spspps.sps)
}
if (nalus.has(H264_NAL_TYPE_PPS)) {
console.warn('PPS', rtp.payload, spspps.sps)
}
if (nalus.has(H264_NAL_TYPE_STAP_A)) {
const parts = depacketizeStapA(rtp.payload);
console.log('stapa', parts);
for (const part of parts) {
}
}
if (nalus.has(H264_NAL_TYPE_IDR)) {
const h264Packetizer = new H264Repacketizer(console, 65535, spspps as any);
// offset the stapa packet by -1 so the sequence numbers can be reused.
h264Packetizer.extraPackets = -1;
const stapas: RtpPacket[] = [];
const idr = RtpPacket.deSerialize(rtspSample.packet);
h264Packetizer.maybeSendStapACodecInfo(idr, stapas);
if (stapas.length === 1) {
const stapa = stapas[0].serialize();
// console.log(stapa);
}
}
}
}
main();

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/amcrest",
"version": "0.0.124",
"version": "0.0.128",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/amcrest",
"version": "0.0.124",
"version": "0.0.128",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/amcrest",
"version": "0.0.124",
"version": "0.0.128",
"description": "Amcrest Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -95,6 +95,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
for (const element of deviceParameters) {
try {
const response = await this.getClient().digestAuth.request({
httpsAgent: amcrestHttpsAgent,
url: `http://${this.getHttpAddress()}/cgi-bin/magicBox.cgi?action=${element.action}`
});
@@ -147,6 +148,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
return;
const response = await this.getClient().digestAuth.request({
httpsAgent: amcrestHttpsAgent,
url: `http://${this.getHttpAddress()}/cgi-bin/configManager.cgi?action=setConfig&${params}`
});
this.console.log('reconfigure result', response.data);
@@ -190,14 +192,11 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
|| event === AmcrestEvent.PhoneCallDetectStart
|| event === AmcrestEvent.AlarmIPCStart
|| event === AmcrestEvent.DahuaTalkInvite) {
if (event === AmcrestEvent.DahuaTalkInvite && payload && multipleCallIds)
{
if (payload.includes(callerId))
{
if (event === AmcrestEvent.DahuaTalkInvite && payload && multipleCallIds) {
if (payload.includes(callerId)) {
this.binaryState = true;
}
} else
{
} else {
this.binaryState = true;
}
}
@@ -259,25 +258,23 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
if (!twoWayAudio)
twoWayAudio = isDoorbell ? 'Amcrest' : 'None';
if (doorbellType == DAHUA_DOORBELL_TYPE)
{
if (doorbellType == DAHUA_DOORBELL_TYPE) {
ret.push(
{
title: 'Multiple Call Buttons',
key: 'multipleCallIds',
description: 'Some Dahua Doorbells integrate multiple Call Buttons for apartment buildings.',
type: 'boolean',
value: (this.storage.getItem('multipleCallIds') === 'true').toString(),
}
{
title: 'Multiple Call Buttons',
key: 'multipleCallIds',
description: 'Some Dahua Doorbells integrate multiple Call Buttons for apartment buildings.',
type: 'boolean',
value: (this.storage.getItem('multipleCallIds') === 'true').toString(),
}
);
}
const multipleCallIds = this.storage.getItem('multipleCallIds');
if (multipleCallIds)
{
if (multipleCallIds) {
ret.push(
{
title: 'Caller ID',
@@ -288,7 +285,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
}
)
}
ret.push(
{
@@ -309,11 +306,11 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
);
return ret;
}
async takeSmartCameraPicture(option?: PictureOptions): Promise<MediaObject> {
return this.createMediaObject(await this.getClient().jpegSnapshot(), 'image/jpeg');
@@ -401,11 +398,11 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
?.replace('.', '')?.toLowerCase()?.trim();
if (audioCodec?.includes('aac'))
audioCodec = 'aac';
else if (audioCodec.includes('g711a'))
else if (audioCodec?.includes('g711a'))
audioCodec = 'pcm_alaw';
else if (audioCodec.includes('g711u'))
else if (audioCodec?.includes('g711u'))
audioCodec = 'pcm_ulaw';
else if (audioCodec.includes('g711'))
else if (audioCodec?.includes('g711'))
audioCodec = 'pcm';
if (vso.audio)
@@ -490,7 +487,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
this.videoStreamOptions = undefined;
super.putSetting(key, value);
this.updateDevice();
this.updateDeviceInfo();
}

View File

@@ -1,5 +0,0 @@
.DS_Store
out/
node_modules/
dist/
.venv

View File

@@ -1,10 +0,0 @@
.DS_Store
out/
node_modules/
*.map
fs
src
.vscode
dist/*.js
dist/*.txt
__pycache__

View File

@@ -1,30 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Scrypted Debugger",
"type": "python",
"request": "attach",
"connect": {
"host": "${config:scrypted.debugHost}",
"port": 10081
},
"justMyCode": false,
"preLaunchTask": "scrypted: deploy+debug",
"pathMappings": [
{
"localRoot": "${workspaceFolder}/../../server/python/",
"remoteRoot": "${config:scrypted.serverRoot}/python",
},
{
"localRoot": "${workspaceFolder}/src",
"remoteRoot": "${config:scrypted.volumeRoot}/plugin.zip"
},
]
}
]
}

View File

@@ -1,27 +0,0 @@
{
// specify the following paths on the target scrypted server:
// 1) where @scrypted/server node module resides: this may either be a checkout or a install.
// 2) where the scrypted "volume" data is located on the server. ie, the docker volume.
// the following default examples are provided for local and docker installations,
// only modifying the debugHost should be necessary:
// local installation
// "scrypted.debugHost": "192.168.2.119",
// "scrypted.serverRoot": "/home/pi/.scrypted/node_modules/@scrypted/server",
// "scrypted.volumeRoot": "/home/pi/.scrypted/volume",
// docker installation
// "scrypted.debugHost": "192.168.2.109",
"scrypted.serverRoot": "/server/node_modules/@scrypted/server",
"scrypted.volumeRoot": "/server/volume",
// local checkout
"scrypted.debugHost": "127.0.0.1",
//"scrypted.serverRoot": "/Volumes/Dev/scrypted/server",
//"scrypted.volumeRoot": "${config:scrypted.serverRoot}/volume",
"python.analysis.extraPaths": [
"./node_modules/@scrypted/sdk/types/scrypted_python"
]
}

View File

@@ -1,20 +0,0 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "scrypted: deploy+debug",
"type": "shell",
"presentation": {
"echo": true,
"reveal": "silent",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}",
},
]
}

View File

@@ -1,43 +0,0 @@
# Arlo Plugin for Scrypted
The Arlo Plugin connects Scrypted to Arlo Cloud, allowing you to access all of your Arlo cameras in Scrypted.
It is highly recommended to create a dedicated Arlo account for use with this plugin and share your cameras from your main account, as Arlo only permits one active login to their servers per account. Using a separate account allows you to use the Arlo app or website simultaneously with this plugin, otherwise logging in from one place will log you out from all other devices.
The account you use for this plugin must have either SMS or email set as the default 2FA option. Once you enter your username and password on the plugin settings page, you should receive a 2FA code through your default 2FA option. Enter that code into the provided box, and your cameras will appear in Scrypted. Or, see below for configuring IMAP to auto-login with 2FA.
If you experience any trouble logging in, clear the username and password boxes, reload the plugin, and try again.
If you are unable to see shared cameras in your separate Arlo account, ensure that both your primary and secondary accounts are upgraded according to this [forum post](https://web.archive.org/web/20230710141914/https://community.arlo.com/t5/Arlo-Secure/Invited-friend-cannot-see-devices-on-their-dashboard-Arlo-Pro-2/m-p/1889396#M1813). Verify the sharing worked by logging in via the Arlo web dashboard.
**If you add or remove cameras from your main Arlo account, or share/un-share/re-share cameras with the Arlo account used with this plugin, ensure that you reload this plugin to get the updated camera state from Arlo Cloud.**
## General Setup Notes
* Ensure that your Arlo account's default 2FA option is set to either SMS or email.
* Motion events notifications should be turned on in the Arlo app. If you are receiving motion push notifications, Scrypted will also receive motion events.
* Disable smart detection and any cloud/local recording in the Arlo app. Arlo Cloud only permits one active stream per camera, so any smart detection or recording features may prevent downstream plugins (e.g. Homekit) from successfully pulling the video feed after a motion event.
* It is highly recommended to enable the Rebroadcast plugin to allow multiple downstream plugins to pull the video feed within Scrypted.
* If there is no audio on your camera, switch to the `FFmpeg (TCP)` parser under the `Cloud RTSP` settings.
* Prebuffering should only be enabled if the camera is wired to a persistent power source, such as a wall outlet. Prebuffering will only work if your camera does not have a battery or `Plugged In to External Power` is selected.
* The plugin supports pulling RTSP or DASH streams from Arlo Cloud. It is recommended to use RTSP for the lowest latency streams. DASH is inconsistent in reliability, and may return finicky codecs that require additional FFmpeg output arguments, e.g. `-vcodec h264`. *Note that both RTSP and DASH will ultimately pull the same video stream feed from your camera, and they cannot both be used at the same time due to the single stream limitation.*
Note that streaming cameras uses extra Internet bandwidth, since video and audio packets will need to travel from the camera through your network, out to Arlo Cloud, and then back to your network and into Scrypted.
## IMAP 2FA
The Arlo Plugin supports using the IMAP protocol to check an email mailbox for Arlo 2FA codes. This requires you to specify an email 2FA option as the default in your Arlo account settings.
The plugin should work with any mailbox that supports IMAP, but so far has been tested with Gmail. To configure a Gmail mailbox, see [here](https://support.google.com/mail/answer/7126229?hl=en) to see the Gmail IMAP settings, and [here](https://support.google.com/accounts/answer/185833?hl=en) to create an App Password. Enter the App Password in place of your normal Gmail password.
The plugin searches for emails sent by Arlo's `do_not_reply@arlo.com` address when looking for 2FA codes. If you are using a service to forward emails to the mailbox registered with this plugin (e.g. a service like iCloud's Hide My Email), it is possible that Arlo's email sender address has been overwritten by the mail forwarder. Check the email registered with this plugin to see what address the mail forwarder uses to replace Arlo's sender address, and update that in the IMAP 2FA settings.
## Virtual Security System for Arlo Sirens
In external integrations like Homekit, sirens are exposed as simple on-off switches. This makes it easy to accidentally hit the switch when using the Home app. The Arlo Plugin creates a "virtual" security system device per siren to allow Scrypted to arm or disarm the siren switch to protect against accidental triggers. This fake security system device will be synced into Homekit as a separate accessory from the camera, with the siren itself merged into the security system accessory.
Note that the virtual security system is NOT tied to your Arlo account at all, and will not make any changes such as switching your device's motion alert armed/disarmed modes. For more information, please see the README on the virtual security system device in Scrypted.
## Video Clips
The Arlo Plugin will show video clips available in Arlo Cloud for cameras with cloud recording enabled. These clips are not downloaded onto your Scrypted server, but rather streamed on-demand. Deleting clips is not available in Scrypted and should be done through the Arlo app or the Arlo web dashboard.

View File

@@ -1,84 +0,0 @@
{
"name": "@scrypted/arlo",
"version": "0.8.26",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/arlo",
"version": "0.8.26",
"license": "Apache",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.2.104",
"dev": true,
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.18.6",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.15.9",
"ncp": "^2.0.0",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"tmp": "^0.2.1",
"ts-loader": "^9.4.2",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.5.0"
},
"bin": {
"scrypted-changelog": "bin/scrypted-changelog.js",
"scrypted-debug": "bin/scrypted-debug.js",
"scrypted-deploy": "bin/scrypted-deploy.js",
"scrypted-deploy-debug": "bin/scrypted-deploy-debug.js",
"scrypted-package-json": "bin/scrypted-package-json.js",
"scrypted-setup-project": "bin/scrypted-setup-project.js",
"scrypted-webpack": "bin/scrypted-webpack.js"
},
"devDependencies": {
"@types/node": "^18.11.18",
"@types/stringify-object": "^4.0.0",
"stringify-object": "^3.3.0",
"ts-node": "^10.4.0",
"typedoc": "^0.23.21"
}
},
"node_modules/@scrypted/sdk": {
"resolved": "../../sdk",
"link": true
}
},
"dependencies": {
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
"@babel/preset-typescript": "^7.18.6",
"@types/node": "^18.11.18",
"@types/stringify-object": "^4.0.0",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.15.9",
"ncp": "^2.0.0",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"stringify-object": "^3.3.0",
"tmp": "^0.2.1",
"ts-loader": "^9.4.2",
"ts-node": "^10.4.0",
"typedoc": "^0.23.21",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.5.0"
}
}
}
}

View File

@@ -1,41 +0,0 @@
{
"name": "@scrypted/arlo",
"version": "0.8.26",
"description": "Arlo Plugin for Scrypted",
"license": "Apache",
"keywords": [
"scrypted",
"plugin",
"arlo",
"camera"
],
"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"
},
"scrypted": {
"name": "Arlo Camera Plugin",
"runtime": "python",
"type": "DeviceProvider",
"interfaces": [
"Settings",
"DeviceProvider"
],
"pluginDependencies": [
"@scrypted/snapshot",
"@scrypted/prebuffer-mixin"
]
},
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}
}

View File

@@ -1 +0,0 @@
from .provider import ArloProvider

View File

@@ -1 +0,0 @@
from .arlo_async import Arlo

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
import ssl
from socket import setdefaulttimeout
import requests
from requests_toolbelt.adapters import host_header_ssl
import scrypted_arlo_go
from .logging import logger
setdefaulttimeout(15)
def pick_host(hosts, hostname_to_match, endpoint_to_test):
setdefaulttimeout(5)
try:
session = requests.Session()
session.mount('https://', host_header_ssl.HostHeaderSSLAdapter())
for host in hosts:
try:
c = ssl.get_server_certificate((host, 443))
scrypted_arlo_go.VerifyCertHostname(c, hostname_to_match)
r = session.post(f"https://{host}{endpoint_to_test}", headers={"Host": hostname_to_match})
r.raise_for_status()
return host
except Exception as e:
logger.warning(f"{host} is invalid: {e}")
raise Exception("no valid hosts found!")
finally:
setdefaulttimeout(15)

View File

@@ -1,16 +0,0 @@
import logging
import sys
# construct logger instance to be used by package arlo
logger = logging.getLogger("lib")
logger.setLevel(logging.INFO)
# output logger to stdout
ch = logging.StreamHandler(sys.stdout)
# log formatting
fmt = logging.Formatter("[Arlo]: %(message)s")
ch.setFormatter(fmt)
# configure handler to logger
logger.addHandler(ch)

View File

@@ -1,85 +0,0 @@
import asyncio
import json
import random
import paho.mqtt.client as mqtt
from .stream_async import Stream
from .logging import logger
class MQTTStream(Stream):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.cached_topics = []
def _gen_client_number(self):
return random.randint(1000000000, 9999999999)
async def start(self):
if self.event_stream is not None:
return
def on_connect(client, userdata, flags, rc):
self.connected = True
self.initializing = False
logger.info(f"MQTT {id(client)} connected")
client.subscribe([
(f"u/{self.arlo.user_id}/in/userSession/connect", 0),
(f"u/{self.arlo.user_id}/in/userSession/disconnect", 0),
])
def on_disconnect(client, *args, **kwargs):
logger.info(f"MQTT {id(client)} disconnected")
def on_message(client, userdata, msg):
payload = msg.payload.decode()
logger.debug(f"Received event: {payload}")
try:
response = json.loads(payload.strip())
except json.JSONDecodeError:
return
if response.get('resource') is not None:
self.event_loop.call_soon_threadsafe(self._queue_response, response)
self.event_stream = mqtt.Client(client_id=f"user_{self.arlo.user_id}_{self._gen_client_number()}", transport="websockets", clean_session=False)
self.event_stream.username_pw_set(self.arlo.user_id, password=self.arlo.request.session.headers.get('Authorization'))
self.event_stream.ws_set_options(path="/mqtt", headers={"Origin": "https://my.arlo.com"})
self.event_stream.on_connect = on_connect
self.event_stream.on_disconnect = on_disconnect
self.event_stream.on_message = on_message
self.event_stream.tls_set()
self.event_stream.connect_async("mqtt-cluster.arloxcld.com", port=443)
self.event_stream.loop_start()
while not self.connected and not self.event_stream_stop_event.is_set():
await asyncio.sleep(0.5)
if not self.event_stream_stop_event.is_set():
self.resubscribe()
async def restart(self):
self.reconnecting = True
self.connected = False
self.event_stream.disconnect()
self.event_stream = None
await self.start()
# give it an extra sleep to ensure any previous connections have disconnected properly
# this is so we can mark reconnecting to False properly
await asyncio.sleep(1)
self.reconnecting = False
def subscribe(self, topics):
if topics:
new_subscriptions = [(topic, 0) for topic in topics]
self.event_stream.subscribe(new_subscriptions)
self.cached_topics.extend(new_subscriptions)
def resubscribe(self):
if self.cached_topics:
self.event_stream.subscribe(self.cached_topics)
def disconnect(self):
super().disconnect()
self.event_stream.disconnect()

View File

@@ -1,114 +0,0 @@
##
# Copyright 2016 Jeffrey D. Walter
#
# 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
#
# http://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.
##
from functools import partialmethod
import requests
from requests.exceptions import HTTPError
from requests_toolbelt.adapters import host_header_ssl
import cloudscraper
from curl_cffi import requests as curl_cffi_requests
import time
import uuid
from .logging import logger
#from requests_toolbelt.utils import dump
#def print_raw_http(response):
# data = dump.dump_all(response, request_prefix=b'', response_prefix=b'')
# print('\n' * 2 + data.decode('utf-8'))
class Request(object):
"""HTTP helper class"""
def __init__(self, timeout=5, mode="curl"):
if mode == "curl":
logger.debug("HTTP helper using curl_cffi")
self.session = curl_cffi_requests.Session(impersonate="chrome110")
elif mode == "cloudscraper":
logger.debug("HTTP helper using cloudscraper")
from .arlo_async import USER_AGENTS
self.session = cloudscraper.CloudScraper(browser={"custom": USER_AGENTS["android"]})
elif mode == "ip":
logger.debug("HTTP helper using requests with HostHeaderSSLAdapter")
self.session = requests.Session()
self.session.mount('https://', host_header_ssl.HostHeaderSSLAdapter())
else:
logger.debug("HTTP helper using requests")
self.session = requests.Session()
self.timeout = timeout
def gen_event_id(self):
return f'FE!{str(uuid.uuid4())}'
def get_time(self):
return int(time.time_ns() / 1_000_000)
def _request(self, url, method='GET', params={}, headers={}, raw=False, skip_event_id=False):
## uncomment for debug logging
"""
import logging
import http.client
http.client.HTTPConnection.debuglevel = 1
#logging.basicConfig()
logging.getLogger().setLevel(logging.DEBUG)
req_log = logging.getLogger('requests.packages.urllib3')
req_log.setLevel(logging.DEBUG)
req_log.propagate = True
#"""
if not skip_event_id:
url = f'{url}?eventId={self.gen_event_id()}&time={self.get_time()}'
if method == 'GET':
#print('COOKIES: ', self.session.cookies.get_dict())
r = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
r.raise_for_status()
elif method == 'PUT':
r = self.session.put(url, json=params, headers=headers, timeout=self.timeout)
r.raise_for_status()
elif method == 'POST':
r = self.session.post(url, json=params, headers=headers, timeout=self.timeout)
r.raise_for_status()
elif method == 'OPTIONS':
r = self.session.options(url, headers=headers, timeout=self.timeout)
r.raise_for_status()
return
body = r.json()
if raw:
return body
else:
if ('success' in body and body['success'] == True) or ('meta' in body and body['meta']['code'] == 200):
if 'data' in body:
return body['data']
else:
raise HTTPError('Request ({0} {1}) failed: {2}'.format(method, url, r.json()), response=r)
def get(self, url, **kwargs):
return self._request(url, 'GET', **kwargs)
def put(self, url, **kwargs):
return self._request(url, 'PUT', **kwargs)
def post(self, url, **kwargs):
return self._request(url, 'POST', **kwargs)
def options(self, url, **kwargs):
return self._request(url, 'OPTIONS', **kwargs)

View File

@@ -1,82 +0,0 @@
import asyncio
import json
import threading
import scrypted_arlo_go
from .stream_async import Stream
from .logging import logger
class EventStream(Stream):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.shutting_down_stream = None # record the eventstream that is currently shutting down
async def start(self):
if self.event_stream is not None:
return
def thread_main(self):
event_stream = self.event_stream
while True:
try:
event = event_stream.Next()
except:
logger.info(f"SSE {event_stream.UUID} exited")
if self.shutting_down_stream is event_stream:
self.shutting_down_stream = None
return None
logger.debug(f"Received event: {event}")
if event.strip() == "":
continue
try:
response = json.loads(event.strip())
except json.JSONDecodeError:
continue
if response.get('action') == 'logout':
if self.event_stream_stop_event.is_set() or \
self.shutting_down_stream is event_stream:
logger.info(f"SSE {event_stream.UUID} disconnected")
self.shutting_down_stream = None
event_stream.Close()
return None
elif response.get('status') == 'connected':
if not self.connected:
logger.info(f"SSE {event_stream.UUID} connected")
self.initializing = False
self.connected = True
else:
self.event_loop.call_soon_threadsafe(self._queue_response, response)
self.event_stream = scrypted_arlo_go.NewSSEClient(
'https://myapi.arlo.com/hmsweb/client/subscribe?token='+self.arlo.request.session.headers.get('Authorization'),
scrypted_arlo_go.HeadersMap(self.arlo.request.session.headers)
)
self.event_stream.Start()
self.event_stream_thread = threading.Thread(name="EventStream", target=thread_main, args=(self, ))
self.event_stream_thread.setDaemon(True)
self.event_stream_thread.start()
while not self.connected and not self.event_stream_stop_event.is_set():
await asyncio.sleep(0.5)
async def restart(self):
self.reconnecting = True
self.connected = False
self.shutting_down_stream = self.event_stream
self.shutting_down_stream.Close()
self.event_stream = None
await self.start()
while self.shutting_down_stream is not None:
# ensure any previous connections have disconnected properly
# this is so we can mark reconnecting to False properly
await asyncio.sleep(1)
self.reconnecting = False
def subscribe(self, topics):
pass

View File

@@ -1,238 +0,0 @@
# This file has been modified to support async semantics and better
# integration with scrypted.
# Original: https://github.com/jeffreydwalter/arlo
##
# Copyright 2016 Jeffrey D. Walter
#
# 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
#
# http://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.
##
import asyncio
import random
import threading
import time
import uuid
from .logging import logger
class Stream:
"""This class provides a queue-based EventStream object."""
def __init__(self, arlo, expire=5):
self.event_stream = None
self.initializing = True
self.connected = False
self.reconnecting = False
self.queues = {}
self.expire = expire
self.refresh = 0
self.refresh_loop_signal = asyncio.Queue()
self.event_stream_stop_event = threading.Event()
self.event_stream_thread = None
self.arlo = arlo
self.event_loop = asyncio.get_event_loop()
self.event_loop.create_task(self._clean_queues())
self.event_loop.create_task(self._refresh_interval())
def __del__(self):
self.disconnect()
@property
def active(self):
"""Represents if this stream is connected or in the process of reconnecting."""
return self.connected or self.reconnecting
async def _refresh_interval(self):
while not self.event_stream_stop_event.is_set():
if self.refresh == 0:
# to avoid spinning, wait until an interval is set
signal = await self.refresh_loop_signal.get()
if signal is None:
# exit signal received from disconnect()
return
continue
interval = self.refresh * 60 # interval in seconds from refresh in minutes
signal_task = asyncio.create_task(self.refresh_loop_signal.get())
# wait until either we receive a signal or the refresh interval expires
done, pending = await asyncio.wait([signal_task, asyncio.sleep(interval)], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
done_task = done.pop()
if done_task is signal_task and done_task.result() is None:
# exit signal received from disconnect()
return
logger.info("Refreshing event stream")
await self.restart()
def set_refresh_interval(self, interval):
self.refresh = interval
self.refresh_loop_signal.put_nowait(object())
async def _clean_queues(self):
interval = self.expire * 4
await asyncio.sleep(interval)
while not self.event_stream_stop_event.is_set():
# since we interrupt the cleanup loop after every queue, there's
# a chance the self.queues dict is modified during iteration.
# so, we first make a copy of all the items of the dict and any
# new queues will be processed on the next loop through
queue_items = [i for i in self.queues.items()]
for key, q in queue_items:
if q.empty():
continue
items = []
num_dropped = 0
while not q.empty():
item = q.get_nowait()
q.task_done()
if not item:
# exit signal received
return
if item.expired:
num_dropped += 1
continue
items.append(item)
for item in items:
q.put_nowait(item)
if num_dropped > 0:
logger.debug(f"Cleaned {num_dropped} events from queue {key}")
# cleanup is not urgent, so give other tasks a chance
await asyncio.sleep(0.1)
await asyncio.sleep(interval)
async def get(self, resource, action, property=None, skip_uuids={}):
if not property:
key = f"{resource}/{action}"
else:
key = f"{resource}/{action}/{property}"
if key not in self.queues:
q = self.queues[key] = asyncio.Queue()
else:
q = self.queues[key]
first_requeued = None
while True:
event = await q.get()
q.task_done()
if not event:
# exit signal received
return None, action
if first_requeued is not None and first_requeued is event:
# if we reach here, we've cycled through the whole queue
# and found nothing for us, so sleep and give the next
# subscriber a chance
q.put_nowait(event)
await asyncio.sleep(random.uniform(0, 0.01))
continue
if event.expired:
continue
elif event.uuid in skip_uuids:
q.put_nowait(event)
if first_requeued is None:
first_requeued = event
else:
return event, action
async def start(self):
raise NotImplementedError()
async def restart(self):
raise NotImplementedError()
def subscribe(self, topics):
raise NotImplementedError()
def _queue_response(self, response):
resource = response.get('resource')
action = response.get('action')
key = f"{resource}/{action}"
now = time.time()
event = StreamEvent(response, now, now + self.expire)
self._queue_impl(key, event)
# specialized setup for error responses
if 'error' in response:
key = f"{resource}/error"
self._queue_impl(key, event)
# for optimized lookups, notify listeners of individual properties
properties = response.get('properties', {})
for property in properties.keys():
key = f"{resource}/{action}/{property}"
self._queue_impl(key, event)
def _queue_impl(self, key, event):
if key not in self.queues:
q = self.queues[key] = asyncio.Queue()
else:
q = self.queues[key]
q.put_nowait(event)
def requeue(self, event, resource, action, property=None):
if not property:
key = f"{resource}/{action}"
else:
key = f"{resource}/{action}/{property}"
self.queues[key].put_nowait(event)
def disconnect(self):
if self.reconnecting:
# disconnect may be called when an old stream is being refreshed/restarted,
# so don't completely shut down if we are reconnecting
return
self.connected = False
def exit_queues(self):
for q in self.queues.values():
q.put_nowait(None)
self.refresh_loop_signal.put_nowait(None)
self.event_loop.call_soon_threadsafe(exit_queues, self)
self.event_stream_stop_event.set()
class StreamEvent:
item = None
timestamp = None
expiration = None
uuid = None
def __init__(self, item, timestamp, expiration):
self.item = item
self.timestamp = timestamp
self.expiration = expiration
self.uuid = str(uuid.uuid4())
@property
def expired(self):
return time.time() > self.expiration

View File

@@ -1,79 +0,0 @@
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
arlo_capabilities: 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())
try:
self.arlo_capabilities = self.provider.arlo.GetDeviceCapabilities(self.arlo_device)
except Exception as e:
self.logger.warning(f"Could not load device capabilities: {e}")
self.arlo_capabilities = {}
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"]
if parent in self.provider.hidden_device_ids:
parent = None
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 []

View File

@@ -1,90 +0,0 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk import ScryptedDeviceBase
from scrypted_sdk.types import Device, DeviceProvider, Setting, SettingValue, Settings, 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, Settings):
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,
ScryptedInterface.Settings.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
async def getSettings(self) -> List[Setting]:
return [
{
"group": "General",
"key": "print_debug",
"title": "Debug Info",
"description": "Prints information about this device to console.",
"type": "button",
}
]
async def putSetting(self, key: str, value: SettingValue) -> None:
if key == "print_debug":
self.logger.info(f"Device Capabilities: {self.arlo_capabilities}")
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)

File diff suppressed because it is too large Load Diff

View File

@@ -1,93 +0,0 @@
import multiprocessing
import subprocess
import time
import threading
import scrypted_arlo_go
HEARTBEAT_INTERVAL = 5
def multiprocess_main(name, logger_port, child_conn, exe, args):
logger = scrypted_arlo_go.NewTCPLogger(logger_port, "HeartbeatChildProcess")
logger.Send(f"{name} starting\n")
sp = subprocess.Popen([exe, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# pull stdout and stderr from the subprocess and forward it over to
# our tcp logger
def logging_thread(stdstream):
while True:
line = stdstream.readline()
if not line:
break
line = str(line, 'utf-8')
logger.Send(line)
stdout_t = threading.Thread(target=logging_thread, args=(sp.stdout,))
stderr_t = threading.Thread(target=logging_thread, args=(sp.stderr,))
stdout_t.start()
stderr_t.start()
while True:
has_data = child_conn.poll(HEARTBEAT_INTERVAL * 3)
if not has_data:
break
# check if the subprocess is still alive, if not then exit
if sp.poll() is not None:
break
keep_alive = child_conn.recv()
if not keep_alive:
break
logger.Send(f"{name} exiting\n")
sp.terminate()
sp.wait()
stdout_t.join()
stderr_t.join()
logger.Send(f"{name} exited\n")
logger.Close()
class HeartbeatChildProcess:
"""Class to manage running a child process that gets cleaned up if the parent exits.
When spawining subprocesses in Python, if the parent is forcibly killed (as is the case
when Scrypted restarts plugins), subprocesses get orphaned. This approach uses parent-child
heartbeats for the child to ensure that the parent process is still alive, and to cleanly
exit the child if the parent has terminated.
"""
def __init__(self, name, logger_port, exe, *args):
self.name = name
self.logger_port = logger_port
self.exe = exe
self.args = args
self.parent_conn, self.child_conn = multiprocessing.Pipe()
self.process = multiprocessing.Process(target=multiprocess_main, args=(name, logger_port, self.child_conn, exe, args))
self.process.daemon = True
self._stop = False
self.thread = threading.Thread(target=self.heartbeat)
def start(self):
self.process.start()
self.thread.start()
def stop(self):
self._stop = True
self.parent_conn.send(False)
def heartbeat(self):
while not self._stop:
time.sleep(HEARTBEAT_INTERVAL)
if not self.process.is_alive():
self.stop()
break
self.parent_conn.send(True)

View File

@@ -1,34 +0,0 @@
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, 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) -> 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_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)
return camera_interfaces

View File

@@ -1,3 +0,0 @@
import os
EXPERIMENTAL = os.environ.get("SCRYPTED_ARLO_EXPERIMENTAL", "0") not in ["", "0"]

View File

@@ -1,43 +0,0 @@
import logging
class ScryptedDeviceLoggingWrapper(logging.Handler):
scrypted_device = None
def __init__(self, scrypted_device):
super().__init__()
self.scrypted_device = scrypted_device
def emit(self, record):
self.scrypted_device.print(self.format(record))
def createScryptedLogger(scrypted_device, name):
logger = logging.getLogger(name)
if logger.hasHandlers():
return logger
logger.setLevel(logging.INFO)
# configure logger to output to scrypted's log stream
sh = ScryptedDeviceLoggingWrapper(scrypted_device)
# log formatting
fmt = logging.Formatter("[Arlo %(name)s]: %(message)s")
sh.setFormatter(fmt)
# configure handler to logger
logger.addHandler(sh)
return logger
class ScryptedDeviceLoggerMixin:
_logger = None
logger_name = None
@property
def logger(self):
if self._logger is None:
self._logger = createScryptedLogger(self, self.logger_name)
return self._logger

View File

@@ -1,814 +0,0 @@
import asyncio
from bs4 import BeautifulSoup
import email
import functools
import imaplib
import json
import logging
import re
import requests
from typing import List
import scrypted_sdk
from scrypted_sdk import ScryptedDeviceBase
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, async_print_exception_guard
from .camera import ArloCamera
from .doorbell import ArloDoorbell
from .basestation import ArloBasestation
from .base import ArloDeviceBase
class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
arlo_cameras = None
arlo_basestations = None
all_device_ids: set = set()
_arlo_mfa_code = None
scrypted_devices = None
_arlo: Arlo = None
_arlo_mfa_complete_auth = None
device_discovery_lock: asyncio.Lock = None
plugin_verbosity_choices = {
"Normal": logging.INFO,
"Verbose": logging.DEBUG
}
arlo_transport_choices = ["MQTT", "SSE"]
mfa_strategy_choices = ["Manual", "IMAP"]
def __init__(self, nativeId: str = None) -> None:
super().__init__(nativeId=nativeId)
self.logger_name = "Provider"
self.arlo_cameras = {}
self.arlo_basestations = {}
self.scrypted_devices = {}
self.imap = None
self.imap_signal = None
self.imap_skip_emails = None
self.device_discovery_lock = asyncio.Lock()
self.propagate_verbosity()
self.propagate_transport()
def load(self):
if self.mfa_strategy == "IMAP":
self.initialize_imap()
else:
_ = self.arlo
asyncio.get_event_loop().call_soon(load, self)
self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None))
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) -> str:
return self.storage.getItem("arlo_username")
@property
def arlo_password(self) -> str:
return self.storage.getItem("arlo_password")
@property
def arlo_auth_headers(self) -> str:
return self.storage.getItem("arlo_auth_headers")
@property
def arlo_user_id(self) -> str:
return self.storage.getItem("arlo_user_id")
@property
def arlo_transport(self) -> str:
return "SSE"
# This code is here for posterity, however it looks that as of 06/01/2023
# Arlo has disabled the MQTT backend
transport = self.storage.getItem("arlo_transport")
if transport is None or transport not in ArloProvider.arlo_transport_choices:
transport = "SSE"
self.storage.setItem("arlo_transport", transport)
return transport
@property
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"
self.storage.setItem("plugin_verbosity", verbosity)
return verbosity
@property
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"
self.storage.setItem("mfa_strategy", strategy)
return strategy
@property
def refresh_interval(self) -> int:
interval = self.storage.getItem("refresh_interval")
if interval is None:
interval = 90
self.storage.setItem("refresh_interval", interval)
return int(interval)
@property
def imap_mfa_host(self) -> str:
return self.storage.getItem("imap_mfa_host")
@property
def imap_mfa_port(self) -> int:
port = self.storage.getItem("imap_mfa_port")
if port is None:
port = 993
self.storage.setItem("imap_mfa_port", port)
return int(port)
@property
def imap_mfa_username(self) -> str:
return self.storage.getItem("imap_mfa_username")
@property
def imap_mfa_password(self) -> str:
return self.storage.getItem("imap_mfa_password")
@property
def imap_mfa_sender(self) -> str:
sender = self.storage.getItem("imap_mfa_sender")
if sender is None or sender == "":
sender = "do_not_reply@arlo.com"
self.storage.setItem("imap_mfa_sender", sender)
return sender
@property
def imap_mfa_interval(self) -> int:
interval = self.storage.getItem("imap_mfa_interval")
if interval is None:
interval = 7
self.storage.setItem("imap_mfa_interval", interval)
return int(interval)
@property
def hidden_devices(self) -> List[str]:
hidden = self.storage.getItem("hidden_devices")
if hidden is None:
hidden = []
self.storage.setItem("hidden_devices", hidden)
return hidden
@property
def hidden_device_ids(self) -> List[str]:
ids = []
for id in self.hidden_devices:
m = re.match(r".*\((.*)\)$", id)
if m is not None:
ids.append(m.group(1))
return ids
@property
def arlo(self) -> Arlo:
if self._arlo is not None:
if self._arlo_mfa_complete_auth is not None:
if not self._arlo_mfa_code:
return None
self.logger.info("Completing Arlo MFA...")
try:
self._arlo_mfa_complete_auth(self._arlo_mfa_code)
finally:
self._arlo_mfa_complete_auth = None
self._arlo_mfa_code = None
self.logger.info("Arlo MFA done")
self.storage.setItem("arlo_auth_headers", json.dumps(dict(self._arlo.request.session.headers.items())))
self.storage.setItem("arlo_user_id", self._arlo.user_id)
self.create_task(self.do_arlo_setup())
return self._arlo
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)
headers = self.arlo_auth_headers
if headers:
self._arlo.UseExistingAuth(self.arlo_user_id, json.loads(headers))
self.logger.info(f"Initialized Arlo client, reusing stored auth headers")
self.create_task(self.do_arlo_setup())
return self._arlo
else:
self._arlo_mfa_complete_auth = self._arlo.LoginMFA()
self.logger.info(f"Initialized Arlo client, waiting for MFA code")
return None
except Exception:
self.logger.exception("Error initializing Arlo client")
self._arlo = None
self._arlo_mfa_complete_auth = None
self._arlo_mfa_code = None
raise
async def do_arlo_setup(self) -> None:
try:
await self.discover_devices()
await self.arlo.Subscribe([
(self.arlo_basestations[camera["parentId"]], camera) for camera in self.arlo_cameras.values()
])
self.arlo.event_stream.set_refresh_interval(self.refresh_interval)
except requests.exceptions.HTTPError:
self.logger.exception("Error logging in")
self.logger.error("Will retry with fresh login")
self._arlo = None
self._arlo_mfa_code = None
self.storage.setItem("arlo_auth_headers", None)
_ = self.arlo
except Exception:
self.logger.exception("Error logging in")
def invalidate_arlo_client(self) -> None:
if self._arlo is not None:
self._arlo.Unsubscribe()
self._arlo = None
self._arlo_mfa_code = None
self._arlo_mfa_complete_auth = None
self.storage.setItem("arlo_auth_headers", "")
self.storage.setItem("arlo_user_id", "")
def get_current_log_level(self) -> int:
return ArloProvider.plugin_verbosity_choices[self.plugin_verbosity]
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)
for _, device in self.scrypted_devices.items():
device.logger.setLevel(log_level)
arlo_lib_logger.setLevel(log_level)
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, try_count=1) -> 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:
return
self.exit_imap()
try:
self.logger.info(f"Trying connect to IMAP (attempt {try_count})")
self.imap = imaplib.IMAP4_SSL(self.imap_mfa_host, port=self.imap_mfa_port)
res, _ = self.imap.login(self.imap_mfa_username, self.imap_mfa_password)
if res.lower() != "ok":
raise Exception(f"IMAP login failed: {res}")
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":
raise Exception(f"IMAP failed to fetch old Arlo emails: {res}")
except Exception:
self.logger.exception("IMAP initialization error")
if try_count >= 10:
self.logger.error("Tried to connect to IMAP too many times. Will request a plugin restart.")
self.create_task(scrypted_sdk.deviceManager.requestRestart())
asyncio.get_event_loop().call_later(try_count*try_count, functools.partial(self.initialize_imap, try_count=try_count+1))
else:
self.logger.info("Connected to IMAP")
self.imap_signal = asyncio.Queue()
self.create_task(self.imap_relogin_loop())
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) -> None:
imap_signal = self.imap_signal
self.logger.info(f"Starting IMAP refresh loop {id(imap_signal)}")
while True:
self.logger.info("Performing IMAP login flow")
# save old client and details in case of error
old_arlo = self._arlo
old_headers = self.storage.getItem("arlo_auth_headers")
old_user_id = self.storage.getItem("arlo_user_id")
# clear everything
self._arlo = None
self._arlo_mfa_code = None
self._arlo_mfa_complete_auth = None
self.storage.setItem("arlo_auth_headers", "")
self.storage.setItem("arlo_user_id", "")
# initialize login and prompt for MFA
try:
_ = self.arlo
except Exception:
self.logger.exception("Unrecoverable login error")
self.logger.error("Will request a plugin restart")
await scrypted_sdk.deviceManager.requestRestart()
return
# do imap lookup
# adapted from https://github.com/twrecked/pyaarlo/blob/77c202b6f789c7104a024f855a12a3df4fc8df38/pyaarlo/tfa.py
try:
try_count = 0
while True:
try_count += 1
sleep_duration = 1
if try_count > 5:
sleep_duration = 2
elif try_count > 10:
sleep_duration = 5
elif try_count > 20:
sleep_duration = 10
self.logger.info(f"Checking IMAP for MFA codes (attempt {try_count})")
self.imap.check()
res, emails = self.imap.search(None, "FROM", self.imap_mfa_sender)
if res.lower() != "ok":
raise Exception("IMAP error: {res}")
if emails == self.imap_skip_emails:
self.logger.info("No new emails found, will sleep and retry")
await asyncio.sleep(sleep_duration)
continue
skip_emails = self.imap_skip_emails[0].split()
def search_email(msg_id):
if msg_id in skip_emails:
return None
res, msg = self.imap.fetch(msg_id, "(BODY.PEEK[])")
if res.lower() != "ok":
raise Exception("IMAP error: {res}")
if isinstance(msg[0][1], bytes):
for part in email.message_from_bytes(msg[0][1]).walk():
if part.get_content_type() != "text/html":
continue
try:
soup = BeautifulSoup(part.get_payload(decode=True), 'html.parser')
for line in soup.get_text().splitlines():
code = re.match(r"^\W*(\d{6})\W*$", line)
if code is not None:
return code.group(1)
except:
continue
return None
for msg_id in emails[0].split():
res = search_email(msg_id)
if res is not None:
self._arlo_mfa_code = res
break
# update previously seen emails list
self.imap_skip_emails = emails
if self._arlo_mfa_code is not None:
self.logger.info("Found MFA code")
break
self.logger.info("No MFA code found, will sleep and retry")
await asyncio.sleep(sleep_duration)
except Exception:
self.logger.exception("Error while checking for MFA codes")
self._arlo = old_arlo
self.storage.setItem("arlo_auth_headers", old_headers)
self.storage.setItem("arlo_user_id", old_user_id)
self._arlo_mfa_code = None
self._arlo_mfa_complete_auth = None
self.logger.error("Will reload IMAP connection")
asyncio.get_event_loop().call_soon(self.initialize_imap)
else:
# finish login
if old_arlo:
old_arlo.Unsubscribe()
try:
_ = self.arlo
except Exception:
self.logger.exception("Unrecoverable login error")
self.logger.error("Will request a plugin restart")
await scrypted_sdk.deviceManager.requestRestart()
return
# continue by sleeping/waiting for a signal
interval = self.imap_mfa_interval * 24 * 60 * 60 # convert interval days to seconds
signal_task = asyncio.create_task(imap_signal.get())
# wait until either we receive a signal or the refresh interval expires
done, pending = await asyncio.wait([signal_task, asyncio.sleep(interval)], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
done_task = done.pop()
if done_task is signal_task and done_task.result() is None:
# exit signal received
self.logger.info(f"Exiting IMAP refresh loop {id(imap_signal)}")
return
async def getSettings(self) -> List[Setting]:
results = [
{
"group": "General",
"key": "arlo_username",
"title": "Arlo Username",
"value": self.arlo_username,
},
{
"group": "General",
"key": "arlo_password",
"title": "Arlo Password",
"type": "password",
"value": self.arlo_password,
},
{
"group": "General",
"key": "mfa_strategy",
"title": "Two Factor Strategy",
"description": "Mechanism to fetch the two factor code for Arlo login. Save after changing this field for more settings.",
"value": self.mfa_strategy,
"choices": self.mfa_strategy_choices,
},
]
if self.mfa_strategy == "Manual":
results.extend([
{
"group": "General",
"key": "arlo_mfa_code",
"title": "Two Factor Code",
"description": "Enter the code sent by Arlo to your email or phone number.",
},
{
"group": "General",
"key": "force_reauth",
"title": "Force Re-Authentication",
"description": "Resets the authentication flow of the plugin. Will also re-do 2FA.",
"value": False,
"type": "boolean",
},
])
else:
results.extend([
{
"group": "IMAP 2FA",
"key": "imap_mfa_host",
"title": "IMAP Hostname",
"value": self.imap_mfa_host,
},
{
"group": "IMAP 2FA",
"key": "imap_mfa_port",
"title": "IMAP Port",
"value": self.imap_mfa_port,
},
{
"group": "IMAP 2FA",
"key": "imap_mfa_username",
"title": "IMAP Username",
"value": self.imap_mfa_username,
},
{
"group": "IMAP 2FA",
"key": "imap_mfa_password",
"title": "IMAP Password",
"type": "password",
"value": self.imap_mfa_password,
},
{
"group": "IMAP 2FA",
"key": "imap_mfa_sender",
"title": "IMAP Email Sender",
"value": self.imap_mfa_sender,
"description": "The sender email address to search for when loading 2FA codes. See plugin README for more details.",
},
{
"group": "IMAP 2FA",
"key": "imap_mfa_interval",
"title": "Refresh Login Interval",
"description": "Interval, in days, to refresh the login session to Arlo Cloud. "
"Must be a value greater than 0.",
"type": "number",
"value": self.imap_mfa_interval,
}
])
results.extend([
{
"group": "General",
"key": "arlo_transport",
"title": "Underlying Transport Protocol",
"description": "Arlo Cloud currently only supports the SSE protocol.",
"value": self.arlo_transport,
"readonly": True,
},
{
"group": "General",
"key": "refresh_interval",
"title": "Refresh Event Stream Interval",
"description": "Interval, in minutes, to refresh the underlying event stream connection to Arlo Cloud. "
"A value of 0 disables this feature.",
"type": "number",
"value": self.refresh_interval,
},
{
"group": "General",
"key": "plugin_verbosity",
"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",
},
{
"group": "General",
"key": "hidden_devices",
"title": "Hidden Devices",
"description": "Select the Arlo devices to hide in this plugin. Hidden devices will be removed from Scrypted and will "
"not be re-added when the plugin reloads.",
"value": self.hidden_devices,
"multiple": True,
"choices": [id for id in self.all_device_ids],
},
])
return results
@async_print_exception_guard
async def putSetting(self, key: str, value: SettingValue) -> None:
if not self.validate_setting(key, value):
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
return
skip_arlo_client = False
if key == "arlo_mfa_code":
self._arlo_mfa_code = value
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" or value == True else "Normal")
self.propagate_verbosity()
skip_arlo_client = True
else:
self.storage.setItem(key, value)
if key == "arlo_transport":
self.propagate_transport()
# force arlo client to be invalidated and reloaded, but
# keep any mfa codes
if self._arlo is not None:
self._arlo.Unsubscribe()
self._arlo = None
elif key == "mfa_strategy":
if value == "IMAP":
self.initialize_imap()
else:
self.exit_imap()
skip_arlo_client = True
elif key == "refresh_interval":
if self._arlo is not None and self._arlo.event_stream:
self._arlo.event_stream.set_refresh_interval(self.refresh_interval)
skip_arlo_client = True
elif key.startswith("imap_mfa"):
self.initialize_imap()
skip_arlo_client = True
elif key == "hidden_devices":
if self._arlo is not None and self._arlo.logged_in:
self._arlo.Unsubscribe()
await self.do_arlo_setup()
skip_arlo_client = True
else:
# force arlo client to be invalidated and reloaded
self.invalidate_arlo_client()
if not skip_arlo_client:
# initialize Arlo client or continue MFA
_ = self.arlo
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
def validate_setting(self, key: str, val: SettingValue) -> bool:
if key == "refresh_interval":
try:
val = int(val)
except ValueError:
self.logger.error(f"Invalid refresh interval '{val}' - must be an integer")
return False
if val < 0:
self.logger.error(f"Invalid refresh interval '{val}' - must be nonnegative")
return False
elif key == "imap_mfa_port":
try:
val = int(val)
except ValueError:
self.logger.error(f"Invalid IMAP port '{val}' - must be an integer")
return False
if val < 0:
self.logger.error(f"Invalid IMAP port '{val}' - must be nonnegative")
return False
elif key == "imap_mfa_interval":
try:
val = int(val)
except ValueError:
self.logger.error(f"Invalid IMAP interval '{val}' - must be an integer")
return False
if val < 1:
self.logger.error(f"Invalid IMAP interval '{val}' - must be positive")
return False
return True
@async_print_exception_guard
async def discover_devices(self) -> None:
async with self.device_discovery_lock:
return await self.discover_devices_impl()
async def discover_devices_impl(self) -> None:
if not self._arlo or not self._arlo.logged_in:
raise Exception("Arlo client not connected, cannot discover devices")
self.logger.info("Discovering devices...")
self.arlo_cameras = {}
self.arlo_basestations = {}
self.all_device_ids = set()
self.scrypted_devices = {}
camera_devices = []
provider_to_device_map = {None: []}
basestations = self.arlo.GetDevices(['basestation', 'siren'])
for basestation in basestations:
nativeId = basestation["deviceId"]
self.all_device_ids.add(f"{basestation['deviceName']} ({nativeId})")
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
if nativeId in self.hidden_device_ids:
self.logger.info(f"Skipping manifest for basestation {nativeId} ({basestation['modelId']}) as it is hidden")
continue
device = await self.getDevice_impl(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(self.arlo_basestations)} basestations")
cameras = self.arlo.GetDevices(['camera', "arloq", "arloqs", "doorbell"])
for camera in cameras:
nativeId = camera["deviceId"]
self.all_device_ids.add(f"{camera['deviceName']} ({nativeId})")
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']} ({camera['modelId']}) because its basestation was not found")
continue
if nativeId in self.arlo_cameras:
self.logger.info(f"Skipping camera {nativeId} ({camera['modelId']}) as it has already been added")
continue
if nativeId in self.hidden_device_ids:
self.logger.info(f"Skipping camera {camera['deviceId']} ({camera['modelId']}) because it is hidden")
continue
self.arlo_cameras[nativeId] = camera
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 = await self.getDevice_impl(nativeId)
scrypted_interfaces = device.get_applicable_interfaces()
manifest = device.get_device_manifest()
self.logger.debug(f"Interfaces for {nativeId} ({camera['modelId']} parent {camera['parentId']}): {scrypted_interfaces}")
if camera["deviceId"] == camera["parentId"] or camera["parentId"] in self.hidden_device_ids:
provider_to_device_map.setdefault(None, []).append(manifest)
else:
provider_to_device_map.setdefault(camera["parentId"], []).append(manifest)
# trickle discover this camera so it exists for later steps
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)
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")
self.logger.info("This could be because some cameras are hidden.")
self.logger.info("If a camera is not hidden but is still missing, ensure all cameras shared with "
"admin permissions in the Arlo app.")
else:
self.logger.info(f"Discovered {len(cameras)} cameras")
for provider_id in provider_to_device_map.keys():
if provider_id is None:
continue
if len(provider_to_device_map[provider_id]) > 0:
self.logger.debug(f"Sending {provider_id} and children to scrypted server")
else:
self.logger.debug(f"Sending {provider_id} to scrypted server")
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
self.logger.debug("Sending top level devices to scrypted server")
await scrypted_sdk.deviceManager.onDevicesChanged({
"devices": provider_to_device_map[None]
})
self.logger.debug("Done discovering devices")
# force a settings refresh so the hidden devices list can be updated
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
self.logger.debug(f"Scrypted requested to load device {nativeId}")
async with self.device_discovery_lock:
return await self.getDevice_impl(nativeId)
async def getDevice_impl(self, nativeId: str) -> ArloDeviceBase:
ret = self.scrypted_devices.get(nativeId)
if ret is None:
ret = self.create_device(nativeId)
if ret is not None:
self.scrypted_devices[nativeId] = ret
return ret
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_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_device["parentId"]]
if arlo_device["deviceType"] == "doorbell":
return ArloDoorbell(nativeId, arlo_device, arlo_basestation, self)
else:
return ArloCamera(nativeId, arlo_device, arlo_basestation, self)

View File

@@ -1,107 +0,0 @@
from aiortc import RTCPeerConnection
from aiortc.contrib.media import MediaPlayer
import asyncio
import threading
import queue
class BackgroundRTCPeerConnection:
"""Proxy class to use RTCPeerConnection in a background thread.
The purpose of this proxy is to ensure that RTCPeerConnection operations
do not block the main asyncio thread. From testing, it seems that the
close() function blocks until the source RTSP server exits, which we
have no control over. Additionally, since asyncio coroutines are tied
to the event loop they were constructed from, it is not possible to only
run close() in a separate thread. Therefore, each instance of RTCPeerConnection
is launched within its own ephemeral thread, which cleans itself up once
close() completes.
"""
def __init__(self, logger):
self.main_loop = asyncio.get_event_loop()
self.background_loop = asyncio.new_event_loop()
self.logger = logger
self.thread_started = queue.Queue(1)
self.thread = threading.Thread(target=self.__background_main)
self.thread.start()
self.thread_started.get()
def __background_main(self):
self.logger.info(f"Background RTC loop {self.thread.name} starting")
self.pc = RTCPeerConnection()
asyncio.set_event_loop(self.background_loop)
self.thread_started.put(True)
self.background_loop.run_forever()
self.logger.info(f"Background RTC loop {self.thread.name} exiting")
async def __run_background(self, coroutine, await_result=True, stop_loop=False):
fut = self.main_loop.create_future()
def background_callback():
# callback to run on main_loop.
def to_main(result, is_error):
if is_error:
fut.set_exception(result)
else:
fut.set_result(result)
# callback to run on background_loop., after the coroutine completes
def callback(task):
is_error = False
if task.exception():
result = task.exception()
is_error = True
else:
result = task.result()
# send results to the main loop
self.main_loop.call_soon_threadsafe(to_main, result, is_error)
# stopping the loop here ensures that the coroutine completed
# and doesn't raise any "task not awaited" exceptions
if stop_loop:
self.background_loop.stop()
task = self.background_loop.create_task(coroutine)
task.add_done_callback(callback)
# start the callback in the background loop
self.background_loop.call_soon_threadsafe(background_callback)
if not await_result:
return None
return await fut
async def createOffer(self):
return await self.__run_background(self.pc.createOffer())
async def setLocalDescription(self, sdp):
return await self.__run_background(self.pc.setLocalDescription(sdp))
async def setRemoteDescription(self, sdp):
return await self.__run_background(self.pc.setRemoteDescription(sdp))
async def addIceCandidate(self, candidate):
return await self.__run_background(self.pc.addIceCandidate(candidate))
async def close(self):
await self.__run_background(self.pc.close(), await_result=False, stop_loop=True)
def add_rtsp_audio(self, rtsp_url):
"""Adds an audio track to the RTCPeerConnection given a source RTSP url.
This constructs a MediaPlayer in the background thread's asyncio loop,
since MediaPlayer also utilizes coroutines and asyncio.
Note that this may block the background thread's event loop if the RTSP
server is not yet ready.
"""
def add_rtsp_audio_background():
media_player = MediaPlayer(rtsp_url, format="rtsp")
self.pc.addTrack(media_player.audio)
self.background_loop.call_soon_threadsafe(add_rtsp_audio_background)

View File

@@ -1,72 +0,0 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk.types import OnOff, SecuritySystemMode, ScryptedInterface, ScryptedDeviceType
from .base import ArloDeviceBase
from .util import async_print_exception_guard
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
@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,
}
@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,
}

View File

@@ -1,72 +0,0 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk.types import OnOff, ScryptedInterface, ScryptedDeviceType
from .base import ArloDeviceBase
from .util import async_print_exception_guard
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
@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
@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):
@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
@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
class ArloNightlight(ArloSpotlight):
def __init__(self, nativeId: str, arlo_device: dict, provider: ArloProvider, camera: ArloCamera) -> None:
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_device, provider=provider, camera=camera)
@async_print_exception_guard
async def turnOn(self) -> None:
self.logger.info("Turning on")
self.provider.arlo.NightlightOn(self.arlo_device)
self.on = True
@async_print_exception_guard
async def turnOff(self) -> None:
self.logger.info("Turning off")
self.provider.arlo.NightlightOff(self.arlo_device)
self.on = False

View File

@@ -1,44 +0,0 @@
import asyncio
import traceback
class BackgroundTaskMixin:
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) -> None:
if not hasattr(self, "background_tasks"):
self.background_tasks = set()
assert task is not None
def print_exception(task):
if task.exception():
self.logger.error(f"task exception: {task.exception()}")
self.background_tasks.add(task)
task.add_done_callback(print_exception)
task.add_done_callback(self.background_tasks.discard)
def cancel_pending_tasks(self) -> None:
if not hasattr(self, "background_tasks"):
return
for task in self.background_tasks:
task.cancel()
def async_print_exception_guard(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:
# hack to detect if the applied function is actually a method
# on a scrypted object
if len(args) > 0 and hasattr(args[0], "logger"):
getattr(args[0], "logger").exception(f"{fn.__qualname__} raised an exception")
else:
traceback.print_exc()
raise
return wrapped

View File

@@ -1,156 +0,0 @@
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
from .util import async_print_exception_guard
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, Settings, Readme, 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_print_exception_guard
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()
@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()

View File

@@ -1,4 +0,0 @@
from arlo_plugin import ArloProvider
def create_scrypted_plugin():
return ArloProvider()

View File

@@ -1,14 +0,0 @@
paho-mqtt==1.6.1
aiohttp==3.8.4
requests==2.28.2
cachetools==5.3.0
scrypted-arlo-go==0.5.2
cloudscraper==1.2.71
curl-cffi==0.5.7
async-timeout==4.0.2
beautifulsoup4==4.12.2
aiortc==1.5.0
av==9.2.0
--extra-index-url=https://bjia56.github.io/armv7l-wheels/
--extra-index-url=https://bjia56.github.io/scrypted-arlo-go/
--prefer-binary

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2021",
"resolveJsonModule": true,
"moduleResolution": "Node16",
"esModuleInterop": true,
"sourceMap": true
},
"include": [
"src/**/*"
]
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/cloud",
"version": "0.1.40",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/cloud",
"version": "0.1.40",
"version": "0.2.3",
"dependencies": {
"@eneris/push-receiver": "^3.1.4",
"@scrypted/common": "file:../../common",

View File

@@ -54,5 +54,5 @@
"@types/nat-upnp": "^1.1.2",
"@types/node": "^20.4.5"
},
"version": "0.1.40"
"version": "0.2.3"
}

View File

@@ -151,6 +151,14 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
hide: true,
json: true,
},
cloudflareEnabled: {
group: 'Advanced',
title: 'Cloudflare',
type: 'boolean',
description: 'Optional: Create a Cloudflare Tunnel to this server at a random domain name. Providing a Cloudflare token will allow usage of a custom domain name.',
defaultValue: true,
onPut: () => deviceManager.requestRestart(),
},
cloudflaredTunnelToken: {
group: 'Advanced',
title: 'Cloudflare Tunnel Token',
@@ -159,11 +167,20 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
this.cloudflared?.child.kill();
},
},
cloudflaredTunnelUrl: {
group: 'Advanced',
title: 'Cloudflare Tunnel URL',
description: 'Cloudflare Tunnel URL is a randomized cloud connection, unless a Cloudflare Tunnel Token is provided.',
readonly: true,
mapGet: () => this.cloudflareTunnel || 'Unavailable',
},
register: {
group: 'Advanced',
title: 'Register',
type: 'button',
onPut: () => this.manager.registrationId.then(r => this.sendRegistrationId(r)),
onPut: () => {
this.manager.registrationId.then(r => this.sendRegistrationId(r))
},
description: 'Register server with Scrypted Cloud.',
},
testPortForward: {
@@ -173,6 +190,14 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
onPut: () => this.testPortForward(),
description: 'Test the port forward connection from Scrypted Cloud.',
},
additionalCorsOrigins: {
title: "Additional CORS Origins",
description: "Debugging purposes only. DO NOT EDIT.",
group: 'CORS',
multiple: true,
combobox: true,
defaultValue: [],
}
});
upnpInterval: NodeJS.Timeout;
upnpClient = upnp.createClient();
@@ -181,6 +206,12 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
randomBytes = crypto.randomBytes(16).toString('base64');
reverseConnections = new Set<Duplex>();
get cloudflareTunnelHost() {
if (!this.cloudflareTunnel)
return;
return new URL(this.cloudflareTunnel).host;
}
constructor() {
super();
@@ -237,6 +268,13 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
// }
// };
this.storageSettings.settings.cloudflaredTunnelToken.onGet =
this.storageSettings.settings.cloudflaredTunnelUrl.onGet = async () => {
return {
hide: !this.storageSettings.values.cloudflareEnabled,
}
};
this.log.clearAlerts();
this.storageSettings.settings.securePort.onPut = (ov, nv) => {
@@ -323,11 +361,14 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
ip = this.storageSettings.values.duckDnsHostname;
}
else if (this.cloudflareTunnelHost) {
ip = this.cloudflareTunnelHost;
}
else {
ip = (await axios(`https://${SCRYPTED_SERVER}/_punch/ip`)).data.ip;
}
if (this.storageSettings.values.forwardingMode === 'Custom Domain')
if (this.storageSettings.values.forwardingMode === 'Custom Domain' || this.cloudflareTunnelHost)
upnpPort = 443;
this.console.log(`Scrypted Cloud mapped https://${ip}:${upnpPort} to https://127.0.0.1:${this.securePort}`);
@@ -338,7 +379,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
const registrationId = await this.manager.registrationId;
const data = await this.sendRegistrationId(registrationId);
if (ip !== 'localhost' && ip !== data.ip_address) {
if (ip !== 'localhost' && ip !== data.ip_address && ip !== this.cloudflareTunnelHost) {
this.log.a(`Scrypted Cloud could not verify the IP Address of your custom domain ${this.storageSettings.values.hostname}.`);
}
this.storageSettings.values.lastPersistedIp = ip;
@@ -347,6 +388,9 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
async testPortForward() {
try {
if (this.storageSettings.values.forwardingMode === 'Disabled')
throw new Error('Port forwarding is disabled.');
const pluginPath = await endpointManager.getPath(undefined, {
public: true,
});
@@ -371,15 +415,16 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
async refreshPortForward() {
if (this.storageSettings.values.forwardingMode === 'Disabled') {
this.updatePortForward(0);
return;
}
let { upnpPort } = this.storageSettings.values;
if (!upnpPort)
upnpPort = Math.round(Math.random() * 30000 + 20000);
if (this.storageSettings.values.forwardingMode === 'Disabled') {
this.updatePortForward(upnpPort);
return;
}
if (upnpPort === 443) {
this.upnpStatus = 'Error: Port 443 Not Allowed';
const err = 'Scrypted Cloud does not allow usage of port 443. Use a custom domain with a SSL terminating reverse proxy.';
@@ -393,7 +438,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
return this.updatePortForward(upnpPort);
if (this.storageSettings.values.forwardingMode === 'Custom Domain')
return this.updatePortForward(upnpPort);
return this.updatePortForward(this.storageSettings.values.upnpPort);
const [localAddress] = await endpointManager.getLocalAddresses() || [];
if (!localAddress) {
@@ -474,6 +519,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
`https://${SCRYPTED_SERVER}`,
// chromecast receiver. move this into google home and chromecast plugins?
'https://koush.github.io',
...this.storageSettings.values.additionalCorsOrigins,
],
});
}
@@ -482,6 +528,19 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
}
async updateExternalAddresses() {
const addresses = await systemManager.getComponent('addresses');
const cloudAddresses: string[] = [];
if (this.storageSettings.values.hostname)
cloudAddresses.push(`https://${this.storageSettings.values.hostname}`);
if (this.cloudflareTunnel)
cloudAddresses.push(this.cloudflareTunnel);
await addresses.setExternalAddresses('@scrypted/cloud', cloudAddresses);
await this.updatePortForward(this.storageSettings.values.upnpPort);
}
getAuthority() {
const upnp_port = this.storageSettings.values.forwardingMode === 'Custom Domain' ? 443 : this.storageSettings.values.upnpPort;
const hostname = this.storageSettings.values.forwardingMode === 'Custom Domain'
@@ -574,6 +633,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
getSSLHostname() {
const validDomain = (this.storageSettings.values.forwardingMode === 'Custom Domain' && this.storageSettings.values.hostname)
|| (this.storageSettings.values.cloudflaredTunnelToken && this.cloudflareTunnelHost)
|| (this.storageSettings.values.duckDnsCertValid && this.storageSettings.values.duckDnsHostname && this.storageSettings.values.upnpPort && `${this.storageSettings.values.duckDnsHostname}:${this.storageSettings.values.upnpPort}`);
return validDomain;
}
@@ -794,6 +854,11 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
async startCloudflared() {
if (!this.storageSettings.values.cloudflareEnabled) {
this.console.log('cloudflared is disabled.');
return;
}
while (true) {
try {
this.console.log('starting cloudflared');
@@ -877,6 +942,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
cloudflareTunnel.child.on('exit', () => deferred.resolve(undefined));
try {
this.cloudflareTunnel = await Promise.any([deferred.promise, cloudflareTunnel.url]);
this.updateExternalAddresses();
if (!this.cloudflareTunnel)
throw new Error('cloudflared exited, the provided cloudflare tunnel token may be invalid.')
}
@@ -903,6 +969,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
finally {
this.cloudflared = undefined;
this.cloudflareTunnel = undefined;
this.updateExternalAddresses();
}
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/core",
"version": "0.1.142",
"version": "0.1.143",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/core",
"version": "0.1.142",
"version": "0.1.143",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/core",
"version": "0.1.142",
"version": "0.1.143",
"description": "Scrypted Core plugin. Provides the UI, websocket, and engine.io APIs.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -112,6 +112,16 @@ export default {
try {
const redirect_uri = new URL(window.location).searchParams.get('redirect_uri');
if (redirect_uri) {
try {
const parsed = new URL(redirect_uri);
// allow everything but javascript evaluation within this browser (ie, custom uri handlers, https, etc are all valid)
if (parsed.protocol === 'javascript:') {
window.location = '/';
return;
}
}
catch (e) {
}
window.location = redirect_uri;
return;
}

View File

@@ -10,20 +10,29 @@
doubleClickZoom: false,
boxZoom: false,
scrollWheelZoom: false,
touchZoom: false,
}"
>
<l-tile-layer :url="url" :attribution="attribution"></l-tile-layer>
<l-marker :lat-lng="position" :icon="icon"></l-marker>
<l-marker :lat-lng="position"></l-marker>
<l-control-attribution position="bottomright" :prefix="prefix"></l-control-attribution>
</l-map>
</template>
<script>
import { latLng, icon } from "leaflet";
import { latLng, Icon } from "leaflet";
import { LMap, LTileLayer, LMarker, LControlAttribution } from "vue2-leaflet";
import 'leaflet/dist/leaflet.css';
import RPCInterface from "../RPCInterface.vue";
// https://vue2-leaflet.netlify.app/quickstart/#marker-icons-are-missing
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});
export default {
mixins: [RPCInterface],
components: {
@@ -37,10 +46,6 @@ export default {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
prefix: '<a target="blank" href="https://leafletjs.com/">Leaflet</a>',
attribution: '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors',
icon: icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
}),
};
},
computed: {

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/coreml",
"version": "0.1.26",
"version": "0.1.28",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/coreml",
"version": "0.1.26",
"version": "0.1.28",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -34,11 +34,12 @@
"type": "API",
"interfaces": [
"Settings",
"ObjectDetection"
"ObjectDetection",
"ObjectDetectionPreview"
]
},
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.1.26"
"version": "0.1.28"
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.129",
"version": "0.0.130",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/hikvision",
"version": "0.0.129",
"version": "0.0.130",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.129",
"version": "0.0.130",
"description": "Hikvision Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -498,6 +498,9 @@ export class H264Repacketizer {
// after the codec information. so codec information can be changed between
// idr and non-idr? maybe it is not applied until next idr?
}
else if (nalType === 0) {
// nal delimiter or something. usually empty.
}
else {
this.console.warn('Skipped a stapa type. Please report this to @koush on Discord.', nalType)
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/objectdetector",
"version": "0.0.164",
"version": "0.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/objectdetector",
"version": "0.0.164",
"version": "0.1.2",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/objectdetector",
"version": "0.0.164",
"version": "0.1.2",
"description": "Scrypted Video Analysis Plugin. Installed alongside a detection service like OpenCV or TensorFlow.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -161,7 +161,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
maybeStartDetection() {
if (!this.hasMotionType) {
// object detection may be restarted if there are slots available.
if (this.cameraDevice.motionDetected && this.plugin.canStartObjectDetection())
if (this.cameraDevice.motionDetected && this.plugin.canStartObjectDetection(this))
this.startPipelineAnalysis();
return;
}
@@ -1022,6 +1022,12 @@ class ObjectDetectionPlugin extends AutoenableMixinProvider implements Settings,
return value;
},
},
developerMode: {
group: 'Advanced',
title: 'Developer Mode',
description: 'Developer mode enables usage of the raw detector object detectors. Using raw object detectors (ie, outside of Scrypted NVR) can cause severe performance degradation.',
type: 'boolean',
}
});
shouldUseSnapshotPipeline() {
@@ -1068,15 +1074,27 @@ class ObjectDetectionPlugin extends AutoenableMixinProvider implements Settings,
return maxConcurrent;
}
canStartObjectDetection() {
canStartObjectDetection(mixin: ObjectDetectionMixin) {
const maxConcurrent = this.maxConcurrent;
const objectDetections = [...this.currentMixins.values()]
const runningDetections = [...this.currentMixins.values()]
.map(d => [...d.currentMixins.values()].filter(dd => !dd.hasMotionType)).flat()
.filter(c => c.detectorRunning)
.sort((a, b) => a.detectionStartTime - b.detectionStartTime);
return objectDetections.length < maxConcurrent;
// already running
if (runningDetections.find(o => o.id === mixin.id))
return false;
if (runningDetections.length < maxConcurrent)
return true;
const [first] = runningDetections;
if (Date.now() - first.detectionStartTime > 30000)
return true;
mixin.console.log(`Not starting object detection to continue processing recent activity on ${first.name}.`);
return false;
}
objectDetectionStarted(name: string, console: Console) {
@@ -1143,7 +1161,7 @@ class ObjectDetectionPlugin extends AutoenableMixinProvider implements Settings,
}
constructor(nativeId?: ScryptedNativeId) {
super(nativeId);
super(nativeId, 'v5');
process.nextTick(() => {
sdk.deviceManager.onDevicesChanged({
@@ -1180,6 +1198,8 @@ class ObjectDetectionPlugin extends AutoenableMixinProvider implements Settings,
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
if (!interfaces.includes(ScryptedInterface.ObjectDetection))
return;
if (!this.storageSettings.values.developerMode && !interfaces.includes(ScryptedInterface.ObjectDetectionGenerator))
return;
return [ScryptedInterface.MixinProvider];
}

View File

@@ -28,5 +28,5 @@ export function getMaxConcurrentObjectDetectionSessions() {
// the total mhz would be 10000 in this case.
// observed idle per cpu speed is 800.
// not sure how hyperthreading plays into this.
return Math.max(2, totalGigahertz / 4000);
return Math.max(2, Math.round(totalGigahertz / 4000));
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/onvif",
"version": "0.0.124",
"version": "0.0.125",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/onvif",
"version": "0.0.124",
"version": "0.0.125",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/onvif",
"version": "0.0.124",
"version": "0.0.125",
"description": "ONVIF Camera Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -187,7 +187,7 @@ export class OnvifIntercom implements Intercom {
let pending: RtpPacket;
let seqNumber = 0;
const forwarder = await startRtpForwarderProcess(console, ffmpegInput, {
const forwarder = await startRtpForwarderProcess(this.camera.console, ffmpegInput, {
audio: {
onRtp: (rtp) => {
// if (true) {
@@ -237,13 +237,13 @@ export class OnvifIntercom implements Intercom {
});
intercomClient.client.on('close', () => forwarder.kill());
forwarder.killPromise.finally(() => intercomClient?.client.destroy());
forwarder.killPromise.finally(() => intercomClient.safeTeardown());
this.camera.console.log('intercom playing');
}
async stopIntercom() {
this.intercomClient?.client?.destroy();
this.intercomClient?.safeTeardown();
this.intercomClient = undefined;
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/opencv",
"version": "0.0.87",
"version": "0.0.89",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/opencv",
"version": "0.0.87",
"version": "0.0.89",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -26,7 +26,8 @@
"runtime": "python",
"type": "API",
"interfaces": [
"ObjectDetection"
"ObjectDetection",
"ObjectDetectionGenerator"
],
"pluginDependencies": [
"@scrypted/objectdetector",
@@ -36,5 +37,5 @@
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.0.87"
"version": "0.0.89"
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/openvino",
"version": "0.1.40",
"version": "0.1.44",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/openvino",
"version": "0.1.40",
"version": "0.1.44",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -34,12 +34,13 @@
"runtime": "python",
"type": "API",
"interfaces": [
"Settings",
"ObjectDetection",
"Settings"
"ObjectDetectionPreview"
]
},
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.1.40"
"version": "0.1.44"
}

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import re
from typing import Any, Tuple
@@ -96,6 +94,7 @@ class OpenVINOPlugin(PredictPlugin, scrypted_sdk.BufferConverter, scrypted_sdk.S
try:
self.compiled_model = self.core.compile_model(xmlFile, mode)
print("EXECUTION_DEVICES", self.compiled_model.get_property("EXECUTION_DEVICES"))
except:
import traceback
traceback.print_exc()
@@ -114,8 +113,6 @@ class OpenVINOPlugin(PredictPlugin, scrypted_sdk.BufferConverter, scrypted_sdk.S
labels_contents = open(labelsFile, 'r').read()
self.labels = parse_label_contents(labels_contents)
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="openvino", )
async def getSettings(self) -> list[Setting]:
mode = self.storage.getItem('mode') or 'Default'
model = self.storage.getItem('model') or 'Default'
@@ -181,7 +178,7 @@ class OpenVINOPlugin(PredictPlugin, scrypted_sdk.BufferConverter, scrypted_sdk.S
return [self.model_dim, self.model_dim]
async def detect_once(self, input: Image.Image, settings: Any, src_size, cvss):
def predict():
async def predict():
infer_request = self.compiled_model.create_infer_request()
# the input_tensor can be created with the shared_memory=True parameter,
# but that seems to cause issues on some platforms.
@@ -259,7 +256,7 @@ class OpenVINOPlugin(PredictPlugin, scrypted_sdk.BufferConverter, scrypted_sdk.S
return objs
try:
objs = await asyncio.get_event_loop().run_in_executor(self.executor, predict)
objs = await predict()
except:
import traceback
traceback.print_exc()

View File

@@ -1,4 +1,4 @@
openvino==2023.0.1
openvino==2023.0.2
# 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'

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/pam-diff",
"version": "0.0.23",
"version": "0.0.24",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/pam-diff",
"version": "0.0.23",
"version": "0.0.24",
"dependencies": {
"@types/node": "^16.6.1",
"pipe2pam": "^0.6.2"

View File

@@ -27,7 +27,8 @@
"name": "PAM Diff Motion Detection",
"type": "API",
"interfaces": [
"ObjectDetection"
"ObjectDetection",
"ObjectDetectionGenerator"
],
"pluginDependencies": [
"@scrypted/objectdetector"
@@ -43,5 +44,5 @@
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.0.23"
"version": "0.0.24"
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/prebuffer-mixin",
"version": "0.9.99",
"version": "0.9.100",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/prebuffer-mixin",
"version": "0.9.99",
"version": "0.9.100",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/prebuffer-mixin",
"version": "0.9.99",
"version": "0.9.100",
"description": "Video Stream Rebroadcast, Prebuffer, and Management Plugin for Scrypted.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -5,6 +5,7 @@ import path from 'path';
import { Duplex } from "stream";
const highWaterMark = 1024 * 1024;
const bufferOverflow = 100 * 1024 * 1024;
// non standard extension that dumps the rtp payload to a file.
export class FileRtspServer extends RtspServer {
@@ -99,6 +100,12 @@ export class FileRtspServer extends RtspServer {
return super.writeRtpPayload(header, rtp);
this.segmentBytesWritten += header.length + rtp.length;
if (this.writeStream.writableLength > bufferOverflow) {
this.writeConsole?.error('RTSP WRITE overflowed.');
this.cleanup();
this.client?.destroy();
return;
}
this.writeStream.write(header);
return this.writeStream.write(rtp);
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/python-codecs",
"version": "0.1.84",
"version": "0.1.86",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/python-codecs",
"version": "0.1.84",
"version": "0.1.86",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/python-codecs",
"version": "0.1.84",
"version": "0.1.86",
"description": "Python Codecs for Scrypted",
"keywords": [
"scrypted",

View File

@@ -98,7 +98,7 @@ def toPILImage(pilImageWrapper: PILImage, options: scrypted_sdk.ImageOptions = N
if not width:
width = pilImage.width * yscale
pilImage = pilImage.resize((width, height), resample=Image.BILINEAR)
pilImage = pilImage.resize((int(width), int(height)), resample=Image.BILINEAR)
return PILImage(pilImage)

View File

@@ -60,7 +60,7 @@ class VipsImage(scrypted_sdk.Image):
return memoryview(gray.write_to_memory())
return await to_thread(format)
return await to_thread(lambda: vipsImage.vipsImage.write_to_buffer('.' + options['format']))
return await to_thread(lambda: vipsImage.vipsImage.write_to_buffer(f'.{options["format"]}[Q=80]'))
async def toImageInternal(self, options: scrypted_sdk.ImageOptions = None):
return await to_thread(lambda: toVipsImage(self, options))

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/reolink",
"version": "0.0.33",
"version": "0.0.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/reolink",
"version": "0.0.33",
"version": "0.0.37",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/reolink",
"version": "0.0.33",
"version": "0.0.37",
"description": "Reolink Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -6,12 +6,13 @@ import { Destroyable, RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } fro
import { OnvifCameraAPI, connectCameraAPI } from './onvif-api';
import { listenEvents } from './onvif-events';
import { OnvifIntercom } from './onvif-intercom';
import { ReolinkCameraClient } from './reolink-api';
import { Enc, ReolinkCameraClient } from './reolink-api';
class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom {
client: ReolinkCameraClient;
onvifClient: OnvifCameraAPI;
onvifIntercom = new OnvifIntercom(this);
videoStreamOptions: Promise<UrlMediaStreamOptions[]>;
storageSettings = new StorageSettings(this, {
doorbell: {
@@ -33,7 +34,7 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom
this.updateDeviceInfo();
this.updateDevice();
}
async startIntercom(media: MediaObject): Promise<void> {
if (!this.onvifIntercom.url) {
const client = await this.getOnvifClient();
@@ -162,8 +163,26 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom
}
async getConstructedVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> {
this.videoStreamOptions ||= this.getConstructedVideoStreamOptionsInternal().catch(e => {
this.constructedVideoStreamOptions = undefined;
throw e;
});
return this.videoStreamOptions;
}
async getConstructedVideoStreamOptionsInternal(): Promise<UrlMediaStreamOptions[]> {
const ret: UrlMediaStreamOptions[] = [];
let encoderConfig: Enc;
try {
const client = this.getClient();
encoderConfig = await client.getEncoderConfiguration();
}
catch (e) {
this.console.error("Codec query failed. Falling back to known defaults.", e);
}
const rtmpPreviews = [
`main.bcs`,
`ext.bcs`,
@@ -184,6 +203,8 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom
}
// rough guesses for rebroadcast stream selection.
const rtmpMainIndex = 0;
const rtmpMain = ret[rtmpMainIndex];
ret[0].container = 'rtmp';
ret[0].video = {
width: 2560,
@@ -219,6 +240,8 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom
}
// rough guesses for h264
const rtspMainIndex = 3;
const rtspMain = ret[rtspMainIndex];
ret[3].container = 'rtsp';
ret[3].video = {
codec: 'h264',
@@ -239,6 +262,23 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom
height: 672,
}
if (encoderConfig) {
const { mainStream } = encoderConfig;
if (mainStream?.width && mainStream?.height) {
rtmpMain.video.width = mainStream.width;
rtmpMain.video.height = mainStream.height;
rtspMain.video.width = mainStream.width;
rtspMain.video.height = mainStream.height;
// 4k h265 rtmp is seemingly nonfunctional, but rtsp works. swap them so there is a functional stream.
if (mainStream.vType === 'h265' || mainStream.vType === 'hevc') {
this.console.warn('Detected h265. Change the camera configuration to use 2k mode to force h264. https://docs.scrypted.app/camera-preparation.html#h-264-video-codec')
rtmpMain.video.codec = 'h265';
rtspMain.video.codec = 'h265';
ret[rtmpMainIndex] = rtspMain;
ret[rtspMainIndex] = rtmpMain;
}
}
}
return ret;
}

View File

@@ -1,8 +1,25 @@
import axios from 'axios';
import AxiosDigestAuth from "@koush/axios-digest-auth";
import https from 'https';
import { getMotionState, reolinkHttpsAgent } from './probe';
export interface Enc {
audio: number;
channel: number;
mainStream: Stream;
subStream: Stream;
}
export interface Stream {
bitRate: number;
frameRate: number;
gop: number;
height: number;
profile: string;
size: string;
vType: string;
width: number;
}
export class ReolinkCameraClient {
digestAuth: AxiosDigestAuth;
@@ -77,4 +94,20 @@ export class ReolinkCameraClient {
return Buffer.from(response.data);
}
async getEncoderConfiguration(): Promise<Enc> {
const url = new URL(`http://${this.host}/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'GetEnc');
// is channel used on this call?
params.set('channel', this.channelId.toString());
params.set('user', this.username);
params.set('password', this.password);
const response = await this.digestAuth.request({
url: url.toString(),
httpsAgent: reolinkHttpsAgent,
});
return response.data?.[0]?.value?.Enc;
}
}

View File

@@ -10,7 +10,7 @@
"port": 10081,
"request": "attach",
"skipFiles": [
"**/plugin-remote-worker.*",
"**/plugin-console.*",
"<node_internals>/**"
],
"preLaunchTask": "scrypted: deploy+debug",

View File

@@ -3,3 +3,12 @@
The Snapshot Plugin is a core plugin that provides video to image conversion and image manipulation functionality.
This plugin can be enabled on cameras to add custom snapshot URLs, crop and scale the snapshots, and improves camera snapshot responsiveness.
## Core Features
* Shrink images for better transfer rate on mobile networks.
* Custom Snapshot URLs for RTSP cameras.
* Cache snapshots for better responsiveness.
* Snapshot error recovery.
* Convert video streams to images for cameras without snapshot URLs.
* Resize image service for other plugins.

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/snapshot",
"version": "0.0.60",
"version": "0.0.62",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/snapshot",
"version": "0.0.60",
"version": "0.0.62",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",
"@types/node": "^18.16.18",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/snapshot",
"version": "0.0.60",
"version": "0.0.62",
"description": "Snapshot Plugin for Scrypted",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/tensorflow-lite",
"version": "0.1.42",
"version": "0.1.44",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/tensorflow-lite",
"version": "0.1.42",
"version": "0.1.44",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -47,11 +47,12 @@
"type": "API",
"interfaces": [
"Settings",
"ObjectDetection"
"ObjectDetection",
"ObjectDetectionPreview"
]
},
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.1.42"
"version": "0.1.44"
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/tensorflow-lite",
"version": "0.1.16",
"version": "0.1.18",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/tensorflow-lite",
"version": "0.1.16",
"version": "0.1.18",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -35,11 +35,12 @@
"type": "API",
"interfaces": [
"Settings",
"ObjectDetection"
"ObjectDetection",
"ObjectDetectionPreview"
]
},
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.1.16"
"version": "0.1.18"
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/unifi-protect",
"version": "0.0.136",
"version": "0.0.137",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/unifi-protect",
"version": "0.0.136",
"version": "0.0.137",
"license": "Apache",
"dependencies": {
"@koush/unifi-protect": "file:../../external/unifi-protect",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/unifi-protect",
"version": "0.0.136",
"version": "0.0.137",
"description": "Unifi Protect Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -317,7 +317,9 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
const rtspChannel = camera.channels.find(check => check.id.toString() === vso.id);
const { rtspAlias } = rtspChannel;
const u = `rtsps://${this.protect.getSetting('ip')}:7441/${rtspAlias}`
// Use the camera's host from the api, otherwise fallback to user-configured nvr ip
const cameraHost = camera.connectionHost || this.protect.getSetting('ip');
const u = `rtsps://${cameraHost}:7441/${rtspAlias}`
const data = Buffer.from(JSON.stringify({
url: u,

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/webrtc",
"version": "0.1.71",
"version": "0.1.73",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/webrtc",
"version": "0.1.71",
"version": "0.1.73",
"dependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/webrtc",
"version": "0.1.71",
"version": "0.1.73",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",

View File

@@ -397,7 +397,7 @@ class WebRTCTrack implements RTCMediaObjectTrack {
if (this.removed.finished)
return;
this.removed.resolve(undefined);
this.control.killed.resolve(undefined);
this.control.endSession();
this.video.sender.onRtcp.allUnsubscribe();
if (cleanupTrackOnly)

Some files were not shown because too many files have changed in this diff Show More