mirror of
https://github.com/koush/scrypted.git
synced 2026-02-07 07:52:12 +00:00
Compare commits
678 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1688fcc126 | ||
|
|
99cae0ba31 | ||
|
|
a7b00b9e91 | ||
|
|
3f2a62c6f2 | ||
|
|
3fc318a370 | ||
|
|
aed8575aa0 | ||
|
|
2e28b50588 | ||
|
|
2e87cc380f | ||
|
|
1fdd2d4b01 | ||
|
|
53b23b2ca8 | ||
|
|
54016a9c78 | ||
|
|
d207a3b824 | ||
|
|
e72a74d008 | ||
|
|
d1b907e45b | ||
|
|
4a4c47ffe2 | ||
|
|
f6baf99935 | ||
|
|
b5cc138e2b | ||
|
|
40738a74cf | ||
|
|
d2b1f104ca | ||
|
|
6cb4f589c0 | ||
|
|
5cf2b26630 | ||
|
|
e7f16af04c | ||
|
|
6287b9deaa | ||
|
|
b9b5fdb712 | ||
|
|
c85af9c8a5 | ||
|
|
069f765507 | ||
|
|
0e587abc79 | ||
|
|
47770c0a8d | ||
|
|
82d1c3afe5 | ||
|
|
1c9b52ce4f | ||
|
|
adcd9fa537 | ||
|
|
91e2c2870b | ||
|
|
1fc892815d | ||
|
|
38ed1acc15 | ||
|
|
3bdc9ab930 | ||
|
|
bfa6346333 | ||
|
|
fcbb308cb8 | ||
|
|
f137edcc8c | ||
|
|
53e6f083b9 | ||
|
|
0f96fdb4bc | ||
|
|
96ea3f3b27 | ||
|
|
a31d6482af | ||
|
|
be16bf7858 | ||
|
|
1dad0126bc | ||
|
|
9292ebbe48 | ||
|
|
0b3a1a1998 | ||
|
|
b5d58b6899 | ||
|
|
215a56f70e | ||
|
|
c593701e72 | ||
|
|
46351f2fd7 | ||
|
|
9bce4acd14 | ||
|
|
cba20ec887 | ||
|
|
7c41516cce | ||
|
|
1f209072ba | ||
|
|
8978bff8a9 | ||
|
|
04c500b855 | ||
|
|
8b4859579c | ||
|
|
90deaf1161 | ||
|
|
de56a8c653 | ||
|
|
a5215ae92b | ||
|
|
73cd40b540 | ||
|
|
93556dd404 | ||
|
|
125b436cb6 | ||
|
|
0a4ea032f5 | ||
|
|
c658cee5c9 | ||
|
|
6589176c8b | ||
|
|
6c4c83f655 | ||
|
|
8d4124adda | ||
|
|
b7cda86df7 | ||
|
|
6622e13e51 | ||
|
|
cbc45da679 | ||
|
|
e7d06c66af | ||
|
|
ea02bc3b6f | ||
|
|
2b43cb7d15 | ||
|
|
f3c0362e18 | ||
|
|
817ae42250 | ||
|
|
8043f83f20 | ||
|
|
d33ab5dbcf | ||
|
|
2b1674bea8 | ||
|
|
f045e59258 | ||
|
|
9125aafc07 | ||
|
|
6f5244ec9f | ||
|
|
f1eb2f988a | ||
|
|
1f659d9a72 | ||
|
|
dd98f12f2a | ||
|
|
2063e3822a | ||
|
|
f7495a7a76 | ||
|
|
fddb9c655f | ||
|
|
297e7a7b4f | ||
|
|
29e080f6b6 | ||
|
|
c72ea24794 | ||
|
|
ada80796de | ||
|
|
1ebcf32998 | ||
|
|
79765ba58e | ||
|
|
ff4665520c | ||
|
|
be5b810335 | ||
|
|
fdc99b7fa6 | ||
|
|
f730d13cbd | ||
|
|
af02753cef | ||
|
|
9334d1c2a4 | ||
|
|
71ecc07e2b | ||
|
|
5310dd5ff6 | ||
|
|
adf1a10659 | ||
|
|
2ecc26c914 | ||
|
|
9a49416831 | ||
|
|
f0eff01898 | ||
|
|
edd071739f | ||
|
|
ab81c568bc | ||
|
|
62470df0af | ||
|
|
19b83eb056 | ||
|
|
b75d4cbfd4 | ||
|
|
8c0bb7b205 | ||
|
|
ef64515e56 | ||
|
|
302272e437 | ||
|
|
80e433f6ef | ||
|
|
60786aba2b | ||
|
|
256fde46f6 | ||
|
|
e1a7dd367e | ||
|
|
8612ba3462 | ||
|
|
ab638f26be | ||
|
|
02b881a2d2 | ||
|
|
35475b03e2 | ||
|
|
0b55c777f8 | ||
|
|
68f86d214c | ||
|
|
2abea2d25b | ||
|
|
1c2f17b9f9 | ||
|
|
e3d4800e4f | ||
|
|
d2f175715b | ||
|
|
93c1a699f1 | ||
|
|
41570e9134 | ||
|
|
3ef75854c2 | ||
|
|
c88a638f4e | ||
|
|
793c4da33a | ||
|
|
68f071660e | ||
|
|
8ea5b6aca6 | ||
|
|
2f13c77444 | ||
|
|
981ad183f5 | ||
|
|
8748be82ef | ||
|
|
a347fc2b73 | ||
|
|
002bf3b52c | ||
|
|
72abcd79ec | ||
|
|
86e5b824c7 | ||
|
|
43f6f176f0 | ||
|
|
bc543aa28e | ||
|
|
e90db378e8 | ||
|
|
f2907532aa | ||
|
|
866706505a | ||
|
|
59db3b622c | ||
|
|
7451b9903a | ||
|
|
aded2e43b1 | ||
|
|
031a7527e1 | ||
|
|
2aca568707 | ||
|
|
6b22d34831 | ||
|
|
429d9ec5a6 | ||
|
|
b426668146 | ||
|
|
8bce14f834 | ||
|
|
7511abf768 | ||
|
|
180c12e8cc | ||
|
|
1ed7d03a20 | ||
|
|
9e7b57f154 | ||
|
|
205fdb0222 | ||
|
|
d8f3edee1e | ||
|
|
90c9efc8a6 | ||
|
|
3893ccd776 | ||
|
|
1b154f14bc | ||
|
|
2e3eba4350 | ||
|
|
450f05910a | ||
|
|
22505c9226 | ||
|
|
7120bf86ff | ||
|
|
b49742204f | ||
|
|
6fda76a5e8 | ||
|
|
08bd785d45 | ||
|
|
aa9ddb35aa | ||
|
|
7997c07179 | ||
|
|
a67e24d5dc | ||
|
|
0d4da0dd06 | ||
|
|
993e903f3b | ||
|
|
fbb11a5312 | ||
|
|
ea72d2159b | ||
|
|
1892fdb529 | ||
|
|
1e16793b20 | ||
|
|
2f6c577b47 | ||
|
|
212306449b | ||
|
|
16445bc38e | ||
|
|
b6e9e15d4f | ||
|
|
39abd49ea0 | ||
|
|
05b9b49732 | ||
|
|
1857acac66 | ||
|
|
fedf184847 | ||
|
|
d2afac0dd6 | ||
|
|
6844b55983 | ||
|
|
379dabc182 | ||
|
|
df3c751f2d | ||
|
|
da714d1f94 | ||
|
|
34ee29b7b4 | ||
|
|
4c48f50e01 | ||
|
|
81a5a4349c | ||
|
|
8526c92dcc | ||
|
|
73fefeec26 | ||
|
|
6060b50856 | ||
|
|
d29cd7e421 | ||
|
|
8589283135 | ||
|
|
837dae5f02 | ||
|
|
c26aa2d01e | ||
|
|
c98eca23ab | ||
|
|
eb5d1ac4f6 | ||
|
|
37b0e46dd0 | ||
|
|
042dd84520 | ||
|
|
62d5c145c2 | ||
|
|
1ea3774849 | ||
|
|
9d8345e901 | ||
|
|
9ed850e327 | ||
|
|
957d27b8ef | ||
|
|
b74a957ecb | ||
|
|
debaedfd8c | ||
|
|
0123a97e3c | ||
|
|
a32d47e192 | ||
|
|
90ed8bd3f5 | ||
|
|
c4f4002f55 | ||
|
|
1ea2828e78 | ||
|
|
eb864456df | ||
|
|
51af4f07ff | ||
|
|
f6201acf2a | ||
|
|
96ac479c73 | ||
|
|
0c08875de3 | ||
|
|
bd05fc1b5d | ||
|
|
5a0d325718 | ||
|
|
015794c1d1 | ||
|
|
02d5b429b7 | ||
|
|
e169d154e7 | ||
|
|
01c7b5674a | ||
|
|
a7a1aed0dc | ||
|
|
6bb3f0fd19 | ||
|
|
7828de9d50 | ||
|
|
ea77bb29d0 | ||
|
|
bb184247d0 | ||
|
|
dbc45173ae | ||
|
|
95a23b2882 | ||
|
|
212883e84b | ||
|
|
1200537d62 | ||
|
|
5f6adc9449 | ||
|
|
7d17236ca7 | ||
|
|
028401362a | ||
|
|
69927be4f4 | ||
|
|
ffee1c5cc2 | ||
|
|
ebc3a03e2c | ||
|
|
4246e3c476 | ||
|
|
3fce0838f1 | ||
|
|
2609e301fe | ||
|
|
f4737bf2ac | ||
|
|
fc102aa526 | ||
|
|
9ef33e156f | ||
|
|
881865a0cb | ||
|
|
be5643cc53 | ||
|
|
7e6eba1596 | ||
|
|
27dde776a6 | ||
|
|
b24159a22a | ||
|
|
b6c242b9d5 | ||
|
|
2fbaa12caa | ||
|
|
eb5a497e82 | ||
|
|
66a0ea08ec | ||
|
|
0527baf14a | ||
|
|
c7c5c6eed5 | ||
|
|
143c950c19 | ||
|
|
8d0bb0fa97 | ||
|
|
964274e50c | ||
|
|
e9844528aa | ||
|
|
0609fc8986 | ||
|
|
9331b71433 | ||
|
|
21f8239db7 | ||
|
|
86042ec3fe | ||
|
|
cdb87fb268 | ||
|
|
63dcd35b17 | ||
|
|
951c3b9be6 | ||
|
|
ed642bb3fe | ||
|
|
8093cdd3d9 | ||
|
|
fcbfc3a73f | ||
|
|
94945a48bd | ||
|
|
e360ede5cb | ||
|
|
bc9ec73567 | ||
|
|
cd7e570508 | ||
|
|
1b06c9c11d | ||
|
|
154ab42d15 | ||
|
|
1929f6e8ed | ||
|
|
58bfa17cfe | ||
|
|
38c7006055 | ||
|
|
b5e16b45a9 | ||
|
|
9c13668812 | ||
|
|
a1ca724d6b | ||
|
|
1b032d669c | ||
|
|
c492c15081 | ||
|
|
ee7076384b | ||
|
|
717cac721a | ||
|
|
af41c853bc | ||
|
|
109b716753 | ||
|
|
07930508fe | ||
|
|
a291abe375 | ||
|
|
f4f34b2da8 | ||
|
|
3b4de526ba | ||
|
|
5de67fca86 | ||
|
|
98dc0b1b6d | ||
|
|
a05595ecc7 | ||
|
|
87be4648f1 | ||
|
|
60e51adb41 | ||
|
|
ace7720fe1 | ||
|
|
b9eb74d403 | ||
|
|
fb7353383d | ||
|
|
bee119b486 | ||
|
|
0b6ffc2b87 | ||
|
|
3863527b4d | ||
|
|
51c48f4a1c | ||
|
|
4c138e9b4c | ||
|
|
e762c305a3 | ||
|
|
5bce335288 | ||
|
|
8201e9883a | ||
|
|
74e5884285 | ||
|
|
9cffd9ffbe | ||
|
|
d8b617f2ae | ||
|
|
aeb564aa5d | ||
|
|
45f672883a | ||
|
|
c0ff857a1b | ||
|
|
64f7e31f54 | ||
|
|
6b55f8876e | ||
|
|
718a31f2c5 | ||
|
|
c1e1d50fa5 | ||
|
|
75c4a1939f | ||
|
|
0d703c2aff | ||
|
|
0a6e4fda75 | ||
|
|
4c2de9e443 | ||
|
|
b8a4fedf1a | ||
|
|
79d9f1d4a1 | ||
|
|
983213c578 | ||
|
|
7dd3d71ebd | ||
|
|
493f8deeef | ||
|
|
b29f2d5ee1 | ||
|
|
96bda10123 | ||
|
|
3294700d31 | ||
|
|
0cf77d4c76 | ||
|
|
953841e3a5 | ||
|
|
393c1017df | ||
|
|
f50176d14a | ||
|
|
7f2bf0b542 | ||
|
|
9e3990400c | ||
|
|
95eed80735 | ||
|
|
be43d0c017 | ||
|
|
386ea9a98a | ||
|
|
9b40978f61 | ||
|
|
f0ee435cd0 | ||
|
|
30748784ef | ||
|
|
8310e33719 | ||
|
|
1d18697161 | ||
|
|
d500b3fd6c | ||
|
|
95ae916b6c | ||
|
|
ec3e16f20f | ||
|
|
30d28f543c | ||
|
|
e0cce24999 | ||
|
|
409b25f8b0 | ||
|
|
8f278abec8 | ||
|
|
d6179dab82 | ||
|
|
ed186e2142 | ||
|
|
3c021bb2c8 | ||
|
|
c522edc622 | ||
|
|
022a103bcb | ||
|
|
efd125b6e4 | ||
|
|
19f7688a65 | ||
|
|
7f18e4629c | ||
|
|
dfe2c937a1 | ||
|
|
47d7a23a3d | ||
|
|
0ea609c80c | ||
|
|
71ee5727f1 | ||
|
|
2383f16112 | ||
|
|
7d5defd736 | ||
|
|
cbf4cf0579 | ||
|
|
422dd94e5c | ||
|
|
076f5e27f1 | ||
|
|
645de2e5fd | ||
|
|
dcf24a77d7 | ||
|
|
7065365a47 | ||
|
|
b82520776e | ||
|
|
638c1f77fd | ||
|
|
73a489ea37 | ||
|
|
77d69f025a | ||
|
|
3bc14ad248 | ||
|
|
03e5a9dec1 | ||
|
|
57b790c332 | ||
|
|
ce2ea63be7 | ||
|
|
2dd4721b7f | ||
|
|
667075dfad | ||
|
|
7abdb06b66 | ||
|
|
43e5822c93 | ||
|
|
bc579514e7 | ||
|
|
825100f94e | ||
|
|
803bfc1560 | ||
|
|
b2013a54ed | ||
|
|
f252407935 | ||
|
|
516f2a2a7b | ||
|
|
c1677ce691 | ||
|
|
5028fb812d | ||
|
|
2db4e2579f | ||
|
|
b339ca6cd2 | ||
|
|
f100999cb1 | ||
|
|
2863756bd6 | ||
|
|
cc408850a0 | ||
|
|
ed1ceeda51 | ||
|
|
df09d8e92a | ||
|
|
298ac960d1 | ||
|
|
62d4d55aae | ||
|
|
a2121c0dc5 | ||
|
|
9b5ea27c0b | ||
|
|
0b0e90fc04 | ||
|
|
d8aff609bf | ||
|
|
d8283c261a | ||
|
|
e3aca964be | ||
|
|
a96025c45f | ||
|
|
6afd4b4579 | ||
|
|
f97669949d | ||
|
|
0a0a31574f | ||
|
|
90fb751a22 | ||
|
|
b8d06fada5 | ||
|
|
2cecb1686f | ||
|
|
db03775530 | ||
|
|
cccbc33f1a | ||
|
|
5f23873366 | ||
|
|
e43accae67 | ||
|
|
b3a0cda6f9 | ||
|
|
58c3348282 | ||
|
|
a9e6d76e99 | ||
|
|
3b58936387 | ||
|
|
3a14ab81c8 | ||
|
|
291178a7b5 | ||
|
|
b65faf1a79 | ||
|
|
9d8a1353c0 | ||
|
|
b29d793178 | ||
|
|
d8e406d415 | ||
|
|
4529872fd6 | ||
|
|
fa86c31340 | ||
|
|
94ded75d40 | ||
|
|
887b61cd7a | ||
|
|
48e3d30987 | ||
|
|
02dba3cd71 | ||
|
|
195769034d | ||
|
|
39c08aa378 | ||
|
|
fa8056d38e | ||
|
|
145f116c68 | ||
|
|
15b6f336e4 | ||
|
|
8b46f0a466 | ||
|
|
a20cc5cd89 | ||
|
|
3d068929fd | ||
|
|
928f9b7579 | ||
|
|
c1c5a42645 | ||
|
|
12643cdde2 | ||
|
|
0bff96a6e6 | ||
|
|
4e7e67de54 | ||
|
|
65c4a30004 | ||
|
|
309a1dc11f | ||
|
|
b7904b73b2 | ||
|
|
9e9ddbc5f3 | ||
|
|
ceda54f91b | ||
|
|
1d4052b839 | ||
|
|
6a5d6e6617 | ||
|
|
f55cc6066f | ||
|
|
527714e434 | ||
|
|
8a1633ffa3 | ||
|
|
56b2ab9c4f | ||
|
|
d330e2eb9d | ||
|
|
b55e7cacb3 | ||
|
|
c70375db06 | ||
|
|
2c23021d40 | ||
|
|
84a4ef4539 | ||
|
|
7f3db0549b | ||
|
|
de0e1784a3 | ||
|
|
5a8798638e | ||
|
|
14da49728c | ||
|
|
55423b2d09 | ||
|
|
596106247b | ||
|
|
5472d90368 | ||
|
|
fcf58413fc | ||
|
|
0d03b91753 | ||
|
|
2fd088e4d6 | ||
|
|
c6933198b2 | ||
|
|
210e684a22 | ||
|
|
53cc4b6ef3 | ||
|
|
d58d138a68 | ||
|
|
c0199a2b76 | ||
|
|
badb1905ce | ||
|
|
735c2dce7b | ||
|
|
ffae3f246f | ||
|
|
31b424f89f | ||
|
|
3b7acc3a90 | ||
|
|
7e66d1ac7f | ||
|
|
a613da069e | ||
|
|
40b73c6589 | ||
|
|
ef16ca83a2 | ||
|
|
76bf1d0d3f | ||
|
|
3d5ccf25d1 | ||
|
|
36fcb713d9 | ||
|
|
e306631850 | ||
|
|
17400fa886 | ||
|
|
c6dc628616 | ||
|
|
f974653e73 | ||
|
|
b83880a8a3 | ||
|
|
ee4d8f52df | ||
|
|
3854b75c6e | ||
|
|
07c3173506 | ||
|
|
2894ab1b96 | ||
|
|
99995ea882 | ||
|
|
d6560fbbe4 | ||
|
|
7205583104 | ||
|
|
8479a16d3d | ||
|
|
409aad4794 | ||
|
|
a29d009e5c | ||
|
|
6772419ccf | ||
|
|
38746ee743 | ||
|
|
c5cb3ffa90 | ||
|
|
e119056267 | ||
|
|
590ad3de37 | ||
|
|
6cd412de88 | ||
|
|
33ca0242b1 | ||
|
|
68d3f10888 | ||
|
|
7a844aac84 | ||
|
|
6f2bb9fd9e | ||
|
|
12e47993a4 | ||
|
|
b0396b77bd | ||
|
|
07c2314376 | ||
|
|
cee140e49f | ||
|
|
a3963af6e7 | ||
|
|
8ff28418b3 | ||
|
|
08a5c2f2b3 | ||
|
|
286bd5b19e | ||
|
|
59f3c2e3ad | ||
|
|
ea1b394061 | ||
|
|
5dc1af76e8 | ||
|
|
771bbd834b | ||
|
|
418724f860 | ||
|
|
2ecf48bc60 | ||
|
|
d19b942d2c | ||
|
|
08e724759d | ||
|
|
80031bc80b | ||
|
|
beb53c672c | ||
|
|
0dc75bf737 | ||
|
|
59008fb964 | ||
|
|
b119e5ee00 | ||
|
|
01d0f4c72a | ||
|
|
9fe3f1a4db | ||
|
|
60bf112ebd | ||
|
|
45aa443889 | ||
|
|
08f4922860 | ||
|
|
899970405a | ||
|
|
b4a3960e43 | ||
|
|
0514e62d78 | ||
|
|
3621e58d4c | ||
|
|
506b24026f | ||
|
|
98b67f5d56 | ||
|
|
33c95aa0e8 | ||
|
|
7d8f86bb6c | ||
|
|
d6717cc58b | ||
|
|
673f8e3b2a | ||
|
|
cae87ba414 | ||
|
|
13362fd53e | ||
|
|
d9f2ba0665 | ||
|
|
64a0f90a9a | ||
|
|
88300910a2 | ||
|
|
7face43d54 | ||
|
|
6a9f35ce2a | ||
|
|
effe76f251 | ||
|
|
58d5539cb8 | ||
|
|
d956ee06d0 | ||
|
|
8ddf91d13b | ||
|
|
3f65cd4f6d | ||
|
|
3ffdbf9d2b | ||
|
|
a51754b0e3 | ||
|
|
e8ee21e567 | ||
|
|
420f070035 | ||
|
|
c78cbc04d3 | ||
|
|
dddf565fbe | ||
|
|
0516ca810d | ||
|
|
fac67696a9 | ||
|
|
c62d4bd3fd | ||
|
|
877e1d4992 | ||
|
|
35b5cddd95 | ||
|
|
a86fb128d9 | ||
|
|
983daae971 | ||
|
|
9b687e3286 | ||
|
|
abfd0ffe35 | ||
|
|
407afa1d8c | ||
|
|
9bafe97ef6 | ||
|
|
cb151e79d8 | ||
|
|
7e6230d7b0 | ||
|
|
7d95de389a | ||
|
|
2ce187bc98 | ||
|
|
100671265e | ||
|
|
965d5af631 | ||
|
|
a19f356a66 | ||
|
|
a520357a23 | ||
|
|
d92d130a7c | ||
|
|
c8dd7d2f04 | ||
|
|
b85b589675 | ||
|
|
9b4cbed28f | ||
|
|
6b1794d32f | ||
|
|
aefe4b6849 | ||
|
|
a68395174a | ||
|
|
8a56e789b7 | ||
|
|
06ef146c5b | ||
|
|
4121cbd400 | ||
|
|
2d3bb8798d | ||
|
|
b7b6ac0f87 | ||
|
|
e5fb65d75e | ||
|
|
290b73f3d9 | ||
|
|
f717e87306 | ||
|
|
b80ac7c60d | ||
|
|
997a4732ec | ||
|
|
6e08f11578 | ||
|
|
87c4814e6f | ||
|
|
2e0e009719 | ||
|
|
77399038e9 | ||
|
|
fae66619fb | ||
|
|
d979b9ec0c | ||
|
|
975319a65d | ||
|
|
7b5aa4ba2d | ||
|
|
670739c82b | ||
|
|
8511bd15a8 | ||
|
|
06d3c89274 | ||
|
|
e13f3eb2f1 | ||
|
|
001918d613 | ||
|
|
c859c3aa40 | ||
|
|
2bce019677 | ||
|
|
6ba3386157 | ||
|
|
51e66d98f9 | ||
|
|
6484804649 | ||
|
|
b2a05c099d | ||
|
|
898331da4c | ||
|
|
9044e782b2 | ||
|
|
aedb985941 | ||
|
|
9ba22e4058 | ||
|
|
ab0afb61ae | ||
|
|
bf00ba0adc | ||
|
|
d564cf1b62 | ||
|
|
544dfb3b24 | ||
|
|
21eeab6c3c | ||
|
|
cf9af910be | ||
|
|
e2e65f93af | ||
|
|
b271567428 | ||
|
|
a88a295d9a | ||
|
|
38ba31ca7d | ||
|
|
1c8ff2493b | ||
|
|
5c9f62e6b6 | ||
|
|
6fd8018c52 | ||
|
|
d900ddf5f1 | ||
|
|
e3a8d311ce | ||
|
|
8bbc3d5470 | ||
|
|
00cf987cec | ||
|
|
7e5dcae64a | ||
|
|
cb67237d7c | ||
|
|
4be848c440 | ||
|
|
b33422b066 | ||
|
|
77418684da | ||
|
|
08cf9f7774 | ||
|
|
9f2fabf9c0 | ||
|
|
e2e1c7be44 | ||
|
|
ba030ba197 | ||
|
|
a4f37bdc16 | ||
|
|
f6c7b00562 | ||
|
|
b951614f7c | ||
|
|
f1dfdb3494 | ||
|
|
ffbd25b13b | ||
|
|
4f03fe2420 | ||
|
|
ffdb386afa | ||
|
|
9eeeaa79d0 | ||
|
|
4163142d1e | ||
|
|
71cddc67e0 | ||
|
|
2cbc4eb54f | ||
|
|
fc94fb4221 | ||
|
|
85ed41c590 | ||
|
|
18c6edd310 | ||
|
|
a1d7a0d9ca | ||
|
|
5d5078534d | ||
|
|
537a968e2e | ||
|
|
4520d1d29f |
50
.github/workflows/docker-HEAD.yml
vendored
50
.github/workflows/docker-HEAD.yml
vendored
@@ -1,50 +0,0 @@
|
||||
name: Publish Scrypted (git HEAD)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16-bullseye"]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image (scrypted)
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
build-args: BASE=${{ matrix.node }}
|
||||
context: .
|
||||
file: docker/Dockerfile.HEAD
|
||||
platforms: linux/amd64,linux/arm64,linux/armhf
|
||||
push: true
|
||||
tags: |
|
||||
koush/scrypted:HEAD
|
||||
ghcr.io/koush/scrypted:HEAD
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
57
.github/workflows/docker-common.yml
vendored
57
.github/workflows/docker-common.yml
vendored
@@ -2,54 +2,69 @@ name: Publish Scrypted Common
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
schedule:
|
||||
# publish the common base once a month.
|
||||
- cron: '30 8 2 * *'
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
build:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
strategy:
|
||||
matrix:
|
||||
NODE_VERSION: ["18"]
|
||||
BUILDPACK_DEPS_BASE: ["bullseye"]
|
||||
BASE: ["jammy"]
|
||||
FLAVOR: ["full", "lite", "thin"]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
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: 192.168.2.119
|
||||
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
append: |
|
||||
- endpoint: ssh://koush@192.168.2.124
|
||||
platforms: linux/arm64
|
||||
- endpoint: ssh://koush@192.168.2.119
|
||||
platforms: linux/armhf
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image (scrypted-common)
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
build-args: NODE_VERSION=${{ matrix.NODE_VERSION }}
|
||||
context: docker/
|
||||
file: docker/Dockerfile.${{ matrix.FLAVOR }}
|
||||
platforms: linux/amd64,linux/arm64,linux/armhf
|
||||
build-args: |
|
||||
NODE_VERSION=${{ matrix.NODE_VERSION }}
|
||||
BASE=${{ matrix.BASE }}
|
||||
context: install/docker/
|
||||
file: install/docker/Dockerfile.${{ matrix.FLAVOR }}
|
||||
platforms: linux/amd64,linux/armhf,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
koush/scrypted-common:${{ matrix.NODE_VERSION }}-${{ matrix.BUILDPACK_DEPS_BASE }}-${{ matrix.FLAVOR }}
|
||||
# ${{ matrix.NODE_VERSION == '16-bullseye' && 'koush/scrypted-common:latest' || '' }}
|
||||
koush/scrypted-common:${{ matrix.NODE_VERSION }}-${{ matrix.BASE }}-${{ matrix.FLAVOR }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
89
.github/workflows/docker.yml
vendored
89
.github/workflows/docker.yml
vendored
@@ -1,48 +1,65 @@
|
||||
name: Publish Scrypted
|
||||
name: Publish Scrypted Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
docker_tag:
|
||||
description: 'Docker Tag'
|
||||
tag:
|
||||
description: "The npm tag used to build the Docker image. The tag will be resolved as a specific version on npm, and that will be used to version the docker image."
|
||||
required: true
|
||||
package_version:
|
||||
description: 'Package Version'
|
||||
publish_tag:
|
||||
description: "The versioned tag for the published Docker image. NPM will use the minor version, Docker should only specify a patch version."
|
||||
required: false
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
build:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
strategy:
|
||||
matrix:
|
||||
BASE: ["18-bullseye-full", "18-bullseye-lite", "18-bullseye-thin"]
|
||||
BASE: ["18-jammy-full", "18-jammy-lite", "18-jammy-thin"]
|
||||
SUPERVISOR: ["", ".s6"]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@master
|
||||
|
||||
- name: NPM Package Request
|
||||
id: npm-request
|
||||
uses: fjogeleit/http-request-action@v1
|
||||
with:
|
||||
path: server
|
||||
url: 'https://registry.npmjs.org/@scrypted/server'
|
||||
method: 'GET'
|
||||
|
||||
- name: Print Version
|
||||
run: echo "Version ${{ github.event.inputs.package_version || steps.package-version.outputs.current-version }}"
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
|
||||
- name: Set NPM Version
|
||||
id: package-version
|
||||
run: echo "NPM_VERSION=${{ fromJson(steps.npm-request.outputs.response)['dist-tags'][ github.event.inputs.tag] }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- 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: 192.168.2.119
|
||||
private-key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
append: |
|
||||
- endpoint: ssh://koush@192.168.2.124
|
||||
platforms: linux/arm64
|
||||
- endpoint: ssh://koush@192.168.2.119
|
||||
platforms: linux/armhf
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -56,25 +73,31 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image (scrypted)
|
||||
uses: docker/build-push-action@v3
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
build-args: |
|
||||
BASE=${{ matrix.BASE }}
|
||||
SCRYPTED_INSTALL_VERSION=${{ github.event.inputs.package_version }}
|
||||
context: docker/
|
||||
file: docker/Dockerfile${{ matrix.SUPERVISOR }}
|
||||
SCRYPTED_INSTALL_VERSION=${{ steps.package-version.outputs.NPM_VERSION }}
|
||||
context: install/docker/
|
||||
file: install/docker/Dockerfile${{ matrix.SUPERVISOR }}
|
||||
platforms: linux/amd64,linux/arm64,linux/armhf
|
||||
push: true
|
||||
tags: |
|
||||
${{ format('koush/scrypted:{0}{1}-v{2}', matrix.BASE, matrix.SUPERVISOR, github.event.inputs.package_version || steps.package-version.outputs.current-version) }}
|
||||
${{ matrix.BASE == '18-bullseye-full' && matrix.SUPERVISOR == '.s6' && format('koush/scrypted:{0}', github.event.inputs.docker_tag) || '' }}
|
||||
${{ github.event.inputs.docker_tag == 'latest' && matrix.BASE == '18-bullseye-lite' && matrix.SUPERVISOR == '' && 'koush/scrypted:lite' || '' }}
|
||||
${{ github.event.inputs.docker_tag == 'latest' && matrix.BASE == '18-bullseye-thin' && matrix.SUPERVISOR == '' && 'koush/scrypted:thin' || '' }}
|
||||
${{ format('koush/scrypted:{0}{1}-v{2}', matrix.BASE, matrix.SUPERVISOR, github.event.inputs.publish_tag || steps.package-version.outputs.NPM_VERSION) }}
|
||||
${{ matrix.BASE == '18-jammy-full' && matrix.SUPERVISOR == '.s6' && format('koush/scrypted:{0}', github.event.inputs.tag) || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-full' && matrix.SUPERVISOR == '' && 'koush/scrypted:full' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-lite' && matrix.SUPERVISOR == '' && 'koush/scrypted:lite' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-thin' && matrix.SUPERVISOR == '' && 'koush/scrypted:thin' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-lite' && matrix.SUPERVISOR == '.s6' && 'koush/scrypted:lite-s6' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-thin' && matrix.SUPERVISOR == '.s6' && 'koush/scrypted:thin-s6' || '' }}
|
||||
|
||||
${{ format('ghcr.io/koush/scrypted:{0}{1}-v{2}', matrix.BASE, matrix.SUPERVISOR, github.event.inputs.package_version || steps.package-version.outputs.current-version) }}
|
||||
${{ matrix.BASE == '18-bullseye-full' && matrix.SUPERVISOR == '.s6' && format('ghcr.io/koush/scrypted:{0}', github.event.inputs.docker_tag) || '' }}
|
||||
${{ github.event.inputs.docker_tag == 'latest' && matrix.BASE == '18-bullseye-lite' && matrix.SUPERVISOR == '' && 'ghcr.io/koush/scrypted:lite' || '' }}
|
||||
${{ github.event.inputs.docker_tag == 'latest' && matrix.BASE == '18-bullseye-thin' && matrix.SUPERVISOR == '' && 'ghcr.io/koush/scrypted:thin' || '' }}
|
||||
${{ format('ghcr.io/koush/scrypted:{0}{1}-v{2}', matrix.BASE, matrix.SUPERVISOR, github.event.inputs.publish_tag || steps.package-version.outputs.NPM_VERSION) }}
|
||||
${{ matrix.BASE == '18-jammy-full' && matrix.SUPERVISOR == '.s6' && format('ghcr.io/koush/scrypted:{0}', github.event.inputs.tag) || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-full' && matrix.SUPERVISOR == '' && 'ghcr.io/koush/scrypted:full' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-lite' && matrix.SUPERVISOR == '' && 'ghcr.io/koush/scrypted:lite' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-thin' && matrix.SUPERVISOR == '' && 'ghcr.io/koush/scrypted:thin' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-lite' && matrix.SUPERVISOR == '.s6' && 'ghcr.io/koush/scrypted:lite-s6' || '' }}
|
||||
${{ github.event.inputs.tag == 'latest' && matrix.BASE == '18-jammy-thin' && matrix.SUPERVISOR == '.s6' && 'ghcr.io/koush/scrypted:thin-s6' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
60
.github/workflows/test.yml
vendored
Normal file
60
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths: ["docker/**", ".github/workflows/test.yml"]
|
||||
pull_request:
|
||||
paths: ["docker/**", ".github/workflows/test.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test_linux_local:
|
||||
name: Test Linux local installation
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run install script
|
||||
run: |
|
||||
cat ./install/local/install-scrypted-dependencies-linux.sh | sudo SERVICE_USER=$USER bash
|
||||
|
||||
- name: Test server is running
|
||||
run: |
|
||||
systemctl status scrypted.service
|
||||
curl -k --retry 20 --retry-all-errors --retry-max-time 600 https://localhost:10443/
|
||||
|
||||
test_mac_local:
|
||||
name: Test Mac local installation
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run install script
|
||||
run: |
|
||||
mkdir -p ~/.scrypted
|
||||
bash ./install/local/install-scrypted-dependencies-mac.sh
|
||||
|
||||
- name: Test server is running
|
||||
run: |
|
||||
curl -k --retry 20 --retry-all-errors --retry-max-time 600 https://localhost:10443/
|
||||
|
||||
test_windows_local:
|
||||
name: Test Windows local installation
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run install script
|
||||
run: |
|
||||
.\install\local\install-scrypted-dependencies-win.ps1
|
||||
|
||||
- name: Test server is running
|
||||
run: |
|
||||
curl -k --retry 20 --retry-all-errors --retry-max-time 600 https://localhost:10443/
|
||||
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -1,6 +1,3 @@
|
||||
[submodule "plugins/homekit/HAP-NodeJS"]
|
||||
path = external/HAP-NodeJS
|
||||
url = ../../koush/HAP-NodeJS
|
||||
[submodule "plugins/unifi-protect/src/unifi-protect"]
|
||||
path = external/unifi-protect
|
||||
url = ../../koush/unifi-protect.git
|
||||
@@ -35,9 +32,6 @@
|
||||
[submodule "plugins/sample-cameraprovider"]
|
||||
path = plugins/sample-cameraprovider
|
||||
url = ../../koush/scrypted-sample-cameraprovider
|
||||
[submodule "plugins/tensorflow-lite/sort_oh"]
|
||||
path = plugins/sort-tracker/sort_oh
|
||||
url = ../../koush/sort_oh.git
|
||||
[submodule "plugins/cloud/node-nat-upnp"]
|
||||
path = plugins/cloud/node-nat-upnp
|
||||
url = ../../koush/node-nat-upnp.git
|
||||
|
||||
@@ -23,6 +23,7 @@ Select the appropriate guide. After installation is finished, remember to visit
|
||||
* Windows
|
||||
* [Local Installation](https://github.com/koush/scrypted/wiki/Installation:-Windows)
|
||||
* [WSL2 Installation](https://github.com/koush/scrypted/wiki/Installation:-WSL2-Windows)
|
||||
* [Home Assistant OS](https://github.com/koush/scrypted/wiki/Installation:-Home-Assistant-OS)
|
||||
<!-- * Docker Desktop is [not supported](https://github.com/koush/scrypted/wiki/Installation:-Docker-Desktop). -->
|
||||
* [ReadyNAS: Docker](https://github.com/koush/scrypted/wiki/Installation:-Docker-ReadyNAS)
|
||||
* [Synology: Docker](https://github.com/koush/scrypted/wiki/Installation:-Docker-Synology-NAS)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
export class Deferred<T> {
|
||||
finished = false;
|
||||
resolve!: (value: T|PromiseLike<T>) => void;
|
||||
reject!: (error: Error) => void;
|
||||
resolve!: (value: T|PromiseLike<T>) => this;
|
||||
reject!: (error: Error) => this;
|
||||
promise: Promise<T> = new Promise((resolve, reject) => {
|
||||
this.resolve = v => {
|
||||
this.finished = true;
|
||||
resolve(v);
|
||||
return this;
|
||||
};
|
||||
this.reject = e => {
|
||||
this.finished = true;
|
||||
reject(e);
|
||||
return this;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ import { EventEmitter } from 'events';
|
||||
import { Server } from 'net';
|
||||
import { Duplex } from 'stream';
|
||||
import { cloneDeep } from './clone-deep';
|
||||
import { Deferred } from "./deferred";
|
||||
import { listenZeroSingleClient } from './listen-cluster';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from './media-helpers';
|
||||
import { createRtspParser } from "./rtsp-server";
|
||||
import { parseSdp } from "./sdp-utils";
|
||||
import { StreamChunk, StreamParser } from './stream-parser';
|
||||
|
||||
const { mediaManager } = sdk;
|
||||
@@ -57,9 +60,13 @@ export async function parseResolution(cp: ChildProcess) {
|
||||
}
|
||||
|
||||
async function parseInputToken(cp: ChildProcess, token: string) {
|
||||
let processed = 0;
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
cp.on('exit', () => reject(new Error('ffmpeg exited while waiting to parse stream information: ' + token)));
|
||||
const parser = (data: Buffer) => {
|
||||
processed += data.length;
|
||||
if (processed > 10000)
|
||||
return resolve(undefined);
|
||||
const stdout: string = data.toString().split('Output ')[0];
|
||||
const idx = stdout.lastIndexOf(`${token}: `);
|
||||
if (idx !== -1) {
|
||||
@@ -77,7 +84,11 @@ async function parseInputToken(cp: ChildProcess, token: string) {
|
||||
};
|
||||
cp.stdout.on('data', parser);
|
||||
cp.stderr.on('data', parser);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
cp.stdout.removeAllListeners('data');
|
||||
cp.stderr.removeAllListeners('data');
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseVideoCodec(cp: ChildProcess) {
|
||||
@@ -158,8 +169,6 @@ export async function startParserSession<T extends string>(ffmpegInput: FFmpegIn
|
||||
|
||||
const args = ffmpegInput.inputArguments.slice();
|
||||
|
||||
let needSdp = false;
|
||||
|
||||
const ensureActive = (killed: () => void) => {
|
||||
if (!isActive) {
|
||||
killed();
|
||||
@@ -211,11 +220,6 @@ export async function startParserSession<T extends string>(ffmpegInput: FFmpegIn
|
||||
}
|
||||
}
|
||||
|
||||
if (needSdp) {
|
||||
args.push('-sdp_file', `pipe:${pipeCount++}`);
|
||||
stdio.push('pipe');
|
||||
}
|
||||
|
||||
// start ffmpeg process with child process pipes
|
||||
args.unshift('-hide_banner');
|
||||
safePrintFFmpegArguments(console, args);
|
||||
@@ -225,20 +229,7 @@ export async function startParserSession<T extends string>(ffmpegInput: FFmpegIn
|
||||
ffmpegLogInitialOutput(console, cp, undefined, options?.storage);
|
||||
cp.on('exit', () => kill(new Error('ffmpeg exited')));
|
||||
|
||||
let sdp: Promise<Buffer[]>;
|
||||
if (needSdp) {
|
||||
sdp = new Promise<Buffer[]>(resolve => {
|
||||
const ret: Buffer[] = [];
|
||||
cp.stdio[pipeCount - 1].on('data', buffer => {
|
||||
ret.push(buffer);
|
||||
resolve(ret);
|
||||
});
|
||||
})
|
||||
}
|
||||
else {
|
||||
sdp = Promise.resolve([]);
|
||||
}
|
||||
|
||||
const deferredStart = new Deferred<void>();
|
||||
// now parse the created pipes
|
||||
const start = () => {
|
||||
for (const p of startParsers) {
|
||||
@@ -257,6 +248,7 @@ export async function startParserSession<T extends string>(ffmpegInput: FFmpegIn
|
||||
const { resetActivityTimer } = setupActivityTimer(container, kill, events, options?.timeout);
|
||||
|
||||
for await (const chunk of parser.parse(pipe as any, parseInt(inputVideoResolution?.[2]), parseInt(inputVideoResolution?.[3]))) {
|
||||
await deferredStart.promise;
|
||||
events.emit(container, chunk);
|
||||
resetActivityTimer();
|
||||
}
|
||||
@@ -268,13 +260,22 @@ export async function startParserSession<T extends string>(ffmpegInput: FFmpegIn
|
||||
});
|
||||
};
|
||||
|
||||
// tbh parsing stdout is super sketchy way of doing this.
|
||||
parseAudioCodec(cp).then(result => inputAudioCodec = result);
|
||||
parseResolution(cp).then(result => inputVideoResolution = result);
|
||||
await parseVideoCodec(cp).then(result => inputVideoCodec = result);
|
||||
const rtsp = (options.parsers as any).rtsp as ReturnType<typeof createRtspParser>;
|
||||
rtsp.sdp.then(sdp => {
|
||||
const parsed = parseSdp(sdp);
|
||||
const audio = parsed.msections.find(msection=>msection.type === 'audio');
|
||||
const video = parsed.msections.find(msection=>msection.type === 'video');
|
||||
inputVideoCodec = video?.codec;
|
||||
inputAudioCodec = audio?.codec;
|
||||
});
|
||||
|
||||
const sdp = rtsp.sdp.then(sdpString => [Buffer.from(sdpString)]);
|
||||
start();
|
||||
|
||||
return {
|
||||
start,
|
||||
start() {
|
||||
deferredStart.resolve();
|
||||
},
|
||||
sdp,
|
||||
get inputAudioCodec() {
|
||||
return inputAudioCodec;
|
||||
@@ -361,8 +362,7 @@ export interface RebroadcasterOptions {
|
||||
},
|
||||
}
|
||||
|
||||
export async function handleRebroadcasterClient(duplex: Promise<Duplex> | Duplex, options?: RebroadcasterOptions) {
|
||||
const socket = await duplex;
|
||||
export function handleRebroadcasterClient(socket: Duplex, options?: RebroadcasterOptions) {
|
||||
const firstWriteData = (data: StreamChunk) => {
|
||||
if (data.startStream) {
|
||||
socket.write(data.startStream)
|
||||
|
||||
@@ -62,4 +62,4 @@ export async function bind(server: dgram.Socket, port: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export { listenZero, listenZeroSingleClient } from "@scrypted/server/src/listen-zero";
|
||||
export { listenZero, listenZeroSingleClient, ListenZeroSingleClientTimeoutError } from "@scrypted/server/src/listen-zero";
|
||||
|
||||
@@ -250,7 +250,8 @@ export class BrowserSignalingSession implements RTCSignalingSession {
|
||||
function logSendCandidate(console: Console, type: string, session: RTCSignalingSession): RTCSignalingSendIceCandidate {
|
||||
return async (candidate) => {
|
||||
try {
|
||||
console.log(`${type} trickled candidate:`, candidate.sdpMLineIndex, candidate.candidate);
|
||||
if (localStorage.getItem('debugLog') === 'true')
|
||||
console.log(`${type} trickled candidate:`, candidate.sdpMLineIndex, candidate.candidate);
|
||||
await session.addIceCandidate(candidate);
|
||||
}
|
||||
catch (e) {
|
||||
@@ -297,7 +298,7 @@ export async function connectRTCSignalingClients(
|
||||
if (offerOptions?.offer && answerOptions?.offer)
|
||||
throw new Error('Both RTC clients have offers and can not negotiate. Consider implementing this in @scrypted/webrtc.');
|
||||
|
||||
if (offerOptions?.requiresOffer && answerOptions.requiresOffer)
|
||||
if (offerOptions?.requiresOffer && answerOptions?.requiresOffer)
|
||||
throw new Error('Both RTC clients require offers and can not negotiate.');
|
||||
|
||||
offerSetup.type = 'offer';
|
||||
@@ -308,11 +309,13 @@ export async function connectRTCSignalingClients(
|
||||
|
||||
const offer = await offerClient.createLocalDescription('offer', offerSetup as RTCAVSignalingSetup,
|
||||
disableTrickle ? undefined : answerQueue.queueSendCandidate);
|
||||
console.log('offer sdp', offer.sdp);
|
||||
if (localStorage.getItem('debugLog') === 'true')
|
||||
console.log('offer sdp', offer.sdp);
|
||||
await answerClient.setRemoteDescription(offer, answerSetup as RTCAVSignalingSetup);
|
||||
const answer = await answerClient.createLocalDescription('answer', answerSetup as RTCAVSignalingSetup,
|
||||
disableTrickle ? undefined : offerQueue.queueSendCandidate);
|
||||
console.log('answer sdp', answer.sdp);
|
||||
if (localStorage.getItem('debugLog') === 'true')
|
||||
console.log('answer sdp', answer.sdp);
|
||||
await offerClient.setRemoteDescription(answer, offerSetup as RTCAVSignalingSetup);
|
||||
offerQueue.flush();
|
||||
answerQueue.flush();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { readLength, readLine } from './read-stream';
|
||||
import { MSection, parseSdp } from './sdp-utils';
|
||||
import { sleep } from './sleep';
|
||||
import { StreamChunk, StreamParser, StreamParserOptions } from './stream-parser';
|
||||
import { URL } from 'url';
|
||||
|
||||
const REQUIRED_WWW_AUTHENTICATE_KEYS = ['realm', 'nonce'];
|
||||
|
||||
@@ -128,6 +129,16 @@ export function getNaluTypes(streamChunk: StreamChunk) {
|
||||
return getNaluTypesInNalu(streamChunk.chunks[streamChunk.chunks.length - 1].subarray(12))
|
||||
}
|
||||
|
||||
export function getNaluFragmentInformation(nalu: Buffer) {
|
||||
const naluType = nalu[0] & 0x1f;
|
||||
const fua = naluType === H264_NAL_TYPE_FU_A;
|
||||
return {
|
||||
fua,
|
||||
fuaStart: fua && !!(nalu[1] & 0x80),
|
||||
fuaEnd: fua && !!(nalu[1] & 0x40),
|
||||
}
|
||||
}
|
||||
|
||||
export function getNaluTypesInNalu(nalu: Buffer, fuaRequireStart = false, fuaRequireEnd = false) {
|
||||
const ret = new Set<number>();
|
||||
const naluType = nalu[0] & 0x1f;
|
||||
@@ -580,7 +591,7 @@ export class RtspClient extends RtspBase {
|
||||
const username = decodeURIComponent(authedUrl.username);
|
||||
const password = decodeURIComponent(authedUrl.password);
|
||||
|
||||
const strippedUrl = new URL(url);
|
||||
const strippedUrl = new URL(url.toString());
|
||||
strippedUrl.username = '';
|
||||
strippedUrl.password = '';
|
||||
|
||||
@@ -670,7 +681,7 @@ export class RtspClient extends RtspBase {
|
||||
});
|
||||
}
|
||||
|
||||
async setup(options: RtspClientTcpSetupOptions | RtspClientUdpSetupOptions) {
|
||||
async setup(options: RtspClientTcpSetupOptions | RtspClientUdpSetupOptions, headers?: Headers) {
|
||||
const protocol = options.type === 'udp' ? '' : '/TCP';
|
||||
const client = options.type === 'udp' ? 'client_port' : 'interleaved';
|
||||
let port: number;
|
||||
@@ -686,9 +697,9 @@ export class RtspClient extends RtspBase {
|
||||
port = options.dgram.address().port;
|
||||
options.dgram.on('message', data => options.onRtp(undefined, data));
|
||||
}
|
||||
const headers: any = {
|
||||
headers = Object.assign({
|
||||
Transport: `RTP/AVP${protocol};unicast;${client}=${port}-${port + 1}`,
|
||||
};
|
||||
}, headers);
|
||||
const response = await this.request('SETUP', headers, options.path);
|
||||
let interleaved: {
|
||||
begin: number;
|
||||
|
||||
@@ -217,14 +217,12 @@ const acontrol = 'a=control:';
|
||||
const artpmap = 'a=rtpmap:';
|
||||
export function parseMSection(msection: string[]) {
|
||||
const control = msection.find(line => line.startsWith(acontrol))?.substring(acontrol.length);
|
||||
const rtpmapFirst = msection.find(line => line.startsWith(artpmap));
|
||||
const mline = parseMLine(msection[0]);
|
||||
|
||||
let codec = parseRtpMap(mline.type, rtpmapFirst).codec;
|
||||
|
||||
const rtpmaps = msection.filter(line => line.startsWith(artpmap)).map(line => parseRtpMap(mline.type, line));
|
||||
|
||||
const rawRtpmaps = msection.filter(line => line.startsWith(artpmap));
|
||||
const rtpmaps = rawRtpmaps.map(line => parseRtpMap(mline.type, line));
|
||||
const codec = parseRtpMap(mline.type, rawRtpmaps[0]).codec;
|
||||
let direction: string;
|
||||
|
||||
for (const checkDirection of ['sendonly', 'sendrecv', 'recvonly', 'inactive']) {
|
||||
const found = msection.find(line => line === 'a=' + checkDirection);
|
||||
if (found) {
|
||||
|
||||
56
common/test/rtsp-proxy.ts
Normal file
56
common/test/rtsp-proxy.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import net from 'net';
|
||||
import { listenZero } from '../src/listen-cluster';
|
||||
import { RtspClient, RtspServer } from '../src/rtsp-server';
|
||||
|
||||
async function main() {
|
||||
const server = net.createServer(async serverSocket => {
|
||||
const client = new RtspClient('rtsp://localhost:57594/911db962087f904d');
|
||||
await client.options();
|
||||
const describeResponse = await client.describe();
|
||||
const sdp = describeResponse.body.toString();
|
||||
const server = new RtspServer(serverSocket, sdp, true);
|
||||
const setupResponse = await server.handlePlayback();
|
||||
if (setupResponse !== 'play') {
|
||||
serverSocket.destroy();
|
||||
client.client.destroy();
|
||||
return;
|
||||
}
|
||||
console.log('playback handled');
|
||||
|
||||
let channel = 0;
|
||||
for (const track of Object.keys(server.setupTracks)) {
|
||||
const setupTrack = server.setupTracks[track];
|
||||
await client.setup({
|
||||
// type: 'udp',
|
||||
|
||||
type: 'tcp',
|
||||
port: channel,
|
||||
|
||||
path: setupTrack.control,
|
||||
onRtp(rtspHeader, rtp) {
|
||||
server.sendTrack(setupTrack.control, rtp, false);
|
||||
},
|
||||
});
|
||||
|
||||
channel += 2;
|
||||
}
|
||||
|
||||
|
||||
await client.play();
|
||||
console.log('client playing');
|
||||
await client.readLoop();
|
||||
});
|
||||
|
||||
let port: number;
|
||||
if (false) {
|
||||
port = await listenZero(server);
|
||||
}
|
||||
else {
|
||||
port = 5555;
|
||||
server.listen(5555)
|
||||
}
|
||||
|
||||
console.log(`rtsp://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,81 +0,0 @@
|
||||
################################################################
|
||||
# THIS FILE IS GENERATED. DO NOT EDIT.
|
||||
################################################################
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.header
|
||||
# This common file will be used by both Docker and the linux
|
||||
# install script.
|
||||
################################################################
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs
|
||||
|
||||
# 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
|
||||
RUN apt-get -y install libedgetpu1-std
|
||||
|
||||
RUN apt-get -y install software-properties-common apt-utils
|
||||
RUN apt-get -y update
|
||||
RUN apt-get -y upgrade
|
||||
|
||||
# base development stuff
|
||||
RUN apt-get -y install \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libgirepository1.0-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
|
||||
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
|
||||
RUN apt-get -y install \
|
||||
gstreamer1.0-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-vaapi
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-gi \
|
||||
python3-gst-1.0 \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-pip \
|
||||
python3-setuptools \
|
||||
python3-skimage \
|
||||
python3-wheel
|
||||
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions typing psutil
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.header
|
||||
################################################################
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
FROM header as base
|
||||
|
||||
ENV SCRYPTED_DOCKER_SERVE="true"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
@@ -1,45 +0,0 @@
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs
|
||||
|
||||
RUN apt-get -y update
|
||||
RUN apt-get -y upgrade
|
||||
RUN apt-get -y install software-properties-common apt-utils
|
||||
RUN apt-get -y update
|
||||
|
||||
# base development stuff
|
||||
RUN apt-get -y install \
|
||||
build-essential \
|
||||
gcc \
|
||||
libgirepository1.0-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
ENV SCRYPTED_FFMPEG_PATH=ffmpeg
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-gi \
|
||||
python3-pip \
|
||||
python3-setuptools \
|
||||
python3-wheel
|
||||
|
||||
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions typing psutil
|
||||
|
||||
ENV SCRYPTED_DOCKER_SERVE="true"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
@@ -1,30 +0,0 @@
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs
|
||||
|
||||
RUN apt-get -y update
|
||||
RUN apt-get -y upgrade
|
||||
RUN apt-get -y install software-properties-common apt-utils
|
||||
RUN apt-get -y update
|
||||
|
||||
# base development stuff
|
||||
RUN apt-get -y install \
|
||||
build-essential \
|
||||
gcc \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
ENV SCRYPTED_FFMPEG_PATH=ffmpeg
|
||||
|
||||
ENV SCRYPTED_DOCKER_SERVE="true"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
@@ -1,13 +0,0 @@
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
FROM header as base
|
||||
|
||||
ENV SCRYPTED_DOCKER_SERVE="true"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
@@ -1,65 +0,0 @@
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.header
|
||||
# This common file will be used by both Docker and the linux
|
||||
# install script.
|
||||
################################################################
|
||||
ARG BUILDPACK_DEPS_BASE="bullseye"
|
||||
FROM buildpack-deps:${BUILDPACK_DEPS_BASE} as header
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs
|
||||
|
||||
# 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
|
||||
RUN apt-get -y install libedgetpu1-std
|
||||
|
||||
RUN apt-get -y install software-properties-common apt-utils
|
||||
RUN apt-get -y update
|
||||
RUN apt-get -y upgrade
|
||||
|
||||
# base development stuff
|
||||
RUN apt-get -y install \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libgirepository1.0-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
|
||||
# ffmpeg
|
||||
RUN apt-get -y install \
|
||||
ffmpeg
|
||||
|
||||
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
|
||||
RUN apt-get -y install \
|
||||
gstreamer1.0-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-vaapi
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-gi \
|
||||
python3-gst-1.0 \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-pip \
|
||||
python3-setuptools \
|
||||
python3-skimage \
|
||||
python3-wheel
|
||||
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
RUN python3 -m pip install aiofiles debugpy typing_extensions typing psutil
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.header
|
||||
################################################################
|
||||
1
external/HAP-NodeJS
vendored
1
external/HAP-NodeJS
vendored
Submodule external/HAP-NodeJS deleted from 3fe1f920f5
2
external/ring-client-api
vendored
2
external/ring-client-api
vendored
Submodule external/ring-client-api updated: d571cdfc00...81f6570f59
2
external/werift
vendored
2
external/werift
vendored
Submodule external/werift updated: 140faa891d...91be7cf469
48
install/config.yaml
Executable file
48
install/config.yaml
Executable file
@@ -0,0 +1,48 @@
|
||||
# Home Assistant Addon Configuration
|
||||
name: Scrypted
|
||||
version: "18-bullseye-full.s6-v0.23.0"
|
||||
slug: scrypted
|
||||
description: Scrypted is a high performance home video integration and automation platform
|
||||
url: "https://github.com/koush/scrypted"
|
||||
arch:
|
||||
- amd64
|
||||
- aarch64
|
||||
- armv7
|
||||
init: false
|
||||
ingress: true
|
||||
ingress_port: 11080
|
||||
panel_icon: mdi:memory
|
||||
hassio_api: true
|
||||
homeassistant_api: true
|
||||
ingress_stream: true
|
||||
host_network: true
|
||||
gpio: true
|
||||
usb: true
|
||||
uart: true
|
||||
video: true
|
||||
image: "ghcr.io/koush/scrypted"
|
||||
environment:
|
||||
SCRYPTED_INSTALL_PLUGIN: "@scrypted/homeassistant"
|
||||
SCRYPTED_VOLUME: "/data/scrypted_data"
|
||||
SCRYPTED_NVR_VOLUME: "/data/scrypted_nvr"
|
||||
SCRYPTED_ADMIN_ADDRESS: "172.30.32.2"
|
||||
SCRYPTED_ADMIN_USERNAME: "homeassistant"
|
||||
SCRYPTED_INSTALL_ENVIRONMENT: "ha"
|
||||
backup_exclude:
|
||||
- '/server/**'
|
||||
- '/data/scrypted_nvr/**'
|
||||
- '/data/scrypted_data/plugins/**'
|
||||
map:
|
||||
- config:rw
|
||||
- media:rw
|
||||
devices:
|
||||
- /dev/mem
|
||||
- /dev/dri/renderD128
|
||||
- /dev/apex_0
|
||||
- /dev/apex_1
|
||||
- /dev/apex_2
|
||||
- /dev/apex_3
|
||||
- /dev/dri/card0
|
||||
- /dev/vchiq
|
||||
- /dev/video10
|
||||
- /dev/video0
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG BASE="18-bullseye-full"
|
||||
ARG BASE="18-jammy-full"
|
||||
FROM koush/scrypted-common:${BASE}
|
||||
|
||||
WORKDIR /
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG BASE="16-bullseye"
|
||||
ARG BASE="16-jammy"
|
||||
FROM koush/scrypted-common:${BASE}
|
||||
|
||||
WORKDIR /
|
||||
131
install/docker/Dockerfile.full
Normal file
131
install/docker/Dockerfile.full
Normal file
@@ -0,0 +1,131 @@
|
||||
################################################################
|
||||
# THIS FILE IS GENERATED. DO NOT EDIT.
|
||||
################################################################
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.header
|
||||
# This common file will be used by both Docker and the linux
|
||||
# install script.
|
||||
################################################################
|
||||
ARG BASE="jammy"
|
||||
FROM ubuntu:${BASE} as header
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# base tools and development stuff
|
||||
RUN apt-get update && apt-get -y install \
|
||||
curl software-properties-common apt-utils \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libcairo2-dev \
|
||||
libgirepository1.0-dev \
|
||||
pkg-config && \
|
||||
apt-get -y update && \
|
||||
apt-get -y upgrade
|
||||
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
|
||||
RUN apt-get update && apt-get install -y nodejs
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
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 \
|
||||
libjpeg-dev zlib1g-dev
|
||||
|
||||
# plugins support fallback to pillow, but vips is faster.
|
||||
RUN apt-get -y install \
|
||||
libvips
|
||||
|
||||
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
|
||||
RUN apt-get -y install \
|
||||
gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-vaapi
|
||||
|
||||
# python3 gstreamer bindings
|
||||
RUN apt-get -y install \
|
||||
python3-gst-1.0
|
||||
|
||||
# python 3.9 from ppa.
|
||||
# 3.9 is the version with prebuilt support for tensorflow lite
|
||||
RUN add-apt-repository ppa:deadsnakes/ppa && \
|
||||
apt-get -y install \
|
||||
python3.9 \
|
||||
python3.9-dev \
|
||||
python3.9-distutils
|
||||
|
||||
# armv7l does not have wheels for any of these
|
||||
# and compile times would forever, if it works at all.
|
||||
# furthermore, it's possible to run 32bit docker on 64bit arm,
|
||||
# which causes weird behavior in python which looks at the arch version
|
||||
# which still reports 64bit, even if running in 32bit docker.
|
||||
# this scenario is not supported and will be reported at runtime.
|
||||
# this bit is not necessary on amd64, but leaving it for consistency.
|
||||
RUN apt-get -y install \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-skimage
|
||||
|
||||
# python pip
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.header
|
||||
################################################################
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
FROM header as base
|
||||
|
||||
# intel opencl gpu for openvino
|
||||
RUN bash -c "if [ \"$(uname -m)\" == \"x86_64\" ]; \
|
||||
then \
|
||||
apt-get update && apt-get install -y gpg-agent && \
|
||||
rm -f /usr/share/keyrings/intel-graphics.gpg && \
|
||||
curl -L https://repositories.intel.com/graphics/intel-graphics.key | gpg --dearmor --output /usr/share/keyrings/intel-graphics.gpg && \
|
||||
echo 'deb [arch=amd64,i386 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/graphics/ubuntu jammy arc' | tee /etc/apt/sources.list.d/intel.gpu.jammy.list && \
|
||||
apt-get -y update && \
|
||||
apt-get -y install intel-opencl-icd intel-media-va-driver-non-free && \
|
||||
apt-get -y dist-upgrade; \
|
||||
fi"
|
||||
|
||||
ENV SCRYPTED_INSTALL_ENVIRONMENT="docker"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230608
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=full
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
43
install/docker/Dockerfile.lite
Normal file
43
install/docker/Dockerfile.lite
Normal file
@@ -0,0 +1,43 @@
|
||||
ARG BASE="jammy"
|
||||
FROM ubuntu:${BASE} as header
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# base tools and development stuff
|
||||
RUN apt-get update && apt-get -y install \
|
||||
curl software-properties-common apt-utils \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libcairo2-dev \
|
||||
libgirepository1.0-dev \
|
||||
pkg-config && \
|
||||
apt-get -y update && \
|
||||
apt-get -y upgrade
|
||||
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
|
||||
RUN apt-get update && apt-get install -y nodejs
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-setuptools \
|
||||
python3-wheel
|
||||
|
||||
# python pip
|
||||
RUN rm -f /usr/lib/python**/EXTERNALLY-MANAGED
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
RUN python3 -m pip install debugpy typing_extensions psutil
|
||||
|
||||
ENV SCRYPTED_INSTALL_ENVIRONMENT="docker"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230608
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=lite
|
||||
22
install/docker/Dockerfile.nvidia
Normal file
22
install/docker/Dockerfile.nvidia
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM koush/18-jammy-full.s6
|
||||
|
||||
WORKDIR /
|
||||
|
||||
# Install miniconda
|
||||
ENV CONDA_DIR /opt/conda
|
||||
RUN wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda.sh && \
|
||||
/bin/bash ~/miniconda.sh -b -p /opt/conda
|
||||
# Put conda in path so we can use conda activate
|
||||
ENV PATH=$CONDA_DIR/bin:$PATH
|
||||
|
||||
RUN conda install -c conda-forge cudatoolkit=11.2.2 cudnn=8.1.0
|
||||
ENV CONDA_PREFIX=/opt/conda
|
||||
ENV LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$CONDA_PREFIX/lib/
|
||||
|
||||
# this is a copy pasta, seems to need a reinstall.
|
||||
# python pip
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
# pyvips is broken on x86 due to mismatch ffi
|
||||
# https://stackoverflow.com/questions/62658237/it-seems-that-the-version-of-the-libffi-library-seen-at-runtime-is-different-fro
|
||||
RUN python3 -m pip install --force-reinstall --no-binary :all: cffi
|
||||
RUN python3 -m pip install debugpy typing_extensions psutil
|
||||
@@ -1,11 +1,12 @@
|
||||
ARG BASE="18-bullseye-full"
|
||||
ARG BASE="18-jammy-full"
|
||||
FROM koush/scrypted-common:${BASE}
|
||||
|
||||
# avahi advertiser support
|
||||
RUN apt-get -y install \
|
||||
RUN apt-get update && apt-get -y install \
|
||||
libnss-mdns \
|
||||
avahi-discover \
|
||||
libavahi-compat-libdnssd-dev
|
||||
libavahi-compat-libdnssd-dev \
|
||||
xz-utils
|
||||
|
||||
# copy configurations and scripts
|
||||
COPY fs /
|
||||
22
install/docker/Dockerfile.thin
Normal file
22
install/docker/Dockerfile.thin
Normal file
@@ -0,0 +1,22 @@
|
||||
ARG BASE="jammy"
|
||||
FROM ubuntu:${BASE} as header
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get -y update && \
|
||||
apt-get -y upgrade && \
|
||||
apt-get -y install curl software-properties-common apt-utils
|
||||
|
||||
# switch to nvm?
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && apt-get update && apt-get install -y nodejs
|
||||
|
||||
ENV SCRYPTED_INSTALL_ENVIRONMENT="docker"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230608
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=thin
|
||||
3
install/docker/docker-build-nvidia.sh
Executable file
3
install/docker/docker-build-nvidia.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
./docker-build.sh
|
||||
|
||||
docker build -t koush/scrypted:18-jammy-full.nvidia -f Dockerfile.nvidia
|
||||
@@ -3,15 +3,16 @@
|
||||
set -x
|
||||
|
||||
NODE_VERSION=18
|
||||
BUILDPACK_DEPS_BASE=bullseye
|
||||
SCRYPTED_INSTALL_VERSION=beta
|
||||
IMAGE_BASE=jammy
|
||||
FLAVOR=full
|
||||
BASE=$NODE_VERSION-$BUILDPACK_DEPS_BASE-$FLAVOR
|
||||
BASE=$NODE_VERSION-$IMAGE_BASE-$FLAVOR
|
||||
echo $BASE
|
||||
SUPERVISOR=.s6
|
||||
SUPERVISOR_BASE=$BASE$SUPERVISOR
|
||||
|
||||
docker build -t koush/scrypted-common:$BASE -f Dockerfile.$FLAVOR \
|
||||
--build-arg NODE_VERSION=$NODE_VERSION --build-arg BUILDPACK_DEPS_BASE=$BUILDPACK_DEPS_BASE . && \
|
||||
--build-arg NODE_VERSION=$NODE_VERSION --build-arg BASE=$IMAGE_BASE . && \
|
||||
\
|
||||
docker build -t koush/scrypted:$SUPERVISOR_BASE -f Dockerfile$SUPERVISOR \
|
||||
--build-arg BASE=$BASE .
|
||||
--build-arg BASE=$BASE --build-arg SCRYPTED_INSTALL_VERSION=$SCRYPTED_INSTALL_VERSION .
|
||||
@@ -32,14 +32,17 @@ services:
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
|
||||
# uncomment this and a line below as needed.
|
||||
# devices:
|
||||
# zwave usb serial device
|
||||
# - /dev/ttyACM0:/dev/ttyACM0
|
||||
# all usb devices, such as coral tpu
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
# intel hardware accelerated video decoding
|
||||
# - /dev/dri:/dev/dri
|
||||
devices:
|
||||
# hardware accelerated video decoding, opencl, etc.
|
||||
- /dev/dri:/dev/dri
|
||||
# uncomment below as necessary.
|
||||
# zwave usb serial device
|
||||
# - /dev/ttyACM0:/dev/ttyACM0
|
||||
# all usb devices, such as coral tpu
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
# coral PCI devices
|
||||
# - /dev/apex_0:/dev/apex_0
|
||||
# - /dev/apex_1:/dev/apex_1
|
||||
|
||||
volumes:
|
||||
- ~/.scrypted/volume:/server/volume
|
||||
@@ -54,6 +57,11 @@ services:
|
||||
# target: /nvr
|
||||
# volume:
|
||||
# nocopy: true
|
||||
|
||||
# uncomment the following lines to expose Avahi, an mDNS advertiser.
|
||||
# make sure Avahi is running on the host machine, otherwise this will not work.
|
||||
# - /var/run/dbus:/var/run/dbus
|
||||
# - /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket
|
||||
# logging is noisy and will unnecessarily wear on flash storage.
|
||||
# scrypted has per device in memory logging that is preferred.
|
||||
logging:
|
||||
@@ -85,4 +93,4 @@ services:
|
||||
# Must match the port in the auto update url above.
|
||||
- 10444:8080
|
||||
# check for updates once an hour (interval is in seconds)
|
||||
command: --interval 3600 --cleanup
|
||||
command: --interval 3600 --cleanup --scope scrypted
|
||||
@@ -1,7 +1,7 @@
|
||||
[server]
|
||||
#host-name=
|
||||
use-ipv4=yes
|
||||
use-ipv6=no
|
||||
use-ipv6=yes
|
||||
enable-dbus=yes
|
||||
ratelimit-interval-usec=1000000
|
||||
ratelimit-burst=1000
|
||||
@@ -14,4 +14,4 @@ rlimit-core=0
|
||||
rlimit-data=4194304
|
||||
rlimit-fsize=0
|
||||
rlimit-nofile=768
|
||||
rlimit-stack=4194304
|
||||
rlimit-stack=4194304
|
||||
@@ -42,7 +42,7 @@ fi
|
||||
WATCHTOWER_HTTP_API_TOKEN=$(echo $RANDOM | md5sum)
|
||||
DOCKER_COMPOSE_YML=$SCRYPTED_HOME/docker-compose.yml
|
||||
echo "Created $DOCKER_COMPOSE_YML"
|
||||
curl -s https://raw.githubusercontent.com/koush/scrypted/main/docker/docker-compose.yml | sed s/SET_THIS_TO_SOME_RANDOM_TEXT/"$(echo $RANDOM | md5sum)"/g > $DOCKER_COMPOSE_YML
|
||||
curl -s https://raw.githubusercontent.com/koush/scrypted/main/install/docker/docker-compose.yml | sed s/SET_THIS_TO_SOME_RANDOM_TEXT/"$(echo $RANDOM | md5sum | head -c 32)"/g > $DOCKER_COMPOSE_YML
|
||||
|
||||
echo "Setting permissions on $SCRYPTED_HOME"
|
||||
chown -R $SERVICE_USER $SCRYPTED_HOME
|
||||
30
install/docker/template/Dockerfile.full.footer
Normal file
30
install/docker/template/Dockerfile.full.footer
Normal file
@@ -0,0 +1,30 @@
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
FROM header as base
|
||||
|
||||
# intel opencl gpu for openvino
|
||||
RUN bash -c "if [ \"$(uname -m)\" == \"x86_64\" ]; \
|
||||
then \
|
||||
apt-get update && apt-get install -y gpg-agent && \
|
||||
rm -f /usr/share/keyrings/intel-graphics.gpg && \
|
||||
curl -L https://repositories.intel.com/graphics/intel-graphics.key | gpg --dearmor --output /usr/share/keyrings/intel-graphics.gpg && \
|
||||
echo 'deb [arch=amd64,i386 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/graphics/ubuntu jammy arc' | tee /etc/apt/sources.list.d/intel.gpu.jammy.list && \
|
||||
apt-get -y update && \
|
||||
apt-get -y install intel-opencl-icd intel-media-va-driver-non-free && \
|
||||
apt-get -y dist-upgrade; \
|
||||
fi"
|
||||
|
||||
ENV SCRYPTED_INSTALL_ENVIRONMENT="docker"
|
||||
ENV SCRYPTED_CAN_RESTART="true"
|
||||
ENV SCRYPTED_VOLUME="/server/volume"
|
||||
ENV SCRYPTED_INSTALL_PATH="/server"
|
||||
|
||||
# changing this forces pip and npm to perform reinstalls.
|
||||
# if this base image changes, this version must be updated.
|
||||
ENV SCRYPTED_BASE_VERSION=20230608
|
||||
ENV SCRYPTED_DOCKER_FLAVOR=full
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.footer
|
||||
################################################################
|
||||
98
install/docker/template/Dockerfile.full.header
Normal file
98
install/docker/template/Dockerfile.full.header
Normal file
@@ -0,0 +1,98 @@
|
||||
################################################################
|
||||
# Begin section generated from template/Dockerfile.full.header
|
||||
# This common file will be used by both Docker and the linux
|
||||
# install script.
|
||||
################################################################
|
||||
ARG BASE="jammy"
|
||||
FROM ubuntu:${BASE} as header
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# base tools and development stuff
|
||||
RUN apt-get update && apt-get -y install \
|
||||
curl software-properties-common apt-utils \
|
||||
build-essential \
|
||||
cmake \
|
||||
gcc \
|
||||
libcairo2-dev \
|
||||
libgirepository1.0-dev \
|
||||
pkg-config && \
|
||||
apt-get -y update && \
|
||||
apt-get -y upgrade
|
||||
|
||||
ARG NODE_VERSION=18
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
|
||||
RUN apt-get update && apt-get install -y nodejs
|
||||
|
||||
# python native
|
||||
RUN apt-get -y install \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
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 \
|
||||
libjpeg-dev zlib1g-dev
|
||||
|
||||
# plugins support fallback to pillow, but vips is faster.
|
||||
RUN apt-get -y install \
|
||||
libvips
|
||||
|
||||
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
|
||||
RUN apt-get -y install \
|
||||
gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav gstreamer1.0-alsa \
|
||||
gstreamer1.0-vaapi
|
||||
|
||||
# python3 gstreamer bindings
|
||||
RUN apt-get -y install \
|
||||
python3-gst-1.0
|
||||
|
||||
# python 3.9 from ppa.
|
||||
# 3.9 is the version with prebuilt support for tensorflow lite
|
||||
RUN add-apt-repository ppa:deadsnakes/ppa && \
|
||||
apt-get -y install \
|
||||
python3.9 \
|
||||
python3.9-dev \
|
||||
python3.9-distutils
|
||||
|
||||
# armv7l does not have wheels for any of these
|
||||
# and compile times would forever, if it works at all.
|
||||
# furthermore, it's possible to run 32bit docker on 64bit arm,
|
||||
# which causes weird behavior in python which looks at the arch version
|
||||
# which still reports 64bit, even if running in 32bit docker.
|
||||
# this scenario is not supported and will be reported at runtime.
|
||||
# this bit is not necessary on amd64, but leaving it for consistency.
|
||||
RUN apt-get -y install \
|
||||
python3-matplotlib \
|
||||
python3-numpy \
|
||||
python3-opencv \
|
||||
python3-pil \
|
||||
python3-skimage
|
||||
|
||||
# python pip
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
################################################################
|
||||
# End section generated from template/Dockerfile.full.header
|
||||
################################################################
|
||||
BIN
install/icon.png
Normal file
BIN
install/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -45,10 +45,10 @@ ARG() {
|
||||
}
|
||||
|
||||
ENV() {
|
||||
echo "ignoring ENV $1"
|
||||
export $@
|
||||
}
|
||||
|
||||
source <(curl -s https://raw.githubusercontent.com/koush/scrypted/main/docker/template/Dockerfile.full.header)
|
||||
source <(curl -s https://raw.githubusercontent.com/koush/scrypted/main/install/docker/template/Dockerfile.full.header)
|
||||
|
||||
if [ -z "$SERVICE_USER" ]
|
||||
then
|
||||
@@ -40,48 +40,29 @@ echo "Installing Scrypted dependencies..."
|
||||
RUN_IGNORE xcode-select --install
|
||||
RUN brew update
|
||||
RUN_IGNORE brew install node@18
|
||||
# needed by scrypted-ffmpeg
|
||||
RUN_IGNORE brew install sdl2
|
||||
# gstreamer plugins
|
||||
RUN_IGNORE brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly
|
||||
# gst python bindings
|
||||
RUN_IGNORE brew install gst-python
|
||||
# python image library
|
||||
RUN_IGNORE brew install pillow
|
||||
# snapshot plugin and others
|
||||
RUN brew install libvips
|
||||
# dlib
|
||||
RUN brew install cmake
|
||||
|
||||
### HACK WORKAROUND
|
||||
### https://github.com/koush/scrypted/issues/544
|
||||
|
||||
brew unpin gstreamer
|
||||
brew unpin gst-python
|
||||
brew unpin gst-plugins-ugly
|
||||
brew unpin gst-plugins-good
|
||||
brew unpin gst-plugins-base
|
||||
brew unpin gst-plugins-good
|
||||
brew unpin gst-plugins-bad
|
||||
|
||||
brew unlink gstreamer
|
||||
brew unlink gst-python
|
||||
brew unlink gst-plugins-ugly
|
||||
brew unlink gst-plugins-good
|
||||
brew unlink gst-plugins-base
|
||||
brew unlink gst-plugins-bad
|
||||
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gstreamer.rb && brew install ./gstreamer.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-python.rb && brew install ./gst-python.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-ugly.rb && brew install ./gst-plugins-ugly.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-good.rb && brew install ./gst-plugins-good.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-base.rb && brew install ./gst-plugins-base.rb
|
||||
curl -O https://raw.githubusercontent.com/Homebrew/homebrew-core/49a8667f0c1a6579fe887bc0fa1c0ce682eb01c8/Formula/gst-plugins-bad.rb && brew install ./gst-plugins-bad.rb
|
||||
|
||||
brew pin gstreamer
|
||||
brew pin gst-python
|
||||
brew pin gst-plugins-ugly
|
||||
brew pin gst-plugins-good
|
||||
brew pin gst-plugins-base
|
||||
brew pin gst-plugins-bad
|
||||
brew unpin gst-plugins-ugly
|
||||
brew unpin gst-libav
|
||||
brew unpin gst-python
|
||||
|
||||
### END HACK WORKAROUND
|
||||
|
||||
# gstreamer plugins
|
||||
RUN_IGNORE brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
|
||||
# gst python bindings
|
||||
RUN_IGNORE brew install gst-python
|
||||
|
||||
ARCH=$(arch)
|
||||
if [ "$ARCH" = "arm64" ]
|
||||
then
|
||||
@@ -102,7 +83,11 @@ then
|
||||
fi
|
||||
|
||||
RUN python$PYTHON_VERSION -m pip install --upgrade pip
|
||||
RUN python$PYTHON_VERSION -m pip install aiofiles debugpy typing_extensions typing opencv-python psutil
|
||||
if [ "$PYTHON_VERSION" != "3.10" ]
|
||||
then
|
||||
RUN python$PYTHON_VERSION -m pip install typing
|
||||
fi
|
||||
RUN python$PYTHON_VERSION -m pip install debugpy typing_extensions opencv-python psutil
|
||||
|
||||
echo "Installing Scrypted Launch Agent..."
|
||||
|
||||
@@ -20,7 +20,7 @@ $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";"
|
||||
|
||||
|
||||
py $SCRYPTED_WINDOWS_PYTHON_VERSION -m pip install --upgrade pip
|
||||
py $SCRYPTED_WINDOWS_PYTHON_VERSION -m pip install aiofiles debugpy typing_extensions typing opencv-python
|
||||
py $SCRYPTED_WINDOWS_PYTHON_VERSION -m pip install debugpy typing_extensions typing opencv-python
|
||||
|
||||
npx -y scrypted@latest install-server
|
||||
|
||||
BIN
install/logo.png
Normal file
BIN
install/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -27,13 +27,6 @@ echo "sdk > npm run build"
|
||||
npm run build
|
||||
popd
|
||||
|
||||
pushd external/HAP-NodeJS
|
||||
echo "external/HAP-NodeJS > npm install"
|
||||
npm install
|
||||
echo "external/HAP-NodeJS > npm run build"
|
||||
npm run build
|
||||
popd
|
||||
|
||||
pushd external/werift
|
||||
echo "external/werift > npm install"
|
||||
npm install
|
||||
|
||||
@@ -23,10 +23,19 @@ async function example() {
|
||||
if (!backyard)
|
||||
throw new Error('Device not found');
|
||||
|
||||
backyard.listen(ScryptedInterface.ObjectDetector, (source, details, data) => {
|
||||
backyard.listen(ScryptedInterface.ObjectDetector, async (source, details, data) => {
|
||||
const results = data as ObjectsDetected;
|
||||
console.log(results);
|
||||
})
|
||||
console.log('detection results', results);
|
||||
// detections that are flagged for retention will have a detectionId.
|
||||
// tf etc won't retain automatically, and this requires a wrapping detector like Scrypted NVR Object Detection
|
||||
// to decide which frames to keep. Otherwise saving all images would be extremely poor performance.
|
||||
if (!results.detectionId)
|
||||
return;
|
||||
|
||||
const media = await backyard.getDetectionInput(results.detectionId);
|
||||
const jpeg = await sdk.mediaManager.convertMediaObjectToBuffer(media, 'image/jpeg');
|
||||
// do something with the buffer like save to disk or send to a service.
|
||||
});
|
||||
}
|
||||
|
||||
example();
|
||||
|
||||
12
packages/client/package-lock.json
generated
12
packages/client/package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@scrypted/client",
|
||||
"version": "1.1.43",
|
||||
"version": "1.1.54",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/client",
|
||||
"version": "1.1.43",
|
||||
"version": "1.1.54",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@scrypted/types": "^0.2.76",
|
||||
"@scrypted/types": "^0.2.91",
|
||||
"axios": "^0.25.0",
|
||||
"engine.io-client": "^6.4.0",
|
||||
"rimraf": "^3.0.2"
|
||||
@@ -21,9 +21,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@scrypted/types": {
|
||||
"version": "0.2.76",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.76.tgz",
|
||||
"integrity": "sha512-/7n8ICkXj8TGba4cHvckLCgSNsOmOGQ8I+Jd8fX9sxkthgsZhF5At8PHhHdkCDS+yfSmfXHkcqluZZOfYPkpAg=="
|
||||
"version": "0.2.91",
|
||||
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.91.tgz",
|
||||
"integrity": "sha512-GfWil8cl2QwlTXk506ZXDALQfuv7zN48PtPlpmBMO/IYTQFtb+RB2zr+FwC9gdvRaZgs9NCCS2Fiig1OY7uxdQ=="
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/client",
|
||||
"version": "1.1.43",
|
||||
"version": "1.1.54",
|
||||
"description": "",
|
||||
"main": "dist/packages/client/src/index.js",
|
||||
"scripts": {
|
||||
@@ -17,7 +17,7 @@
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scrypted/types": "^0.2.76",
|
||||
"@scrypted/types": "^0.2.91",
|
||||
"axios": "^0.25.0",
|
||||
"engine.io-client": "^6.4.0",
|
||||
"rimraf": "^3.0.2"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { timeoutPromise } from "../../../common/src/promise-utils";
|
||||
import { BrowserSignalingSession, waitPeerConnectionIceConnected, waitPeerIceConnectionClosed } from "../../../common/src/rtc-signaling";
|
||||
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';
|
||||
@@ -77,25 +78,48 @@ export interface ScryptedClientOptions extends Partial<ScryptedLoginOptions> {
|
||||
transports?: string[];
|
||||
}
|
||||
|
||||
function isInstalledApp() {
|
||||
return globalThis.navigator?.userAgent.includes('InstalledApp');
|
||||
}
|
||||
|
||||
function isRunningStandalone() {
|
||||
return globalThis.matchMedia?.('(display-mode: standalone)').matches || globalThis.navigator?.userAgent.includes('InstalledApp');
|
||||
return globalThis.matchMedia?.('(display-mode: standalone)').matches || isInstalledApp();
|
||||
}
|
||||
|
||||
export async function logoutScryptedClient(baseUrl?: string) {
|
||||
const url = baseUrl ? new URL('/logout', baseUrl).toString() : '/logout';
|
||||
const url = combineBaseUrl(baseUrl, 'logout');
|
||||
const response = await axios(url, {
|
||||
withCredentials: true,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export function getCurrentBaseUrl() {
|
||||
// an endpoint within scrypted will be served at /endpoint/[org/][id]
|
||||
// find the endpoint prefix and anything prior to that will be the server base url.
|
||||
const url = new URL(window.location.href);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
let endpointPath = window.location.pathname;
|
||||
const parts = endpointPath.split('/');
|
||||
const index = parts.findIndex(p => p === 'endpoint');
|
||||
if (index === -1) {
|
||||
// console.warn('path not recognized, does not contain the segment "endpoint".')
|
||||
return undefined;
|
||||
}
|
||||
const keep = parts.slice(0, index);
|
||||
keep.push('');
|
||||
url.pathname = keep.join('/');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function loginScryptedClient(options: ScryptedLoginOptions) {
|
||||
let { baseUrl, username, password, change_password, maxAge } = options;
|
||||
// pwa should stay logged in for a year.
|
||||
if (!maxAge && isRunningStandalone())
|
||||
maxAge = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const url = `${baseUrl || ''}/login`;
|
||||
const url = combineBaseUrl(baseUrl, 'login');
|
||||
const response = await axios.post(url, {
|
||||
username,
|
||||
password,
|
||||
@@ -128,7 +152,7 @@ export async function loginScryptedClient(options: ScryptedLoginOptions) {
|
||||
|
||||
export async function checkScryptedClientLogin(options?: ScryptedConnectionOptions) {
|
||||
let { baseUrl } = options || {};
|
||||
const url = `${baseUrl || ''}/login`;
|
||||
const url = combineBaseUrl(baseUrl, 'login');
|
||||
const response = await axios.get(url, {
|
||||
withCredentials: true,
|
||||
...options?.axiosConfig,
|
||||
@@ -137,6 +161,7 @@ export async function checkScryptedClientLogin(options?: ScryptedConnectionOptio
|
||||
const directAddress = response.headers['x-scrypted-direct-address'];
|
||||
|
||||
return {
|
||||
hostname: response.data.hostname as string,
|
||||
redirect: response.data.redirect as string,
|
||||
username: response.data.username as string,
|
||||
expiration: response.data.expiration as number,
|
||||
@@ -144,6 +169,7 @@ export async function checkScryptedClientLogin(options?: ScryptedConnectionOptio
|
||||
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: response.data.addresses as string[],
|
||||
scryptedCloud,
|
||||
directAddress,
|
||||
@@ -174,9 +200,12 @@ export function redirectScryptedLogin(options?: {
|
||||
globalThis.location.href = redirect_uri;
|
||||
}
|
||||
|
||||
export function combineBaseUrl(baseUrl: string, rootPath: string) {
|
||||
return baseUrl ? new URL(rootPath, baseUrl).toString() : '/' + rootPath;
|
||||
}
|
||||
|
||||
export async function redirectScryptedLogout(baseUrl?: string) {
|
||||
baseUrl = baseUrl || '';
|
||||
globalThis.location.href = `${baseUrl}/logout`;
|
||||
globalThis.location.href = combineBaseUrl(baseUrl, 'logout');
|
||||
}
|
||||
|
||||
export async function connectScryptedClient(options: ScryptedClientOptions): Promise<ScryptedClientStatic> {
|
||||
@@ -218,9 +247,10 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
|
||||
}
|
||||
|
||||
let socket: IOClientSocket;
|
||||
const endpointPath = `/endpoint/${pluginId}`;
|
||||
const eioPath = `endpoint/${pluginId}/engine.io/api`;
|
||||
const eioEndpoint = baseUrl ? new URL(eioPath, baseUrl).pathname : '/' + eioPath;
|
||||
const eioOptions: Partial<SocketOptions> = {
|
||||
path: `${endpointPath}/engine.io/api`,
|
||||
path: eioEndpoint,
|
||||
withCredentials: true,
|
||||
extraHeaders,
|
||||
rejectUnauthorized: false,
|
||||
@@ -237,14 +267,15 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
|
||||
// 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 = !isChrome;
|
||||
const localAddressDefault = isNotChromeOrIsInstalledApp;
|
||||
if (((scryptedCloud && options.local === undefined && localAddressDefault) || options.local) && localAddresses) {
|
||||
addresses.push(...localAddresses);
|
||||
}
|
||||
|
||||
const directAddressDefault = directAddress && (!isChrome || !isIPAddress(directAddress));
|
||||
const directAddressDefault = directAddress && (isNotChromeOrIsInstalledApp || !isIPAddress(directAddress));
|
||||
if (((scryptedCloud && options.direct === undefined && directAddressDefault) || options.direct) && directAddress) {
|
||||
addresses.push(directAddress);
|
||||
}
|
||||
@@ -505,22 +536,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
|
||||
console.log('api attached', Date.now() - start);
|
||||
|
||||
mediaManager.createMediaObject = async<T extends MediaObjectOptions>(data: any, mimeType: string, options: T) => {
|
||||
const mo: MediaObjectRemote & {
|
||||
[RpcPeer.PROPERTY_PROXY_PROPERTIES]: any,
|
||||
[RpcPeer.PROPERTY_JSON_DISABLE_SERIALIZATION]: true,
|
||||
} = {
|
||||
[RpcPeer.PROPERTY_JSON_DISABLE_SERIALIZATION]: true,
|
||||
[RpcPeer.PROPERTY_PROXY_PROPERTIES]: {
|
||||
mimeType,
|
||||
sourceId: options?.sourceId,
|
||||
},
|
||||
mimeType,
|
||||
sourceId: options?.sourceId,
|
||||
async getData() {
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mo as any;
|
||||
return new MediaObject(mimeType, data, options) as any;
|
||||
}
|
||||
|
||||
const { browserSignalingSession, connectionManagementId, updateSessionId } = rpcPeer.params;
|
||||
|
||||
4
packages/h264-repacketizer/package-lock.json
generated
4
packages/h264-repacketizer/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/h264-packetizer",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/h264-packetizer",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/h264-repacketizer",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
2
plugins/alexa/.vscode/settings.json
vendored
2
plugins/alexa/.vscode/settings.json
vendored
@@ -1,4 +1,4 @@
|
||||
|
||||
{
|
||||
"scrypted.debugHost": "10.10.0.50",
|
||||
"scrypted.debugHost": "koushik-ubuntu",
|
||||
}
|
||||
2837
plugins/alexa/package-lock.json
generated
2837
plugins/alexa/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/alexa",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.5",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
@@ -39,6 +39,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.4.2",
|
||||
"@scrypted/sdk": "^0.2.70"
|
||||
"@scrypted/sdk": "../../sdk"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ const includeToken = 4;
|
||||
|
||||
export let DEBUG = false;
|
||||
|
||||
function debug(...args: any[]) {
|
||||
if (DEBUG)
|
||||
console.debug(...args);
|
||||
}
|
||||
|
||||
class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, MixinProvider, Settings {
|
||||
storageSettings = new StorageSettings(this, {
|
||||
tokenInfo: {
|
||||
@@ -34,6 +39,14 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
|
||||
description: 'This is the endpoint Alexa will use to send events to. This is set after you login.',
|
||||
type: 'string',
|
||||
readonly: true
|
||||
},
|
||||
debug: {
|
||||
title: 'Debug Events',
|
||||
description: 'Log all events to the console. This will be very noisy and should not be left enabled.',
|
||||
type: 'boolean',
|
||||
onPut(oldValue: boolean, newValue: boolean) {
|
||||
DEBUG = newValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -44,6 +57,8 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
|
||||
constructor(nativeId?: string) {
|
||||
super(nativeId);
|
||||
|
||||
DEBUG = this.storageSettings.values.debug ?? false;
|
||||
|
||||
alexaHandlers.set('Alexa.Authorization/AcceptGrant', this.onAlexaAuthorization);
|
||||
alexaHandlers.set('Alexa.Discovery/Discover', this.onDiscoverEndpoints);
|
||||
|
||||
@@ -141,12 +156,23 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
|
||||
if (!supportedType)
|
||||
return;
|
||||
|
||||
const report = await supportedType.sendEvent(eventSource, eventDetails, eventData);
|
||||
let report = await supportedType.sendEvent(eventSource, eventDetails, eventData);
|
||||
|
||||
if (!report && eventDetails.eventInterface === ScryptedInterface.Online) {
|
||||
report = {};
|
||||
}
|
||||
|
||||
if (!report && eventDetails.eventInterface === ScryptedInterface.Battery) {
|
||||
report = {};
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
this.console.warn(`${eventDetails.eventInterface}.${eventDetails.property} not supported for device ${eventSource.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
debug("event", eventDetails.eventInterface, eventDetails.property, eventSource.type);
|
||||
|
||||
let data = {
|
||||
"event": {
|
||||
"header": {
|
||||
@@ -234,7 +260,7 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
|
||||
const endpoint = await this.getAlexaEndpoint();
|
||||
const self = this;
|
||||
|
||||
this.console.assert(!DEBUG, `event:`, data);
|
||||
debug("send event to alexa", data);
|
||||
|
||||
return axios.post(`https://${endpoint}/v3/events`, data, {
|
||||
headers: {
|
||||
@@ -570,6 +596,8 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
|
||||
const { authorization } = request.headers;
|
||||
if (!this.validAuths.has(authorization)) {
|
||||
try {
|
||||
debug("making authorization request to Scrypted");
|
||||
|
||||
await axios.get('https://home.scrypted.app/_punch/getcookie', {
|
||||
headers: {
|
||||
'Authorization': authorization,
|
||||
@@ -590,11 +618,11 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi
|
||||
const { directive } = body;
|
||||
const { namespace, name } = directive.header;
|
||||
|
||||
this.console.assert(!DEBUG, `request: ${namespace}/${name}`);
|
||||
|
||||
const mapName = `${namespace}/${name}`;
|
||||
const handler = alexaHandlers.get(mapName);
|
||||
|
||||
debug("received directive from alexa", mapName, body);
|
||||
|
||||
const handler = alexaHandlers.get(mapName);
|
||||
if (handler)
|
||||
return handler.apply(this, [request, response, directive]);
|
||||
|
||||
@@ -641,7 +669,7 @@ class HttpResponseLoggingImpl implements AlexaHttpResponse {
|
||||
if (options.code !== 200)
|
||||
this.console.error(`response error ${options.code}:`, body);
|
||||
else
|
||||
this.console.assert(!DEBUG, `response ${options.code}:`, body);
|
||||
debug("response to alexa directive", options.code, body);
|
||||
|
||||
if (typeof body === 'object')
|
||||
body = JSON.stringify(body);
|
||||
|
||||
@@ -17,6 +17,13 @@ export class AlexaSignalingSession implements RTCSignalingSession {
|
||||
sdp: this.directive.payload.offer.value,
|
||||
},
|
||||
disableTrickle: true,
|
||||
disableTurn: true,
|
||||
// this could be a low resolution screen, no way of knowning, so never send a 1080p stream
|
||||
screen: {
|
||||
devicePixelRatio: 1,
|
||||
width: 1280,
|
||||
height: 720
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
plugins/amcrest/package-lock.json
generated
20
plugins/amcrest/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.119",
|
||||
"version": "0.0.122",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.119",
|
||||
"version": "0.0.122",
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
@@ -16,7 +16,7 @@
|
||||
"multiparty": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.0"
|
||||
"@types/node": "^18.15.11"
|
||||
}
|
||||
},
|
||||
"../../common": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"../../sdk": {
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.2.68",
|
||||
"version": "0.2.87",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
@@ -100,9 +100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "16.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.0.tgz",
|
||||
"integrity": "sha512-8MLkBIYQMuhRBQzGN9875bYsOhPnf/0rgXGo66S2FemHkhbn9qtsz9ywV1iCG+vbjigE4WUNVvw37Dx+L0qsPg=="
|
||||
"version": "18.15.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
|
||||
"integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q=="
|
||||
},
|
||||
"node_modules/auth-header": {
|
||||
"version": "1.0.0",
|
||||
@@ -291,9 +291,9 @@
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "16.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.0.tgz",
|
||||
"integrity": "sha512-8MLkBIYQMuhRBQzGN9875bYsOhPnf/0rgXGo66S2FemHkhbn9qtsz9ywV1iCG+vbjigE4WUNVvw37Dx+L0qsPg=="
|
||||
"version": "18.15.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
|
||||
"integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q=="
|
||||
},
|
||||
"auth-header": {
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.119",
|
||||
"version": "0.0.122",
|
||||
"description": "Amcrest Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
@@ -36,12 +36,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@koush/axios-digest-auth": "^0.8.5",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/multiparty": "^0.0.33",
|
||||
"multiparty": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.0"
|
||||
"@types/node": "^18.15.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,16 @@ export class AmcrestCameraClient {
|
||||
});
|
||||
}
|
||||
|
||||
async reboot() {
|
||||
const response = await this.digestAuth.request({
|
||||
httpsAgent: amcrestHttpsAgent,
|
||||
method: "GET",
|
||||
responseType: 'text',
|
||||
url: `http://${this.ip}/cgi-bin/magicBox.cgi?action=reboot`,
|
||||
});
|
||||
return response.data as string;
|
||||
}
|
||||
|
||||
async checkTwoWayAudio() {
|
||||
const response = await this.digestAuth.request({
|
||||
httpsAgent: amcrestHttpsAgent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ffmpegLogInitialOutput } from '@scrypted/common/src/media-helpers';
|
||||
import { readLength } from "@scrypted/common/src/read-stream";
|
||||
import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, MediaObject, MediaStreamOptions, PictureOptions, RequestRecordingStreamOptions, ResponseMediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, VideoCameraConfiguration, VideoRecorder } from "@scrypted/sdk";
|
||||
import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, MediaObject, MediaStreamOptions, PictureOptions, Reboot, RequestRecordingStreamOptions, ResponseMediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, VideoCameraConfiguration, VideoRecorder } from "@scrypted/sdk";
|
||||
import child_process, { ChildProcess } from 'child_process';
|
||||
import { PassThrough, Readable, Stream } from "stream";
|
||||
import { OnvifIntercom } from "../../onvif/src/onvif-intercom";
|
||||
@@ -23,7 +23,7 @@ function findValue(blob: string, prefix: string, key: string) {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, Camera, Intercom, VideoRecorder {
|
||||
class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, Camera, Intercom, VideoRecorder, Reboot {
|
||||
eventStream: Stream;
|
||||
cp: ChildProcess;
|
||||
client: AmcrestCameraClient;
|
||||
@@ -37,9 +37,15 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
|
||||
this.storage.removeItem('amcrestDoorbell');
|
||||
}
|
||||
|
||||
this.updateDevice();
|
||||
this.updateDeviceInfo();
|
||||
}
|
||||
|
||||
async reboot() {
|
||||
const client = this.getClient();
|
||||
await client.reboot();
|
||||
}
|
||||
|
||||
getRecordingStreamCurrentTime(recordingStream: MediaObject): Promise<number> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
@@ -440,6 +446,29 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
|
||||
return this.videoStreamOptions;
|
||||
}
|
||||
|
||||
updateDevice() {
|
||||
const doorbellType = this.storage.getItem('doorbellType');
|
||||
const isDoorbell = doorbellType === AMCREST_DOORBELL_TYPE || doorbellType === DAHUA_DOORBELL_TYPE;
|
||||
// true is the legacy value before onvif was added.
|
||||
const twoWayAudio = this.storage.getItem('twoWayAudio') === 'true'
|
||||
|| this.storage.getItem('twoWayAudio') === 'ONVIF'
|
||||
|| this.storage.getItem('twoWayAudio') === 'Amcrest';
|
||||
|
||||
const interfaces = this.provider.getInterfaces();
|
||||
let type: ScryptedDeviceType = undefined;
|
||||
if (isDoorbell) {
|
||||
type = ScryptedDeviceType.Doorbell;
|
||||
interfaces.push(ScryptedInterface.BinarySensor)
|
||||
}
|
||||
if (isDoorbell || twoWayAudio) {
|
||||
interfaces.push(ScryptedInterface.Intercom);
|
||||
}
|
||||
const continuousRecording = this.storage.getItem('continuousRecording') === 'true';
|
||||
if (continuousRecording)
|
||||
interfaces.push(ScryptedInterface.VideoRecorder);
|
||||
this.provider.updateDevice(this.nativeId, this.name, interfaces, type);
|
||||
}
|
||||
|
||||
async putSetting(key: string, value: string) {
|
||||
if (key === 'continuousRecording') {
|
||||
if (value === 'true') {
|
||||
@@ -461,27 +490,8 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
|
||||
this.videoStreamOptions = undefined;
|
||||
|
||||
super.putSetting(key, value);
|
||||
const doorbellType = this.storage.getItem('doorbellType');
|
||||
const isDoorbell = doorbellType === AMCREST_DOORBELL_TYPE || doorbellType === DAHUA_DOORBELL_TYPE;
|
||||
// true is the legacy value before onvif was added.
|
||||
const twoWayAudio = this.storage.getItem('twoWayAudio') === 'true'
|
||||
|| this.storage.getItem('twoWayAudio') === 'ONVIF'
|
||||
|| this.storage.getItem('twoWayAudio') === 'Amcrest';
|
||||
|
||||
const interfaces = this.provider.getInterfaces();
|
||||
let type: ScryptedDeviceType = undefined;
|
||||
if (isDoorbell) {
|
||||
type = ScryptedDeviceType.Doorbell;
|
||||
interfaces.push(ScryptedInterface.BinarySensor)
|
||||
}
|
||||
if (isDoorbell || twoWayAudio) {
|
||||
interfaces.push(ScryptedInterface.Intercom);
|
||||
}
|
||||
const continuousRecording = this.storage.getItem('continuousRecording') === 'true';
|
||||
if (continuousRecording)
|
||||
interfaces.push(ScryptedInterface.VideoRecorder);
|
||||
this.provider.updateDevice(this.nativeId, this.name, interfaces, type);
|
||||
|
||||
|
||||
this.updateDevice();
|
||||
this.updateDeviceInfo();
|
||||
}
|
||||
|
||||
@@ -576,6 +586,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
|
||||
class AmcrestProvider extends RtspProvider {
|
||||
getAdditionalInterfaces() {
|
||||
return [
|
||||
ScryptedInterface.Reboot,
|
||||
ScryptedInterface.VideoCameraConfiguration,
|
||||
ScryptedInterface.Camera,
|
||||
ScryptedInterface.AudioSensor,
|
||||
@@ -616,7 +627,7 @@ class AmcrestProvider extends RtspProvider {
|
||||
this.console.warn('Error probing two way audio', e);
|
||||
}
|
||||
}
|
||||
settings.newCamera ||= 'Hikvision Camera';
|
||||
settings.newCamera ||= 'Amcrest Camera';
|
||||
|
||||
nativeId = await super.createDevice(settings, nativeId);
|
||||
|
||||
|
||||
2
plugins/arlo/.vscode/settings.json
vendored
2
plugins/arlo/.vscode/settings.json
vendored
@@ -22,6 +22,6 @@
|
||||
//"scrypted.volumeRoot": "${config:scrypted.serverRoot}/volume",
|
||||
|
||||
"python.analysis.extraPaths": [
|
||||
"./node_modules/@scrypted/sdk/scrypted_python"
|
||||
"./node_modules/@scrypted/sdk/types/scrypted_python"
|
||||
]
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
# Arlo Plugin for Scrypted
|
||||
|
||||
The Arlo Plugin connects Scrypted to Arlo cloud, allowing you to access all of your Arlo cameras in 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 connection to their servers per account. Using a separate account allows you to use the Arlo app or website simultaneously with this plugin.
|
||||
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.
|
||||
|
||||
## 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 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.
|
||||
|
||||
6
plugins/arlo/package-lock.json
generated
6
plugins/arlo/package-lock.json
generated
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "@scrypted/arlo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.7.29",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/arlo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.7.29",
|
||||
"devDependencies": {
|
||||
"@scrypted/sdk": "file:../../sdk"
|
||||
}
|
||||
},
|
||||
"../../sdk": {
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.2.78",
|
||||
"version": "0.2.101",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/arlo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.7.29",
|
||||
"description": "Arlo Plugin for Scrypted",
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
|
||||
@@ -24,12 +24,15 @@ limitations under the License.
|
||||
# Import helper classes that are part of this library.
|
||||
|
||||
from .request import Request
|
||||
from .host_picker import pick_host
|
||||
from .mqtt_stream_async import MQTTStream
|
||||
from .sse_stream_async import EventStream
|
||||
from .logging import logger
|
||||
|
||||
# Import all of the other stuff.
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from cachetools import cached, TTLCache
|
||||
import scrypted_arlo_go
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
@@ -37,6 +40,8 @@ import base64
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
stream_class = MQTTStream
|
||||
|
||||
@@ -77,8 +82,11 @@ USER_AGENTS = {
|
||||
class Arlo(object):
|
||||
BASE_URL = 'my.arlo.com'
|
||||
AUTH_URL = 'ocapi-app.arlo.com'
|
||||
BACKUP_AUTH_HOSTS = list(scrypted_arlo_go.BACKUP_AUTH_HOSTS())
|
||||
TRANSID_PREFIX = 'web'
|
||||
|
||||
random.shuffle(BACKUP_AUTH_HOSTS)
|
||||
|
||||
def __init__(self, username, password):
|
||||
self.username = username
|
||||
self.password = password
|
||||
@@ -136,8 +144,7 @@ class Arlo(object):
|
||||
self.BASE_URL = 'myapi.arlo.com'
|
||||
|
||||
def LoginMFA(self):
|
||||
self.request = Request()
|
||||
|
||||
device_id = str(uuid.uuid4())
|
||||
headers = {
|
||||
'DNT': '1',
|
||||
'schemaVersion': '1',
|
||||
@@ -148,11 +155,33 @@ class Arlo(object):
|
||||
'Referer': f'https://{self.BASE_URL}/',
|
||||
'Source': 'arloCamWeb',
|
||||
'TE': 'Trailers',
|
||||
'x-user-device-id': device_id,
|
||||
'x-user-device-automation-name': 'QlJPV1NFUg==',
|
||||
'x-user-device-type': 'BROWSER',
|
||||
'Host': self.AUTH_URL,
|
||||
}
|
||||
|
||||
self.request = Request()
|
||||
try:
|
||||
auth_host = self.AUTH_URL
|
||||
self.request.options(f'https://{auth_host}/api/auth', headers=headers)
|
||||
logger.info("Using primary authentication host")
|
||||
except Exception as e:
|
||||
# in case cloudflare rejects our auth request...
|
||||
logger.warning(f"Using fallback authentication host due to: {e}")
|
||||
|
||||
auth_host = pick_host([
|
||||
base64.b64decode(h.encode("utf-8")).decode("utf-8")
|
||||
for h in self.BACKUP_AUTH_HOSTS
|
||||
], self.AUTH_URL, "/api/auth")
|
||||
logger.debug(f"Selected backup authentication host {auth_host}")
|
||||
|
||||
self.request = Request(mode="ip")
|
||||
|
||||
# Authenticate
|
||||
self.request.options(f'https://{auth_host}/api/auth', headers=headers)
|
||||
auth_body = self.request.post(
|
||||
f'https://{self.AUTH_URL}/api/auth',
|
||||
f'https://{auth_host}/api/auth',
|
||||
params={
|
||||
'email': self.username,
|
||||
'password': str(base64.b64encode(self.password.encode('utf-8')), 'utf-8'),
|
||||
@@ -167,21 +196,26 @@ class Arlo(object):
|
||||
|
||||
# Retrieve MFA factor id
|
||||
factors_body = self.request.get(
|
||||
f'https://{self.AUTH_URL}/api/getFactors',
|
||||
f'https://{auth_host}/api/getFactors',
|
||||
params={'data': auth_body['data']['issued']},
|
||||
headers=headers,
|
||||
raw=True
|
||||
)
|
||||
factor_id = next(
|
||||
i for i in factors_body['data']['items']
|
||||
if (i['factorType'] == 'EMAIL' or i['factorType'] == 'SMS')
|
||||
and i['factorRole'] == "PRIMARY"
|
||||
)['factorId']
|
||||
iter([
|
||||
i for i in factors_body['data']['items']
|
||||
if (i['factorType'] == 'EMAIL' or i['factorType'] == 'SMS')
|
||||
and i['factorRole'] == "PRIMARY"
|
||||
]),
|
||||
{}
|
||||
).get('factorId')
|
||||
if not factor_id:
|
||||
raise Exception("Could not find valid 2FA method - is the primary 2FA set to either Email or SMS?")
|
||||
|
||||
# Start factor auth
|
||||
start_auth_body = self.request.post(
|
||||
f'https://{self.AUTH_URL}/api/startAuth',
|
||||
{'factorId': factor_id},
|
||||
f'https://{auth_host}/api/startAuth',
|
||||
params={'factorId': factor_id},
|
||||
headers=headers,
|
||||
raw=True
|
||||
)
|
||||
@@ -191,8 +225,8 @@ class Arlo(object):
|
||||
nonlocal self, factor_auth_code, headers
|
||||
|
||||
finish_auth_body = self.request.post(
|
||||
f'https://{self.AUTH_URL}/api/finishAuth',
|
||||
{
|
||||
f'https://{auth_host}/api/finishAuth',
|
||||
params={
|
||||
'factorAuthCode': factor_auth_code,
|
||||
'otp': code
|
||||
},
|
||||
@@ -200,6 +234,11 @@ class Arlo(object):
|
||||
raw=True
|
||||
)
|
||||
|
||||
if finish_auth_body.get('data', {}).get('token') is None:
|
||||
raise Exception("Could not complete 2FA, maybe invalid token? If the error persists, please try reloading the plugin and logging in again.")
|
||||
|
||||
self.request = Request()
|
||||
|
||||
# Update Authorization code with new code
|
||||
headers = {
|
||||
'Auth-Version': '2',
|
||||
@@ -253,14 +292,16 @@ class Arlo(object):
|
||||
cameras[camera['deviceId']] = camera
|
||||
|
||||
# filter out cameras without basestation, where they are their own basestations
|
||||
# for now, keep doorbells and sirens in the list so they get pings
|
||||
# this is so battery-powered devices do not drain due to pings
|
||||
# for wired devices, keep doorbells, sirens, and arloq in the list so they get pings
|
||||
proper_basestations = {}
|
||||
for basestation in basestations.values():
|
||||
if basestation['deviceId'] == basestation.get('parentId') and basestation['deviceType'] not in ['doorbell', 'siren']:
|
||||
if basestation['deviceId'] == basestation.get('parentId') and \
|
||||
basestation['deviceType'] not in ['doorbell', 'siren', 'arloq', 'arloqs']:
|
||||
continue
|
||||
proper_basestations[basestation['deviceId']] = basestation
|
||||
|
||||
logger.info(f"Will send heartbeat to the following basestations: {list(proper_basestations.keys())}")
|
||||
logger.info(f"Will send heartbeat to the following devices: {list(proper_basestations.keys())}")
|
||||
|
||||
# start heartbeat loop with only basestations
|
||||
asyncio.get_event_loop().create_task(heartbeat(self, list(proper_basestations.values())))
|
||||
@@ -348,7 +389,7 @@ class Arlo(object):
|
||||
body['from'] = self.user_id+'_web'
|
||||
body['to'] = basestation_id
|
||||
|
||||
self.request.post(f'https://{self.BASE_URL}/hmsweb/users/devices/notify/'+body['to'], body, headers={"xcloudId":basestation.get('xCloudId')})
|
||||
self.request.post(f'https://{self.BASE_URL}/hmsweb/users/devices/notify/'+body['to'], params=body, headers={"xcloudId":basestation.get('xCloudId')})
|
||||
return body.get('transId')
|
||||
|
||||
def Ping(self, basestation):
|
||||
@@ -382,6 +423,33 @@ class Arlo(object):
|
||||
self.HandleEvents(basestation, resource, [('is', 'motionDetected')], callbackwrapper)
|
||||
)
|
||||
|
||||
def SubscribeToAudioEvents(self, basestation, camera, callback):
|
||||
"""
|
||||
Use this method to subscribe to audio events. You must provide a callback function which will get called once per audio event.
|
||||
|
||||
The callback function should have the following signature:
|
||||
def callback(self, event)
|
||||
|
||||
This is an example of handling a specific event, in reality, you'd probably want to write a callback for HandleEvents()
|
||||
that has a big switch statement in it to handle all the various events Arlo produces.
|
||||
|
||||
Returns the Task object that contains the subscription loop.
|
||||
"""
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
|
||||
def callbackwrapper(self, event):
|
||||
properties = event.get('properties', {})
|
||||
stop = None
|
||||
if 'audioDetected' in properties:
|
||||
stop = callback(properties['audioDetected'])
|
||||
if not stop:
|
||||
return None
|
||||
return stop
|
||||
|
||||
return asyncio.get_event_loop().create_task(
|
||||
self.HandleEvents(basestation, resource, [('is', 'audioDetected')], callbackwrapper)
|
||||
)
|
||||
|
||||
def SubscribeToBatteryEvents(self, basestation, camera, callback):
|
||||
"""
|
||||
Use this method to subscribe to battery events. You must provide a callback function which will get called once per battery event.
|
||||
@@ -573,7 +641,7 @@ class Arlo(object):
|
||||
If you pass in a valid device type, as a string or a list, this method will return an array of just those devices that match that type. An example would be ['basestation', 'camera']
|
||||
To filter provisioned or unprovisioned devices pass in a True/False value for filter_provisioned. By default both types are returned.
|
||||
"""
|
||||
devices = self.request.get(f'https://{self.BASE_URL}/hmsweb/v2/users/devices')
|
||||
devices = self._getDevicesImpl()
|
||||
if device_type:
|
||||
devices = [ device for device in devices if device.get('deviceType') in device_type]
|
||||
|
||||
@@ -585,23 +653,34 @@ class Arlo(object):
|
||||
|
||||
return devices
|
||||
|
||||
async def StartStream(self, basestation, camera):
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=60))
|
||||
def _getDevicesImpl(self):
|
||||
devices = self.request.get(f'https://{self.BASE_URL}/hmsweb/v2/users/devices')
|
||||
return devices
|
||||
|
||||
async def StartStream(self, basestation, camera, mode="rtsp"):
|
||||
"""
|
||||
This function returns the url of the rtsp video stream.
|
||||
This stream needs to be called within 30 seconds or else it becomes invalid.
|
||||
It can be streamed with: ffmpeg -re -i 'rtsps://<url>' -acodec copy -vcodec copy test.mp4
|
||||
The request to /users/devices/startStream returns: { url:rtsp://<url>:443/vzmodulelive?egressToken=b<xx>&userAgent=iOS&cameraId=<camid>}
|
||||
|
||||
If mode is set to "dash", returns the url to the mpd file for DASH streaming.
|
||||
"""
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
|
||||
if mode not in ["rtsp", "dash"]:
|
||||
raise ValueError("mode must be 'rtsp' or 'dash'")
|
||||
|
||||
# nonlocal variable hack for Python 2.x.
|
||||
class nl:
|
||||
stream_url_dict = None
|
||||
|
||||
def trigger(self):
|
||||
ua = USER_AGENTS['arlo'] if mode == "rtsp" else USER_AGENTS["firefox"]
|
||||
nl.stream_url_dict = self.request.post(
|
||||
f'https://{self.BASE_URL}/hmsweb/users/devices/startStream',
|
||||
{
|
||||
params={
|
||||
"to": camera.get('parentId'),
|
||||
"from": self.user_id + "_web",
|
||||
"resource": "cameras/" + camera.get('deviceId'),
|
||||
@@ -614,14 +693,17 @@ class Arlo(object):
|
||||
"cameraId": camera.get('deviceId')
|
||||
}
|
||||
},
|
||||
headers={"xcloudId":camera.get('xCloudId')}
|
||||
headers={"xcloudId":camera.get('xCloudId'), 'User-Agent': ua}
|
||||
)
|
||||
|
||||
def callback(self, event):
|
||||
#return nl.stream_url_dict['url'].replace("rtsp://", "rtsps://")
|
||||
properties = event.get("properties", {})
|
||||
if properties.get("activityState") == "userStreamActive":
|
||||
return nl.stream_url_dict['url'].replace("rtsp://", "rtsps://")
|
||||
if mode == "rtsp":
|
||||
return nl.stream_url_dict['url'].replace("rtsp://", "rtsps://")
|
||||
else:
|
||||
return nl.stream_url_dict['url'].replace(":80", "")
|
||||
return None
|
||||
|
||||
return await self.TriggerAndHandleEvent(
|
||||
@@ -632,6 +714,27 @@ class Arlo(object):
|
||||
callback,
|
||||
)
|
||||
|
||||
def GetMPDHeaders(self, url: str) -> dict:
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Connection": "keep-alive",
|
||||
"DNT": "1",
|
||||
"Egress-Token": query['egressToken'][0],
|
||||
"Origin": "https://my.arlo.com",
|
||||
"Referer": "https://my.arlo.com/",
|
||||
"User-Agent": USER_AGENTS["firefox"],
|
||||
}
|
||||
return headers
|
||||
|
||||
def GetSIPInfo(self):
|
||||
resp = self.request.get(f'https://{self.BASE_URL}/hmsweb/users/devices/sipInfo')
|
||||
return resp
|
||||
|
||||
def StartPushToTalk(self, basestation, camera):
|
||||
url = f'https://{self.BASE_URL}/hmsweb/users/devices/{self.user_id}_{camera.get("deviceId")}/pushtotalk'
|
||||
resp = self.request.get(url)
|
||||
@@ -674,7 +777,7 @@ class Arlo(object):
|
||||
def trigger(self):
|
||||
self.request.post(
|
||||
f"https://{self.BASE_URL}/hmsweb/users/devices/fullFrameSnapshot",
|
||||
{
|
||||
params={
|
||||
"to": camera.get("parentId"),
|
||||
"from": self.user_id + "_web",
|
||||
"resource": "cameras/" + camera.get("deviceId"),
|
||||
@@ -709,3 +812,165 @@ class Arlo(object):
|
||||
trigger,
|
||||
callback,
|
||||
)
|
||||
|
||||
def SirenOn(self, basestation, camera=None):
|
||||
if camera is not None:
|
||||
resource = f"siren/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "on",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": "siren",
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "on",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
|
||||
def SirenOff(self, basestation, camera=None):
|
||||
if camera is not None:
|
||||
resource = f"siren/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "off",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": "siren",
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"sirenState": "off",
|
||||
"duration": 300,
|
||||
"volume": 8,
|
||||
"pattern": "alarm"
|
||||
}
|
||||
})
|
||||
|
||||
def SpotlightOn(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"spotlight": {
|
||||
"enabled": True,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def SpotlightOff(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"spotlight": {
|
||||
"enabled": False,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def FloodlightOn(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"floodlight": {
|
||||
"on": True,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def FloodlightOff(self, basestation, camera):
|
||||
resource = f"cameras/{camera.get('deviceId')}"
|
||||
return self.Notify(basestation, {
|
||||
"action": "set",
|
||||
"resource": resource,
|
||||
"publishResponse": True,
|
||||
"properties": {
|
||||
"floodlight": {
|
||||
"on": False,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
def GetLibrary(self, device, from_date: datetime, to_date: datetime):
|
||||
"""
|
||||
This call returns the following:
|
||||
presignedContentUrl is a link to the actual video in Amazon AWS.
|
||||
presignedThumbnailUrl is a link to the thumbnail .jpg of the actual video in Amazon AWS.
|
||||
[
|
||||
{
|
||||
"mediaDurationSecond": 30,
|
||||
"contentType": "video/mp4",
|
||||
"name": "XXXXXXXXXXXXX",
|
||||
"presignedContentUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX.mp4?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"lastModified": 1472881430181,
|
||||
"localCreatedDate": XXXXXXXXXXXXX,
|
||||
"presignedThumbnailUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX_thumb.jpg?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"reason": "motionRecord",
|
||||
"deviceId": "XXXXXXXXXXXXX",
|
||||
"createdBy": "XXXXXXXXXXXXX",
|
||||
"createdDate": "20160903",
|
||||
"timeZone": "America/Chicago",
|
||||
"ownerId": "XXX-XXXXXXX",
|
||||
"utcCreatedDate": XXXXXXXXXXXXX,
|
||||
"currentState": "new",
|
||||
"mediaDuration": "00:00:30"
|
||||
}
|
||||
]
|
||||
"""
|
||||
# give the query range a bit of buffer
|
||||
from_date_internal = from_date - timedelta(days=1)
|
||||
to_date_internal = to_date + timedelta(days=1)
|
||||
|
||||
return [
|
||||
result for result in
|
||||
self._getLibraryCached(from_date_internal.strftime("%Y%m%d"), to_date_internal.strftime("%Y%m%d"))
|
||||
if result["deviceId"] == device["deviceId"]
|
||||
and datetime.fromtimestamp(int(result["name"]) / 1000.0) <= to_date
|
||||
and datetime.fromtimestamp(int(result["name"]) / 1000.0) >= from_date
|
||||
]
|
||||
|
||||
@cached(cache=TTLCache(maxsize=512, ttl=60))
|
||||
def _getLibraryCached(self, from_date: str, to_date: str):
|
||||
logger.debug(f"Library cache miss for {from_date}, {to_date}")
|
||||
return self.request.post(
|
||||
f'https://{self.BASE_URL}/hmsweb/users/library',
|
||||
params={
|
||||
'dateFrom': from_date,
|
||||
'dateTo': to_date
|
||||
}
|
||||
)
|
||||
|
||||
def GetSmartFeatures(self, device) -> dict:
|
||||
smart_features = self._getSmartFeaturesCached()
|
||||
key = f"{device['owner']['ownerId']}_{device['deviceId']}"
|
||||
return smart_features["features"].get(key, {})
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=60))
|
||||
def _getSmartFeaturesCached(self) -> dict:
|
||||
return self.request.get(f'https://{self.BASE_URL}/hmsweb/users/subscription/smart/features')
|
||||
26
plugins/arlo/src/arlo_plugin/arlo/host_picker.py
Normal file
26
plugins/arlo/src/arlo_plugin/arlo/host_picker.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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(5)
|
||||
|
||||
|
||||
def pick_host(hosts, hostname_to_match, endpoint_to_test):
|
||||
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!")
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
import requests
|
||||
from requests.exceptions import HTTPError
|
||||
from requests_toolbelt.adapters import host_header_ssl
|
||||
import cloudscraper
|
||||
import time
|
||||
import uuid
|
||||
|
||||
@@ -27,8 +29,13 @@ import uuid
|
||||
class Request(object):
|
||||
"""HTTP helper class"""
|
||||
|
||||
def __init__(self, timeout=5):
|
||||
self.session = requests.Session()
|
||||
def __init__(self, timeout=5, mode="cloudscraper"):
|
||||
if mode == "cloudscraper":
|
||||
from .arlo_async import USER_AGENTS
|
||||
self.session = cloudscraper.CloudScraper(browser={"custom": USER_AGENTS["arlo"]})
|
||||
elif mode == "ip":
|
||||
self.session = requests.Session()
|
||||
self.session.mount('https://', host_header_ssl.HostHeaderSSLAdapter())
|
||||
self.timeout = timeout
|
||||
|
||||
def gen_event_id(self):
|
||||
@@ -37,7 +44,7 @@ class Request(object):
|
||||
def get_time(self):
|
||||
return int(time.time_ns() / 1_000_000)
|
||||
|
||||
def _request(self, url, method='GET', params={}, headers={}, stream=False, raw=False):
|
||||
def _request(self, url, method='GET', params={}, headers={}, raw=False, skip_event_id=False):
|
||||
|
||||
## uncomment for debug logging
|
||||
"""
|
||||
@@ -51,14 +58,13 @@ class Request(object):
|
||||
req_log.propagate = True
|
||||
#"""
|
||||
|
||||
url = f'{url}?eventId={self.gen_event_id()}&time={self.get_time()}'
|
||||
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, stream=stream, timeout=self.timeout)
|
||||
r = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
if stream is True:
|
||||
return r
|
||||
elif method == 'PUT':
|
||||
r = self.session.put(url, json=params, headers=headers, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
@@ -81,14 +87,14 @@ class Request(object):
|
||||
else:
|
||||
raise HTTPError('Request ({0} {1}) failed: {2}'.format(method, url, r.json()), response=r)
|
||||
|
||||
def get(self, url, params={}, headers={}, stream=False, raw=False):
|
||||
return self._request(url, 'GET', params=params, headers=headers, stream=stream, raw=raw)
|
||||
def get(self, url, **kwargs):
|
||||
return self._request(url, 'GET', **kwargs)
|
||||
|
||||
def put(self, url, params={}, headers={}, raw=False):
|
||||
return self._request(url, 'PUT', params=params, headers=headers, raw=raw)
|
||||
def put(self, url, **kwargs):
|
||||
return self._request(url, 'PUT', **kwargs)
|
||||
|
||||
def post(self, url, params={}, headers={}, raw=False):
|
||||
return self._request(url, 'POST', params=params, headers=headers, raw=raw)
|
||||
def post(self, url, **kwargs):
|
||||
return self._request(url, 'POST', **kwargs)
|
||||
|
||||
def options(self, url, headers={}, raw=False):
|
||||
return self._request(url, 'OPTIONS', headers=headers, raw=raw)
|
||||
def options(self, url, **kwargs):
|
||||
return self._request(url, 'OPTIONS', **kwargs)
|
||||
|
||||
69
plugins/arlo/src/arlo_plugin/base.py
Normal file
69
plugins/arlo/src/arlo_plugin/base.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Device
|
||||
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
|
||||
|
||||
class ArloDeviceBase(ScryptedDeviceBase, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
nativeId: str = None
|
||||
arlo_device: dict = None
|
||||
arlo_basestation: dict = None
|
||||
provider: ArloProvider = None
|
||||
stop_subscriptions: bool = False
|
||||
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None:
|
||||
super().__init__(nativeId=nativeId)
|
||||
|
||||
self.logger_name = nativeId
|
||||
|
||||
self.nativeId = nativeId
|
||||
self.arlo_device = arlo_device
|
||||
self.arlo_basestation = arlo_basestation
|
||||
self.provider = provider
|
||||
self.logger.setLevel(self.provider.get_current_log_level())
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.stop_subscriptions = True
|
||||
self.cancel_pending_tasks()
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
"""Returns the list of Scrypted interfaces that applies to this device."""
|
||||
return []
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
"""Returns the Scrypted device type that applies to this device."""
|
||||
return ""
|
||||
|
||||
def get_device_manifest(self) -> Device:
|
||||
"""Returns the Scrypted device manifest representing this device."""
|
||||
parent = None
|
||||
if self.arlo_device.get("parentId") and self.arlo_device["parentId"] != self.arlo_device["deviceId"]:
|
||||
parent = self.arlo_device["parentId"]
|
||||
|
||||
return {
|
||||
"info": {
|
||||
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": self.arlo_device.get("firmwareVersion"),
|
||||
"serialNumber": self.arlo_device["deviceId"],
|
||||
},
|
||||
"nativeId": self.arlo_device["deviceId"],
|
||||
"name": self.arlo_device["deviceName"],
|
||||
"interfaces": self.get_applicable_interfaces(),
|
||||
"type": self.get_device_type(),
|
||||
"providerNativeId": parent,
|
||||
}
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> List[Device]:
|
||||
"""Returns the list of child device manifests representing hardware features built into this device."""
|
||||
return []
|
||||
71
plugins/arlo/src/arlo_plugin/basestation.py
Normal file
71
plugins/arlo/src/arlo_plugin/basestation.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Device, DeviceProvider, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .base import ArloDeviceBase
|
||||
from .vss import ArloSirenVirtualSecuritySystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
|
||||
|
||||
class ArloBasestation(ArloDeviceBase, DeviceProvider):
|
||||
MODELS_WITH_SIRENS = [
|
||||
"vmb4000",
|
||||
"vmb4500"
|
||||
]
|
||||
|
||||
vss: ArloSirenVirtualSecuritySystem = None
|
||||
|
||||
def __init__(self, nativeId: str, arlo_basestation: dict, provider: ArloProvider) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_basestation, arlo_basestation=arlo_basestation, provider=provider)
|
||||
|
||||
@property
|
||||
def has_siren(self) -> bool:
|
||||
return any([self.arlo_device["modelId"].lower().startswith(model) for model in ArloBasestation.MODELS_WITH_SIRENS])
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
return [ScryptedInterface.DeviceProvider.value]
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.DeviceProvider.value
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> List[Device]:
|
||||
if not self.has_siren:
|
||||
# this basestation has no builtin siren, so no manifests to return
|
||||
return []
|
||||
|
||||
vss = self.get_or_create_vss()
|
||||
return [
|
||||
{
|
||||
"info": {
|
||||
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": self.arlo_device.get("firmwareVersion"),
|
||||
"serialNumber": self.arlo_device["deviceId"],
|
||||
},
|
||||
"nativeId": vss.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System',
|
||||
"interfaces": vss.get_applicable_interfaces(),
|
||||
"type": vss.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
},
|
||||
] + vss.get_builtin_child_device_manifests()
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ScryptedDeviceBase:
|
||||
if not nativeId.startswith(self.nativeId):
|
||||
# must be a camera, so get it from the provider
|
||||
return await self.provider.getDevice(nativeId)
|
||||
if not nativeId.endswith("vss"):
|
||||
return None
|
||||
return self.get_or_create_vss()
|
||||
|
||||
def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem:
|
||||
vss_id = f'{self.arlo_device["deviceId"]}.vss'
|
||||
if not self.vss:
|
||||
self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.vss
|
||||
@@ -1,47 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from async_timeout import timeout as async_timeout
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
import scrypted_arlo_go
|
||||
|
||||
import scrypted_sdk
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Settings, Camera, VideoCamera, MotionSensor, Battery, ScryptedMimeTypes, ScryptedInterface
|
||||
from scrypted_sdk.types import Setting, Settings, 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 .experimental import EXPERIMENTAL
|
||||
from .base import ArloDeviceBase
|
||||
from .spotlight import ArloSpotlight, ArloFloodlight
|
||||
from .vss import ArloSirenVirtualSecuritySystem
|
||||
from .child_process import HeartbeatChildProcess
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
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 ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor, Battery, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
timeout = 30
|
||||
nativeId = None
|
||||
arlo_device = None
|
||||
arlo_basestation = None
|
||||
provider = None
|
||||
class ArloCamera(ArloDeviceBase, Settings, Camera, VideoCamera, DeviceProvider, VideoClips, MotionSensor, AudioSensor, Battery, Charger):
|
||||
MODELS_WITH_SPOTLIGHTS = [
|
||||
"vmc4040p",
|
||||
"vmc2030",
|
||||
"vmc2032",
|
||||
"vmc4041p",
|
||||
"vmc4050p",
|
||||
"vmc5040",
|
||||
"vml2030",
|
||||
"vml4030",
|
||||
]
|
||||
|
||||
def __init__(self, nativeId, arlo_device, arlo_basestation, provider):
|
||||
super().__init__(nativeId=nativeId)
|
||||
MODELS_WITH_FLOODLIGHTS = ["fb1001"]
|
||||
|
||||
self.logger_name = nativeId
|
||||
MODELS_WITH_SIRENS = [
|
||||
"vmc4040p",
|
||||
"fb1001",
|
||||
"vmc2030",
|
||||
"vmc2020",
|
||||
"vmc2032",
|
||||
"vmc4041p",
|
||||
"vmc4050p",
|
||||
"vmc5040",
|
||||
"vml2030",
|
||||
"vmc4030",
|
||||
"vml4030",
|
||||
"vmc4030p",
|
||||
]
|
||||
|
||||
self.nativeId = nativeId
|
||||
self.arlo_device = arlo_device
|
||||
self.arlo_basestation = arlo_basestation
|
||||
self.provider = provider
|
||||
self.logger.setLevel(self.provider.get_current_log_level())
|
||||
MODELS_WITH_AUDIO_SENSORS = [
|
||||
"vmc4040p",
|
||||
"fb1001",
|
||||
"vmc4041p",
|
||||
"vmc4050p",
|
||||
"vmc5040",
|
||||
"vmc3040",
|
||||
"vmc3040s",
|
||||
"vmc4030",
|
||||
"vml4030",
|
||||
"vmc4030p",
|
||||
]
|
||||
|
||||
self.intercom_session = None
|
||||
MODELS_WITHOUT_BATTERY = [
|
||||
"avd1001",
|
||||
"vmc3040",
|
||||
"vmc3040s",
|
||||
]
|
||||
|
||||
timeout: int = 30
|
||||
intercom_session = None
|
||||
goSM = None
|
||||
light: ArloSpotlight = None
|
||||
vss: ArloSirenVirtualSecuritySystem = None
|
||||
picture_lock: asyncio.Lock = None
|
||||
|
||||
# eco mode bookkeeping
|
||||
last_picture: bytes = None
|
||||
last_picture_time: datetime = datetime(1970, 1, 1)
|
||||
|
||||
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.stop_subscriptions = False
|
||||
self.start_motion_subscription()
|
||||
self.start_audio_subscription()
|
||||
self.start_battery_subscription()
|
||||
self.create_task(self.delayed_init())
|
||||
|
||||
def __del__(self):
|
||||
self.stop_subscriptions = True
|
||||
self.cancel_pending_tasks()
|
||||
async def delayed_init(self) -> None:
|
||||
if not self.has_battery:
|
||||
return
|
||||
|
||||
def start_motion_subscription(self):
|
||||
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
|
||||
|
||||
def start_motion_subscription(self) -> None:
|
||||
def callback(motionDetected):
|
||||
self.motionDetected = motionDetected
|
||||
return self.stop_subscriptions
|
||||
@@ -50,7 +121,22 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
self.provider.arlo.SubscribeToMotionEvents(self.arlo_basestation, self.arlo_device, callback)
|
||||
)
|
||||
|
||||
def start_battery_subscription(self):
|
||||
def start_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)
|
||||
)
|
||||
|
||||
def start_battery_subscription(self) -> None:
|
||||
if not self.has_battery:
|
||||
return
|
||||
|
||||
def callback(batteryLevel):
|
||||
self.batteryLevel = batteryLevel
|
||||
return self.stop_subscriptions
|
||||
@@ -59,94 +145,228 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
self.provider.arlo.SubscribeToBatteryEvents(self.arlo_basestation, self.arlo_device, callback)
|
||||
)
|
||||
|
||||
def get_applicable_interfaces(self) -> list:
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
results = set([
|
||||
ScryptedInterface.VideoCamera.value,
|
||||
ScryptedInterface.Camera.value,
|
||||
ScryptedInterface.MotionSensor.value,
|
||||
ScryptedInterface.Battery.value,
|
||||
ScryptedInterface.Settings.value,
|
||||
])
|
||||
|
||||
if self.two_way_audio:
|
||||
results.discard(ScryptedInterface.RTCSignalingChannel.value)
|
||||
if EXPERIMENTAL or not self.uses_sip_push_to_talk:
|
||||
results.add(ScryptedInterface.Intercom.value)
|
||||
|
||||
if self.webrtc_emulation:
|
||||
results.add(ScryptedInterface.RTCSignalingChannel.value)
|
||||
results.discard(ScryptedInterface.Intercom.value)
|
||||
if self.has_battery:
|
||||
results.add(ScryptedInterface.Battery.value)
|
||||
results.add(ScryptedInterface.Charger.value)
|
||||
|
||||
if not self._can_push_to_talk():
|
||||
results.discard(ScryptedInterface.RTCSignalingChannel.value)
|
||||
results.discard(ScryptedInterface.Intercom.value)
|
||||
if self.has_siren or self.has_spotlight or self.has_floodlight:
|
||||
results.add(ScryptedInterface.DeviceProvider.value)
|
||||
|
||||
if self.has_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:
|
||||
light = self.get_or_create_spotlight_or_floodlight()
|
||||
results.append({
|
||||
"info": {
|
||||
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": self.arlo_device.get("firmwareVersion"),
|
||||
"serialNumber": self.arlo_device["deviceId"],
|
||||
},
|
||||
"nativeId": light.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} {"Spotlight" if self.has_spotlight else "Floodlight"}',
|
||||
"interfaces": light.get_applicable_interfaces(),
|
||||
"type": light.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
})
|
||||
if self.has_siren:
|
||||
vss = self.get_or_create_vss()
|
||||
results.extend([
|
||||
{
|
||||
"info": {
|
||||
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": self.arlo_device.get("firmwareVersion"),
|
||||
"serialNumber": self.arlo_device["deviceId"],
|
||||
},
|
||||
"nativeId": vss.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} Siren Virtual Security System',
|
||||
"interfaces": vss.get_applicable_interfaces(),
|
||||
"type": vss.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
},
|
||||
] + vss.get_builtin_child_device_manifests())
|
||||
return results
|
||||
|
||||
@property
|
||||
def webrtc_emulation(self):
|
||||
def wired_to_power(self) -> bool:
|
||||
if self.storage:
|
||||
return self.storage.getItem("webrtc_emulation")
|
||||
return True if self.storage.getItem("wired_to_power") else False
|
||||
else:
|
||||
return False
|
||||
|
||||
@property
|
||||
def two_way_audio(self):
|
||||
def eco_mode(self) -> bool:
|
||||
if self.storage:
|
||||
val = self.storage.getItem("two_way_audio")
|
||||
if val is None:
|
||||
val = True
|
||||
return val
|
||||
return True if self.storage.getItem("eco_mode") else False
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def getSettings(self):
|
||||
if self._can_push_to_talk():
|
||||
return [
|
||||
@property
|
||||
def snapshot_throttle_interval(self) -> bool:
|
||||
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_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 uses_sip_push_to_talk(self) -> bool:
|
||||
return self.arlo_device["deviceId"] == self.arlo_device["parentId"]
|
||||
|
||||
async def getSettings(self) -> List[Setting]:
|
||||
result = []
|
||||
if self.has_battery:
|
||||
result.append(
|
||||
{
|
||||
"key": "two_way_audio",
|
||||
"title": "(Experimental) Enable native two-way audio",
|
||||
"value": self.two_way_audio,
|
||||
"description": "Enables two-way audio for this device. Not yet completely functional on all audio senders.",
|
||||
"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(
|
||||
{
|
||||
"key": "webrtc_emulation",
|
||||
"title": "(Highly Experimental) Emulate WebRTC Camera",
|
||||
"value": self.webrtc_emulation,
|
||||
"description": "Configures the plugin to offer this device as a WebRTC camera, merging video/audio stream with two-way audio. "
|
||||
"If enabled, takes precedence over native two-way audio. May use increased system resources.",
|
||||
"type": "boolean",
|
||||
},
|
||||
]
|
||||
"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",
|
||||
}
|
||||
)
|
||||
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)
|
||||
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 def putSetting(self, key, value):
|
||||
if key in ["webrtc_emulation", "two_way_audio"]:
|
||||
self.storage.setItem(key, value == "true")
|
||||
await self.provider.discoverDevices()
|
||||
|
||||
async def getPictureOptions(self):
|
||||
return []
|
||||
|
||||
async def takePicture(self, options=None):
|
||||
@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")
|
||||
return await real_device.getVideoStream({"refresh": False})
|
||||
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")
|
||||
|
||||
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}")
|
||||
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")
|
||||
|
||||
if pic_url is None:
|
||||
raise Exception("Error taking snapshot")
|
||||
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}")
|
||||
|
||||
return await scrypted_sdk.mediaManager.createMediaObject(str.encode(pic_url), ScryptedMimeTypes.Url.value)
|
||||
if pic_url is None:
|
||||
raise Exception("Error taking snapshot")
|
||||
|
||||
async def getVideoStreamOptions(self):
|
||||
return [
|
||||
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',
|
||||
@@ -160,64 +380,230 @@ class ArloCamera(ScryptedDeviceBase, Settings, Camera, VideoCamera, MotionSensor
|
||||
"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,
|
||||
}
|
||||
]
|
||||
|
||||
async def _getVideoStreamURL(self):
|
||||
self.logger.info("Requesting stream")
|
||||
rtsp_url = await asyncio.wait_for(self.provider.arlo.StartStream(self.arlo_basestation, self.arlo_device), timeout=self.timeout)
|
||||
self.logger.debug(f"Got stream URL at {rtsp_url}")
|
||||
return rtsp_url
|
||||
if id is None:
|
||||
return options
|
||||
|
||||
async def getVideoStream(self, options=None):
|
||||
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")
|
||||
rtsp_url = await self._getVideoStreamURL()
|
||||
return await scrypted_sdk.mediaManager.createMediaObject(str.encode(rtsp_url), ScryptedMimeTypes.Url.value)
|
||||
|
||||
async def startRTCSignalingSession(self, scrypted_session):
|
||||
try:
|
||||
plugin_session = ArloCameraRTCSignalingSession(self)
|
||||
await plugin_session.initialize()
|
||||
mso = await self.getVideoStreamOptions(id=options["id"])
|
||||
mso['refreshAt'] = round(time.time() * 1000) + 30 * 60 * 1000
|
||||
container = mso["container"]
|
||||
|
||||
scrypted_setup = {
|
||||
"type": "offer",
|
||||
"audio": {
|
||||
"direction": "sendrecv" if self._can_push_to_talk() else "recvonly",
|
||||
},
|
||||
"video": {
|
||||
"direction": "recvonly",
|
||||
}
|
||||
}
|
||||
plugin_setup = {}
|
||||
url = await self._getVideoStreamURL(container)
|
||||
additional_ffmpeg_args = []
|
||||
|
||||
scrypted_offer = await scrypted_session.createLocalDescription("offer", scrypted_setup, sendIceCandidate=plugin_session.addIceCandidate)
|
||||
self.logger.info(f"Scrypted offer sdp:\n{scrypted_offer['sdp']}")
|
||||
await plugin_session.setRemoteDescription(scrypted_offer, plugin_setup)
|
||||
plugin_answer = await plugin_session.createLocalDescription("answer", plugin_setup, scrypted_session.sendIceCandidate)
|
||||
self.logger.info(f"Scrypted answer sdp:\n{plugin_answer['sdp']}")
|
||||
await scrypted_session.setRemoteDescription(plugin_answer, scrypted_setup)
|
||||
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']
|
||||
|
||||
return ArloCameraRTCSessionControl(plugin_session)
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
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 def startIntercom(self, media):
|
||||
@async_print_exception_guard
|
||||
async def startIntercom(self, media) -> None:
|
||||
self.logger.info("Starting intercom")
|
||||
self.intercom_session = ArloCameraRTCSignalingSession(self)
|
||||
await self.intercom_session.initialize_push_to_talk(media)
|
||||
|
||||
async def stopIntercom(self):
|
||||
if self.uses_sip_push_to_talk:
|
||||
sip_info = self.provider.arlo.GetSIPInfo()
|
||||
sip_call_info = sip_info["sipCallInfo"]
|
||||
|
||||
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.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.goSM = scrypted_arlo_go.NewSIPWebRTCManager("Arlo SIP "+self.nativeId, 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.goSM.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"],
|
||||
"-vn",
|
||||
"-acodec", "libopus",
|
||||
"-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("Arlo Subprocess "+self.logger_name, ffmpeg_path, *ffmpeg_args)
|
||||
self.intercom_ffmpeg_subprocess.start()
|
||||
|
||||
self.goSM.Start()
|
||||
else:
|
||||
# we need to do signaling through arlo cloud apis
|
||||
self.intercom_session = ArloCameraIntercomSession(self)
|
||||
await self.intercom_session.initialize_push_to_talk(media)
|
||||
|
||||
self.logger.info("Intercom ready")
|
||||
|
||||
@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
|
||||
if self.goSM is not None:
|
||||
self.goSM.Close()
|
||||
self.goSM = None
|
||||
|
||||
def _can_push_to_talk(self):
|
||||
# Right now, only implement push to talk for basestation cameras
|
||||
return self.arlo_device["deviceId"] != self.arlo_device["parentId"]
|
||||
async def getVideoClip(self, videoId: str) -> MediaObject:
|
||||
self.logger.info(f"Getting video clip {videoId}")
|
||||
|
||||
id_as_time = int(videoId) / 1000.0
|
||||
start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10)
|
||||
end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10)
|
||||
|
||||
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
|
||||
for recording in library:
|
||||
if videoId == recording["name"]:
|
||||
return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedContentUrl"])
|
||||
self.logger.warn(f"Clip {videoId} not found")
|
||||
return None
|
||||
|
||||
async def getVideoClipThumbnail(self, thumbnailId: str) -> MediaObject:
|
||||
self.logger.info(f"Getting video clip thumbnail {thumbnailId}")
|
||||
|
||||
id_as_time = int(thumbnailId) / 1000.0
|
||||
start = datetime.fromtimestamp(id_as_time) - timedelta(seconds=10)
|
||||
end = datetime.fromtimestamp(id_as_time) + timedelta(seconds=10)
|
||||
|
||||
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
|
||||
for recording in library:
|
||||
if thumbnailId == recording["name"]:
|
||||
return await scrypted_sdk.mediaManager.createMediaObjectFromUrl(recording["presignedThumbnailUrl"])
|
||||
self.logger.warn(f"Clip thumbnail {thumbnailId} not found")
|
||||
return None
|
||||
|
||||
async def getVideoClips(self, options: VideoClipOptions = None) -> List[VideoClip]:
|
||||
self.logger.info(f"Fetching remote video clips {options}")
|
||||
|
||||
start = datetime.fromtimestamp(options["startTime"] / 1000.0)
|
||||
end = datetime.fromtimestamp(options["endTime"] / 1000.0)
|
||||
|
||||
library = self.provider.arlo.GetLibrary(self.arlo_device, start, end)
|
||||
clips = []
|
||||
for recording in library:
|
||||
clip = {
|
||||
"duration": recording["mediaDurationSecond"] * 1000.0,
|
||||
"id": recording["name"],
|
||||
"thumbnailId": recording["name"],
|
||||
"videoId": recording["name"],
|
||||
"startTime": recording["utcCreatedDate"],
|
||||
"description": recording["reason"],
|
||||
"resources": {
|
||||
"thumbnail": {
|
||||
"href": recording["presignedThumbnailUrl"],
|
||||
},
|
||||
"video": {
|
||||
"href": recording["presignedContentUrl"],
|
||||
},
|
||||
},
|
||||
}
|
||||
clips.append(clip)
|
||||
|
||||
if options.get("reverseOrder"):
|
||||
clips.reverse()
|
||||
return clips
|
||||
|
||||
@async_print_exception_guard
|
||||
async def removeVideoClips(self, videoClipIds: List[str]) -> None:
|
||||
# Arlo does support deleting, but let's be safe and disable that
|
||||
raise Exception("deleting Arlo video clips is not implemented by this plugin")
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
|
||||
if (nativeId.endswith("spotlight") and self.has_spotlight) or (nativeId.endswith("floodlight") and self.has_floodlight):
|
||||
return self.get_or_create_spotlight_or_floodlight()
|
||||
if nativeId.endswith("vss") and self.has_siren:
|
||||
return self.get_or_create_vss()
|
||||
return None
|
||||
|
||||
def get_or_create_spotlight_or_floodlight(self) -> ArloSpotlight:
|
||||
if self.has_spotlight:
|
||||
light_id = f'{self.arlo_device["deviceId"]}.spotlight'
|
||||
if not self.light:
|
||||
self.light = ArloSpotlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
elif self.has_floodlight:
|
||||
light_id = f'{self.arlo_device["deviceId"]}.floodlight'
|
||||
if not self.light:
|
||||
self.light = ArloFloodlight(light_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.light
|
||||
|
||||
def get_or_create_vss(self) -> ArloSirenVirtualSecuritySystem:
|
||||
if self.has_siren:
|
||||
vss_id = f'{self.arlo_device["deviceId"]}.vss'
|
||||
if not self.vss:
|
||||
self.vss = ArloSirenVirtualSecuritySystem(vss_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.vss
|
||||
|
||||
|
||||
class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
|
||||
class ArloCameraIntercomSession(BackgroundTaskMixin):
|
||||
def __init__(self, camera):
|
||||
super().__init__()
|
||||
self.camera = camera
|
||||
@@ -226,10 +612,8 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
|
||||
self.arlo_device = camera.arlo_device
|
||||
self.arlo_basestation = camera.arlo_basestation
|
||||
|
||||
self.ffmpeg_subprocess = None
|
||||
self.intercom_ffmpeg_subprocess = None
|
||||
|
||||
self.scrypted_pc = None
|
||||
self.arlo_pc = None
|
||||
self.arlo_sdp_answered = False
|
||||
|
||||
@@ -276,24 +660,26 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
|
||||
self.provider.arlo.SubscribeToCandidateAnswers(self.arlo_basestation, self.arlo_device, callback)
|
||||
)
|
||||
|
||||
async def initialize(self):
|
||||
self.logger.info("Initializing video stream for RTC")
|
||||
rtsp_url = await self.camera._getVideoStreamURL()
|
||||
@async_print_exception_guard
|
||||
async def initialize_push_to_talk(self, media):
|
||||
self.logger.info("Initializing push to talk")
|
||||
|
||||
cfg = scrypted_arlo_go.WebRTCConfiguration(
|
||||
ICEServers=scrypted_arlo_go.Slice_webrtc_ICEServer([
|
||||
scrypted_arlo_go.NewWebRTCICEServer(
|
||||
scrypted_arlo_go.go.Slice_string(["turn:turn0.clockworkmod.com", "turn:n0.clockworkmod.com", "turn:n1.clockworkmod.com"]),
|
||||
"foo",
|
||||
"bar"
|
||||
)
|
||||
])
|
||||
)
|
||||
cfg = scrypted_arlo_go.WebRTCConfiguration()
|
||||
self.scrypted_pc = scrypted_arlo_go.NewWebRTCManager("Arlo "+self.camera.logger_name, cfg)
|
||||
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]}")
|
||||
|
||||
audio_port = self.scrypted_pc.InitializeAudioRTPListener(scrypted_arlo_go.WebRTCMimeTypeOpus)
|
||||
video_port = self.scrypted_pc.InitializeVideoRTPListener(scrypted_arlo_go.WebRTCMimeTypeH264)
|
||||
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("Arlo WebRTC "+self.camera.logger_name, 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 = [
|
||||
@@ -302,200 +688,48 @@ class ArloCameraRTCSignalingSession(BackgroundTaskMixin):
|
||||
"-loglevel", "error",
|
||||
"-analyzeduration", "0",
|
||||
"-fflags", "-nobuffer",
|
||||
"-max_probe_packets", "2",
|
||||
"-vcodec", "h264",
|
||||
"-acodec", "aac",
|
||||
"-i", rtsp_url,
|
||||
"-an",
|
||||
"-vcodec", "copy",
|
||||
"-f", "rtp",
|
||||
"-flush_packets", "1",
|
||||
f"rtp://localhost:{video_port}",
|
||||
"-probesize", "500000",
|
||||
*ffmpeg_params["inputArguments"],
|
||||
"-vn",
|
||||
"-acodec", "libopus",
|
||||
"-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 {ffmpeg_args}")
|
||||
self.logger.debug(f"Starting ffmpeg at {ffmpeg_path} with '{' '.join(ffmpeg_args)}'")
|
||||
|
||||
self.ffmpeg_subprocess = HeartbeatChildProcess("Arlo "+self.camera.logger_name, ffmpeg_path, *ffmpeg_args)
|
||||
self.ffmpeg_subprocess.start()
|
||||
self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("Arlo Subprocess "+self.camera.logger_name, ffmpeg_path, *ffmpeg_args)
|
||||
self.intercom_ffmpeg_subprocess.start()
|
||||
|
||||
if self.camera._can_push_to_talk():
|
||||
self.create_task(self.initialize_push_to_talk())
|
||||
self.sdp_answered = False
|
||||
|
||||
async def initialize_push_to_talk(self, media=None):
|
||||
try:
|
||||
self.logger.info("Initializing push to talk")
|
||||
offer = self.arlo_pc.CreateOffer()
|
||||
offer_sdp = scrypted_arlo_go.WebRTCSessionDescriptionSDP(offer)
|
||||
self.logger.info(f"Arlo offer sdp:\n{offer_sdp}")
|
||||
|
||||
session_id, ice_servers = self.provider.arlo.StartPushToTalk(self.arlo_basestation, self.arlo_device)
|
||||
self.logger.debug(f"Received ice servers: {[ice['url'] for ice in ice_servers]}")
|
||||
self.arlo_pc.SetLocalDescription(offer)
|
||||
|
||||
cfg = scrypted_arlo_go.WebRTCConfiguration(
|
||||
ICEServers=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("Arlo "+self.camera.logger_name, cfg)
|
||||
self.provider.arlo.NotifyPushToTalkSDP(
|
||||
self.arlo_basestation, self.arlo_device,
|
||||
session_id, offer_sdp
|
||||
)
|
||||
|
||||
if media is not None:
|
||||
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"],
|
||||
"-vn",
|
||||
"-acodec", "libopus",
|
||||
"-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 {ffmpeg_args}")
|
||||
|
||||
self.intercom_ffmpeg_subprocess = HeartbeatChildProcess("Arlo "+self.camera.logger_name, ffmpeg_path, *ffmpeg_args)
|
||||
self.intercom_ffmpeg_subprocess.start()
|
||||
else:
|
||||
self.logger.debug("Starting audio track forwarder")
|
||||
self.scrypted_pc.ForwardAudioTo(self.arlo_pc)
|
||||
self.logger.debug("Started audio track forwarder")
|
||||
|
||||
self.sdp_answered = False
|
||||
|
||||
offer = self.arlo_pc.CreateOffer()
|
||||
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(
|
||||
candidates = self.arlo_pc.WaitAndGetICECandidates()
|
||||
self.logger.debug(f"Gathered {len(candidates)} candidates")
|
||||
for candidate in candidates:
|
||||
candidate = scrypted_arlo_go.WebRTCICECandidateInit(
|
||||
scrypted_arlo_go.WebRTCICECandidate(handle=candidate).ToJSON()
|
||||
).Candidate
|
||||
self.logger.debug(f"Sending candidate to Arlo: {candidate}")
|
||||
self.provider.arlo.NotifyPushToTalkCandidate(
|
||||
self.arlo_basestation, self.arlo_device,
|
||||
session_id, offer_sdp
|
||||
session_id, candidate,
|
||||
)
|
||||
|
||||
def forward_candidates():
|
||||
try:
|
||||
candidates = self.arlo_pc.WaitAndGetICECandidates()
|
||||
self.logger.debug(f"Gathered {len(candidates)} candidates")
|
||||
for candidate in candidates:
|
||||
candidate = scrypted_arlo_go.WebRTCICECandidateInit(
|
||||
scrypted_arlo_go.WebRTCICECandidate(handle=candidate).ToJSON()
|
||||
).Candidate
|
||||
self.logger.debug(f"Sending candidate to Arlo: {candidate}")
|
||||
self.provider.arlo.NotifyPushToTalkCandidate(
|
||||
self.arlo_basestation, self.arlo_device,
|
||||
session_id, candidate,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
t = threading.Thread(target=forward_candidates)
|
||||
t.start()
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
|
||||
async def createLocalDescription(self, type, setup, sendIceCandidate=None):
|
||||
if type == "offer":
|
||||
raise Exception("can only create answers in ArloCameraRTCSignalingSession.createLocalDescription")
|
||||
|
||||
answer = self.scrypted_pc.CreateAnswer()
|
||||
answer_sdp = scrypted_arlo_go.WebRTCSessionDescriptionSDP(answer)
|
||||
|
||||
self.scrypted_pc.SetLocalDescription(answer)
|
||||
|
||||
if sendIceCandidate is not None:
|
||||
loop = asyncio.get_event_loop()
|
||||
def forward_candidates():
|
||||
try:
|
||||
candidates = self.scrypted_pc.WaitAndGetICECandidates()
|
||||
self.logger.debug(f"Gathered {len(candidates)} candidates")
|
||||
for candidate in candidates:
|
||||
candidate = scrypted_arlo_go.WebRTCICECandidateInit(
|
||||
scrypted_arlo_go.WebRTCICECandidate(handle=candidate).ToJSON()
|
||||
).Candidate
|
||||
self.logger.debug(f"Sending candidate to scrypted: {candidate}")
|
||||
loop.call_soon_threadsafe(
|
||||
self.create_task,
|
||||
sendIceCandidate({
|
||||
"candidate": candidate,
|
||||
"sdpMid": "0",
|
||||
"sdpMLineIndex": 0,
|
||||
})
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
t = threading.Thread(target=forward_candidates)
|
||||
t.start()
|
||||
|
||||
return {
|
||||
"sdp": answer_sdp,
|
||||
"type": "answer"
|
||||
}
|
||||
|
||||
async def setRemoteDescription(self, description, setup):
|
||||
if description["type"] != "offer":
|
||||
raise Exception("can only accept offers in ArloCameraRTCSignalingSession.createLocalDescription")
|
||||
|
||||
sdp = scrypted_arlo_go.WebRTCSessionDescription(scrypted_arlo_go.NewWebRTCSDPType("offer"), description["sdp"])
|
||||
self.scrypted_pc.SetRemoteDescription(sdp)
|
||||
|
||||
async def addIceCandidate(self, candidate):
|
||||
candidate = scrypted_arlo_go.WebRTCICECandidateInit(candidate["candidate"], "0", 0)
|
||||
self.scrypted_pc.AddICECandidate(candidate)
|
||||
|
||||
async def getOptions(self):
|
||||
pass
|
||||
|
||||
async def unmute_relay(self):
|
||||
return
|
||||
await self.arlo_pc.unmute_relay(self.arlo_relay_track)
|
||||
|
||||
async def mute_relay(self):
|
||||
return
|
||||
await self.arlo_pc.mute_relay(self.arlo_relay_track)
|
||||
|
||||
async def shutdown(self):
|
||||
if self.ffmpeg_subprocess is not None:
|
||||
self.ffmpeg_subprocess.stop()
|
||||
self.ffmpeg_subprocess = None
|
||||
if self.intercom_ffmpeg_subprocess is not None:
|
||||
self.intercom_ffmpeg_subprocess.stop()
|
||||
self.intercom_ffmpeg_subprocess = None
|
||||
if self.scrypted_pc is not None:
|
||||
self.scrypted_pc.Close()
|
||||
self.scrypted_pc = None
|
||||
if self.arlo_pc is not None:
|
||||
self.arlo_pc.Close()
|
||||
self.arlo_pc = None
|
||||
|
||||
|
||||
class ArloCameraRTCSessionControl:
|
||||
def __init__(self, arlo_session):
|
||||
self.arlo_session = arlo_session
|
||||
self.logger = arlo_session.logger
|
||||
|
||||
async def setPlayback(self, options):
|
||||
self.logger.debug(f"setPlayback options {options}")
|
||||
audio = options.get("audio")
|
||||
if audio is None:
|
||||
return
|
||||
if audio:
|
||||
await self.arlo_session.unmute_relay()
|
||||
else:
|
||||
await self.arlo_session.mute_relay()
|
||||
|
||||
async def endSession(self):
|
||||
self.logger.info("Ending RTC session")
|
||||
await self.arlo_session.shutdown()
|
||||
self.arlo_pc = None
|
||||
@@ -1,29 +1,34 @@
|
||||
from scrypted_sdk.types import BinarySensor, ScryptedInterface
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk.types import BinarySensor, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .camera import ArloCamera
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
|
||||
|
||||
class ArloDoorbell(ArloCamera, BinarySensor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
|
||||
self.start_doorbell_subscription()
|
||||
|
||||
def start_doorbell_subscription(self):
|
||||
def start_doorbell_subscription(self) -> None:
|
||||
def callback(doorbellPressed):
|
||||
self.binaryState = doorbellPressed
|
||||
return self.stop_subscriptions
|
||||
|
||||
|
||||
self.register_task(
|
||||
self.provider.arlo.SubscribeToDoorbellEvents(self.arlo_basestation, self.arlo_device, callback)
|
||||
)
|
||||
|
||||
def get_applicable_interfaces(self):
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.Doorbell.value
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
camera_interfaces = super().get_applicable_interfaces()
|
||||
camera_interfaces.append(ScryptedInterface.BinarySensor.value)
|
||||
|
||||
model_id = self.arlo_device['properties']['modelId'].lower()
|
||||
if model_id.startswith("avd1001"):
|
||||
camera_interfaces.remove(ScryptedInterface.Battery.value)
|
||||
return camera_interfaces
|
||||
|
||||
1
plugins/arlo/src/arlo_plugin/experimental.py
Normal file
1
plugins/arlo/src/arlo_plugin/experimental.py
Normal file
@@ -0,0 +1 @@
|
||||
EXPERIMENTAL = False
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
class ScryptedDeviceLoggingWrapper(logging.Handler):
|
||||
@@ -20,7 +19,7 @@ def createScryptedLogger(scrypted_device, name):
|
||||
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# configure logger to output to scrypted's log stream
|
||||
# configure logger to output to scrypted's log stream
|
||||
sh = ScryptedDeviceLoggingWrapper(scrypted_device)
|
||||
|
||||
# log formatting
|
||||
|
||||
@@ -6,27 +6,31 @@ import logging
|
||||
import re
|
||||
import requests
|
||||
import traceback
|
||||
from typing import List
|
||||
|
||||
import scrypted_sdk
|
||||
from scrypted_sdk import ScryptedDeviceBase
|
||||
from scrypted_sdk.types import Settings, DeviceProvider, DeviceDiscovery, ScryptedInterface, ScryptedDeviceType
|
||||
from scrypted_sdk.types import Setting, SettingValue, Settings, DeviceProvider, ScryptedInterface
|
||||
|
||||
from .arlo import Arlo
|
||||
from .arlo.arlo_async import change_stream_class
|
||||
from .arlo.logging import logger as arlo_lib_logger
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin, async_print_exception_guard
|
||||
from .camera import ArloCamera
|
||||
from .doorbell import ArloDoorbell
|
||||
from .logging import ScryptedDeviceLoggerMixin
|
||||
from .util import BackgroundTaskMixin
|
||||
from .basestation import ArloBasestation
|
||||
from .base import ArloDeviceBase
|
||||
|
||||
|
||||
class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, ScryptedDeviceLoggerMixin, BackgroundTaskMixin):
|
||||
arlo_cameras = None
|
||||
arlo_basestations = None
|
||||
_arlo_mfa_code = None
|
||||
scrypted_devices = None
|
||||
_arlo = None
|
||||
_arlo_mfa_complete_auth = None
|
||||
device_discovery_lock: asyncio.Lock = None
|
||||
|
||||
plugin_verbosity_choices = {
|
||||
"Normal": logging.INFO,
|
||||
@@ -37,7 +41,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
|
||||
mfa_strategy_choices = ["Manual", "IMAP"]
|
||||
|
||||
def __init__(self, nativeId=None):
|
||||
def __init__(self, nativeId: str = None) -> None:
|
||||
super().__init__(nativeId=nativeId)
|
||||
self.logger_name = "provider"
|
||||
|
||||
@@ -47,6 +51,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.imap = None
|
||||
self.imap_signal = None
|
||||
self.imap_skip_emails = None
|
||||
self.device_discovery_lock = asyncio.Lock()
|
||||
|
||||
self.propagate_verbosity()
|
||||
self.propagate_transport()
|
||||
@@ -60,28 +65,31 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
asyncio.get_event_loop().call_soon(load, self)
|
||||
self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None))
|
||||
|
||||
def print(self, *args, **kwargs):
|
||||
def print(self, *args, **kwargs) -> None:
|
||||
"""Overrides the print() from ScryptedDeviceBase to avoid double-printing in the main plugin console."""
|
||||
print(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def arlo_username(self):
|
||||
def arlo_username(self) -> str:
|
||||
return self.storage.getItem("arlo_username")
|
||||
|
||||
@property
|
||||
def arlo_password(self):
|
||||
def arlo_password(self) -> str:
|
||||
return self.storage.getItem("arlo_password")
|
||||
|
||||
@property
|
||||
def arlo_auth_headers(self):
|
||||
def arlo_auth_headers(self) -> str:
|
||||
return self.storage.getItem("arlo_auth_headers")
|
||||
|
||||
@property
|
||||
def arlo_user_id(self):
|
||||
def arlo_user_id(self) -> str:
|
||||
return self.storage.getItem("arlo_user_id")
|
||||
|
||||
@property
|
||||
def arlo_transport(self):
|
||||
def arlo_transport(self) -> str:
|
||||
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"
|
||||
@@ -89,7 +97,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return transport
|
||||
|
||||
@property
|
||||
def plugin_verbosity(self):
|
||||
def plugin_verbosity(self) -> str:
|
||||
verbosity = self.storage.getItem("plugin_verbosity")
|
||||
if verbosity is None or verbosity not in ArloProvider.plugin_verbosity_choices:
|
||||
verbosity = "Normal"
|
||||
@@ -97,7 +105,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return verbosity
|
||||
|
||||
@property
|
||||
def mfa_strategy(self):
|
||||
def mfa_strategy(self) -> str:
|
||||
strategy = self.storage.getItem("mfa_strategy")
|
||||
if strategy is None or strategy not in ArloProvider.mfa_strategy_choices:
|
||||
strategy = "Manual"
|
||||
@@ -105,7 +113,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return strategy
|
||||
|
||||
@property
|
||||
def refresh_interval(self):
|
||||
def refresh_interval(self) -> int:
|
||||
interval = self.storage.getItem("refresh_interval")
|
||||
if interval is None:
|
||||
interval = 90
|
||||
@@ -113,11 +121,11 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return int(interval)
|
||||
|
||||
@property
|
||||
def imap_mfa_host(self):
|
||||
def imap_mfa_host(self) -> str:
|
||||
return self.storage.getItem("imap_mfa_host")
|
||||
|
||||
@property
|
||||
def imap_mfa_port(self):
|
||||
def imap_mfa_port(self) -> int:
|
||||
port = self.storage.getItem("imap_mfa_port")
|
||||
if port is None:
|
||||
port = 993
|
||||
@@ -125,32 +133,34 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return int(port)
|
||||
|
||||
@property
|
||||
def imap_mfa_username(self):
|
||||
def imap_mfa_username(self) -> str:
|
||||
return self.storage.getItem("imap_mfa_username")
|
||||
|
||||
@property
|
||||
def imap_mfa_password(self):
|
||||
def imap_mfa_password(self) -> str:
|
||||
return self.storage.getItem("imap_mfa_password")
|
||||
|
||||
@property
|
||||
def imap_mfa_interval(self):
|
||||
def imap_mfa_interval(self) -> int:
|
||||
interval = self.storage.getItem("imap_mfa_interval")
|
||||
if interval is None:
|
||||
interval = 7
|
||||
interval = 7
|
||||
self.storage.setItem("imap_mfa_interval", interval)
|
||||
return int(interval)
|
||||
|
||||
@property
|
||||
def arlo(self):
|
||||
def arlo(self) -> Arlo:
|
||||
if self._arlo is not None:
|
||||
if self._arlo_mfa_complete_auth is not None:
|
||||
if self._arlo_mfa_code == "":
|
||||
if not self._arlo_mfa_code:
|
||||
return None
|
||||
|
||||
self.logger.info("Completing Arlo MFA...")
|
||||
self._arlo_mfa_complete_auth(self._arlo_mfa_code)
|
||||
self._arlo_mfa_complete_auth = None
|
||||
self._arlo_mfa_code = None
|
||||
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())))
|
||||
@@ -162,7 +172,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
|
||||
if not self.arlo_username or not self.arlo_password:
|
||||
return None
|
||||
|
||||
|
||||
self.logger.info("Trying to initialize Arlo client...")
|
||||
try:
|
||||
self._arlo = Arlo(self.arlo_username, self.arlo_password)
|
||||
@@ -170,7 +180,6 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
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:
|
||||
@@ -180,19 +189,17 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._arlo = None
|
||||
self._arlo_mfa_complete_auth = None
|
||||
self._arlo_mfa_code = None
|
||||
return None
|
||||
|
||||
async def do_arlo_setup(self):
|
||||
async def do_arlo_setup(self) -> None:
|
||||
try:
|
||||
await self.discoverDevices()
|
||||
await self.discover_devices()
|
||||
await self.arlo.Subscribe([
|
||||
(self.arlo_basestations[camera["parentId"]], camera) for camera in self.arlo_cameras.values()
|
||||
])
|
||||
|
||||
for nativeId in self.arlo_cameras.keys():
|
||||
await self.getDevice(nativeId)
|
||||
|
||||
self.arlo.event_stream.set_refresh_interval(self.refresh_interval)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
traceback.print_exc()
|
||||
@@ -204,7 +211,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
def invalidate_arlo_client(self):
|
||||
def invalidate_arlo_client(self) -> None:
|
||||
if self._arlo is not None:
|
||||
self._arlo.Unsubscribe()
|
||||
self._arlo = None
|
||||
@@ -213,10 +220,10 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.storage.setItem("arlo_auth_headers", "")
|
||||
self.storage.setItem("arlo_user_id", "")
|
||||
|
||||
def get_current_log_level(self):
|
||||
def get_current_log_level(self) -> int:
|
||||
return ArloProvider.plugin_verbosity_choices[self.plugin_verbosity]
|
||||
|
||||
def propagate_verbosity(self):
|
||||
def propagate_verbosity(self) -> None:
|
||||
self.print(f"Setting plugin verbosity to {self.plugin_verbosity}")
|
||||
log_level = self.get_current_log_level()
|
||||
self.logger.setLevel(log_level)
|
||||
@@ -224,11 +231,11 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
device.logger.setLevel(log_level)
|
||||
arlo_lib_logger.setLevel(log_level)
|
||||
|
||||
def propagate_transport(self):
|
||||
def propagate_transport(self) -> None:
|
||||
self.print(f"Setting plugin transport to {self.arlo_transport}")
|
||||
change_stream_class(self.arlo_transport)
|
||||
|
||||
def initialize_imap(self):
|
||||
def initialize_imap(self) -> None:
|
||||
if not self.imap_mfa_host or not self.imap_mfa_port or \
|
||||
not self.imap_mfa_username or not self.imap_mfa_password or \
|
||||
not self.imap_mfa_interval:
|
||||
@@ -245,7 +252,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
res, _ = self.imap.select(mailbox="INBOX", readonly=True)
|
||||
if res.lower() != "ok":
|
||||
raise Exception(f"IMAP failed to fetch INBOX: {res}")
|
||||
|
||||
|
||||
# fetch existing arlo emails so we skip them going forward
|
||||
res, self.imap_skip_emails = self.imap.search(None, "FROM", "do_not_reply@arlo.com")
|
||||
if res.lower() != "ok":
|
||||
@@ -258,14 +265,14 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.imap_signal = asyncio.Queue()
|
||||
self.create_task(self.imap_relogin_loop())
|
||||
|
||||
def exit_imap(self):
|
||||
def exit_imap(self) -> None:
|
||||
if self.imap_signal:
|
||||
self.imap_signal.put_nowait(None)
|
||||
self.imap_signal = None
|
||||
self.imap_skip_emails = None
|
||||
self.imap = None
|
||||
|
||||
async def imap_relogin_loop(self):
|
||||
async def imap_relogin_loop(self) -> None:
|
||||
imap_signal = self.imap_signal
|
||||
self.logger.info(f"Starting IMAP refresh loop {id(imap_signal)}")
|
||||
while True:
|
||||
@@ -368,7 +375,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.logger.info(f"Exiting IMAP refresh loop {id(imap_signal)}")
|
||||
return
|
||||
|
||||
async def getSettings(self):
|
||||
async def getSettings(self) -> List[Setting]:
|
||||
results = [
|
||||
{
|
||||
"group": "General",
|
||||
@@ -447,15 +454,15 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
"value": self.imap_mfa_interval,
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
results.extend([
|
||||
{
|
||||
"group": "General",
|
||||
"key": "arlo_transport",
|
||||
"title": "Underlying Transport Protocol",
|
||||
"description": "Select the underlying transport protocol used to connect to Arlo Cloud.",
|
||||
"description": "Arlo Cloud currently only supports the SSE protocol.",
|
||||
"value": self.arlo_transport,
|
||||
"choices": self.arlo_transport_choices,
|
||||
"readonly": True,
|
||||
},
|
||||
{
|
||||
"group": "General",
|
||||
@@ -469,17 +476,17 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
{
|
||||
"group": "General",
|
||||
"key": "plugin_verbosity",
|
||||
"title": "Plugin Verbosity",
|
||||
"description": "Select the verbosity of this plugin. 'Verbose' will show debugging messages, "
|
||||
"including events received from connected Arlo cameras.",
|
||||
"value": self.plugin_verbosity,
|
||||
"choices": sorted(self.plugin_verbosity_choices.keys()),
|
||||
"title": "Verbose Logging",
|
||||
"description": "Enable this option to show debug messages, including events received from connected Arlo cameras.",
|
||||
"value": self.plugin_verbosity == "Verbose",
|
||||
"type": "boolean",
|
||||
},
|
||||
])
|
||||
|
||||
return results
|
||||
|
||||
async def putSetting(self, key, value):
|
||||
@async_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
|
||||
@@ -490,13 +497,14 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
elif key == "force_reauth":
|
||||
# force arlo client to be invalidated and reloaded
|
||||
self.invalidate_arlo_client()
|
||||
elif key == "plugin_verbosity":
|
||||
self.storage.setItem(key, "Verbose" if value == "true" or value == True else "Normal")
|
||||
self.propagate_verbosity()
|
||||
skip_arlo_client = True
|
||||
else:
|
||||
self.storage.setItem(key, value)
|
||||
|
||||
if key == "plugin_verbosity":
|
||||
self.propagate_verbosity()
|
||||
skip_arlo_client = True
|
||||
elif key == "arlo_transport":
|
||||
if key == "arlo_transport":
|
||||
self.propagate_transport()
|
||||
# force arlo client to be invalidated and reloaded, but
|
||||
# keep any mfa codes
|
||||
@@ -525,7 +533,7 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
_ = self.arlo
|
||||
await self.onDeviceEvent(ScryptedInterface.Settings.value, None)
|
||||
|
||||
def validate_setting(self, key, val):
|
||||
def validate_setting(self, key: str, val: SettingValue) -> bool:
|
||||
if key == "refresh_interval":
|
||||
try:
|
||||
val = int(val)
|
||||
@@ -555,7 +563,12 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
return False
|
||||
return True
|
||||
|
||||
async def discoverDevices(self, duration=0):
|
||||
@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")
|
||||
|
||||
@@ -564,70 +577,127 @@ class ArloProvider(ScryptedDeviceBase, Settings, DeviceProvider, DeviceDiscovery
|
||||
self.arlo_basestations = {}
|
||||
self.scrypted_devices = {}
|
||||
|
||||
camera_devices = []
|
||||
provider_to_device_map = {None: []}
|
||||
|
||||
basestations = self.arlo.GetDevices(['basestation', 'siren'])
|
||||
for basestation in basestations:
|
||||
self.arlo_basestations[basestation["deviceId"]] = basestation
|
||||
nativeId = basestation["deviceId"]
|
||||
self.logger.debug(f"Adding {nativeId}")
|
||||
|
||||
if nativeId in self.arlo_basestations:
|
||||
self.logger.info(f"Skipping basestation {nativeId} ({basestation['modelId']}) as it has already been added")
|
||||
continue
|
||||
self.arlo_basestations[nativeId] = basestation
|
||||
|
||||
device = await self.getDevice_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")
|
||||
|
||||
devices = []
|
||||
cameras = self.arlo.GetDevices(['camera', "arloq", "arloqs", "doorbell"])
|
||||
for camera in cameras:
|
||||
nativeId = camera["deviceId"]
|
||||
self.logger.debug(f"Adding {nativeId}")
|
||||
|
||||
if camera["deviceId"] != camera["parentId"] and camera["parentId"] not in self.arlo_basestations:
|
||||
self.logger.info(f"Skipping camera {camera['deviceId']} because its basestation was not found")
|
||||
self.logger.info(f"Skipping camera {camera['deviceId']} ({camera['modelId']}) because its basestation was not found")
|
||||
continue
|
||||
|
||||
if camera["deviceId"] == camera["parentId"]:
|
||||
self.arlo_basestations[camera["deviceId"]] = camera
|
||||
|
||||
nativeId = camera["deviceId"]
|
||||
if nativeId in self.arlo_cameras:
|
||||
self.logger.info(f"Skipping camera {nativeId} ({camera['modelId']}) as it has already been added")
|
||||
continue
|
||||
self.arlo_cameras[nativeId] = camera
|
||||
|
||||
scrypted_interfaces = (await self.getDevice(nativeId)).get_applicable_interfaces()
|
||||
self.logger.debug(f"Interfaces for {nativeId} ({camera['modelId']}): {scrypted_interfaces}")
|
||||
if camera["deviceId"] == camera["parentId"]:
|
||||
# these are standalone cameras with no basestation, so they act as their
|
||||
# own basestation
|
||||
self.arlo_basestations[camera["deviceId"]] = camera
|
||||
|
||||
device = {
|
||||
"info": {
|
||||
"model": f"{camera['properties']['modelId']} ({camera['properties'].get('hwVersion', '')})".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": camera.get("firmwareVersion"),
|
||||
"serialNumber": camera["deviceId"],
|
||||
},
|
||||
"nativeId": camera["deviceId"],
|
||||
"name": camera["deviceName"],
|
||||
"interfaces": scrypted_interfaces,
|
||||
"type": ScryptedDeviceType.Camera.value,
|
||||
"providerNativeId": self.nativeId,
|
||||
}
|
||||
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}")
|
||||
|
||||
devices.append(device)
|
||||
if camera["deviceId"] == camera["parentId"]:
|
||||
provider_to_device_map.setdefault(None, []).append(manifest)
|
||||
else:
|
||||
provider_to_device_map.setdefault(camera["parentId"], []).append(manifest)
|
||||
|
||||
await scrypted_sdk.deviceManager.onDevicesChanged({
|
||||
"devices": devices,
|
||||
})
|
||||
# trickle discover this camera so it exists for later steps
|
||||
await scrypted_sdk.deviceManager.onDeviceDiscovered(manifest)
|
||||
|
||||
if len(cameras) != len(devices):
|
||||
self.logger.info(f"Discovered {len(cameras)} cameras, but only {len(devices)} are usable")
|
||||
# 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")
|
||||
|
||||
async def getDevice(self, nativeId):
|
||||
for provider_id in provider_to_device_map.keys():
|
||||
if provider_id is None:
|
||||
continue
|
||||
await scrypted_sdk.deviceManager.onDevicesChanged({
|
||||
"devices": provider_to_device_map[provider_id],
|
||||
"providerNativeId": provider_id,
|
||||
})
|
||||
|
||||
# ensure devices at the root match all that was discovered
|
||||
await scrypted_sdk.deviceManager.onDevicesChanged({
|
||||
"devices": provider_to_device_map[None]
|
||||
})
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
|
||||
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, None)
|
||||
if ret is None:
|
||||
ret = self.create_camera(nativeId)
|
||||
ret = self.create_device(nativeId)
|
||||
if ret is not None:
|
||||
self.scrypted_devices[nativeId] = ret
|
||||
return ret
|
||||
|
||||
def create_camera(self, nativeId):
|
||||
if nativeId not in self.arlo_cameras:
|
||||
def create_device(self, nativeId: str) -> ArloDeviceBase:
|
||||
if nativeId not in self.arlo_cameras and nativeId not in self.arlo_basestations:
|
||||
self.logger.warning(f"Cannot create device for nativeId {nativeId}, maybe it hasn't been loaded yet?")
|
||||
return None
|
||||
arlo_camera = self.arlo_cameras[nativeId]
|
||||
|
||||
if arlo_camera["parentId"] not in self.arlo_basestations:
|
||||
arlo_device = self.arlo_cameras.get(nativeId)
|
||||
if not arlo_device:
|
||||
# this is a basestation, so build the basestation object
|
||||
arlo_device = self.arlo_basestations[nativeId]
|
||||
return ArloBasestation(nativeId, arlo_device, self)
|
||||
|
||||
if arlo_device["parentId"] not in self.arlo_basestations:
|
||||
self.logger.warning(f"Cannot create camera with nativeId {nativeId} when {arlo_device['parentId']} is not a valid basestation")
|
||||
return None
|
||||
arlo_basestation = self.arlo_basestations[arlo_camera["parentId"]]
|
||||
arlo_basestation = self.arlo_basestations[arlo_device["parentId"]]
|
||||
|
||||
if arlo_camera["deviceType"] == "doorbell":
|
||||
return ArloDoorbell(nativeId, arlo_camera, arlo_basestation, self)
|
||||
if arlo_device["deviceType"] == "doorbell":
|
||||
return ArloDoorbell(nativeId, arlo_device, arlo_basestation, self)
|
||||
else:
|
||||
return ArloCamera(nativeId, arlo_camera, arlo_basestation, self)
|
||||
return ArloCamera(nativeId, arlo_device, arlo_basestation, self)
|
||||
72
plugins/arlo/src/arlo_plugin/siren.py
Normal file
72
plugins/arlo/src/arlo_plugin/siren.py
Normal file
@@ -0,0 +1,72 @@
|
||||
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,
|
||||
}
|
||||
54
plugins/arlo/src/arlo_plugin/spotlight.py
Normal file
54
plugins/arlo/src/arlo_plugin/spotlight.py
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
@@ -1,13 +1,14 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
|
||||
class BackgroundTaskMixin:
|
||||
def create_task(self, coroutine):
|
||||
def create_task(self, coroutine) -> asyncio.Task:
|
||||
task = asyncio.get_event_loop().create_task(coroutine)
|
||||
self.register_task(task)
|
||||
return task
|
||||
|
||||
def register_task(self, task):
|
||||
def register_task(self, task) -> None:
|
||||
if not hasattr(self, "background_tasks"):
|
||||
self.background_tasks = set()
|
||||
|
||||
@@ -21,6 +22,18 @@ class BackgroundTaskMixin:
|
||||
task.add_done_callback(print_exception)
|
||||
task.add_done_callback(self.background_tasks.discard)
|
||||
|
||||
def cancel_pending_tasks(self):
|
||||
def cancel_pending_tasks(self) -> None:
|
||||
if not hasattr(self, "background_tasks"):
|
||||
return
|
||||
for task in self.background_tasks:
|
||||
task.cancel()
|
||||
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:
|
||||
traceback.print_exc()
|
||||
raise
|
||||
return wrapped
|
||||
156
plugins/arlo/src/arlo_plugin/vss.py
Normal file
156
plugins/arlo/src/arlo_plugin/vss.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from scrypted_sdk.types import Device, DeviceProvider, Setting, Settings, SettingValue, SecuritySystem, SecuritySystemMode, Readme, ScryptedInterface, ScryptedDeviceType
|
||||
|
||||
from .base import ArloDeviceBase
|
||||
from .siren import ArloSiren
|
||||
from .util import async_print_exception_guard
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/
|
||||
from .provider import ArloProvider
|
||||
from .basestation import ArloBasestation
|
||||
from .camera import ArloCamera
|
||||
|
||||
|
||||
class ArloSirenVirtualSecuritySystem(ArloDeviceBase, SecuritySystem, Settings, Readme, DeviceProvider):
|
||||
"""A virtual, emulated security system that controls when scrypted events can trip the real physical siren."""
|
||||
|
||||
SUPPORTED_MODES = [SecuritySystemMode.AwayArmed.value, SecuritySystemMode.HomeArmed.value, SecuritySystemMode.Disarmed.value]
|
||||
|
||||
siren: ArloSiren = None
|
||||
parent: ArloBasestation | ArloCamera = None
|
||||
|
||||
def __init__(self, nativeId: str, arlo_device: dict, arlo_basestation: dict, provider: ArloProvider, parent: ArloBasestation | ArloCamera) -> None:
|
||||
super().__init__(nativeId=nativeId, arlo_device=arlo_device, arlo_basestation=arlo_basestation, provider=provider)
|
||||
self.parent = parent
|
||||
self.create_task(self.delayed_init())
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
mode = self.storage.getItem("mode")
|
||||
if mode is None or mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES:
|
||||
mode = SecuritySystemMode.Disarmed.value
|
||||
return mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, mode: str) -> None:
|
||||
if mode not in ArloSirenVirtualSecuritySystem.SUPPORTED_MODES:
|
||||
raise ValueError(f"invalid mode {mode}")
|
||||
self.storage.setItem("mode", mode)
|
||||
self.securitySystemState = {
|
||||
**self.securitySystemState,
|
||||
"mode": mode,
|
||||
}
|
||||
self.create_task(self.onDeviceEvent(ScryptedInterface.Settings.value, None))
|
||||
|
||||
async def delayed_init(self) -> None:
|
||||
iterations = 1
|
||||
while not self.stop_subscriptions:
|
||||
if iterations > 100:
|
||||
self.logger.error("Delayed init exceeded iteration limit, giving up")
|
||||
return
|
||||
|
||||
try:
|
||||
self.securitySystemState = {
|
||||
"supportedModes": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES,
|
||||
"mode": self.mode,
|
||||
}
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Delayed init failed, will try again: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
iterations += 1
|
||||
|
||||
def get_applicable_interfaces(self) -> List[str]:
|
||||
return [
|
||||
ScryptedInterface.SecuritySystem.value,
|
||||
ScryptedInterface.DeviceProvider.value,
|
||||
ScryptedInterface.Settings.value,
|
||||
ScryptedInterface.Readme.value,
|
||||
]
|
||||
|
||||
def get_device_type(self) -> str:
|
||||
return ScryptedDeviceType.SecuritySystem.value
|
||||
|
||||
def get_builtin_child_device_manifests(self) -> List[Device]:
|
||||
siren = self.get_or_create_siren()
|
||||
return [
|
||||
{
|
||||
"info": {
|
||||
"model": f"{self.arlo_device['modelId']} {self.arlo_device['properties'].get('hwVersion', '')}".strip(),
|
||||
"manufacturer": "Arlo",
|
||||
"firmware": self.arlo_device.get("firmwareVersion"),
|
||||
"serialNumber": self.arlo_device["deviceId"],
|
||||
},
|
||||
"nativeId": siren.nativeId,
|
||||
"name": f'{self.arlo_device["deviceName"]} Siren',
|
||||
"interfaces": siren.get_applicable_interfaces(),
|
||||
"type": siren.get_device_type(),
|
||||
"providerNativeId": self.nativeId,
|
||||
}
|
||||
]
|
||||
|
||||
async def getSettings(self) -> List[Setting]:
|
||||
return [
|
||||
{
|
||||
"key": "mode",
|
||||
"title": "Arm Mode",
|
||||
"description": "If disarmed, the associated siren will not be physically triggered even if toggled.",
|
||||
"value": self.mode,
|
||||
"choices": ArloSirenVirtualSecuritySystem.SUPPORTED_MODES,
|
||||
},
|
||||
]
|
||||
|
||||
async def putSetting(self, key: str, value: SettingValue) -> None:
|
||||
if key != "mode":
|
||||
raise ValueError(f"invalid setting {key}")
|
||||
self.mode = value
|
||||
if self.mode == SecuritySystemMode.Disarmed.value:
|
||||
await self.get_or_create_siren().turnOff()
|
||||
|
||||
async def getReadmeMarkdown(self) -> str:
|
||||
return """
|
||||
# Virtual Security System for Arlo Sirens
|
||||
|
||||
This security system device is not a real physical device, but a virtual, emulated device provided by the Arlo Scrypted plugin. Its purpose is to grant security system semantics of Arm/Disarm to avoid the accidental, unwanted triggering of the real physical siren through integrations such as Homekit.
|
||||
|
||||
To allow the siren to trigger, set the Arm Mode to any of the Armed options. When Disarmed, any triggers of the siren will be ignored. Switching modes will not perform any changes to Arlo cloud or your Arlo account, but rather only to this Scrypted device.
|
||||
|
||||
If this virtual security system is synced to Homekit, the siren device will be merged into the same security system accessory as a switch. The siren device will not be added as a separate accessory. To access the siren as a switch without the security system, disable syncing of the virtual security system and enable syncing of the siren, then ensure that the virtual security system is armed manually in its settings in Scrypted.
|
||||
""".strip()
|
||||
|
||||
async def getDevice(self, nativeId: str) -> ArloDeviceBase:
|
||||
if not nativeId.endswith("siren"):
|
||||
return None
|
||||
return self.get_or_create_siren()
|
||||
|
||||
def get_or_create_siren(self) -> ArloSiren:
|
||||
siren_id = f'{self.arlo_device["deviceId"]}.siren'
|
||||
if not self.siren:
|
||||
self.siren = ArloSiren(siren_id, self.arlo_device, self.arlo_basestation, self.provider, self)
|
||||
return self.siren
|
||||
|
||||
@async_print_exception_guard
|
||||
async def armSecuritySystem(self, mode: SecuritySystemMode) -> None:
|
||||
self.logger.info(f"Arming {mode}")
|
||||
self.mode = mode
|
||||
self.securitySystemState = {
|
||||
**self.securitySystemState,
|
||||
"mode": mode,
|
||||
}
|
||||
if mode == SecuritySystemMode.Disarmed.value:
|
||||
await self.get_or_create_siren().turnOff()
|
||||
|
||||
@async_print_exception_guard
|
||||
async def disarmSecuritySystem(self) -> None:
|
||||
self.logger.info(f"Disarming")
|
||||
self.mode = SecuritySystemMode.Disarmed.value
|
||||
self.securitySystemState = {
|
||||
**self.securitySystemState,
|
||||
"mode": SecuritySystemMode.Disarmed.value,
|
||||
}
|
||||
await self.get_or_create_siren().turnOff()
|
||||
@@ -1,7 +1,11 @@
|
||||
paho-mqtt==1.6.1
|
||||
sseclient==0.0.22
|
||||
requests
|
||||
scrypted-arlo-go==0.0.1
|
||||
aiohttp==3.8.4
|
||||
requests==2.28.2
|
||||
cachetools==5.3.0
|
||||
scrypted-arlo-go==0.1.3
|
||||
cloudscraper==1.2.71
|
||||
async-timeout==4.0.2
|
||||
--extra-index-url=https://www.piwheels.org/simple/
|
||||
--extra-index-url=https://bjia56.github.io/scrypted-arlo-go/
|
||||
--prefer-binary
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
The C300X Plugin for Scrypted allows viewing your C300X intercom with incoming video/audio.
|
||||
|
||||
WARNING: You will need access to the device, see https://github.com/fquinto/bticinoClasse300x
|
||||
WARNING: You will need access to the device, see https://github.com/fquinto/bticinoClasse300x.
|
||||
|
||||
You also need the **[c300x-controller](https://github.com/slyoldfox/c300x-controller)** and node (v17.9.1) running on your device which will expose an API for the intercom.
|
||||
|
||||
## Development instructions
|
||||
|
||||
@@ -17,12 +19,37 @@ $ num run scrypted-deploy 127.0.0.1
|
||||
|
||||
After flashing a custom firmware you must at least:
|
||||
|
||||
* Install [node](https://nodejs.org/download/release/latest-v17.x/node-v17.9.1-linux-armv7l.tar.gz) on your device and run the c300x-controller on the device
|
||||
* Install [/lib/libatomic.so.1](http://ftp.de.debian.org/debian/pool/main/g/gcc-10-cross/libatomic1-armhf-cross_10.2.1-6cross1_all.deb) in **/lib**
|
||||
* Allow access to the SIP server on port 5060
|
||||
* Allow your IP to authenticated with the SIP server
|
||||
* Add a SIP user for scrypted
|
||||
|
||||
To do this use the guide below:
|
||||
|
||||
## Installing node and c300x-controller
|
||||
|
||||
```
|
||||
$ cd /home/bticino/cfg/extra/
|
||||
$ mkdir node
|
||||
$ cd node
|
||||
$ wget https://nodejs.org/download/release/latest-v17.x/node-v17.9.1-linux-armv7l.tar.gz
|
||||
$ tar xvfz node-v17.9.1-linux-armv7l.tar.gz
|
||||
```
|
||||
|
||||
Node will require libatomic.so.1 which isn't shipped with the device, get the .deb file from http://ftp.de.debian.org/debian/pool/main/g/gcc-10-cross/libatomic1-armhf-cross_10.2.1-6cross1_all.deb
|
||||
|
||||
```
|
||||
$ ar x libatomic1-armhf-cross_10.2.1-6cross1_all.deb
|
||||
```
|
||||
|
||||
scp the `libatomic.so.1` to `/lib` and check that node works:
|
||||
|
||||
```
|
||||
$ root@C3X-00-00-00-00-00--2222222:~# /home/bticino/cfg/extra/node/bin/node -v
|
||||
v17.9.1
|
||||
```
|
||||
|
||||
## Make flexisip listen on a reachable IP and add users to it
|
||||
|
||||
To be able to talk to our own SIP server, we need to make the SIP server on the C300X
|
||||
@@ -93,7 +120,7 @@ hashed-passwords=true
|
||||
reject-wrong-client-certificates=true
|
||||
````
|
||||
|
||||
Now we will add a `user agent` (user) that will be used by `baresip` to register itself with `flexisip`
|
||||
Now we will add a `user agent` (user) that will be used by `scrypted` to register itself with `flexisip`
|
||||
|
||||
Edit the `/etc/flexisip/users/users.db.txt` file and create a new line by copy/pasting the c300x user.
|
||||
|
||||
@@ -101,7 +128,7 @@ For example:
|
||||
|
||||
````
|
||||
c300x@1234567.bs.iotleg.com md5:ffffffffffffffffffffffffffffffff ;
|
||||
baresip@1234567.bs.iotleg.com md5:ffffffffffffffffffffffffffffffff ;
|
||||
scrypted@1234567.bs.iotleg.com md5:ffffffffffffffffffffffffffffffff ;
|
||||
````
|
||||
|
||||
Leave the md5 as the same value - I use `fffff....` just for this example.
|
||||
@@ -110,7 +137,7 @@ Edit the `/etc/flexisip/users/route.conf` file and add a new line to it, it spec
|
||||
Change the IP address to the place where you will run `baresip` (same as `trusted-hosts` above)
|
||||
|
||||
````
|
||||
<sip:baresip@1234567.bs.iotleg.com> <sip:192.168.0.XX>
|
||||
<sip:scrypted@1234567.bs.iotleg.com> <sip:192.168.0.XX>
|
||||
````
|
||||
|
||||
Edit the `/etc/flexisip/users/route_int.conf` file.
|
||||
@@ -121,7 +148,7 @@ You can look at it as a group of users that is called when you call `alluser@123
|
||||
|
||||
Add your username at the end (make sure you stay on the same line, NOT a new line!)
|
||||
````
|
||||
<sip:alluser@1234567.bs.iotleg.com> ..., <sip:baresip@1234567.bs.iotleg.com>
|
||||
<sip:alluser@1234567.bs.iotleg.com> ..., <sip:scrypted@1234567.bs.iotleg.com>
|
||||
````
|
||||
|
||||
Reboot and verify flexisip is listening on the new IP address.
|
||||
|
||||
4680
plugins/bticino/package-lock.json
generated
4680
plugins/bticino/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,49 @@
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.5",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
|
||||
"prescrypted-vscode-launch": "scrypted-webpack",
|
||||
"scrypted-vscode-launch": "scrypted-deploy-debug",
|
||||
"scrypted-deploy-debug": "scrypted-deploy-debug",
|
||||
"scrypted-debug": "scrypted-debug",
|
||||
"scrypted-deploy": "scrypted-deploy",
|
||||
"scrypted-readme": "scrypted-readme",
|
||||
"scrypted-package-json": "scrypted-package-json"
|
||||
},
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
"plugin",
|
||||
"sip"
|
||||
],
|
||||
"scrypted": {
|
||||
"name": "BTicino SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@homebridge/camera-utils": "^2.0.4",
|
||||
"rxjs": "^7.5.5",
|
||||
"sdp": "^3.0.3",
|
||||
"sip": "0.0.6",
|
||||
"stun": "^2.1.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.9",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
"build": "scrypted-webpack",
|
||||
"prepublishOnly": "cross-env NODE_ENV=production scrypted-webpack",
|
||||
"prescrypted-vscode-launch": "scrypted-webpack",
|
||||
"scrypted-vscode-launch": "scrypted-deploy-debug",
|
||||
"scrypted-deploy-debug": "scrypted-deploy-debug",
|
||||
"scrypted-debug": "scrypted-debug",
|
||||
"scrypted-deploy": "scrypted-deploy",
|
||||
"scrypted-readme": "scrypted-readme",
|
||||
"scrypted-package-json": "scrypted-package-json"
|
||||
},
|
||||
"keywords": [
|
||||
"scrypted",
|
||||
"plugin",
|
||||
"sip"
|
||||
],
|
||||
"scrypted": {
|
||||
"name": "BTicino SIP Plugin",
|
||||
"type": "DeviceProvider",
|
||||
"interfaces": [
|
||||
"DeviceProvider",
|
||||
"DeviceCreator"
|
||||
],
|
||||
"pluginDependencies": [
|
||||
"@scrypted/prebuffer-mixin",
|
||||
"@scrypted/pam-diff",
|
||||
"@scrypted/snapshot"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@slyoldfox/sip": "^0.0.6-1",
|
||||
"sdp": "^3.0.3",
|
||||
"stun": "^2.1.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
"@scrypted/sdk": "file:../../sdk",
|
||||
"@types/node": "^16.9.6",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
421
plugins/bticino/src/bticino-camera.ts
Normal file
421
plugins/bticino/src/bticino-camera.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { closeQuiet, createBindZero, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { sleep } from '@scrypted/common/src/sleep';
|
||||
import { RtspServer } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls } from '@scrypted/common/src/sdp-utils';
|
||||
import sdk, { BinarySensor, Camera, DeviceProvider, FFmpegInput, HttpRequest, HttpRequestHandler, HttpResponse, Intercom, MediaObject, MediaStreamUrl, PictureOptions, Reboot, ResponseMediaStreamOptions, ScryptedDevice, ScryptedDeviceBase, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoClip, VideoClipOptions, VideoClips } from '@scrypted/sdk';
|
||||
import { SipCallSession } from '../../sip/src/sip-call-session';
|
||||
import { RtpDescription } from '../../sip/src/rtp-utils';
|
||||
import { VoicemailHandler } from './bticino-voicemailHandler';
|
||||
import { CompositeSipMessageHandler } from '../../sip/src/compositeSipMessageHandler';
|
||||
import { SipHelper } from './sip-helper';
|
||||
import child_process, { ChildProcess } from 'child_process';
|
||||
import dgram from 'dgram';
|
||||
import { BticinoStorageSettings } from './storage-settings';
|
||||
import { BticinoSipPlugin } from './main';
|
||||
import { BticinoSipLock } from './bticino-lock';
|
||||
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from '@scrypted/common/src/media-helpers';
|
||||
import { PersistentSipManager } from './persistent-sip-manager';
|
||||
import { InviteHandler } from './bticino-inviteHandler';
|
||||
import { SipRequest } from '../../sip/src/sip-manager';
|
||||
|
||||
import { get } from 'http'
|
||||
import { ControllerApi } from './c300x-controller-api';
|
||||
|
||||
const STREAM_TIMEOUT = 65000;
|
||||
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 audioOutForwarder: dgram.Socket
|
||||
private audioOutProcess: ChildProcess
|
||||
private currentMedia: FFmpegInput | MediaStreamUrl
|
||||
private currentMediaMimeType: string
|
||||
private refreshTimeout: NodeJS.Timeout
|
||||
public requestHandlers: CompositeSipMessageHandler = new CompositeSipMessageHandler()
|
||||
public incomingCallRequest : SipRequest
|
||||
private settingsStorage: BticinoStorageSettings = new BticinoStorageSettings( this )
|
||||
private voicemailHandler : VoicemailHandler = new VoicemailHandler(this)
|
||||
private inviteHandler : InviteHandler = new InviteHandler(this)
|
||||
private controllerApi : ControllerApi = new ControllerApi(this)
|
||||
//TODO: randomize this
|
||||
private keyAndSalt : string = "/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X"
|
||||
//private decodedSrtpOptions : SrtpOptions = decodeSrtpOptions( this.keyAndSalt )
|
||||
private persistentSipManager : PersistentSipManager
|
||||
public doorbellWebhookUrl : string
|
||||
public doorbellLockWebhookUrl : string
|
||||
|
||||
constructor(nativeId: string, public provider: BticinoSipPlugin) {
|
||||
super(nativeId)
|
||||
|
||||
this.requestHandlers.add( this.voicemailHandler ).add( this.inviteHandler )
|
||||
this.persistentSipManager = new PersistentSipManager( this );
|
||||
(async() => {
|
||||
this.doorbellWebhookUrl = await this.doorbellWebhookEndpoint()
|
||||
this.doorbellLockWebhookUrl = await this.doorbellLockWebhookEndpoint()
|
||||
})();
|
||||
}
|
||||
|
||||
reboot(): Promise<void> {
|
||||
return new Promise<void>( (resolve,reject ) => {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
|
||||
get(`http://${c300x}:8080/reboot?now`, (res) => {
|
||||
console.log("Reboot API result: " + res.statusCode)
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
|
||||
return new Promise<VideoClip[]>( (resolve,reject ) => {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
if( !c300x ) return []
|
||||
get(`http://${c300x}:8080/videoclips?raw=true&startTime=${options.startTime/1000}&endTime=${options.endTime/1000}`, (res) => {
|
||||
let rawData = '';
|
||||
res.on('data', (chunk) => { rawData += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsedData : [] = JSON.parse(rawData);
|
||||
let videoClips : VideoClip[] = []
|
||||
parsedData.forEach( (item) => {
|
||||
let videoClip : VideoClip = {
|
||||
id: item['file'],
|
||||
startTime: parseInt(item['info']['UnixTime']) * 1000,
|
||||
duration: item['info']['Duration'] * 1000,
|
||||
//description: item['info']['Date'],
|
||||
thumbnailId: item['file']
|
||||
|
||||
}
|
||||
videoClips.push( videoClip )
|
||||
} )
|
||||
return resolve(videoClips)
|
||||
} catch (e) {
|
||||
reject(e.message)
|
||||
console.error(e.message);
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getVideoClip(videoId: string): Promise<MediaObject> {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
const url = `http://${c300x}:8080/voicemail?msg=${videoId}/aswm.avi&raw=true`;
|
||||
return mediaManager.createMediaObjectFromUrl(url);
|
||||
}
|
||||
getVideoClipThumbnail(thumbnailId: string): Promise<MediaObject> {
|
||||
let c300x = SipHelper.getIntercomIp(this)
|
||||
const url = `http://${c300x}:8080/voicemail?msg=${thumbnailId}/aswm.jpg&raw=true`;
|
||||
return mediaManager.createMediaObjectFromUrl(url);
|
||||
}
|
||||
|
||||
removeVideoClips(...videoClipIds: string[]): Promise<void> {
|
||||
//TODO
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
sipUnlock(): Promise<void> {
|
||||
this.log.i("unlocking C300X door ")
|
||||
return this.persistentSipManager.enable().then( (sipCall) => {
|
||||
sipCall.message( '*8*19*20##' )
|
||||
.then( () =>
|
||||
sleep(1000)
|
||||
.then( () => sipCall.message( '*8*20*20##' ) )
|
||||
)
|
||||
} )
|
||||
}
|
||||
|
||||
getAswmStatus() : Promise<void> {
|
||||
return this.persistentSipManager.enable().then( (sipCall) => {
|
||||
sipCall.message( "GetAswmStatus!" )
|
||||
} )
|
||||
}
|
||||
|
||||
async takePicture(option?: PictureOptions): Promise<MediaObject> {
|
||||
throw new Error("The SIP doorbell camera does not provide snapshots. Install the Snapshot Plugin if snapshots are available via an URL.");
|
||||
}
|
||||
|
||||
async getPictureOptions(): Promise<PictureOptions[]> {
|
||||
return
|
||||
}
|
||||
|
||||
getSettings(): Promise<Setting[]> {
|
||||
return this.settingsStorage.getSettings()
|
||||
}
|
||||
|
||||
putSetting(key: string, value: SettingValue): Promise<void> {
|
||||
return this.settingsStorage.putSetting(key, value)
|
||||
}
|
||||
|
||||
async startIntercom(media: MediaObject): Promise<void> {
|
||||
if (!this.session)
|
||||
throw new Error("not in call");
|
||||
|
||||
this.stopIntercom();
|
||||
|
||||
const ffmpegInput: FFmpegInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput)).toString());
|
||||
|
||||
const audioOutForwarder = await createBindZero()
|
||||
this.audioOutForwarder = audioOutForwarder.server
|
||||
audioOutForwarder.server.on('message', message => {
|
||||
if( this.session )
|
||||
this.session.audioSplitter.send(message, 40004, this.remoteRtpDescription.address)
|
||||
return null
|
||||
});
|
||||
|
||||
const args = ffmpegInput.inputArguments.slice();
|
||||
args.push(
|
||||
'-vn', '-dn', '-sn',
|
||||
'-acodec', 'speex',
|
||||
'-flags', '+global_header',
|
||||
'-ac', '1',
|
||||
'-ar', '8k',
|
||||
'-f', 'rtp',
|
||||
//'-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80',
|
||||
//'-srtp_out_params', encodeSrtpOptions(this.decodedSrtpOptions),
|
||||
`rtp://127.0.0.1:${audioOutForwarder.port}?pkt_size=188`,
|
||||
);
|
||||
|
||||
this.console.log("===========================================")
|
||||
safePrintFFmpegArguments( this.console, args )
|
||||
this.console.log("===========================================")
|
||||
|
||||
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), args);
|
||||
ffmpegLogInitialOutput(this.console, cp)
|
||||
this.audioOutProcess = cp;
|
||||
cp.on('exit', () => this.console.log('two way audio ended'));
|
||||
this.session.onCallEnded.subscribe(() => {
|
||||
closeQuiet(audioOutForwarder.server);
|
||||
safeKillFFmpeg(cp)
|
||||
});
|
||||
}
|
||||
|
||||
async stopIntercom(): Promise<void> {
|
||||
closeQuiet(this.audioOutForwarder)
|
||||
this.audioOutProcess?.kill('SIGKILL')
|
||||
this.audioOutProcess = undefined
|
||||
this.audioOutForwarder = undefined
|
||||
}
|
||||
|
||||
resetStreamTimeout() {
|
||||
this.log.d('starting/refreshing stream')
|
||||
clearTimeout(this.refreshTimeout)
|
||||
this.refreshTimeout = setTimeout(() => this.stopSession(), STREAM_TIMEOUT)
|
||||
}
|
||||
|
||||
hasActiveCall() {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
if (this.session) {
|
||||
this.log.d('ending sip session')
|
||||
this.session.stop()
|
||||
this.session = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
|
||||
if( !SipHelper.sipOptions( this ) ) {
|
||||
// Bail out fast when no options are set and someone enables prebuffering
|
||||
throw new Error('Please configure from/to/domain settings')
|
||||
}
|
||||
|
||||
if (options?.metadata?.refreshAt) {
|
||||
if (!this.currentMedia?.mediaStreamOptions)
|
||||
throw new Error("no stream to refresh");
|
||||
|
||||
const currentMedia = this.currentMedia
|
||||
currentMedia.mediaStreamOptions.refreshAt = Date.now() + STREAM_TIMEOUT;
|
||||
currentMedia.mediaStreamOptions.metadata = {
|
||||
refreshAt: currentMedia.mediaStreamOptions.refreshAt
|
||||
};
|
||||
this.resetStreamTimeout()
|
||||
return mediaManager.createMediaObject(currentMedia, this.currentMediaMimeType)
|
||||
}
|
||||
|
||||
this.stopSession();
|
||||
const { clientPromise: playbackPromise, port: playbackPort, url: clientUrl } = await listenZeroSingleClient()
|
||||
|
||||
const playbackUrl = clientUrl
|
||||
|
||||
playbackPromise.then(async (client) => {
|
||||
client.setKeepAlive(true, 10000)
|
||||
let sip: SipCallSession
|
||||
try {
|
||||
await this.controllerApi.updateStreamEndpoint()
|
||||
let rtsp: RtspServer;
|
||||
const cleanup = () => {
|
||||
client.destroy();
|
||||
if (this.session === sip)
|
||||
this.session = undefined
|
||||
try {
|
||||
this.log.d('cleanup(): stopping sip session.')
|
||||
sip.stop()
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
rtsp?.destroy()
|
||||
}
|
||||
|
||||
client.on('close', cleanup)
|
||||
client.on('error', cleanup)
|
||||
|
||||
let sipOptions = SipHelper.sipOptions( this )
|
||||
|
||||
sip = await this.persistentSipManager.session( sipOptions );
|
||||
// Validate this sooner
|
||||
if( !sip ) return Promise.reject("Cannot create session")
|
||||
|
||||
sip.onCallEnded.subscribe(cleanup)
|
||||
|
||||
// Call the C300X
|
||||
this.remoteRtpDescription = await sip.callOrAcceptInvite(
|
||||
( audio ) => {
|
||||
return [
|
||||
//TODO: Payload types are hardcoded
|
||||
`m=audio 65000 RTP/SAVP 110`,
|
||||
`a=rtpmap:110 speex/8000`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${this.keyAndSalt}`,
|
||||
]
|
||||
}, ( video ) => {
|
||||
if( false ) {
|
||||
//TODO: implement later
|
||||
return [
|
||||
`m=video 0 RTP/SAVP 0`
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
//TODO: Payload types are hardcoded
|
||||
`m=video 65002 RTP/SAVP 96`,
|
||||
`a=rtpmap:96 H264/90000`,
|
||||
`a=fmtp:96 profile-level-id=42801F`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${this.keyAndSalt}`,
|
||||
'a=recvonly'
|
||||
]
|
||||
}
|
||||
}, this.incomingCallRequest );
|
||||
|
||||
this.incomingCallRequest = undefined
|
||||
|
||||
//let sdp: string = replacePorts(this.remoteRtpDescription.sdp, 0, 0 )
|
||||
let sdp : string = [
|
||||
"v=0",
|
||||
"m=audio 5000 RTP/AVP 110",
|
||||
"c=IN IP4 127.0.0.1",
|
||||
"a=rtpmap:110 speex/8000/1",
|
||||
"m=video 5002 RTP/AVP 96",
|
||||
"c=IN IP4 127.0.0.1",
|
||||
"a=rtpmap:96 H264/90000",
|
||||
].join('\r\n')
|
||||
//sdp = sdp.replaceAll(/a=crypto\:1.*/g, '')
|
||||
//sdp = sdp.replaceAll(/RTP\/SAVP/g, 'RTP\/AVP')
|
||||
//sdp = sdp.replaceAll('\r\n\r\n', '\r\n')
|
||||
sdp = addTrackControls(sdp)
|
||||
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n')
|
||||
if( sipOptions.debugSip )
|
||||
this.log.d('SIP: Updated SDP:\n' + sdp);
|
||||
|
||||
client.write(sdp)
|
||||
client.end()
|
||||
|
||||
this.session = sip
|
||||
}
|
||||
catch (e) {
|
||||
this.console.error(e)
|
||||
sip?.stop()
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
this.resetStreamTimeout();
|
||||
|
||||
const mediaStreamOptions = Object.assign(this.getSipMediaStreamOptions(), {
|
||||
refreshAt: Date.now() + STREAM_TIMEOUT,
|
||||
});
|
||||
|
||||
const ffmpegInput: FFmpegInput = {
|
||||
url: undefined,
|
||||
container: 'sdp',
|
||||
mediaStreamOptions,
|
||||
inputArguments: [
|
||||
'-f', 'sdp',
|
||||
'-i', playbackUrl,
|
||||
],
|
||||
};
|
||||
this.currentMedia = ffmpegInput;
|
||||
this.currentMediaMimeType = ScryptedMimeTypes.FFmpegInput;
|
||||
|
||||
return mediaManager.createFFmpegMediaObject(ffmpegInput);
|
||||
}
|
||||
|
||||
getSipMediaStreamOptions(): ResponseMediaStreamOptions {
|
||||
return {
|
||||
id: 'sip',
|
||||
name: 'SIP',
|
||||
// this stream is NOT scrypted blessed due to wackiness in the h264 stream.
|
||||
// tool: "scrypted",
|
||||
container: 'sdp',
|
||||
audio: {
|
||||
// this is a hint to let homekit, et al, know that it's speex audio and needs transcoding.
|
||||
codec: 'speex',
|
||||
},
|
||||
source: 'cloud', // to disable prebuffering
|
||||
userConfigurable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
||||
return [
|
||||
this.getSipMediaStreamOptions(),
|
||||
]
|
||||
}
|
||||
|
||||
async getDevice(nativeId: string) : Promise<BticinoSipLock> {
|
||||
return new BticinoSipLock(this)
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
||||
this.voicemailHandler.cancelTimer()
|
||||
this.persistentSipManager.cancelTimer()
|
||||
this.controllerApi.cancelTimer()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.console.log("Reset the incoming call request")
|
||||
this.incomingCallRequest = undefined
|
||||
this.binaryState = false
|
||||
}
|
||||
|
||||
public async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
||||
if (request.url.endsWith('/pressed')) {
|
||||
this.binaryState = true
|
||||
setTimeout( () => {
|
||||
// Assumption that flexisip only holds this call active for 20 seconds ... might be revised
|
||||
this.reset()
|
||||
}, 20 * 1000 )
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else {
|
||||
response.send('Unsupported operation', {
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async doorbellWebhookEndpoint(): Promise<string> {
|
||||
let webhookUrl = await sdk.endpointManager.getLocalEndpoint( this.nativeId, { insecure: false, public: true });
|
||||
let endpoints = ["/pressed"]
|
||||
this.console.log( webhookUrl + " , endpoints: " + endpoints.join(' - ') )
|
||||
return `${webhookUrl}`;
|
||||
}
|
||||
|
||||
private async doorbellLockWebhookEndpoint(): Promise<string> {
|
||||
let webhookUrl = await sdk.endpointManager.getLocalEndpoint(this.nativeId + '-lock', { insecure: false, public: true });
|
||||
let endpoints = ["/lock", "/unlock", "/unlocked", "/locked"]
|
||||
this.console.log( webhookUrl + " -> endpoints: " + endpoints.join(' - ') )
|
||||
return `${webhookUrl}`;
|
||||
}
|
||||
}
|
||||
32
plugins/bticino/src/bticino-inviteHandler.ts
Normal file
32
plugins/bticino/src/bticino-inviteHandler.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SipRequestHandler, SipRequest } from "../../sip/src/sip-manager"
|
||||
import { BticinoSipCamera } from "./bticino-camera"
|
||||
import { stringifyUri } from '@slyoldfox/sip'
|
||||
|
||||
export class InviteHandler extends SipRequestHandler {
|
||||
constructor( private sipCamera : BticinoSipCamera ) {
|
||||
super()
|
||||
this.sipCamera.binaryState = false
|
||||
}
|
||||
|
||||
handle(request: SipRequest) {
|
||||
//TODO: restrict this to call from:c300x@ AND to:alluser@ ?
|
||||
if( request.method == 'CANCEL' ) {
|
||||
let reason = request.headers["reason"] ? ( ' - ' + request.headers["reason"] ) : ''
|
||||
this.sipCamera.console.log('CANCEL voice call from: ' + stringifyUri( request.headers.from.uri ) + ' to: ' + stringifyUri( request.headers.to.uri ) + reason )
|
||||
this.sipCamera?.reset()
|
||||
}
|
||||
if( request.method === 'INVITE' ) {
|
||||
this.sipCamera.console.log("INCOMING voice call from: " + stringifyUri( request.headers.from.uri ) + ' to: ' + stringifyUri( request.headers.to.uri ) )
|
||||
|
||||
this.sipCamera.binaryState = true
|
||||
this.sipCamera.incomingCallRequest = request
|
||||
|
||||
setTimeout( () => {
|
||||
// Assumption that flexisip only holds this call active for 20 seconds ... might be revised
|
||||
this.sipCamera?.reset()
|
||||
}, 20 * 1000 )
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
56
plugins/bticino/src/bticino-lock.ts
Normal file
56
plugins/bticino/src/bticino-lock.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import sdk, { ScryptedDeviceBase, Lock, LockState, HttpRequest, HttpResponse, HttpRequestHandler } from "@scrypted/sdk";
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
|
||||
export class BticinoSipLock extends ScryptedDeviceBase implements Lock, HttpRequestHandler {
|
||||
private timeout : NodeJS.Timeout
|
||||
|
||||
constructor(public camera: BticinoSipCamera) {
|
||||
super( camera.nativeId + "-lock")
|
||||
}
|
||||
|
||||
lock(): Promise<void> {
|
||||
if( !this.timeout ) {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.lockState = LockState.Locked
|
||||
this.timeout = undefined
|
||||
} , 3000);
|
||||
} else {
|
||||
this.camera.console.log("Still attempting previous locking ...")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
unlock(): Promise<void> {
|
||||
this.lockState = LockState.Unlocked
|
||||
this.lock()
|
||||
return this.camera.sipUnlock()
|
||||
}
|
||||
|
||||
public async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
||||
if (request.url.endsWith('/unlocked')) {
|
||||
this.lockState = LockState.Unlocked
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/locked') ) {
|
||||
this.lockState = LockState.Locked
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/lock') ) {
|
||||
this.lock();
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else if( request.url.endsWith('/unlock') ) {
|
||||
this.unlock();
|
||||
response.send('Success', {
|
||||
code: 200,
|
||||
});
|
||||
} else {
|
||||
response.send('Unsupported operation', {
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
69
plugins/bticino/src/bticino-voicemailHandler.ts
Normal file
69
plugins/bticino/src/bticino-voicemailHandler.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { SipRequestHandler, SipRequest } from "../../sip/src/sip-manager"
|
||||
import { BticinoSipCamera } from "./bticino-camera"
|
||||
|
||||
export class VoicemailHandler extends SipRequestHandler {
|
||||
private timeout : NodeJS.Timeout
|
||||
|
||||
constructor( private sipCamera : BticinoSipCamera ) {
|
||||
super()
|
||||
this.timeout = setTimeout( () => {
|
||||
// Delay a bit an run in a different thread in case this fails
|
||||
this.checkVoicemail()
|
||||
}, 10000 )
|
||||
}
|
||||
|
||||
checkVoicemail() {
|
||||
if( !this.sipCamera )
|
||||
return
|
||||
if( this.isEnabled() ) {
|
||||
this.sipCamera.console.debug("Checking answering machine, cameraId: " + this.sipCamera.id )
|
||||
this.sipCamera.getAswmStatus().catch( e => this.sipCamera.console.error(e) )
|
||||
} else {
|
||||
this.sipCamera.console.debug("Answering machine check not enabled, cameraId: " + this.sipCamera.id )
|
||||
}
|
||||
//TODO: make interval customizable, now every 5 minutes
|
||||
this.timeout = setTimeout( () => this.checkVoicemail() , 5 * 60 * 1000 )
|
||||
}
|
||||
|
||||
cancelTimer() {
|
||||
if( this.timeout ) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
handle(request: SipRequest) {
|
||||
if( this.isEnabled() ) {
|
||||
const lastVoicemailMessageTimestamp : number = Number.parseInt( this.sipCamera.storage.getItem('lastVoicemailMessageTimestamp') ) || -1
|
||||
const message : string = request.content.toString()
|
||||
if( message.startsWith('*#8**40*0*0*1176*0*2##') ) {
|
||||
this.sipCamera.console.debug("Handling incoming answering machine reply")
|
||||
const messages : string[] = message.split(';')
|
||||
let lastMessageTimestamp : number = 0
|
||||
let countNewMessages : number = 0
|
||||
messages.forEach( (message, index) => {
|
||||
if( index > 0 ) {
|
||||
const parts = message.split('|')
|
||||
if( parts.length == 4 ) {
|
||||
let messageTimestamp = Number.parseInt( parts[2] )
|
||||
if( messageTimestamp > lastVoicemailMessageTimestamp )
|
||||
countNewMessages++
|
||||
if( index == messages.length-2 )
|
||||
lastMessageTimestamp = messageTimestamp
|
||||
}
|
||||
}
|
||||
} )
|
||||
if( (lastVoicemailMessageTimestamp == null && lastMessageTimestamp > 0) ||
|
||||
( lastVoicemailMessageTimestamp != null && lastMessageTimestamp > lastVoicemailMessageTimestamp ) ) {
|
||||
this.sipCamera.log.a(`You have ${countNewMessages} new voicemail messages.`)
|
||||
this.sipCamera.storage.setItem('lastVoicemailMessageTimestamp', lastMessageTimestamp.toString())
|
||||
} else {
|
||||
this.sipCamera.console.debug("No new messages since: " + lastVoicemailMessageTimestamp + " lastMessage: " + lastMessageTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() : boolean {
|
||||
return this.sipCamera?.storage?.getItem('notifyVoicemail')?.toLocaleLowerCase() === 'true' || false
|
||||
}
|
||||
}
|
||||
125
plugins/bticino/src/c300x-controller-api.ts
Normal file
125
plugins/bticino/src/c300x-controller-api.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as nodeIp from "ip";
|
||||
import { get } from 'http'
|
||||
import * as net from 'net'
|
||||
import { BticinoSipCamera } from "./bticino-camera";
|
||||
import { SipHelper } from './sip-helper';
|
||||
|
||||
export class ControllerApi {
|
||||
private timeout : NodeJS.Timeout
|
||||
|
||||
constructor( private sipCamera : BticinoSipCamera ) {
|
||||
this.timeout = setTimeout( () => {
|
||||
// Delay a bit an run in a different thread in case this fails
|
||||
this.registerEndpoints( true )
|
||||
}, 5000 )
|
||||
}
|
||||
|
||||
/**
|
||||
* Will validate certain requirements for scrypted to work correctly with the intercom:
|
||||
*/
|
||||
public static validate( ipAddress ) {
|
||||
return this.validateFlexisipSipPort(ipAddress).then( this.validateController )
|
||||
}
|
||||
|
||||
/**
|
||||
* Will validate if the non secure SIP port was opened after modifying /etc/init.d/flexisipsh
|
||||
*/
|
||||
private static validateFlexisipSipPort( ipAddress : string ) : Promise<string> {
|
||||
let conn = net.createConnection( { host: ipAddress, port: 5060, timeout: 5000 } )
|
||||
return new Promise( (resolve, reject) => {
|
||||
conn.setTimeout(5000);
|
||||
conn.on('connect', () => resolve( ipAddress ));
|
||||
conn.on('timeout', () => reject( new Error("Timeout connecting to port 5060, is this a Bticino intercom? Did you change /etc/init.d/flexisipsh to make it listen on this port?") ) );
|
||||
conn.on('error', () => reject( new Error("Error connecting to port 5060, is this a Bticino intercom? Did you change /etc/init.d/flexisipsh to make it listen on this port?") ) );
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Will validate if the c300x-controller is running on port 8080.
|
||||
* The c300x-controller will return errors if some configuration errors are present on the intercom.
|
||||
*/
|
||||
private static validateController( ipAddress : string ) : Promise<void> {
|
||||
// Will throw an exception if invalid format
|
||||
const c300x = nodeIp.toBuffer( ipAddress )
|
||||
const validatedIp = nodeIp.toString(c300x)
|
||||
|
||||
const url = `http://${validatedIp}:8080/validate-setup?raw=true`
|
||||
|
||||
return new Promise( (resolve, reject) => get(url, (res) => {
|
||||
let body = "";
|
||||
res.on("data", data => { body += data });
|
||||
res.on("end", () => {
|
||||
try {
|
||||
let parsedBody = JSON.parse( body )
|
||||
if( parsedBody["errors"].length > 0 ) {
|
||||
reject( new Error( parsedBody["errors"][0] ) )
|
||||
} else {
|
||||
parsedBody["ipAddress"] = validatedIp
|
||||
resolve( parsedBody )
|
||||
}
|
||||
} catch( e ) {
|
||||
reject( e )
|
||||
}
|
||||
})
|
||||
res.on("error", (e) => { reject(e)})
|
||||
if( res.statusCode != 200 ) {
|
||||
reject( new Error(`Could not validate required c300x-controller. Check ${url}`) )
|
||||
}
|
||||
} ).on("error", (e) => { reject(`Could not connect to the c300x-controller at ${url}`) }) )
|
||||
}
|
||||
|
||||
/**
|
||||
* This verifies if the intercom is customized correctly. It verifies:
|
||||
*
|
||||
* - if a dedicated scrypted sip user is added for this specific camera instance in /etc/flexisip/users/users.db.txt
|
||||
* - if this dedicated scrypted sip user is configured in /etc/flexisip/users/route.conf and /etc/flexisip/users/route_int.conf
|
||||
*/
|
||||
public registerEndpoints( verifyUser : boolean ) {
|
||||
let ipAddress = SipHelper.getIntercomIp(this.sipCamera)
|
||||
let sipFrom = SipHelper.getIdentifier(this.sipCamera)
|
||||
const pressed = Buffer.from(this.sipCamera.doorbellWebhookUrl + 'pressed').toString('base64')
|
||||
const locked = Buffer.from(this.sipCamera.doorbellLockWebhookUrl + 'locked').toString('base64')
|
||||
const unlocked = Buffer.from(this.sipCamera.doorbellLockWebhookUrl + 'unlocked').toString('base64')
|
||||
get(`http://${ipAddress}:8080/register-endpoint?raw=true&identifier=${sipFrom}&pressed=${pressed}&locked=${locked}&unlocked=${unlocked}&verifyUser=${verifyUser}`, (res) => {
|
||||
if( verifyUser ) {
|
||||
let body = "";
|
||||
res.on("data", data => { body += data });
|
||||
res.on("end", () => {
|
||||
try {
|
||||
let parsedBody = JSON.parse( body )
|
||||
if( parsedBody["errors"].length > 0 ) {
|
||||
this.sipCamera.log.a("This camera is not setup correctly, it will not be able to receive the incoming doorbell stream. Check the console for the errors.")
|
||||
parsedBody["errors"].forEach( error => {
|
||||
this.sipCamera.console.error( "ERROR: " + error )
|
||||
});
|
||||
}
|
||||
} catch( e ) {
|
||||
this.sipCamera.console.error("Error parsing body to JSON: " + body )
|
||||
}
|
||||
})
|
||||
}
|
||||
console.log("Endpoint registration status: " + res.statusCode)
|
||||
});
|
||||
|
||||
// The default evict time on the c300x-controller is 5 minutes, so this will certainly be within bounds
|
||||
this.timeout = setTimeout( () => this.registerEndpoints( false ) , 2 * 60 * 1000 )
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs the c300x-controller where to send the stream to
|
||||
*/
|
||||
public updateStreamEndpoint() : Promise<void> {
|
||||
let ipAddress = SipHelper.getIntercomIp(this.sipCamera)
|
||||
let sipFrom = SipHelper.getIdentifier(this.sipCamera)
|
||||
return new Promise( (resolve, reject) => get(`http://${ipAddress}:8080/register-endpoint?raw=true&updateStreamEndpoint=${sipFrom}`, (res) => {
|
||||
if( res.statusCode != 200 ) reject( "ERROR: Could not update streaming endpoint, call returned: " + res.statusCode )
|
||||
else resolve()
|
||||
} ) );
|
||||
}
|
||||
|
||||
public cancelTimer() {
|
||||
if( this.timeout ) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user