mirror of
https://github.com/koush/scrypted.git
synced 2026-02-06 23:42:19 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e0e9bc22a | ||
|
|
9e495c74d9 | ||
|
|
a9baeafe71 | ||
|
|
ee68fcd7d2 | ||
|
|
af6e18dc1a | ||
|
|
ddb8c7cf58 | ||
|
|
2be3c7f3df | ||
|
|
274f449e2f | ||
|
|
1109333e0f | ||
|
|
0349977a4d | ||
|
|
48548aafd5 | ||
|
|
ab70cce1b5 | ||
|
|
83fe0c2b7a | ||
|
|
77676a27c2 | ||
|
|
015dfab7a6 | ||
|
|
7f0f0cb6bd | ||
|
|
e49e13a167 | ||
|
|
9fd353236b | ||
|
|
e006d599d7 | ||
|
|
71cbe83a2a | ||
|
|
1438af8aea | ||
|
|
2237eb3221 | ||
|
|
7b56e86383 | ||
|
|
3653fb83d3 | ||
|
|
cd766a603e | ||
|
|
3648492299 | ||
|
|
88e8530677 | ||
|
|
325f84ca7e | ||
|
|
0a4b862fd8 | ||
|
|
e7d7fd6a00 | ||
|
|
f9dda8d1ca | ||
|
|
8b7decd077 | ||
|
|
9dd5e10eba | ||
|
|
475b833508 | ||
|
|
5f9006148a | ||
|
|
b77f1a55c1 | ||
|
|
6b9163e84e | ||
|
|
bc03bdd235 | ||
|
|
2592a7c228 | ||
|
|
0a4336879c | ||
|
|
e5cef3f217 | ||
|
|
d34396afbc | ||
|
|
2622fc9256 | ||
|
|
410b1a4813 | ||
|
|
403c742be3 | ||
|
|
50a471b78f | ||
|
|
9b7ead26e0 | ||
|
|
3127bc38cb | ||
|
|
fb8b1a893d | ||
|
|
779d8eaa42 | ||
|
|
5eab99866f | ||
|
|
e10a4f3c58 | ||
|
|
2585b1832e | ||
|
|
5e8e0d7773 | ||
|
|
7c17b478d7 | ||
|
|
9f5dd55c73 | ||
|
|
b6f400382d | ||
|
|
024b2166b8 | ||
|
|
b49771840e | ||
|
|
4001fc996f | ||
|
|
0d97010ca8 | ||
|
|
e243d99d12 | ||
|
|
86a91dfbe4 | ||
|
|
c86ae752e8 | ||
|
|
b7ca477b98 | ||
|
|
c37f8926b8 | ||
|
|
4b181a8ac9 | ||
|
|
b8439aaec3 | ||
|
|
77d0c33657 | ||
|
|
0b6d61a801 | ||
|
|
71a2d27cbd | ||
|
|
f8f79f5cc2 | ||
|
|
988f297e32 | ||
|
|
6e109d89e0 | ||
|
|
6ada4854bc | ||
|
|
bc5e89668f | ||
|
|
4c11def52b | ||
|
|
8890d307f4 | ||
|
|
9f8f562dcc | ||
|
|
2ce798c8c2 | ||
|
|
4271ef321f |
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
10
.github/workflows/docker-common.yml
vendored
10
.github/workflows/docker-common.yml
vendored
@@ -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
|
||||
|
||||
13
.github/workflows/docker.yml
vendored
13
.github/workflows/docker.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
2
external/werift
vendored
2
external/werift
vendored
Submodule external/werift updated: 9815d03344...b63f339b55
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
553
packages/client/package-lock.json
generated
553
packages/client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
23
packages/h264-repacketizer/.vscode/launch.json
vendored
Normal file
23
packages/h264-repacketizer/.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
93
packages/h264-repacketizer/test/test.ts
Normal file
93
packages/h264-repacketizer/test/test.ts
Normal 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();
|
||||
4
plugins/amcrest/package-lock.json
generated
4
plugins/amcrest/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.124",
|
||||
"version": "0.0.128",
|
||||
"description": "Amcrest Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
5
plugins/arlo/.gitignore
vendored
5
plugins/arlo/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
.DS_Store
|
||||
out/
|
||||
node_modules/
|
||||
dist/
|
||||
.venv
|
||||
@@ -1,10 +0,0 @@
|
||||
.DS_Store
|
||||
out/
|
||||
node_modules/
|
||||
*.map
|
||||
fs
|
||||
src
|
||||
.vscode
|
||||
dist/*.js
|
||||
dist/*.txt
|
||||
__pycache__
|
||||
30
plugins/arlo/.vscode/launch.json
vendored
30
plugins/arlo/.vscode/launch.json
vendored
@@ -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"
|
||||
},
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
27
plugins/arlo/.vscode/settings.json
vendored
27
plugins/arlo/.vscode/settings.json
vendored
@@ -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"
|
||||
]
|
||||
}
|
||||
20
plugins/arlo/.vscode/tasks.json
vendored
20
plugins/arlo/.vscode/tasks.json
vendored
@@ -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}",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
84
plugins/arlo/package-lock.json
generated
84
plugins/arlo/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
from .provider import ArloProvider
|
||||
@@ -1 +0,0 @@
|
||||
from .arlo_async import Arlo
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
import os
|
||||
|
||||
EXPERIMENTAL = os.environ.get("SCRYPTED_ARLO_EXPERIMENTAL", "0") not in ["", "0"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,4 +0,0 @@
|
||||
from arlo_plugin import ArloProvider
|
||||
|
||||
def create_scrypted_plugin():
|
||||
return ArloProvider()
|
||||
@@ -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
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2021",
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node16",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
4
plugins/cloud/package-lock.json
generated
4
plugins/cloud/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
"@types/nat-upnp": "^1.1.2",
|
||||
"@types/node": "^20.4.5"
|
||||
},
|
||||
"version": "0.1.40"
|
||||
"version": "0.2.3"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
plugins/core/package-lock.json
generated
4
plugins/core/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '© <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: {
|
||||
|
||||
4
plugins/coreml/package-lock.json
generated
4
plugins/coreml/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -34,11 +34,12 @@
|
||||
"type": "API",
|
||||
"interfaces": [
|
||||
"Settings",
|
||||
"ObjectDetection"
|
||||
"ObjectDetection",
|
||||
"ObjectDetectionPreview"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.1.26"
|
||||
"version": "0.1.28"
|
||||
}
|
||||
|
||||
4
plugins/hikvision/package-lock.json
generated
4
plugins/hikvision/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/hikvision",
|
||||
"version": "0.0.129",
|
||||
"version": "0.0.130",
|
||||
"description": "Hikvision Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
4
plugins/objectdetector/package-lock.json
generated
4
plugins/objectdetector/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
4
plugins/onvif/package-lock.json
generated
4
plugins/onvif/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
4
plugins/opencv/package-lock.json
generated
4
plugins/opencv/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
4
plugins/openvino/package-lock.json
generated
4
plugins/openvino/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
4
plugins/pam-diff/package-lock.json
generated
4
plugins/pam-diff/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/pam-diff",
|
||||
"version": "0.0.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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
4
plugins/prebuffer-mixin/package-lock.json
generated
4
plugins/prebuffer-mixin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/prebuffer-mixin",
|
||||
"version": "0.9.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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
4
plugins/python-codecs/package-lock.json
generated
4
plugins/python-codecs/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/python-codecs",
|
||||
"version": "0.1.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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/python-codecs",
|
||||
"version": "0.1.84",
|
||||
"version": "0.1.86",
|
||||
"description": "Python Codecs for Scrypted",
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
4
plugins/reolink/package-lock.json
generated
4
plugins/reolink/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/reolink",
|
||||
"version": "0.0.33",
|
||||
"version": "0.0.37",
|
||||
"description": "Reolink Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
2
plugins/snapshot/.vscode/launch.json
vendored
2
plugins/snapshot/.vscode/launch.json
vendored
@@ -10,7 +10,7 @@
|
||||
"port": 10081,
|
||||
"request": "attach",
|
||||
"skipFiles": [
|
||||
"**/plugin-remote-worker.*",
|
||||
"**/plugin-console.*",
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"preLaunchTask": "scrypted: deploy+debug",
|
||||
|
||||
@@ -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.
|
||||
|
||||
4
plugins/snapshot/package-lock.json
generated
4
plugins/snapshot/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
plugins/tensorflow-lite/package-lock.json
generated
4
plugins/tensorflow-lite/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -47,11 +47,12 @@
|
||||
"type": "API",
|
||||
"interfaces": [
|
||||
"Settings",
|
||||
"ObjectDetection"
|
||||
"ObjectDetection",
|
||||
"ObjectDetectionPreview"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.1.42"
|
||||
"version": "0.1.44"
|
||||
}
|
||||
|
||||
4
plugins/tensorflow/package-lock.json
generated
4
plugins/tensorflow/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -35,11 +35,12 @@
|
||||
"type": "API",
|
||||
"interfaces": [
|
||||
"Settings",
|
||||
"ObjectDetection"
|
||||
"ObjectDetection",
|
||||
"ObjectDetectionPreview"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
},
|
||||
"version": "0.1.16"
|
||||
"version": "0.1.18"
|
||||
}
|
||||
|
||||
4
plugins/unifi-protect/package-lock.json
generated
4
plugins/unifi-protect/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
plugins/webrtc/package-lock.json
generated
4
plugins/webrtc/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user