Compare commits
696
Commits
v0.4.3
...
v0.16.0-rc9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8db8eb3b6 | ||
|
|
a34b6dec67 | ||
|
|
bded3eb156 | ||
|
|
9ca73934ce | ||
|
|
e02b429523 | ||
|
|
22e4b7dabe | ||
|
|
130b5acf84 | ||
|
|
d0fa303c88 | ||
|
|
dff0746450 | ||
|
|
815dfd9a8e | ||
|
|
b0ea0566a5 | ||
|
|
0167900415 | ||
|
|
9279ba2aa9 | ||
|
|
2abfc3e843 | ||
|
|
4ff83585b4 | ||
|
|
3cf2b5e093 | ||
|
|
6a7eff6501 | ||
|
|
3805a41d9f | ||
|
|
f8c78fb499 | ||
|
|
3314ff85ed | ||
|
|
67b24eb89a | ||
|
|
d416e42925 | ||
|
|
17655849ae | ||
|
|
d9e3761c7b | ||
|
|
382db80584 | ||
|
|
695dc96340 | ||
|
|
8b1851b647 | ||
|
|
eeaa9cdf6f | ||
|
|
5cd59c049e | ||
|
|
5d86e0ac9a | ||
|
|
d9136e611f | ||
|
|
5ca1aca3c0 | ||
|
|
08632ec9ce | ||
|
|
45ba46e637 | ||
|
|
e51740ca89 | ||
|
|
eb7d7d9601 | ||
|
|
c4b70f963c | ||
|
|
b73d122b15 | ||
|
|
db072b9dea | ||
|
|
5c56c903e8 | ||
|
|
0758fa66d3 | ||
|
|
6edb89a606 | ||
|
|
7f15827791 | ||
|
|
93d498aafb | ||
|
|
b8da6e2197 | ||
|
|
4f00aac7a2 | ||
|
|
7dd32a0dad | ||
|
|
eb8f0f6437 | ||
|
|
8fcd579a69 | ||
|
|
6479423c6c | ||
|
|
be1a5bf92f | ||
|
|
2788ab58ea | ||
|
|
31de65f155 | ||
|
|
fd2e4ed9a4 | ||
|
|
b029c32744 | ||
|
|
df88be56e9 | ||
|
|
816f09b002 | ||
|
|
507f350ec2 | ||
|
|
7067c05c6b | ||
|
|
338a10c8a2 | ||
|
|
44beddac18 | ||
|
|
534b5ad6e0 | ||
|
|
04ad77ad42 | ||
|
|
476042a6e6 | ||
|
|
d40787499e | ||
|
|
9e53ebfb05 | ||
|
|
0455210598 | ||
|
|
4d66d8e0c4 | ||
|
|
020b6d9855 | ||
|
|
1d9efabfd6 | ||
|
|
b8fc4934e2 | ||
|
|
37743f78d5 | ||
|
|
27cddaac70 | ||
|
|
9d828a254c | ||
|
|
8bc4544eeb | ||
|
|
cec801c39c | ||
|
|
2abf195391 | ||
|
|
e6469aea5f | ||
|
|
470ab17813 | ||
|
|
42e444c0e0 | ||
|
|
2baae9ea45 | ||
|
|
789724a6a3 | ||
|
|
e53439141a | ||
|
|
d5ec39113e | ||
|
|
58117b3daf | ||
|
|
558c190512 | ||
|
|
9e124ce5cc | ||
|
|
396824d38c | ||
|
|
cc860c3d03 | ||
|
|
6a5256e9b1 | ||
|
|
00c2c2cc29 | ||
|
|
4775bda51c | ||
|
|
38fed26742 | ||
|
|
99235fbc80 | ||
|
|
feef686767 | ||
|
|
1e00f657a7 | ||
|
|
672fca6dbf | ||
|
|
bc85779272 | ||
|
|
669fb008d1 | ||
|
|
7281241edd | ||
|
|
9394964688 | ||
|
|
d8c80c24fc | ||
|
|
12f7e65d0a | ||
|
|
d31cb60a17 | ||
|
|
1b8bae3d25 | ||
|
|
598b2e4235 | ||
|
|
569249a6f4 | ||
|
|
754d441111 | ||
|
|
4ca9cc21fe | ||
|
|
2fc3e735c5 | ||
|
|
80d886bfb1 | ||
|
|
359d6930b4 | ||
|
|
8fe67ea574 | ||
|
|
ca60d99e90 | ||
|
|
f0445683ca | ||
|
|
61e18ee0a8 | ||
|
|
9cb55dff66 | ||
|
|
f7bc4c7f93 | ||
|
|
7fc63e5f23 | ||
|
|
af279b337d | ||
|
|
384c2f372b | ||
|
|
6abb14ceeb | ||
|
|
6a0be737d3 | ||
|
|
611a167c30 | ||
|
|
0f9301742a | ||
|
|
baa2b8cd49 | ||
|
|
30681cd243 | ||
|
|
b5c9a51e9c | ||
|
|
f6575ea05f | ||
|
|
295833efbf | ||
|
|
5e98f59cdb | ||
|
|
77649e0991 | ||
|
|
5c9ced057b | ||
|
|
7a2b3b9967 | ||
|
|
4582600900 | ||
|
|
78ec104392 | ||
|
|
f15f00e8c5 | ||
|
|
e953a2f918 | ||
|
|
9d6075c0f7 | ||
|
|
90ad0c7cc9 | ||
|
|
62a74db683 | ||
|
|
12b5dfdfd7 | ||
|
|
255812defc | ||
|
|
7b2e015eed | ||
|
|
a8605bd91f | ||
|
|
e3f9f4b0c1 | ||
|
|
ec476bb9d6 | ||
|
|
60cbcb7717 | ||
|
|
4b11a8b16a | ||
|
|
dfb90b8c86 | ||
|
|
2f0cec2724 | ||
|
|
887a1d0a2b | ||
|
|
1d65d558e8 | ||
|
|
d49a1fe36c | ||
|
|
33f0e16e7a | ||
|
|
86aae07367 | ||
|
|
1e86e7eb79 | ||
|
|
0c7737cd7f | ||
|
|
0c79620baa | ||
|
|
9bc20cc82e | ||
|
|
c51551afc5 | ||
|
|
f80a93f8e1 | ||
|
|
853c596167 | ||
|
|
819e383b05 | ||
|
|
724734847a | ||
|
|
cdd01c5186 | ||
|
|
5a5c34b6a1 | ||
|
|
28271efbe6 | ||
|
|
1dbb8d4b21 | ||
|
|
e1bbbb5c7e | ||
|
|
addc32e3e6 | ||
|
|
b201c95e8d | ||
|
|
19b2541c81 | ||
|
|
6a2650dda0 | ||
|
|
c122c4ecfb | ||
|
|
43ed6f1600 | ||
|
|
fae1e1a554 | ||
|
|
4587268ecb | ||
|
|
e0ff5c484a | ||
|
|
bc4956c64b | ||
|
|
cc5fbcd3a7 | ||
|
|
db23acd46c | ||
|
|
973374f6a1 | ||
|
|
13e2262c2f | ||
|
|
69775188a6 | ||
|
|
0554e4e159 | ||
|
|
f576c97c1d | ||
|
|
e6c842072c | ||
|
|
bf87f3676d | ||
|
|
a87581638c | ||
|
|
42ed18fcd5 | ||
|
|
a66c926475 | ||
|
|
6a481aaa74 | ||
|
|
72d97f4944 | ||
|
|
1694187b39 | ||
|
|
a531a8e8a3 | ||
|
|
44d5bb65ae | ||
|
|
c64f5cfb23 | ||
|
|
19a29bc3a1 | ||
|
|
6159987928 | ||
|
|
8a8c4ea20b | ||
|
|
e43d2f9a88 | ||
|
|
deba0cbba6 | ||
|
|
0c3359abcf | ||
|
|
a76069174d | ||
|
|
19c9178db7 | ||
|
|
2ebf2db04d | ||
|
|
26de89c1b9 | ||
|
|
8f68aad238 | ||
|
|
7b586e2947 | ||
|
|
800ddeaf7a | ||
|
|
4ed2e3dab5 | ||
|
|
2b9c82b432 | ||
|
|
3e7c9f3d5c | ||
|
|
8d0b51ed9c | ||
|
|
c178fa5457 | ||
|
|
532ad92714 | ||
|
|
deb0a682d8 | ||
|
|
12e22dee8e | ||
|
|
ae0f954632 | ||
|
|
b92941538b | ||
|
|
ce13444427 | ||
|
|
9c461c4cfa | ||
|
|
23858c2912 | ||
|
|
2284a56c82 | ||
|
|
323143cb2b | ||
|
|
d6064a9aac | ||
|
|
a982aa595f | ||
|
|
54c52a7f1e | ||
|
|
bdb8cedf64 | ||
|
|
554b18adfe | ||
|
|
5619af2a00 | ||
|
|
957003aeb1 | ||
|
|
6fc64a486e | ||
|
|
534e3fb34e | ||
|
|
9ab346ed91 | ||
|
|
1bf5dda803 | ||
|
|
6cd8fd8ab2 | ||
|
|
a689839616 | ||
|
|
783aa5e547 | ||
|
|
a6ff2fa0df | ||
|
|
eb498bfb22 | ||
|
|
2ebbe46ada | ||
|
|
2f4e8fcade | ||
|
|
efe05525e4 | ||
|
|
4122ae8f64 | ||
|
|
5d296cc836 | ||
|
|
fa50e315a9 | ||
|
|
4a4811bee2 | ||
|
|
ad7ece4d45 | ||
|
|
17d1aa9690 | ||
|
|
b00839ecb3 | ||
|
|
4900ea8ba6 | ||
|
|
4fb16398e4 | ||
|
|
a1fc0c5a2b | ||
|
|
6adb252cdd | ||
|
|
3637bbad8d | ||
|
|
694efe71f8 | ||
|
|
315735bc2f | ||
|
|
db6403b468 | ||
|
|
92dda40e84 | ||
|
|
6c8fc0abce | ||
|
|
2d74b12d43 | ||
|
|
197695ee40 | ||
|
|
490c42550d | ||
|
|
7367eb6e87 | ||
|
|
414b647dc8 | ||
|
|
57882bb811 | ||
|
|
bb76f09f31 | ||
|
|
741cf489c8 | ||
|
|
7ce332b3ca | ||
|
|
8b7a491c89 | ||
|
|
604eebd1b7 | ||
|
|
c2f44b3dfd | ||
|
|
f4480da276 | ||
|
|
ccad3dcb0c | ||
|
|
7767d3af65 | ||
|
|
79e699ba78 | ||
|
|
b28d6f4eb4 | ||
|
|
d021f48c67 | ||
|
|
1e71af28dd | ||
|
|
cb5867d430 | ||
|
|
bb887936b6 | ||
|
|
720d520398 | ||
|
|
8fb1116641 | ||
|
|
c319762152 | ||
|
|
726d8b8c65 | ||
|
|
683650e2c2 | ||
|
|
50b1a35168 | ||
|
|
b88a5feddf | ||
|
|
93472e38de | ||
|
|
85c14ede8d | ||
|
|
58ce109518 | ||
|
|
cf65648293 | ||
|
|
d46e7ed96d | ||
|
|
00395b3825 | ||
|
|
f67f0dd6a2 | ||
|
|
d90cd3309f | ||
|
|
130aa8382a | ||
|
|
e836a6fa58 | ||
|
|
92d89b9fe5 | ||
|
|
c10fb432f7 | ||
|
|
37b729b597 | ||
|
|
749c68bb8a | ||
|
|
dcf0ce6394 | ||
|
|
102190e61b | ||
|
|
a7340c97ca | ||
|
|
43c1162634 | ||
|
|
4db7a33ef3 | ||
|
|
bfdc1e47bd | ||
|
|
afdd73118d | ||
|
|
5511af6244 | ||
|
|
d629d9fa3a | ||
|
|
3e22d83962 | ||
|
|
57b05daad7 | ||
|
|
419eb51b66 | ||
|
|
0262f14a53 | ||
|
|
c4b255d3bf | ||
|
|
13b7debb33 | ||
|
|
a203c4a29f | ||
|
|
d8bac39c52 | ||
|
|
f1c06198b2 | ||
|
|
95b45f507a | ||
|
|
54bfb99d60 | ||
|
|
9dda477e3d | ||
|
|
2ca6b9edda | ||
|
|
b22764614d | ||
|
|
09c168f6e8 | ||
|
|
414eabd3ae | ||
|
|
b536bd7eb5 | ||
|
|
532a8d5071 | ||
|
|
793797e3a7 | ||
|
|
602b6160ab | ||
|
|
aff95ba9e9 | ||
|
|
74aad2af1c | ||
|
|
b96ad8b4e4 | ||
|
|
a8b731fe21 | ||
|
|
ff8f091a74 | ||
|
|
147b6143b0 | ||
|
|
2835ccf7d2 | ||
|
|
4deb53eab8 | ||
|
|
1c49b1a943 | ||
|
|
67cd4ab962 | ||
|
|
1a2017b337 | ||
|
|
7775898078 | ||
|
|
8f0bd1224d | ||
|
|
77e200f6a5 | ||
|
|
d880998c43 | ||
|
|
fbd82030bb | ||
|
|
162b409c52 | ||
|
|
8e2d2e81e8 | ||
|
|
5504dfa94e | ||
|
|
d5f866bd34 | ||
|
|
02937c0ef5 | ||
|
|
ae5d73e197 | ||
|
|
15da35a6bc | ||
|
|
9738f84457 | ||
|
|
663a128469 | ||
|
|
bc14ab053c | ||
|
|
3b897f6dbc | ||
|
|
92f908928d | ||
|
|
da537773a7 | ||
|
|
778592c6cf | ||
|
|
0e33457ed0 | ||
|
|
abcf2ae1a1 | ||
|
|
28fb53029c | ||
|
|
d809cbcb3d | ||
|
|
8dea1a4312 | ||
|
|
45f7dd6cbb | ||
|
|
ac28dddd8b | ||
|
|
aa58383318 | ||
|
|
b0698f246a | ||
|
|
46c5d5eb3c | ||
|
|
1922b2535c | ||
|
|
4297290fba | ||
|
|
1daf5d22af | ||
|
|
8fd989e991 | ||
|
|
559cc4a5ac | ||
|
|
66a66c9bd3 | ||
|
|
f3a8036649 | ||
|
|
5c6e4364e8 | ||
|
|
fd64f41b22 | ||
|
|
0d7aac3458 | ||
|
|
0bebcd8303 | ||
|
|
ff35c56c9a | ||
|
|
853ccbce79 | ||
|
|
8153fab887 | ||
|
|
216541288e | ||
|
|
a855cceb05 | ||
|
|
d9991d5acd | ||
|
|
2ee2ae7d4e | ||
|
|
04c2be9087 | ||
|
|
34fc9bc527 | ||
|
|
1a24e1896f | ||
|
|
1d67764dc8 | ||
|
|
8f250d204f | ||
|
|
643ae8c387 | ||
|
|
f9615a2d90 | ||
|
|
f7a3b1d0ef | ||
|
|
94aed43d2e | ||
|
|
90f30deea8 | ||
|
|
1bbc71ef17 | ||
|
|
c84a0deb5f | ||
|
|
ece65a9158 | ||
|
|
5a8af75a5b | ||
|
|
84f987d29c | ||
|
|
ed25745deb | ||
|
|
a1fd05735e | ||
|
|
5a9f8f6dfc | ||
|
|
2639055372 | ||
|
|
c53b2bd777 | ||
|
|
db29bae140 | ||
|
|
52f1fde47a | ||
|
|
e705fbd403 | ||
|
|
2d437b4462 | ||
|
|
ae946dbb53 | ||
|
|
ea2b22dbf0 | ||
|
|
7ee57b2c6b | ||
|
|
ccd393a533 | ||
|
|
1b571c7d4b | ||
|
|
4b91fcb615 | ||
|
|
1e0d069dcd | ||
|
|
92088779e0 | ||
|
|
a6e02433b6 | ||
|
|
1aa5dc1c71 | ||
|
|
3c78923853 | ||
|
|
d17183a152 | ||
|
|
2e5e343913 | ||
|
|
cec6035d23 | ||
|
|
bdc760608e | ||
|
|
4f41899338 | ||
|
|
557f21b637 | ||
|
|
4241b50002 | ||
|
|
8b0507f1a7 | ||
|
|
d62429189e | ||
|
|
c83b173a17 | ||
|
|
9c1974c227 | ||
|
|
c621ba3150 | ||
|
|
7710e3f972 | ||
|
|
a4f65c1102 | ||
|
|
80c6444c2e | ||
|
|
2022b80f9d | ||
|
|
7e9900c5f5 | ||
|
|
132df9e8c4 | ||
|
|
07e3549306 | ||
|
|
e0fa0a0cde | ||
|
|
31771ca87d | ||
|
|
1b898398a7 | ||
|
|
d8fcb679cd | ||
|
|
72a5ccb769 | ||
|
|
e872fc9ce5 | ||
|
|
51961474b6 | ||
|
|
8be35dab30 | ||
|
|
0ab17aede8 | ||
|
|
6db8a151f8 | ||
|
|
1a12eb367f | ||
|
|
f61f68a5c2 | ||
|
|
0781d79ca7 | ||
|
|
4ffdf499c9 | ||
|
|
f8f318c203 | ||
|
|
59540818f9 | ||
|
|
b2b531ad3c | ||
|
|
4e6957a772 | ||
|
|
e747fb1010 | ||
|
|
9d110cdd72 | ||
|
|
6fa0eacff3 | ||
|
|
7ce924f52e | ||
|
|
91b3c2a64b | ||
|
|
97579400b1 | ||
|
|
63b01dde14 | ||
|
|
d9b996ab02 | ||
|
|
3af105edf5 | ||
|
|
5057592b0d | ||
|
|
13bb819e07 | ||
|
|
eefa9252e5 | ||
|
|
c5b84ac357 | ||
|
|
787256ce66 | ||
|
|
2f1877267f | ||
|
|
9c34fc9281 | ||
|
|
1b9bba7c3d | ||
|
|
123867ecd8 | ||
|
|
26d900897b | ||
|
|
f7a5b5cc03 | ||
|
|
7fd822caf2 | ||
|
|
01c6f0b12b | ||
|
|
62c89a925e | ||
|
|
07e1759819 | ||
|
|
8fac6c5bc0 | ||
|
|
bd4307af58 | ||
|
|
bd6e3bb4e3 | ||
|
|
c2630a43e1 | ||
|
|
2e6fb97cc5 | ||
|
|
674dae4440 | ||
|
|
a1082812bb | ||
|
|
9d838fe4eb | ||
|
|
fe0aef69e4 | ||
|
|
1b1235210e | ||
|
|
451d725946 | ||
|
|
9d5ae9552a | ||
|
|
c3c558e934 | ||
|
|
a570413e66 | ||
|
|
3e23374b32 | ||
|
|
1c6a5eb6c6 | ||
|
|
40e9e4bd6c | ||
|
|
e1ff851068 | ||
|
|
8a4e507f22 | ||
|
|
cd7b5bfabc | ||
|
|
346882f1ff | ||
|
|
5bc2466da2 | ||
|
|
672f59ab4e | ||
|
|
ae26a9e371 | ||
|
|
37fc047174 | ||
|
|
6343965bbc | ||
|
|
411da60bb2 | ||
|
|
72c36d8381 | ||
|
|
b0974c1ab2 | ||
|
|
a76eac085a | ||
|
|
5319714a37 | ||
|
|
5d9105655f | ||
|
|
b98c03b96b | ||
|
|
ec703ae4b0 | ||
|
|
9ce09b4761 | ||
|
|
6d002fc8b0 | ||
|
|
7228340677 | ||
|
|
9335a37feb | ||
|
|
371348533f | ||
|
|
26f6defe60 | ||
|
|
52bc4af1f0 | ||
|
|
8c94305804 | ||
|
|
0eb57698bd | ||
|
|
7b0db80edd | ||
|
|
0e63e97a57 | ||
|
|
e4a21964d2 | ||
|
|
39ad37f23b | ||
|
|
34bc61e1c3 | ||
|
|
e6a7c367b3 | ||
|
|
66afd33b79 | ||
|
|
cf617d720e | ||
|
|
4f2eca08e0 | ||
|
|
2972780c18 | ||
|
|
9c3f3f2f98 | ||
|
|
0d83a7fcb1 | ||
|
|
80f4ca7c4f | ||
|
|
96e7fce304 | ||
|
|
13a358c07c | ||
|
|
389745533c | ||
|
|
65dd547a32 | ||
|
|
91a877da79 | ||
|
|
d357215e0e | ||
|
|
ff47dc4731 | ||
|
|
fedea74256 | ||
|
|
66a6701a66 | ||
|
|
5bdfd6da52 | ||
|
|
a8f90400b2 | ||
|
|
3348386a4c | ||
|
|
1b53c4a0dd | ||
|
|
24158638ef | ||
|
|
901cbfc9d1 | ||
|
|
9f85068a0d | ||
|
|
90b83544f4 | ||
|
|
1d8e8e53d9 | ||
|
|
46a0a433ec | ||
|
|
d38d30471a | ||
|
|
5aad8ea641 | ||
|
|
74f4bd7763 | ||
|
|
51dd69a51f | ||
|
|
950e7d4971 | ||
|
|
623b694c78 | ||
|
|
7162ea24cc | ||
|
|
968b77b8bd | ||
|
|
6e00b126d1 | ||
|
|
003ceb0944 | ||
|
|
ce6dec50cd | ||
|
|
87643ddcab | ||
|
|
017d8aa6c0 | ||
|
|
87b83465b3 | ||
|
|
a1900f0130 | ||
|
|
cf9ecff529 | ||
|
|
db3dee81e9 | ||
|
|
798032be08 | ||
|
|
d6834dca06 | ||
|
|
933cd7229a | ||
|
|
8e51a86971 | ||
|
|
8ab44041eb | ||
|
|
79191d757a | ||
|
|
7d01f54d73 | ||
|
|
63c0e11cc9 | ||
|
|
7347d03f93 | ||
|
|
fffb10d7e6 | ||
|
|
91b3de1444 | ||
|
|
8340f2f78a | ||
|
|
e65840d269 | ||
|
|
c245bc24a6 | ||
|
|
71ebc3dc8a | ||
|
|
7ade321dd3 | ||
|
|
19c0114eea | ||
|
|
968c0cf8fc | ||
|
|
f8d4874f2f | ||
|
|
261d6293dd | ||
|
|
988265fd9f | ||
|
|
ba03894e94 | ||
|
|
556c28d4b9 | ||
|
|
9d2a8c6b69 | ||
|
|
89b517f47e | ||
|
|
150016f543 | ||
|
|
5df599be15 | ||
|
|
0fd4b4b274 | ||
|
|
dd744053f4 | ||
|
|
be68c0d714 | ||
|
|
2a4b0d3f4b | ||
|
|
46dd002e5d | ||
|
|
04bc0ace3f | ||
|
|
fa6cc49343 | ||
|
|
231d191556 | ||
|
|
b6c8141042 | ||
|
|
ae209a37f7 | ||
|
|
0d07ae1b4c | ||
|
|
cfeed4f432 | ||
|
|
6ca243c05c | ||
|
|
cfa2f0f1bc | ||
|
|
d4456555b3 | ||
|
|
01babca0f4 | ||
|
|
123444ddf6 | ||
|
|
aeeb894ad9 | ||
|
|
d290fe4096 | ||
|
|
565e3e1b9d | ||
|
|
b23fe8319b | ||
|
|
eb41b4285e | ||
|
|
ab684f64ff | ||
|
|
abea28ef00 | ||
|
|
8934645c4d | ||
|
|
68d64665a6 | ||
|
|
5099d0e67e | ||
|
|
7a3ba3ae03 | ||
|
|
80272b1e27 | ||
|
|
8d807289ae | ||
|
|
cb946209a2 | ||
|
|
890b58d0ce | ||
|
|
6623f2559d | ||
|
|
b395eba4f3 | ||
|
|
c13f3d5b77 | ||
|
|
13c7373917 | ||
|
|
b0cb3dda37 | ||
|
|
a0ab6700d8 | ||
|
|
ebf4cb53b5 | ||
|
|
9fa2e967dc | ||
|
|
f853b410e7 | ||
|
|
874f717bd1 | ||
|
|
479a41c900 | ||
|
|
a0a1e2837c | ||
|
|
bd783385de | ||
|
|
5f19b3e47b | ||
|
|
c7fe0a5b5c | ||
|
|
5eb1b663f4 | ||
|
|
1e194385b8 | ||
|
|
731fb87012 | ||
|
|
5e1634dbfa | ||
|
|
7d366386da | ||
|
|
b4a03778d3 | ||
|
|
657924532e | ||
|
|
9099909983 | ||
|
|
9cf94ceb86 | ||
|
|
c529f474fe | ||
|
|
e31a5979bb | ||
|
|
90ee45349c | ||
|
|
ab7305b7b2 | ||
|
|
af0cdb006a | ||
|
|
326906f073 | ||
|
|
d54a6cbb71 | ||
|
|
94dab01e6b | ||
|
|
afa7b2259f | ||
|
|
dad1aec874 | ||
|
|
14c7322cde | ||
|
|
7b07146afa | ||
|
|
296c21fd3f | ||
|
|
5215d0f298 | ||
|
|
daa27e4989 | ||
|
|
69b7be744a | ||
|
|
7fa1353173 | ||
|
|
3653e9d63d | ||
|
|
0a2e087eaa | ||
|
|
11b683a156 | ||
|
|
d387a40154 | ||
|
|
0023be40c1 | ||
|
|
529db79ea4 | ||
|
|
32a27f2d98 | ||
|
|
09f2e29f27 | ||
|
|
fa4a1a4e8c | ||
|
|
8206a49dc7 | ||
|
|
6ad66f0d14 | ||
|
|
6399f43677 | ||
|
|
3a5c85e6dd | ||
|
|
ac440d814e | ||
|
|
270f2f8de4 | ||
|
|
8ad03fd30b | ||
|
|
10837e3841 |
@@ -0,0 +1,5 @@
|
||||
[run]
|
||||
parallel = true
|
||||
omit =
|
||||
tests/*
|
||||
setup.py
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Home Assistant version (if applicable):** <HA Version Number>
|
||||
|
||||
**`blinkpy` version (not needed if filling out Home Assistant version):**
|
||||
|
||||
**Log Output/Additional Information**
|
||||
If using home-assistant, please paste the output of the log showing your error below. If not, please include any additional useful information.
|
||||
|
||||
```
|
||||
PASTE LOG OUTPUT HERE
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the feature request (such as API endpoint responses, etc).
|
||||
@@ -0,0 +1,9 @@
|
||||
## Description:
|
||||
|
||||
|
||||
**Related issue (if applicable):** fixes #<blinkpy issue number goes here>
|
||||
|
||||
## Checklist:
|
||||
- [ ] Local tests with `tox` run successfully **PR cannot be meged unless tests pass**
|
||||
- [ ] Changes tested locally to ensure platform still works as intended
|
||||
- [ ] Tests added to verify new code works
|
||||
@@ -0,0 +1,30 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- Priority
|
||||
- bug
|
||||
- Help Wanted
|
||||
|
||||
# Set to true to ignore issues in a project (defaults to false)
|
||||
exemptProjects: true
|
||||
|
||||
# Set to true to ignore issues in a milestone (defaults to false)
|
||||
exemptMilestones: true
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs within seven days. Thank you
|
||||
for your contributions.
|
||||
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: >
|
||||
This issue is now being closed due to inactivity.
|
||||
@@ -0,0 +1,41 @@
|
||||
name: coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, dev ]
|
||||
pull_request:
|
||||
branches: [ master, dev ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
platform:
|
||||
- ubuntu-latest
|
||||
python-version: [3.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements_test.txt
|
||||
pip install tox
|
||||
pip install codecov
|
||||
- name: Test
|
||||
run: |
|
||||
tox -r -e cov
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v1.0.6
|
||||
with:
|
||||
flags: unittests
|
||||
file: ./coverage.xml
|
||||
name: blinkpy
|
||||
fail_ci_if_error: true
|
||||
@@ -0,0 +1,34 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, dev ]
|
||||
pull_request:
|
||||
branches: [ master, dev ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements_test.txt
|
||||
pip install tox
|
||||
- name: Lint
|
||||
run: |
|
||||
tox -r -e lint
|
||||
@@ -0,0 +1,31 @@
|
||||
# This workflows will upload a Python Package using Twine when a release is created
|
||||
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
|
||||
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.7'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools wheel twine
|
||||
- name: Build and publish
|
||||
env:
|
||||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
||||
run: |
|
||||
python setup.py bdist_wheel
|
||||
twine upload dist/*
|
||||
@@ -0,0 +1,33 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, dev ]
|
||||
pull_request:
|
||||
branches: [ master, dev ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
platform:
|
||||
- ubuntu-latest
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements_test.txt
|
||||
pip install tox
|
||||
- name: Test
|
||||
run: |
|
||||
tox -r
|
||||
+7
-2
@@ -1,11 +1,16 @@
|
||||
.pytest_cache/*
|
||||
.cache/*
|
||||
.tox/*
|
||||
__pycache__/*
|
||||
htmlcov/*
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
*.pyc
|
||||
*.egg*/*
|
||||
dist/*
|
||||
MANIFEST
|
||||
README
|
||||
.sh
|
||||
build/*
|
||||
docs/_build
|
||||
*.log
|
||||
venv
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
image: python
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
before_script:
|
||||
- curl -O https://bootstrap.pypa.io/get-pip.py
|
||||
- python get-pip.py
|
||||
- pip install tox
|
||||
|
||||
python35:
|
||||
image: python:3.5
|
||||
stage: test
|
||||
script: tox -e py35
|
||||
|
||||
python36:
|
||||
image: python:3.6
|
||||
stage: test
|
||||
script: tox -e py36
|
||||
|
||||
lint:
|
||||
image: python:3.6
|
||||
stage: test
|
||||
script: tox -e lint
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 19.10b0
|
||||
hooks:
|
||||
- id: black
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
files: ^((blinkpy|tests)/.+)?[^/]+\.py$
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.8.2
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
- flake8-docstrings==1.5.0
|
||||
- pydocstyle==5.0.2
|
||||
files: ^(blinkpy|tests)/.+\.py$
|
||||
- repo: https://github.com/Lucas-C/pre-commit-hooks-markup
|
||||
rev: v1.0.0
|
||||
hooks:
|
||||
- id: rst-linter
|
||||
files: /.+\.rst$
|
||||
@@ -0,0 +1,289 @@
|
||||
# BlinkMonitorProtocol
|
||||
Unofficial documentation for the Client API of the Blink Wire-Free HD Home Monitoring & Alert System.
|
||||
|
||||
Copied from https://github.com/MattTW/BlinkMonitorProtocol
|
||||
|
||||
I am not affiliated with the company in any way - this documentation is strictly **"AS-IS"**. My goal was to uncover enough to arm and disarm the system programatically so that I can issue those commands in sync with my home alarm system arm/disarm. Just some raw notes at this point but should be enough for creating programmatic APIs. Lots more to be discovered and documented - feel free to contribute!
|
||||
|
||||
The Client API is a straightforward REST API using JSON and HTTPS.
|
||||
|
||||
## Login
|
||||
|
||||
Client login to the Blink Servers.
|
||||
|
||||
**Request:**
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "Content-Type: application/json" --data-binary '{
|
||||
> "password" : "*your blink password*",
|
||||
> "client_specifier" : "iPhone 9.2 | 2.2 | 222",
|
||||
> "email" : "*your blink login/email*"
|
||||
>}' --compressed https://rest.prod.immedia-semi.com/login
|
||||
|
||||
**Response:**
|
||||
>{"authtoken":{"authtoken":"*an auth token*","message":"auth"},"networks":{"*network id*":{"name":"*name*","onboarded":true}},"region":{"*regioncode for endpoint*":"*region name"}}
|
||||
|
||||
**Notes:**
|
||||
The authtoken value is passed in a header in future calls.
|
||||
The region code for endpoint is required to form the URL of the REST endpoint for future calls.
|
||||
Depending on the region you are registered you will need to change the REST endpoints below:
|
||||
- from `https://rest.prod.immedia-semi.com`
|
||||
- to `https://rest.prde.immedia-semi.com` if e.g. your device is registered in Germany
|
||||
Please note that at this moment it seems that all regions are not implemented equally: not all endpoints are available in all regions
|
||||
|
||||
## Networks
|
||||
|
||||
Obtain information about the Blink networks defined for the logged in user.
|
||||
|
||||
**Request:**
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/networks
|
||||
|
||||
**Response:**
|
||||
JSON response containing information including Network ID and Account ID.
|
||||
|
||||
**Notes:**
|
||||
Network ID is needed to issue arm/disarm calls
|
||||
|
||||
|
||||
## Sync Modules
|
||||
|
||||
Obtain information about the Blink Sync Modules on the given network.
|
||||
|
||||
**Request:**
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id_from_networks_call*/syncmodules
|
||||
|
||||
**Response:**
|
||||
JSON response containing information about the known state of the Sync module, most notably if it is online
|
||||
|
||||
**Notes:**
|
||||
Probably not strictly needed but checking result can verify that the sync module is online and will respond to requests to arm/disarm, etc.
|
||||
|
||||
|
||||
## Arm
|
||||
|
||||
Arm the given network (start recording/reporting motion events)
|
||||
|
||||
**Request:**
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id_from_networks_call*/arm
|
||||
|
||||
**Response:**
|
||||
JSON response containing information about the arm command request, including the command/request ID
|
||||
|
||||
**Notes:**
|
||||
When this call returns, it does not mean the arm request is complete, the client must gather the request ID from the response and poll for the status of the command.
|
||||
|
||||
## Disarm
|
||||
|
||||
Disarm the given network (stop recording/reporting motion events)
|
||||
|
||||
**Request:**
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id_from_networks_call*/disarm
|
||||
|
||||
**Response:**
|
||||
JSON response containing information about the disarm command request, including the command/request ID
|
||||
|
||||
**Notes:**
|
||||
When this call returns, it does not mean the disarm request is complete, the client must gather the request ID from the response and poll for the status of the command.
|
||||
|
||||
|
||||
## Command Status
|
||||
|
||||
Get status info on the given command
|
||||
|
||||
**Request:**
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/command/*command_id*
|
||||
|
||||
**Response:**
|
||||
JSON response containing state information of the given command, most notably whether it has completed and was successful.
|
||||
|
||||
**Notes:**
|
||||
After an arm/disarm command, the client appears to poll this URL every second or so until the response indicates the command is complete.
|
||||
|
||||
**Known Commands:**
|
||||
lv_relay, arm, disarm, thumbnail, clip
|
||||
|
||||
## Home Screen
|
||||
|
||||
Return information displayed on the home screen of the mobile client
|
||||
|
||||
**Request:**
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/homescreen
|
||||
|
||||
**Response:**
|
||||
JSON response containing information that the mobile client displays on the home page, including: status, armed state, links to thumbnails for each camera, etc.
|
||||
|
||||
**Notes:**
|
||||
Not necessary to as part of issuing arm/disarm commands, but contains good summary info.
|
||||
|
||||
## Events, thumbnails & video captures
|
||||
|
||||
**Request**
|
||||
Get events for a given network (sync module) -- Need network ID from home
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/events/network/*network__id*
|
||||
|
||||
**Response**
|
||||
A json list of evets incluing URL's. Replace the "mp4" with "jpg" extension to get the thumbnail of each clip
|
||||
|
||||
|
||||
**Request**
|
||||
Get a video clip from the events list
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed **video url from events list.mp4** > video.mp4
|
||||
|
||||
**Response**
|
||||
The mp4 video
|
||||
|
||||
**Request**
|
||||
Get a thumbnail from the events list
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed **video url from events list.jpg** > video_thumb.jpg
|
||||
|
||||
**Response**
|
||||
The jpg bytes.
|
||||
|
||||
**Notes**
|
||||
Note that you replace the 'mp4' with a 'jpg' to get the thumbnail
|
||||
|
||||
**Request**
|
||||
Captures a new thumbnail for a camera
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/thumbnail
|
||||
|
||||
**Response**
|
||||
Command information.
|
||||
|
||||
**Request**
|
||||
Captures a new video for a camera
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/clip
|
||||
|
||||
**Response**
|
||||
Command information.
|
||||
|
||||
## Video Information
|
||||
|
||||
**Request**
|
||||
Get the total number of videos in the system
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/videos/count
|
||||
|
||||
**Response**
|
||||
JSON response containing the total video count.
|
||||
|
||||
**Request**
|
||||
Gets a paginated set of video information
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/videos/page/0
|
||||
|
||||
**Response**
|
||||
JSON response containing a set of video information, including: camera name, creation time, thumbnail URI, size, length
|
||||
|
||||
**Request**
|
||||
Gets information for a specific video by ID
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/video/*video_id*
|
||||
|
||||
**Response**
|
||||
JSON response containing video information
|
||||
|
||||
**Request**
|
||||
Gets a list of unwatched videos
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/videos/unwatched
|
||||
|
||||
**Response**
|
||||
JSON response containing unwatched video information
|
||||
|
||||
**Request**
|
||||
Deletes a video
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/api/v2/video/*video_id*/delete
|
||||
|
||||
**Response**
|
||||
Unknown - not tested
|
||||
|
||||
**Request**
|
||||
Deletes all videos
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/api/v2/videos/deleteall
|
||||
|
||||
**Response**
|
||||
Unknown - not tested
|
||||
|
||||
## Cameras
|
||||
|
||||
**Request**
|
||||
Gets a list of cameras
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/cameras
|
||||
|
||||
**Response**
|
||||
JSON response containing camera information
|
||||
|
||||
**Request**
|
||||
Gets information for one camera
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*
|
||||
|
||||
**Response**
|
||||
JSON response containing camera information
|
||||
|
||||
**Request**
|
||||
Gets camera sensor information
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/signals
|
||||
|
||||
**Response**
|
||||
JSON response containing camera sensor information, such as wifi strength, temperature, and battery level
|
||||
|
||||
**Request**
|
||||
Enables motion detection for one camera
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: $auth_token" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/enable
|
||||
|
||||
**Response**
|
||||
JSON response containing camera information
|
||||
|
||||
**Request**
|
||||
Disables motion detection for one camera
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: $auth_token" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/disable
|
||||
|
||||
**Response**
|
||||
JSON response containing camera information
|
||||
|
||||
*Note*: enabling or disabling motion detection is independent of arming or disarming the system. No motion detection or video recording will take place unless the system is armed.
|
||||
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
**Request**
|
||||
Gets information about devices that have connected to the blink service
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/account/clients
|
||||
|
||||
**Response**
|
||||
JSON response containing client information, including: type, name, connection time, user ID
|
||||
|
||||
**Request**
|
||||
Gets information about supported regions
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/regions
|
||||
|
||||
**Response**
|
||||
JSON response containing region information
|
||||
|
||||
**Request**
|
||||
Gets information about system health
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/health
|
||||
|
||||
**Response**
|
||||
"all ports tested are open"
|
||||
|
||||
**Request**
|
||||
Gets information about programs
|
||||
|
||||
>curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v1/networks/*network_id*/programs
|
||||
|
||||
**Response**
|
||||
Unknown.
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
=========
|
||||
Changelog
|
||||
=========
|
||||
|
||||
A list of changes between each release
|
||||
|
||||
0.15.0 (2020-05-08)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Breaking Changes:**
|
||||
|
||||
- Removed support for Python 3.5 (3.6 is now the minimum supported version)
|
||||
- Deprecated ``Blink.login()`` method. Please only use the ``Blink.start()`` method for logging in.
|
||||
|
||||
**New Functions**
|
||||
|
||||
- Add ``device_id`` override when logging in (for debug and to differentiate applications) (`#245 <https://github.com/fronzbot/blinkpy/pull/245>`__)
|
||||
|
||||
This can be used by instantiating the Blink class with the ``device_id`` parameter.
|
||||
|
||||
**All Changes:**
|
||||
|
||||
- Fix setup.py use of internal pip structure (`#233 <https://github.com/fronzbot/blinkpy/pull/233>`__)
|
||||
- Update python-slugify requirement from ~=3.0.2 to ~=4.0.0 (`#234 <https://github.com/fronzbot/blinkpy/pull/234>`__)
|
||||
- Update python-dateutil requirement from ~=2.8.0 to ~=2.8.1 (`#230 <https://github.com/fronzbot/blinkpy/pull/230>`__)
|
||||
- Bump requests from 2.22.0 to 2.23.0 (`#231 <https://github.com/fronzbot/blinkpy/pull/231>`__)
|
||||
- Refactor login logic in preparation for 2FA (`#241 <https://github.com/fronzbot/blinkpy/pull/241>`__)
|
||||
- Add 2FA Support (`#242 <https://github.com/fronzbot/blinkpy/pull/242>`__) (fixes (`#210 <https://github.com/fronzbot/blinkpy/pull/210>`__))
|
||||
- Re-set key_required and available variables after setup (`#245 <https://github.com/fronzbot/blinkpy/pull/245>`__)
|
||||
- Perform system refresh after setup (`#245 <https://github.com/fronzbot/blinkpy/pull/245>`__)
|
||||
- Fix typos (`#244 <https://github.com/fronzbot/blinkpy/pull/244>`__)
|
||||
|
||||
0.14.3 (2020-04-22)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Add time check on recorded videos before determining motion
|
||||
- Fix motion detection variable suck to ``True``
|
||||
- Add ability to load credentials from a json file
|
||||
- Only allow ``motion_detected`` variable to trigger if system was armed
|
||||
- Log response message from server if not attempting a re-authorization
|
||||
|
||||
0.14.2 (2019-10-12)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Update dependencies
|
||||
- Dockerize `(@3ch01c <https://github.com/fronzbot/blinkpy/pull/198>__)`
|
||||
|
||||
0.14.1 (2019-06-20)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Fix timeout problems blocking blinkpy startup
|
||||
- Updated login urls using ``rest-region`` subdomain
|
||||
- Removed deprecated thumbanil recovery from homescreen
|
||||
|
||||
0.14.0 (2019-05-23)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Breaking Changes:**
|
||||
|
||||
- ``BlinkCamera.battery`` no longer reports a percentage, instead it returns a string representing the state of the battery.
|
||||
- Previous logic for calculating percentage was incorrect
|
||||
- raw battery voltage can be accessed via ``BlinkCamera.battery_voltage``
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- Updated video endpoint (fixes broken motion detection)
|
||||
- Removed throttling from critical api methods which prevented proper operation of multi-sync unit setups
|
||||
- Slugify downloaded video names to allow for OS interoperability
|
||||
- Added one minute offset (``Blink.motion_interval``) when checking for recent motion to allow time for events to propagate to server prior to refresh call.
|
||||
|
||||
**Everything else:**
|
||||
|
||||
- Changed all urls to use ``rest-region`` rather than ``rest.region``. Ability to revert to old method is enabled by instantiating ``Blink()`` with the ``legacy_subdomain`` variable set to ``True``.
|
||||
- Added debug mode to ``blinkpy.download_videos`` routine to simply print the videos prepped for download, rather than actually saving them.
|
||||
- Use UTC for time conversions, rather than local timezone
|
||||
|
||||
|
||||
0.13.1 (2019-03-01)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Remove throttle decorator from network status request
|
||||
|
||||
0.13.0 (2019-03-01)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Breaking change:**
|
||||
Wifi status reported in dBm again, instead of bars (which is great). Also, the old ``get_camera_info`` method has changed and requires a ``camera_id`` parameter.
|
||||
|
||||
- Adds throttle decorator
|
||||
- Decorate following functions with 4s throttle (call method with ``force=True`` to override):
|
||||
- request_network_status
|
||||
- request_syncmodule
|
||||
- request_system_arm
|
||||
- request_system_disarm
|
||||
- request_sync_events
|
||||
- request_new_image
|
||||
- request_new_video
|
||||
- request_video_count
|
||||
- request_cameras
|
||||
- request_camera_info
|
||||
- request_camera_sensors
|
||||
- request_motion_detection_enable
|
||||
- request_motion_detection_disable
|
||||
- Use the updated homescreen api endpoint to retrieve camera information. The old method to retrieve all cameras at once seems to not exist, and this was the only solution I could figure out and confirm to work.
|
||||
- Adds throttle decorator to refresh function to prevent too many frequent calls with ``force_cache`` flag set to ``True``. This additional throttle can be overridden with the ``force=True`` argument passed to the refresh function.
|
||||
- Add ability to cycle through login api endpoints to anticipate future endpoint deprecation
|
||||
|
||||
|
||||
0.12.1 (2019-01-31)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Remove logging improvements since they were incompatible with home-assistant logging
|
||||
|
||||
0.12.0 (2019-01-31)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Fix video api endpoint, re-enables motion detection
|
||||
- Add improved logging capability
|
||||
- Add download video method
|
||||
- Prevent blinkpy from failing at setup due to api error
|
||||
|
||||
|
||||
0.11.2 (2019-01-23)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Hotfix to prevent platform from stalling due to API change
|
||||
- Motion detection and video recovery broken until new API endpoint discovered
|
||||
|
||||
0.11.1 (2019-01-02)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Fixed incorrect backup login url
|
||||
- Added calibrated temperature property for cameras
|
||||
|
||||
|
||||
0.11.0 (2018-11-23)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Added support for multiple sync modules
|
||||
|
||||
0.10.3 (2018-11-18)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Use networks endpoint rather than homecreen to retrieve arm/disarm status (`@md-reddevil <https://github.com/fronzbot/blinkpy/pull/119>`__)
|
||||
- Fix incorrect command status endpoint (`@md-reddevil <https://github.com/fronzbot/blinkpy/pull/118>`__)
|
||||
- Add extra debug logging
|
||||
- Remove error prior to re-authorization (only log error when re-auth failed)
|
||||
|
||||
|
||||
0.10.2 (2018-10-30)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Set minimum required version of the requests library to 2.20.0 due to vulnerability in earlier releases.
|
||||
- When multiple networks detected, changed log level to ``warning`` from ``error``
|
||||
|
||||
|
||||
0.10.1 (2018-10-18)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Fix re-authorization bug (fixes `#101 <https://github.com/fronzbot/blinkpy/issues/#101>`__)
|
||||
- Log an error if saving video that doesn't exist
|
||||
|
||||
0.10.0 (2018-10-16)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Moved all API calls to own module for easier maintainability
|
||||
- Added network ids to sync module and cameras to allow for multi-network use
|
||||
- Removed dependency on video existance prior to camera setup (fixes `#93 <https://github.com/fronzbot/blinkpy/issues/#93>`__)
|
||||
- Camera wifi_strength now reported in wifi "bars" rather than dBm due to API endpoint change
|
||||
- Use homescreen thumbnail as fallback in case it's not in the camera endpoint
|
||||
- Removed "armed" and "status" attributes from camera (status of camera only reported by "motion_enabled" now)
|
||||
- Added serial number attributes to sync module and cameras
|
||||
- Check network_id from login response and verify that network is onboarded (fixes `#90 <https://github.com/fronzbot/#90>`__)
|
||||
- Check if retrieved clip is "None" prior to storing in cache
|
||||
|
||||
0.9.0 (2018-09-27)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Complete code refactoring to enable future multi-sync module support
|
||||
- Add image and video caching to the cameras
|
||||
- Add internal throttling of system refresh
|
||||
- Use session for http requests
|
||||
|
||||
**Breaking change:**
|
||||
- Cameras now accessed through sync module ``Blink.sync.cameras``
|
||||
|
||||
|
||||
0.8.1 (2018-09-24)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Update requirements_test.txt
|
||||
- Update linter versions
|
||||
- Fix pylint warnings
|
||||
- Remove object from class declarations
|
||||
- Remove useless returns from functions
|
||||
- Fix pylint errors
|
||||
- change if comparison to fix (consider-using-in)
|
||||
- Disabled no else-if-return check
|
||||
- Fix useless-import-alias
|
||||
- Disable no-else-return
|
||||
- Fix motion detection
|
||||
- Use an array of recent video clips to determine if motion has been detected.
|
||||
- Reset the value every system refresh
|
||||
|
||||
0.8.0 (2018-05-21)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Added support for battery voltage level (fixes `#64 <https://github.com/fronzbot/blinkpy/issues/64>`__)
|
||||
- Added motion detection per camera
|
||||
- Added fully accessible camera configuration dict
|
||||
- Added celcius property to camera (fixes `#60 <https://github.com/fronzbot/blinkpy/issues/60>`__)
|
||||
|
||||
0.7.1 (2018-05-09)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Fixed pip 10 import issue during setup (`@fronzbot <https://github.com/fronzbot/blinkpy/pull/61>`__)
|
||||
|
||||
0.7.0 (2018-02-08)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
- Fixed style errors for bumped pydocstring and pylint versions
|
||||
- Changed Blink.cameras dictionary to be case-insensitive (fixes `#35 <https://github.com/fronzbot/blinkpy/issues/35>`__)
|
||||
- Changed api endpoint for video extraction (fixes `#35 <https://github.com/fronzbot/blinkpy/issues/35>`__ and `#41 <https://github.com/fronzbot/blinkpy/issues/41>`__)
|
||||
- Removed last_motion() function from Blink class
|
||||
- Refactored code for better organization
|
||||
- Moved some request calls out of @property methods (enables future CLI support)
|
||||
- Renamed get_summary() method to summary and changed to @property
|
||||
- Added ability to download most recent video clip
|
||||
- Improved camera arm/disarm handling (`@b10m <https://github.com/fronzbot/blinkpy/pull/50>`__)
|
||||
- Added authentication to ``login()`` function and deprecated ``setup_system()`` in favor of ``start()``
|
||||
- Added ``attributes`` dictionary to camera object
|
||||
|
||||
0.6.0 (2017-05-12)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Removed redundent properties that only called hidden variables
|
||||
- Revised request wrapper function to be more intelligent
|
||||
- Added tests to ensure exceptions are caught and handled (100% coverage!)
|
||||
- Added auto-reauthorization (token refresh) when a request fails due to an expired token (`@tySwift93 <https://github.com/fronzbot/blinkpy/pull/24>`__)
|
||||
- Added battery level string to reduce confusion with the way Blink reports battery level as integer from 0 to 3
|
||||
|
||||
0.5.2 (2017-03-12)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Fixed packaging mishap, same as 0.5.0 otherwise
|
||||
|
||||
0.5.0 (2017-03-12)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Fixed region handling problem
|
||||
- Added rest.piri subdomain as a backup if region can't be found
|
||||
- Improved the file writing function
|
||||
- Large test coverage increase
|
||||
|
||||
0.4.4 (2017-03-06)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Fixed bug where region id was not being set in the header
|
||||
|
||||
0.4.3 (2017-03-05)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Changed to bdist_wheel release
|
||||
|
||||
0.4.2 (2017-01-28)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Fixed inability to retrieve motion data due to Key Error
|
||||
|
||||
0.4.1 (2017-01-27)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Fixed refresh bug (0.3.1 did not actually fix the problem)
|
||||
- Image refresh routine added (per camera)
|
||||
- Dictionary of thumbnails per camera added
|
||||
- Improved test coverage
|
||||
|
||||
0.3.1 (2017-01-25)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Fixed refresh bug (Key Error)
|
||||
|
||||
0.3.0 (2017-01-25)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Added device id to camera lookup table
|
||||
- Added image to file method
|
||||
|
||||
0.2.0 (2017-01-21)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
- Initial release of blinkpy
|
||||
@@ -0,0 +1,109 @@
|
||||
========================
|
||||
Contributing to blinkpy
|
||||
========================
|
||||
|
||||
Everyone is welcome to contribute to blinkpy! The process to get started is described below.
|
||||
|
||||
|
||||
Fork the Repository
|
||||
-------------------
|
||||
|
||||
You can do this right in github: just click the 'fork' button at the top right.
|
||||
|
||||
Start Developing
|
||||
-----------------
|
||||
|
||||
1. Setup Local Repository
|
||||
.. code:: bash
|
||||
|
||||
$ git clone https://github.com/<YOUR_GIT_USERNAME>/blinkpy.git
|
||||
$ cd blinkpy
|
||||
$ git remote add upstream https://github.com/fronzbot/blinkpy.git
|
||||
|
||||
2. Create virtualenv and install dependencies
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ python -m venv venv
|
||||
$ source venv/bin/activate
|
||||
$ pip install -r requirements.txt
|
||||
$ pip install -r requirements_test.txt
|
||||
$ pre-commit install
|
||||
|
||||
3. Create a Local Branch
|
||||
|
||||
First, you will want to create a new branch to hold your changes:
|
||||
``git checkout -b <your-branch-name>``
|
||||
|
||||
|
||||
4. Make changes
|
||||
|
||||
Now you can make changes to your code. It is worthwhile to test your code as you progress (see the **Testing** section)
|
||||
|
||||
5. Commit Your Changes
|
||||
|
||||
To commit changes to your branch, simply add the files you want and the commit them to the branch. After that, you can push to your fork on GitHub:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ git add .
|
||||
$ git commit
|
||||
$ git push origin HEAD
|
||||
|
||||
6. Submit your pull request on GitHub
|
||||
|
||||
- On GitHub, navigate to the `blinkpy <https://github.com/fronzbot/blinkpy>`__ repository.
|
||||
- In the "Branch" menu, choose the branch that contains your commits (from your fork).
|
||||
- To the right of the Branch menu, click New pull request.
|
||||
- The base branch dropdown menu should read ``dev``. Use the compare branch drop-down menu to choose the branch you made your changes in.
|
||||
- Type a title and complete the provided description for your pull request.
|
||||
- Click Create pull request.
|
||||
- More detailed instructions can be found here: `Creating a Pull Request` <https://help.github.com/articles/creating-a-pull-request>`__
|
||||
|
||||
7. Prior to merge approval
|
||||
|
||||
Finally, the ``blinkpy`` repository uses continuous integration tools to run tests prior to merging. If there are any problems, you will see a red 'X' next to your pull request.
|
||||
|
||||
|
||||
Testing
|
||||
-------
|
||||
|
||||
It is important to test the code to make sure your changes don't break anything major and that they pass PEP8 style conventions.
|
||||
First, you need to locally install ``tox``
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ pip install tox
|
||||
|
||||
|
||||
You can then run all of the tests with the following command:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ tox
|
||||
|
||||
**Tips**
|
||||
|
||||
If you only want to see if you can pass the local tests, you can run ``tox -e py37`` (or whatever python version you have installed. Only ``py36``, ``py37``, and ``py38`` will be accepted). If you just want to check for style violations, you can run ``tox -e lint``. Regardless, when you submit a pull request, your code MUST pass both the unit tests, and the linters.
|
||||
|
||||
If you need to change anything in ``requirements.txt`` for any reason, you'll want to regenerate the virtual envrionments used by ``tox`` by running with the ``-r`` flag: ``tox -r``
|
||||
|
||||
If you want to run a single test (perhaps you only changed a small thing in one file) you can run ``tox -e py37 -- tests/<testname>.py -x``. This will run the test ``<testname>.py`` and stop testing upon the first failure, making it easier to figure out why a particular test might be failing. The test structure mimics the library structure, so if you changed something in ``sync_module.py``, the associated test file would be in ``test_sync_module.py`` (ie. the filename is prepended with ``test_``.
|
||||
|
||||
|
||||
Catching Up With Reality
|
||||
-------------------------
|
||||
|
||||
If your code is taking a while to develop, you may be behind the ``dev`` branch, in which case you need to catch up before creating your pull-request. To do this you can run ``git rebase`` as follows (running this on your local branch):
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ git fetch upstream dev
|
||||
$ git rebase upstream/dev
|
||||
|
||||
If rebase detects conflicts, repeat the following process until all changes have been resolved:
|
||||
|
||||
1. ``git status`` shows you the filw with a conflict. You will need to edit that file and resolve the lines between ``<<<< | >>>>``.
|
||||
2. Add the modified file: ``git add <file>`` or ``git add .``.
|
||||
3. Continue rebase: ``git rebase --continue``.
|
||||
4. Repeat until all conflicts resolved.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM python:3.7-alpine
|
||||
LABEL maintainer="Kevin Fronczak <kfronczak@gmail.com>"
|
||||
|
||||
VOLUME /media
|
||||
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN pip3 install blinkpy
|
||||
|
||||
COPY app/ .
|
||||
|
||||
ENTRYPOINT ["python", "./app.py"]
|
||||
CMD []
|
||||
@@ -0,0 +1,4 @@
|
||||
include README.rst
|
||||
include LICENSE.md
|
||||
include API.md
|
||||
include tests/*.py
|
||||
+163
-96
@@ -1,9 +1,12 @@
|
||||
**blinkpy**
|
||||
============
|
||||
A Python library for the Blink Camera system
|
||||
blinkpy |Build Status| |Coverage Status| |Docs| |PyPi Version| |Codestyle|
|
||||
=============================================================================================
|
||||
A Python library for the Blink Camera system (Python 3.6+)
|
||||
|
||||
**Disclaimers**
|
||||
===============
|
||||
Like the library? Consider buying me a cup of coffee!
|
||||
|
||||
`Buy me a Coffee! <https://buymeacoffee.com/kevinfronczak>`__
|
||||
|
||||
**Disclaimer:**
|
||||
Published under the MIT license - See LICENSE file for more details.
|
||||
|
||||
"Blink Wire-Free HS Home Monitoring & Alert Systems" is a trademark owned by Immedia Inc., see www.blinkforhome.com for more information.
|
||||
@@ -11,118 +14,182 @@ I am in no way affiliated with Blink, nor Immedia Inc.
|
||||
|
||||
Original protocol hacking by MattTW : https://github.com/MattTW/BlinkMonitorProtocol
|
||||
|
||||
**Installation**
|
||||
================
|
||||
``pip3 install blinkpy``
|
||||
API calls faster than 60 seconds is not recommended as it can overwhelm Blink's servers. Please use this module responsibly.
|
||||
|
||||
**Purpose**
|
||||
===========
|
||||
This library was built with the intention of allowing easy communication with Blink camera systems, specifically so I can add a module into homeassistant https://home-assistant.io
|
||||
Installation
|
||||
-------------
|
||||
``pip install blinkpy``
|
||||
|
||||
**Usage**
|
||||
=========
|
||||
In terms of usage, you just need to instantiate the module with a username and password
|
||||
::
|
||||
import blinkpy
|
||||
blink = blinkpy.Blink(username='YOUR USER NAME', password='YOUR PASSWORD')
|
||||
Installing Development Version
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
To install the current development version, perform the following steps. Note that the following will create a blinkpy directory in your home area:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ cd ~
|
||||
$ git clone https://github.com/fronzbot/blinkpy.git
|
||||
$ cd blinkpy
|
||||
$ rm -rf build dist
|
||||
$ python3 setup.py bdist_wheel
|
||||
$ pip3 install --upgrade dist/*.whl
|
||||
|
||||
|
||||
If you leave out either of those parameters, you need to call the login function which will prompt for your username and password
|
||||
::
|
||||
blink.login()
|
||||
If you'd like to contribute to this library, please read the `contributing instructions <https://github.com/fronzbot/blinkpy/blob/dev/CONTRIBUTING.rst>`__.
|
||||
|
||||
For more information on how to use this library, please `read the docs <https://blinkpy.readthedocs.io/en/latest/>`__.
|
||||
|
||||
Purpose
|
||||
-------
|
||||
This library was built with the intention of allowing easy communication with Blink camera systems, specifically to support the `Blink component <https://home-assistant.io/components/blink>`__ in `homeassistant <https://home-assistant.io/>`__.
|
||||
|
||||
Quick Start
|
||||
=============
|
||||
The simplest way to use this package from a terminal is to call ``Blink.start()`` which will prompt for your Blink username and password and then log you in. In addition, http requests are throttled internally via use of the ``Blink.refresh_rate`` variable, which can be set at initialization and defaults to 30 seconds.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
|
||||
blink = Blink()
|
||||
blink.start()
|
||||
|
||||
|
||||
Once the login information is entered, you can run the `setup_system()` function which will attempt to authenticate with Blink servers using your username and password, obtain network ids, and create a list of cameras.
|
||||
The cameras are of a BlinkCamera class, of which the following parameters can be used (the code below creates a Blink object and iterates through each camera found)
|
||||
::
|
||||
blink = blinkpy.Blink(username='YOUR USER NAME', password='YOUR PASSWORD')
|
||||
blink.setup_system()
|
||||
This flow will prompt you for your username and password. Once entered, if you likely will need to send a 2FA key to the blink servers (this pin is sent to your email address). When you receive this pin, enter at the prompt and the Blink library will proceed with setup.
|
||||
|
||||
for camera in blink.cameras:
|
||||
print(camera.name) # Name of the camera
|
||||
print(camera.id) # Integer id of the camera (assigned by Blink)
|
||||
print(camera.armed) # Whether the device is armed/disarmed (ie. detecting motion)
|
||||
print(camera.clip) # Link to last motion clip captured
|
||||
print(camera.thumbnail) # Link to current camera thumbnail
|
||||
print(camera.temperature) # Current camera temperature (not super accurate, but might be useful for someone)
|
||||
print(camera.battery) # Current battery level... I think the value ranges from 0-3, but not quite sure yet.
|
||||
print(camera.notifications) # Number of unread notifications (ie. motion alerts that haven't been viewed)
|
||||
print(camera.motion) # Dictionary containing values for keys ['video', 'image', 'time']
|
||||
# which correspond to last motion recorded, thumbnail of last motion, and timestamp of last motion
|
||||
Starting blink without a prompt
|
||||
-------------------------------
|
||||
In some cases, having an interactive command-line session is not desired. In this case, you will need to set the ``Blink.auth.no_prompt`` value to ``True``. In addition, since you will not be prompted with a username and password, you must supply the login data to the blink authentication handler. This is best done by instantiating your own auth handler with a dictionary containing at least your username and password.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.auth import Auth
|
||||
|
||||
blink = Blink()
|
||||
# Can set no_prompt when initializing auth handler
|
||||
auth = Auth({"username": <your username>, "password": <your password>}, no_prompt=True)
|
||||
blink.auth = auth
|
||||
blink.start()
|
||||
|
||||
|
||||
**class Blink**
|
||||
---------------
|
||||
The following properties/methods are availiable to the Blink class
|
||||
Since you will not be prompted for any 2FA pin, you must call the ``blink.auth.send_auth_key`` function. There are two required parameters: the ``blink`` object as well as the ``key`` you received from Blink for 2FA:
|
||||
|
||||
**Blink.cameras**
|
||||
Returns a list of BlinkCamera objects found by the system
|
||||
.. code:: python
|
||||
|
||||
**Blink.network_id**
|
||||
Returns the current network id
|
||||
|
||||
**Blink.account_id**
|
||||
Returns the account id
|
||||
|
||||
**Blink.events**
|
||||
Returns a list of events recorded by blink. This information will contain links to any motion caught by an armed camera.
|
||||
|
||||
**Blink.online**
|
||||
Returns online status of sync module (True = online, False = offline)
|
||||
|
||||
**Blink.last_motion()**
|
||||
Finds last motion information for each camera and stores it in the BlinkCamera.motion field
|
||||
|
||||
**Blink.arm**
|
||||
Set to True to arm, False to disarm. Can be used to see the status of the system as well
|
||||
|
||||
**Blink.refresh()**
|
||||
Forces a refresh of all camera information
|
||||
|
||||
**Blink.get_summary()**
|
||||
Returns json formatted summary of the system
|
||||
|
||||
**Blink.get_cameras()**
|
||||
Finds all cameras in the system and creates them
|
||||
|
||||
**Blink.set_links()**
|
||||
Gives each BlinkCamera object the links needed to find recent images and videos
|
||||
|
||||
**Blink.login()**
|
||||
Prompts user for login information
|
||||
|
||||
**Blink.get_auth_token()**
|
||||
Uses login information to retrieve authorization token from Blink for further communication
|
||||
|
||||
**Blink.get_ids()**
|
||||
Retrieves the network_id and account_id from Blink in order to access video and image pages on their server
|
||||
|
||||
**Blink.setup_system()**
|
||||
A wrapper script that calls:
|
||||
::
|
||||
self.get_auth_token()
|
||||
self.get_ids()
|
||||
self.get_camers()
|
||||
self.set_links()
|
||||
auth.send_auth_key(blink, <your key>)
|
||||
blink.setup_post_verify()
|
||||
|
||||
|
||||
**class BlinkCamera**
|
||||
---------------------
|
||||
This class is just a wrapper for each individual camera in order to make communication with individual cameras less clunky. The following properties/methods are availiable (in addition to the ones mentioned earlier)
|
||||
Supplying credentials from file
|
||||
--------------------------------
|
||||
Other use cases may involved loading credentials from a file. This file must be ``json`` formatted and contain a minimum of ``username`` and ``password``. A built in function in the ``blinkpy.helpers.util`` module can aid in loading this file. Note, if ``no_prompt`` is desired, a similar flow can be followed as above.
|
||||
|
||||
**BlinkCamera.snap_picture()**
|
||||
Takes an image with the camera and saves it as the new thumbnail. The Blink.refresh() method should be called after this if you want to store the new thumbnail link
|
||||
.. code:: python
|
||||
|
||||
**BlinkCamera.set_motion_detect(enable=True/False)**
|
||||
Sending True to this function will enable motion detection for the camera. Setting to False will disable motion detection
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.auth import Auth
|
||||
from blinkpy.helpers.util import json_load
|
||||
|
||||
**BlinkCamera.image_to_file(path)**
|
||||
This will write the current thumbnail to the location indicated in 'path'
|
||||
blink = Blink()
|
||||
auth = Auth(json_load("<File Location>"))
|
||||
blink.auth = auth
|
||||
blink.start()
|
||||
|
||||
|
||||
Saving credentials
|
||||
-------------------
|
||||
This library also allows you to save your credentials to use in future sessions. Saved information includes authentication tokens as well as unique ids which should allow for a more streamlined experience and limits the frequency of login requests. This data can be saved as follows (it can then be loaded by following the instructions above for supplying credentials from a file):
|
||||
|
||||
.. code:: python
|
||||
|
||||
blink.save("<File location>")
|
||||
|
||||
|
||||
Getting cameras
|
||||
----------------
|
||||
Cameras are instantiated as individual ``BlinkCamera`` classes within a ``BlinkSyncModule`` instance. All of your sync modules are stored within the ``Blink.sync`` dictionary and can be accessed using the name of the sync module as the key (this is the name of your sync module in the Blink App).
|
||||
|
||||
The below code will display cameras and their available attributes:
|
||||
|
||||
.. code:: python
|
||||
|
||||
for name, camera in blink.cameras.items():
|
||||
print(name) # Name of the camera
|
||||
print(camera.attributes) # Print available attributes of camera
|
||||
|
||||
|
||||
The most recent images and videos can be accessed as a bytes-object via internal variables. These can be updated with calls to ``Blink.refresh()`` but will only make a request if motion has been detected or other changes have been found. This can be overridden with the ``force`` flag, but this should be used for debugging only since it overrides the internal request throttling.
|
||||
|
||||
.. code:: python
|
||||
|
||||
camera = blink.cameras['SOME CAMERA NAME']
|
||||
blink.refresh(force=True) # force a cache update USE WITH CAUTION
|
||||
camera.image_from_cache.raw # bytes-like image object (jpg)
|
||||
camera.video_from_cache.raw # bytes-like video object (mp4)
|
||||
|
||||
The ``blinkpy`` api also allows for saving images and videos to a file and snapping a new picture from the camera remotely:
|
||||
|
||||
.. code:: python
|
||||
|
||||
camera = blink.cameras['SOME CAMERA NAME']
|
||||
camera.snap_picture() # Take a new picture with the camera
|
||||
blink.refresh() # Get new information from server
|
||||
camera.image_to_file('/local/path/for/image.jpg')
|
||||
camera.video_to_file('/local/path/for/video.mp4')
|
||||
|
||||
|
||||
Arming Blink
|
||||
-------------
|
||||
Methods exist to arm/disarm the sync module, as well as enable/disable motion detection for individual cameras. This is done as follows:
|
||||
|
||||
.. code:: python
|
||||
|
||||
# Arm a sync module
|
||||
blink.sync["SYNC MODULE NAME"].arm = True
|
||||
|
||||
# Disarm a sync module
|
||||
blink.sync["SYNC MODULE NAME"].disarm = False
|
||||
|
||||
# Print arm status of a sync module - a system refresh should be performed first
|
||||
blink.refresh()
|
||||
sync = blink.sync["SYNC MODULE NAME"]
|
||||
print(f"{sync.name} status: {sync.arm}")
|
||||
|
||||
Similar methods exist for individual cameras:
|
||||
|
||||
.. code:: python
|
||||
|
||||
camera = blink.cameras["SOME CAMERA NAME"]
|
||||
|
||||
# Enable motion detection on a camera
|
||||
camera.arm = True
|
||||
|
||||
# Disable motion detection on a camera
|
||||
camera.arm = False
|
||||
|
||||
# Print arm status of a sync module - a system refresh should be performed first
|
||||
blink.refresh()
|
||||
print(f"{camera.name} status: {camera.arm}")
|
||||
|
||||
|
||||
Download videos
|
||||
----------------
|
||||
You can also use this library to download all videos from the server. In order to do this, you must specify a ``path``. You may also specifiy a how far back in time to go to retrieve videos via the ``since=`` variable (a simple string such as ``"2017/09/21"`` is sufficient), as well as how many pages to traverse via the ``page=`` variable. Note that by default, the library will search the first ten pages which is sufficient in most use cases. Additionally, you can specidy one or more cameras via the ``camera=`` property. This can be a single string indicating the name of the camera, or a list of camera names. By default, it is set to the string ``'all'`` to grab videos from all cameras.
|
||||
|
||||
Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34am to the ``/home/blink`` directory:
|
||||
|
||||
.. code:: python
|
||||
|
||||
blink.download_videos('/home/blink', since='2018/07/04 09:34')
|
||||
|
||||
|
||||
.. |Build Status| image:: https://github.com/fronzbot/blinkpy/workflows/build/badge.svg
|
||||
:target: https://github.com/fronzbot/blinkpy/actions?query=workflow%3Abuild
|
||||
.. |Coverage Status| image:: https://codecov.io/gh/fronzbot/blinkpy/branch/dev/graph/badge.svg
|
||||
:target: https://codecov.io/gh/fronzbot/blinkpy
|
||||
.. |PyPi Version| image:: https://img.shields.io/pypi/v/blinkpy.svg
|
||||
:target: https://pypi.python.org/pypi/blinkpy
|
||||
.. |Docs| image:: https://readthedocs.org/projects/blinkpy/badge/?version=latest
|
||||
:target: http://blinkpy.readthedocs.io/en/latest/?badge=latest
|
||||
.. |Codestyle| image:: https://img.shields.io/badge/code%20style-black-000000.svg
|
||||
:target: https://github.com/psf/black
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from blinkpy import Blink
|
||||
@@ -0,0 +1 @@
|
||||
"""Python init file for app.py."""
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
"""Script to run blinkpy as an app."""
|
||||
from os import environ
|
||||
from datetime import datetime, timedelta
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.auth import Auth
|
||||
from blinkpy.helpers.util import json_load
|
||||
|
||||
|
||||
CREDFILE = environ.get("CREDFILE")
|
||||
TIMEDELTA = timedelta(environ.get("TIMEDELTA", 1))
|
||||
|
||||
|
||||
def get_date():
|
||||
"""Return now - timedelta for blinkpy."""
|
||||
return (datetime.now() - TIMEDELTA).isoformat()
|
||||
|
||||
|
||||
def download_videos(blink, save_dir="/media"):
|
||||
"""Make request to download videos."""
|
||||
blink.download_videos(save_dir, since=get_date())
|
||||
|
||||
|
||||
def start():
|
||||
"""Startup blink app."""
|
||||
blink = Blink()
|
||||
blink.auth = Auth(json_load(CREDFILE))
|
||||
blink.start()
|
||||
return blink
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the app."""
|
||||
blink = start()
|
||||
download_videos(blink)
|
||||
blink.save(CREDFILE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
docker build -t fronzbot/blinkpy:latest ./
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# bash run.sh [username] [password]
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo ""
|
||||
echo "ERROR: Requries Blink username and password as arguments."
|
||||
echo "bash run.sh [username] [password]"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -ex
|
||||
USER=fronzbot
|
||||
IMAGE=blinkpy
|
||||
CONFIG=$HOME/blinkpy_media
|
||||
USERNAME=$1
|
||||
PASSWORD=$2
|
||||
|
||||
mkdir -p $CONFIG
|
||||
|
||||
result=$(docker images -q $IMAGE)
|
||||
if [ $result ]; then
|
||||
docker rm $IMAGE
|
||||
fi
|
||||
docker run -it --name ${IMAGE} \
|
||||
-v $CONFIG:/media \
|
||||
-e USERNAME=${USERNAME} \
|
||||
-e PASSWORD=${PASSWORD} \
|
||||
$USER/$IMAGE \
|
||||
/bin/bash
|
||||
|
||||
-394
@@ -1,394 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
'''
|
||||
blinkpy by Kevin Fronczak - A Blink camera Python library
|
||||
https://github.com/fronzbot/blinkpy
|
||||
Original protocol hacking by MattTW : https://github.com/MattTW/BlinkMonitorProtocol
|
||||
|
||||
Published under the MIT license - See LICENSE file for more details.
|
||||
|
||||
"Blink Wire-Free HS Home Monitoring & Alert Systems" is a trademark owned by Immedia Inc., see www.blinkforhome.com for more information.
|
||||
I am in no way affiliated with Blink, nor Immedia Inc.
|
||||
'''
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import getpass
|
||||
import json
|
||||
|
||||
BLINK_URL = 'immedia-semi.com'
|
||||
LOGIN_URL = 'https://prod.' + BLINK_URL + '/login'
|
||||
BASE_URL = 'https://prod.' + BLINK_URL
|
||||
DEFAULT_URL = 'prod.' + BLINK_URL
|
||||
|
||||
logger = logging.getLogger('blinkpy')
|
||||
|
||||
|
||||
def _request(url, data=None, headers=None, type='get', stream=False, json=True):
|
||||
"""Wrapper function for request"""
|
||||
if type is 'post':
|
||||
response = requests.post(url, headers=headers, data=data).json()
|
||||
elif type is 'get' and json:
|
||||
response = requests.get(url, headers=headers, stream=stream).json()
|
||||
elif type is 'get' and not json:
|
||||
response = requests.get(url, headers=headers, stream=stream)
|
||||
else:
|
||||
raise ValueError("Cannot perform requests of type " + type)
|
||||
|
||||
if json and 'message' in response.keys():
|
||||
raise BlinkAuthenticationException(response['code'], response['message'])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class BlinkException(Exception):
|
||||
def __init__(self, id, message):
|
||||
self.id = id
|
||||
self.message = message
|
||||
|
||||
|
||||
class BlinkAuthenticationException(BlinkException):
|
||||
pass
|
||||
|
||||
|
||||
class BlinkCamera(object):
|
||||
"""Class to initialize individual camera"""
|
||||
def __init__(self, config):
|
||||
self._ID = str(config['device_id'])
|
||||
self._NAME = config['name']
|
||||
self._STATUS = config['armed']
|
||||
self._THUMB = BASE_URL + config['thumbnail'] + '.jpg'
|
||||
self._CLIP = BASE_URL + config['thumbnail'] + '.mp4'
|
||||
self._TEMPERATURE = config['temp']
|
||||
self._BATTERY = config['battery']
|
||||
self._NOTIFICATIONS = config['notifications']
|
||||
self._MOTION = {}
|
||||
self._HEADER = None
|
||||
self._IMAGE_LINK = None
|
||||
self._ARM_LINK = None
|
||||
self._REGION_ID = config['region_id']
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._ID
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._NAME
|
||||
|
||||
@name.setter
|
||||
def name(self, value):
|
||||
self._NAME = value
|
||||
|
||||
@property
|
||||
def region_id(self):
|
||||
return self._REGION_ID
|
||||
|
||||
@property
|
||||
def armed(self):
|
||||
return self._STATUS
|
||||
|
||||
@property
|
||||
def clip(self):
|
||||
return self._CLIP
|
||||
|
||||
@clip.setter
|
||||
def clip(self, value):
|
||||
self._CLIP = value
|
||||
|
||||
@property
|
||||
def thumbnail(self):
|
||||
# RUN THUMB ACQ HERE
|
||||
return self._THUMB
|
||||
|
||||
@thumbnail.setter
|
||||
def thumbnail(self, value):
|
||||
self._THUMB = value
|
||||
|
||||
@property
|
||||
def temperature(self):
|
||||
return self._TEMPERATURE
|
||||
|
||||
@temperature.setter
|
||||
def temperature(self, value):
|
||||
self._TEMPERATURE = value
|
||||
|
||||
@property
|
||||
def battery(self):
|
||||
return self._BATTERY
|
||||
|
||||
@battery.setter
|
||||
def battery(self, value):
|
||||
self._BATTERY = value
|
||||
|
||||
@property
|
||||
def notifications(self):
|
||||
return self._NOTIFICATIONS
|
||||
|
||||
@notifications.setter
|
||||
def notifications(self, value):
|
||||
self._NOTIFICATIONS = value
|
||||
|
||||
@property
|
||||
def image_link(self):
|
||||
return self._IMAGE_LINK
|
||||
|
||||
@image_link.setter
|
||||
def image_link(self, value):
|
||||
self._IMAGE_LINK = value
|
||||
|
||||
@property
|
||||
def arm_link(self):
|
||||
return self._ARM_LINK
|
||||
|
||||
@arm_link.setter
|
||||
def arm_link(self, value):
|
||||
self._ARM_LINK = value
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
return self._HEADER
|
||||
|
||||
@header.setter
|
||||
def header(self, value):
|
||||
self._HEADER = value
|
||||
|
||||
@property
|
||||
def motion(self):
|
||||
return self._MOTION
|
||||
|
||||
@motion.setter
|
||||
def motion(self, value):
|
||||
"""Sets link to last motion and timestamp"""
|
||||
self._MOTION = value
|
||||
|
||||
def snap_picture(self):
|
||||
"""Takes a picture with camera to create a new thumbnail"""
|
||||
_request(self._IMAGE_LINK, headers=self._HEADER, type='post')
|
||||
|
||||
def set_motion_detect(self, enable):
|
||||
"""Sets motion detection"""
|
||||
url = self._ARM_LINK
|
||||
if enable:
|
||||
_request(url + 'enable', headers=self._HEADER, type='post')
|
||||
else:
|
||||
_request(url + 'disable', headers=self._HEADER, type='post')
|
||||
|
||||
def update(self, values):
|
||||
self._NAME = values['name']
|
||||
self._STATUS = values['armed']
|
||||
self._THUMB = BASE_URL + values['thumbnail'] + '.jpg'
|
||||
self._CLIP = BASE_URL + values['thumbnail'] + '.mp4'
|
||||
self._TEMPERATURE = values['temp']
|
||||
self._BATTERY = values['battery']
|
||||
self._NOTIFICATIONS = values['notifications']
|
||||
|
||||
def image_refresh(self):
|
||||
url = BASE_URL + '/homescreen'
|
||||
response = _request(url, headers=self._HEADER, type='get')['devices']
|
||||
for element in response:
|
||||
try:
|
||||
if str(element['device_id']) == self._ID:
|
||||
self._THUMB = BASE_URL + element['thumbnail'] + '.jpg'
|
||||
return self._THUMB
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def image_to_file(self, path):
|
||||
thumb = self.image_refresh()
|
||||
response = _request(thumb, headers=self._HEADER, stream=True, json=False)
|
||||
if response.status_code == 200:
|
||||
with open(path, 'wb') as f:
|
||||
for chunk in response:
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
class Blink(object):
|
||||
"""Class to initialize communication and sync module"""
|
||||
def __init__(self, username=None, password=None):
|
||||
"""Constructor for class"""
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._TOKEN = None
|
||||
self._AUTH_HEADER = None
|
||||
self._NETWORKID = None
|
||||
self._ACCOUNTID = None
|
||||
self._REGION = None
|
||||
self._REGION_ID = None
|
||||
self._HOST = None
|
||||
self._EVENTS = []
|
||||
self._CAMERAS = {}
|
||||
self._IDLOOKUP = {}
|
||||
|
||||
@property
|
||||
def cameras(self):
|
||||
return self._CAMERAS
|
||||
|
||||
@property
|
||||
def camera_thumbs(self):
|
||||
self.refresh()
|
||||
data = {}
|
||||
for name, camera in self._CAMERAS.items():
|
||||
data[name] = camera.thumbnail
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def id_table(self):
|
||||
return self._IDLOOKUP
|
||||
|
||||
@property
|
||||
def network_id(self):
|
||||
return self._NETWORKID
|
||||
|
||||
@property
|
||||
def account_id(self):
|
||||
return self._ACCOUNTID
|
||||
|
||||
@property
|
||||
def region(self):
|
||||
return self._REGION
|
||||
|
||||
@property
|
||||
def region_id(self):
|
||||
return self._REGION_ID
|
||||
|
||||
@property
|
||||
def events(self):
|
||||
"""Gets all events on server"""
|
||||
url = BASE_URL + '/events/network/' + self._NETWORKID
|
||||
headers = self._AUTH_HEADER
|
||||
self._EVENTS = _request(url, headers=headers, type='get')['event']
|
||||
return self._EVENTS
|
||||
|
||||
@property
|
||||
def online(self):
|
||||
"""Returns True or False depending on if sync module is online/offline"""
|
||||
url = BASE_URL + 'network/' + self._NETWORKID + '/syncmodules'
|
||||
headers = self._AUTH_HEADER
|
||||
online_dict = {'online': True, 'offline': False}
|
||||
return online_dict[_request(url, headers=headers, type='get')['syncmodule']['status']]
|
||||
|
||||
def last_motion(self):
|
||||
"""Finds last motion of each camera"""
|
||||
recent = self.events
|
||||
for element in recent:
|
||||
try:
|
||||
camera_id = str(element['camera_id'])
|
||||
camera_name = self._IDLOOKUP[camera_id]
|
||||
camera = self._CAMERAS[camera_name]
|
||||
if element['type'] == 'motion':
|
||||
url = BASE_URL + element['video_url']
|
||||
camera.motion = {'video': url, 'image': url[:-3] + 'jpg', 'time': element['created_at']}
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def arm(self):
|
||||
"""Returns status of sync module: armed/disarmed"""
|
||||
return self.get_summary()['network']['armed']
|
||||
|
||||
@arm.setter
|
||||
def arm(self, value):
|
||||
"""Arms or disarms system. Arms/disarms all if camera not named"""
|
||||
if value:
|
||||
value_to_append = 'arm'
|
||||
else:
|
||||
value_to_append = 'disarm'
|
||||
url = BASE_URL + '/network/' + self._NETWORKID + '/' + value_to_append
|
||||
_request(url, headers=self._AUTH_HEADER, type='post')
|
||||
|
||||
def refresh(self):
|
||||
"""Gets all blink cameras and pulls their most recent status"""
|
||||
response = self.get_summary()['devices']
|
||||
|
||||
for name, camera in self._CAMERAS.items():
|
||||
for element in response:
|
||||
try:
|
||||
if str(element['device_id']) == camera.id:
|
||||
camera.update(element)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def get_summary(self):
|
||||
"""Gets a full summary of device information"""
|
||||
url = BASE_URL + '/homescreen'
|
||||
headers = self._AUTH_HEADER
|
||||
|
||||
if self._AUTH_HEADER is None:
|
||||
raise BlinkException(0, "Authentication header incorrect. Are you sure you logged in and received your token?")
|
||||
|
||||
return _request(url, headers=headers, type='get')
|
||||
|
||||
def get_cameras(self):
|
||||
"""Finds and creates cameras"""
|
||||
response = self.get_summary()['devices']
|
||||
for element in response:
|
||||
if 'device_type' in element.keys():
|
||||
if element['device_type'] == 'camera':
|
||||
# Add region to config
|
||||
element['region_id'] = self._REGION_ID
|
||||
device = BlinkCamera(element)
|
||||
self._CAMERAS[device.name] = device
|
||||
self._IDLOOKUP[device.id] = device.name
|
||||
|
||||
def set_links(self):
|
||||
"""Sets access links and required headers for each camera in system"""
|
||||
for name, camera in self._CAMERAS.items():
|
||||
image_url = BASE_URL + '/network/' + self._NETWORKID + '/camera/' + camera.id + '/thumbnail'
|
||||
arm_url = BASE_URL + '/network/' + self._NETWORKID + '/camera/' + camera.id + '/'
|
||||
camera.image_link = image_url
|
||||
camera.arm_link = arm_url
|
||||
camera.header = self._AUTH_HEADER
|
||||
|
||||
def setup_system(self):
|
||||
"""Method logs in and sets auth token and network ids for future requests"""
|
||||
if self._username is None or self._password is None:
|
||||
raise BlinkAuthenticationException(3, "Cannot authenticate since either password or username has not been set")
|
||||
|
||||
self.get_auth_token()
|
||||
self.get_ids()
|
||||
self.get_cameras()
|
||||
self.set_links()
|
||||
|
||||
def login(self):
|
||||
"""Prompts user for username and password"""
|
||||
self._username = input("Username:")
|
||||
self._password = getpass.getpass("Password:")
|
||||
|
||||
def get_auth_token(self):
|
||||
"""Retrieves the authentication token from Blink"""
|
||||
if not isinstance(self._username, str):
|
||||
raise BlinkAuthenticationException(0, "Username must be a string")
|
||||
if not isinstance(self._password, str):
|
||||
raise BlinkAuthenticationException(0, "Password must be a string")
|
||||
|
||||
headers = {'Host': DEFAULT_URL,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
data = json.dumps({
|
||||
"email": self._username,
|
||||
"password": self._password,
|
||||
"client_specifier": "iPhone 9.2 | 2.2 | 222"
|
||||
})
|
||||
response = _request(LOGIN_URL, headers=headers, data=data, type='post')
|
||||
self._TOKEN = response['authtoken']['authtoken']
|
||||
(self._REGION_ID, self._REGION), = response['region'].items()
|
||||
self._HOST = self._REGION + '.' + BLINK_URL
|
||||
self._AUTH_HEADER = {'Host': self._HOST,
|
||||
'TOKEN_AUTH': self._TOKEN
|
||||
}
|
||||
|
||||
def get_ids(self):
|
||||
"""Sets the network ID and Account ID"""
|
||||
url = BASE_URL + '/networks'
|
||||
headers = self._AUTH_HEADER
|
||||
|
||||
if self._AUTH_HEADER is None:
|
||||
raise BlinkException(0, "Authentication header incorrect. Are you sure you logged in and received your token?")
|
||||
|
||||
response = _request(url, headers=headers, type='get')
|
||||
self._NETWORKID = str(response['networks'][0]['id'])
|
||||
self._ACCOUNTID = str(response['networks'][0]['account_id'])
|
||||
@@ -0,0 +1 @@
|
||||
"""Init file for blinkpy."""
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
"""Implements known blink API calls."""
|
||||
|
||||
import logging
|
||||
from json import dumps
|
||||
from blinkpy.helpers.util import get_time, Throttle
|
||||
from blinkpy.helpers.constants import DEFAULT_URL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MIN_THROTTLE_TIME = 5
|
||||
|
||||
|
||||
def request_login(
|
||||
auth, url, login_data, is_retry=False,
|
||||
):
|
||||
"""
|
||||
Login request.
|
||||
|
||||
:param auth: Auth instance.
|
||||
:param url: Login url.
|
||||
:login_data: Dictionary containing blink login data.
|
||||
"""
|
||||
headers = {"Host": DEFAULT_URL, "Content-Type": "application/json"}
|
||||
data = dumps(
|
||||
{
|
||||
"email": login_data["username"],
|
||||
"password": login_data["password"],
|
||||
"notification_key": login_data["notification_key"],
|
||||
"unique_id": login_data["uid"],
|
||||
"app_version": "6.0.7 (520300) #afb0be72a",
|
||||
"device_identifier": login_data["device_id"],
|
||||
"client_name": "Computer",
|
||||
"client_type": "android",
|
||||
"os_version": "5.1.1",
|
||||
"reauth": "false",
|
||||
}
|
||||
)
|
||||
return auth.query(
|
||||
url=url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_resp=False,
|
||||
reqtype="post",
|
||||
is_retry=is_retry,
|
||||
)
|
||||
|
||||
|
||||
def request_verify(auth, blink, verify_key):
|
||||
"""Send verification key to blink servers."""
|
||||
url = f"{blink.urls.base_url}/api/v4/account/{blink.account_id}/client/{blink.client_id}/pin/verify"
|
||||
data = dumps({"pin": verify_key})
|
||||
return auth.query(
|
||||
url=url, headers=auth.header, data=data, json_resp=False, reqtype="post",
|
||||
)
|
||||
|
||||
|
||||
def request_networks(blink):
|
||||
"""Request all networks information."""
|
||||
url = f"{blink.urls.base_url}/networks"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_network_update(blink, network):
|
||||
"""
|
||||
Request network update.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/update"
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
def request_user(blink):
|
||||
"""Get user information from blink servers."""
|
||||
url = f"{blink.urls.base_url}/user"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_network_status(blink, network):
|
||||
"""
|
||||
Request network information.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_syncmodule(blink, network):
|
||||
"""
|
||||
Request sync module info.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/syncmodules"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_system_arm(blink, network):
|
||||
"""
|
||||
Arm system.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/state/arm"
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_system_disarm(blink, network):
|
||||
"""
|
||||
Disarm system.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/state/disarm"
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
def request_command_status(blink, network, command_id):
|
||||
"""
|
||||
Request command status.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param command_id: Command id to check.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/command/{command_id}"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_homescreen(blink):
|
||||
"""Request homescreen info."""
|
||||
url = f"{blink.urls.base_url}/api/v3/accounts/{blink.account_id}/homescreen"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_sync_events(blink, network):
|
||||
"""
|
||||
Request events from sync module.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/events/network/{network}"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_new_image(blink, network, camera_id):
|
||||
"""
|
||||
Request to capture new thumbnail for camera.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param camera_id: Camera ID of camera to request new image from.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/thumbnail"
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_new_video(blink, network, camera_id):
|
||||
"""
|
||||
Request to capture new video clip.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param camera_id: Camera ID of camera to request new video from.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/clip"
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_video_count(blink):
|
||||
"""Request total video count."""
|
||||
url = f"{blink.urls.base_url}/api/v2/videos/count"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_videos(blink, time=None, page=0):
|
||||
"""
|
||||
Perform a request for videos.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param time: Get videos since this time. In epoch seconds.
|
||||
:param page: Page number to get videos from.
|
||||
"""
|
||||
timestamp = get_time(time)
|
||||
url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/media/changed?since={timestamp}&page={page}"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_cameras(blink, network):
|
||||
"""
|
||||
Request all camera information.
|
||||
|
||||
:param Blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/cameras"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_camera_info(blink, network, camera_id):
|
||||
"""
|
||||
Request camera info for one camera.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param camera_id: Camera ID of camera to request info from.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_camera_usage(blink):
|
||||
"""
|
||||
Request camera status.
|
||||
|
||||
:param blink: Blink instance.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/api/v1/camera/usage"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
def request_camera_liveview(blink, network, camera_id):
|
||||
"""
|
||||
Request camera liveview.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param camera_id: Camera ID of camera to request liveview from.
|
||||
"""
|
||||
url = (
|
||||
f"{blink.urls.base_url}/api/v3/networks/{network}/cameras/{camera_id}/liveview"
|
||||
)
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
def request_camera_sensors(blink, network, camera_id):
|
||||
"""
|
||||
Request camera sensor info for one camera.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param camera_id: Camera ID of camera to request sesnor info from.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/signals"
|
||||
return http_get(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_motion_detection_enable(blink, network, camera_id):
|
||||
"""
|
||||
Enable motion detection for a camera.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param camera_id: Camera ID of camera to enable.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/enable"
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def request_motion_detection_disable(blink, network, camera_id):
|
||||
"""Disable motion detection for a camera.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param network: Sync module network id.
|
||||
:param camera_id: Camera ID of camera to disable.
|
||||
"""
|
||||
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/disable"
|
||||
return http_post(blink, url)
|
||||
|
||||
|
||||
def http_get(blink, url, stream=False, json=True, is_retry=False):
|
||||
"""
|
||||
Perform an http get request.
|
||||
|
||||
:param url: URL to perform get request.
|
||||
:param stream: Stream response? True/FALSE
|
||||
:param json: Return json response? TRUE/False
|
||||
:param is_retry: Is this part of a re-auth attempt?
|
||||
"""
|
||||
_LOGGER.debug("Making GET request to %s", url)
|
||||
return blink.auth.query(
|
||||
url=url,
|
||||
headers=blink.auth.header,
|
||||
reqtype="get",
|
||||
stream=stream,
|
||||
json_resp=json,
|
||||
is_retry=is_retry,
|
||||
)
|
||||
|
||||
|
||||
def http_post(blink, url, is_retry=False):
|
||||
"""
|
||||
Perform an http post request.
|
||||
|
||||
:param url: URL to perfom post request.
|
||||
:param is_retry: Is this part of a re-auth attempt?
|
||||
"""
|
||||
_LOGGER.debug("Making POST request to %s", url)
|
||||
return blink.auth.query(
|
||||
url=url, headers=blink.auth.header, reqtype="post", is_retry=is_retry
|
||||
)
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
"""Login handler for blink."""
|
||||
import logging
|
||||
from functools import partial
|
||||
from requests import Request, Session, exceptions
|
||||
from blinkpy import api
|
||||
from blinkpy.helpers import util
|
||||
from blinkpy.helpers.constants import BLINK_URL, LOGIN_ENDPOINT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Auth:
|
||||
"""Class to handle login communication."""
|
||||
|
||||
def __init__(self, login_data=None, no_prompt=False, login_method="v4"):
|
||||
"""
|
||||
Initialize auth handler.
|
||||
|
||||
:param login_data: dictionary for login data
|
||||
must contain the following:
|
||||
- username
|
||||
- password
|
||||
:param no_prompt: Should any user input prompts
|
||||
be supressed? True/FALSE
|
||||
:param login_method: Choose the login endpoint to use. Default: v4. v3 uses email verification rather than a 2FA code.
|
||||
"""
|
||||
if login_data is None:
|
||||
login_data = {}
|
||||
self.data = login_data
|
||||
self.token = login_data.get("token", None)
|
||||
self.host = login_data.get("host", None)
|
||||
self.region_id = login_data.get("region_id", None)
|
||||
self.client_id = login_data.get("client_id", None)
|
||||
self.account_id = login_data.get("account_id", None)
|
||||
self.login_url = LOGIN_ENDPOINT[login_method]
|
||||
self.login_response = None
|
||||
self.is_errored = False
|
||||
self.no_prompt = no_prompt
|
||||
self.session = self.create_session()
|
||||
|
||||
@property
|
||||
def login_attributes(self):
|
||||
"""Return a dictionary of login attributes."""
|
||||
self.data["token"] = self.token
|
||||
self.data["host"] = self.host
|
||||
self.data["region_id"] = self.region_id
|
||||
self.data["client_id"] = self.client_id
|
||||
self.data["account_id"] = self.account_id
|
||||
return self.data
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
"""Return authorization header."""
|
||||
if self.token is None:
|
||||
return None
|
||||
return {"Host": self.host, "TOKEN_AUTH": self.token}
|
||||
|
||||
def create_session(self):
|
||||
"""Create a session for blink communication."""
|
||||
sess = Session()
|
||||
sess.get = partial(sess.get, timeout=10)
|
||||
return sess
|
||||
|
||||
def prepare_request(self, url, headers, data, reqtype):
|
||||
"""Prepare a request."""
|
||||
req = Request(reqtype.upper(), url, headers=headers, data=data)
|
||||
return req.prepare()
|
||||
|
||||
def validate_login(self):
|
||||
"""Check login information and prompt if not available."""
|
||||
self.data["username"] = self.data.get("username", None)
|
||||
self.data["password"] = self.data.get("password", None)
|
||||
if not self.no_prompt:
|
||||
self.data = util.prompt_login_data(self.data)
|
||||
|
||||
self.data = util.validate_login_data(self.data)
|
||||
|
||||
def login(self):
|
||||
"""Attempt login to blink servers."""
|
||||
self.validate_login()
|
||||
_LOGGER.info("Attempting login with %s", self.login_url)
|
||||
response = api.request_login(self, self.login_url, self.data, is_retry=False,)
|
||||
try:
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
raise LoginError
|
||||
except AttributeError:
|
||||
raise LoginError
|
||||
|
||||
def refresh_token(self):
|
||||
"""Refresh auth token."""
|
||||
self.is_errored = True
|
||||
try:
|
||||
_LOGGER.info("Token expired, attempting automatic refresh.")
|
||||
self.login_response = self.login()
|
||||
self.region_id = self.login_response["region"]["tier"]
|
||||
self.host = f"{self.region_id}.{BLINK_URL}"
|
||||
self.token = self.login_response["authtoken"]["authtoken"]
|
||||
self.client_id = self.login_response["client"]["id"]
|
||||
self.account_id = self.login_response["account"]["id"]
|
||||
self.is_errored = False
|
||||
except LoginError:
|
||||
_LOGGER.error("Login endpoint failed. Try again later.")
|
||||
raise TokenRefreshFailed
|
||||
except (TypeError, KeyError):
|
||||
_LOGGER.error("Malformed login response: %s", self.login_response)
|
||||
raise TokenRefreshFailed
|
||||
return True
|
||||
|
||||
def startup(self):
|
||||
"""Initialize tokens for communication."""
|
||||
self.validate_login()
|
||||
if None in self.login_attributes.values():
|
||||
self.refresh_token()
|
||||
|
||||
def validate_response(self, response, json_resp):
|
||||
"""Check for valid response."""
|
||||
if not json_resp:
|
||||
self.is_errored = False
|
||||
return response
|
||||
self.is_errored = True
|
||||
try:
|
||||
if response.status_code in [101, 401]:
|
||||
raise UnauthorizedError
|
||||
if response.status_code == 404:
|
||||
raise exceptions.ConnectionError
|
||||
json_data = response.json()
|
||||
except KeyError:
|
||||
pass
|
||||
except (AttributeError, ValueError):
|
||||
raise BlinkBadResponse
|
||||
|
||||
self.is_errored = False
|
||||
return json_data
|
||||
|
||||
def query(
|
||||
self,
|
||||
url=None,
|
||||
data=None,
|
||||
headers=None,
|
||||
reqtype="get",
|
||||
stream=False,
|
||||
json_resp=True,
|
||||
is_retry=False,
|
||||
):
|
||||
"""
|
||||
Perform server requests.
|
||||
|
||||
:param url: URL to perform request
|
||||
:param data: Data to send
|
||||
:param headers: Headers to send
|
||||
:param reqtype: Can be 'get' or 'post' (default: 'get')
|
||||
:param stream: Stream response? True/FALSE
|
||||
:param json_resp: Return JSON response? TRUE/False
|
||||
:param is_retry: Is this part of a re-auth attempt? True/FALSE
|
||||
"""
|
||||
req = self.prepare_request(url, headers, data, reqtype)
|
||||
try:
|
||||
response = self.session.send(req, stream=stream)
|
||||
return self.validate_response(response, json_resp)
|
||||
except (exceptions.ConnectionError, exceptions.Timeout):
|
||||
_LOGGER.error(
|
||||
"Connection error. Endpoint %s possibly down or throttled. %s: %s",
|
||||
url,
|
||||
response.status_code,
|
||||
response.reason,
|
||||
)
|
||||
except BlinkBadResponse:
|
||||
_LOGGER.error(
|
||||
"Expected json response from %s, but received: %s: %s",
|
||||
url,
|
||||
response.status_code,
|
||||
response.reason,
|
||||
)
|
||||
except UnauthorizedError:
|
||||
try:
|
||||
if not is_retry:
|
||||
self.refresh_token()
|
||||
return self.query(
|
||||
url=url,
|
||||
data=data,
|
||||
headers=self.header,
|
||||
reqtype=reqtype,
|
||||
stream=stream,
|
||||
json_resp=json_resp,
|
||||
is_retry=True,
|
||||
)
|
||||
_LOGGER.error("Unable to access %s after token refresh.", url)
|
||||
except TokenRefreshFailed:
|
||||
_LOGGER.error("Unable to refresh token.")
|
||||
return None
|
||||
|
||||
def send_auth_key(self, blink, key):
|
||||
"""Send 2FA key to blink servers."""
|
||||
if key is not None:
|
||||
response = api.request_verify(self, blink, key)
|
||||
try:
|
||||
json_resp = response.json()
|
||||
blink.available = json_resp["valid"]
|
||||
except (KeyError, TypeError):
|
||||
_LOGGER.error("Did not receive valid response from server.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_key_required(self):
|
||||
"""Check if 2FA key is required."""
|
||||
try:
|
||||
if self.login_response["client"]["verification_required"]:
|
||||
return True
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class TokenRefreshFailed(Exception):
|
||||
"""Class to throw failed refresh exception."""
|
||||
|
||||
|
||||
class LoginError(Exception):
|
||||
"""Class to throw failed login exception."""
|
||||
|
||||
|
||||
class BlinkBadResponse(Exception):
|
||||
"""Class to throw bad json response exception."""
|
||||
|
||||
|
||||
class UnauthorizedError(Exception):
|
||||
"""Class to throw an unauthorized access error."""
|
||||
@@ -0,0 +1,348 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
blinkpy is an unofficial api for the Blink security camera system.
|
||||
|
||||
repo url: https://github.com/fronzbot/blinkpy
|
||||
|
||||
Original protocol hacking by MattTW :
|
||||
https://github.com/MattTW/BlinkMonitorProtocol
|
||||
|
||||
Published under the MIT license - See LICENSE file for more details.
|
||||
"Blink Wire-Free HS Home Monitoring & Alert Systems" is a trademark
|
||||
owned by Immedia Inc., see www.blinkforhome.com for more information.
|
||||
blinkpy is in no way affiliated with Blink, nor Immedia Inc.
|
||||
"""
|
||||
|
||||
import os.path
|
||||
import time
|
||||
import logging
|
||||
from shutil import copyfileobj
|
||||
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from dateutil.parser import parse
|
||||
from slugify import slugify
|
||||
|
||||
from blinkpy import api
|
||||
from blinkpy.sync_module import BlinkSyncModule, BlinkOwl
|
||||
from blinkpy.helpers import util
|
||||
from blinkpy.helpers.constants import (
|
||||
DEFAULT_MOTION_INTERVAL,
|
||||
DEFAULT_REFRESH,
|
||||
MIN_THROTTLE_TIME,
|
||||
)
|
||||
from blinkpy.helpers.constants import __version__
|
||||
from blinkpy.auth import Auth, TokenRefreshFailed, LoginError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Blink:
|
||||
"""Class to initialize communication."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
refresh_rate=DEFAULT_REFRESH,
|
||||
motion_interval=DEFAULT_MOTION_INTERVAL,
|
||||
no_owls=False,
|
||||
):
|
||||
"""
|
||||
Initialize Blink system.
|
||||
|
||||
:param refresh_rate: Refresh rate of blink information.
|
||||
Defaults to 15 (seconds)
|
||||
:param motion_interval: How far back to register motion in minutes.
|
||||
Defaults to last refresh time.
|
||||
Useful for preventing motion_detected property
|
||||
from de-asserting too quickly.
|
||||
:param no_owls: Disable searching for owl entries (blink mini cameras only known entity). Prevents an uneccessary API call if you don't have these in your network.
|
||||
"""
|
||||
self.auth = Auth()
|
||||
self.account_id = None
|
||||
self.client_id = None
|
||||
self.network_ids = []
|
||||
self.urls = None
|
||||
self.sync = CaseInsensitiveDict({})
|
||||
self.last_refresh = None
|
||||
self.refresh_rate = refresh_rate
|
||||
self.networks = []
|
||||
self.cameras = CaseInsensitiveDict({})
|
||||
self.video_list = CaseInsensitiveDict({})
|
||||
self.motion_interval = motion_interval
|
||||
self.version = __version__
|
||||
self.available = False
|
||||
self.key_required = False
|
||||
self.homescreen = {}
|
||||
self.no_owls = no_owls
|
||||
|
||||
@util.Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def refresh(self, force=False):
|
||||
"""
|
||||
Perform a system refresh.
|
||||
|
||||
:param force: Force an update of the camera data
|
||||
"""
|
||||
if self.check_if_ok_to_update() or force:
|
||||
if not self.available:
|
||||
self.setup_post_verify()
|
||||
|
||||
for sync_name, sync_module in self.sync.items():
|
||||
_LOGGER.debug("Attempting refresh of sync %s", sync_name)
|
||||
sync_module.refresh(force_cache=force)
|
||||
if not force:
|
||||
# Prevents rapid clearing of motion detect property
|
||||
self.last_refresh = int(time.time())
|
||||
return True
|
||||
return False
|
||||
|
||||
def start(self):
|
||||
"""Perform full system setup."""
|
||||
try:
|
||||
self.auth.startup()
|
||||
self.setup_login_ids()
|
||||
self.setup_urls()
|
||||
self.get_homescreen()
|
||||
except (LoginError, TokenRefreshFailed, BlinkSetupError):
|
||||
_LOGGER.error("Cannot setup Blink platform.")
|
||||
self.available = False
|
||||
return False
|
||||
|
||||
self.key_required = self.auth.check_key_required()
|
||||
if self.key_required:
|
||||
if self.auth.no_prompt:
|
||||
return True
|
||||
self.setup_prompt_2fa()
|
||||
return self.setup_post_verify()
|
||||
|
||||
def setup_prompt_2fa(self):
|
||||
"""Prompt for 2FA."""
|
||||
email = self.auth.data["username"]
|
||||
pin = input(f"Enter code sent to {email}: ")
|
||||
result = self.auth.send_auth_key(self, pin)
|
||||
self.key_required = not result
|
||||
|
||||
def setup_post_verify(self):
|
||||
"""Initialize blink system after verification."""
|
||||
try:
|
||||
self.setup_networks()
|
||||
networks = self.setup_network_ids()
|
||||
cameras = self.setup_camera_list()
|
||||
except BlinkSetupError:
|
||||
self.available = False
|
||||
return False
|
||||
|
||||
for name, network_id in networks.items():
|
||||
sync_cameras = cameras.get(network_id, {})
|
||||
self.setup_sync_module(name, network_id, sync_cameras)
|
||||
|
||||
self.cameras = self.merge_cameras()
|
||||
|
||||
self.available = True
|
||||
self.key_required = False
|
||||
return True
|
||||
|
||||
def setup_sync_module(self, name, network_id, cameras):
|
||||
"""Initialize a sync module."""
|
||||
self.sync[name] = BlinkSyncModule(self, name, network_id, cameras)
|
||||
self.sync[name].start()
|
||||
|
||||
def get_homescreen(self):
|
||||
"""Get homecreen information."""
|
||||
if self.no_owls:
|
||||
_LOGGER.debug("Skipping owl extraction.")
|
||||
self.homescreen = {}
|
||||
return
|
||||
self.homescreen = api.request_homescreen(self)
|
||||
|
||||
def setup_owls(self):
|
||||
"""Check for mini cameras."""
|
||||
network_list = []
|
||||
camera_list = []
|
||||
try:
|
||||
for owl in self.homescreen["owls"]:
|
||||
name = owl["name"]
|
||||
network_id = str(owl["network_id"])
|
||||
if network_id in self.network_ids:
|
||||
camera_list.append(
|
||||
{network_id: {"name": name, "id": network_id, "type": "mini"}}
|
||||
)
|
||||
continue
|
||||
if owl["onboarded"]:
|
||||
network_list.append(str(network_id))
|
||||
self.sync[name] = BlinkOwl(self, name, network_id, owl)
|
||||
self.sync[name].start()
|
||||
except KeyError:
|
||||
# No sync-less devices found
|
||||
pass
|
||||
|
||||
self.network_ids.extend(network_list)
|
||||
return camera_list
|
||||
|
||||
def setup_camera_list(self):
|
||||
"""Create camera list for onboarded networks."""
|
||||
all_cameras = {}
|
||||
response = api.request_camera_usage(self)
|
||||
try:
|
||||
for network in response["networks"]:
|
||||
camera_network = str(network["network_id"])
|
||||
if camera_network not in all_cameras:
|
||||
all_cameras[camera_network] = []
|
||||
for camera in network["cameras"]:
|
||||
all_cameras[camera_network].append(
|
||||
{"name": camera["name"], "id": camera["id"]}
|
||||
)
|
||||
mini_cameras = self.setup_owls()
|
||||
for camera in mini_cameras:
|
||||
for network, camera_info in camera.items():
|
||||
all_cameras[network].append(camera_info)
|
||||
return all_cameras
|
||||
except (KeyError, TypeError):
|
||||
_LOGGER.error("Unable to retrieve cameras from response %s", response)
|
||||
raise BlinkSetupError
|
||||
|
||||
def setup_login_ids(self):
|
||||
"""Retrieve login id numbers from login response."""
|
||||
self.client_id = self.auth.client_id
|
||||
self.account_id = self.auth.account_id
|
||||
|
||||
def setup_urls(self):
|
||||
"""Create urls for api."""
|
||||
try:
|
||||
self.urls = util.BlinkURLHandler(self.auth.region_id)
|
||||
except TypeError:
|
||||
_LOGGER.error(
|
||||
"Unable to extract region is from response %s", self.auth.login_response
|
||||
)
|
||||
raise BlinkSetupError
|
||||
|
||||
def setup_networks(self):
|
||||
"""Get network information."""
|
||||
response = api.request_networks(self)
|
||||
try:
|
||||
self.networks = response["summary"]
|
||||
except (KeyError, TypeError):
|
||||
raise BlinkSetupError
|
||||
|
||||
def setup_network_ids(self):
|
||||
"""Create the network ids for onboarded networks."""
|
||||
all_networks = []
|
||||
network_dict = {}
|
||||
try:
|
||||
for network, status in self.networks.items():
|
||||
if status["onboarded"]:
|
||||
all_networks.append(f"{network}")
|
||||
network_dict[status["name"]] = network
|
||||
except AttributeError:
|
||||
_LOGGER.error(
|
||||
"Unable to retrieve network information from %s", self.networks
|
||||
)
|
||||
raise BlinkSetupError
|
||||
|
||||
self.network_ids = all_networks
|
||||
return network_dict
|
||||
|
||||
def check_if_ok_to_update(self):
|
||||
"""Check if it is ok to perform an http request."""
|
||||
current_time = int(time.time())
|
||||
last_refresh = self.last_refresh
|
||||
if last_refresh is None:
|
||||
last_refresh = 0
|
||||
if current_time >= (last_refresh + self.refresh_rate):
|
||||
return True
|
||||
return False
|
||||
|
||||
def merge_cameras(self):
|
||||
"""Merge all sync camera dicts into one."""
|
||||
combined = CaseInsensitiveDict({})
|
||||
for sync in self.sync:
|
||||
combined = util.merge_dicts(combined, self.sync[sync].cameras)
|
||||
return combined
|
||||
|
||||
def save(self, file_name):
|
||||
"""Save login data to file."""
|
||||
util.json_save(self.auth.login_attributes, file_name)
|
||||
|
||||
def download_videos(self, path, since=None, camera="all", stop=10, debug=False):
|
||||
"""
|
||||
Download all videos from server since specified time.
|
||||
|
||||
:param path: Path to write files. /path/<cameraname>_<recorddate>.mp4
|
||||
:param since: Date and time to get videos from.
|
||||
Ex: "2018/07/28 12:33:00" to retrieve videos since
|
||||
July 28th 2018 at 12:33:00
|
||||
:param camera: Camera name to retrieve. Defaults to "all".
|
||||
Use a list for multiple cameras.
|
||||
:param stop: Page to stop on (~25 items per page. Default page 10).
|
||||
:param debug: Set to TRUE to prevent downloading of items.
|
||||
Instead of downloading, entries will be printed to log.
|
||||
"""
|
||||
if since is None:
|
||||
since_epochs = self.last_refresh
|
||||
else:
|
||||
parsed_datetime = parse(since, fuzzy=True)
|
||||
since_epochs = parsed_datetime.timestamp()
|
||||
|
||||
formatted_date = util.get_time(time_to_convert=since_epochs)
|
||||
_LOGGER.info("Retrieving videos since %s", formatted_date)
|
||||
|
||||
if not isinstance(camera, list):
|
||||
camera = [camera]
|
||||
|
||||
for page in range(1, stop):
|
||||
response = api.request_videos(self, time=since_epochs, page=page)
|
||||
_LOGGER.debug("Processing page %s", page)
|
||||
try:
|
||||
result = response["media"]
|
||||
if not result:
|
||||
raise KeyError
|
||||
except (KeyError, TypeError):
|
||||
_LOGGER.info("No videos found on page %s. Exiting.", page)
|
||||
break
|
||||
|
||||
self._parse_downloaded_items(result, camera, path, debug)
|
||||
|
||||
def _parse_downloaded_items(self, result, camera, path, debug):
|
||||
"""Parse downloaded videos."""
|
||||
for item in result:
|
||||
try:
|
||||
created_at = item["created_at"]
|
||||
camera_name = item["device_name"]
|
||||
is_deleted = item["deleted"]
|
||||
address = item["media"]
|
||||
except KeyError:
|
||||
_LOGGER.info("Missing clip information, skipping...")
|
||||
continue
|
||||
|
||||
if camera_name not in camera and "all" not in camera:
|
||||
_LOGGER.debug("Skipping videos for %s.", camera_name)
|
||||
continue
|
||||
|
||||
if is_deleted:
|
||||
_LOGGER.debug("%s: %s is marked as deleted.", camera_name, address)
|
||||
continue
|
||||
|
||||
clip_address = f"{self.urls.base_url}{address}"
|
||||
filename = f"{camera_name}-{created_at}"
|
||||
filename = f"{slugify(filename)}.mp4"
|
||||
filename = os.path.join(path, filename)
|
||||
|
||||
if not debug:
|
||||
if os.path.isfile(filename):
|
||||
_LOGGER.info("%s already exists, skipping...", filename)
|
||||
continue
|
||||
|
||||
response = api.http_get(self, url=clip_address, stream=True, json=False)
|
||||
with open(filename, "wb") as vidfile:
|
||||
copyfileobj(response.raw, vidfile)
|
||||
|
||||
_LOGGER.info("Downloaded video to %s", filename)
|
||||
else:
|
||||
print(
|
||||
(
|
||||
f"Camera: {camera_name}, Timestamp: {created_at}, "
|
||||
"Address: {address}, Filename: {filename}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class BlinkSetupError(Exception):
|
||||
"""Class to handle setup errors."""
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Defines Blink cameras."""
|
||||
|
||||
from shutil import copyfileobj
|
||||
import logging
|
||||
from blinkpy import api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlinkCamera:
|
||||
"""Class to initialize individual camera."""
|
||||
|
||||
def __init__(self, sync):
|
||||
"""Initiailize BlinkCamera."""
|
||||
self.sync = sync
|
||||
self.name = None
|
||||
self.camera_id = None
|
||||
self.network_id = None
|
||||
self.thumbnail = None
|
||||
self.serial = None
|
||||
self.motion_enabled = None
|
||||
self.battery_voltage = None
|
||||
self.clip = None
|
||||
self.temperature = None
|
||||
self.temperature_calibrated = None
|
||||
self.battery_state = None
|
||||
self.motion_detected = None
|
||||
self.wifi_strength = None
|
||||
self.last_record = None
|
||||
self._cached_image = None
|
||||
self._cached_video = None
|
||||
self.camera_type = ""
|
||||
|
||||
@property
|
||||
def attributes(self):
|
||||
"""Return dictionary of all camera attributes."""
|
||||
attributes = {
|
||||
"name": self.name,
|
||||
"camera_id": self.camera_id,
|
||||
"serial": self.serial,
|
||||
"temperature": self.temperature,
|
||||
"temperature_c": self.temperature_c,
|
||||
"temperature_calibrated": self.temperature_calibrated,
|
||||
"battery": self.battery,
|
||||
"battery_voltage": self.battery_voltage,
|
||||
"thumbnail": self.thumbnail,
|
||||
"video": self.clip,
|
||||
"motion_enabled": self.motion_enabled,
|
||||
"motion_detected": self.motion_detected,
|
||||
"wifi_strength": self.wifi_strength,
|
||||
"network_id": self.sync.network_id,
|
||||
"sync_module": self.sync.name,
|
||||
"last_record": self.last_record,
|
||||
}
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def battery(self):
|
||||
"""Return battery as string."""
|
||||
return self.battery_state
|
||||
|
||||
@property
|
||||
def temperature_c(self):
|
||||
"""Return temperature in celcius."""
|
||||
try:
|
||||
return round((self.temperature - 32) / 9.0 * 5.0, 1)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def image_from_cache(self):
|
||||
"""Return the most recently cached image."""
|
||||
if self._cached_image:
|
||||
return self._cached_image
|
||||
return None
|
||||
|
||||
@property
|
||||
def video_from_cache(self):
|
||||
"""Return the most recently cached video."""
|
||||
if self._cached_video:
|
||||
return self._cached_video
|
||||
return None
|
||||
|
||||
@property
|
||||
def arm(self):
|
||||
"""Return arm status of camera."""
|
||||
return self.motion_enabled
|
||||
|
||||
@arm.setter
|
||||
def arm(self, value):
|
||||
"""Set camera arm status."""
|
||||
if value:
|
||||
return api.request_motion_detection_enable(
|
||||
self.sync.blink, self.network_id, self.camera_id
|
||||
)
|
||||
return api.request_motion_detection_disable(
|
||||
self.sync.blink, self.network_id, self.camera_id
|
||||
)
|
||||
|
||||
def snap_picture(self):
|
||||
"""Take a picture with camera to create a new thumbnail."""
|
||||
return api.request_new_image(self.sync.blink, self.network_id, self.camera_id)
|
||||
|
||||
def set_motion_detect(self, enable):
|
||||
"""Set motion detection."""
|
||||
_LOGGER.warning(
|
||||
"Method is deprecated as of v0.16.0 and will be removed in a future version. Please use the BlinkCamera.arm property instead."
|
||||
)
|
||||
if enable:
|
||||
return api.request_motion_detection_enable(
|
||||
self.sync.blink, self.network_id, self.camera_id
|
||||
)
|
||||
return api.request_motion_detection_disable(
|
||||
self.sync.blink, self.network_id, self.camera_id
|
||||
)
|
||||
|
||||
def update(self, config, force_cache=False, **kwargs):
|
||||
"""Update camera info."""
|
||||
self.extract_config_info(config)
|
||||
self.get_sensor_info()
|
||||
self.update_images(config, force_cache=force_cache)
|
||||
|
||||
def extract_config_info(self, config):
|
||||
"""Extract info from config."""
|
||||
self.name = config.get("name", "unknown")
|
||||
self.camera_id = str(config.get("id", "unknown"))
|
||||
self.network_id = str(config.get("network_id", "unknown"))
|
||||
self.serial = config.get("serial", None)
|
||||
self.motion_enabled = config.get("enabled", "unknown")
|
||||
self.battery_voltage = config.get("battery_voltage", None)
|
||||
self.battery_state = config.get("battery_state", None)
|
||||
self.temperature = config.get("temperature", None)
|
||||
self.wifi_strength = config.get("wifi_strength", None)
|
||||
|
||||
def get_sensor_info(self):
|
||||
"""Retrieve calibrated temperatue from special endpoint."""
|
||||
resp = api.request_camera_sensors(
|
||||
self.sync.blink, self.network_id, self.camera_id
|
||||
)
|
||||
try:
|
||||
self.temperature_calibrated = resp["temp"]
|
||||
except (TypeError, KeyError):
|
||||
self.temperature_calibrated = self.temperature
|
||||
_LOGGER.warning("Could not retrieve calibrated temperature.")
|
||||
|
||||
def update_images(self, config, force_cache=False):
|
||||
"""Update images for camera."""
|
||||
new_thumbnail = None
|
||||
thumb_addr = None
|
||||
if config.get("thumbnail", False):
|
||||
thumb_addr = config["thumbnail"]
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Could not find thumbnail for camera %s", self.name, exc_info=True
|
||||
)
|
||||
|
||||
if thumb_addr is not None:
|
||||
new_thumbnail = f"{self.sync.urls.base_url}{thumb_addr}.jpg"
|
||||
|
||||
try:
|
||||
self.motion_detected = self.sync.motion[self.name]
|
||||
except KeyError:
|
||||
self.motion_detected = False
|
||||
|
||||
clip_addr = None
|
||||
try:
|
||||
clip_addr = self.sync.last_record[self.name]["clip"]
|
||||
self.last_record = self.sync.last_record[self.name]["time"]
|
||||
self.clip = f"{self.sync.urls.base_url}{clip_addr}"
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# If the thumbnail or clip have changed, update the cache
|
||||
update_cached_image = False
|
||||
if new_thumbnail != self.thumbnail or self._cached_image is None:
|
||||
update_cached_image = True
|
||||
self.thumbnail = new_thumbnail
|
||||
|
||||
update_cached_video = False
|
||||
if self._cached_video is None or self.motion_detected:
|
||||
update_cached_video = True
|
||||
|
||||
if new_thumbnail is not None and (update_cached_image or force_cache):
|
||||
self._cached_image = api.http_get(
|
||||
self.sync.blink, url=self.thumbnail, stream=True, json=False
|
||||
)
|
||||
if clip_addr is not None and (update_cached_video or force_cache):
|
||||
self._cached_video = api.http_get(
|
||||
self.sync.blink, url=self.clip, stream=True, json=False
|
||||
)
|
||||
|
||||
def get_liveview(self):
|
||||
"""Get livewview rtsps link."""
|
||||
response = api.request_camera_liveview(
|
||||
self.sync.blink, self.sync.network_id, self.camera_id
|
||||
)
|
||||
return response["server"]
|
||||
|
||||
def image_to_file(self, path):
|
||||
"""
|
||||
Write image to file.
|
||||
|
||||
:param path: Path to write file
|
||||
"""
|
||||
_LOGGER.debug("Writing image from %s to %s", self.name, path)
|
||||
response = self._cached_image
|
||||
if response.status_code == 200:
|
||||
with open(path, "wb") as imgfile:
|
||||
copyfileobj(response.raw, imgfile)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Cannot write image to file, response %s", response.status_code
|
||||
)
|
||||
|
||||
def video_to_file(self, path):
|
||||
"""
|
||||
Write video to file.
|
||||
|
||||
:param path: Path to write file
|
||||
"""
|
||||
_LOGGER.debug("Writing video from %s to %s", self.name, path)
|
||||
response = self._cached_video
|
||||
if response is None:
|
||||
_LOGGER.error("No saved video exist for %s.", self.name)
|
||||
return
|
||||
with open(path, "wb") as vidfile:
|
||||
copyfileobj(response.raw, vidfile)
|
||||
|
||||
|
||||
class BlinkCameraMini(BlinkCamera):
|
||||
"""Define a class for a Blink Mini camera."""
|
||||
|
||||
def __init__(self, sync):
|
||||
"""Initialize a Blink Mini cameras."""
|
||||
super().__init__(sync)
|
||||
self.camera_type = "mini"
|
||||
|
||||
@property
|
||||
def arm(self):
|
||||
"""Return camera arm status."""
|
||||
return self.sync.arm
|
||||
|
||||
@arm.setter
|
||||
def arm(self, value):
|
||||
"""Set camera arm status."""
|
||||
_LOGGER.warning(
|
||||
"Individual camera motion detection enable/disable for Blink Mini cameras is unsupported at this time."
|
||||
)
|
||||
|
||||
def snap_picture(self):
|
||||
"""Snap picture for a blink mini camera."""
|
||||
url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.network_id}/owls/{self.camera_id}/thumbnail"
|
||||
return api.http_post(self.sync.blink, url)
|
||||
|
||||
def get_sensor_info(self):
|
||||
"""Get sensor info for blink mini camera."""
|
||||
|
||||
def get_liveview(self):
|
||||
"""Get liveview link."""
|
||||
url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.network_id}/owls/{self.camera_id}/liveview"
|
||||
response = api.http_post(self.sync.blink, url)
|
||||
server = response["server"]
|
||||
server_split = server.split(":")
|
||||
server_split[0] = "rtsps"
|
||||
link = "".join(server_split)
|
||||
return link
|
||||
@@ -0,0 +1 @@
|
||||
"""Init file for blinkpy helper functions."""
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Generates constants for use in blinkpy."""
|
||||
|
||||
import os
|
||||
|
||||
MAJOR_VERSION = 0
|
||||
MINOR_VERSION = 16
|
||||
PATCH_VERSION = "0-rc9"
|
||||
|
||||
__version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}"
|
||||
|
||||
REQUIRED_PYTHON_VER = (3, 6, 0)
|
||||
|
||||
PROJECT_NAME = "blinkpy"
|
||||
PROJECT_PACKAGE_NAME = "blinkpy"
|
||||
PROJECT_LICENSE = "MIT"
|
||||
PROJECT_AUTHOR = "Kevin Fronczak"
|
||||
PROJECT_COPYRIGHT = f" 2017, {PROJECT_AUTHOR}"
|
||||
PROJECT_URL = "https://github.com/fronzbot/blinkpy"
|
||||
PROJECT_EMAIL = "kfronczak@gmail.com"
|
||||
PROJECT_DESCRIPTION = "A Blink camera Python library " "running on Python 3."
|
||||
PROJECT_LONG_DESCRIPTION = (
|
||||
"blinkpy is an open-source "
|
||||
"unofficial API for the Blink Camera "
|
||||
"system with the intention for easy "
|
||||
"integration into various home "
|
||||
"automation platforms."
|
||||
)
|
||||
if os.path.exists("README.rst"):
|
||||
PROJECT_LONG_DESCRIPTION = open("README.rst").read()
|
||||
PROJECT_CLASSIFIERS = [
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Topic :: Home Automation",
|
||||
]
|
||||
|
||||
PROJECT_GITHUB_USERNAME = "fronzbot"
|
||||
PROJECT_GITHUB_REPOSITORY = "blinkpy"
|
||||
|
||||
PYPI_URL = f"https://pypi.python.org/pypi/{PROJECT_PACKAGE_NAME}"
|
||||
|
||||
"""
|
||||
URLS
|
||||
"""
|
||||
BLINK_URL = "immedia-semi.com"
|
||||
DEFAULT_URL = f"rest-prod.{BLINK_URL}"
|
||||
BASE_URL = f"https://{DEFAULT_URL}"
|
||||
LOGIN_ENDPOINT = {
|
||||
"v4": f"{BASE_URL}/api/v4/account/login",
|
||||
"v3": f"{BASE_URL}/api/v3/login",
|
||||
}
|
||||
|
||||
"""
|
||||
Dictionaries
|
||||
"""
|
||||
ONLINE = {"online": True, "offline": False}
|
||||
|
||||
"""
|
||||
OTHER
|
||||
"""
|
||||
DEVICE_ID = "Blinkpy"
|
||||
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
||||
DEFAULT_MOTION_INTERVAL = 1
|
||||
DEFAULT_REFRESH = 30
|
||||
MIN_THROTTLE_TIME = 2
|
||||
SIZE_NOTIFICATION_KEY = 152
|
||||
SIZE_UID = 16
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Module to define error types."""
|
||||
|
||||
USERNAME = (0, "Username must be a string")
|
||||
PASSWORD = (1, "Password must be a string")
|
||||
AUTHENTICATE = (
|
||||
2,
|
||||
"Cannot authenticate since either password or username has not been set",
|
||||
)
|
||||
AUTH_TOKEN = (
|
||||
3,
|
||||
"Authentication header incorrect. Are you sure you received your token?",
|
||||
)
|
||||
REQUEST = (4, "Cannot perform request (get/post type incorrect)")
|
||||
|
||||
BLINK_ERRORS = [400, 404]
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Useful functions for blinkpy."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import secrets
|
||||
from calendar import timegm
|
||||
from functools import wraps
|
||||
from getpass import getpass
|
||||
import dateutil.parser
|
||||
from blinkpy.helpers import constants as const
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def json_load(file_name):
|
||||
"""Load json credentials from file."""
|
||||
try:
|
||||
with open(file_name, "r") as json_file:
|
||||
data = json.load(json_file)
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
_LOGGER.error("Could not find %s", file_name)
|
||||
except json.decoder.JSONDecodeError:
|
||||
_LOGGER.error("File %s has improperly formatted json", file_name)
|
||||
return None
|
||||
|
||||
|
||||
def json_save(data, file_name):
|
||||
"""Save data to file location."""
|
||||
with open(file_name, "w") as json_file:
|
||||
json.dump(data, json_file, indent=4)
|
||||
|
||||
|
||||
def gen_uid(size):
|
||||
"""Create a random sring."""
|
||||
full_token = secrets.token_hex(size)
|
||||
return full_token[0:size]
|
||||
|
||||
|
||||
def time_to_seconds(timestamp):
|
||||
"""Convert TIMESTAMP_FORMAT time to seconds."""
|
||||
try:
|
||||
dtime = dateutil.parser.isoparse(timestamp)
|
||||
except ValueError:
|
||||
_LOGGER.error("Incorrect timestamp format for conversion: %s.", timestamp)
|
||||
return False
|
||||
return timegm(dtime.timetuple())
|
||||
|
||||
|
||||
def get_time(time_to_convert=None):
|
||||
"""Create blink-compatible timestamp."""
|
||||
if time_to_convert is None:
|
||||
time_to_convert = time.time()
|
||||
return time.strftime(const.TIMESTAMP_FORMAT, time.gmtime(time_to_convert))
|
||||
|
||||
|
||||
def merge_dicts(dict_a, dict_b):
|
||||
"""Merge two dictionaries into one."""
|
||||
duplicates = [val for val in dict_a if val in dict_b]
|
||||
if duplicates:
|
||||
_LOGGER.warning(
|
||||
("Duplicates found during merge: %s. " "Renaming is recommended."),
|
||||
duplicates,
|
||||
)
|
||||
return {**dict_a, **dict_b}
|
||||
|
||||
|
||||
def prompt_login_data(data):
|
||||
"""Prompt user for username and password."""
|
||||
if data["username"] is None:
|
||||
data["username"] = input("Username:")
|
||||
if data["password"] is None:
|
||||
data["password"] = getpass("Password:")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def validate_login_data(data):
|
||||
"""Check for missing keys."""
|
||||
data["uid"] = data.get("uid", gen_uid(const.SIZE_UID))
|
||||
data["notification_key"] = data.get(
|
||||
"notification_key", gen_uid(const.SIZE_NOTIFICATION_KEY)
|
||||
)
|
||||
data["device_id"] = data.get("device_id", const.DEVICE_ID)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class BlinkException(Exception):
|
||||
"""Class to throw general blink exception."""
|
||||
|
||||
def __init__(self, errcode):
|
||||
"""Initialize BlinkException."""
|
||||
super().__init__()
|
||||
self.errid = errcode[0]
|
||||
self.message = errcode[1]
|
||||
|
||||
|
||||
class BlinkAuthenticationException(BlinkException):
|
||||
"""Class to throw authentication exception."""
|
||||
|
||||
|
||||
class BlinkURLHandler:
|
||||
"""Class that handles Blink URLS."""
|
||||
|
||||
def __init__(self, region_id):
|
||||
"""Initialize the urls."""
|
||||
if region_id is None:
|
||||
raise TypeError
|
||||
self.subdomain = f"rest-{region_id}"
|
||||
self.base_url = f"https://{self.subdomain}.{const.BLINK_URL}"
|
||||
self.home_url = f"{self.base_url}/homescreen"
|
||||
self.event_url = f"{self.base_url}/events/network"
|
||||
self.network_url = f"{self.base_url}/network"
|
||||
self.networks_url = f"{self.base_url}/networks"
|
||||
self.video_url = f"{self.base_url}/api/v2/videos"
|
||||
_LOGGER.debug("Setting base url to %s.", self.base_url)
|
||||
|
||||
|
||||
class Throttle:
|
||||
"""Class for throttling api calls."""
|
||||
|
||||
def __init__(self, seconds=10):
|
||||
"""Initialize throttle class."""
|
||||
self.throttle_time = seconds
|
||||
self.last_call = 0
|
||||
|
||||
def __call__(self, method):
|
||||
"""Throttle caller method."""
|
||||
|
||||
def throttle_method():
|
||||
"""Call when method is throttled."""
|
||||
return None
|
||||
|
||||
@wraps(method)
|
||||
def wrapper(*args, **kwargs):
|
||||
"""Wrap that checks for throttling."""
|
||||
force = kwargs.pop("force", False)
|
||||
now = int(time.time())
|
||||
last_call_delta = now - self.last_call
|
||||
if force or last_call_delta > self.throttle_time:
|
||||
result = method(*args, *kwargs)
|
||||
self.last_call = now
|
||||
return result
|
||||
|
||||
return throttle_method()
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Defines a sync module for Blink."""
|
||||
|
||||
import logging
|
||||
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from blinkpy import api
|
||||
from blinkpy.camera import BlinkCamera, BlinkCameraMini
|
||||
from blinkpy.helpers.util import time_to_seconds
|
||||
from blinkpy.helpers.constants import ONLINE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlinkSyncModule:
|
||||
"""Class to initialize sync module."""
|
||||
|
||||
def __init__(self, blink, network_name, network_id, camera_list):
|
||||
"""
|
||||
Initialize Blink sync module.
|
||||
|
||||
:param blink: Blink class instantiation
|
||||
"""
|
||||
self.blink = blink
|
||||
self.network_id = network_id
|
||||
self.region_id = blink.auth.region_id
|
||||
self.name = network_name
|
||||
self.serial = None
|
||||
self.status = "offline"
|
||||
self.sync_id = None
|
||||
self.host = None
|
||||
self.summary = None
|
||||
self.network_info = None
|
||||
self.events = []
|
||||
self.cameras = CaseInsensitiveDict({})
|
||||
self.motion_interval = blink.motion_interval
|
||||
self.motion = {}
|
||||
self.last_record = {}
|
||||
self.camera_list = camera_list
|
||||
self.available = False
|
||||
|
||||
@property
|
||||
def attributes(self):
|
||||
"""Return sync attributes."""
|
||||
attr = {
|
||||
"name": self.name,
|
||||
"id": self.sync_id,
|
||||
"network_id": self.network_id,
|
||||
"serial": self.serial,
|
||||
"status": self.status,
|
||||
"region_id": self.region_id,
|
||||
}
|
||||
return attr
|
||||
|
||||
@property
|
||||
def urls(self):
|
||||
"""Return device urls."""
|
||||
return self.blink.urls
|
||||
|
||||
@property
|
||||
def online(self):
|
||||
"""Return boolean system online status."""
|
||||
try:
|
||||
return ONLINE[self.status]
|
||||
except KeyError:
|
||||
_LOGGER.error("Unknown sync module status %s", self.status)
|
||||
self.available = False
|
||||
return False
|
||||
|
||||
@property
|
||||
def arm(self):
|
||||
"""Return status of sync module: armed/disarmed."""
|
||||
try:
|
||||
return self.network_info["network"]["armed"]
|
||||
except (KeyError, TypeError):
|
||||
self.available = False
|
||||
return None
|
||||
|
||||
@arm.setter
|
||||
def arm(self, value):
|
||||
"""Arm or disarm camera."""
|
||||
if value:
|
||||
return api.request_system_arm(self.blink, self.network_id)
|
||||
return api.request_system_disarm(self.blink, self.network_id)
|
||||
|
||||
def start(self):
|
||||
"""Initialize the system."""
|
||||
response = self.sync_initialize()
|
||||
if not response:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.sync_id = self.summary["id"]
|
||||
self.serial = self.summary["serial"]
|
||||
self.status = self.summary["status"]
|
||||
except KeyError:
|
||||
_LOGGER.error(
|
||||
"Could not extract some sync module info: %s", response, exc_info=True
|
||||
)
|
||||
|
||||
is_ok = self.get_network_info()
|
||||
self.check_new_videos()
|
||||
|
||||
if not is_ok or not self.update_cameras():
|
||||
return False
|
||||
self.available = True
|
||||
return True
|
||||
|
||||
def sync_initialize(self):
|
||||
"""Initialize a sync module."""
|
||||
response = api.request_syncmodule(self.blink, self.network_id)
|
||||
try:
|
||||
self.summary = response["syncmodule"]
|
||||
self.network_id = self.summary["network_id"]
|
||||
except (TypeError, KeyError):
|
||||
_LOGGER.error(
|
||||
"Could not retrieve sync module information with response: %s", response
|
||||
)
|
||||
return False
|
||||
return response
|
||||
|
||||
def update_cameras(self, camera_type=BlinkCamera):
|
||||
"""Update cameras from server."""
|
||||
try:
|
||||
for camera_config in self.camera_list:
|
||||
if "name" not in camera_config:
|
||||
break
|
||||
blink_camera_type = camera_config.get("type", "")
|
||||
name = camera_config["name"]
|
||||
self.motion[name] = False
|
||||
owl_info = self.get_owl_info(name)
|
||||
if blink_camera_type == "mini":
|
||||
camera_type = BlinkCameraMini
|
||||
self.cameras[name] = camera_type(self)
|
||||
camera_info = self.get_camera_info(
|
||||
camera_config["id"], owl_info=owl_info
|
||||
)
|
||||
self.cameras[name].update(camera_info, force_cache=True, force=True)
|
||||
|
||||
except KeyError:
|
||||
_LOGGER.error("Could not create camera instances for %s", self.name)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_owl_info(self, name):
|
||||
"""Extract owl information."""
|
||||
try:
|
||||
for owl in self.blink.homescreen["owls"]:
|
||||
if owl["name"] == name:
|
||||
return owl
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_events(self, **kwargs):
|
||||
"""Retrieve events from server."""
|
||||
force = kwargs.pop("force", False)
|
||||
response = api.request_sync_events(self.blink, self.network_id, force=force)
|
||||
try:
|
||||
return response["event"]
|
||||
except (TypeError, KeyError):
|
||||
_LOGGER.error("Could not extract events: %s", response, exc_info=True)
|
||||
return False
|
||||
|
||||
def get_camera_info(self, camera_id, **kwargs):
|
||||
"""Retrieve camera information."""
|
||||
owl = kwargs.get("owl_info", None)
|
||||
if owl is not None:
|
||||
return owl
|
||||
response = api.request_camera_info(self.blink, self.network_id, camera_id)
|
||||
try:
|
||||
return response["camera"][0]
|
||||
except (TypeError, KeyError):
|
||||
_LOGGER.error("Could not extract camera info: %s", response, exc_info=True)
|
||||
return {}
|
||||
|
||||
def get_network_info(self):
|
||||
"""Retrieve network status."""
|
||||
self.network_info = api.request_network_update(self.blink, self.network_id)
|
||||
try:
|
||||
if self.network_info["network"]["sync_module_error"]:
|
||||
raise KeyError
|
||||
except (TypeError, KeyError):
|
||||
self.available = False
|
||||
return False
|
||||
return True
|
||||
|
||||
def refresh(self, force_cache=False):
|
||||
"""Get all blink cameras and pulls their most recent status."""
|
||||
if not self.get_network_info():
|
||||
return
|
||||
self.check_new_videos()
|
||||
for camera_name in self.cameras.keys():
|
||||
camera_id = self.cameras[camera_name].camera_id
|
||||
camera_info = self.get_camera_info(
|
||||
camera_id, owl_info=self.get_owl_info(camera_name)
|
||||
)
|
||||
self.cameras[camera_name].update(camera_info, force_cache=force_cache)
|
||||
self.available = True
|
||||
|
||||
def check_new_videos(self):
|
||||
"""Check if new videos since last refresh."""
|
||||
try:
|
||||
interval = self.blink.last_refresh - self.motion_interval * 60
|
||||
except TypeError:
|
||||
# This is the first start, so refresh hasn't happened yet.
|
||||
# No need to check for motion.
|
||||
return False
|
||||
|
||||
resp = api.request_videos(self.blink, time=interval, page=1)
|
||||
|
||||
for camera in self.cameras.keys():
|
||||
self.motion[camera] = False
|
||||
|
||||
try:
|
||||
info = resp["media"]
|
||||
except (KeyError, TypeError):
|
||||
_LOGGER.warning("Could not check for motion. Response: %s", resp)
|
||||
return False
|
||||
|
||||
for entry in info:
|
||||
try:
|
||||
name = entry["device_name"]
|
||||
clip = entry["media"]
|
||||
timestamp = entry["created_at"]
|
||||
if self.check_new_video_time(timestamp):
|
||||
self.motion[name] = True and self.arm
|
||||
self.last_record[name] = {"clip": clip, "time": timestamp}
|
||||
except KeyError:
|
||||
_LOGGER.debug("No new videos since last refresh.")
|
||||
|
||||
return True
|
||||
|
||||
def check_new_video_time(self, timestamp):
|
||||
"""Check if video has timestamp since last refresh."""
|
||||
return time_to_seconds(timestamp) > self.blink.last_refresh
|
||||
|
||||
|
||||
class BlinkOwl(BlinkSyncModule):
|
||||
"""Representation of a sync-less device."""
|
||||
|
||||
def __init__(self, blink, name, network_id, response):
|
||||
"""Initialize a sync-less object."""
|
||||
cameras = [{"name": name, "id": response["id"]}]
|
||||
super().__init__(blink, name, network_id, cameras)
|
||||
self.sync_id = response["id"]
|
||||
self.serial = response["serial"]
|
||||
self.status = response["enabled"]
|
||||
if not self.serial:
|
||||
self.serial = f"{network_id}-{self.sync_id}"
|
||||
|
||||
def sync_initialize(self):
|
||||
"""Initialize a sync-less module."""
|
||||
self.summary = {
|
||||
"id": self.sync_id,
|
||||
"name": self.name,
|
||||
"serial": self.serial,
|
||||
"status": self.status,
|
||||
"onboarded": True,
|
||||
"account_id": self.blink.account_id,
|
||||
"network_id": self.network_id,
|
||||
}
|
||||
return self.summary
|
||||
|
||||
def update_cameras(self, camera_type=BlinkCameraMini):
|
||||
"""Update sync-less cameras."""
|
||||
return super().update_cameras(camera_type=BlinkCameraMini)
|
||||
|
||||
def get_camera_info(self, camera_id, **kwargs):
|
||||
"""Retrieve camera information."""
|
||||
try:
|
||||
for owl in self.blink.homescreen["owls"]:
|
||||
if owl["name"] == self.name:
|
||||
self.status = owl["enabled"]
|
||||
return owl
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_network_info(self):
|
||||
"""Get network info for sync-less module."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def network_info(self):
|
||||
"""Format owl response to resemble sync module."""
|
||||
return {
|
||||
"network": {
|
||||
"id": self.network_id,
|
||||
"name": self.name,
|
||||
"armed": self.status,
|
||||
"sync_module_error": False,
|
||||
"account_id": self.blink.account_id,
|
||||
}
|
||||
}
|
||||
|
||||
@network_info.setter
|
||||
def network_info(self, value):
|
||||
"""Set network_info property."""
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
codecov:
|
||||
branch: dev
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 80%
|
||||
threshold: 2%
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
threshold: 5%
|
||||
|
||||
comment: true
|
||||
require_ci_to_pass: yes
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../CHANGES.rst
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../CONTRIBUTING.rst
|
||||
@@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
SPHINXPROJ = blinkpy
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../README.rst
|
||||
@@ -0,0 +1,81 @@
|
||||
=======================
|
||||
Advanced Library Usage
|
||||
=======================
|
||||
|
||||
Usage of this library was designed with the `Home Assistant <https://home-assistant.io>`__ project in mind. With that said, this library is flexible to be used in other scripts where advanced usage not covered in the Quick Start guide may be required. This usage guide will attempt to cover as many use cases as possible.
|
||||
|
||||
Throttling
|
||||
--------------
|
||||
In general, attempting too many requests to the Blink servers will result in your account being throttled. Where possible, adding a delay between calls is ideal. For use cases where this is not an acceptable solution, the ``blinkpy.helpers.util`` module contains a ``Throttle`` class that can be used as a decorator for calls. There are many examples of usage within the ``blinkpy.api`` module. A simple example of usage is covered below, where the decorated method is prevented from executing again until 10s has passed. Note that if the method call is throttled by the decorator, the method will return `None`.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from blinkpy.helpers.util import Throttle
|
||||
|
||||
@Throttle(seconds=10)
|
||||
def my_method(*args):
|
||||
"""Some method to be throttled."""
|
||||
return True
|
||||
|
||||
Custom Sessions
|
||||
-----------------
|
||||
By default, the ``blink.auth.Auth`` class creates its own websession via its ``create_session`` method. This is done when the class is initialized and is accessible via the ``Auth.session`` property. To override with a custom websession, the following code can accomplish that:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.auth import Auth
|
||||
|
||||
blink = Blink()
|
||||
blink.auth = Auth()
|
||||
blink.auth.session = YourCustomSession
|
||||
|
||||
Custom HTTP requests
|
||||
---------------------
|
||||
In addition to custom sessions, custom blink server requests can be performed. This give you the ability to bypass the built-in ``Auth.query`` method. It also allows flexibility by giving you the option to pass your own url, rather than be limited to what is currently implemented in the ``blinkpy.api`` module.
|
||||
|
||||
**Send custom url**
|
||||
This prepares a standard "GET" request.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.auth import Auth
|
||||
|
||||
blink = Blink()
|
||||
blink.auth = Auth()
|
||||
url = some_api_endpoint_string
|
||||
request = blink.auth.prepare_request(url, blink.auth.header, None, "get")
|
||||
response = blink.auth.session.send(request)
|
||||
|
||||
**Overload query method**
|
||||
Another option is to create your own ``Auth`` class with a custom ``query`` method to avoid the built-in response checking. This allows you to use the built in ``blinkpy.api`` endpoints, but also gives you flexibility to send your own urls.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.auth import Auth
|
||||
from blinkpy import api
|
||||
|
||||
class CustomAuth(Auth):
|
||||
def query(
|
||||
self,
|
||||
url=None,
|
||||
data=None,
|
||||
headers=self.header,
|
||||
reqtype="get",
|
||||
stream=False,
|
||||
json_resp=True,
|
||||
**kwargs
|
||||
):
|
||||
req = self.prepare_request(url, headers, data, reqtype)
|
||||
return self.session.send(req, stream=stream)
|
||||
|
||||
blink = blink.Blink()
|
||||
blink.auth = CustomAuth()
|
||||
|
||||
# Send custom GET query
|
||||
response = blink.auth.query(url=some_custom_url)
|
||||
|
||||
# Call built-in networks api endpoint
|
||||
response = api.request_networks(blink)
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# blinkpy documentation build configuration file, created by
|
||||
# sphinx-quickstart on Sat Jan 20 22:20:16 2018.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#
|
||||
# needs_sphinx = '1.0'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
import sphinx_rtd_theme
|
||||
from blinkpy.helpers.constants import __version__
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
extensions = ['sphinx.ext.autodoc',
|
||||
'sphinx.ext.doctest',
|
||||
'sphinx.ext.mathjax',
|
||||
'sphinx.ext.viewcode']
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'blinkpy'
|
||||
copyright = '2018, Kevin Fronczak'
|
||||
author = 'Kevin Fronczak'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = __version__
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = __version__
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This patterns also effect to html_static_path and html_extra_path
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = False
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
#html_theme = 'alabaster'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#
|
||||
# html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Custom sidebar templates, must be a dictionary that maps document names
|
||||
# to template names.
|
||||
#
|
||||
# This is required for the alabaster theme
|
||||
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
|
||||
html_sidebars = {
|
||||
'**': [
|
||||
'relations.html', # needs 'show_related': True theme option to display
|
||||
'searchbox.html',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'blinkpydoc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'blinkpy.tex', 'blinkpy Documentation',
|
||||
'Kevin Fronczak', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'blinkpy', 'blinkpy Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'blinkpy', 'blinkpy Documentation',
|
||||
author, 'blinkpy', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.. blinkpy documentation master file, created by
|
||||
sphinx-quickstart on Sat Jan 20 22:20:16 2018.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to blinkpy's documentation!
|
||||
===================================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Contents:
|
||||
:glob:
|
||||
|
||||
README
|
||||
advanced
|
||||
CONTRIBUTING
|
||||
modules/*
|
||||
CHANGES
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
@@ -0,0 +1,36 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=.
|
||||
set BUILDDIR=_build
|
||||
set SPHINXPROJ=blinkpy
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||
|
||||
:end
|
||||
popd
|
||||
@@ -0,0 +1,41 @@
|
||||
.. _core_module:
|
||||
|
||||
===========================
|
||||
Blinkpy Library Reference
|
||||
===========================
|
||||
|
||||
blinkpy.py
|
||||
-----------
|
||||
.. automodule:: blinkpy.blinkpy
|
||||
:members:
|
||||
|
||||
auth.py
|
||||
--------
|
||||
|
||||
.. automodule:: blinkpy.auth
|
||||
:members:
|
||||
|
||||
sync_module.py
|
||||
----------------
|
||||
|
||||
.. automodule:: blinkpy.sync_module
|
||||
:members:
|
||||
|
||||
camera.py
|
||||
-----------
|
||||
|
||||
.. automodule:: blinkpy.camera
|
||||
:members:
|
||||
|
||||
api.py
|
||||
---------
|
||||
|
||||
.. automodule:: blinkpy.api
|
||||
:members:
|
||||
|
||||
helpers/util.py
|
||||
----------------
|
||||
|
||||
.. automodule:: blinkpy.helpers.util
|
||||
:members:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
import blinkpy
|
||||
import time
|
||||
|
||||
blink = blinkpy.Blink()
|
||||
blink.login()
|
||||
blink.setup_system()
|
||||
|
||||
for camera in blink.cameras:
|
||||
print('Arming ' + camera.name)
|
||||
camera.set_motion_detect(True)
|
||||
time.sleep(5)
|
||||
blink.refresh()
|
||||
|
||||
print('Arming Blink')
|
||||
blink.arm = True
|
||||
time.sleep(5)
|
||||
print('Blink armed? ' + str(blink.arm))
|
||||
print('Disarming Blink')
|
||||
time.sleep(5)
|
||||
blink.arm = False
|
||||
print('Blink armed? ' + str(blink.arm))
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
import blinkpy
|
||||
|
||||
blink = blinkpy.Blink()
|
||||
blink.login()
|
||||
blink.setup_system()
|
||||
|
||||
for camera in blink.cameras:
|
||||
print(camera.name) # Name of the camera
|
||||
print(camera.id) # Integer id of the camera (assigned by Blink)
|
||||
print(camera.armed) # Whether the device is armed/disarmed (ie. detecting motion)
|
||||
print(camera.clip) # Link to last motion clip captured
|
||||
print(camera.thumbnail) # Link to current camera thumbnail
|
||||
print(camera.temperature) # Current camera temperature (not super accurate, but might be useful for someone)
|
||||
print(camera.battery) # Current battery level... I think the value ranges from 0-3, but not quite sure yet.
|
||||
print(camera.notifications) # Number of unread notifications (ie. motion alerts that haven't been viewed)
|
||||
print(camera.motion) # Dictionary containing values for keys ['video', 'image', 'time']
|
||||
# which correspond to last motion recorded, thumbnail of last motion, and timestamp of last motion
|
||||
@@ -0,0 +1,32 @@
|
||||
[MASTER]
|
||||
reports=no
|
||||
|
||||
# Reasons disabled:
|
||||
# locally-disabled - it spams too much
|
||||
# duplicate-code - it's annoying
|
||||
# unused-argument - generic callbacks and setup methods create a lot of warnings
|
||||
# too-many-* - are not enforced for the sake of readability
|
||||
# too-few-* - same as too-many-*
|
||||
# no-else-return - I don't see any reason to enforce this. both forms are readable
|
||||
# no-self-use - stupid and only annoying
|
||||
# unexpected-keyword-arg - doesn't allow for use of **kwargs, which is dumb
|
||||
|
||||
disable=
|
||||
format,
|
||||
bad-continuation,
|
||||
locally-disabled,
|
||||
unused-argument,
|
||||
duplicate-code,
|
||||
implicit-str-concat,
|
||||
too-many-arguments,
|
||||
too-many-branches,
|
||||
too-many-instance-attributes,
|
||||
too-many-locals,
|
||||
too-many-public-methods,
|
||||
too-many-return-statements,
|
||||
too-many-statements,
|
||||
too-many-lines,
|
||||
too-few-public-methods,
|
||||
no-else-return,
|
||||
no-self-use,
|
||||
unexpected-keyword-arg,
|
||||
+4
-5
@@ -1,5 +1,4 @@
|
||||
pytest>=2.9.2
|
||||
pytest-cov>=2.3.1
|
||||
pytest-timeout>=1.0.0
|
||||
requests>=2.12.4
|
||||
testtools
|
||||
python-dateutil~=2.8.1
|
||||
requests~=2.24.0
|
||||
python-slugify~=4.0.0
|
||||
testtools==2.4.0
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
black==19.10b0
|
||||
coverage==5.1
|
||||
flake8==3.8.3
|
||||
flake8-docstrings==1.5.0
|
||||
pre-commit==2.5.1
|
||||
pylint==2.5.3
|
||||
pydocstyle==5.0.2
|
||||
pytest==5.4.3
|
||||
pytest-cov==2.10.0
|
||||
pytest-sugar==0.9.3
|
||||
pytest-timeout==1.4.1
|
||||
restructuredtext-lint==1.3.1
|
||||
pygments==2.6.1
|
||||
@@ -0,0 +1,18 @@
|
||||
[flake8]
|
||||
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
|
||||
doctests = True
|
||||
# To work with Black
|
||||
max-line-length = 88
|
||||
|
||||
# E501: line too long
|
||||
# W503: Line break occurred before a binary operator
|
||||
# E203: Whitespace before ':'
|
||||
# D202 No blank lines allowed after function docstring
|
||||
# W504 line break after binary operator
|
||||
|
||||
ignore =
|
||||
E501,
|
||||
W503,
|
||||
E203,
|
||||
D202,
|
||||
W504,
|
||||
@@ -1,26 +1,41 @@
|
||||
"""Setup file for blinkpy."""
|
||||
# -*- coding: utf-8 -*-
|
||||
from os.path import abspath, dirname
|
||||
from setuptools import setup, find_packages
|
||||
from blinkpy.helpers.constants import (
|
||||
__version__, PROJECT_PACKAGE_NAME, PROJECT_LICENSE, PROJECT_URL,
|
||||
PROJECT_EMAIL, PROJECT_DESCRIPTION, PROJECT_CLASSIFIERS, PROJECT_AUTHOR,
|
||||
)
|
||||
|
||||
from setuptools import setup
|
||||
PROJECT_VERSION = __version__
|
||||
|
||||
THIS_DIR = abspath(dirname(__file__))
|
||||
|
||||
REQUIRES = [
|
||||
"python-dateutil~=2.8.1",
|
||||
"requests~=2.23.0",
|
||||
"python-slugify~=4.0.0",
|
||||
]
|
||||
|
||||
PACKAGES = find_packages(exclude=['tests*', 'docs'])
|
||||
|
||||
with open('{}/README.rst'.format(THIS_DIR), encoding='utf-8') as readme_file:
|
||||
LONG_DESCRIPTION = readme_file.read()
|
||||
|
||||
setup(
|
||||
name = 'blinkpy',
|
||||
version = '0.4.',
|
||||
description = 'A Blink camera Python library',
|
||||
long_description='A library that communicates with Blink cameras',
|
||||
author = 'Kevin Fronczak',
|
||||
author_email = "kfronczak@gmail.com",
|
||||
license='MIT',
|
||||
url = 'https://github.com/fronzbot/blinkpy',
|
||||
name=PROJECT_PACKAGE_NAME,
|
||||
version=PROJECT_VERSION,
|
||||
description=PROJECT_DESCRIPTION,
|
||||
long_description=LONG_DESCRIPTION,
|
||||
author=PROJECT_AUTHOR,
|
||||
author_email=PROJECT_EMAIL,
|
||||
license=PROJECT_LICENSE,
|
||||
url=PROJECT_URL,
|
||||
platforms='any',
|
||||
py_modules=['blinkpy'],
|
||||
install_requires=['requests>=2,<3'],
|
||||
classifiers=[
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Environment :: Plugins',
|
||||
'Environment :: Web Environment'
|
||||
]
|
||||
)
|
||||
packages=PACKAGES,
|
||||
include_package_data=True,
|
||||
install_requires=REQUIRES,
|
||||
test_suite='tests',
|
||||
classifiers=PROJECT_CLASSIFIERS
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Init file for tests directory."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Simple mock responses definitions."""
|
||||
|
||||
|
||||
class MockResponse:
|
||||
"""Class for mock request response."""
|
||||
|
||||
def __init__(self, json_data, status_code, raw_data=None):
|
||||
"""Initialize mock get response."""
|
||||
self.json_data = json_data
|
||||
self.status_code = status_code
|
||||
self.raw_data = raw_data
|
||||
self.reason = "foobar"
|
||||
|
||||
def json(self):
|
||||
"""Return json data from get_request."""
|
||||
return self.json_data
|
||||
|
||||
@property
|
||||
def raw(self):
|
||||
"""Return raw data from get request."""
|
||||
return self.raw_data
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Test login handler."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from requests import exceptions
|
||||
from blinkpy.auth import (
|
||||
Auth,
|
||||
LoginError,
|
||||
TokenRefreshFailed,
|
||||
BlinkBadResponse,
|
||||
UnauthorizedError,
|
||||
)
|
||||
import blinkpy.helpers.constants as const
|
||||
import tests.mock_responses as mresp
|
||||
|
||||
USERNAME = "foobar"
|
||||
PASSWORD = "deadbeef"
|
||||
|
||||
|
||||
class TestAuth(unittest.TestCase):
|
||||
"""Test the Auth class in blinkpy."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up Login Handler."""
|
||||
self.auth = Auth()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after test."""
|
||||
self.auth = None
|
||||
|
||||
@mock.patch("blinkpy.helpers.util.gen_uid")
|
||||
@mock.patch("blinkpy.auth.util.getpass")
|
||||
def test_empty_init(self, getpwd, genuid):
|
||||
"""Test initialization with no params."""
|
||||
auth = Auth()
|
||||
self.assertDictEqual(auth.data, {})
|
||||
getpwd.return_value = "bar"
|
||||
genuid.return_value = 1234
|
||||
with mock.patch("builtins.input", return_value="foo"):
|
||||
auth.validate_login()
|
||||
expected_data = {
|
||||
"username": "foo",
|
||||
"password": "bar",
|
||||
"uid": 1234,
|
||||
"notification_key": 1234,
|
||||
"device_id": const.DEVICE_ID,
|
||||
}
|
||||
self.assertDictEqual(auth.data, expected_data)
|
||||
|
||||
@mock.patch("blinkpy.helpers.util.gen_uid")
|
||||
@mock.patch("blinkpy.auth.util.getpass")
|
||||
def test_barebones_init(self, getpwd, genuid):
|
||||
"""Test basebones initialization."""
|
||||
login_data = {"username": "foo", "password": "bar"}
|
||||
auth = Auth(login_data)
|
||||
self.assertDictEqual(auth.data, login_data)
|
||||
getpwd.return_value = "bar"
|
||||
genuid.return_value = 1234
|
||||
with mock.patch("builtins.input", return_value="foo"):
|
||||
auth.validate_login()
|
||||
expected_data = {
|
||||
"username": "foo",
|
||||
"password": "bar",
|
||||
"uid": 1234,
|
||||
"notification_key": 1234,
|
||||
"device_id": const.DEVICE_ID,
|
||||
}
|
||||
self.assertDictEqual(auth.data, expected_data)
|
||||
|
||||
def test_full_init(self):
|
||||
"""Test full initialization."""
|
||||
login_data = {
|
||||
"username": "foo",
|
||||
"password": "bar",
|
||||
"token": "token",
|
||||
"host": "host",
|
||||
"region_id": "region_id",
|
||||
"client_id": "client_id",
|
||||
"account_id": "account_id",
|
||||
"uid": 1234,
|
||||
"notification_key": 4321,
|
||||
"device_id": "device_id",
|
||||
}
|
||||
auth = Auth(login_data)
|
||||
self.assertEqual(auth.token, "token")
|
||||
self.assertEqual(auth.host, "host")
|
||||
self.assertEqual(auth.region_id, "region_id")
|
||||
self.assertEqual(auth.client_id, "client_id")
|
||||
self.assertEqual(auth.account_id, "account_id")
|
||||
auth.validate_login()
|
||||
self.assertDictEqual(auth.login_attributes, login_data)
|
||||
|
||||
def test_bad_response_code(self):
|
||||
"""Check bad response code from server."""
|
||||
self.auth.is_errored = False
|
||||
fake_resp = mresp.MockResponse({"code": 404}, 404)
|
||||
with self.assertRaises(exceptions.ConnectionError):
|
||||
self.auth.validate_response(fake_resp, True)
|
||||
self.assertTrue(self.auth.is_errored)
|
||||
|
||||
self.auth.is_errored = False
|
||||
fake_resp = mresp.MockResponse({"code": 101}, 401)
|
||||
with self.assertRaises(UnauthorizedError):
|
||||
self.auth.validate_response(fake_resp, True)
|
||||
self.assertTrue(self.auth.is_errored)
|
||||
|
||||
def test_good_response_code(self):
|
||||
"""Check good response code from server."""
|
||||
fake_resp = mresp.MockResponse({"foo": "bar"}, 200)
|
||||
self.auth.is_errored = True
|
||||
self.assertEqual(self.auth.validate_response(fake_resp, True), {"foo": "bar"})
|
||||
self.assertFalse(self.auth.is_errored)
|
||||
|
||||
def test_response_not_json(self):
|
||||
"""Check response when not json."""
|
||||
fake_resp = "foobar"
|
||||
self.auth.is_errored = True
|
||||
self.assertEqual(self.auth.validate_response(fake_resp, False), "foobar")
|
||||
self.assertFalse(self.auth.is_errored)
|
||||
|
||||
def test_response_bad_json(self):
|
||||
"""Check response when not json but expecting json."""
|
||||
self.auth.is_errored = False
|
||||
with self.assertRaises(BlinkBadResponse):
|
||||
self.auth.validate_response(None, True)
|
||||
self.assertTrue(self.auth.is_errored)
|
||||
|
||||
def test_header(self):
|
||||
"""Test header data."""
|
||||
self.auth.token = "bar"
|
||||
self.auth.host = "foo"
|
||||
expected_header = {"Host": "foo", "TOKEN_AUTH": "bar"}
|
||||
self.assertDictEqual(self.auth.header, expected_header)
|
||||
|
||||
def test_header_no_token(self):
|
||||
"""Test header without token."""
|
||||
self.auth.token = None
|
||||
self.assertEqual(self.auth.header, None)
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.validate_login", return_value=None)
|
||||
@mock.patch("blinkpy.auth.api.request_login")
|
||||
def test_login(self, mock_req, mock_validate):
|
||||
"""Test login handling."""
|
||||
fake_resp = mresp.MockResponse({"foo": "bar"}, 200)
|
||||
mock_req.return_value = fake_resp
|
||||
self.assertEqual(self.auth.login(), {"foo": "bar"})
|
||||
mock_req.assert_called_with(
|
||||
self.auth, const.LOGIN_ENDPOINT["v4"], {}, is_retry=False
|
||||
)
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.validate_login", return_value=None)
|
||||
@mock.patch("blinkpy.auth.api.request_login")
|
||||
def test_login_v3(self, mock_req, mock_validate):
|
||||
"""Test login handling."""
|
||||
auth_v3 = Auth(login_method="v3")
|
||||
fake_resp = mresp.MockResponse({"foo": "bar"}, 200)
|
||||
mock_req.return_value = fake_resp
|
||||
self.assertEqual(auth_v3.login(), {"foo": "bar"})
|
||||
mock_req.assert_called_with(
|
||||
auth_v3, const.LOGIN_ENDPOINT["v3"], {}, is_retry=False
|
||||
)
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.validate_login", return_value=None)
|
||||
@mock.patch("blinkpy.auth.api.request_login")
|
||||
def test_login_bad_response(self, mock_req, mock_validate):
|
||||
"""Test login handling when bad response."""
|
||||
fake_resp = mresp.MockResponse({"foo": "bar"}, 404)
|
||||
mock_req.return_value = fake_resp
|
||||
self.auth.is_errored = False
|
||||
with self.assertRaises(LoginError):
|
||||
self.auth.login()
|
||||
with self.assertRaises(TokenRefreshFailed):
|
||||
self.auth.refresh_token()
|
||||
self.assertTrue(self.auth.is_errored)
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.login")
|
||||
def test_refresh_token(self, mock_login):
|
||||
"""Test refresh token method."""
|
||||
mock_login.return_value = {
|
||||
"region": {"tier": "test"},
|
||||
"authtoken": {"authtoken": "foobar"},
|
||||
"client": {"id": 1234},
|
||||
"account": {"id": 5678},
|
||||
}
|
||||
self.assertTrue(self.auth.refresh_token())
|
||||
self.assertEqual(self.auth.region_id, "test")
|
||||
self.assertEqual(self.auth.token, "foobar")
|
||||
self.assertEqual(self.auth.client_id, 1234)
|
||||
self.assertEqual(self.auth.account_id, 5678)
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.login")
|
||||
def test_refresh_token_failed(self, mock_login):
|
||||
"""Test refresh token failed."""
|
||||
mock_login.return_value = {}
|
||||
self.auth.is_errored = False
|
||||
with self.assertRaises(TokenRefreshFailed):
|
||||
self.auth.refresh_token()
|
||||
self.assertTrue(self.auth.is_errored)
|
||||
|
||||
def test_check_key_required(self):
|
||||
"""Check key required method."""
|
||||
self.auth.login_response = {}
|
||||
self.assertFalse(self.auth.check_key_required())
|
||||
|
||||
self.auth.login_response = {"client": {"verification_required": False}}
|
||||
self.assertFalse(self.auth.check_key_required())
|
||||
|
||||
self.auth.login_response = {"client": {"verification_required": True}}
|
||||
self.assertTrue(self.auth.check_key_required())
|
||||
|
||||
@mock.patch("blinkpy.auth.api.request_verify")
|
||||
def test_send_auth_key(self, mock_req):
|
||||
"""Check sending of auth key."""
|
||||
mock_blink = MockBlink(None)
|
||||
mock_req.return_value = mresp.MockResponse({"valid": True}, 200)
|
||||
self.assertTrue(self.auth.send_auth_key(mock_blink, 1234))
|
||||
self.assertTrue(mock_blink.available)
|
||||
|
||||
mock_req.return_value = mresp.MockResponse(None, 200)
|
||||
self.assertFalse(self.auth.send_auth_key(mock_blink, 1234))
|
||||
|
||||
mock_req.return_value = mresp.MockResponse({}, 200)
|
||||
self.assertFalse(self.auth.send_auth_key(mock_blink, 1234))
|
||||
|
||||
self.assertTrue(self.auth.send_auth_key(mock_blink, None))
|
||||
|
||||
@mock.patch("blinkpy.auth.api.request_verify")
|
||||
def test_send_auth_key_fail(self, mock_req):
|
||||
"""Check handling of auth key failure."""
|
||||
mock_blink = MockBlink(None)
|
||||
mock_req.return_value = mresp.MockResponse(None, 200)
|
||||
self.assertFalse(self.auth.send_auth_key(mock_blink, 1234))
|
||||
mock_req.return_value = mresp.MockResponse({}, 200)
|
||||
self.assertFalse(self.auth.send_auth_key(mock_blink, 1234))
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.validate_response")
|
||||
@mock.patch("blinkpy.auth.Auth.refresh_token")
|
||||
def test_query_retry(self, mock_refresh, mock_validate):
|
||||
"""Check handling of request retry."""
|
||||
self.auth.session = MockSession()
|
||||
mock_validate.side_effect = [UnauthorizedError, "foobar"]
|
||||
mock_refresh.return_value = True
|
||||
self.assertEqual(self.auth.query(url="http://example.com"), "foobar")
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.validate_response")
|
||||
@mock.patch("blinkpy.auth.Auth.refresh_token")
|
||||
def test_query_retry_failed(self, mock_refresh, mock_validate):
|
||||
"""Check handling of failed retry request."""
|
||||
self.auth.seession = MockSession()
|
||||
mock_validate.side_effect = [UnauthorizedError, BlinkBadResponse]
|
||||
mock_refresh.return_value = True
|
||||
self.assertEqual(self.auth.query(url="http://example.com"), None)
|
||||
|
||||
mock_validate.side_effect = [UnauthorizedError, TokenRefreshFailed]
|
||||
self.assertEqual(self.auth.query(url="http://example.com"), None)
|
||||
|
||||
|
||||
class MockSession:
|
||||
"""Object to mock a session."""
|
||||
|
||||
def send(self, *args, **kwargs):
|
||||
"""Mock send function."""
|
||||
return None
|
||||
|
||||
|
||||
class MockBlink:
|
||||
"""Object to mock basic blink class."""
|
||||
|
||||
def __init__(self, login_response):
|
||||
"""Initialize mock blink class."""
|
||||
self.available = False
|
||||
self.login_response = login_response
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests camera and system functions."""
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import logging
|
||||
|
||||
from blinkpy import blinkpy
|
||||
from blinkpy.sync_module import BlinkSyncModule
|
||||
from blinkpy.helpers.util import get_time, BlinkURLHandler
|
||||
|
||||
|
||||
class MockSyncModule(BlinkSyncModule):
|
||||
"""Mock http requests from sync module."""
|
||||
|
||||
def __init__(self, blink, header):
|
||||
"""Create mock sync module instance."""
|
||||
super().__init__(blink, header, network_id=None, camera_list=None)
|
||||
self.blink = blink
|
||||
self.header = header
|
||||
self.return_value = None
|
||||
self.return_value2 = None
|
||||
|
||||
def http_get(self, url, stream=False, json=True):
|
||||
"""Mock get request."""
|
||||
if stream and self.return_value2 is not None:
|
||||
return self.return_value2
|
||||
return self.return_value
|
||||
|
||||
def http_post(self, url):
|
||||
"""Mock post request."""
|
||||
return self.return_value
|
||||
|
||||
|
||||
class TestBlinkFunctions(unittest.TestCase):
|
||||
"""Test Blink and BlinkCamera functions in blinkpy."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up Blink module."""
|
||||
self.blink = blinkpy.Blink()
|
||||
self.blink.urls = BlinkURLHandler("test")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after test."""
|
||||
self.blink = None
|
||||
|
||||
def test_merge_cameras(self):
|
||||
"""Test merge camera functionality."""
|
||||
first_dict = {"foo": "bar", "test": 123}
|
||||
next_dict = {"foobar": 456, "bar": "foo"}
|
||||
self.blink.sync["foo"] = BlinkSyncModule(self.blink, "foo", 1, [])
|
||||
self.blink.sync["bar"] = BlinkSyncModule(self.blink, "bar", 2, [])
|
||||
self.blink.sync["foo"].cameras = first_dict
|
||||
self.blink.sync["bar"].cameras = next_dict
|
||||
result = self.blink.merge_cameras()
|
||||
expected = {"foo": "bar", "test": 123, "foobar": 456, "bar": "foo"}
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
@mock.patch("blinkpy.blinkpy.api.request_videos")
|
||||
def test_download_video_exit(self, mock_req):
|
||||
"""Test we exit method when provided bad response."""
|
||||
blink = blinkpy.Blink()
|
||||
# pylint: disable=protected-access
|
||||
blinkpy._LOGGER.setLevel(logging.DEBUG)
|
||||
blink.last_refresh = 0
|
||||
mock_req.return_value = {}
|
||||
formatted_date = get_time(blink.last_refresh)
|
||||
expected_log = [
|
||||
"INFO:blinkpy.blinkpy:Retrieving videos since {}".format(formatted_date),
|
||||
"DEBUG:blinkpy.blinkpy:Processing page 1",
|
||||
"INFO:blinkpy.blinkpy:No videos found on page 1. Exiting.",
|
||||
]
|
||||
with self.assertLogs() as dl_log:
|
||||
blink.download_videos("/tmp")
|
||||
self.assertEqual(dl_log.output, expected_log)
|
||||
|
||||
@mock.patch("blinkpy.blinkpy.api.request_videos")
|
||||
def test_parse_downloaded_items(self, mock_req):
|
||||
"""Test ability to parse downloaded items list."""
|
||||
blink = blinkpy.Blink()
|
||||
# pylint: disable=protected-access
|
||||
blinkpy._LOGGER.setLevel(logging.DEBUG)
|
||||
generic_entry = {
|
||||
"created_at": "1970",
|
||||
"device_name": "foo",
|
||||
"deleted": True,
|
||||
"media": "/bar.mp4",
|
||||
}
|
||||
result = [generic_entry]
|
||||
mock_req.return_value = {"media": result}
|
||||
blink.last_refresh = 0
|
||||
formatted_date = get_time(blink.last_refresh)
|
||||
expected_log = [
|
||||
"INFO:blinkpy.blinkpy:Retrieving videos since {}".format(formatted_date),
|
||||
"DEBUG:blinkpy.blinkpy:Processing page 1",
|
||||
"DEBUG:blinkpy.blinkpy:foo: /bar.mp4 is marked as deleted.",
|
||||
]
|
||||
with self.assertLogs() as dl_log:
|
||||
blink.download_videos("/tmp", stop=2)
|
||||
self.assertEqual(dl_log.output, expected_log)
|
||||
|
||||
@mock.patch("blinkpy.blinkpy.api.request_videos")
|
||||
def test_parse_camera_not_in_list(self, mock_req):
|
||||
"""Test ability to parse downloaded items list."""
|
||||
blink = blinkpy.Blink()
|
||||
# pylint: disable=protected-access
|
||||
blinkpy._LOGGER.setLevel(logging.DEBUG)
|
||||
generic_entry = {
|
||||
"created_at": "1970",
|
||||
"device_name": "foo",
|
||||
"deleted": True,
|
||||
"media": "/bar.mp4",
|
||||
}
|
||||
result = [generic_entry]
|
||||
mock_req.return_value = {"media": result}
|
||||
blink.last_refresh = 0
|
||||
formatted_date = get_time(blink.last_refresh)
|
||||
expected_log = [
|
||||
"INFO:blinkpy.blinkpy:Retrieving videos since {}".format(formatted_date),
|
||||
"DEBUG:blinkpy.blinkpy:Processing page 1",
|
||||
"DEBUG:blinkpy.blinkpy:Skipping videos for foo.",
|
||||
]
|
||||
with self.assertLogs() as dl_log:
|
||||
blink.download_videos("/tmp", camera="bar", stop=2)
|
||||
self.assertEqual(dl_log.output, expected_log)
|
||||
@@ -1,100 +0,0 @@
|
||||
import blinkpy
|
||||
import requests
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from blinkpy import LOGIN_URL
|
||||
from blinkpy import BASE_URL
|
||||
import test_const as const
|
||||
|
||||
def mocked_requests_post(*args, **kwargs):
|
||||
class MockPostResponse:
|
||||
def __init__(self,json_data,status_code):
|
||||
self.json_data = json_data
|
||||
self.status_code = status_code
|
||||
def json(self):
|
||||
return self.json_data
|
||||
|
||||
if args[0] == LOGIN_URL:
|
||||
return MockPostResponse({"region":{const.REGION_ID: const.REGION}, "authtoken":{"authtoken":const.TOKEN}}, 200)
|
||||
elif args[0].split("/")[-1] == 'arm':
|
||||
return MockPostResponse({"armed":True}, 200)
|
||||
elif args[0].split("/")[-1] == 'disarm':
|
||||
return MockPostResponse({"armed":False}, 200)
|
||||
else:
|
||||
return MockPostResponse({}, 200)
|
||||
|
||||
return MockPostResponse({'message':'ERROR','code':404}, 404)
|
||||
|
||||
def mocked_requests_get(*args, **kwargs):
|
||||
class MockGetResponse:
|
||||
def __init__(self,json_data,status_code):
|
||||
self.json_data = json_data
|
||||
self.status_code = status_code
|
||||
def json(self):
|
||||
return self.json_data
|
||||
|
||||
if args[0] == BASE_URL + '/networks':
|
||||
return MockGetResponse({'networks':[{"id":const.NETWORK_ID,"account_id":const.ACCOUNT_ID},{"nothing":"nothing"}]}, 200)
|
||||
else:
|
||||
return MockGetResponse(const.response, 200)
|
||||
|
||||
|
||||
return MockGetResponse({'message':'ERROR','code':404}, 404)
|
||||
|
||||
class TestBlinkRequests(unittest.TestCase):
|
||||
@mock.patch('blinkpy.requests.post', side_effect=mocked_requests_post)
|
||||
@mock.patch('blinkpy.requests.get', side_effect=mocked_requests_get)
|
||||
def test_blink_setup(self, mock_get, mock_post):
|
||||
blink = blinkpy.Blink(username='user',password='password')
|
||||
blink.setup_system()
|
||||
|
||||
self.assertEqual(blink.network_id, str(const.NETWORK_ID))
|
||||
self.assertEqual(blink.account_id, str(const.ACCOUNT_ID))
|
||||
self.assertEqual(blink.region, const.REGION)
|
||||
self.assertEqual(blink.region_id, const.REGION_ID)
|
||||
self.assertEqual(blink.online, const.ISONLINE)
|
||||
self.assertEqual(blink.arm, const.ARMED)
|
||||
|
||||
@mock.patch('blinkpy.requests.post', side_effect=mocked_requests_post)
|
||||
@mock.patch('blinkpy.requests.get', side_effect=mocked_requests_get)
|
||||
def test_blink_camera_setup_and_motion(self, mock_get, mock_post):
|
||||
blink = blinkpy.Blink(username='user',password='password')
|
||||
blink.setup_system()
|
||||
blink.last_motion()
|
||||
for name, camera in blink.cameras.items():
|
||||
if camera.id == str(const.DEVICE_ID):
|
||||
self.assertEqual(name, const.CAMERA_NAME)
|
||||
self.assertEqual(camera.armed, const.ARMED)
|
||||
self.assertEqual(camera.motion['video'], BASE_URL + const.THUMB + '.mp4')
|
||||
elif camera.id == str(const.DEVICE_ID2):
|
||||
self.assertEqual(name, const.CAMERA_NAME2)
|
||||
self.assertEqual(camera.armed, const.ARMED2)
|
||||
self.assertEqual(len(camera.motion.keys()), 0)
|
||||
else:
|
||||
assert False is True
|
||||
|
||||
@mock.patch('blinkpy.requests.post', side_effect=mocked_requests_post)
|
||||
@mock.patch('blinkpy.requests.get', side_effect=mocked_requests_get)
|
||||
def test_blink_refresh(self, mock_get, mock_post):
|
||||
blink = blinkpy.Blink(username='user',password='password')
|
||||
blink.setup_system()
|
||||
const.response['devices'][0]['thumbnail'] = const.THUMB + const.THUMB2
|
||||
blink.refresh()
|
||||
for name, camera in blink.cameras.items():
|
||||
if camera.id == str(const.DEVICE_ID):
|
||||
self.assertEqual(camera.thumbnail, BASE_URL + const.THUMB + const.THUMB2 + '.jpg')
|
||||
elif camera.id == str(const.DEVICE_ID2):
|
||||
pass
|
||||
else:
|
||||
assert False is True
|
||||
|
||||
const.response['devices'][0]['thumbnail'] = 'new'
|
||||
blink.cameras[const.CAMERA_NAME].image_refresh()
|
||||
for name, camera in blink.cameras.items():
|
||||
if camera.id == str(const.DEVICE_ID):
|
||||
self.assertEqual(camera.thumbnail, BASE_URL + 'new' + '.jpg')
|
||||
elif camera.id == str(const.DEVICE_ID2):
|
||||
pass
|
||||
else:
|
||||
assert False is True
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Test full system.
|
||||
|
||||
Tests the system initialization and attributes of
|
||||
the main Blink system. Tests if we properly catch
|
||||
any communication related errors at startup.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from blinkpy.blinkpy import Blink, BlinkSetupError
|
||||
from blinkpy.sync_module import BlinkOwl
|
||||
from blinkpy.helpers.constants import __version__
|
||||
|
||||
|
||||
class TestBlinkSetup(unittest.TestCase):
|
||||
"""Test the Blink class in blinkpy."""
|
||||
|
||||
def setUp(self):
|
||||
"""Initialize blink test object."""
|
||||
self.blink = Blink()
|
||||
self.blink.available = True
|
||||
|
||||
def tearDown(self):
|
||||
"""Cleanup blink test object."""
|
||||
self.blink = None
|
||||
|
||||
def test_initialization(self):
|
||||
"""Verify we can initialize blink."""
|
||||
blink = Blink()
|
||||
self.assertEqual(blink.version, __version__)
|
||||
|
||||
def test_network_id_failure(self):
|
||||
"""Check that with bad network data a setup error is raised."""
|
||||
self.blink.networks = None
|
||||
with self.assertRaises(BlinkSetupError):
|
||||
self.blink.setup_network_ids()
|
||||
|
||||
def test_multiple_networks(self):
|
||||
"""Check that we handle multiple networks appropriately."""
|
||||
self.blink.networks = {
|
||||
"0000": {"onboarded": False, "name": "foo"},
|
||||
"5678": {"onboarded": True, "name": "bar"},
|
||||
"1234": {"onboarded": False, "name": "test"},
|
||||
}
|
||||
self.blink.setup_network_ids()
|
||||
self.assertTrue("5678" in self.blink.network_ids)
|
||||
|
||||
def test_multiple_onboarded_networks(self):
|
||||
"""Check that we handle multiple networks appropriately."""
|
||||
self.blink.networks = {
|
||||
"0000": {"onboarded": False, "name": "foo"},
|
||||
"5678": {"onboarded": True, "name": "bar"},
|
||||
"1234": {"onboarded": True, "name": "test"},
|
||||
}
|
||||
self.blink.setup_network_ids()
|
||||
self.assertTrue("0000" not in self.blink.network_ids)
|
||||
self.assertTrue("5678" in self.blink.network_ids)
|
||||
self.assertTrue("1234" in self.blink.network_ids)
|
||||
|
||||
@mock.patch("blinkpy.blinkpy.time.time")
|
||||
def test_throttle(self, mock_time):
|
||||
"""Check throttling functionality."""
|
||||
now = self.blink.refresh_rate + 1
|
||||
mock_time.return_value = now
|
||||
self.assertEqual(self.blink.last_refresh, None)
|
||||
self.assertEqual(self.blink.check_if_ok_to_update(), True)
|
||||
self.assertEqual(self.blink.last_refresh, None)
|
||||
with mock.patch(
|
||||
"blinkpy.sync_module.BlinkSyncModule.refresh", return_value=True
|
||||
):
|
||||
self.blink.refresh()
|
||||
|
||||
self.assertEqual(self.blink.last_refresh, now)
|
||||
self.assertEqual(self.blink.check_if_ok_to_update(), False)
|
||||
self.assertEqual(self.blink.last_refresh, now)
|
||||
|
||||
def test_sync_case_insensitive_dict(self):
|
||||
"""Check that we can access sync modules ignoring case."""
|
||||
self.blink.sync["test"] = 1234
|
||||
self.assertEqual(self.blink.sync["test"], 1234)
|
||||
self.assertEqual(self.blink.sync["TEST"], 1234)
|
||||
self.assertEqual(self.blink.sync["tEsT"], 1234)
|
||||
|
||||
@mock.patch("blinkpy.api.request_camera_usage")
|
||||
@mock.patch("blinkpy.api.request_homescreen")
|
||||
def test_setup_cameras(self, mock_home, mock_req):
|
||||
"""Check retrieval of camera information."""
|
||||
mock_home.return_value = {}
|
||||
mock_req.return_value = {
|
||||
"networks": [
|
||||
{
|
||||
"network_id": 1234,
|
||||
"cameras": [
|
||||
{"id": 5678, "name": "foo"},
|
||||
{"id": 5679, "name": "bar"},
|
||||
],
|
||||
},
|
||||
{"network_id": 4321, "cameras": [{"id": 0000, "name": "test"}]},
|
||||
]
|
||||
}
|
||||
result = self.blink.setup_camera_list()
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"1234": [{"name": "foo", "id": 5678}, {"name": "bar", "id": 5679}],
|
||||
"4321": [{"name": "test", "id": 0000}],
|
||||
},
|
||||
)
|
||||
|
||||
@mock.patch("blinkpy.api.request_camera_usage")
|
||||
def test_setup_cameras_failure(self, mock_home):
|
||||
"""Check that on failure we raise a setup error."""
|
||||
mock_home.return_value = {}
|
||||
with self.assertRaises(BlinkSetupError):
|
||||
self.blink.setup_camera_list()
|
||||
mock_home.return_value = None
|
||||
with self.assertRaises(BlinkSetupError):
|
||||
self.blink.setup_camera_list()
|
||||
|
||||
def test_setup_urls(self):
|
||||
"""Check setup of URLS."""
|
||||
self.blink.auth.region_id = "test"
|
||||
self.blink.setup_urls()
|
||||
self.assertEqual(self.blink.urls.subdomain, "rest-test")
|
||||
|
||||
def test_setup_urls_failure(self):
|
||||
"""Check that on failure we raise a setup error."""
|
||||
self.blink.auth.region_id = None
|
||||
with self.assertRaises(BlinkSetupError):
|
||||
self.blink.setup_urls()
|
||||
|
||||
@mock.patch("blinkpy.api.request_networks")
|
||||
def test_setup_networks(self, mock_networks):
|
||||
"""Check setup of networks."""
|
||||
mock_networks.return_value = {"summary": "foobar"}
|
||||
self.blink.setup_networks()
|
||||
self.assertEqual(self.blink.networks, "foobar")
|
||||
|
||||
@mock.patch("blinkpy.api.request_networks")
|
||||
def test_setup_networks_failure(self, mock_networks):
|
||||
"""Check that on failure we raise a setup error."""
|
||||
mock_networks.return_value = {}
|
||||
with self.assertRaises(BlinkSetupError):
|
||||
self.blink.setup_networks()
|
||||
mock_networks.return_value = None
|
||||
with self.assertRaises(BlinkSetupError):
|
||||
self.blink.setup_networks()
|
||||
|
||||
@mock.patch("blinkpy.blinkpy.Auth.send_auth_key")
|
||||
def test_setup_prompt_2fa(self, mock_key):
|
||||
"""Test setup with 2fa prompt."""
|
||||
self.blink.auth.data["username"] = "foobar"
|
||||
self.blink.key_required = True
|
||||
mock_key.return_value = True
|
||||
with mock.patch("builtins.input", return_value="foo"):
|
||||
self.blink.setup_prompt_2fa()
|
||||
self.assertFalse(self.blink.key_required)
|
||||
mock_key.return_value = False
|
||||
with mock.patch("builtins.input", return_value="foo"):
|
||||
self.blink.setup_prompt_2fa()
|
||||
self.assertTrue(self.blink.key_required)
|
||||
|
||||
@mock.patch("blinkpy.blinkpy.Blink.setup_camera_list")
|
||||
@mock.patch("blinkpy.api.request_networks")
|
||||
@mock.patch("blinkpy.blinkpy.Blink.setup_owls")
|
||||
def test_setup_post_verify(self, mock_owl, mock_networks, mock_camera):
|
||||
"""Test setup after verification."""
|
||||
self.blink.available = False
|
||||
self.blink.key_required = True
|
||||
mock_owl.return_value = True
|
||||
mock_networks.return_value = {
|
||||
"summary": {"foo": {"onboarded": False, "name": "bar"}}
|
||||
}
|
||||
mock_camera.return_value = []
|
||||
self.assertTrue(self.blink.setup_post_verify())
|
||||
self.assertTrue(self.blink.available)
|
||||
self.assertFalse(self.blink.key_required)
|
||||
|
||||
@mock.patch("blinkpy.api.request_networks")
|
||||
def test_setup_post_verify_failure(self, mock_networks):
|
||||
"""Test failed setup after verification."""
|
||||
self.blink.available = False
|
||||
mock_networks.return_value = {}
|
||||
self.assertFalse(self.blink.setup_post_verify())
|
||||
self.assertFalse(self.blink.available)
|
||||
|
||||
def test_merge_cameras(self):
|
||||
"""Test merging of cameras."""
|
||||
self.blink.sync = {
|
||||
"foo": MockSync({"test": 123, "foo": "bar"}),
|
||||
"bar": MockSync({"fizz": "buzz", "bar": "foo"}),
|
||||
}
|
||||
combined = self.blink.merge_cameras()
|
||||
self.assertEqual(combined["test"], 123)
|
||||
self.assertEqual(combined["foo"], "bar")
|
||||
self.assertEqual(combined["fizz"], "buzz")
|
||||
self.assertEqual(combined["bar"], "foo")
|
||||
|
||||
@mock.patch("blinkpy.blinkpy.BlinkOwl.start")
|
||||
def test_initialize_blink_minis(self, mock_start):
|
||||
"""Test blink mini initialization."""
|
||||
mock_start.return_value = True
|
||||
self.blink.homescreen = {
|
||||
"owls": [
|
||||
{
|
||||
"enabled": False,
|
||||
"id": 1,
|
||||
"name": "foo",
|
||||
"network_id": 2,
|
||||
"onboarded": True,
|
||||
"status": "online",
|
||||
"thumbnail": "/foo/bar",
|
||||
"serial": "1234",
|
||||
},
|
||||
{
|
||||
"enabled": True,
|
||||
"id": 3,
|
||||
"name": "bar",
|
||||
"network_id": 4,
|
||||
"onboarded": True,
|
||||
"status": "online",
|
||||
"thumbnail": "/foo/bar",
|
||||
"serial": "abcd",
|
||||
},
|
||||
]
|
||||
}
|
||||
self.blink.sync = {}
|
||||
self.blink.setup_owls()
|
||||
self.assertEqual(self.blink.sync["foo"].__class__, BlinkOwl)
|
||||
self.assertEqual(self.blink.sync["bar"].__class__, BlinkOwl)
|
||||
self.assertEqual(self.blink.sync["foo"].arm, False)
|
||||
self.assertEqual(self.blink.sync["bar"].arm, True)
|
||||
self.assertEqual(self.blink.sync["foo"].name, "foo")
|
||||
self.assertEqual(self.blink.sync["bar"].name, "bar")
|
||||
|
||||
def test_blink_mini_cameras_returned(self):
|
||||
"""Test that blink mini cameras are found if attached to sync module."""
|
||||
self.blink.network_ids = ["1234"]
|
||||
self.blink.homescreen = {
|
||||
"owls": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "foo",
|
||||
"network_id": 1234,
|
||||
"onboarded": True,
|
||||
"enabled": True,
|
||||
"status": "online",
|
||||
"thumbnail": "/foo/bar",
|
||||
"serial": "abc123",
|
||||
}
|
||||
]
|
||||
}
|
||||
result = self.blink.setup_owls()
|
||||
self.assertEqual(self.blink.network_ids, ["1234"])
|
||||
self.assertEqual(
|
||||
result, [{"1234": {"name": "foo", "id": "1234", "type": "mini"}}]
|
||||
)
|
||||
|
||||
self.blink.no_owls = True
|
||||
self.blink.network_ids = []
|
||||
self.blink.get_homescreen()
|
||||
result = self.blink.setup_owls()
|
||||
self.assertEqual(self.blink.network_ids, [])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@mock.patch("blinkpy.api.request_camera_usage")
|
||||
def test_blink_mini_attached_to_sync(self, mock_usage):
|
||||
"""Test that blink mini cameras are properly attached to sync module."""
|
||||
self.blink.network_ids = ["1234"]
|
||||
self.blink.homescreen = {
|
||||
"owls": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "foo",
|
||||
"network_id": 1234,
|
||||
"onboarded": True,
|
||||
"enabled": True,
|
||||
"status": "online",
|
||||
"thumbnail": "/foo/bar",
|
||||
"serial": "abc123",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]}
|
||||
result = self.blink.setup_camera_list()
|
||||
self.assertEqual(
|
||||
result, {"1234": [{"name": "foo", "id": "1234", "type": "mini"}]}
|
||||
)
|
||||
|
||||
|
||||
class MockSync:
|
||||
"""Mock sync module class."""
|
||||
|
||||
def __init__(self, cameras):
|
||||
"""Initialize fake class."""
|
||||
self.cameras = cameras
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Test all camera attributes.
|
||||
|
||||
Tests the camera initialization and attributes of
|
||||
individual BlinkCamera instantiations once the
|
||||
Blink system is set up.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.helpers.util import BlinkURLHandler
|
||||
from blinkpy.sync_module import BlinkSyncModule
|
||||
from blinkpy.camera import BlinkCamera, BlinkCameraMini
|
||||
|
||||
|
||||
CAMERA_CFG = {
|
||||
"camera": [
|
||||
{
|
||||
"battery_voltage": 90,
|
||||
"motion_alert": True,
|
||||
"wifi_strength": -30,
|
||||
"temperature": 68,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.query")
|
||||
class TestBlinkCameraSetup(unittest.TestCase):
|
||||
"""Test the Blink class in blinkpy."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up Blink module."""
|
||||
self.blink = Blink()
|
||||
self.blink.urls = BlinkURLHandler("test")
|
||||
self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, [])
|
||||
self.camera = BlinkCamera(self.blink.sync["test"])
|
||||
self.camera.name = "foobar"
|
||||
self.blink.sync["test"].cameras["foobar"] = self.camera
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after test."""
|
||||
self.blink = None
|
||||
self.camera = None
|
||||
|
||||
def test_camera_update(self, mock_resp):
|
||||
"""Test that we can properly update camera properties."""
|
||||
config = {
|
||||
"name": "new",
|
||||
"id": 1234,
|
||||
"network_id": 5678,
|
||||
"serial": "12345678",
|
||||
"enabled": False,
|
||||
"battery_voltage": 90,
|
||||
"battery_state": "ok",
|
||||
"temperature": 68,
|
||||
"wifi_strength": 4,
|
||||
"thumbnail": "/thumb",
|
||||
}
|
||||
self.camera.last_record = ["1"]
|
||||
self.camera.sync.last_record = {
|
||||
"new": {"clip": "/test.mp4", "time": "1970-01-01T00:00:00"}
|
||||
}
|
||||
mock_resp.side_effect = [
|
||||
{"temp": 71},
|
||||
"test",
|
||||
"foobar",
|
||||
]
|
||||
self.camera.update(config)
|
||||
self.assertEqual(self.camera.name, "new")
|
||||
self.assertEqual(self.camera.camera_id, "1234")
|
||||
self.assertEqual(self.camera.network_id, "5678")
|
||||
self.assertEqual(self.camera.serial, "12345678")
|
||||
self.assertEqual(self.camera.motion_enabled, False)
|
||||
self.assertEqual(self.camera.battery, "ok")
|
||||
self.assertEqual(self.camera.temperature, 68)
|
||||
self.assertEqual(self.camera.temperature_c, 20)
|
||||
self.assertEqual(self.camera.temperature_calibrated, 71)
|
||||
self.assertEqual(self.camera.wifi_strength, 4)
|
||||
self.assertEqual(
|
||||
self.camera.thumbnail, "https://rest-test.immedia-semi.com/thumb.jpg"
|
||||
)
|
||||
self.assertEqual(
|
||||
self.camera.clip, "https://rest-test.immedia-semi.com/test.mp4"
|
||||
)
|
||||
self.assertEqual(self.camera.image_from_cache, "test")
|
||||
self.assertEqual(self.camera.video_from_cache, "foobar")
|
||||
|
||||
def test_no_thumbnails(self, mock_resp):
|
||||
"""Tests that thumbnail is 'None' if none found."""
|
||||
mock_resp.return_value = "foobar"
|
||||
self.camera.last_record = ["1"]
|
||||
config = {
|
||||
"name": "new",
|
||||
"id": 1234,
|
||||
"network_id": 5678,
|
||||
"serial": "12345678",
|
||||
"enabled": False,
|
||||
"battery_voltage": 90,
|
||||
"battery_state": "ok",
|
||||
"temperature": 68,
|
||||
"wifi_strength": 4,
|
||||
"thumbnail": "",
|
||||
}
|
||||
self.camera.sync.homescreen = {"devices": []}
|
||||
self.assertEqual(self.camera.temperature_calibrated, None)
|
||||
with self.assertLogs() as logrecord:
|
||||
self.camera.update(config, force=True)
|
||||
self.assertEqual(self.camera.thumbnail, None)
|
||||
self.assertEqual(self.camera.last_record, ["1"])
|
||||
self.assertEqual(self.camera.temperature_calibrated, 68)
|
||||
self.assertEqual(
|
||||
logrecord.output,
|
||||
[
|
||||
(
|
||||
"WARNING:blinkpy.camera:Could not retrieve calibrated "
|
||||
"temperature."
|
||||
),
|
||||
(
|
||||
"WARNING:blinkpy.camera:Could not find thumbnail for camera new"
|
||||
"\nNoneType: None"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def test_no_video_clips(self, mock_resp):
|
||||
"""Tests that we still proceed with camera setup with no videos."""
|
||||
mock_resp.return_value = "foobar"
|
||||
config = {
|
||||
"name": "new",
|
||||
"id": 1234,
|
||||
"network_id": 5678,
|
||||
"serial": "12345678",
|
||||
"enabled": False,
|
||||
"battery_voltage": 90,
|
||||
"battery_state": "ok",
|
||||
"temperature": 68,
|
||||
"wifi_strength": 4,
|
||||
"thumbnail": "/foobar",
|
||||
}
|
||||
self.camera.sync.homescreen = {"devices": []}
|
||||
self.camera.update(config, force_cache=True)
|
||||
self.assertEqual(self.camera.clip, None)
|
||||
self.assertEqual(self.camera.video_from_cache, None)
|
||||
|
||||
def test_camera_arm_status(self, mock_resp):
|
||||
"""Test arming and disarming camera."""
|
||||
self.camera.motion_enabled = None
|
||||
self.assertFalse(self.camera.arm)
|
||||
self.camera.motion_enabled = False
|
||||
self.assertFalse(self.camera.arm)
|
||||
self.camera.motion_enabled = True
|
||||
self.assertTrue(self.camera.arm)
|
||||
|
||||
@mock.patch("blinkpy.camera.api.request_motion_detection_enable")
|
||||
@mock.patch("blinkpy.camera.api.request_motion_detection_disable")
|
||||
def test_motion_detection_enable_disable(self, mock_dis, mock_en, mock_rep):
|
||||
"""Test setting motion detection enable properly."""
|
||||
mock_dis.return_value = "disable"
|
||||
mock_en.return_value = "enable"
|
||||
self.assertEqual(self.camera.set_motion_detect(True), "enable")
|
||||
self.assertEqual(self.camera.set_motion_detect(False), "disable")
|
||||
|
||||
def test_missing_attributes(self, mock_resp):
|
||||
"""Test that attributes return None if missing."""
|
||||
self.camera.temperature = None
|
||||
self.camera.serial = None
|
||||
attr = self.camera.attributes
|
||||
self.assertEqual(attr["serial"], None)
|
||||
self.assertEqual(attr["temperature"], None)
|
||||
self.assertEqual(attr["temperature_c"], None)
|
||||
|
||||
def test_mini_missing_attributes(self, mock_resp):
|
||||
"""Test that attributes return None if missing."""
|
||||
camera = BlinkCameraMini(self.blink.sync)
|
||||
self.blink.sync.network_id = None
|
||||
self.blink.sync.name = None
|
||||
attr = camera.attributes
|
||||
for key in attr:
|
||||
self.assertEqual(attr[key], None)
|
||||
@@ -1,75 +0,0 @@
|
||||
import testtools
|
||||
import unittest
|
||||
import blinkpy
|
||||
from blinkpy import BASE_URL
|
||||
|
||||
class TestBlink(unittest.TestCase):
|
||||
def test_blink_instance(self):
|
||||
blink = blinkpy.Blink()
|
||||
|
||||
def test_blink_camera_attributes(self):
|
||||
config = {'name':'name', 'device_id': 1248, 'armed':True, 'thumbnail':'/test/url/image', 'temp':70, 'battery':3, 'notifications':0, 'region_id':'rid'}
|
||||
|
||||
camera = blinkpy.BlinkCamera(config)
|
||||
|
||||
# Check that values are properly stored and can be recalled
|
||||
self.assertEqual(camera.name, config['name'])
|
||||
self.assertEqual(camera.id, str(config['device_id']))
|
||||
self.assertEqual(camera.armed, config['armed'])
|
||||
self.assertEqual(camera.clip, BASE_URL + config['thumbnail'] + '.mp4')
|
||||
self.assertEqual(camera.thumbnail, BASE_URL + config['thumbnail'] + '.jpg')
|
||||
self.assertEqual(camera.temperature, config['temp'])
|
||||
self.assertEqual(camera.battery, config['battery'])
|
||||
self.assertEqual(camera.notifications, config['notifications'])
|
||||
self.assertEqual(camera.region_id, config['region_id'])
|
||||
|
||||
# Check that values can be changed with individual methods
|
||||
test_header = {'Header':'test','Test':'1234'}
|
||||
test_img = 'http://image-link.com'
|
||||
test_arm = 'http://arm-link.com'
|
||||
test_motion = {'video':'url','image':'url','time':'timestamp'}
|
||||
|
||||
camera.name = config['name']+'_new'
|
||||
camera.clip = BASE_URL + config['thumbnail'] + '.mp4_new'
|
||||
camera.thumbnail = BASE_URL + config['thumbnail'] + '.jpg_new'
|
||||
camera.temperature = config['temp'] + 10
|
||||
camera.battery = config['battery'] + 1
|
||||
camera.notifications = config['notifications'] + 1
|
||||
camera.image_link = test_img
|
||||
camera.arm_link = test_arm
|
||||
camera.header = test_header
|
||||
camera.motion = test_motion
|
||||
|
||||
# Check that values are properly stored and can be recalled
|
||||
self.assertEqual(camera.name , config['name']+'_new')
|
||||
self.assertEqual(camera.clip , BASE_URL + config['thumbnail'] + '.mp4_new')
|
||||
self.assertEqual(camera.thumbnail, BASE_URL + config['thumbnail'] + '.jpg_new')
|
||||
self.assertEqual(camera.temperature, config['temp'] + 10)
|
||||
self.assertEqual(camera.battery, config['battery'] + 1)
|
||||
self.assertEqual(camera.notifications, config['notifications'] + 1)
|
||||
self.assertEqual(camera.image_link, test_img)
|
||||
self.assertEqual(camera.arm_link, test_arm)
|
||||
self.assertEqual(camera.header, test_header)
|
||||
self.assertEqual(camera.motion, test_motion)
|
||||
|
||||
# Verify bulk update function
|
||||
test_name = camera.name +' last'
|
||||
test_status = True
|
||||
test_url = '-this-is-a-test/for_realz'
|
||||
test_temp = camera.temperature + 7
|
||||
test_battery = camera.battery - 1
|
||||
test_notif = camera.notifications - 1
|
||||
values = {'name':test_name, 'armed':test_status, 'thumbnail':test_url, 'temp':test_temp, 'battery':test_battery, 'notifications':test_notif}
|
||||
|
||||
camera.update(values)
|
||||
# Check that values are properly stored and can be recalled
|
||||
jpg_url = BASE_URL + test_url +'.jpg'
|
||||
mp4_url = BASE_URL + test_url +'.mp4'
|
||||
self.assertEqual(camera.name, test_name)
|
||||
self.assertEqual(camera.armed, test_status)
|
||||
self.assertEqual(camera.clip, mp4_url)
|
||||
self.assertEqual(camera.thumbnail, jpg_url)
|
||||
self.assertEqual(camera.temperature, test_temp)
|
||||
self.assertEqual(camera.battery, test_battery)
|
||||
self.assertEqual(camera.notifications, test_notif)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
ISONLINE = True
|
||||
ARMED = True
|
||||
ARMED2 = False
|
||||
REGION_ID = 'test'
|
||||
REGION = 'Oceaniaeurasia'
|
||||
TOKEN = 'abcd1234$$@@'
|
||||
NETWORK_ID = 1337
|
||||
DEVICE_ID = 9876
|
||||
DEVICE_ID2 = 6789
|
||||
ACCOUNT_ID = 1234
|
||||
CAMERA_NAME = 'My Camera'
|
||||
CAMERA_NAME2 = 'Number 2'
|
||||
BATTERY = 3
|
||||
BATTERY2 = 1
|
||||
TEMP = 70
|
||||
TEMP2 = 66
|
||||
THUMB = '/url/camera/7777/clip'
|
||||
THUMB2 = '/url/camera/8888/clip'
|
||||
NOTIFS = 1
|
||||
NOTIFS2 = 1
|
||||
SYNC_ID = 1000
|
||||
|
||||
if ISONLINE:
|
||||
ONLINE = 'online'
|
||||
else:
|
||||
ONLINE = 'offline'
|
||||
|
||||
|
||||
response = {'account': {'notifications': 1},
|
||||
'devices': [{'device_type': 'camera',
|
||||
'notifications': NOTIFS,
|
||||
'battery': BATTERY,
|
||||
'active': 'disabled',
|
||||
'errors': 0,
|
||||
'error_msg': '',
|
||||
'enabled': False,
|
||||
'temp': TEMP,
|
||||
'updated_at': '2017-01-27T03:14:24+00:00',
|
||||
'lfr_strength': 3,
|
||||
'armed': ARMED,
|
||||
'device_id': DEVICE_ID,
|
||||
'wifi_strength': 5,
|
||||
'warning': 0,
|
||||
'thumbnail': THUMB,
|
||||
'name': CAMERA_NAME,
|
||||
'status': 'done'},
|
||||
{'device_type': 'camera',
|
||||
'notifications': NOTIFS2,
|
||||
'battery': BATTERY2,
|
||||
'active': 'disabled',
|
||||
'errors': 0,
|
||||
'error_msg': '',
|
||||
'enabled': False,
|
||||
'temp': TEMP2,
|
||||
'updated_at': '2017-01-27T03:14:24+00:00',
|
||||
'lfr_strength': 3,
|
||||
'armed': ARMED2,
|
||||
'device_id': DEVICE_ID2,
|
||||
'wifi_strength': 5,
|
||||
'warning': 0,
|
||||
'thumbnail': THUMB2,
|
||||
'name': CAMERA_NAME2,
|
||||
'status': 'done'},
|
||||
{'updated_at': '2017-01-26T19:32:10+00:00',
|
||||
'device_type': 'sync_module',
|
||||
'notifications': 0,
|
||||
'device_id': SYNC_ID,
|
||||
'status':
|
||||
'online',
|
||||
'last_hb':
|
||||
'2017-01-27T03:14:49+00:00',
|
||||
'errors': 0,
|
||||
'error_msg': '',
|
||||
'warning': 0}],
|
||||
'network': {'armed': ARMED,
|
||||
'wifi_strength': 5,
|
||||
'warning': 0,
|
||||
'error_msg': '',
|
||||
'name': 'Blink',
|
||||
'notifications': NOTIFS,
|
||||
'status': 'ok'},
|
||||
'event': [{'camera_name': CAMERA_NAME,
|
||||
'updated_at': '2017-01-28T19:51:52+00:00',
|
||||
'sync_module_id': None,
|
||||
'camera': DEVICE_ID,
|
||||
'type': 'motion',
|
||||
'duration': None,
|
||||
'status': None,
|
||||
'created_at': '2017-01-28T19:51:52+00:00',
|
||||
'camera_id': DEVICE_ID,
|
||||
'id': 123456789,
|
||||
'siren_id': None,
|
||||
'account_id': ACCOUNT_ID,
|
||||
'notified': True,
|
||||
'siren': None,
|
||||
'syncmodule': None,
|
||||
'video_url': THUMB+'.mp4',
|
||||
'command_id': None,
|
||||
'network_id': None,
|
||||
'account': ACCOUNT_ID,
|
||||
'video_id': 100000001},
|
||||
{'updated_at': '2017-01-28T19:51:29+00:00',
|
||||
'sync_module_id': SYNC_ID,
|
||||
'camera': None,
|
||||
'type': 'armed',
|
||||
'duration': None,
|
||||
'status': None,
|
||||
'created_at':
|
||||
'2017-01-28T19:51:29+00:00',
|
||||
'camera_id': None,
|
||||
'id': 123456789,
|
||||
'siren_id': None,
|
||||
'account_id': ACCOUNT_ID,
|
||||
'notified': False,
|
||||
'siren': None,
|
||||
'syncmodule': SYNC_ID,
|
||||
'command_id': None,
|
||||
'network_id': None,
|
||||
'account': ACCOUNT_ID},
|
||||
{'updated_at': '2017-01-28T19:00:12+00:00',
|
||||
'sync_module_id': SYNC_ID,
|
||||
'camera': None,
|
||||
'type': 'disarmed',
|
||||
'duration': None,
|
||||
'status': None,
|
||||
'created_at': '2017-01-28T19:00:12+00:00',
|
||||
'camera_id': None,
|
||||
'id': 123456789,
|
||||
'siren_id': None,
|
||||
'account_id': 2463,
|
||||
'notified': False,
|
||||
'siren': None,
|
||||
'syncmodule': SYNC_ID,
|
||||
'command_id': None,
|
||||
'network_id': None,
|
||||
'account': ACCOUNT_ID},
|
||||
{'camera_name': CAMERA_NAME,
|
||||
'updated_at': '2017-01-28T18:59:55+00:00',
|
||||
'sync_module_id': None,
|
||||
'camera': DEVICE_ID,
|
||||
'type': 'motion',
|
||||
'duration': None,
|
||||
'status': None,
|
||||
'created_at': '2017-01-28T18:59:55+00:00',
|
||||
'camera_id': DEVICE_ID,
|
||||
'id': 123456789,
|
||||
'siren_id': None,
|
||||
'account_id': ACCOUNT_ID,
|
||||
'notified': True,
|
||||
'siren': None,
|
||||
'syncmodule': None,
|
||||
'command_id': None,
|
||||
'network_id': None,
|
||||
'account': ACCOUNT_ID}],
|
||||
'syncmodule':{'name':'SyncName',
|
||||
'status':ONLINE}}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import testtools
|
||||
import blinkpy
|
||||
|
||||
class TestImport(testtools.TestCase):
|
||||
def test_import(self):
|
||||
pass
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests camera and system functions."""
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
from blinkpy.helpers.util import BlinkURLHandler
|
||||
from blinkpy.sync_module import BlinkSyncModule, BlinkOwl
|
||||
from blinkpy.camera import BlinkCamera, BlinkCameraMini
|
||||
|
||||
|
||||
@mock.patch("blinkpy.auth.Auth.query")
|
||||
class TestBlinkSyncModule(unittest.TestCase):
|
||||
"""Test BlinkSyncModule functions in blinkpy."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up Blink module."""
|
||||
self.blink = Blink(motion_interval=0)
|
||||
self.blink.last_refresh = 0
|
||||
self.blink.urls = BlinkURLHandler("test")
|
||||
self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", [])
|
||||
self.camera = BlinkCamera(self.blink.sync)
|
||||
self.mock_start = [
|
||||
{
|
||||
"syncmodule": {
|
||||
"id": 1234,
|
||||
"network_id": 5678,
|
||||
"serial": "12345678",
|
||||
"status": "foobar",
|
||||
}
|
||||
},
|
||||
{"event": True},
|
||||
{},
|
||||
{},
|
||||
None,
|
||||
{"devicestatus": {}},
|
||||
]
|
||||
self.blink.sync["test"].network_info = {"network": {"armed": True}}
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after test."""
|
||||
self.blink = None
|
||||
self.camera = None
|
||||
self.mock_start = None
|
||||
|
||||
def test_bad_status(self, mock_resp):
|
||||
"""Check that we mark module unavaiable on bad status."""
|
||||
self.blink.sync["test"].status = None
|
||||
self.blink.sync["test"].available = True
|
||||
self.assertFalse(self.blink.sync["test"].online)
|
||||
self.assertFalse(self.blink.sync["test"].available)
|
||||
|
||||
def test_bad_arm(self, mock_resp):
|
||||
"""Check that we mark module unavaiable if bad arm status."""
|
||||
self.blink.sync["test"].network_info = None
|
||||
self.blink.sync["test"].available = True
|
||||
self.assertEqual(self.blink.sync["test"].arm, None)
|
||||
self.assertFalse(self.blink.sync["test"].available)
|
||||
self.blink.sync["test"].network_info = {}
|
||||
self.blink.sync["test"].available = True
|
||||
self.assertEqual(self.blink.sync["test"].arm, None)
|
||||
self.assertFalse(self.blink.sync["test"].available)
|
||||
|
||||
def test_get_events(self, mock_resp):
|
||||
"""Test get events function."""
|
||||
mock_resp.return_value = {"event": True}
|
||||
self.assertEqual(self.blink.sync["test"].get_events(), True)
|
||||
|
||||
def test_get_events_fail(self, mock_resp):
|
||||
"""Test handling of failed get events function."""
|
||||
mock_resp.return_value = None
|
||||
self.assertFalse(self.blink.sync["test"].get_events())
|
||||
mock_resp.return_value = {}
|
||||
self.assertFalse(self.blink.sync["test"].get_events())
|
||||
|
||||
def test_get_camera_info(self, mock_resp):
|
||||
"""Test get camera info function."""
|
||||
mock_resp.return_value = {"camera": ["foobar"]}
|
||||
self.assertEqual(self.blink.sync["test"].get_camera_info("1234"), "foobar")
|
||||
|
||||
def test_get_camera_info_fail(self, mock_resp):
|
||||
"""Test handling of failed get camera info function."""
|
||||
mock_resp.return_value = None
|
||||
self.assertEqual(self.blink.sync["test"].get_camera_info("1"), {})
|
||||
mock_resp.return_value = {}
|
||||
self.assertEqual(self.blink.sync["test"].get_camera_info("1"), {})
|
||||
mock_resp.return_value = {"camera": None}
|
||||
self.assertEqual(self.blink.sync["test"].get_camera_info("1"), {})
|
||||
|
||||
def test_get_network_info(self, mock_resp):
|
||||
"""Test network retrieval."""
|
||||
mock_resp.return_value = {"network": {"sync_module_error": False}}
|
||||
self.assertTrue(self.blink.sync["test"].get_network_info())
|
||||
mock_resp.return_value = {"network": {"sync_module_error": True}}
|
||||
self.assertFalse(self.blink.sync["test"].get_network_info())
|
||||
|
||||
def test_get_network_info_failure(self, mock_resp):
|
||||
"""Test failed network retrieval."""
|
||||
mock_resp.return_value = {}
|
||||
self.blink.sync["test"].available = True
|
||||
self.assertFalse(self.blink.sync["test"].get_network_info())
|
||||
self.assertFalse(self.blink.sync["test"].available)
|
||||
self.blink.sync["test"].available = True
|
||||
mock_resp.return_value = None
|
||||
self.assertFalse(self.blink.sync["test"].get_network_info())
|
||||
self.assertFalse(self.blink.sync["test"].available)
|
||||
|
||||
def test_check_new_videos_startup(self, mock_resp):
|
||||
"""Test that check_new_videos does not block startup."""
|
||||
sync_module = self.blink.sync["test"]
|
||||
self.blink.last_refresh = None
|
||||
self.assertFalse(sync_module.check_new_videos())
|
||||
|
||||
def test_check_new_videos(self, mock_resp):
|
||||
"""Test recent video response."""
|
||||
mock_resp.return_value = {
|
||||
"media": [
|
||||
{
|
||||
"device_name": "foo",
|
||||
"media": "/foo/bar.mp4",
|
||||
"created_at": "1990-01-01T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
sync_module = self.blink.sync["test"]
|
||||
sync_module.cameras = {"foo": None}
|
||||
sync_module.blink.last_refresh = 0
|
||||
self.assertEqual(sync_module.motion, {})
|
||||
self.assertTrue(sync_module.check_new_videos())
|
||||
self.assertEqual(
|
||||
sync_module.last_record["foo"],
|
||||
{"clip": "/foo/bar.mp4", "time": "1990-01-01T00:00:00+00:00"},
|
||||
)
|
||||
self.assertEqual(sync_module.motion, {"foo": True})
|
||||
mock_resp.return_value = {"media": []}
|
||||
self.assertTrue(sync_module.check_new_videos())
|
||||
self.assertEqual(sync_module.motion, {"foo": False})
|
||||
self.assertEqual(
|
||||
sync_module.last_record["foo"],
|
||||
{"clip": "/foo/bar.mp4", "time": "1990-01-01T00:00:00+00:00"},
|
||||
)
|
||||
|
||||
def test_check_new_videos_old_date(self, mock_resp):
|
||||
"""Test videos return response with old date."""
|
||||
mock_resp.return_value = {
|
||||
"media": [
|
||||
{
|
||||
"device_name": "foo",
|
||||
"media": "/foo/bar.mp4",
|
||||
"created_at": "1970-01-01T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
sync_module = self.blink.sync["test"]
|
||||
sync_module.cameras = {"foo": None}
|
||||
sync_module.blink.last_refresh = 1000
|
||||
self.assertTrue(sync_module.check_new_videos())
|
||||
self.assertEqual(sync_module.motion, {"foo": False})
|
||||
|
||||
def test_check_no_motion_if_not_armed(self, mock_resp):
|
||||
"""Test that motion detection is not set if module unarmed."""
|
||||
mock_resp.return_value = {
|
||||
"media": [
|
||||
{
|
||||
"device_name": "foo",
|
||||
"media": "/foo/bar.mp4",
|
||||
"created_at": "1990-01-01T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
}
|
||||
sync_module = self.blink.sync["test"]
|
||||
sync_module.cameras = {"foo": None}
|
||||
sync_module.blink.last_refresh = 1000
|
||||
self.assertTrue(sync_module.check_new_videos())
|
||||
self.assertEqual(sync_module.motion, {"foo": True})
|
||||
sync_module.network_info = {"network": {"armed": False}}
|
||||
self.assertTrue(sync_module.check_new_videos())
|
||||
self.assertEqual(sync_module.motion, {"foo": False})
|
||||
|
||||
def test_check_multiple_videos(self, mock_resp):
|
||||
"""Test motion found even with multiple videos."""
|
||||
mock_resp.return_value = {
|
||||
"media": [
|
||||
{
|
||||
"device_name": "foo",
|
||||
"media": "/foo/bar.mp4",
|
||||
"created_at": "1970-01-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"device_name": "foo",
|
||||
"media": "/bar/foo.mp4",
|
||||
"created_at": "1990-01-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"device_name": "foo",
|
||||
"media": "/foobar.mp4",
|
||||
"created_at": "1970-01-01T00:00:01+00:00",
|
||||
},
|
||||
]
|
||||
}
|
||||
sync_module = self.blink.sync["test"]
|
||||
sync_module.cameras = {"foo": None}
|
||||
sync_module.blink.last_refresh = 1000
|
||||
self.assertTrue(sync_module.check_new_videos())
|
||||
self.assertEqual(sync_module.motion, {"foo": True})
|
||||
expected_result = {
|
||||
"foo": {"clip": "/bar/foo.mp4", "time": "1990-01-01T00:00:00+00:00"}
|
||||
}
|
||||
self.assertEqual(sync_module.last_record, expected_result)
|
||||
|
||||
def test_check_new_videos_failed(self, mock_resp):
|
||||
"""Test method when response is unexpected."""
|
||||
mock_resp.side_effect = [None, "just a string", {}]
|
||||
sync_module = self.blink.sync["test"]
|
||||
sync_module.cameras = {"foo": None}
|
||||
|
||||
sync_module.motion["foo"] = True
|
||||
self.assertFalse(sync_module.check_new_videos())
|
||||
self.assertFalse(sync_module.motion["foo"])
|
||||
|
||||
sync_module.motion["foo"] = True
|
||||
self.assertFalse(sync_module.check_new_videos())
|
||||
self.assertFalse(sync_module.motion["foo"])
|
||||
|
||||
sync_module.motion["foo"] = True
|
||||
self.assertFalse(sync_module.check_new_videos())
|
||||
self.assertFalse(sync_module.motion["foo"])
|
||||
|
||||
def test_sync_start(self, mock_resp):
|
||||
"""Test sync start function."""
|
||||
mock_resp.side_effect = self.mock_start
|
||||
self.blink.sync["test"].start()
|
||||
self.assertEqual(self.blink.sync["test"].name, "test")
|
||||
self.assertEqual(self.blink.sync["test"].sync_id, 1234)
|
||||
self.assertEqual(self.blink.sync["test"].network_id, 5678)
|
||||
self.assertEqual(self.blink.sync["test"].serial, "12345678")
|
||||
self.assertEqual(self.blink.sync["test"].status, "foobar")
|
||||
|
||||
def test_unexpected_summary(self, mock_resp):
|
||||
"""Test unexpected summary response."""
|
||||
self.mock_start[0] = None
|
||||
mock_resp.side_effect = self.mock_start
|
||||
self.assertFalse(self.blink.sync["test"].start())
|
||||
|
||||
def test_summary_with_no_network_id(self, mock_resp):
|
||||
"""Test handling of bad summary."""
|
||||
self.mock_start[0]["syncmodule"] = None
|
||||
mock_resp.side_effect = self.mock_start
|
||||
self.assertFalse(self.blink.sync["test"].start())
|
||||
|
||||
def test_summary_with_only_network_id(self, mock_resp):
|
||||
"""Test handling of sparse summary."""
|
||||
self.mock_start[0]["syncmodule"] = {"network_id": 8675309}
|
||||
mock_resp.side_effect = self.mock_start
|
||||
self.blink.sync["test"].start()
|
||||
self.assertEqual(self.blink.sync["test"].network_id, 8675309)
|
||||
|
||||
def test_unexpected_camera_info(self, mock_resp):
|
||||
"""Test unexpected camera info response."""
|
||||
self.blink.sync["test"].cameras["foo"] = None
|
||||
self.mock_start[5] = None
|
||||
mock_resp.side_effect = self.mock_start
|
||||
self.blink.sync["test"].start()
|
||||
self.assertEqual(self.blink.sync["test"].cameras, {"foo": None})
|
||||
|
||||
def test_missing_camera_info(self, mock_resp):
|
||||
"""Test missing key from camera info response."""
|
||||
self.blink.sync["test"].cameras["foo"] = None
|
||||
self.mock_start[5] = {}
|
||||
self.blink.sync["test"].start()
|
||||
self.assertEqual(self.blink.sync["test"].cameras, {"foo": None})
|
||||
|
||||
def test_sync_attributes(self, mock_resp):
|
||||
"""Test sync attributes."""
|
||||
self.assertEqual(self.blink.sync["test"].attributes["name"], "test")
|
||||
self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234")
|
||||
|
||||
def test_owl_start(self, mock_resp):
|
||||
"""Test owl camera instantiation."""
|
||||
response = {
|
||||
"name": "foo",
|
||||
"id": 2,
|
||||
"serial": "foobar123",
|
||||
"enabled": True,
|
||||
"network_id": 1,
|
||||
"thumbnail": "/foo/bar",
|
||||
}
|
||||
self.blink.last_refresh = None
|
||||
self.blink.homescreen = {"owls": [response]}
|
||||
owl = BlinkOwl(self.blink, "foo", 1234, response)
|
||||
self.assertTrue(owl.start())
|
||||
self.assertTrue("foo" in owl.cameras)
|
||||
self.assertEqual(owl.cameras["foo"].__class__, BlinkCameraMini)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Test various api functions."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import time
|
||||
from blinkpy.helpers.util import json_load, Throttle, time_to_seconds
|
||||
|
||||
|
||||
class TestUtil(unittest.TestCase):
|
||||
"""Test the helpers/util module."""
|
||||
|
||||
def setUp(self):
|
||||
"""Initialize the blink module."""
|
||||
|
||||
def tearDown(self):
|
||||
"""Tear down blink module."""
|
||||
|
||||
def test_throttle(self):
|
||||
"""Test the throttle decorator."""
|
||||
calls = []
|
||||
|
||||
@Throttle(seconds=5)
|
||||
def test_throttle():
|
||||
calls.append(1)
|
||||
|
||||
now = int(time.time())
|
||||
now_plus_four = now + 4
|
||||
now_plus_six = now + 6
|
||||
|
||||
test_throttle()
|
||||
self.assertEqual(1, len(calls))
|
||||
|
||||
# Call again, still shouldn't fire
|
||||
test_throttle()
|
||||
self.assertEqual(1, len(calls))
|
||||
|
||||
# Call with force
|
||||
test_throttle(force=True)
|
||||
self.assertEqual(2, len(calls))
|
||||
|
||||
# Call without throttle, shouldn't fire
|
||||
test_throttle()
|
||||
self.assertEqual(2, len(calls))
|
||||
|
||||
# Fake time as 4 seconds from now
|
||||
with mock.patch("time.time", return_value=now_plus_four):
|
||||
test_throttle()
|
||||
self.assertEqual(2, len(calls))
|
||||
|
||||
# Fake time as 6 seconds from now
|
||||
with mock.patch("time.time", return_value=now_plus_six):
|
||||
test_throttle()
|
||||
self.assertEqual(3, len(calls))
|
||||
|
||||
def test_throttle_per_instance(self):
|
||||
"""Test that throttle is done once per instance of class."""
|
||||
|
||||
class Tester:
|
||||
"""A tester class for throttling."""
|
||||
|
||||
def test(self):
|
||||
"""Test the throttle."""
|
||||
return True
|
||||
|
||||
tester = Tester()
|
||||
throttled = Throttle(seconds=1)(tester.test)
|
||||
self.assertEqual(throttled(), True)
|
||||
self.assertEqual(throttled(), None)
|
||||
|
||||
def test_throttle_multiple_objects(self):
|
||||
"""Test that function is throttled even if called by multiple objects."""
|
||||
|
||||
@Throttle(seconds=5)
|
||||
def test_throttle_method():
|
||||
return True
|
||||
|
||||
class Tester:
|
||||
"""A tester class for throttling."""
|
||||
|
||||
def test(self):
|
||||
"""Test function for throttle."""
|
||||
return test_throttle_method()
|
||||
|
||||
tester1 = Tester()
|
||||
tester2 = Tester()
|
||||
self.assertEqual(tester1.test(), True)
|
||||
self.assertEqual(tester2.test(), None)
|
||||
|
||||
def test_throttle_on_two_methods(self):
|
||||
"""Test that throttle works for multiple methods."""
|
||||
|
||||
class Tester:
|
||||
"""A tester class for throttling."""
|
||||
|
||||
@Throttle(seconds=3)
|
||||
def test1(self):
|
||||
"""Test function for throttle."""
|
||||
return True
|
||||
|
||||
@Throttle(seconds=5)
|
||||
def test2(self):
|
||||
"""Test function for throttle."""
|
||||
return True
|
||||
|
||||
tester = Tester()
|
||||
now = time.time()
|
||||
now_plus_4 = now + 4
|
||||
now_plus_6 = now + 6
|
||||
|
||||
self.assertEqual(tester.test1(), True)
|
||||
self.assertEqual(tester.test2(), True)
|
||||
self.assertEqual(tester.test1(), None)
|
||||
self.assertEqual(tester.test2(), None)
|
||||
|
||||
with mock.patch("time.time", return_value=now_plus_4):
|
||||
self.assertEqual(tester.test1(), True)
|
||||
self.assertEqual(tester.test2(), None)
|
||||
|
||||
with mock.patch("time.time", return_value=now_plus_6):
|
||||
self.assertEqual(tester.test1(), None)
|
||||
self.assertEqual(tester.test2(), True)
|
||||
|
||||
def test_time_to_seconds(self):
|
||||
"""Test time to seconds conversion."""
|
||||
correct_time = "1970-01-01T00:00:05+00:00"
|
||||
wrong_time = "1/1/1970 00:00:03"
|
||||
self.assertEqual(time_to_seconds(correct_time), 5)
|
||||
self.assertFalse(time_to_seconds(wrong_time))
|
||||
|
||||
def test_json_load_bad_data(self):
|
||||
"""Check that bad file is handled."""
|
||||
self.assertEqual(json_load("fake.file"), None)
|
||||
with mock.patch("builtins.open", mock.mock_open(read_data="")):
|
||||
self.assertEqual(json_load("fake.file"), None)
|
||||
@@ -1,23 +1,58 @@
|
||||
[tox]
|
||||
envlist = py34, pep8
|
||||
envlist = build, py36, py37, py38, lint
|
||||
skip_missing_interpreters = True
|
||||
skipsdist = True
|
||||
|
||||
[testenv]
|
||||
setenv =
|
||||
LAND=en_US.UTF-8
|
||||
LANG=en_US.UTF-8
|
||||
PYTHONPATH = {toxinidir}
|
||||
commands =
|
||||
py.test -v --timeout=30 --duration=10 --cov=blinkpy --cov-report html {posargs}
|
||||
pytest --timeout=9 --durations=10 --cov=blinkpy --cov-report term-missing {posargs}
|
||||
deps =
|
||||
-r{toxinidir}/requirements.txt
|
||||
|
||||
[testenv:pep8]
|
||||
-r{toxinidir}/requirements_test.txt
|
||||
|
||||
[testenv:cov]
|
||||
setenv =
|
||||
LANG=en_US.UTF-8
|
||||
PYTHONPATH = {toxinidir}
|
||||
commands =
|
||||
pytest --timeout=9 --durations=10 --cov=blinkpy --cov-report=xml {posargs}
|
||||
deps =
|
||||
-r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/requirements_test.txt
|
||||
|
||||
[testenv:pylint]
|
||||
deps =
|
||||
-r{toxinidir}/requirements.txt
|
||||
flake8
|
||||
-r{toxinidir}/requirements_test.txt
|
||||
basepython = python3
|
||||
ignore_errors = True
|
||||
commands =
|
||||
pylint --rcfile={toxinidir}/pylintrc blinkpy tests app
|
||||
|
||||
[testenv:lint]
|
||||
deps =
|
||||
-r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/requirements_test.txt
|
||||
basepython = python3
|
||||
commands =
|
||||
flake8 blinkpy.py
|
||||
|
||||
[flake8]
|
||||
ignore = E501
|
||||
exclude = .venv,.git,.tox,dist,doc,*lib/python,*egg,build
|
||||
flake8 blinkpy tests app
|
||||
pydocstyle blinkpy tests app
|
||||
black --check --diff blinkpy tests app
|
||||
rst-lint README.rst CHANGES.rst CONTRIBUTING.rst
|
||||
|
||||
[testenv:build]
|
||||
recreate = True
|
||||
skip_install = True
|
||||
whitelist_externals =
|
||||
/bin/sh
|
||||
/bin/rm
|
||||
deps =
|
||||
-r{toxinidir}/requirements_test.txt
|
||||
commands =
|
||||
/bin/rm -rf build dist
|
||||
python setup.py bdist_wheel
|
||||
/bin/sh -c "pip install --upgrade dist/*.whl"
|
||||
py.test tests
|
||||
|
||||
Reference in New Issue
Block a user