Compare commits

..

208 Commits

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

* Add fallback to user-configured nvr ip

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

* document source of marker icon workaround

* disable touch zoom
2023-09-09 09:57:15 -07:00
Koushik Dutta
8890d307f4 docker: add builder secrets 2023-09-09 09:39:03 -07:00
Koushik Dutta
9f8f562dcc docker: fixup template path 2023-09-08 21:34:13 -07:00
Koushik Dutta
2ce798c8c2 server: postrelease 2023-09-08 20:12:08 -07:00
Koushik Dutta
4271ef321f postrelease 2023-09-08 20:11:59 -07:00
Koushik Dutta
f976903a29 server: update deps 2023-09-08 20:11:15 -07:00
Nick Berardi
4ca63aadd5 alexa: display camera on doorbell press (#1066) 2023-09-08 13:56:49 -07:00
Koushik Dutta
6c932aec89 snapshot: refactor to remove ffmpeg usage 2023-09-07 09:34:23 -07:00
Koushik Dutta
d7030c3dcf videoanalysis: ignore webcodec if not running under electron 2023-09-06 08:43:59 -07:00
Koushik Dutta
172ebf06de server: add pending result method tracker 2023-09-06 07:50:53 -07:00
Koushik Dutta
5f28c5a291 postbeta 2023-09-06 07:50:32 -07:00
Koushik Dutta
4c9ba5073e cloud: cleanup 2023-09-05 22:51:49 -07:00
Koushik Dutta
11d67f36be cloud: show port in Advanced in Disabled/Cloudflare Tunnel mode 2023-09-05 20:00:43 -07:00
Koushik Dutta
d38357ded9 webrtc: better 6to4 detection 2023-09-05 10:31:34 -07:00
Koushik Dutta
f22e2ccfe7 webrtc: fast path for ipv6 relay candidates 2023-09-05 09:47:22 -07:00
Koushik Dutta
e2b2f68477 server: postbeta 2023-09-05 08:29:24 -07:00
Koushik Dutta
57e87fbe8d postbeta 2023-09-05 08:29:14 -07:00
Koushik Dutta
31b05162fc beta 2023-09-04 17:56:14 -07:00
Koushik Dutta
c63efa0fca cloud: fixup settings.json 2023-09-04 17:56:10 -07:00
Koushik Dutta
ce5255aa45 postbeta 2023-09-04 17:02:25 -07:00
Koushik Dutta
4692be1586 server: v6/v4 mixup fix 2023-09-04 17:02:17 -07:00
Koushik Dutta
632d971dd5 server: remove axios 2023-09-04 16:56:49 -07:00
Koushik Dutta
2f17c85e99 postbeta 2023-09-04 16:56:36 -07:00
Koushik Dutta
9c6cdc9ac3 postbeta 2023-09-04 16:46:13 -07:00
Koushik Dutta
7007456bdd server: fix ipv6 addresses 2023-09-04 16:46:05 -07:00
Koushik Dutta
73fc738c0b cloud: additional bin path fixes 2023-09-03 17:45:55 -07:00
Koushik Dutta
abd1227fab cloud: fix cloudflared bin install 2023-09-03 17:39:29 -07:00
Brett Jia
7d2226df75 arlo: upstreaming changes (#1059)
* eager stream urls

* bump 0.8.16 for beta

* use curl-cffi everywhere, use alternative to piwheels, configurable eager streams

* bump 0.8.17 for beta

* bump 0.8.18 for release

* update backup hosts

* bump 0.8.19 for release

* resurrect pyav and aiortc

* bump 0.8.20 for beta

* unify scrypted-arlo-go and aiortc, disable aiortc

* update backup hosts

* use native sse client

* bump 0.8.21 for beta

* fix native sseclient restart loop

* update backup hosts

* bump 0.8.22 for beta

* handle disconnects with python-level restart

* bump 0.8.23 for beta

* move sse restart to native code

* bump 0.8.24 for beta

* bump 0.8.25 for release

* update backup hosts

* bump 0.8.26 for release
2023-09-03 15:41:04 -07:00
Koushik Dutta
8f50415920 cloud: need to learn to code 2023-09-02 18:27:17 -07:00
Koushik Dutta
20ed523b30 cloud: fix EACCES 2023-09-02 17:25:29 -07:00
Koushik Dutta
effadb1437 cloud: add/shim macos arm64 cloudflared builds 2023-09-02 14:50:57 -07:00
Koushik Dutta
07c7c91c63 coreml: public beta that uses use coremltools beta 2023-09-01 14:41:46 -07:00
Koushik Dutta
878ddbdf1c python-codecs: fix windows leak 2023-08-31 18:10:10 -07:00
Koushik Dutta
d95e9c78ea cameras: update ip address in info after adding 2023-08-30 09:05:17 -07:00
Koushik Dutta
49dc1d8f36 python-codecs: add support for gstreamer jpeg output, publish beta 2023-08-29 20:54:17 -07:00
Koushik Dutta
425e17a88b tensorflow-lite: fix windows 2023-08-27 22:10:19 -07:00
Koushik Dutta
9bca6b0a94 ha: add network share support 2023-08-27 10:28:14 -07:00
Koushik Dutta
3a62d9cd31 cli: add ffplay filter args 2023-08-26 21:31:31 -07:00
Koushik Dutta
8f6bedd9d8 sdk: publish 2023-08-26 20:38:19 -07:00
Koushik Dutta
1c2a9d767f rebroadcast: Fix up output arguments handling and rtsp rebroadcast 2023-08-26 19:55:27 -07:00
Koushik Dutta
7ecee4298c sdk: update 2023-08-26 16:51:01 -07:00
Koushik Dutta
4f1aad895f sdk: update 2023-08-26 16:50:04 -07:00
Koushik Dutta
94667d2136 core: promote snapshot to core plugin, publish 2023-08-26 13:43:38 -07:00
Koushik Dutta
7d13055eae Merge branch 'main' of github.com:koush/scrypted 2023-08-26 13:38:00 -07:00
Koushik Dutta
f90140dbd7 core: add doc links 2023-08-26 13:37:41 -07:00
Brett Jia
8b3a66b6ba core: replace google maps with leaflet/OSM (#1046)
* core: replace google maps with leaflet/OSM

* core: publish
2023-08-26 10:40:40 -07:00
Brett Jia
8c03852cfb chromecast: publish (#1045) 2023-08-25 19:16:51 -07:00
slyoldfox
d795cd527d bticino: 0.0.11 - kick off audio and video streams sooner without waiting for the SIP call to be established (#1044)
Co-authored-by: Marc Vanbrabant <marc@foreach.be>
2023-08-25 08:36:26 -07:00
Koushik Dutta
a24d986717 tflite: update yolov8n 320 model 2023-08-24 19:48:32 -07:00
Koushik Dutta
60ec304e68 predict: report hardware acceleration optiosn 2023-08-24 12:25:36 -07:00
Koushik Dutta
6a9d498ff8 snapshot: relax error messages 2023-08-24 09:23:53 -07:00
Koushik Dutta
c60821043b cameras: remove pam-diff dependency 2023-08-24 08:49:35 -07:00
Koushik Dutta
e5a63dd992 coreml: remove python-codecs dependency, mac should use the desktop app. 2023-08-23 21:20:29 -07:00
Koushik Dutta
f77ea922f2 predict: readd python codecs dependency 2023-08-23 21:19:48 -07:00
Koushik Dutta
1e8deeb638 Merge branch 'main' of github.com:koush/scrypted 2023-08-23 18:50:28 -07:00
Koushik Dutta
a28ecb71e1 videoanalysis: add better explanation for pipeline ffmpeg pipeline failure 2023-08-23 18:50:21 -07:00
Brett Jia
4067455396 core: publish (#1038) 2023-08-23 10:50:16 -07:00
Brett Jia
9b828a6045 core: docker installs delay update prompt until image is ready (#1034)
* core: docker installs delay update prompt until image is ready

* update settings page with new check
2023-08-23 09:58:11 -07:00
Koushik Dutta
efce576c68 server: beta 2023-08-21 13:38:09 -07:00
Koushik Dutta
66b314f2aa postbeta 2023-08-21 13:36:52 -07:00
Koushik Dutta
d6ebc1fa85 postbeta 2023-08-21 13:35:45 -07:00
Koushik Dutta
8d756a26bd server: Fix hang caused by null-ish headers 2023-08-21 13:33:24 -07:00
Koushik Dutta
81c28b86d3 reolink: update readme 2023-08-20 11:02:29 -07:00
Koushik Dutta
73f5e03774 core: publish 2023-08-18 08:42:28 -07:00
Koushik Dutta
cd078afcf9 client: fix webrtc usage 2023-08-17 18:31:50 -07:00
Koushik Dutta
6e393514cf cloud: add No TLS Verify to cloudflare readme section 2023-08-17 10:38:01 -07:00
Koushik Dutta
4b62bceede cloud: add cloudflare tunnel token option 2023-08-17 10:19:09 -07:00
Koushik Dutta
fbbbdd8ab5 cloud: publish 2023-08-16 19:34:02 -07:00
Koushik Dutta
a0e28c0a28 core: publish 2023-08-16 19:07:08 -07:00
Koushik Dutta
ff28238422 cloud: publish beta 2023-08-16 19:05:44 -07:00
Koushik Dutta
4e9744360a client: add support for cloudflared 2023-08-16 18:59:36 -07:00
Koushik Dutta
7336fac8c4 cloud: minor code cleanups and remove duckdns 2023-08-16 18:02:45 -07:00
Koushik Dutta
6771d17829 cloud: restructure 2023-08-16 14:50:52 -07:00
Koushik Dutta
62f1ca66f6 core: make iframe logins less confusing. show hostname on login screen. 2023-08-16 10:57:31 -07:00
Koushik Dutta
13cc562e68 cloud: duckdns prototype 2023-08-15 21:40:23 -07:00
Koushik Dutta
aff1e86d6f Revert "cloud: revert duckdns + letsencrypt"
This reverts commit 7d022548b9.
2023-08-15 21:29:30 -07:00
Koushik Dutta
c1f1e96109 predict: cleanups 2023-08-15 21:29:18 -07:00
Koushik Dutta
a36b3066fe python-codecs: fix corrupt frames 2023-08-15 21:27:56 -07:00
Koushik Dutta
cadf10b505 Merge branch 'main' of github.com:koush/scrypted 2023-08-15 11:16:26 -07:00
Koushik Dutta
ed541629b2 core: change type to prevent mqtt from enabling 2023-08-15 11:16:09 -07:00
Koushik Dutta
7d022548b9 cloud: revert duckdns + letsencrypt 2023-08-12 23:14:13 -07:00
Koushik Dutta
9aa9bae3a3 cloud: fixup hostname logic 2023-08-12 23:09:41 -07:00
Koushik Dutta
7f29b05980 cloud: supports letsencrypt via duckdns 2023-08-12 22:54:25 -07:00
Koushik Dutta
b89573e910 cloud: cleanup deps 2023-08-12 20:28:34 -07:00
Koushik Dutta
18426bcdc1 cloud: restructure 2023-08-12 20:05:56 -07:00
Koushik Dutta
f562dd5362 cloud: fix unhandled rejection 2023-08-12 19:59:00 -07:00
Koushik Dutta
1f1218a594 cloud: increase connection pool 2023-08-12 19:55:59 -07:00
Koushik Dutta
1aca97c2ae common: updates 2023-08-12 19:38:17 -07:00
Koushik Dutta
bd41410367 common: add async queue 2023-08-12 14:10:51 -07:00
Koushik Dutta
291d734a05 videoanalysis: restart object detection if crashed or evicted 2023-08-12 12:55:13 -07:00
Koushik Dutta
feec534b86 python-codecs: publish 2023-08-11 09:29:27 -07:00
Koushik Dutta
9ae7e6c0b5 h264-repacketizer: add types 2023-08-11 09:29:16 -07:00
Koushik Dutta
a6f11d6d0c cloud: improve head of line issues 2023-08-11 09:28:59 -07:00
Koushik Dutta
a15af8005b opencv: avoid broken version 4.8.0.76 2023-08-10 07:39:38 -07:00
Koushik Dutta
c13a3f252a core: publish 2023-08-10 07:35:30 -07:00
Koushik Dutta
0eaf9ef2d9 Merge branch 'main' of github.com:koush/scrypted 2023-08-10 07:35:16 -07:00
Nick Berardi
b9fc69347a alexa: added helpful error messages regarding token expiration (#1007) 2023-08-09 17:24:54 -07:00
Koushik Dutta
f6e8a363ab webrtc: fix webrtc connection timeout leak 2023-08-07 09:02:43 -07:00
Brett Jia
a6d163ec5a core: aggregate streams support horizontal padding (#1002) 2023-08-06 15:23:04 -07:00
Brett Jia
2d62944ac1 python-codecs: make annotations compatible to pre-3.10 (#1000) 2023-08-06 09:54:08 -07:00
Koushik Dutta
b564553998 python-codecs: rollback sdk bug 2023-08-06 09:53:26 -07:00
Koushik Dutta
6e4fdb6e99 videoanalysis: fix object detection eviction bug 2023-08-04 13:47:19 -07:00
Koushik Dutta
ca00983ecd client: better webrtc api connection usage 2023-08-03 20:33:24 -07:00
Koushik Dutta
36b8b9eeed common: formatting 2023-08-03 19:45:19 -07:00
Koushik Dutta
fbd6937627 webrtc/core: streamline p2p connection 2023-08-03 19:18:51 -07:00
Koushik Dutta
7c66826657 docker: dont pass usb through by default 2023-08-02 08:41:46 -07:00
Koushik Dutta
62c4a8b240 detection plugins: remove image splitting logic, let upstream handle that. switch to yolov8_320 as default. 2023-07-31 14:12:56 -07:00
Koushik Dutta
af860d840a mac: fix cli script 2023-07-31 13:54:43 -07:00
Koushik Dutta
42eb4fc80b python-codecs: dont letterbox resize requests. 2023-07-31 00:56:03 -07:00
Koushik Dutta
5c965936e9 Merge branch 'main' of github.com:koush/scrypted 2023-07-30 23:53:47 -07:00
Koushik Dutta
fe5cc59872 core: fix object detection svg layout 2023-07-30 23:53:42 -07:00
Brett Jia
5d965ebfa7 arlo: upstreaming changes to 0.8.15 (#982)
* wip hidden devices

* hidden devices impl

* bump 0.8.12 for beta

* update backup auth hosts

* bump 0.8.13 for release

* use curl-cffi everywhere

* bump 0.8.14 for beta

* Revert "use curl-cffi everywhere"

This reverts commit 80422a8037.

* update auth hosts

* bump 0.8.15 for release
2023-07-29 10:02:12 -07:00
Koushik Dutta
b462249d93 Merge branch 'main' of github.com:koush/scrypted 2023-07-28 21:19:46 -07:00
Koushik Dutta
29d8abed45 tensorflow-lite: more models 2023-07-28 21:19:41 -07:00
Koushik Dutta
65cb13b0d1 tensorflow-lite: add more models 2023-07-28 20:24:51 -07:00
Koushik Dutta
522f8e9cba Update config.yaml 2023-07-28 13:36:15 -07:00
Koushik Dutta
16199463ec python-codecs: publsih 2023-07-28 00:02:17 -07:00
Koushik Dutta
220c010232 python-codecs: fix zygote pop usage. implement firstFrameOnly pipeline blocking. 2023-07-28 00:01:01 -07:00
Koushik Dutta
02238f99b2 python-codecs: use zygote to speed up inference startup 2023-07-27 23:52:52 -07:00
Koushik Dutta
1e53234cd6 Merge branch 'main' of github.com:koush/scrypted 2023-07-27 14:54:52 -07:00
Koushik Dutta
824b7327a1 cloud: update deps 2023-07-27 14:54:41 -07:00
Koushik Dutta
81d4a3f249 Update docker.yml 2023-07-27 13:48:43 -07:00
Koushik Dutta
db1bd07b71 docker: increment base 2023-07-27 13:32:24 -07:00
Koushik Dutta
35026f6b5b Merge branch 'main' of github.com:koush/scrypted 2023-07-27 13:25:37 -07:00
Koushik Dutta
9160efc2f7 docker: allow pip to install to system 2023-07-27 13:25:33 -07:00
Koushik Dutta
6bc1e6a742 Update docker-common.yml 2023-07-27 13:16:45 -07:00
Koushik Dutta
475e4a60d7 Update docker-common.yml 2023-07-27 13:15:31 -07:00
Koushik Dutta
1f2edf1a12 Update docker-common.yml 2023-07-27 13:05:38 -07:00
Koushik Dutta
b3db0aa78f Update docker-common.yml 2023-07-27 13:05:21 -07:00
Koushik Dutta
0766d67a75 docker: move coral to full image only 2023-07-27 12:52:46 -07:00
Koushik Dutta
d2ac428916 server: publish 2023-07-26 17:35:36 -07:00
Koushik Dutta
945fb16bd6 postrelease 2023-07-26 17:35:19 -07:00
213 changed files with 17700 additions and 30828 deletions

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

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

View File

@@ -6,11 +6,11 @@ on:
jobs:
build:
name: Push Docker image to Docker Hub
# runs-on: self-hosted
runs-on: ubuntu-latest
runs-on: self-hosted
# runs-on: ubuntu-latest
strategy:
matrix:
NODE_VERSION: ["18"]
NODE_VERSION: ["18", "20"]
BASE: ["jammy"]
FLAVOR: ["full", "lite", "thin"]
steps:
@@ -20,28 +20,27 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# - name: Set up SSH
# uses: MrSquaare/ssh-setup-action@v2
# with:
# host: 192.168.2.124
# private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
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: 192.168.2.119
# private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
host: ${{ secrets.DOCKER_SSH_HOST_ARM7 }}
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# with:
# platforms: linux/arm64,linux/armhf
# append: |
# - endpoint: ssh://koush@192.168.2.124
# # platforms: linux/arm64
# platforms: linux/arm64
# # - endpoint: ssh://koush@192.168.2.119
# # platforms: linux/armhf
with:
platforms: linux/arm64,linux/armhf
append: |
- endpoint: ssh://${{ secrets.DOCKER_SSH_USER }}@${{ secrets.DOCKER_SSH_HOST_ARM64 }}
platforms: linux/arm64
- endpoint: ssh://${{ secrets.DOCKER_SSH_USER }}@${{ secrets.DOCKER_SSH_HOST_ARM7 }}
platforms: linux/armhf
- name: Login to Docker Hub
uses: docker/login-action@v2

View File

@@ -15,11 +15,11 @@ on:
jobs:
build:
name: Push Docker image to Docker Hub
# runs-on: self-hosted
runs-on: ubuntu-latest
runs-on: self-hosted
# 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
@@ -39,29 +39,28 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# - name: Set up SSH
# uses: MrSquaare/ssh-setup-action@v2
# with:
# host: 192.168.2.124
# private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
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: 192.168.2.119
# private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up SSH
uses: MrSquaare/ssh-setup-action@v2
with:
host: ${{ secrets.DOCKER_SSH_HOST_ARM7 }}
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# with:
# platforms: linux/arm64,linux/armhf
# append: |
# - endpoint: ssh://koush@192.168.2.124
# # platforms: linux/arm64
# platforms: linux/arm64
# # - endpoint: ssh://koush@192.168.2.119
# # platforms: linux/armhf
with:
platforms: linux/arm64,linux/armhf
append: |
- endpoint: ssh://${{ secrets.DOCKER_SSH_USER }}@${{ secrets.DOCKER_SSH_HOST_ARM64 }}
platforms: linux/arm64
- 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:

2
.gitmodules vendored
View File

@@ -33,5 +33,5 @@
path = plugins/sample-cameraprovider
url = ../../koush/scrypted-sample-cameraprovider
[submodule "plugins/cloud/node-nat-upnp"]
path = plugins/cloud/node-nat-upnp
path = plugins/cloud/external/node-nat-upnp
url = ../../koush/node-nat-upnp.git

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/common",
"version": "1.0.1",
"version": "1.0.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/common",
"version": "1.0.1",
"version": "1.0.2",
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",

View File

@@ -1,5 +1,6 @@
{
"name": "@scrypted/common",
"private": true,
"version": "1.0.1",
"description": "",
"main": "index.js",

171
common/src/async-queue.ts Normal file
View File

@@ -0,0 +1,171 @@
import { Deferred } from "./deferred";
class EndError extends Error {
}
export function createAsyncQueue<T>() {
let ended: Error | undefined;
const waiting: Deferred<T>[] = [];
const queued: { item: T, dequeued?: Deferred<void> }[] = [];
const dequeue = async () => {
if (queued.length) {
const { item, dequeued: enqueue } = queued.shift()!;
enqueue?.resolve();
return item;
}
if (ended)
throw ended;
const deferred = new Deferred<T>();
waiting.push(deferred);
return deferred.promise;
}
const submit = (item: T, dequeued?: Deferred<void>, signal?: AbortSignal) => {
if (ended)
return false;
if (waiting.length) {
const deferred = waiting.shift();
dequeued?.resolve();
deferred.resolve(item);
return true;
}
const qi = {
item,
dequeued,
};
queued!.push(qi);
signal?.addEventListener('abort', () => {
const index = queued.indexOf(qi);
if (index === -1)
return;
queued.splice(index, 1);
dequeued?.reject(new Error('abort'));
});
return true;
}
function queue() {
return (async function* () {
while (true) {
try {
const item = await dequeue();
yield item;
}
catch (e) {
if (e instanceof EndError)
return;
throw e;
}
}
})();
}
function clear(error?: Error) {
const ret: T[] = [];
const items = queued.splice(0, queued.length);
for (const item of items) {
if (error)
item.dequeued?.reject(error)
else
item.dequeued?.resolve(undefined);
ret.push(item.item);
}
return ret;
}
return {
clear() {
return clear();
},
queued,
async pipe(callback: (i: T) => void) {
for await (const i of queue()) {
callback(i as any);
}
},
submit(item: T, signal?: AbortSignal) {
return submit(item, undefined, signal);
},
end(e?: Error) {
if (ended)
return false;
// catch to prevent unhandled rejection.
ended = e || new EndError()
clear(e);
return true;
},
async enqueue(item: T, signal?: AbortSignal) {
const dequeued = new Deferred<void>();
if (!submit(item, dequeued, signal))
return false;
await dequeued.promise;
return true;
},
dequeue,
get queue() {
return queue();
}
}
}
// async function testSlowEnqueue() {
// const asyncQueue = createAsyncQueue<number>();
// asyncQueue.submit(-1);
// asyncQueue.submit(-1);
// asyncQueue.submit(-1);
// asyncQueue.submit(-1);
// (async () => {
// console.log('go');
// for (let i = 0; i < 10; i++) {
// asyncQueue.submit(i);
// await sleep(100);
// }
// asyncQueue.end(new Error('fail'));
// })();
// const runQueue = async (str?: string) => {
// for await (const n of asyncQueue.queue) {
// console.log(str, n);
// }
// }
// runQueue('start');
// setTimeout(runQueue, 400);
// }
// async function testSlowDequeue() {
// const asyncQueue = createAsyncQueue<number>();
// const runQueue = async (str?: string) => {
// for await (const n of asyncQueue.queue) {
// await sleep(100);
// }
// }
// runQueue()
// .catch(e => console.error('queue threw', e));
// console.log('go');
// for (let i = 0; i < 10; i++) {
// console.log(await asyncQueue.enqueue(i));
// console.log(i);
// }
// asyncQueue.end(new Error('fail'));
// console.log(await asyncQueue.enqueue(555));
// }
// testSlowDequeue();

View File

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

View File

@@ -51,14 +51,8 @@ function silence() {
return ret;
}
export class BrowserSignalingSession implements RTCSignalingSession {
private pc: RTCPeerConnection;
pcDeferred = new Deferred<RTCPeerConnection>();
dcDeferred = new Deferred<RTCDataChannel>();
microphone: RTCRtpSender;
micEnabled = false;
onPeerConnection: (pc: RTCPeerConnection) => Promise<void>;
options: RTCSignalingOptions = {
function createOptions() {
const options: RTCSignalingOptions = {
userAgent: getUserAgent(),
capabilities: {
audio: RTCRtpReceiver.getCapabilities?.('audio') || {
@@ -76,6 +70,18 @@ export class BrowserSignalingSession implements RTCSignalingSession {
height: screen.height,
},
};
return options;
}
export class BrowserSignalingSession implements RTCSignalingSession {
private pc: RTCPeerConnection;
pcDeferred = new Deferred<RTCPeerConnection>();
dcDeferred = new Deferred<RTCDataChannel>();
microphone: RTCRtpSender;
micEnabled = false;
onPeerConnection: (pc: RTCPeerConnection) => Promise<void>;
__proxy_props = { options: createOptions() };
options = createOptions();
constructor() {
}
@@ -284,6 +290,10 @@ function createCandidateQueue(console: Console, type: string, session: RTCSignal
}
}
export async function legacyGetSignalingSessionOptions(session: RTCSignalingSession) {
return typeof session.options === 'object' ? session.options : await session.getOptions();
}
export async function connectRTCSignalingClients(
console: Console,
offerClient: RTCSignalingSession,
@@ -291,8 +301,8 @@ export async function connectRTCSignalingClients(
answerClient: RTCSignalingSession,
answerSetup: Partial<RTCAVSignalingSetup>
) {
const offerOptions = await offerClient.getOptions();
const answerOptions = await answerClient.getOptions();
const offerOptions = await legacyGetSignalingSessionOptions(offerClient);
const answerOptions = await legacyGetSignalingSessionOptions(answerClient);
const disableTrickle = offerOptions?.disableTrickle || answerOptions?.disableTrickle;
if (offerOptions?.offer && answerOptions?.offer)

View File

@@ -1,6 +1,6 @@
# Home Assistant Addon Configuration
name: Scrypted
version: "18-jammy-full.s6-v0.39.4"
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"
@@ -35,6 +35,7 @@ backup_exclude:
map:
- config:rw
- media:rw
- share:rw
devices:
- /dev/mem
- /dev/dri/renderD128

View File

@@ -36,12 +36,6 @@ RUN apt-get -y install \
python3-setuptools \
python3-wheel
# Coral Edge TPU
# https://coral.ai/docs/accelerator/get-started/#runtime-on-linux
RUN echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | tee /etc/apt/sources.list.d/coral-edgetpu.list
RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
RUN apt-get -y update && apt-get -y install libedgetpu1-std
# these are necessary for pillow-simd, additional on disk size is small
# but could consider removing this.
RUN apt-get -y install \
@@ -74,13 +68,12 @@ RUN apt-get -y install \
python3-pil \
python3-skimage
# python pip
# allow pip to install to system
RUN rm -f /usr/lib/python**/EXTERNALLY-MANAGED
# pyvips is broken on x86 due to mismatch ffi
# https://stackoverflow.com/questions/62658237/it-seems-that-the-version-of-the-libffi-library-seen-at-runtime-is-different-fro
RUN rm -f /usr/lib/python**/EXTERNALLY-MANAGED
RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install --force-reinstall --no-binary :all: cffi
RUN python3 -m pip install debugpy typing_extensions psutil
@@ -113,10 +106,19 @@ RUN add-apt-repository ppa:deadsnakes/ppa && \
python3.9-dev \
python3.9-distutils
# allow pip to install to system
RUN rm -f /usr/lib/python**/EXTERNALLY-MANAGED
RUN python3.9 -m pip install --upgrade pip
RUN python3.9 -m pip install --force-reinstall --no-binary :all: cffi
RUN python3.9 -m pip install debugpy typing_extensions psutil
# Coral Edge TPU
# https://coral.ai/docs/accelerator/get-started/#runtime-on-linux
RUN echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | tee /etc/apt/sources.list.d/coral-edgetpu.list
RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
RUN apt-get -y update && apt-get -y install libedgetpu1-std
ENV SCRYPTED_INSTALL_ENVIRONMENT="docker"
ENV SCRYPTED_CAN_RESTART="true"
ENV SCRYPTED_VOLUME="/server/volume"
@@ -127,7 +129,7 @@ ENV SCRYPTED_FFMPEG_PATH="/usr/bin/ffmpeg"
# changing this forces pip and npm to perform reinstalls.
# if this base image changes, this version must be updated.
ENV SCRYPTED_BASE_VERSION="20230608"
ENV SCRYPTED_BASE_VERSION="20230727"
ENV SCRYPTED_DOCKER_FLAVOR="full"
################################################################

View File

@@ -43,5 +43,5 @@ ENV SCRYPTED_FFMPEG_PATH="/usr/bin/ffmpeg"
# changing this forces pip and npm to perform reinstalls.
# if this base image changes, this version must be updated.
ENV SCRYPTED_BASE_VERSION="20230608"
ENV SCRYPTED_BASE_VERSION="20230727"
ENV SCRYPTED_DOCKER_FLAVOR="lite"

View File

@@ -21,5 +21,5 @@ ENV SCRYPTED_FFMPEG_PATH="/usr/bin/ffmpeg"
# changing this forces pip and npm to perform reinstalls.
# if this base image changes, this version must be updated.
ENV SCRYPTED_BASE_VERSION="20230608"
ENV SCRYPTED_BASE_VERSION="20230727"
ENV SCRYPTED_DOCKER_FLAVOR="thin"

View File

@@ -50,7 +50,7 @@ services:
# Modify to add the additional volume for Scrypted NVR.
# The following example would mount the /mnt/sda/video path on the host
# to the /nvr path inside the docker container.
# - /mnt/sda/video:/nvr
# - /mnt/media/video:/nvr
# Or use a network mount from one of the CIFS/NFS examples at the top of this file.
# - type: volume
@@ -67,17 +67,25 @@ services:
# Default volume for the Scrypted database. Typically should not be changed.
- ~/.scrypted/volume:/server/volume
devices:
devices: [
# uncomment the common systems devices to pass
# them through to docker.
# all usb devices, such as coral tpu
- /dev/bus/usb:/dev/bus/usb
# "/dev/bus/usb:/dev/bus/usb",
# hardware accelerated video decoding, opencl, etc.
# - /dev/dri:/dev/dri
# "/dev/dri:/dev/dri",
# uncomment below as necessary.
# zwave usb serial device
# - /dev/ttyACM0:/dev/ttyACM0
# "/dev/ttyACM0:/dev/ttyACM0",
# coral PCI devices
# - /dev/apex_0:/dev/apex_0
# - /dev/apex_1:/dev/apex_1
# "/dev/apex_0:/dev/apex_0",
# "/dev/apex_1:/dev/apex_1",
]
container_name: scrypted
restart: unless-stopped

View File

@@ -23,10 +23,19 @@ RUN add-apt-repository ppa:deadsnakes/ppa && \
python3.9-dev \
python3.9-distutils
# allow pip to install to system
RUN rm -f /usr/lib/python**/EXTERNALLY-MANAGED
RUN python3.9 -m pip install --upgrade pip
RUN python3.9 -m pip install --force-reinstall --no-binary :all: cffi
RUN python3.9 -m pip install debugpy typing_extensions psutil
# Coral Edge TPU
# https://coral.ai/docs/accelerator/get-started/#runtime-on-linux
RUN echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | tee /etc/apt/sources.list.d/coral-edgetpu.list
RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
RUN apt-get -y update && apt-get -y install libedgetpu1-std
ENV SCRYPTED_INSTALL_ENVIRONMENT="docker"
ENV SCRYPTED_CAN_RESTART="true"
ENV SCRYPTED_VOLUME="/server/volume"
@@ -37,7 +46,7 @@ ENV SCRYPTED_FFMPEG_PATH="/usr/bin/ffmpeg"
# changing this forces pip and npm to perform reinstalls.
# if this base image changes, this version must be updated.
ENV SCRYPTED_BASE_VERSION="20230608"
ENV SCRYPTED_BASE_VERSION="20230727"
ENV SCRYPTED_DOCKER_FLAVOR="full"
################################################################

View File

@@ -33,12 +33,6 @@ RUN apt-get -y install \
python3-setuptools \
python3-wheel
# Coral Edge TPU
# https://coral.ai/docs/accelerator/get-started/#runtime-on-linux
RUN echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | tee /etc/apt/sources.list.d/coral-edgetpu.list
RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
RUN apt-get -y update && apt-get -y install libedgetpu1-std
# these are necessary for pillow-simd, additional on disk size is small
# but could consider removing this.
RUN apt-get -y install \
@@ -71,13 +65,12 @@ RUN apt-get -y install \
python3-pil \
python3-skimage
# python pip
# allow pip to install to system
RUN rm -f /usr/lib/python**/EXTERNALLY-MANAGED
# pyvips is broken on x86 due to mismatch ffi
# https://stackoverflow.com/questions/62658237/it-seems-that-the-version-of-the-libffi-library-seen-at-runtime-is-different-fro
RUN rm -f /usr/lib/python**/EXTERNALLY-MANAGED
RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install --force-reinstall --no-binary :all: cffi
RUN python3 -m pip install debugpy typing_extensions psutil

View File

@@ -58,6 +58,9 @@ brew unpin gst-python
### END HACK WORKAROUND
# seems to be necessary for python-codecs' pycairo dependency or something?
RUN_IGNORE gobject-introspection libffi pkg-config
# gstreamer plugins
RUN_IGNORE brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
# gst python bindings

View File

@@ -19,11 +19,11 @@
"-r",
"ts-node/register"
],
"preLaunchTask": "npm: build",
"args": [
"ffplay",
"Kitchen",
"getRecordingStream",
"{\"startTime\":1677699495709}"
"Baby Camera@192.168.2.109",
"getVideoStream",
],
"sourceMaps": true,
"resolveSourceMapLocations": [
@@ -35,4 +35,4 @@
],
},
]
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "scrypted",
"version": "1.0.67",
"version": "1.0.69",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scrypted",
"version": "1.0.67",
"version": "1.0.69",
"license": "ISC",
"dependencies": {
"@scrypted/client": "^1.1.43",

View File

@@ -1,6 +1,6 @@
{
"name": "scrypted",
"version": "1.0.67",
"version": "1.0.69",
"description": "",
"main": "./dist/main.js",
"bin": {

View File

@@ -172,8 +172,11 @@ async function main() {
ffmpegInput.inputArguments = ffmpegInput.inputArguments.map(i => i === ffmpegInput.url ? ffmpegInput.urls?.[0] : i);
}
}
console.log('ffplay', ...ffmpegInput.inputArguments);
child_process.spawn('ffplay', ffmpegInput.inputArguments, {
const args = [...ffmpegInput.inputArguments];
if (ffmpegInput.h264FilterArguments)
args.push(...ffmpegInput.h264FilterArguments);
console.log('ffplay', ...args);
child_process.spawn('ffplay', args, {
stdio: 'inherit',
});
sdk.disconnect();

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { MediaObjectOptions, RTCConnectionManagement, RTCSignalingSession, ScryptedStatic } from "@scrypted/types";
import axios, { AxiosRequestConfig } from 'axios';
import axios, { AxiosRequestConfig, AxiosRequestHeaders } from 'axios';
import * as eio from 'engine.io-client';
import { SocketOptions } from 'engine.io-client';
import { Deferred } from "../../../common/src/deferred";
@@ -8,7 +8,6 @@ import { BrowserSignalingSession, waitPeerConnectionIceConnected, waitPeerIceCon
import { DataChannelDebouncer } from "../../../plugins/webrtc/src/datachannel-debouncer";
import type { IOSocket } from '../../../server/src/io';
import { MediaObject } from '../../../server/src/plugin/mediaobject';
import type { MediaObjectRemote } from '../../../server/src/plugin/plugin-api';
import { attachPluginRemote } from '../../../server/src/plugin/plugin-remote';
import { RpcPeer } from '../../../server/src/rpc';
import { createRpcDuplexSerializer, createRpcSerializer } from '../../../server/src/rpc-serializer';
@@ -48,9 +47,8 @@ export interface ScryptedClientStatic extends ScryptedStatic {
browserSignalingSession?: BrowserSignalingSession;
address?: string;
connectionType: ScryptedClientConnectionType;
authorization?: string;
queryToken?: { [parameter: string]: string };
rpcPeer: RpcPeer,
rpcPeer: RpcPeer;
loginResult: ScryptedClientLoginResult;
}
export interface ScryptedConnectionOptions {
@@ -59,6 +57,7 @@ export interface ScryptedConnectionOptions {
webrtc?: boolean;
baseUrl?: string;
axiosConfig?: AxiosRequestConfig;
previousLoginResult?: ScryptedClientLoginResult;
}
export interface ScryptedLoginOptions extends ScryptedConnectionOptions {
@@ -133,34 +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'];
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,
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'];
return {
baseUrl,
hostname: response.data.hostname as string,
redirect: response.data.redirect as string,
username: response.data.username as string,
@@ -171,11 +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,
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);
@@ -211,46 +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,
@@ -263,25 +359,45 @@ 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 ((tryAlternateAddresses && options.direct === undefined) || options.direct) {
if (cloudAddress)
addresses.push(cloudAddress);
for (const externalAddress of externalAddresses || []) {
addresses.push(externalAddress);
}
}
const tryAddresses = !!addresses.length;
const tryWebrtc = !!globalThis.RTCPeerConnection && (scryptedCloud && options.webrtc === undefined) || options.webrtc;
const webrtcLastFailedKey = 'webrtcLastFailed';
const canUseWebrtc = !!globalThis.RTCPeerConnection;
let tryWebrtc = canUseWebrtc && options.webrtc;
// try webrtc by default on scrypted cloud.
// but webrtc takes a while to fail, so backoff if it fails to prevent continual slow starts.
if (scryptedCloud && canUseWebrtc && globalThis.localStorage && options.webrtc === undefined) {
tryWebrtc = true;
const webrtcLastFailed = parseFloat(localStorage.getItem(webrtcLastFailedKey));
// if webrtc has failed in the past day, dont attempt to use it.
const now = Date.now();
if (webrtcLastFailed < now && webrtcLastFailed > now - 1 * 24 * 60 * 60 * 1000) {
tryWebrtc = false;
console.warn('WebRTC API connection recently failed. Skipping.')
}
}
console.log({
tryLocalAddressess: tryAddresses,
tryWebrtc,
@@ -309,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);
@@ -328,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,
@@ -458,7 +577,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
const p2pPromises = [...promises];
promises.push((async () => {
const waitDuration = tryWebrtc ? 3000 : (tryAddresses ? 1000 : 0);
const waitDuration = tryWebrtc ? 10000 : (tryAddresses ? 1000 : 0);
console.log('waiting', waitDuration);
if (waitDuration) {
// give the peer to peers a second, but then try connecting directly.
@@ -478,6 +597,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
await once(check, 'open');
return {
ready: check,
address: explicitBaseUrl,
connectionType: 'http',
};
})());
@@ -485,6 +605,9 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
const any = Promise.any(promises);
let { ready, connectionType, address, rpcPeer } = await any;
if (tryWebrtc && connectionType !== 'webrtc')
localStorage.setItem(webrtcLastFailedKey, Date.now().toString());
console.log('connected', connectionType, address)
socket = ready;
@@ -602,9 +725,18 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
pluginHostAPI: undefined,
rtcConnectionManagement,
browserSignalingSession,
authorization,
queryToken,
rpcPeer,
loginResult: {
username,
token,
directAddress,
localAddresses,
externalAddresses,
scryptedCloud,
queryToken,
authorization,
cloudAddress,
}
}
socket.on('close', () => {

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/rpc",
"version": "0.0.2",
"version": "0.0.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/rpc",
"version": "0.0.2",
"version": "0.0.4",
"license": "ISC",
"devDependencies": {
"@types/node": "^18.11.18",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/deferred",
"version": "0.0.2",
"version": "0.0.4",
"description": "",
"main": "dist/index.js",
"scripts": {

View File

@@ -0,0 +1 @@
../../../common/src/async-queue.ts

View File

@@ -0,0 +1 @@
../../../common/src/deferred.ts

View File

@@ -1 +0,0 @@
../../../common/src/deferred.ts

View File

@@ -0,0 +1,2 @@
export * from './deferred';
export * from './async-queue';

View File

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

View File

@@ -1,16 +1,17 @@
{
"name": "@scrypted/h264-packetizer",
"name": "@scrypted/h264-repacketizer",
"version": "0.0.7",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/h264-packetizer",
"name": "@scrypted/h264-repacketizer",
"version": "0.0.7",
"license": "ISC",
"devDependencies": {
"@types/node": "^18.11.18",
"rimraf": "^4.1.1",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
}
},
@@ -43,12 +44,121 @@
"../sdk/types": {
"extraneous": true
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
"integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
"dev": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
"dev": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==",
"dev": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"node_modules/@types/node": {
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==",
"dev": true
},
"node_modules/acorn": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
"integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
},
"node_modules/rimraf": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.1.1.tgz",
@@ -64,6 +174,49 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ts-node": {
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
"dev": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
@@ -76,26 +229,165 @@
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"engines": {
"node": ">=6"
}
}
},
"dependencies": {
"@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"requires": {
"@jridgewell/trace-mapping": "0.3.9"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
"integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
"dev": true
},
"@jridgewell/sourcemap-codec": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
"dev": true
},
"@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"requires": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==",
"dev": true
},
"@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true
},
"@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true
},
"@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"@types/node": {
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==",
"dev": true
},
"acorn": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
"integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
"dev": true
},
"acorn-walk": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
"dev": true
},
"arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
},
"create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true
},
"make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
},
"rimraf": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.1.1.tgz",
"integrity": "sha512-Z4Y81w8atcvaJuJuBB88VpADRH66okZAuEm+Jtaufa+s7rZmIz+Hik2G53kGaNytE7lsfXyWktTmfVz0H9xuDg==",
"dev": true
},
"ts-node": {
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
"dev": true,
"requires": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
}
},
"typescript": {
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
"dev": true
},
"v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
},
"yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true
}
}
}

View File

@@ -14,6 +14,7 @@
"devDependencies": {
"@types/node": "^18.11.18",
"rimraf": "^4.1.1",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
}
}

View File

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

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2019",
"target": "ES2020",
"noImplicitAny": true,
"outDir": "./dist",
"esModuleInterop": true,
@@ -10,6 +10,7 @@
"declaration": true,
"resolveJsonModule": true,
},
"exclude": ["**/node_modules"],
"include": [
"src/**/*"
],

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/alexa",
"version": "0.2.6",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/alexa",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"axios": "^1.3.4",
"uuid": "^9.0.0"
@@ -18,7 +18,7 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.2.101",
"version": "0.2.104",
"dev": true,
"license": "ISC",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/alexa",
"version": "0.2.6",
"version": "0.2.8",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",

View File

@@ -392,14 +392,21 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
})
}
private setReauthenticateAlert() {
const msg: string = "Please reauthenticate by following the directions below.";
this.log.a(msg);
}
getAccessToken(): Promise<string> {
if (this.accessToken)
return this.accessToken;
this.log.clearAlerts();
const { tokenInfo } = this.storageSettings.values;
if (tokenInfo === undefined) {
this.log.e("Please reauthenticate by following the directions below.");
this.setReauthenticateAlert();
throw new Error("'tokenInfo' is undefined");
}
@@ -432,19 +439,19 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
case 'invalid_grant':
case 'unauthorized_client':
self.console.error(error?.response?.data);
self.log.e(error?.response?.data?.error_description);
self.log.a(error?.response?.data?.error_description);
self.storageSettings.values.tokenInfo = undefined;
self.accessToken = undefined;
break;
case 'authorization_pending':
self.console.warn(error?.response?.data);
self.log.w(error?.response?.data?.error_description);
self.log.a(error?.response?.data?.error_description);
break;
case 'expired_token':
self.console.warn(error?.response?.data);
self.log.w(error?.response?.data?.error_description);
self.log.a(error?.response?.data?.error_description);
self.accessToken = undefined;
break;
@@ -488,6 +495,8 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
});
if (accessToken !== undefined) {
this.log.clearAlerts();
try {
response.send({
"event": {

View File

@@ -120,7 +120,7 @@ export async function getCameraCapabilities(device: ScryptedDevice): Promise<Dis
"interface": "Alexa.RTCSessionController",
"version": "3",
"configuration": {
isFullDuplexAudioSupported: true,
"isFullDuplexAudioSupported": true,
}
} as DiscoveryCapability
];

View File

@@ -7,10 +7,19 @@ import { Response, WebRTCAnswerGeneratedForSessionEvent, WebRTCSessionConnectedE
export class AlexaSignalingSession implements RTCSignalingSession {
constructor(public response: AlexaHttpResponse, public directive: any) {
this.options = this.createOptions();
this.__proxy_props = { options: this.createOptions() };
}
__proxy_props: { options: RTCSignalingOptions; };
options: RTCSignalingOptions;
async getOptions(): Promise<RTCSignalingOptions> {
return {
return this.options;
}
private createOptions() {
const options: RTCSignalingOptions = {
proxy: true,
offer: {
type: 'offer',
@@ -24,7 +33,9 @@ export class AlexaSignalingSession implements RTCSignalingSession {
width: 1280,
height: 720
}
}
};
return options;
}
async createLocalDescription(type: "offer" | "answer", setup: RTCAVSignalingSetup, sendIceCandidate: RTCSignalingSendIceCandidate): Promise<RTCSessionDescriptionInit> {

View File

@@ -6,7 +6,7 @@ import { supportedTypes } from ".";
supportedTypes.set(ScryptedDeviceType.Doorbell, {
async discover(device: ScryptedDevice): Promise<Partial<DiscoveryEndpoint>> {
let capabilities: any[] = [];
const displayCategories: DisplayCategory[] = ['DOORBELL'];
const displayCategories: DisplayCategory[] = [];
if (device.interfaces.includes(ScryptedInterface.RTCSignalingChannel)) {
capabilities = await getCameraCapabilities(device);
@@ -24,6 +24,9 @@ supportedTypes.set(ScryptedDeviceType.Doorbell, {
);
}
// Important: If your device is a video doorbell, make sure that you list CAMERA before DOORBELL in the displayCategories list.
displayCategories.push('DOORBELL');
return {
displayCategories,
capabilities
@@ -38,6 +41,9 @@ supportedTypes.set(ScryptedDeviceType.Doorbell, {
if (response)
return response;
if (eventDetails.eventInterface === ScryptedInterface.BinarySensor && eventData === false)
return {};
if (eventDetails.eventInterface === ScryptedInterface.BinarySensor && eventData === true)
return {
event: {

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"module": "commonjs",
"module": "Node16",
"target": "ES2021",
"resolveJsonModule": true,
"moduleResolution": "Node16",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +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.
## General Setup Notes
* Ensure that your Arlo account's default 2FA option is set to either SMS or email.
* Motion events notifications should be turned on in the Arlo app. If you are receiving motion push notifications, Scrypted will also receive motion events.
* Disable smart detection and any cloud/local recording in the Arlo app. Arlo Cloud only permits one active stream per camera, so any smart detection or recording features may prevent downstream plugins (e.g. Homekit) from successfully pulling the video feed after a motion event.
* It is highly recommended to enable the Rebroadcast plugin to allow multiple downstream plugins to pull the video feed within Scrypted.
* If there is no audio on your camera, switch to the `FFmpeg (TCP)` parser under the `Cloud RTSP` settings.
* Prebuffering should only be enabled if the camera is wired to a persistent power source, such as a wall outlet. Prebuffering will only work if your camera does not have a battery or `Plugged In to External Power` is selected.
* The plugin supports pulling RTSP or DASH streams from Arlo Cloud. It is recommended to use RTSP for the lowest latency streams. DASH is inconsistent in reliability, and may return finicky codecs that require additional FFmpeg output arguments, e.g. `-vcodec h264`. *Note that both RTSP and DASH will ultimately pull the same video stream feed from your camera, and they cannot both be used at the same time due to the single stream limitation.*
Note that streaming cameras uses extra Internet bandwidth, since video and audio packets will need to travel from the camera through your network, out to Arlo Cloud, and then back to your network and into Scrypted.
## IMAP 2FA
The Arlo Plugin supports using the IMAP protocol to check an email mailbox for Arlo 2FA codes. This requires you to specify an email 2FA option as the default in your Arlo account settings.
The plugin should work with any mailbox that supports IMAP, but so far has been tested with Gmail. To configure a Gmail mailbox, see [here](https://support.google.com/mail/answer/7126229?hl=en) to see the Gmail IMAP settings, and [here](https://support.google.com/accounts/answer/185833?hl=en) to create an App Password. Enter the App Password in place of your normal Gmail password.
The plugin searches for emails sent by Arlo's `do_not_reply@arlo.com` address when looking for 2FA codes. If you are using a service to forward emails to the mailbox registered with this plugin (e.g. a service like iCloud's Hide My Email), it is possible that Arlo's email sender address has been overwritten by the mail forwarder. Check the email registered with this plugin to see what address the mail forwarder uses to replace Arlo's sender address, and update that in the IMAP 2FA settings.
## Virtual Security System for Arlo Sirens
In external integrations like Homekit, sirens are exposed as simple on-off switches. This makes it easy to accidentally hit the switch when using the Home app. The Arlo Plugin creates a "virtual" security system device per siren to allow Scrypted to arm or disarm the siren switch to protect against accidental triggers. This fake security system device will be synced into Homekit as a separate accessory from the camera, with the siren itself merged into the security system accessory.
Note that the virtual security system is NOT tied to your Arlo account at all, and will not make any changes such as switching your device's motion alert armed/disarmed modes. For more information, please see the README on the virtual security system device in Scrypted.
## Video Clips
The Arlo Plugin will show video clips available in Arlo Cloud for cameras with cloud recording enabled. These clips are not downloaded onto your Scrypted server, but rather streamed on-demand. Deleting clips is not available in Scrypted and should be done through the Arlo app or the Arlo web dashboard.

View File

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

View File

@@ -1,41 +0,0 @@
{
"name": "@scrypted/arlo",
"version": "0.8.11",
"description": "Arlo Plugin for Scrypted",
"license": "Apache",
"keywords": [
"scrypted",
"plugin",
"arlo",
"camera"
],
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",
"build": "scrypted-webpack",
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
"prescrypted-vscode-launch": "scrypted-webpack",
"scrypted-vscode-launch": "scrypted-deploy-debug",
"scrypted-deploy-debug": "scrypted-deploy-debug",
"scrypted-debug": "scrypted-debug",
"scrypted-deploy": "scrypted-deploy",
"scrypted-readme": "scrypted-readme",
"scrypted-package-json": "scrypted-package-json"
},
"scrypted": {
"name": "Arlo Camera Plugin",
"runtime": "python",
"type": "DeviceProvider",
"interfaces": [
"Settings",
"DeviceProvider"
],
"pluginDependencies": [
"@scrypted/snapshot",
"@scrypted/prebuffer-mixin"
]
},
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,70 +0,0 @@
import asyncio
import json
import sseclient
import threading
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
for event in event_stream:
logger.debug(f"Received event: {event}")
if event is None:
logger.info(f"SSE {id(event_stream)} appears to be broken")
return None
if event.data.strip() == "":
continue
try:
response = json.loads(event.data.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 {id(event_stream)} disconnected")
self.shutting_down_stream = None
return None
elif response.get('status') == 'connected':
if not self.connected:
logger.info(f"SSE {id(event_stream)} connected")
self.initializing = False
self.connected = True
else:
self.event_loop.call_soon_threadsafe(self._queue_response, response)
self.event_stream = sseclient.SSEClient('https://myapi.arlo.com/hmsweb/client/subscribe?token='+self.arlo.request.session.headers.get('Authorization'), session=self.arlo.request.session)
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.event_stream = None
await self.start()
while self.shutting_down_stream is not None:
# ensure any previous connections have disconnected properly
# this is so we can mark reconnecting to False properly
await asyncio.sleep(1)
self.reconnecting = False
def subscribe(self, topics):
pass

View File

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

View File

@@ -1,76 +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"]
return {
"info": {
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
"manufacturer": "Arlo",
"firmware": self.arlo_device.get("firmwareVersion"),
"serialNumber": self.arlo_device["deviceId"],
},
"nativeId": self.arlo_device["deviceId"],
"name": self.arlo_device["deviceName"],
"interfaces": self.get_applicable_interfaces(),
"type": self.get_device_type(),
"providerNativeId": parent,
}
def get_builtin_child_device_manifests(self) -> List[Device]:
"""Returns the list of child device manifests representing hardware features built into this device."""
return []

View File

@@ -1,90 +0,0 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk import ScryptedDeviceBase
from scrypted_sdk.types import Device, DeviceProvider, Setting, SettingValue, Settings, ScryptedInterface, ScryptedDeviceType
from .base import ArloDeviceBase
from .vss import ArloSirenVirtualSecuritySystem
if TYPE_CHECKING:
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
from .provider import ArloProvider
class ArloBasestation(ArloDeviceBase, DeviceProvider, Settings):
MODELS_WITH_SIRENS = [
"vmb4000",
"vmb4500"
]
vss: ArloSirenVirtualSecuritySystem = None
def __init__(self, nativeId: str, arlo_basestation: dict, provider: ArloProvider) -> None:
super().__init__(nativeId=nativeId, arlo_device=arlo_basestation, arlo_basestation=arlo_basestation, provider=provider)
@property
def has_siren(self) -> bool:
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloBasestation.MODELS_WITH_SIRENS])
def get_applicable_interfaces(self) -> List[str]:
return [
ScryptedInterface.DeviceProvider.value,
ScryptedInterface.Settings.value,
]
def get_device_type(self) -> str:
return ScryptedDeviceType.DeviceProvider.value
def get_builtin_child_device_manifests(self) -> List[Device]:
if not self.has_siren:
# this basestation has no builtin siren, so no manifests to return
return []
vss = self.get_or_create_vss()
return [
{
"info": {
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
"manufacturer": "Arlo",
"firmware": self.arlo_device.get("firmwareVersion"),
"serialNumber": self.arlo_device["deviceId"],
},
"nativeId": vss.nativeId,
"name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System',
"interfaces": vss.get_applicable_interfaces(),
"type": vss.get_device_type(),
"providerNativeId": self.nativeId,
},
] + vss.get_builtin_child_device_manifests()
async def getDevice(self, nativeId: str) -> ScryptedDeviceBase:
if not nativeId.startswith(self.nativeId):
# must be a camera, so get it from the provider
return await self.provider.getDevice(nativeId)
if not nativeId.endswith("vss"):
return None
return self.get_or_create_vss()
def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem:
vss_id = f'{self.arlo_device["deviceId"]}.vss'
if not self.vss:
self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self)
return self.vss
async def getSettings(self) -> List[Setting]:
return [
{
"group": "General",
"key": "print_debug",
"title": "Debug Info",
"description": "Prints information about this device to console.",
"type": "button",
}
]
async def putSetting(self, key: str, value: SettingValue) -> None:
if key == "print_debug":
self.logger.info(f"Device Capabilities: {self.arlo_capabilities}")
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)

View File

@@ -1,904 +0,0 @@
from __future__ import annotations
import asyncio
import aiohttp
from async_timeout import timeout as async_timeout
from datetime import datetime, timedelta
import json
import socket
import time
import threading
from typing import List, TYPE_CHECKING
import scrypted_arlo_go
import scrypted_sdk
from scrypted_sdk.types import Setting, Settings, SettingValue, Device, Camera, VideoCamera, RequestMediaStreamOptions, VideoClips, VideoClip, VideoClipOptions, MotionSensor, AudioSensor, Battery, Charger, ChargeState, DeviceProvider, MediaObject, ResponsePictureOptions, ResponseMediaStreamOptions, ScryptedMimeTypes, ScryptedInterface, ScryptedDeviceType
from .arlo.arlo_async import USER_AGENTS
from .base import ArloDeviceBase
from .spotlight import ArloSpotlight, ArloFloodlight, ArloNightlight
from .vss import ArloSirenVirtualSecuritySystem
from .child_process import HeartbeatChildProcess
from .util import BackgroundTaskMixin, 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
class ArloCameraIntercomSession(BackgroundTaskMixin):
def __init__(self, camera: ArloCamera) -> None:
super().__init__()
self.camera = camera
self.logger = camera.logger
self.provider = camera.provider
self.arlo_device = camera.arlo_device
self.arlo_basestation = camera.arlo_basestation
async def initialize_push_to_talk(self, media: MediaObject) -> None:
raise NotImplementedError("not implemented")
async def shutdown(self) -> None:
raise NotImplementedError("not implemented")
class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider, VideoClips, MotionSensor, AudioSensor, Battery, Charger):
MODELS_WITH_SPOTLIGHTS = [
"vmc2030",
"vmc2032",
"vmc4040p",
"vmc4041p",
"vmc4050p",
"vmc4060p",
"vmc5040",
"vml2030",
"vml4030",
]
MODELS_WITH_FLOODLIGHTS = ["fb1001"]
MODELS_WITH_NIGHTLIGHTS = [
"abc1000",
"abc1000a",
]
MODELS_WITH_SIRENS = [
"fb1001",
"vmc2020",
"vmc2030",
"vmc2032",
"vmc4030",
"vmc4030p",
"vmc4040p",
"vmc4041p",
"vmc4050p",
"vmc4060p",
"vmc5040",
"vml2030",
"vml4030",
]
MODELS_WITH_AUDIO_SENSORS = [
"abc1000",
"abc1000a",
"fb1001",
"vmc3040",
"vmc3040s",
"vmc4030",
"vmc4030p",
"vmc4040p",
"vmc4041p",
"vmc4050p",
"vmc5040",
"vml4030",
]
MODELS_WITHOUT_BATTERY = [
"avd1001",
"vmc2040",
"vmc3040",
"vmc3040s",
]
timeout: int = 30
intercom_session: ArloCameraIntercomSession = None
light: ArloSpotlight = None
vss: ArloSirenVirtualSecuritySystem = None
# eco mode bookkeeping
picture_lock: asyncio.Lock = None
last_picture: bytes = None
last_picture_time: datetime = datetime(1970, 1, 1)
# socket logger
logger_loop: asyncio.AbstractEventLoop = None
logger_server: asyncio.AbstractServer = None
logger_server_port: int = 0
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.picture_lock = asyncio.Lock()
self.start_error_subscription()
self.start_motion_subscription()
self.start_audio_subscription()
self.start_battery_subscription()
self.create_task(self.delayed_init())
def __del__(self) -> None:
super().__del__()
def logger_exit_callback():
self.logger_server.close()
self.logger_loop.stop()
self.logger_loop.close()
self.logger_loop.call_soon_threadsafe(logger_exit_callback)
async def delayed_init(self) -> None:
await self.create_tcp_logger_server()
if not self.has_battery:
return
iterations = 1
while not self.stop_subscriptions:
if iterations > 100:
self.logger.error("Delayed init exceeded iteration limit, giving up")
return
try:
self.chargeState = ChargeState.Charging.value if self.wired_to_power else ChargeState.NotCharging.value
return
except Exception as e:
self.logger.debug(f"Delayed init failed, will try again: {e}")
await asyncio.sleep(0.1)
iterations += 1
@async_print_exception_guard
async def create_tcp_logger_server(self) -> None:
self.logger_loop = asyncio.new_event_loop()
def thread_main():
asyncio.set_event_loop(self.logger_loop)
self.logger_loop.run_forever()
threading.Thread(target=thread_main).start()
# this is a bit convoluted since we need the async functions to run in the
# logger loop thread instead of in the current thread
def setup_callback():
async def callback(reader, writer):
try:
while not reader.at_eof():
line = await reader.readline()
if not line:
break
line = str(line, 'utf-8')
line = line.rstrip()
self.logger.info(line)
writer.close()
await writer.wait_closed()
except Exception:
self.logger.exception("Logger server callback raised an exception")
async def setup():
self.logger_server = await asyncio.start_server(callback, host='localhost', port=0, family=socket.AF_INET, flags=socket.SOCK_STREAM)
self.logger_server_port = self.logger_server.sockets[0].getsockname()[1]
self.logger.info(f"Started logging server at localhost:{self.logger_server_port}")
self.logger_loop.create_task(setup())
self.logger_loop.call_soon_threadsafe(setup_callback)
def start_error_subscription(self) -> None:
def callback(code, message):
self.logger.error(f"Arlo returned error code {code} with message: {message}")
return self.stop_subscriptions
self.register_task(
self.provider.arlo.SubscribeToErrorEvents(self.arlo_basestation, self.arlo_device, callback)
)
def start_motion_subscription(self) -> None:
def callback(motionDetected):
self.motionDetected = motionDetected
return self.stop_subscriptions
self.register_task(
self.provider.arlo.SubscribeToMotionEvents(self.arlo_basestation, self.arlo_device, callback, self.logger)
)
def start_audio_subscription(self) -> None:
if not self.has_audio_sensor:
return
def callback(audioDetected):
self.audioDetected = audioDetected
return self.stop_subscriptions
self.register_task(
self.provider.arlo.SubscribeToAudioEvents(self.arlo_basestation, self.arlo_device, callback, self.logger)
)
def start_battery_subscription(self) -> None:
if not self.has_battery:
return
def callback(batteryLevel):
self.batteryLevel = batteryLevel
return self.stop_subscriptions
self.register_task(
self.provider.arlo.SubscribeToBatteryEvents(self.arlo_basestation, self.arlo_device, callback)
)
def get_applicable_interfaces(self) -> List[str]:
results = set([
ScryptedInterface.VideoCamera.value,
ScryptedInterface.Camera.value,
ScryptedInterface.MotionSensor.value,
ScryptedInterface.Settings.value,
])
if self.has_push_to_talk:
results.add(ScryptedInterface.Intercom.value)
if self.has_battery:
results.add(ScryptedInterface.Battery.value)
results.add(ScryptedInterface.Charger.value)
if self.has_siren or self.has_spotlight or self.has_floodlight:
results.add(ScryptedInterface.DeviceProvider.value)
if self.has_audio_sensor:
results.add(ScryptedInterface.AudioSensor.value)
if self.has_cloud_recording:
results.add(ScryptedInterface.VideoClips.value)
return list(results)
def get_device_type(self) -> str:
return ScryptedDeviceType.Camera.value
def get_builtin_child_device_manifests(self) -> List[Device]:
results = []
if self.has_spotlight or self.has_floodlight or self.has_nightlight:
light = self.get_or_create_light()
results.append({
"info": {
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
"manufacturer": "Arlo",
"firmware": self.arlo_device.get("firmwareVersion"),
"serialNumber": self.arlo_device["deviceId"],
},
"nativeId": light.nativeId,
"name": f'{self.arlo_device["deviceName"]} {"Spotlight" if self.has_spotlight else "Floodlight" if self.has_floodlight else "Nightlight"}',
"interfaces": light.get_applicable_interfaces(),
"type": light.get_device_type(),
"providerNativeId": self.nativeId,
})
if self.has_siren:
vss = self.get_or_create_vss()
results.extend([
{
"info": {
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
"manufacturer": "Arlo",
"firmware": self.arlo_device.get("firmwareVersion"),
"serialNumber": self.arlo_device["deviceId"],
},
"nativeId": vss.nativeId,
"name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System',
"interfaces": vss.get_applicable_interfaces(),
"type": vss.get_device_type(),
"providerNativeId": self.nativeId,
},
] + vss.get_builtin_child_device_manifests())
return results
@property
def wired_to_power(self) -> bool:
if self.storage:
return True if self.storage.getItem("wired_to_power") else False
else:
return False
@property
def eco_mode(self) -> bool:
if self.storage:
return True if self.storage.getItem("eco_mode") else False
else:
return False
@property
def snapshot_throttle_interval(self) -> int:
interval = self.storage.getItem("snapshot_throttle_interval")
if interval is None:
interval = 60
self.storage.setItem("snapshot_throttle_interval", interval)
return int(interval)
@property
def has_cloud_recording(self) -> bool:
return self.provider.arlo.GetSmartFeatures(self.arlo_device).get("planFeatures", {}).get("eventRecording", False)
@property
def has_spotlight(self) -> bool:
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_SPOTLIGHTS])
@property
def has_floodlight(self) -> bool:
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_FLOODLIGHTS])
@property
def has_nightlight(self) -> bool:
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_NIGHTLIGHTS])
@property
def has_siren(self) -> bool:
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_SIRENS])
@property
def has_audio_sensor(self) -> bool:
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITH_AUDIO_SENSORS])
@property
def has_battery(self) -> bool:
return not any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloCamera.MODELS_WITHOUT_BATTERY])
@property
def has_push_to_talk(self) -> bool:
return bool(self.arlo_capabilities.get("Capabilities", {}).get("PushToTalk", {}).get("fullDuplex"))
@property
def uses_sip_push_to_talk(self) -> bool:
return "sip" in self.arlo_capabilities.get("Capabilities", {}).get("PushToTalk", {}).get("signal", [])
async def getSettings(self) -> List[Setting]:
result = []
if self.has_battery:
result.append(
{
"group": "General",
"key": "wired_to_power",
"title": "Plugged In to External Power",
"value": self.wired_to_power,
"description": "Informs Scrypted that this device is plugged in to an external power source. " + \
"Will allow features like persistent prebuffer to work. " + \
"Note that a persistent prebuffer may cause excess battery drain if the external power is not able to charge faster than the battery consumption rate.",
"type": "boolean",
},
)
result.append(
{
"group": "General",
"key": "eco_mode",
"title": "Eco Mode",
"value": self.eco_mode,
"description": "Configures Scrypted to limit the number of requests made to this camera. " + \
"Additional eco mode settings will appear when this is turned on.",
"type": "boolean",
}
)
if self.eco_mode:
result.append(
{
"group": "Eco Mode",
"key": "snapshot_throttle_interval",
"title": "Snapshot Throttle Interval",
"value": self.snapshot_throttle_interval,
"description": "Time, in minutes, to throttle snapshot requests. " + \
"When eco mode is on, snapshot requests to the camera will be throttled for the given duration. " + \
"Cached snapshots may be returned if the time since the last snapshot has not exceeded the interval. " + \
"A value of 0 will disable throttling even when eco mode is on.",
"type": "number",
}
)
result.append(
{
"group": "General",
"key": "print_debug",
"title": "Debug Info",
"description": "Prints information about this device to console.",
"type": "button",
}
)
return result
@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
if key in ["wired_to_power"]:
self.storage.setItem(key, value == "true" or value == True)
await self.provider.discover_devices()
elif key in ["eco_mode"]:
self.storage.setItem(key, value == "true" or value == True)
elif key == "print_debug":
self.logger.info(f"Device Capabilities: {self.arlo_capabilities}")
else:
self.storage.setItem(key, value)
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
def validate_setting(self, key: str, val: SettingValue) -> bool:
if key == "snapshot_throttle_interval":
try:
val = int(val)
except ValueError:
self.logger.error(f"Invalid snapshot throttle interval '{val}' - must be an integer")
return False
return True
async def getPictureOptions(self) -> List[ResponsePictureOptions]:
return []
@async_print_exception_guard
async def takePicture(self, options: dict = None) -> MediaObject:
self.logger.info("Taking picture")
real_device = await scrypted_sdk.systemManager.api.getDeviceById(self.getScryptedProperty("id"))
msos = await real_device.getVideoStreamOptions()
if any(["prebuffer" in m for m in msos]):
self.logger.info("Getting snapshot from prebuffer")
try:
return await real_device.getVideoStream({"refresh": False})
except Exception as e:
self.logger.warning(f"Could not fetch from prebuffer due to: {e}")
self.logger.warning("Will try to fetch snapshot from Arlo cloud")
async with self.picture_lock:
if self.eco_mode and self.snapshot_throttle_interval > 0:
if datetime.now() - self.last_picture_time <= timedelta(minutes=self.snapshot_throttle_interval):
self.logger.info("Using cached image")
return await scrypted_sdk.mediaManager.createMediaObject(self.last_picture, "image/jpeg")
pic_url = await asyncio.wait_for(self.provider.arlo.TriggerFullFrameSnapshot(self.arlo_basestation, self.arlo_device), timeout=self.timeout)
self.logger.debug(f"Got snapshot URL for at {pic_url}")
if pic_url is None:
raise Exception("Error taking snapshot: no url returned")
async with async_timeout(self.timeout):
async with aiohttp.ClientSession() as session:
async with session.get(pic_url) as resp:
if resp.status != 200:
raise Exception(f"Unexpected status downloading snapshot image: {resp.status}")
self.last_picture = await resp.read()
self.last_picture_time = datetime.now()
return await scrypted_sdk.mediaManager.createMediaObject(self.last_picture, "image/jpeg")
async def getVideoStreamOptions(self, id: str = None) -> List[ResponseMediaStreamOptions]:
options = [
{
"id": 'default',
"name": 'Cloud RTSP',
"container": 'rtsp',
"video": {
"codec": 'h264',
},
"audio": None if self.arlo_device.get("modelId") == "VMC3030" else {
"codec": 'aac',
},
"source": 'cloud',
"tool": 'scrypted',
"userConfigurable": False,
},
{
"id": 'dash',
"name": 'Cloud DASH',
"container": 'dash',
"video": {
"codec": 'unknown',
},
"audio": None if self.arlo_device.get("modelId") == "VMC3030" else {
"codec": 'unknown',
},
"source": 'cloud',
"tool": 'ffmpeg',
"userConfigurable": False,
}
]
if id is None:
return options
return next(iter([o for o in options if o['id'] == id]))
async def _getVideoStreamURL(self, container: str) -> str:
self.logger.info(f"Requesting {container} stream")
url = await asyncio.wait_for(self.provider.arlo.StartStream(self.arlo_basestation, self.arlo_device, mode=container), timeout=self.timeout)
self.logger.debug(f"Got {container} stream URL at {url}")
return url
@async_print_exception_guard
async def getVideoStream(self, options: RequestMediaStreamOptions = None) -> MediaObject:
self.logger.debug("Entered getVideoStream")
mso = await self.getVideoStreamOptions(id=options["id"])
mso['refreshAt'] = round(time.time() * 1000) + 30 * 60 * 1000
container = mso["container"]
url = await self._getVideoStreamURL(container)
additional_ffmpeg_args = []
if container == "dash":
headers = self.provider.arlo.GetMPDHeaders(url)
ffmpeg_headers = '\r\n'.join([
f'{k}: {v}'
for k, v in headers.items()
])
additional_ffmpeg_args = ['-headers', ffmpeg_headers+'\r\n']
ffmpeg_input = {
'url': url,
'container': container,
'mediaStreamOptions': mso,
'inputArguments': [
'-f', container,
*additional_ffmpeg_args,
'-i', url,
]
}
return await scrypted_sdk.mediaManager.createFFmpegMediaObject(ffmpeg_input)
@async_print_exception_guard
async def startIntercom(self, media: MediaObject) -> None:
self.logger.info("Starting intercom")
if self.uses_sip_push_to_talk:
# signaling happens over sip
self.intercom_session = ArloCameraSIPIntercomSession(self)
else:
# we need to do signaling through arlo cloud apis
self.intercom_session = ArloCameraWebRTCIntercomSession(self)
await self.intercom_session.initialize_push_to_talk(media)
self.logger.info("Intercom initialized")
@async_print_exception_guard
async def stopIntercom(self) -> None:
self.logger.info("Stopping intercom")
if self.intercom_session is not None:
await self.intercom_session.shutdown()
self.intercom_session = None
async def getVideoClip(self, videoId: str) -> MediaObject:
self.logger.info(f"Getting video clip {videoId}")
id_as_time = int(videoId) / 1000.0
start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10)
end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10)
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
for recording in library:
if videoId == recording["name"]:
return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedContentUrl"])
self.logger.warn(f"Clip {videoId} not found")
return None
async def getVideoClipThumbnail(self, thumbnailId: str) -> MediaObject:
self.logger.info(f"Getting video clip thumbnail {thumbnailId}")
id_as_time = int(thumbnailId) / 1000.0
start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10)
end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10)
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
for recording in library:
if thumbnailId == recording["name"]:
return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedThumbnailUrl"])
self.logger.warn(f"Clip thumbnail {thumbnailId} not found")
return None
async def getVideoClips(self, options: VideoClipOptions = None) -> List[VideoClip]:
self.logger.info(f"Fetching remote video clips {options}")
start = datetime.fromtimestamp(options["startTime"] / 1000.0)
end = datetime.fromtimestamp(options["endTime"] / 1000.0)
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
clips = []
for recording in library:
clip = {
"duration": recording["mediaDurationSecond"] * 1000.0,
"id": recording["name"],
"thumbnailId": recording["name"],
"videoId": recording["name"],
"startTime": recording["utcCreatedDate"],
"description": recording["reason"],
"resources": {
"thumbnail": {
"href": recording["presignedThumbnailUrl"],
},
"video": {
"href": recording["presignedContentUrl"],
},
},
}
clips.append(clip)
if options.get("reverseOrder"):
clips.reverse()
return clips
@async_print_exception_guard
async def removeVideoClips(self, videoClipIds: List[str]) -> None:
# Arlo Cloud does support deleting, but let's be safe and not expose that here
raise Exception("deleting Arlo video clips is not implemented by this plugin - please delete clips through the Arlo app")
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
if (nativeId.endswith("spotlight") and self.has_spotlight) or (nativeId.endswith("floodlight") and self.has_floodlight) or (nativeId.endswith("nightlight") and self.has_nightlight):
return self.get_or_create_light()
if nativeId.endswith("vss") and self.has_siren:
return self.get_or_create_vss()
return None
def get_or_create_light(self) -> ArloSpotlight:
if self.has_spotlight:
light_id = f'{self.arlo_device["deviceId"]}.spotlight'
if not self.light:
self.light = ArloSpotlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self)
elif self.has_floodlight:
light_id = f'{self.arlo_device["deviceId"]}.floodlight'
if not self.light:
self.light = ArloFloodlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self)
elif self.has_nightlight:
light_id = f'{self.arlo_device["deviceId"]}.nightlight'
if not self.light:
self.light = ArloNightlight(light_id, self.arlo_device, self.provider, self)
return self.light
def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem:
if self.has_siren:
vss_id = f'{self.arlo_device["deviceId"]}.vss'
if not self.vss:
self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self)
return self.vss
class ArloCameraWebRTCIntercomSession(ArloCameraIntercomSession):
def __init__(self, camera: ArloCamera) -> None:
super().__init__(camera)
self.arlo_pc = None
self.arlo_sdp_answered = False
self.intercom_ffmpeg_subprocess = None
self.stop_subscriptions = False
self.start_sdp_answer_subscription()
self.start_candidate_answer_subscription()
def __del__(self) -> None:
self.stop_subscriptions = True
self.cancel_pending_tasks()
def start_sdp_answer_subscription(self) -> None:
def callback(sdp):
if self.arlo_pc and not self.arlo_sdp_answered:
if "a=mid:" not in sdp:
# arlo appears to not return a mux id in the response, which
# doesn't play nicely with our webrtc peers. let's add it
sdp += "a=mid:0\r\n"
self.logger.info(f"Arlo response sdp:\n{sdp}")
sdp = scrypted_arlo_go.WebRTCSessionDescription(scrypted_arlo_go.NewWebRTCSDPType("answer"), sdp)
self.arlo_pc.SetRemoteDescription(sdp)
self.arlo_sdp_answered = True
return self.stop_subscriptions
self.register_task(
self.provider.arlo.SubscribeToSDPAnswers(self.arlo_basestation, self.arlo_device, callback)
)
def start_candidate_answer_subscription(self) -> None:
def callback(candidate):
if self.arlo_pc:
prefix = "a=candidate:"
if candidate.startswith(prefix):
candidate = candidate[len(prefix):]
candidate = candidate.strip()
self.logger.info(f"Arlo response candidate: {candidate}")
candidate = scrypted_arlo_go.WebRTCICECandidateInit(candidate, "0", 0)
self.arlo_pc.AddICECandidate(candidate)
return self.stop_subscriptions
self.register_task(
self.provider.arlo.SubscribeToCandidateAnswers(self.arlo_basestation, self.arlo_device, callback)
)
@async_print_exception_guard
async def initialize_push_to_talk(self, media: MediaObject) -> None:
self.logger.info("Initializing push to talk")
session_id, ice_servers = self.provider.arlo.StartPushToTalk(self.arlo_basestation, self.arlo_device)
self.logger.debug(f"Received ice servers: {[ice['url'] for ice in ice_servers]}")
ice_servers = scrypted_arlo_go.Slice_webrtc_ICEServer([
scrypted_arlo_go.NewWebRTCICEServer(
scrypted_arlo_go.go.Slice_string([ice['url']]),
ice.get('username', ''),
ice.get('credential', '')
)
for ice in ice_servers
])
self.arlo_pc = scrypted_arlo_go.NewWebRTCManager(self.camera.logger_server_port, ice_servers)
ffmpeg_params = json.loads(await scrypted_sdk.mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput.value))
self.logger.debug(f"Received ffmpeg params: {ffmpeg_params}")
audio_port = self.arlo_pc.InitializeAudioRTPListener(scrypted_arlo_go.WebRTCMimeTypeOpus)
ffmpeg_path = await scrypted_sdk.mediaManager.getFFmpegPath()
ffmpeg_args = [
"-y",
"-hide_banner",
"-loglevel", "error",
"-analyzeduration", "0",
"-fflags", "-nobuffer",
"-probesize", "500000",
*ffmpeg_params["inputArguments"],
"-acodec", "libopus",
"-flags", "+global_header",
"-vbr", "off",
"-ar", "48k",
"-b:a", "32k",
"-bufsize", "96k",
"-ac", "2",
"-application", "lowdelay",
"-dn", "-sn", "-vn",
"-frame_duration", "20",
"-f", "rtp",
"-flush_packets", "1",
f"rtp://localhost:{audio_port}?pkt_size={scrypted_arlo_go.UDP_PACKET_SIZE()}",
]
self.logger.debug(f"Starting ffmpeg at {ffmpeg_path} with '{' '.join(ffmpeg_args)}'")
self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("FFmpeg", self.camera.logger_server_port, ffmpeg_path, *ffmpeg_args)
self.intercom_ffmpeg_subprocess.start()
self.sdp_answered = False
offer = self.arlo_pc.CreateOffer()
offer_sdp = scrypted_arlo_go.WebRTCSessionDescriptionSDP(offer)
self.logger.info(f"Arlo offer sdp:\n{offer_sdp}")
self.arlo_pc.SetLocalDescription(offer)
self.provider.arlo.NotifyPushToTalkSDP(
self.arlo_basestation, self.arlo_device,
session_id, offer_sdp
)
def trickle_candidates():
count = 0
try:
while True:
candidate = self.arlo_pc.GetNextICECandidate()
candidate = scrypted_arlo_go.WebRTCICECandidateInit(
scrypted_arlo_go.WebRTCICECandidate(handle=candidate.handle).ToJSON()
).Candidate
self.logger.debug(f"Sending candidate to Arlo: {candidate}")
self.provider.arlo.NotifyPushToTalkCandidate(
self.arlo_basestation, self.arlo_device,
session_id, candidate,
)
count += 1
except RuntimeError as e:
if str(e) == "no more candidates":
self.logger.debug(f"End of candidates, found {count} candidate(s)")
else:
self.logger.exception("Exception while processing trickle candidates")
except Exception:
self.logger.exception("Exception while processing trickle candidates")
# we can trickle candidates asynchronously so the caller to startIntercom
# knows we are ready to receive packets
threading.Thread(target=trickle_candidates).start()
@async_print_exception_guard
async def shutdown(self) -> None:
if self.intercom_ffmpeg_subprocess is not None:
self.intercom_ffmpeg_subprocess.stop()
self.intercom_ffmpeg_subprocess = None
if self.arlo_pc is not None:
self.arlo_pc.Close()
self.arlo_pc = None
class ArloCameraSIPIntercomSession(ArloCameraIntercomSession):
def __init__(self, camera: ArloCamera) -> None:
super().__init__(camera)
self.arlo_sip = None
self.intercom_ffmpeg_subprocess = None
@async_print_exception_guard
async def initialize_push_to_talk(self, media: MediaObject) -> None:
self.logger.info("Initializing push to talk")
sip_info = self.provider.arlo.GetSIPInfo()
sip_call_info = sip_info["sipCallInfo"]
# though GetSIPInfo returns ice servers, there doesn't seem to be any indication
# that they are used on the arlo web dashboard, so just use what Chrome inserts
ice_servers = [{"url": "stun:stun.l.google.com:19302"}]
self.logger.debug(f"Will use ice servers: {[ice['url'] for ice in ice_servers]}")
ice_servers = scrypted_arlo_go.Slice_webrtc_ICEServer([
scrypted_arlo_go.NewWebRTCICEServer(
scrypted_arlo_go.go.Slice_string([ice['url']]),
ice.get('username', ''),
ice.get('credential', '')
)
for ice in ice_servers
])
sip_cfg = scrypted_arlo_go.SIPInfo(
DeviceID=self.camera.nativeId,
CallerURI=f"sip:{sip_call_info['id']}@{sip_call_info['domain']}:{sip_call_info['port']}",
CalleeURI=sip_call_info['calleeUri'],
Password=sip_call_info['password'],
UserAgent="SIP.js/0.20.1",
WebsocketURI="wss://livestream-z2-prod.arlo.com:7443",
WebsocketOrigin="https://my.arlo.com",
WebsocketHeaders=scrypted_arlo_go.HeadersMap({"User-Agent": USER_AGENTS["arlo"]}),
)
self.arlo_sip = scrypted_arlo_go.NewSIPWebRTCManager(self.camera.logger_server_port, ice_servers, sip_cfg)
ffmpeg_params = json.loads(await scrypted_sdk.mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput.value))
self.logger.debug(f"Received ffmpeg params: {ffmpeg_params}")
audio_port = self.arlo_sip.InitializeAudioRTPListener(scrypted_arlo_go.WebRTCMimeTypeOpus)
ffmpeg_path = await scrypted_sdk.mediaManager.getFFmpegPath()
ffmpeg_args = [
"-y",
"-hide_banner",
"-loglevel", "error",
"-analyzeduration", "0",
"-fflags", "-nobuffer",
"-probesize", "500000",
*ffmpeg_params["inputArguments"],
"-acodec", "libopus",
"-flags", "+global_header",
"-vbr", "off",
"-ar", "48k",
"-b:a", "32k",
"-bufsize", "96k",
"-ac", "2",
"-application", "lowdelay",
"-dn", "-sn", "-vn",
"-frame_duration", "20",
"-f", "rtp",
"-flush_packets", "1",
f"rtp://localhost:{audio_port}?pkt_size={scrypted_arlo_go.UDP_PACKET_SIZE()}",
]
self.logger.debug(f"Starting ffmpeg at {ffmpeg_path} with '{' '.join(ffmpeg_args)}'")
self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("FFmpeg", self.camera.logger_server_port, ffmpeg_path, *ffmpeg_args)
self.intercom_ffmpeg_subprocess.start()
def sip_start():
try:
self.arlo_sip.Start()
except Exception:
self.logger.exception("Exception starting sip call")
# do remaining setup asynchronously so the caller to startIntercom
# can start sending packets
threading.Thread(target=sip_start).start()
@async_print_exception_guard
async def shutdown(self) -> None:
if self.intercom_ffmpeg_subprocess is not None:
self.intercom_ffmpeg_subprocess.stop()
self.intercom_ffmpeg_subprocess = None
if self.arlo_sip is not None:
self.arlo_sip.Close()
self.arlo_sip = None

View File

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

View File

@@ -1,34 +0,0 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk.types import BinarySensor, ScryptedInterface, ScryptedDeviceType
from .camera import ArloCamera
if TYPE_CHECKING:
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
from .provider import ArloProvider
class ArloDoorbell(ArloCamera, BinarySensor):
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None:
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
self.start_doorbell_subscription()
def start_doorbell_subscription(self) -> None:
def callback(doorbellPressed):
self.binaryState = doorbellPressed
return self.stop_subscriptions
self.register_task(
self.provider.arlo.SubscribeToDoorbellEvents(self.arlo_basestation, self.arlo_device, callback)
)
def get_device_type(self) -> str:
return ScryptedDeviceType.Doorbell.value
def get_applicable_interfaces(self) -> List[str]:
camera_interfaces = super().get_applicable_interfaces()
camera_interfaces.append(ScryptedInterface.BinarySensor.value)
return camera_interfaces

View File

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

View File

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

View File

@@ -1,760 +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
_arlo_mfa_code = None
scrypted_devices = None
_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 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",
},
])
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
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:
raise Exception("Arlo client not connected, cannot discover devices")
self.logger.info("Discovering devices...")
self.arlo_cameras = {}
self.arlo_basestations = {}
self.scrypted_devices = {}
camera_devices = []
provider_to_device_map = {None: []}
basestations = self.arlo.GetDevices(['basestation', 'siren'])
for basestation in basestations:
nativeId = basestation["deviceId"]
self.logger.debug(f"Adding {nativeId}")
if nativeId in self.arlo_basestations:
self.logger.info(f"Skipping basestation {nativeId} ({basestation['modelId']}) as it has already been added")
continue
self.arlo_basestations[nativeId] = basestation
device = await self.getDevice_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(basestations)} basestations")
cameras = self.arlo.GetDevices(['camera', "arloq", "arloqs", "doorbell"])
for camera in cameras:
nativeId = camera["deviceId"]
self.logger.debug(f"Adding {nativeId}")
if camera["deviceId"] != camera["parentId"] and camera["parentId"] not in self.arlo_basestations:
self.logger.info(f"Skipping camera {camera['deviceId']} ({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
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"]:
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(f"Are all cameras shared with admin permissions?")
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")
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
async with self.device_discovery_lock:
return await self.getDevice_impl(nativeId)
async def getDevice_impl(self, nativeId: str) -> ArloDeviceBase:
ret = self.scrypted_devices.get(nativeId)
if ret is None:
ret = self.create_device(nativeId)
if ret is not None:
self.scrypted_devices[nativeId] = ret
return ret
def create_device(self, nativeId: str) -> ArloDeviceBase:
if nativeId not in self.arlo_cameras and nativeId not in self.arlo_basestations:
self.logger.warning(f"Cannot create device for nativeId {nativeId}, maybe it hasn't been loaded yet?")
return None
arlo_device = self.arlo_cameras.get(nativeId)
if not arlo_device:
# this is a basestation, so build the basestation object
arlo_device = self.arlo_basestations[nativeId]
return ArloBasestation(nativeId, arlo_device, self)
if arlo_device["parentId"] not in self.arlo_basestations:
self.logger.warning(f"Cannot create camera with nativeId {nativeId} when {arlo_device['parentId']} is not a valid basestation")
return None
arlo_basestation = self.arlo_basestations[arlo_device["parentId"]]
if arlo_device["deviceType"] == "doorbell":
return ArloDoorbell(nativeId, arlo_device, arlo_basestation, self)
else:
return ArloCamera(nativeId, arlo_device, arlo_basestation, self)

View File

@@ -1,72 +0,0 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk.types import OnOff, SecuritySystemMode, ScryptedInterface, ScryptedDeviceType
from .base import ArloDeviceBase
from .util import async_print_exception_guard
if TYPE_CHECKING:
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
from .provider import ArloProvider
from .vss import ArloSirenVirtualSecuritySystem
class ArloSiren(ArloDeviceBase, OnOff):
vss: ArloSirenVirtualSecuritySystem = None
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, vss: ArloSirenVirtualSecuritySystem) -> None:
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
self.vss = vss
def get_applicable_interfaces(self) -> List[str]:
return [ScryptedInterface.OnOff.value]
def get_device_type(self) -> str:
return ScryptedDeviceType.Siren.value
@async_print_exception_guard
async def turnOn(self) -> None:
from .basestation import ArloBasestation
self.logger.info("Turning on")
if self.vss.securitySystemState["mode"] == SecuritySystemMode.Disarmed.value:
self.logger.info("Virtual security system is disarmed, ignoring trigger")
# set and unset this property to force homekit to display the
# switch as off
self.on = True
self.on = False
self.vss.securitySystemState = {
**self.vss.securitySystemState,
"triggered": False,
}
return
if isinstance(self.vss.parent, ArloBasestation):
self.logger.debug("Parent device is a basestation")
self.provider.arlo.SirenOn(self.arlo_basestation)
else:
self.logger.debug("Parent device is a camera")
self.provider.arlo.SirenOn(self.arlo_basestation, self.arlo_device)
self.on = True
self.vss.securitySystemState = {
**self.vss.securitySystemState,
"triggered": True,
}
@async_print_exception_guard
async def turnOff(self) -> None:
from .basestation import ArloBasestation
self.logger.info("Turning off")
if isinstance(self.vss.parent, ArloBasestation):
self.provider.arlo.SirenOff(self.arlo_basestation)
else:
self.provider.arlo.SirenOff(self.arlo_basestation, self.arlo_device)
self.on = False
self.vss.securitySystemState = {
**self.vss.securitySystemState,
"triggered": False,
}

View File

@@ -1,72 +0,0 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
from scrypted_sdk.types import OnOff, ScryptedInterface, ScryptedDeviceType
from .base import ArloDeviceBase
from .util import async_print_exception_guard
if TYPE_CHECKING:
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
from .provider import ArloProvider
from .camera import ArloCamera
class ArloSpotlight(ArloDeviceBase, OnOff):
camera: ArloCamera = None
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, camera: ArloCamera) -> None:
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
self.camera = camera
def get_applicable_interfaces(self) -> List[str]:
return [ScryptedInterface.OnOff.value]
def get_device_type(self) -> str:
return ScryptedDeviceType.Light.value
@async_print_exception_guard
async def turnOn(self) -> None:
self.logger.info("Turning on")
self.provider.arlo.SpotlightOn(self.arlo_basestation, self.arlo_device)
self.on = True
@async_print_exception_guard
async def turnOff(self) -> None:
self.logger.info("Turning off")
self.provider.arlo.SpotlightOff(self.arlo_basestation, self.arlo_device)
self.on = False
class ArloFloodlight(ArloSpotlight):
@async_print_exception_guard
async def turnOn(self) -> None:
self.logger.info("Turning on")
self.provider.arlo.FloodlightOn(self.arlo_basestation, self.arlo_device)
self.on = True
@async_print_exception_guard
async def turnOff(self) -> None:
self.logger.info("Turning off")
self.provider.arlo.FloodlightOff(self.arlo_basestation, self.arlo_device)
self.on = False
class ArloNightlight(ArloSpotlight):
def __init__(self, nativeId: str, arlo_device: dict, provider: ArloProvider, camera: ArloCamera) -> None:
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_device, provider=provider, camera=camera)
@async_print_exception_guard
async def turnOn(self) -> None:
self.logger.info("Turning on")
self.provider.arlo.NightlightOn(self.arlo_device)
self.on = True
@async_print_exception_guard
async def turnOff(self) -> None:
self.logger.info("Turning off")
self.provider.arlo.NightlightOff(self.arlo_device)
self.on = False

View File

@@ -1,44 +0,0 @@
import asyncio
import traceback
class BackgroundTaskMixin:
def create_task(self, coroutine) -> asyncio.Task:
task = asyncio.get_event_loop().create_task(coroutine)
self.register_task(task)
return task
def register_task(self, task) -> None:
if not hasattr(self, "background_tasks"):
self.background_tasks = set()
assert task is not None
def print_exception(task):
if task.exception():
self.logger.error(f"task exception: {task.exception()}")
self.background_tasks.add(task)
task.add_done_callback(print_exception)
task.add_done_callback(self.background_tasks.discard)
def cancel_pending_tasks(self) -> None:
if not hasattr(self, "background_tasks"):
return
for task in self.background_tasks:
task.cancel()
def async_print_exception_guard(fn):
"""Decorator to print an exception's stack trace before re-raising the exception."""
async def wrapped(*args, **kwargs):
try:
return await fn(*args, **kwargs)
except Exception:
# hack to detect if the applied function is actually a method
# on a scrypted object
if len(args) > 0 and hasattr(args[0], "logger"):
getattr(args[0], "logger").exception(f"{fn.__qualname__} raised an exception")
else:
traceback.print_exc()
raise
return wrapped

View File

@@ -1,156 +0,0 @@
from __future__ import annotations
import asyncio
from typing import List, TYPE_CHECKING
from scrypted_sdk.types import Device, DeviceProvider, Setting, Settings, SettingValue, SecuritySystem, SecuritySystemMode, Readme, ScryptedInterface, ScryptedDeviceType
from .base import ArloDeviceBase
from .siren import ArloSiren
from .util import async_print_exception_guard
if TYPE_CHECKING:
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
from .provider import ArloProvider
from .basestation import ArloBasestation
from .camera import ArloCamera
class ArloSirenVirtualSecuritySystem(ArloDeviceBase, SecuritySystem, Settings, Readme, DeviceProvider):
"""A virtual, emulated security system that controls when scrypted events can trip the real physical siren."""
SUPPORTED_MODES = [SecuritySystemMode.AwayArmed.value, SecuritySystemMode.HomeArmed.value, SecuritySystemMode.Disarmed.value]
siren: ArloSiren = None
parent: ArloBasestation | ArloCamera = None
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, parent: ArloBasestation | ArloCamera) -> None:
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
self.parent = parent
self.create_task(self.delayed_init())
@property
def mode(self) -> str:
mode = self.storage.getItem("mode")
if mode is None or mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES:
mode = SecuritySystemMode.Disarmed.value
return mode
@mode.setter
def mode(self, mode: str) -> None:
if mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES:
raise ValueError(f"invalid mode {mode}")
self.storage.setItem("mode", mode)
self.securitySystemState = {
**self.securitySystemState,
"mode": mode,
}
self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None))
async def delayed_init(self) -> None:
iterations = 1
while not self.stop_subscriptions:
if iterations > 100:
self.logger.error("Delayed init exceeded iteration limit, giving up")
return
try:
self.securitySystemState = {
"supportedModes": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES,
"mode": self.mode,
}
return
except Exception as e:
self.logger.debug(f"Delayed init failed, will try again: {e}")
await asyncio.sleep(0.1)
iterations += 1
def get_applicable_interfaces(self) -> List[str]:
return [
ScryptedInterface.SecuritySystem.value,
ScryptedInterface.DeviceProvider.value,
ScryptedInterface.Settings.value,
ScryptedInterface.Readme.value,
]
def get_device_type(self) -> str:
return ScryptedDeviceType.SecuritySystem.value
def get_builtin_child_device_manifests(self) -> List[Device]:
siren = self.get_or_create_siren()
return [
{
"info": {
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
"manufacturer": "Arlo",
"firmware": self.arlo_device.get("firmwareVersion"),
"serialNumber": self.arlo_device["deviceId"],
},
"nativeId": siren.nativeId,
"name": f'{self.arlo_device["deviceName"]} Siren',
"interfaces": siren.get_applicable_interfaces(),
"type": siren.get_device_type(),
"providerNativeId": self.nativeId,
}
]
async def getSettings(self) -> List[Setting]:
return [
{
"key": "mode",
"title": "Arm Mode",
"description": "If disarmed, the associated siren will not be physically triggered even if toggled.",
"value": self.mode,
"choices": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES,
},
]
async def putSetting(self, key: str, value: SettingValue) -> None:
if key != "mode":
raise ValueError(f"invalid setting {key}")
self.mode = value
if self.mode == SecuritySystemMode.Disarmed.value:
await self.get_or_create_siren().turnOff()
async def getReadmeMarkdown(self) -> str:
return """
# Virtual Security System for Arlo Sirens
This security system device is not a real physical device, but a virtual, emulated device provided by the Arlo Scrypted plugin. Its purpose is to grant security system semantics of Arm/Disarm to avoid the accidental, unwanted triggering of the real physical siren through integrations such as Homekit.
To allow the siren to trigger, set the Arm Mode to any of the Armed options. When Disarmed, any triggers of the siren will be ignored. Switching modes will not perform any changes to Arlo cloud or your Arlo account, but rather only to this Scrypted device.
If this virtual security system is synced to Homekit, the siren device will be merged into the same security system accessory as a switch. The siren device will not be added as a separate accessory. To access the siren as a switch without the security system, disable syncing of the virtual security system and enable syncing of the siren, then ensure that the virtual security system is armed manually in its settings in Scrypted.
""".strip()
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
if not nativeId.endswith("siren"):
return None
return self.get_or_create_siren()
def get_or_create_siren(self) -> ArloSiren:
siren_id = f'{self.arlo_device["deviceId"]}.siren'
if not self.siren:
self.siren = ArloSiren(siren_id, self.arlo_device, self.arlo_basestation, self.provider, self)
return self.siren
@async_print_exception_guard
async def armSecuritySystem(self, mode: SecuritySystemMode) -> None:
self.logger.info(f"Arming {mode}")
self.mode = mode
self.securitySystemState = {
**self.securitySystemState,
"mode": mode,
}
if mode == SecuritySystemMode.Disarmed.value:
await self.get_or_create_siren().turnOff()
@async_print_exception_guard
async def disarmSecuritySystem(self) -> None:
self.logger.info(f"Disarming")
self.mode = SecuritySystemMode.Disarmed.value
self.securitySystemState = {
**self.securitySystemState,
"mode": SecuritySystemMode.Disarmed.value,
}
await self.get_or_create_siren().turnOff()

View File

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

View File

@@ -1,13 +0,0 @@
paho-mqtt==1.6.1
sseclient==0.0.22
aiohttp==3.8.4
requests==2.28.2
cachetools==5.3.0
scrypted-arlo-go==0.4.0
cloudscraper==1.2.71
curl-cffi==0.5.7; platform_machine != 'armv7l'
async-timeout==4.0.2
beautifulsoup4==4.12.2
--extra-index-url=https://www.piwheels.org/simple/
--extra-index-url=https://bjia56.github.io/scrypted-arlo-go/
--prefer-binary

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/bticino",
"version": "0.0.9",
"version": "0.0.11",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/bticino",
"version": "0.0.9",
"version": "0.0.11",
"dependencies": {
"@slyoldfox/sip": "^0.0.6-1",
"sdp": "^3.0.3",
@@ -40,7 +40,7 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.2.85",
"version": "0.2.103",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -905,9 +905,9 @@
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw=="
},
"node_modules/semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"bin": {
"semver": "bin/semver"
}
@@ -1832,9 +1832,9 @@
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw=="
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="
},
"shebang-command": {
"version": "2.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/bticino",
"version": "0.0.9",
"version": "0.0.11",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",
@@ -28,7 +28,6 @@
],
"pluginDependencies": [
"@scrypted/prebuffer-mixin",
"@scrypted/pam-diff",
"@scrypted/snapshot"
]
},

View File

@@ -27,7 +27,7 @@ const { mediaManager } = sdk;
export class BticinoSipCamera extends ScryptedDeviceBase implements DeviceProvider, Intercom, Camera, VideoCamera, Settings, BinarySensor, HttpRequestHandler, VideoClips, Reboot {
private session: SipCallSession
private remoteRtpDescription: RtpDescription
private remoteRtpDescription: Promise<RtpDescription>
private audioOutForwarder: dgram.Socket
private audioOutProcess: ChildProcess
private currentMedia: FFmpegInput | MediaStreamUrl
@@ -158,9 +158,10 @@ export class BticinoSipCamera extends ScryptedDeviceBase implements DeviceProvid
const audioOutForwarder = await createBindZero()
this.audioOutForwarder = audioOutForwarder.server
let address = (await this.remoteRtpDescription).address
audioOutForwarder.server.on('message', message => {
if( this.session )
this.session.audioSplitter.send(message, 40004, this.remoteRtpDescription.address)
this.session.audioSplitter.send(message, 40004, address)
return null
});
@@ -244,7 +245,12 @@ export class BticinoSipCamera extends ScryptedDeviceBase implements DeviceProvid
client.setKeepAlive(true, 10000)
let sip: SipCallSession
try {
await this.controllerApi.updateStreamEndpoint()
if( !this.incomingCallRequest ) {
// If this is a "view" call, update the stream endpoint to send it only to "us"
// In case of an incoming doorbell event, the C300X is already streaming video to all registered endpoints
await this.controllerApi.updateStreamEndpoint()
}
let rtsp: RtspServer;
const cleanup = () => {
client.destroy();
@@ -271,7 +277,7 @@ export class BticinoSipCamera extends ScryptedDeviceBase implements DeviceProvid
sip.onCallEnded.subscribe(cleanup)
// Call the C300X
this.remoteRtpDescription = await sip.callOrAcceptInvite(
this.remoteRtpDescription = sip.callOrAcceptInvite(
( audio ) => {
return [
//TODO: Payload types are hardcoded

View File

@@ -1,13 +1,12 @@
{
"name": "@scrypted/chromecast",
"version": "0.1.55",
"version": "0.1.56",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/chromecast",
"version": "0.1.55",
"hasInstallScript": true,
"version": "0.1.56",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",
@@ -40,38 +39,39 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.0.199",
"version": "0.2.103",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.16.7",
"@babel/preset-typescript": "^7.18.6",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^8.2.3",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.13.8",
"esbuild": "^0.15.9",
"ncp": "^2.0.0",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"tmp": "^0.2.1",
"webpack": "^5.59.0"
"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-readme": "bin/scrypted-readme.js",
"scrypted-setup-project": "bin/scrypted-setup-project.js",
"scrypted-webpack": "bin/scrypted-webpack.js"
},
"devDependencies": {
"@types/node": "^16.11.1",
"@types/node": "^18.11.18",
"@types/stringify-object": "^4.0.0",
"stringify-object": "^3.3.0",
"ts-node": "^10.4.0",
"typedoc": "^0.22.8",
"typescript-json-schema": "^0.50.1",
"webpack-bundle-analyzer": "^4.5.0"
"typedoc": "^0.23.21"
}
},
"../sdk": {
@@ -386,23 +386,24 @@
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
"@babel/preset-typescript": "^7.16.7",
"@types/node": "^16.11.1",
"@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": "^8.2.3",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.13.8",
"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.22.8",
"typescript-json-schema": "^0.50.1",
"webpack": "^5.59.0",
"typedoc": "^0.23.21",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.5.0"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/chromecast",
"version": "0.1.55",
"version": "0.1.56",
"description": "Send video, audio, and text to speech notifications to Chromecast and Google Home devices",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -2,3 +2,4 @@
out/
node_modules/
dist/
external

View File

@@ -6,4 +6,4 @@ fs
src
.vscode
dist/*.js
node-nat-upnp
external

View File

@@ -3,8 +3,35 @@
1. Log into Scrypted Cloud using the login button.
2. This Scrypted server is now available at https://home.scrypted.app.
See below for additional recommendations.
## Optional but Recommended
## Port Forwarding
1. Set up Port Forwarding with UPNP or Router Forwarding.
2. Use the Advanced Tab to verify Port Forwarding is correctly configured.
1. Open the Firewall and Port Forwarding Settings on the network's router.
2. Use the ports shown in Settings to configure a Port Forwarding rule on the router.
Use the `Test Port Forward` buttin in `Advanced` Settings tab to verify the configuration is correct.
## Custom Domains
Custom Domains can be used with the Cloud Plugin.
Set up a reverse proxy to the https Forward Port shown in settings.
## Cloudflare Tunnels
Scrypted Cloud automatically creates a login free tunnel for remote access.
The following steps are only necessary if you want to associate the tunnel with your existing Cloudflare account to manage it remotely.
1. Create the Tunnel in the [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com).
2. Copy the token shown for the tunnel shown in the `install [token]` command. E.g. `cloudflared service install eyJhI344aA...`.
3. Paste the token into the Cloud Plugin Advanced Settings.
4. Add a `Public Hostname` to the tunnel.
* Choose a (sub)domain.
* Service `Type` is `HTTPS` and `URL` is `localhost:port`. Replace the port with `Forward Port` from Cloud Plugin Settings.
* Expand `Additional Application Settings` -> `TLS` menus and enable `No TLS Verify`.
5. Reload Cloud Plugin.
6. Verify Cloudflare successfully connected by observing the `Console` Logs.

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"scrypted": {
"name": "Scrypted Cloud",
"type": "API",
"realfs": true,
"interfaces": [
"SystemSettings",
"BufferConverter",
@@ -40,20 +41,18 @@
"@eneris/push-receiver": "^3.1.4",
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"axios": "^0.25.0",
"bpmux": "^8.1.3",
"debug": "^4.3.1",
"axios": "^1.4.0",
"bpmux": "^8.2.1",
"cloudflared": "^0.4.0",
"exponential-backoff": "^3.1.1",
"http-proxy": "^1.18.1",
"lodash": "^4.17.21",
"nat-upnp": "file:./node-nat-upnp",
"query-string": "^6.14.1"
"nat-upnp": "file:./external/node-nat-upnp"
},
"devDependencies": {
"@types/debug": "^4.1.5",
"@types/http-proxy": "^1.17.5",
"@types/lodash": "^4.14.191",
"@types/http-proxy": "^1.17.11",
"@types/ip": "^1.1.0",
"@types/nat-upnp": "^1.1.2",
"@types/node": "^18.11.18"
"@types/node": "^20.4.5"
},
"version": "0.1.14"
"version": "0.2.3"
}

View File

@@ -0,0 +1,58 @@
import path from 'path';
// "optionalDependencies": {
// "@greenlock/manager": "^3.1.0",
// "@koush/greenlock": "^4.0.9",
// "acme-dns-01-duckdns": "^3.0.1",
// "greenlock-store-fs": "^3.2.2"
// },
export async function registerDuckDns(duckDnsHostname: string, duckDnsToken: string): Promise<{
cert: string;
chain: string;
privkey: string;
}> {
const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME;
const greenlockD = path.join(pluginVolume, 'greenlock.d');
const Greenlock = require('@koush/greenlock');
const greenlock = Greenlock.create({
packageRoot: process.env.NODE_PATH,
configDir: greenlockD,
packageAgent: 'Scrypted/1.0',
maintainerEmail: 'koushd@gmail.com',
notify: function (event, details) {
if ('error' === event) {
// `details` is an error object in this case
console.error(details);
}
}
});
await greenlock.manager
.defaults({
challenges: {
'dns-01': {
module: 'acme-dns-01-duckdns',
token: duckDnsToken,
},
},
agreeToTerms: true,
subscriberEmail: 'koushd@gmail.com',
});
const altnames = [duckDnsHostname];
const r = await greenlock
.add({
subject: altnames[0],
altnames: altnames
});
const result = await greenlock
.get({ servername: duckDnsHostname });
const { pems } = result;
return pems;
}

View File

@@ -11,12 +11,19 @@ import upnp from 'nat-upnp';
import net from 'net';
import os from 'os';
import path from 'path';
import qs from 'query-string';
import { Duplex } from 'stream';
import { Duplex, Readable } from 'stream';
import tls from 'tls';
import Url from 'url';
import { createSelfSignedCertificate } from '../../../server/src/cert';
import { PushManager } from './push';
import { readLine } from '../../../common/src/read-stream';
import { qsparse, qsstringify } from "./qs";
import * as cloudflared from 'cloudflared';
import fs, { mkdirSync, renameSync, rmSync } from 'fs';
import { backOff } from "exponential-backoff";
import ip from 'ip';
import { Deferred } from "@scrypted/common/src/deferred";
// import { registerDuckDns } from "./greenlock";
const { deviceManager, endpointManager, systemManager } = sdk;
@@ -34,9 +41,9 @@ class ScryptedPush extends ScryptedDeviceBase implements BufferConverter {
}
async convert(data: Buffer | string, fromMimeType: string): Promise<Buffer> {
if (this.cloud.storageSettings.values.forwardingMode === 'Custom Domain' && this.cloud.storageSettings.values.hostname) {
return Buffer.from(`https://${this.cloud.getHostname()}${await this.cloud.getCloudMessagePath()}/${data}`);
}
const validDomain = this.cloud.getSSLHostname();
if (validDomain)
return Buffer.from(`https://${validDomain}${await this.cloud.getCloudMessagePath()}/${data}`);
const url = `http://127.0.0.1/push/${data}`;
return this.cloud.whitelist(url, 10 * 365 * 24 * 60 * 60 * 1000, `https://${this.cloud.getHostname()}${SCRYPTED_CLOUD_MESSAGE_PATH}`);
@@ -44,6 +51,8 @@ class ScryptedPush extends ScryptedDeviceBase implements BufferConverter {
}
class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings, BufferConverter, DeviceProvider, HttpRequestHandler {
cloudflareTunnel: string;
cloudflared: Awaited<ReturnType<typeof cloudflared.tunnel>>;
manager = new PushManager(DEFAULT_SENDER_ID);
server: http.Server;
secureServer: https.Server;
@@ -83,23 +92,46 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
placeholder: 'my-server.dyndns.com',
onPut: () => this.scheduleRefreshPortForward(),
},
securePort: {
title: 'Local HTTPS Port',
description: 'The Scrypted Cloud plugin listens on this port for for cloud connections. The router must use UPNP, port forwarding, or a reverse proxy to send requests to this port.',
type: 'number',
onPut: (ov, nv) => {
if (ov && ov !== nv)
this.log.a('Reload the Scrypted Cloud Plugin to apply the port change.');
duckDnsToken: {
hide: true,
title: 'Duck DNS Token',
placeholder: 'xxxxx123456',
onPut: () => {
this.storageSettings.values.duckDnsCertValid = false;
this.log.a('Reload the Scrypted Cloud Plugin to apply the Duck DNS change.');
}
},
duckDnsHostname: {
hide: true,
title: 'Duck DNS Hostname',
placeholder: 'my-scrypted.duckdns.org',
onPut: () => {
this.storageSettings.values.duckDnsCertValid = false;
this.log.a('Reload the Scrypted Cloud Plugin to apply the Duck DNS change.');
}
},
duckDnsCertValid: {
type: 'boolean',
hide: true,
},
upnpPort: {
title: 'External HTTPS Port',
title: 'From Port',
description: "The external network port on router used by port forwarding.",
type: 'number',
onPut: (ov, nv) => {
if (ov !== nv)
this.scheduleRefreshPortForward();
},
},
securePort: {
title: 'Forward Port',
description: 'The internal https port used by the Scrypted Cloud plugin. Connections must be forwarded to this port on this server\'s internal IP address.',
type: 'number',
onPut: (ov, nv) => {
if (ov && ov !== nv)
this.log.a('Reload the Scrypted Cloud Plugin to apply the port change.');
}
},
upnpStatus: {
title: 'UPNP Status',
description: 'The status of the UPNP NAT reservation.',
@@ -119,11 +151,36 @@ 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',
description: 'Optional: Enter the Cloudflare token from the Cloudflare Dashbaord to track and manage the tunnel remotely.',
onPut: () => {
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: {
@@ -133,12 +190,27 @@ 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();
upnpStatus = 'Starting';
securePort: number;
randomBytes = crypto.randomBytes(16).toString('base64');
reverseConnections = new Set<Duplex>();
get cloudflareTunnelHost() {
if (!this.cloudflareTunnel)
return;
return new URL(this.cloudflareTunnel).host;
}
constructor() {
super();
@@ -171,7 +243,8 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
this.storageSettings.settings.securePort.onGet = async () => {
return {
hide: this.storageSettings.values.forwardingMode === 'Disabled',
group: this.storageSettings.values.forwardingMode === 'Disabled' ? 'Advanced' : undefined,
title: this.storageSettings.values.forwardingMode === 'Disabled' ? 'Cloudflare Port' : 'Forward Port',
}
};
@@ -181,6 +254,27 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
};
// this.storageSettings.settings.duckDnsToken.onGet = async () => {
// return {
// hide: this.storageSettings.values.forwardingMode === 'Custom Domain'
// || this.storageSettings.values.forwardingMode === 'Disabled',
// }
// };
// this.storageSettings.settings.duckDnsHostname.onGet = async () => {
// return {
// hide: this.storageSettings.values.forwardingMode === 'Custom Domain'
// || this.storageSettings.values.forwardingMode === 'Disabled',
// }
// };
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) => {
@@ -229,17 +323,55 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
this.storageSettings.values.upnpPort = upnpPort;
// scrypted cloud will replace localhost with requesting ip.
const ip = this.storageSettings.values.forwardingMode === 'Custom Domain'
? this.storageSettings.values.hostname?.toString()
: (await axios(`https://${SCRYPTED_SERVER}/_punch/ip`)).data.ip;
let ip: string;
if (this.storageSettings.values.forwardingMode === 'Custom Domain') {
ip = this.storageSettings.values.hostname?.toString();
if (!ip)
throw new Error('Hostname is required for port Custom Domain setup.');
}
else if (this.storageSettings.values.duckDnsHostname && this.storageSettings.values.duckDnsToken) {
try {
const url = new URL('https://www.duckdns.org/update');
url.searchParams.set('domains', this.storageSettings.values.duckDnsHostname);
url.searchParams.set('token', this.storageSettings.values.duckDnsToken);
await axios(url.toString());
}
catch (e) {
this.console.error('Duck DNS Erorr', e);
throw new Error('Duck DNS Error. See Console Logs.');
}
if (!ip)
throw new Error('Hostname is required for port Custom Domain setup.');
try {
throw new Error('not implemented');
// const pems = await registerDuckDns(this.storageSettings.values.duckDnsHostname, this.storageSettings.values.duckDnsToken);
// this.storageSettings.values.duckDnsCertValid = true;
// const certificate = this.storageSettings.values.certificate;
// const chain = pems.cert.trim() + '\n' + pems.chain.trim();
// if (certificate.certificate !== chain || certificate.serviceKey !== pems.privkey) {
// certificate.certificate = chain;
// certificate.serviceKey = pems.privkey;
// this.storageSettings.values.certificate = certificate;
// deviceManager.requestRestart();
// }
}
catch (e) {
this.console.error("Let's Encrypt Error", e);
throw new Error("Let's Encrypt Error. See Console Logs.");
}
if (this.storageSettings.values.forwardingMode === 'Custom Domain')
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' || this.cloudflareTunnelHost)
upnpPort = 443;
this.console.log(`Mapped port https://127.0.0.1:${this.securePort} to https://${ip}:${upnpPort}`);
this.console.log(`Scrypted Cloud mapped https://${ip}:${upnpPort} to https://127.0.0.1:${this.securePort}`);
// the ip is not sent, but should be checked to see if it changed.
if (this.storageSettings.values.lastPersistedUpnpPort !== upnpPort || ip !== this.storageSettings.values.lastPersistedIp) {
@@ -247,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;
@@ -256,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,
});
@@ -280,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.';
@@ -302,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) {
@@ -341,21 +477,21 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
async whitelist(localUrl: string, ttl: number, baseUrl: string): Promise<Buffer> {
const local = Url.parse(localUrl);
const local = new URL(localUrl);
if (this.storageSettings.values.forwardingMode === 'Custom Domain' && this.storageSettings.values.hostname) {
return Buffer.from(`${baseUrl}${local.path}`);
if (this.getSSLHostname()) {
return Buffer.from(`${baseUrl}${local.pathname}`);
}
if (this.whitelisted.has(local.path)) {
return Buffer.from(this.whitelisted.get(local.path));
if (this.whitelisted.has(local.pathname)) {
return Buffer.from(this.whitelisted.get(local.pathname));
}
const { token_info } = this.storageSettings.values;
if (!token_info)
throw new Error('@scrypted/cloud is not logged in.');
const q = qs.stringify({
scope: local.path,
const q = qsstringify({
scope: local.pathname,
ttl,
})
const scope = await axios(`https://${this.getHostname()}/_punch/scope?${q}`, {
@@ -365,13 +501,13 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
})
const { userToken, userTokenSignature } = scope.data;
const tokens = qs.stringify({
const tokens = qsstringify({
user_token: userToken,
user_token_signature: userTokenSignature
})
const url = `${baseUrl}${local.path}?${tokens}`;
this.whitelisted.set(local.path, url);
const url = `${baseUrl}${local.pathname}?${tokens}`;
this.whitelisted.set(local.pathname, url);
return Buffer.from(url);
}
@@ -383,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,
],
});
}
@@ -391,9 +528,24 @@ 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' ? this.storageSettings.values.hostname : undefined;
const hostname = this.storageSettings.values.forwardingMode === 'Custom Domain'
? this.storageSettings.values.hostname
: this.storageSettings.values.duckDnsToken && this.storageSettings.values.duckDnsHostname;
if (upnp_port === 443 && !hostname) {
const error = this.storageSettings.values.forwardingMode === 'Custom Domain'
@@ -413,7 +565,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
const { upnp_port, hostname } = this.getAuthority();
const registration_secret = this.storageSettings.values.registrationSecret || crypto.randomBytes(8).toString('base64');
const q = qs.stringify({
const q = qsstringify({
upnp_port,
registration_id,
sender_id: DEFAULT_SENDER_ID,
@@ -479,10 +631,15 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
async releaseDevice(id: string, nativeId: string): Promise<void> {
}
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;
}
getHostname() {
if (this.storageSettings.values.forwardingMode === 'Custom Domain' && this.storageSettings.values.hostname)
return this.storageSettings.values.hostname;
return SCRYPTED_SERVER;
return this.getSSLHostname() || SCRYPTED_SERVER;
}
async convert(data: Buffer, fromMimeType: string): Promise<Buffer> {
@@ -518,7 +675,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
async getOauthUrl(): Promise<string> {
const args = qs.stringify({
const args = qsstringify({
hostname: os.hostname(),
registration_id: await this.manager.registrationId,
sender_id: DEFAULT_SENDER_ID,
@@ -553,9 +710,9 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
const handler = async (req: http.IncomingMessage, res: http.ServerResponse) => {
this.console.log(req.socket?.remoteAddress, req.url);
const url = Url.parse(req.url);
if (url.path.startsWith('/web/oauth/callback') && url.query) {
const query = qs.parse(url.query);
const url = new URL(req.url, 'https://localhost');
if (url.pathname.startsWith('/web/oauth/callback') && url.search) {
const query = qsparse(url.searchParams);
if (!query.callback_url && query.token_info && query.user_info) {
this.storageSettings.values.token_info = query.token_info;
this.storageSettings.values.lastPersistedRegistrationId = await this.manager.registrationId;
@@ -569,16 +726,19 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
return;
}
}
else if (url.path === '/web/') {
if (this.storageSettings.values.forwardingMode === 'Custom Domain' && this.storageSettings.values.hostname)
res.setHeader('Location', `https://${this.storageSettings.values.hostname}/endpoint/@scrypted/core/public/`);
else
else if (url.pathname === '/web/') {
const validDomain = this.getSSLHostname();
if (validDomain) {
res.setHeader('Location', `https://${validDomain}/endpoint/@scrypted/core/public/`);
}
else {
res.setHeader('Location', '/endpoint/@scrypted/core/public/');
}
res.writeHead(302);
res.end();
return;
}
else if (url.path === '/web/component/home/endpoint') {
else if (url.pathname === '/web/component/home/endpoint') {
this.proxy.web(req, res, {
target: googleHomeTarget.toString(),
ignorePath: true,
@@ -586,7 +746,7 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
});
return;
}
else if (url.path === '/web/component/alexa/endpoint') {
else if (url.pathname === '/web/component/alexa/endpoint') {
this.proxy.web(req, res, {
target: alexaTarget.toString(),
ignorePath: true,
@@ -598,9 +758,13 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
this.proxy.web(req, res, { headers }, (err) => console.error(err));
}
const wsHandler = (req: http.IncomingMessage, socket: Duplex, head: Buffer) => this.proxy.ws(req, socket, head, { target: wsTarget.toString(), ws: true, secure: false, headers }, (err) => console.error(err));
const wsHandler = (req: http.IncomingMessage, socket: Duplex, head: Buffer) => {
this.console.log(req.socket?.remoteAddress, req.url);
this.proxy.ws(req, socket, head, { target: wsTarget.toString(), ws: true, secure: false, headers }, (err) => console.error(err))
};
this.server = http.createServer(handler);
this.server.keepAliveTimeout = 0;
this.server.on('upgrade', wsHandler);
// this can be localhost because this is a server initiated loopback proxy through bpmux
this.server.listen(0, '127.0.0.1');
@@ -620,7 +784,9 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
this.upnpInterval = setInterval(() => this.refreshPortForward(), 30 * 60 * 1000);
this.refreshPortForward();
const agent = new http.Agent({ maxSockets: Number.MAX_VALUE, keepAlive: true });
this.proxy = HttpProxy.createProxy({
agent,
target: httpTarget,
secure: false,
});
@@ -628,7 +794,8 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
this.proxy.on('proxyRes', (res, req) => {
res.headers['X-Scrypted-Cloud'] = req.headers['x-scrypted-cloud'];
res.headers['X-Scrypted-Direct-Address'] = req.headers['x-scrypted-direct-address'];
res.headers['Access-Control-Expose-Headers'] = 'X-Scrypted-Cloud, X-Scrypted-Direct-Address';
res.headers['X-Scrypted-Cloud-Address'] = this.cloudflareTunnel;
res.headers['Access-Control-Expose-Headers'] = 'X-Scrypted-Cloud, X-Scrypted-Direct-Address, X-Scrypted-Cloud-Address';
});
let backoff = 0;
@@ -653,14 +820,21 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
backoff = Date.now();
const random = Math.random().toString(36).substring(2);
this.console.log('scrypted server requested a connection:', random);
const registrationId = await this.manager.registrationId;
this.ensureReverseConnections(registrationId);
const client = tls.connect(4001, SCRYPTED_SERVER, {
rejectUnauthorized: false,
});
client.on('close', () => this.console.log('scrypted server connection ended:', random));
const registrationId = await this.manager.registrationId;
client.write(registrationId + '\n');
const mux: any = new bpmux.BPMux(client as any);
mux.on('handshake', async (socket: Duplex) => {
this.ensureReverseConnections(registrationId);
this.console.warn('mux connection required');
let local: any;
await new Promise(resolve => process.nextTick(resolve));
@@ -675,9 +849,173 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
});
}
});
this.startCloudflared();
}
async startCloudflared() {
if (!this.storageSettings.values.cloudflareEnabled) {
this.console.log('cloudflared is disabled.');
return;
}
while (true) {
try {
this.console.log('starting cloudflared');
this.cloudflared = await backOff(async () => {
const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME;
const version = 2;
const cloudflareD = path.join(pluginVolume, 'cloudflare.d', `v${version}`, `${process.platform}-${process.arch}`);
const bin = path.join(cloudflareD, cloudflared.bin);
if (!fs.existsSync(bin)) {
for (let i = 0; i <= version; i++) {
const cloudflareD = path.join(pluginVolume, 'cloudflare.d', `v${version}`);
rmSync(cloudflareD, {
force: true,
recursive: true,
});
}
if (process.platform === 'darwin' && process.arch === 'arm64') {
const bin = path.join(cloudflareD, cloudflared.bin);
mkdirSync(path.dirname(bin), {
recursive: true,
});
const tmp = `${bin}.tmp`;
const stream = await axios('https://github.com/scryptedapp/cloudflared/releases/download/2023.8.2/cloudflared-darwin-arm64', {
responseType: 'stream',
});
const write = stream.data.pipe(fs.createWriteStream(tmp));
await once(write, 'close');
renameSync(tmp, bin);
fs.chmodSync(bin, 0o0755)
}
else {
await cloudflared.install(bin);
}
}
process.chdir(cloudflareD);
const secureUrl = `https://127.0.0.1:${this.securePort}`;
const args: any = {};
if (this.storageSettings.values.cloudflaredTunnelToken) {
args['run'] = null;
args['--token'] = this.storageSettings.values.cloudflaredTunnelToken;
}
else {
args['--no-tls-verify'] = null;
args['--url'] = secureUrl;
}
const deferred = new Deferred<string>();
const cloudflareTunnel = cloudflared.tunnel(args);
cloudflareTunnel.child.stdout.on('data', data => this.console.log(data.toString()));
cloudflareTunnel.child.stderr.on('data', data => {
const string: string = data.toString();
this.console.error(string);
const lines = string.split('\n');
for (const line of lines) {
if (line.includes('hostname'))
this.console.log(line);
const match = /config=(".*?}")/gm.exec(line)
if (match) {
const json = match[1];
this.console.log(json);
try {
// the config is already json stringified and needs to be double parsed.
// '2023-09-02T21:18:10Z INF Updated to new configuration config="{\"ingress\":[{\"hostname\":\"tunneltest.example.com\", \"originRequest\":{\"noTLSVerify\":true}, \"service\":\"https://localhost:52960\"}, {\"service\":\"http_status:404\"}], \"warp-routing\":{\"enabled\":false}}" version=6'
const parsed = JSON.parse(JSON.parse(json));
const hostname = parsed.ingress?.[0]?.hostname;
if (!hostname)
deferred.resolve(undefined)
else
deferred.resolve(`https://${hostname}`)
}
catch (e) {
this.console.error("Error parsing config", e);
}
}
}
});
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.')
}
catch (e) {
this.console.error('cloudflared error', e);
throw e;
}
this.console.log(`cloudflare url mapped ${this.cloudflareTunnel} to ${secureUrl}`);
return cloudflareTunnel;
}, {
startingDelay: 60000,
timeMultiple: 1.2,
numOfAttempts: 1000,
maxDelay: 300000,
});
await once(this.cloudflared.child, 'exit');
throw new Error('cloudflared exited.');
}
catch (e) {
this.console.error('cloudflared error', e);
throw e;
}
finally {
this.cloudflared = undefined;
this.cloudflareTunnel = undefined;
this.updateExternalAddresses();
}
}
}
ensureReverseConnections(registrationId: string) {
while (this.reverseConnections.size < 10) {
this.createReverseConnection(registrationId);
}
}
async createReverseConnection(registrationId: string) {
const client = tls.connect(4001, SCRYPTED_SERVER, {
rejectUnauthorized: false,
});
this.reverseConnections.add(client);
const random = Math.random().toString(36).substring(2);
let claimed = false;
client.on('close', () => {
this.console.log('scrypted server reverse connection ended:', random);
this.reverseConnections.delete(client);
if (claimed)
this.ensureReverseConnections(registrationId);
});
client.write(`reverse:${registrationId}\n`);
try {
const read = await readLine(client);
}
catch (e) {
return;
}
claimed = true;
let local: any;
await new Promise(resolve => process.nextTick(resolve));
const port = (this.server.address() as any).port;
local = net.connect({
port,
host: '127.0.0.1',
});
await new Promise(resolve => process.nextTick(resolve));
client.pipe(local).pipe(client);
}
async oauthCallback(req: http.IncomingMessage, res: http.ServerResponse) {
const reqUrl = new URL(req.url, 'https://localhost');

16
plugins/cloud/src/qs.ts Normal file
View File

@@ -0,0 +1,16 @@
export function qsstringify(dict: any) {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(dict)) {
params.set(k, v?.toString());
}
return params.toString();
}
export function qsparse(search: URLSearchParams) {
const ret: any = {};
for (const [k, v] of search.entries()) {
ret[k] = v;
}
return ret;
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/core",
"version": "0.1.130",
"version": "0.1.143",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/core",
"version": "0.1.130",
"version": "0.1.143",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",
@@ -87,34 +87,35 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.2.21",
"version": "0.2.103",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.16.7",
"@babel/preset-typescript": "^7.18.6",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^8.2.3",
"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",
"typescript": "^4.9.3",
"webpack": "^5.74.0",
"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-readme": "bin/scrypted-readme.js",
"scrypted-setup-project": "bin/scrypted-setup-project.js",
"scrypted-webpack": "bin/scrypted-webpack.js"
},
"devDependencies": {
"@types/node": "^18.11.9",
"@types/node": "^18.11.18",
"@types/stringify-object": "^4.0.0",
"stringify-object": "^3.3.0",
"ts-node": "^10.4.0",
@@ -249,12 +250,12 @@
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
"@babel/preset-typescript": "^7.16.7",
"@types/node": "^18.11.9",
"@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": "^8.2.3",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.15.9",
"ncp": "^2.0.0",
@@ -262,10 +263,11 @@
"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.3",
"webpack": "^5.74.0",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.5.0"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/core",
"version": "0.1.130",
"version": "0.1.143",
"description": "Scrypted Core plugin. Provides the UI, websocket, and engine.io APIs.",
"author": "Scrypted",
"license": "Apache-2.0",
@@ -24,7 +24,7 @@
],
"scrypted": {
"name": "Scrypted Core",
"type": "DeviceProvider",
"type": "Builtin",
"interfaces": [
"@scrypted/launcher-ignore",
"HttpRequestHandler",
@@ -34,6 +34,7 @@
"Settings"
],
"pluginDependencies": [
"@scrypted/snapshot",
"@scrypted/webrtc"
]
},

View File

@@ -80,7 +80,8 @@ function createVideoCamera(devices: VideoCamera[], console: Console): VideoCamer
for (let i = 0; i < inputs.length; i++) {
ffmpegInput.inputArguments.push(...inputs[i].inputArguments);
filter.push(`[${i}:v] scale=-1:${h},pad=${w}:ih:(ow-iw)/2 [pos${i}];`)
// https://superuser.com/a/891478
filter.push(`[${i}:v] scale=(iw*sar)*min(${w}/(iw*sar)\\,${h}/ih):ih*min(${w}/(iw*sar)\\,${h}/ih),pad=${w}:${h}:(${w}-iw*min(${w}/iw\\,${h}/ih))/2:(${h}-ih*min(${w}/iw\\,${h}/ih))/2 [pos${i}];`)
}
for (let i = inputs.length; i < dim * dim; i++) {
ffmpegInput.inputArguments.push(

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,9 @@
"private": true,
"scripts": {
"dev": "concurrently --names 'server,client' --prefix-colors 'gray,white.bold' --prefix '{time} ({name})\t' --timestamp-format 'HH:mm:ss.SSS' --kill-others npm:serve-server npm:serve",
"serve": "vue-cli-service serve --open",
"serve": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve --open",
"serve-server": "cd ../../../server && npm run serve",
"build": "vue-cli-service build --dest ../fs/dist",
"build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build --dest ../fs/dist",
"lint": "vue-cli-service lint"
},
"dependencies": {
@@ -26,6 +26,7 @@
"draggabilly": "^2.3.0",
"engine.io-client": "^5.2.0",
"feather-icons": "^4.28.0",
"leaflet": "^1.9.4",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"monaco-editor": "^0.27.0",
@@ -49,7 +50,7 @@
"vue-script2": "^2.1.0",
"vue-slider-component": "^3.2.11",
"vue-swatches": "^1.0.4",
"vue2-google-maps": "^0.10.7",
"vue2-leaflet": "^2.7.1",
"vuetify": "^2.6.13",
"vuex": "^3.6.2",
"webpack-dev-server": "^4.9.2",
@@ -58,6 +59,8 @@
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/plugin-proposal-optional-catch-binding": "^7.18.6",
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@babel/plugin-transform-modules-commonjs": "^7.13.8",
"@babel/plugin-transform-typescript": "^7.13.0",

View File

@@ -10,10 +10,18 @@
<v-card width="300px" class="elevation-24">
<v-card-title style="justify-content: center;" class="headline text-uppercase">Scrypted
</v-card-title>
<v-card-subtitle style="text-align: center;">{{ $store.state.version }}</v-card-subtitle>
<v-card-subtitle v-if="$store.state.loginHostname"
style="text-align: center; font-weight: 300; font-size: .75rem !important; font-family: Quicksand, sans-serif!important;"
class="text-subtitle-2 text-uppercase">
{{ $store.state.version }}
<br />
Logged into: {{ $store.state.loginHostname
}}
</v-card-subtitle>
<v-card-subtitle v-else style="text-align: center;">{{ $store.state.version }}</v-card-subtitle>
<v-list class="transparent">
<v-list-item v-for="application in applications" :key="application.name"
:to="application.to" :href="application.href">
<v-list-item v-for="application in applications" :key="application.name" :to="application.to"
:href="application.href">
<v-icon small>{{ application.icon }}</v-icon>
<v-list-item-title style="text-align: center;">{{ application.name }}
</v-list-item-title>
@@ -27,11 +35,11 @@
<v-card-actions>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon href="https://twitter.com/scryptedapp/">
<v-icon small>fab fa-twitter</v-icon>
<v-btn v-on="on" icon href="https://discord.gg/DcFzmBHYGq">
<v-icon small>fab fa-discord</v-icon>
</v-btn>
</template>
<span>Twitter</span>
<span>Discord</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
@@ -43,19 +51,11 @@
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon href="https://github.com/koush/scrypted">
<v-icon small>fab fa-github</v-icon>
<v-btn v-on="on" icon href="https://docs.scrypted.app">
<v-icon small>fa fa-file-text</v-icon>
</v-btn>
</template>
<span>Github</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon href="https://discord.gg/DcFzmBHYGq">
<v-icon small>fab fa-discord</v-icon>
</v-btn>
</template>
<span>Discord</span>
<span>Documentation</span>
</v-tooltip>
<v-spacer></v-spacer>
<v-tooltip bottom>

View File

@@ -7,6 +7,9 @@
</v-card-title>
<v-card-subtitle v-if="$store.state.hasLogin === false" style="display: flex; justify-content: center;" class="text-uppercase">Create Account
</v-card-subtitle>
<v-card-subtitle v-if="$store.state.loginHostname"
style="text-align: center; font-weight: 300; font-size: .75rem !important; font-family: Quicksand, sans-serif!important;"
class="text-subtitle-2 text-uppercase">Log into: {{ $store.state.loginHostname }}</v-card-subtitle>
<v-container grid-list-md>
<v-layout wrap>
<v-flex xs12>
@@ -109,6 +112,16 @@ export default {
try {
const redirect_uri = new URL(window.location).searchParams.get('redirect_uri');
if (redirect_uri) {
try {
const parsed = new URL(redirect_uri);
// allow everything but javascript evaluation within this browser (ie, custom uri handlers, https, etc are all valid)
if (parsed.protocol === 'javascript:') {
window.location = '/';
return;
}
}
catch (e) {
}
window.location = redirect_uri;
return;
}

View File

@@ -6,14 +6,6 @@
</v-card-title>
<v-card-text>Connection interrupted.</v-card-text>
<v-card-actions>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon href="https://github.com/koush/scrypted">
<v-icon small>fab fa-github</v-icon>
</v-btn>
</template>
<span>Github</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon href="https://discord.gg/DcFzmBHYGq">
@@ -22,6 +14,22 @@
</template>
<span>Discord</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon href="https://www.reddit.com/r/Scrypted/">
<v-icon small>fab fa-reddit</v-icon>
</v-btn>
</template>
<span>Reddit</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon href="https://docs.scrypted.app">
<v-icon small>fa fa-file-text</v-icon>
</v-btn>
</template>
<span>Documentation</span>
</v-tooltip>
<v-spacer></v-spacer>
<v-btn text @click="reconnect">Reconnect</v-btn>
</v-card-actions>

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