pax_global_header00006660000000000000000000000064142125004230014503gustar00rootroot0000000000000052 comment=ffc5866fd1cdb1ee6674782bcf5d1a8e414fa269 rabbit-air-python-rabbitair-ffc5866/000077500000000000000000000000001421250042300174245ustar00rootroot00000000000000rabbit-air-python-rabbitair-ffc5866/.gitignore000066400000000000000000000000561421250042300214150ustar00rootroot00000000000000__pycache__/ *.egg-info/ dist/ .coverage .tox rabbit-air-python-rabbitair-ffc5866/LICENSE000066400000000000000000000261351421250042300204400ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. rabbit-air-python-rabbitair-ffc5866/README.md000066400000000000000000000044161421250042300207100ustar00rootroot00000000000000Rabbit Air Python library ========================= This library can be used to control Rabbit Air air purifiers over a local network. # Usage ```python #! /usr/bin/env python3 import asyncio from rabbitair import Mode, Speed, UdpClient async def main(): with UdpClient("ip", "token") as client: # Getting the current state of the air purifier state = await client.get_state() print(state) # Controlling the air purifier print("Power Off") await client.set_state(power=False) await asyncio.sleep(3) print("Power On") await client.set_state(power=True) await asyncio.sleep(3) print("Set Speed to High") await client.set_state(speed=Speed.High) await asyncio.sleep(3) print("Set Mode to Auto") await client.set_state(mode=Mode.Auto) asyncio.run(main()) ``` # Retrieving the Access Token To establish a connection, you need to know the address and access token of the device. 1. Open the Rabbit Air mobile app. You will see a list of devices connected to your account. 2. Tap the list item and the device control page will open. 3. On the device page tap the `Edit` button. You will see a page with the device location and name settings. 4. On this page, quickly tap on "Serial Number" several times until you see two more lines that were previously hidden. The first is the device ID, and the second is the access token. Note that the device ID is used as an mDNS name of the device. So you can specify it as the `host` value by adding the suffix ".local" at the end. For example, you got: ![Screenshot: Access token on the "Edit device" screen](https://raw.githubusercontent.com/rabbit-air/python-rabbitair/master/images/access_token.png) Then you can use `abcdef1234_123456789012345678.local` as the `host` and `0123456789ABCDEF0123456789ABCDEF` as the `token`. In some cases the access token may not be available right away, then you will see a "Tap for setup user key" message instead. To generate the access token, tap on this message and follow the instructions. If the app says "your device is not supported", it probably means that you are trying to connect to a first-generation MinusA2 model (an older hardware revision). It is not yet supported. rabbit-air-python-rabbitair-ffc5866/images/000077500000000000000000000000001421250042300206715ustar00rootroot00000000000000rabbit-air-python-rabbitair-ffc5866/images/access_token.png000066400000000000000000001526221421250042300240500ustar00rootroot00000000000000PNG  IHDR7gAMA asRGB, cHRMz&u0`:pQ<IDATx]xTE.**[D"H *4Q(MAQP)"HMHHB: 鄄l6dlzOvzln}xݙ{gΜ3[i4Fh4FQh4EFhF(*4Fh4m9 =vD1Q1fwgY)*4v]jأ_5)gU%ܒs𴂣ڹ^*ݤ tH崦ʛ x0߁Bh׏ѕ_1Ǫ dJZ(cr., \'2dAң7zS= :rU`,gJb}JmMH_⧖ŒuQJh@B4Sjb˺IJ+zÌ ..-/-dzV~Wp}WxVuEz.(SWV4ٜ*jcj>2(!|rU"3vSFݜ6Qᚐ[Rf9_S]EdR$VWTfWKh8Z- 4q]`f^t(HQ@WRp;ECh7-;.FEJ/X9*Q%iIEd5EnGB.]\@ LpQ"s*W.*CI4Yhgת*5U\1>NBlFQmʇiG1InyЊu*V8h/kK僃^tOkR.*ȉ2$^]wq>h4ᴉ. YŖ+ DbWDvPo(JyXpYjYTB@+2LPANƕcI De",.9V YM[iFh7ReZ(eW% tHQ<<[?P'Dܒ Wk wRLL`~H*2E@T>sd-,+4_R.0aN bXih}:'*Eᴦ2.񹅥VDWe@jsyPx-?Uy?RJh@KN_)r1\Qꮨ ~)KcV2'#?5_RG=, 9?F{>2Y.([qMRGE[\Vu.Q$Jˮ$%"cȀkvi_dGŀX'h4k#J7i]EQcwTNqiyG'e24,k?kek-N;Y!#v~HQh4MsOWhKܫxGeOH7OE29ij])CgsiⴸsF~t!F/=$H.Icg+E ZCVP\" >&*,ؠ_eGD64csHpg's'%i{\YpM̍T q I?qVLT}DC#/dJ*1aCT(ڀ( :Plk3 Jʽəl]&M[U/5cgPvcڐ#𴂊ȬQΜ_ݬRI 4T}(4T6Xm^q+8Y5#91m8Ojd9#̢Ҳ+cÝ?t:i+m]Pfin(*BQd/]LEm%l/^9,n0>N]4ǻ&NǂG5re@*@##ɸʂr vnQAɗ)*d7f~tG_=)rѲ9~i_W9i#݅~)_{g\;Yz Rym[O>.*()/-+/)+"t #9W/CB^u2̅']J֕{7gDs|n<Em|8D^y>h_W\(@i`aG=SU ÿ\pk/(rK>Tm ɂy!m r4K:љW࿜QG|IFUZ*F2TU~W'󒳊APڒuT2,&rfDZA\\;Tk/#@i?CaP,AɞtpOMKɇ}qLu>o>ȗcV?u%l6]35Y,tTj>uf?n>?³\'sD?w6GtMF)jaPKǺ('*GUxd@|@6<r: HEhSfkŌȧn`BE`p icp( 6 ?R1⨀ [hNzb5Q>SWh0GV.c˺3mu4'n[óⴛBxgLH Qл&i'Î(&(7f^ԩJR/hLuOXh'%~?#B\ᾈ߅a˧n. W<6%͸lLvOH+.~K# \㴫+& O#3꧒R oC07% 1V.,Õup2F9+F(g{g;;"{[:j~fo>I^73Lh2\53R+*s|M**T 7+PWh0֐,,zrfMtU0ttQwY[2ҹbA̢!6]G>w"cE@Xe?T(ǹ*E:².QQ9y,BԢ"t%BvC\#qڱG+f<KlFKBuJ2qMunLb&v؀F]][ZVѕ 4/̎(J/E_-:Ua_S}>IJZ7ɭbdc]_wV5j̸~FQ1+ՕaZsJ,;ҥ39ťlek J <(\_XR>PW\y%1=eԸU`ѧzd5QTYŹ~ur] @]}չEe`!YuA{rP( hs#[IJ@E%B>B~}„8i!Q_Y03t0̺V/SQ8i 6IQᙔ[DB?-Jiٕ}gs Ґ# ǥW~ϲ|k3Wr/Pf QTL8mПM&GW)eb`>*糊2N{ Y XRQS=5sG^(C򔹐NG;wY[rY|f%QTLϧTZ1.C#1\qc~WŸ=ޥ✮a(R.TWi+׹ i1UžjIT8+pӗt3!u8*%:c *:s;_@(*4敘[VUNX8Q8Vq{b.<|BdIj~eUx^z?# &%)&GE=\d2e .^Ыky[ï(*4Uv2bj̩ Wu99|6G,OYmJT`[sxL䓔k LL?&r;_3*-=+4 fɱŇrWMtU+#+.gn MʭLT!SeS3SAMʤn%)ӌclx$8EFξU'gW%*(7h )y ӋJf??3=%b0K%DeJ4= QXR@T̏~ F ?l{xv?+<]qWUEF}tD;:__7)*Sʯ\m р/e Я=4bWI@j~e]+9Q)t!?pYQ҈cn\ZW:y_"q+xX Bpe_C6kECmʰhU႓j\En&Z^݉ 5P~F=.hgD،)uR!~JPA F\ݧy./N]4\fsQ]8>|>drE@fBfAOrSF( N 伏*Wcg Bն}aG;kwÈ4r8Lxĝ>n2JL$SO4EFhF(*4Fh4Bh4EFhFQTh4FQh4Bh4 FѮkQh4&VFh4X+Aa#PT  AAQ! (*AAQ! (*AE AE A A BAPT BAPT  AAPT  AAQ! (*AAQ! (*AE  (*AE A A BA BAPTnٳQQQ3AQ_;JJJXtu x){n֬YϞ=#)))IIIΝ{w{\#FM]kܵkzu4|||ZnݲeKGGGr#!!瞫wx,=Rx瓓/]d9^R3vS֭['ٳPTn Iݻwk׮#F%q ,cCPT ҹs-ZL:rGW^y%888@xxoѽ{w___z*F#Z ^n ,7় HĉoQYf #>}d&CQ"ɓ'Y uBPTDEx3bXe]RTL(H4M\\ٳg/]!x… YYYC섄ܹsQY*#HNNFj奚/b%&&і$^$E,dd)YBVx**66 _E#S-Z@Ə_PP`zX/" 111JҒ;KeCQ,2,Y/_6Y3EeFiQ71_III(ʪAQ뢢Ǝo&{nx뭷Q~g\裏 % ,xW7oSOjj'N0jC ygСܹsϜ9socǎ=wF6'$_~M4{ e^꧟~*B6jq^nY+`?CƍnH-zk݀t|ɵkׂpPJ cVR;v02@~'Gk˩ي.^B;G|P,>|8dRDM.E#(*uZT $} eOQ ITp_\\PPiӦWr@Dp{a;gфnKK&AСJ<?d[GHpU/͛7#yMហ3y \իxMN+p N蠒*ٺ ^wl:_b:ȐТ'N?#hʕ.<JgI+ !?ڻw<%'{! ³6l`miii_J!^AbƍC7LT7l^$*h'N01`{rT5rHehHWʩ`g"SC?,_ Ș1c!;rEY˗/hK.*`qTFV`\SL% /mEؖ GG4J ": s hСr?Cb`j:QI=l\yb< LTD$[/e˖-q}IZ/!*b9sƤJ3- }u=\ywg>_ۋ^jep"ST"##-/Id_8 萐,$ P1t^ O>oHn+=hF۶mfEi,Y}ߕJ=DIݵkh ;|ɔ#˖-C "^^^&ӽ{wΝ*\gA݅Z}QI=4و3{V\8e0777X,IT $={V1Ǐ/X )&$"""fݺu]~4G}!yhDbjjAc69p"X[pS&M … Źs$5[WzVXQYyg!ϋ E'Mw8Ɂ)V_^$?S{A+sXE+[<&K#GSE~^{ P'*`L4{>h_}+iiiZ.o$*[dȑ<}13||p5-硡y7o/ҥK5< &8(O=TttyjI6\իr0ICdb)oÞ{5/ ~@A9ӣyb&&bzHQ\ߢ(J1F(pT*2{l9X"*4 q,3sX~n&z( G,KT[h1a„Ǐ%M6[c(=L)[Tݻw7C‹Vlҥ裏|ATުU+(SG=HD~BQEE^ /? 4]RT@虊fITG ֦M/OOOAʼCl"*«C/800pÆ {嗅ҰaéS]:?|~on~˺Ë[XVԤ"R,JNNNK•Ν;_@V%Cj/C E)zMEXׯhF6mp2r{vefa̙Ky"k) JOws-relEፋm{쩭/D۷D2@xx?`Zb4,[~|vx;دRT(*7dggñ#`ݺuߋ-2 \HG_iӦȲՐA*c=tN{0۷#*BBc lP(ĜR%v&V W9W%5[WzDŬY*BŠ>j4}^zqEIG2? a=*DTW)*ATDZl%u'IT.Ab^xQ,vzÍiLuVKvܿ?p뭷B1cƈ#O:%Ou#;Tb\&bqƒ'߲e ( Do8rb celE! RjCp\M̅g]qEEVbRhT&LOH{3]:Kc"Ν&/\ R޽{wEi_Ltג*E~BQADUY)`r4={shP08r G 6F(LT.[ b֢E i)6L:ǿ[bI58:QAÞ5k]zieEo\dw|rXx#=zQu^ @gq>9޴2bEv@fZ"*"zHq< B, j/|x9b )^Vݹsg&Mɓ'KpgAxK.EE LZw^y7|PQoHE~BQADM % (-V1)*?>}Ѐׯ_?sVZ0h/_6n*?X.մiSի7o3Hrqc_ 3)S]w/B 8`1vX*4fҤIzB}F!mbNE~4$Mx_~%G cb{C E+S%IM ;Sc Tẗ#+n`?~xec #^18U/Dn?D?I=x`c7yX"*J{dd1=$i-pE B Eo]3-[޺|Ȥԥ!0i=zҥKV ?ЍO mr2( Rif?>W_ 9 ܱcJ %FO-fH!5ɳVD]ہX,X(3fݲeK0宮H0)G%33gN2esΈVǃ^.^@ϒ8D_xM'N@P )a( юϩG=D,0yJ1EF@@?,mFTNDFK("V5?*O@ |U|S<6o],޵&1bԚL5V^zZ$ϺTu1j%3BAQ! {vn3CBE0bZ}cǎuxE  Q5 '={|o;99AQ!*ťKqIwuWvo~X ϶iF: BT@ӝQz 8#2@֯_tС}oyάB5>q]wy{{djJ_~Yb9 .tvv6>eWpuN<u>,>>K/],Z[o.Y䚾Ap]@P-+S ,,L|:t(?>JBQ!" ?qW@@\׵_ov7ѭ[7Խ6m$''*))9s$UL _pAѰPTԖ-[6_9kqqq9rHff QQQ 5LhƫjٳgO&(*DEttSO=ֻ`)uyoKJJc_ӧO7*AQ!Μ9O.ZQ_W{buKz W3\, Zeu16%VaaygP-Νku.k5SQ ʍ'Oz{{o=Zѣ;歇zM4Z}ĉM6㏀Vk233³RRR \RRLRaa bVV0Ν)3xnqq1.]oYb3›dLHHxVvvI'FDDHt:իQ ~e]Ѡm۶xb֭[_\4… "X x:J G-[<L$o"9U"=+۷oGĨT-E`3~J5aɒ%چ 222oEE%(*iԩwqK+УAhf| ѣX$&hLi>쳸 СC[oQ{ʈ`ҨQ# [6X7oO?-_~k֬1f̘[h"))Y`W_}z-iI|x!xcয়~o#ۻwop;=<<`]2}e˖5o\^b ̯b=|r^?{* [nйs碢"j٤Ih()S /\gST(8gϞƅlEE%(* e9CG+F S!mlڴ)T4{l?4i7DDC}'ЇI"@Gj#\%1y۵kW+sy&]N:;n8j,`2Q]~Q\D,j 1ݹsgƍ,6qDD mŧz͠+ n *V$C'cw}w۶mQP(>!H44D_`"D":ūׇJطo_)~BD3f,)L-A_aĈs^^#<~zϞ=kRTn޼̙3*:$T*!___!c+VRj'L*^*/v@ mzmӁЯG P8RE!o5/*ך IT ‡CxZ@N%)jJeuXTj"5gZ|8(1nPE]|Ct !yúJPTJa~+x!!!Z,1o5E:`[QDU"<^l6m`̘1ReE2A  8f1yh^z!"8]>Rnܸ1믿Jc_qqqp#TsХKر4SMQi֬ʭ_~P>15l\k2$QAD1?aRŴWMQVT-&L0E$4huE%(*5 aS֭˫q[VUƦM 4.nݺq}1}&%x駥Je\=rx'}zĢ#''kkJ%r_|a>C Q_|lt>PT(*DM ڼK+>M Əo<`E[k′}'N<==oCXVX!-i2VD6 :f8[BJ^^^(RƯ QG+]we0GQ5'*hh{fN=zMD;h 1L!{PG@(fDeb4I^jQ5BjM6~stt<Ǐ?XG<饣?q} J3f PTZTxǎ+41uT Hj޽u;5X LE0XQָqZێ;y ,xXR,MAyyyB.*YYY"]׋"=4YBQ5-*hƍ3ü~~~bU5k$7m511Q ͝;?C ),, |ɝsEA-ZհHK׺uVQgfر霊V E9QAܾ};H>z]u/lT5*C̩m^Gb?i(:cѹ_$$Il A"T{ 4C3f\$]Q[=ƍ%jF`DE<פ߿E%99{=)*EE،ξI־xߡC jU𔋋 y eiŎ1K6lYuV~ǎKu։NL~C2`QF2KZJG͈#Ȧ|ˡD9r$JX,}?PއG, 5~L p)qJٳ`p_~R9::j&*WX7M''''%%|'ބ  ~[G [988{"G2uv!<:55ھ}r {/9"߿wޑNɵDM=۶mk.ӧA(:$^l۶MNnѣGP\XݸqcS?Sk@'O\p?.fk]Tڵktj,=܃޲e EBԚ\bcc+Y=#9]Zt'MdU___qJeoH6 BL 49c\bp) Aŀk4ӧ @8$R5k4j?}IT3g"_"0!U?}H6OZޱcYf!#.*YzW*ȗ^z 5VTWBQ!l^zuyA޽A`&M֜qqqonxb*1a>oWN+@]{1GI¸q! <տGGGdjذaH?-EرgϞ`1< :F:-- ܧOOx Y"!kkݺ5R0UٞL02ܻzJ*a[bb':E DL\Ă᧷z/BEe@֐TlBcJPTQ2???!!!$$$44-;gŒ*YJ222288]xqyȔ0,2/>XJCGD }0w28a Q(H mn&jrGAQ!(*Q233_uȐ!w-2k,Y AQ! }CtPlllii)_>ϟ< AD-$ *!Jv,0nl$uAȠe'*Hk A Z9ۯo/Q|2E 2}= 4Knr$S- ~bQQ*PK.A5A CT]]] @Πh=^T bbb &@ j{o{Qp¡C4 _Q[#oIII'P"Rپ}HbuԩqOѵk׸8)Qf|D X= %@l~M6/b&M r=z4*jrrr^^^bb/;MA}:nsKD,q@6m,*:.չsDQQҥK6lpBZx񢓓kz}%TxGrȑ xzzJ^q:+U) jOT*N+2>P|~7o|w۶mM6 S/ҕPi2YR=*SqC3Jnt ҮӢ!Rir 섒iӦ=s?-XYfp_D??~߿dɒ?عsرc+QQQbYݻ{ '9sϟaֈX.\سgϬY o~x87QΊn ]봨/1h40֨cǎկ_҇/2rW_}ow1l0D <֭[;88l^:u1c}~%''KХKvM2eԨQ<̈# _QΊn lE%11=8K¶k޼7|craHHHƍ׭[WGQQі-[6m%zʦM+"jժդI :}4|gBRRR:uJMM|իWs%$QcΊnA :-* H%[QC~g/**Yf/m۶ 1KWߵk&R+*8=!`)tO>СC5X: iiQIJJB*y@ Q|r}觞z:{`qFF$ˬ_~˗/g_FoKvuVMt ҮӢr! 7ׂ Lz*M4_D{BTt:]ǎ?uE;S]vC5jwDԀrMn ]%6d &V۶m;x`.W_EeȐ! 't Ί\rEz5iQo5jaO>EXp^֭ !0wڵk^^_Q+ʵ)W6.t yD1Q8~C=tRrv&gϞ}F-MX7QyfnN<^o,_ [oպuHyVQ۷o?|ϣ̙3^}\ԩSϞ=!<q}! T0VkW_Wv: ++kٲe͛7oѢEΝ-~퀀yNT;tЪUg|#AʃAvڥ^Ϯb4Aa?gt999sf  Z9oDpA@AX2hlzK}II+7A:@ dв]?yUϮyɁvA"@ bֳwNRSSmEFFrr V A~E<>>AԼ~A Qf#A@G L^d / j [PQjl^enWjjkZZ ׏͂lA ޚ<ܤ^ g5''s\ l}`A ۚ?^繤$!!) @AT;#@ZîW+:tŋT k'(D :ulmիŲ@<<>>111(LT\\\JAb=$A L& "ڝVWŤVQ'Oa I0A OP=u܏ggg$%%%A2A IP%-Q! kPT  AAQ! (*AAQ! (*AE AE A A BAPT BAPT  AAPT  AAQ! (*AAQ! (*AE  (*AE A A BA BAPT  AAPT  AAQ!  AAQ! (*AE  (*AE AE A BA BAPT  A BA BAPT  awEGGGEE%''@P\\P(Ξ=VKJJ* xW5k6R`]@YYYJJJRRAPTjSN:t/O?͛7o۶ NJygի7o>>[nٲ#GN644o߾wy'n{ᇛ4iҰa[nW)S\tR_u1{l**Y=R}۠W^y4t1WN:{޽{Cf.cǎ7CQ7wKOF:_Eh,J… ^um۶ 9oR9c ߎ^\<(*uKEEe͚5hw<ӧY :(ҥKoV"+W,..6,55]v/ҹs(*AQ&/vv1--L%ްanٿ6x6gϞh4U* nUTTtEDbGD¸8BtĒX.::ʺVC!ؕPJ0|y, III2dXQG:Ni] 2CQ[#bE uCf%11QV TīG. ""<;ص:WZ%D_n7}+I9rv^2 |r}E= O?mݺO?Ͼ꫋-2to߾?V;zh777X ?F͛#߽{\ GG!C SO=|uaܹgΜ1_brF|~?8@NN`رR"RnڳgHF˖-ׇb7ϛzx{{?+33sǎ}o޽oߞ}Bqx9nK,yPtx">ػw/Š F<f'O!QQQBΝEQ:uDFy;#?@45<]>s /篿BGuBΜ9i+CD[ox틠X?5gTx|`ѡC8$$Qlܸ'D=c7;p[n֭c7DqذabߏwuHW_}&5".\ذaC֣A/ҌFnq,d{EF!M4AK Ζ (3@bt @ 6-Zxxx@ZNdiӦ""  Z;vLك<qīqwCqt5/=x ML=(s20T^T˗oٲYfV3$`׮]Hx"nd7a>|A_Yfѣwy|P=DAAyuBv _TfqE>PEPT(w5ƞqkGCSA7 ~[ouSN5B/~BiӦO "tqq4hmz|=>"8֯_(s3~`𓭪ӕ&n&E@n߾=&_&u[;b?@Ư> eu}j4\/2tEvVGD7S0 ߇N"H]4 / ȑ#k]*{*[n5C3IQAѷmРEEE&L ˗/G2 '|1U%YW1G-[?BRY\9$Q3fLAA QAqOhֳgOڶm[D Sbu3!(*ՅDp䭛Z;yc>|X {yyIwW&W-[&ĉՌ((qƸ 4'!u/:u]&) 8`F@Պј7xCN&E%==]p(x9++MD\@&|#oLկfWL.BRY$*B#`\ƍ'&i]myQN3!(*uHT*ۧ*Z Nju&MEMNKc5bFwiuD4 <7nܘj~aĤO?tTTT)DDDYn]f͚! &yY^vڵJQd[l+*aaaM4Ņ B3KΝ+f:l^͌@,ƅeOE5Eprr#{ NE:̈́TRW}fkGիhf- /+c;VGrԩmۊeH/ĉcܨKkF) :~S uAT@"bю;*PTB# g\z(^ gsQAĿO?ѣ*O;=PEEm[̋Ju AQ.u놊ժU˗/׌࡝:uݻW~=zVG!7|_u=aÆdb̘1| L\T4TM­ǀ7~뭷BS{9hIG=Bj@Tl[̋J5 AQP.VG|j`uJ.]$|-lb]D,#3믃>jTp Vʂ,MXuԌ͗4Jdɒ;;v,~ˢhtzMs*5#*6NUz*o&EJHU-ȑ#o@ӧO=>S;-S۷o!r:o!c]D@%`k AF[ob6mT*UYTyE]SN=Iβаݻw,gL_Qī ((#E*UAQP2* \υ:cҴMi6i6锶I6sy5e}NN$mߓ'OrkZާs~zwv?6S bs:=`u!S Vdzk >>g7|vv{/o+=aݬZO3@xx=@vW^yeVVV+u]ܼfSq?~gE3~ƍb#9eEv'Ol96t!S (B7lnMMM P_ҹ2)) .wyQȥˤT7o3*g1rHn_S\nҤIɎN1o Hޫ}.X3Ϛ5ˊY% S ns0a=՟k_BFM>=rѢEn"uTQh)))_לQHj$Z_v-'hGQ梢^z ;yekXXqpU\\ob_)/6Q={e/*7oYZHMl+n@q{N{Ѿ[%%%jwÐckf\2k*]>{T]v6~wl=DRS11{ H~S E޲C!АhT$.Tףq9'|"BÛo^y3JL%9~_3 t2N6AkBK&ؕ"""%Ϋ+V;w }S( R6dqȐ!~a`=y{$EXNTn.3bfPdɒo~&_4mߧO9.VaA.77#w:̈Oh7"}vR3uH4tDU>$c„ YN/!S CDD4>,Z`ek׮m i]j,oRXX" :+W+**kM䨢~'^hQNNN+>>CȋL-[v}69zC1(}q!F-\IٰaR~ 2%~C {8p 22qUG DGG "wW 83g>_$|=̐ir4i|y睗&"]b믿sV~GVľR^^"S9{i9$7xo%r}|/oGD V ;gS?rIz+wu?|W23iF"<<CI`*>11qȑ b;rjMMM?k333x;¿կP#盀^ݾ`nIٳGߠ%S9]dͦBN[׭[wZ/c=߉+B$[n?~VVYywƉs֌Q=\?d*}ßn[?#L%m۶s\o&!ji}Jz-ZW\ T vT:V &ڎJ{PfJα 赦T]NQ]\޹btu2#߿Mv%ܩuBL8F~~^ZZ0b\dȑ%%%M+++Əꫯ2d;Ğ7ot.[l #ƌ&r5k+׿R}cbgᾹ{jt0'SL'?ɱ=m7mjjrJwngܹ)Ɉuuu )//'N3fdffM dymݺNa8AÊ _&;i|3Wjnt1576tPJk|aUUU˪UÝ&jG544;uRRչocOh]ii\rԩSmb Ku(t4{.nd@ H_|=S@O~/9>^{Qo!Xp᷾-u _l۶:69}2||O>9/"N'> ˋ)K/^z)^fee]}} J?>hss9bsw4$pS~ƍ[o+U .}F^ s뮻nϞ=l6ju58OXTTٟ;owoA{[z1-2w\w^xs_ﻸo~n~_䢿a66ژLOpW^y%`׮s(@!LL=!4P67iӟ4OAk*4p\#u$:𢺺w~s!֨C=ߏ<bţ;oۯ~ hʣmŹMsjHO?n?OͷnFjڵCXoWd ;ho(s![]{)aXXeCe"###4 >OYm"7rT 2V- [9_ |T2ׅKFm lN99#==z /8 rhj0ut]g4jkJ3Ų`Μ9겠M4LŖ>S!W\jяNg!4vM-,AiȑvO%33ӊ<~OvM:# f7|g`M}U#g϶G3y=$Bw}>g,Al.::\h*({\Ca8bCq^Gkvׇh=F6ˎ1я~TבFݲIuxh*)'NӷQ'RtS6md-͹LJQ{23Yhl8pg]}M旿%'/%[;A3y\r [E7oAtEyf؄p.aMv܉J3@dΆ_;kAk<*g>{Jv|zb`"lg?K7WNi*[ Tn]vy,6<Xol՞tWH!S1S!H$>1''(: /k*N ?j(_L>05kx:å^Jt- :"S^W^yŽ{W,܅[M%K 1117xzxԺh*C`P6SfwvS1ڡڽ{97:ES6Nk9Sb?jBcB(}ux6bgI>*^ v ""™ n6v뮻܅ '}qπ!Is„ 6?ClC==Q/1Epd7A+6|'h*/r{Fd`MtJ{c=.JeA4^:>J5F 6%jUOB}衇X`)M Ni*-S!'rE]|8yd>ꪫ-q7:t>|?c2[,^{)vO GsMgs~o}[=k*\/V:/m5#!SISA};<>T6kO|²{{͛NYtg? |w}j+V nf<+n=۷K_Or M4sssq4w{Tlhw3TBԏL'MNhJAtZ&zo怼⋽TẀځ۷'3ft|)Yfٳk_{D΢S|W=c=e*$vZJIQXo tVpݤpfJ(ڳs9-SAm[SSlY/B,^ x9}$w'ra?y%[vy5װ3KL%DHTzT^t۬yT֮]Zd/#l{+/h*566 ]wݮ]N{n!CfʹT_#/((Gk< \ʡCT(5 1_.m K1LZh3cƌw}u JO dff O~=&L 56=#Gznmoo[aaaM/goMx?x'ыr2Fq*x%}6 vG>{y9眃M6-@MMM*R/8&X_ +S$׿)疼Й y wuB+U|U?V5P#!SaSGFQ;:XTTԦM+W?A_BI֮:>, r85*Ӗ2c/ %Tq!_|1y1Ex>Z`*d裏N:{hn<= {Ω1as=n*AGBM 4VS"TZ?;4iRYYtvGMf3^qSLoZR:;w;r7ky}U!'LJ\JtKجY1{W_}Ȑ![p|֭c 1ʱS0 L 3[nt: 1RqDWBTD!_.3 AjU"d* Pl:ee]ىDe_;wD8k0aƒ>x~xN,:ٳg7t9ze*B"_Wwo`/0>τId*B"%%%Qn{wĉ2+HewqSOZJ\ 6dȐ}2{Z[[4innnll~2iC"d*B!d*B!d*B!LE!LE!LE!!!!!2!2!2!W}{ B)$R)S d$%%%w^~}rrrlllttb!.FD*Ld=ӯǽԢE윜B!\ #"T"&≄"iKm۶P)III;v(..RlX;GvBv6l"H(B"HkgϘJcc#gFVVVyyN!: ",2'L3?x`\\6Fp=Bq`UEf׻T9ÇN"ւ"-𞝦,Z(''ȑ#B:YEx߳Tjjjcbb4@B /=$$$UUU2 !Dw"pJM!)))66V"=+0R ٦rQ{BU!z D؞ zʱcv(Bу H1,TCh*QQQ7nԃB 28yBa O!DG@e9t~LСCt,#Hd*x`rzQ!zȲs+!1b~B@h0cǎY&..EO!z3PbXM1**jǎrB;AP?rE>|XMtA("9rdϞ=)))6mw1 P#׽TH6l``["j׮]+W,))Z\\/| o4E,z $FT( ӟ>򑏄6?ĉl/}3fw"Q%K\|/Қ5klْxb^5 C}隉 cǎk /+V8u7o꫟{9@{عsիy„ ]{OZ[[x≛nɽ$RYj:ϧTJKK)eV Ԕ)SLW]uպu|c.3(///~q7:3W<9]$~777?w~K-?A$B\#ڽT (euu.4>,a׿{qƜ9srrrl% KMM}'O;hTPs~X uK<`Lu9Eb3f`T^{-plaa!DFFΟ?]Fv:t??~x leSN={m.,զb㩦&]0,OӉ WoIp…gl7'? 7gƍj+ ԡ[dS,^ط 9hK/7M^/_l{Tw]wF)jT^uUqS]$`qq $Sa&W\qcvfkyy9r-X7Sa5bĈ\U&޹yl*w֚D8pM7 4bJ?`*olllf {),,:u*C'x^vT|szg\py] 'e3j"(ЧkDW޽{)VߋFFF^ve : _ؾ}{S'?T> #p/>y'':h*ٝ^xͫzzӍ7ȉo^"XkY6~}222I&97;m*cƌ{G۰a{eʲew~Y{>(;TDAiA Jaa!7ҋi&Go>7|9}X2h 4NuĜwyrVZEF2tjZ/ݫM~5j_~Qf+#]4˿կ>䓡6+WҥKۻ$SAǞq^j*hXto衇޽{_|6tR__|w?gp%Tn?uuuN"$^TTPP#AwѝRSSCȢ| . ֧~oЙ `OojcӦM/\Sƍ|lJI{YbK"5m v3p ~=Ȣ,Xy($[nerW|߿뮻OOԧ>\SСC?vm> vr߾EB"}ro*Y"H}}}rr2T{ool@il kE,{,))7 Z쐛;#F̜93++c1;ov"YTDo=Hs!)S`TA^Zs"(  0T 11z8;?. ]^>"tкh: ER鵒B;WIb+TR__e[!z۰qFCzZ)B^Ҡ_]WWtkjX!z 28:3Lܽ{wddd s 2{BW֔YB#rHvsf'Ab9rLmeggB/"#ȡ+rر]vq2[n!D; #G9;vxE`Bm 6FA'='x%==]@l\tDQn;=]K.+,,TB.Gf[$ΗSNII9ׯ__[[!DpwEfc?ݻwY!9Wd!l#'/Z(##Q"%'"#=5ԯs߹sgbb"ucjM---GB@GDD0M EHS6m~ir#--.b!>8"`"'o:SM=Ä ݻ[! yD$J5]"F"B"B"B"BTBTBTBTBBBB!SB!SB!SB!SB!d*B!d*B!d*B!LE!LE!LE!LE!!!!2!2!2!2!B"B"B"BTBTBTBTBBBB!SB!SB!SB!SB!d*B!d*B!d*B!LE!LE!LE!LE!!!! BTBTBTBBBBB!SB!SB!SB!d*B!d*B!d*B!d*B!LE!LE!LE!!!!!2!2!2!B"B"B"B"BTBTBTBBBBB!SB!SB!SB!d*B!d*B!d*B!d*B!LE!LE!LE!!!!!2!2!2!2!B"B"B"BTBTBTBTBBBB!SB!SB!SB!SB!d*B!d*B!d*B!LE!LE!LE!LE!!!!2!2!2!2!B"B"B"BTBTBTBTBBBB!SB!SB!SB!SB!d*B!d*B!d*B!LE!LE!LE!LE!!!!!2!2!2!B"B"B"B"BTBTBTBBBBB!SB!SB!SB!d*B!d*B!d*رcG8zhד`"999EEEرG?@e.4KMb7p%}e8(.\8m4*b͂==ƇpdffE>b ~[zs84͝;9mSx +|dd޽{=N:k׮pd||SQ"*ĤedkXX;SQQaׅ=z4jڇR ͐𹹹{…,D_42'tZ;} N'S<*1HgZ~m'Xa,X GqE5g ̴(#z?!%KPfn '$4PQFrbBlg+As( pCd5}8qSH R}׍(grr ƍ7{lwárId*g{4&(;t萧{8 "/3z#VaQs~cB{nB^[Dy%%%P sjcƌe?~|TT:ܹ>^Ǔ&ƖP '5ã[TvcXnyWNTzlP3g$v߲ǏUF.9%wtqtް6[|Hjq`jdAPس3eu:ONdC\zT=Crrrx ʙ=1cb+:MѬ Kf*n}oO~+r :b*V z&5ۙ7nxߍz \ʃ[p^ bU(؞8P=2 .K'ȔP3I¹)2Z =8Es)<g1KwVq|)Q6nرi&FFքs m fgfDS9NT0B7bccm 0RB )t9BB:A 286m⎗tP0jn"+k}lTXrLd333%S&N0y䈈&y~QE1eDܫ-.cϙ3gN4J[!vFl]x1#,Zhx\e]_XNTz/t'*++!{̃nTE9 vQ9"F0KdF*}vKSlOmB=Iʦt58'0&Na71ĉ4M=X[sˇuSLQ mIY`UۃDƍKMMujO LT !r*@;̙.-T22:eI6::,T շx Jp"2:nQt>@ii)b>1h4XLzZbS&M"ltL\']= , SA2JJJlcڵvK̃֬Y&CW^յ7R )A#P+9X޽PՔnnSsc 9Sܩj,}M`QWW'Yh ϰg4 8w+0s)ݏ7_JBLvYpc=b}LWπCB'7;Xf$t/)`*3g„ 8511رcԩS`);OQ'M8ĉ)$,)JSaEDOڴf&)Jz V6b'8w\5r^Irrr"{p=Wz *;{p՞d$#Z;gGlIsIs-ȝJzs߬:!S9|bbbVZp{8׭[KCC@؞ q@ʢtڒbaON!D=y`mP6wiy[`b|B,lŮp#ݴi(Nv{a:bQFygS>bV1;MH?@CU3$\ ݕf/kQ>2L{ܓ\,K.Sw@>O}._H23]x9Շ( zU3 t9NTBTBTBTBBBB!SB!SB!SB!SB!d*B!d*B!d*B!LE!LE!LEtcǎڵ+XߡT\\c_藖ݻwgffnڴ+}l{_će<}yT{19_؝(%&@1<_xݟ{U'd*}:CDD;OKmӇ[}!%K`Μ9N(Ummmvv}oӧ[CyyyJJ6<_СC˖-#kzwd8YpiӦ͟?͸i{uBŹybN'v,QΊ+bիW,#sRx>f<ɱS{;]g̘_TT[}L/ʼyF,SIKK2dHEEEGGe 577[,#F0`ZC2ZhF#gӨQ/_Sldt<q He,,,V9#'8%Lސ ȕGRCMXWK sa+bJAشiaޗNTr``NA'TٚS[[ݻ[Iv`MM krp$22-..f`?[o#@f+Gq.N]۞tȗ,:b*ÉsvE(#%GhJn6É!ʈ{r!5C(#i*Ѐa :bÆ kĿ$..Ol^BR. =JL;SRv6ЗNTZhK,bDH f*t'nj9{α(40@H89sLTVV" 5ǒB &g94w.ZxF*X%1! n:wh)Z?DY IpUFTO(N8o F;g(3cPA<ʈSWx!Wb۠ٺk.߷o%6mIܱj>ypY2J|q)Pd:7nmJЗN#S9k>6x;2`h Fɓ'[ǠoO2lf?ӝ;wrYǫNTZSA^^ "R;քr6c$HۣagرRRc*Ts|ؿ[n i;n*yyyatTT g Ř:u*J n*PoDG-, Ө(*a<>-Xp>Ǡ3<<ܽ9 QB||3!/1Đ5BRXU'd*g3􄢢"0 ?sSܨ?p|$nFHt`Ź=nF}ȝ]1>CHrQ@bj P+V`lIOm pKxL;:nSr\249Ϛ5dD!=7B1{F&_Ǝ| pvH0C[NcTξ2,%b`@̝;`*I反#N k*kL@5-h*+ @loʔ)Iȿe#;epfA}':g*$BaoM"xl'##'hoHHHUUU{1B~~gx;L&MrvL.,^&#,Zhxh(MT4 ;L,Ĺyx![1C1"'hgV{MŦHd13>n8\S*#I&Ut{:ѣڃqlESaf){p8?c&Rߘ11arSO˹ne vd{M29sgqrհas*͞_~gA=pQ\uBrAy뭷Ba3<=67߷oW"L7uTO:a A%x:vTI,X@g{5jk brGͭ3G="hKl)0!&M"lD6 'HQrqPyaTT13'z&ܷ!YY sA'Gǝ,Y#=Bև>٥K:5i#aS@&8pÆ RN{>QcRSSS{.4 [tE_5AlOHgɒ%V-rGmIܙqz}ᠷ{Ԅ &uyˢEg]؟BI0`.!+W8SHG 9_2u@9%=gDPGk|0uNK72yĶ/Ν;=X~s&jjB4SywA -6S/ӑXO&#;̙3 qu>jB6p@>|8}َX׍3fXfvwYRRBPP bj*k׮" LT>95 4ΝOk}kR  l1{NW)ܝxߙDZ]87ff/JРѝ,XnU}ќU:!޻}L:.G/BJ|-Y)}LЂu1̷;A$X3Y,\w } wo"}BA;S@~Cqbs6B)PB>4##vf9lByWM )affsA_$ BsAIS@@"SuB/]b;8 ݒA&D躄N_g7DS#GD>-;WɈBzf=e#2;| ]ƞ={6aaas`ZZE4.J/18;A@T9ml߾}圲]TCq(!<>$05!_䂓 @z(S WDD'}V'P"#T :MVad#t΢")FBBŨ25A +SI9MzѤIFP4Aغxb9G oΌptK'iӦN[RɑKwhÜ-ݡsTZyx^@@T`tXMJJW.&6M/(pزTz,amm-B~zY`-NBG]ȧ*"*鐞IesUvsqۖ< DzLbq.EyrsQ<<}:x-.FЉjK`5pY^^&]\Rff d*f9D>bLH5!Lcz84;|<=Ed^wLwВv P:+++g͛G7f͚p𯓦=7,q̲eش~+0=ϵɖRvz#5-bX޳gUd;vpք&50a$NĽ,KOQ1i}ܸzkOM L%8 Tf[BPZɓm!Aj}'Ml~9ΝKckVdM ?j(Zpv|Yd FӦM#b{–҇M6 ξk6b~Qm "8-NS :팜ٲZJy֛>\UH`5Q3P32`Y,_<66vÆ !%~ل*mrS9vXkkkIIɲ8Iy455vіe9NU8heInY]knЖ~;w 7_ё|w䪪jqtF/dآU*[ZB-Bz ̚=WnqZ?K.;||E_-N¸ƦjK]u([n9kLsQpD'~~]pSs+Bt[[[ssf =tgdz!44(?,).BC+B(G)//_X+7gs#BG8|t E*jPJ;NK_%Wܰy%%%Nk8oP#ҒY,AeLf,!(?En+l b WWW\f":#[@YT3juqm}}е%ȦrȑjܟU[B\<2cC ?/)Sx8o477_!hƚ9;rԴ5)+#/L>'$>b97l^||"#<' OHL\:^PI*!C",d%K7*zIMBVWkyӦRQؐMmL7V--jo]KyIY/%:7)~[Vy}@[OėJtvIg׃l*KKIgollܴi/bZZZ!;{g6zbӞ={bT% +ӪΕa+.)^IߐE7?@!}`Bߐ_ oDߛx=Vx$5 %9~kuy~s9奞aU^/-jh96TzPkEu T*++(mwًgemtW;'g6czHm6#$eeaȿ:ICYYYaQLMC: B4&0ƿ8;"i[p󎲦#'zݡ cfxEÑ75Йg޷K͑cog;>MΪXl/mpѥUuT0HuZRkrr f<=g57l^mm-Ćnp TT THAvDG/%/ٲe%P$;TTD4Ʀa%f*O+UtCK՜xVlgY%EQd_E%B.Zv3#%g%_Sqrtٯ jS!2Rw t, 8״۷e޷]T1zLe4Ȋuv祥1eq~gBj*{]]ր>ȉ^Ģ4I[#2*wDfMO N/wOE!c_m7KSii"[L=Sq؝_WӫMe]Ay⯹+֮[ﹻ|y򂅋)?peb] g#[TnF"555ٳɎQ‰KT?Q<} }UM04)=1XYYC',;RJ 9vq'_l^M)H{׻MeGNNPrX'hs IAU5k nTRRJ vMW`ŦۻBm*sUWW BfSWVniʯ x _֡2Nm[˽p6$='VX(=1{*[fi{{WNή hT6#825Y7q5*paǎP\\x{*6.ė6YnnI?7KKNi*6wcPܹUUU2GM汞3 7R~:h] SUYCE̱aU5e3'ʯ?yW1^n*v.\G*io1 OLZβf`w %Kݦg^!S ZlYcsq6y d*TjJQNىʆ *\{bԾ-bpi7K*Lsb=#/'U2穄߻8} Y̪U-߸{t g}Զۭ3a&vO%;{ L,$9eJܨ$ա{[ThU5/ AOUÿKvTbGTRء}clqaM+n!? L llܴLEMS'l+MePzK)l8h⢝maŵRƿYR4jMʼn!_( 7eÕTj^%О{.mO8 ZmFE/XVfϸ7gh=-~>!!Ľ8oqq1طc{ÜEUUuSiii>9;8%Iq:O"(WZK5MG-,_^/'՝{ol=ps~UKKۃ*{gUq@K,EEjrɲ,_)=u[rϹW CDP * $2$H Hg sg2Wot:C~?${{n^c RZ{n6su].v >v=$fgg9rJ 盙ͬ5==||Rm0Y'G%tqֵ01)޾Rew%^&/?_?1S]SInAb㎈1LEoji]:7h T" 1ҖW:6O4{.sH6^nX&iJoo/}_@\r9WO'm=Edz 45RoܸZSSSPPu(oYYYrwf"=ȅ CCcc|N_+I@-:<]]]MMMF;Jxzzn MpOV䢰Fw|w-bTU8_qPW(aY)a1E',??'/\ "Mp=)T]sǪ Ϙ|؉ҋI@,YBݰΘoP"xմ8( Wkjj\4b"Wbtt˩ĒhU‚k:G12eԝx444TUU榤Nbdg_~zmmr` 8E.LbKzىK뎗5N2~Lݧ $JPr /3ě`MMMv]|f奥{`:}xKKJO99 D֢Ҍ\ҥߋKJH$i)g^/ي$J ,2cJOZB|| %*--]+]Vx/f4766J )GooyGH7^‘G."[Z*kZΗg\L/7yzDžV[uNkY r%J 첓) /3v `* `*  `*`* `* `*x1B/a/G!G!"L!BSA! B B!L!BSA!0B B!L!TBa*!0B B! TBa*!BSA! TB>]@# LMMpYXEg ܲM%$z- .m4XN\ mU'OK9*n+ 7BUTlV%k%!W)PZc`xխ~GkXrg.t9t>U07?JdqiX᦭MeyUhS{~3%EJy-uTUk9GSQ"wzT`u[NTW ¸ m*OǪԮ_ͬ ƒiy\TCv h&u ¯޶Da `Xu0510SySm60"L\J ۪%RO|j^owdr']~I=\?`|yDZض]oMn%\m-_F6 *rǪGCiił?6(Bmߺ:Čm߫$=c 3~ظYX?50j>?"}qC NQJuCR㩲wF&y@\x4,1=]wۅs:UQ=r݆04Z Rg3iL)- v4eeV [:$fgZ WO4X](eڪ~ݭ#c:ǍyfM cW #J=|df^i*vG}U+^hїWt--y4٩WR;z{Kj3p}w]=Fᆪ6KN]N\Ro^?t8AlEzŎcg7X""Un.gf<6769LiyOS1 yYIɎ8X3떔 æ"li*?\+\dձH*YQp挵0F;'WYE]{o}}rK({29`Ew [UÿB'r:%aMvcZ4EOuj.doBC}tSn*h04zkZk(g%bP8s!pTVvՕbw]l7hS7M%gSiYOH3;6nvꞲZyV͗}nMe بqF[3s44Z#=b9y~4O4B EK:8$u0IM]*&N}]Q]#ZsXe~x]q>~ P&_^r{oh;Zp? oY/N'a*_B Bȟܢ;u>'n=a!L!Ba*!0B B! TBa*!0BSA! TB!L!BSA! B B!L!B J?>E!o:?_YHL[o_~/HyY ~7HN_xZf_|qQQQ6'+ #N#`o޼id]~֭TJ9S`D><~WGR@V̆ !#kɺ4 ?y\2یL ̙a'"c"kɺ4 aw}7%%7S ||)&e S'3g|w<-;eee>RXV0SOjii-J裏FDD"+z J{l|S1^+))5*"L3g꫑b*~N?^_"+!0I>裲2_J 3k֬I@UdE[T&Obb$J MTdEY6T&ɇ~(ABB3g+]sNST0Ҙuw/LS;O<dxNes* K/k׮_>=42", "wheel" ] build-backend = "setuptools.build_meta" rabbit-air-python-rabbitair-ffc5866/pytest.ini000066400000000000000000000000351421250042300214530ustar00rootroot00000000000000[pytest] asyncio_mode = auto rabbit-air-python-rabbitair-ffc5866/rabbitair/000077500000000000000000000000001421250042300213635ustar00rootroot00000000000000rabbit-air-python-rabbitair-ffc5866/rabbitair/__init__.py000066400000000000000000000012331421250042300234730ustar00rootroot00000000000000"""Rabbit Air Python library.""" from .client import Client from .exceptions import NetworkError, ProtocolError from .response import ( RSSI, Error, FilterType, Gas, Info, Lights, Mode, Model, Moodlight, Quality, Sensitivity, Speed, State, TimerMode, ) from .tcp import TcpClient from .udp import UdpClient __all__ = [ "Client", "Error", "FilterType", "Gas", "Info", "Lights", "Mode", "Model", "Moodlight", "NetworkError", "ProtocolError", "Quality", "RSSI", "Sensitivity", "Speed", "State", "TcpClient", "TimerMode", "UdpClient", ] rabbit-air-python-rabbitair-ffc5866/rabbitair/client.py000066400000000000000000000234271421250042300232230ustar00rootroot00000000000000"""Rabbit Air protocol client.""" import asyncio import json import os import socket import time from abc import ABC, abstractmethod from random import SystemRandom from types import TracebackType from typing import Any, Dict, List, Optional, Type from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from zeroconf.asyncio import AsyncZeroconf from .exceptions import ProtocolError from .response import ( Info, Lights, Mode, Moodlight, Sensitivity, Speed, State, TimerMode, ) class Client(ABC): """Base class for the Rabbit Air protocol client. To create an instance of the class use UdpClient or TcpClient. """ _sock: Optional[socket.socket] = None _ts_diff: Optional[float] = None def __init__( self, host: str, token: Optional[str], port: int = 9009, zeroconf: Optional[AsyncZeroconf] = None, ) -> None: """Initialize the client.""" self._host = host self._token = bytes.fromhex(token) if token else None if self._token and len(self._token) != 16: raise ValueError("Invalid token length") self._port = port self._zeroconf = zeroconf self._id = SystemRandom().randrange(0x1000000) self._lock = asyncio.Lock() def __enter__(self) -> "Client": """Enter the runtime context related to this object.""" return self def __exit__( self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> Optional[bool]: """Close the client.""" if self._sock is not None: self._sock.close() return None @classmethod @abstractmethod def _create_socket(cls) -> socket.socket: """Create a network socket.""" async def _resolve(self, host: str) -> str: if self._zeroconf is not None and host.endswith(".local"): info = await self._zeroconf.async_get_service_info( "_rabbitair._udp.local.", host + "." ) if info: addr = info.parsed_addresses() if len(addr) > 0: return addr[0] return host async def _start(self) -> None: assert self._sock is None self._sock = self._create_socket() self._sock.setblocking(False) loop = asyncio.get_running_loop() host = await self._resolve(self._host) await loop.sock_connect(self._sock, (host, self._port)) def _stop(self) -> None: assert self._sock is not None self._sock.close() self._sock = None self._ts_diff = None @staticmethod def _clock() -> float: return time.clock_gettime(time.CLOCK_BOOTTIME) def _get_ts(self) -> int: assert self._ts_diff is not None return round(self._clock() + self._ts_diff) def _next_id(self) -> int: value = self._id self._id += 1 return value def _decrypt(self, msg: bytes) -> bytes: assert self._token is not None iv = msg[-16:] msg = msg[:-16] cipher = Cipher(algorithms.AES(self._token), modes.CBC(iv), default_backend()) decryptor = cipher.decryptor() # type: ignore[no-untyped-call] msg = decryptor.update(msg) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() # type: ignore[no-untyped-call] return unpadder.update(msg) + unpadder.finalize() # type: ignore[no-any-return] def _encrypt(self, msg: bytes) -> bytes: assert self._token is not None padder = padding.PKCS7(128).padder() # type: ignore[no-untyped-call] msg = padder.update(msg) + padder.finalize() iv = os.urandom(16) cipher = Cipher(algorithms.AES(self._token), modes.CBC(iv), default_backend()) encryptor = cipher.encryptor() # type: ignore[no-untyped-call] return encryptor.update(msg) + encryptor.finalize() + iv # type: ignore[no-any-return] @abstractmethod async def _recvmsg(self) -> bytes: """Receive messages over the network.""" @abstractmethod async def _sendmsg(self, data: bytes) -> None: """Send messages over the network.""" async def _exchange(self, request_id: int, data: bytes) -> Dict[str, Any]: await self._sendmsg(data) while True: data = await self._recvmsg() if self._token: data = self._decrypt(data) try: response: Dict[str, Any] = json.loads(data) if response["id"] == request_id: return response except (json.JSONDecodeError, KeyError): # Ignore any garbage or unexpected responses pass async def _command(self, request: Dict[str, Any]) -> Dict[str, Any]: data = json.dumps(request, separators=(",", ":")).encode() if self._token: data = self._encrypt(data) response = await self._exchange(request["id"], data) if response.get("error"): raise ProtocolError() return response async def command(self, request: Dict[str, Any]) -> Dict[str, Any]: """Send raw command to the device.""" async with self._lock: try: if not self._sock: await self._start() if self._token: if self._ts_diff is None: ts_request = {"id": self._next_id(), "cmd": 9} response = await self._command(ts_request) ts = response["data"]["ts"] self._ts_diff = ts - self._clock() request["ts"] = ts else: request["ts"] = self._get_ts() request["id"] = self._next_id() return await self._command(request) except ProtocolError: raise except Exception: self._stop() raise async def get_state(self) -> State: """Get the current state of the device.""" response = await self.command({"cmd": 4}) return State(response["data"]) async def set_state( self, power: Optional[bool] = None, mode: Optional[Mode] = None, speed: Optional[Speed] = None, sensitivity: Optional[Sensitivity] = None, ionizer: Optional[bool] = None, moodlight: Optional[Moodlight] = None, filter_cleaning: Optional[bool] = None, filter_replacement: Optional[bool] = None, filter_life: Optional[int] = None, filter_timer: Optional[int] = None, lights: Optional[Lights] = None, color: Optional[List[int]] = None, light_sensor_ctl: Optional[bool] = None, filter_ctl: Optional[bool] = None, buzzer: Optional[bool] = None, child_lock: Optional[bool] = None, timer_mode: Optional[TimerMode] = None, timer: Optional[int] = None, schedule: Optional[str] = None, ) -> None: """Change the state of the device.""" data: Dict[str, Any] = {} if power is not None: data["power"] = power if mode is not None: data["mode"] = mode.value if speed is not None: data["speed"] = speed.value if sensitivity is not None: data["sensitivity"] = sensitivity.value if ionizer is not None: data["ionizer"] = ionizer if moodlight is not None: data["moodlight"] = moodlight.value if filter_cleaning is not None: data["filter_cleaning"] = filter_cleaning if filter_replacement is not None: data["filter_replacement"] = filter_replacement if filter_life is not None: if filter_life < 0 or filter_life > 525600: raise ValueError("The filter life value must be in the range 0-1440") data["filter_life"] = filter_life if filter_timer is not None: if filter_timer < 0 or filter_timer > 525600: raise ValueError("The filter timer value must be in the range 0-1440") data["filter_timer"] = filter_timer if lights is not None: data["all_light_off"] = lights.value if color is not None: if len(color) != 9: raise ValueError("The color length must be 9") for v in color: if v < 0 or v > 40: raise ValueError("The color values must be in the range 0-40") data["color"] = color if light_sensor_ctl is not None: data["lsens_ctl"] = light_sensor_ctl if filter_ctl is not None: data["filter_ctl"] = filter_ctl if buzzer is not None: data["buzzer"] = buzzer if child_lock is not None: data["lock"] = child_lock if timer_mode is not None: data["timer_mode"] = timer_mode.value if timer is not None: if timer < 0 or timer > 1440: raise ValueError("The timer value must be in the range 0-1440") data["timer"] = timer if schedule is not None: if len(schedule) != 24: raise ValueError("The schedule length must be 24") for c in schedule: if (c < "0" or c > "5") and c != "A": raise ValueError("The schedule values must be 0-5,A") data["schedule"] = schedule await self.command({"cmd": 4, "data": data}) async def get_info(self) -> Info: """Get information about the device.""" response = await self.command({"cmd": 255}) return Info(response["data"]) rabbit-air-python-rabbitair-ffc5866/rabbitair/exceptions.py000066400000000000000000000004141421250042300241150ustar00rootroot00000000000000"""Rabbit Air Python library exceptions.""" class NetworkError(Exception): """The network connection behaved in an unexpected way.""" pass class ProtocolError(Exception): """The protocol message was recognized but has invalid parameters.""" pass rabbit-air-python-rabbitair-ffc5866/rabbitair/py.typed000066400000000000000000000000001421250042300230500ustar00rootroot00000000000000rabbit-air-python-rabbitair-ffc5866/rabbitair/response.py000066400000000000000000000270701421250042300236010ustar00rootroot00000000000000"""Rabbit Air responses.""" import inspect from enum import Enum, unique from typing import Generic, List, Optional, TypeVar try: from typing import TypedDict except ImportError: from typing_extensions import TypedDict T = TypeVar("T") @unique class Model(Enum): """Device model.""" MinusA2 = 1 BioGS = 2 A3 = 3 @unique class Mode(Enum): """Mode of operation.""" Auto = 0 Pollen = 1 Manual = 2 @unique class Speed(Enum): """Fan speed. Note: SuperSilent mode cannot be set manually. """ SuperSilent = 0 Silent = 1 Low = 2 Medium = 3 High = 4 Turbo = 5 @unique class Quality(Enum): """Air quality. Note: the Lowest value is not actually used. """ Lowest = 0 Low = 1 Medium = 2 High = 3 Highest = 4 @unique class Sensitivity(Enum): """Sensitivity level of the sensors.""" High = 0 Medium = 1 Low = 2 class Moodlight(Enum): """Mood Light mode.""" Off = 0 On = 1 Auto = 2 Preset1 = 2 Preset2 = 3 Preset3 = 4 @unique class Lights(Enum): """Light settings.""" Off = 0 On = 1 Auto = 2 @unique class Error(Enum): """Internal error codes.""" NoError = 0 DustSensor = 1 GasSensor = 2 GasAndDust = 3 FanLow = 4 NFC = 5 HallSensor = 8 @unique class FilterType(Enum): """Filter type.""" Unknown = 0 ToxinAbsorber = 1 OdorRemover = 2 GermDefense = 3 PetAllergy = 4 @unique class Gas(Enum): """Gas sensor readings.""" Preheat = 0 Level1 = 1 Level2 = 2 Level3 = 3 Level4 = 4 @unique class TimerMode(Enum): """Timer mode.""" Off = 0 On = 1 Schedule = 2 class StateDict(TypedDict, total=False): """State response typing.""" model: int firmware: List[int] power: bool mode: int speed: int quality: int sensitivity: int ionizer: bool idle: int moodlight: int sleep: bool filter_cleaning: bool filter_replacement: bool filter_life: int light_sensor: bool particulate_sensor: int filter_timer: int all_light_off: int error: int tag_state: int tag_uid: List[int] filter_type: int pm_sensor: List[int] color: List[int] lsens_ctl: bool filter_ctl: bool buzzer: bool gas: int lock: bool open: bool timer_mode: int timer: int schedule: str rssi: int v: str class RssiDict(TypedDict, total=False): """RSSI typing.""" cur: int min: int max: int avg: int class InfoDict(TypedDict, total=False): """Info response typing.""" name: str mcu: str build: str mac: str time: str uptime: int mup: int wup: int iup: int cup: int fv: str rssi: RssiDict class Response(Generic[T]): """Base class for the device response.""" def __init__(self, data: T) -> None: """Initialize.""" self.data = data def __repr__(self) -> str: """Return the string representation of the object.""" items = [] props = inspect.getmembers(type(self), lambda x: isinstance(x, property)) for name, prop in props: try: value = prop.fget(self) except Exception as ex: value = repr(ex) items.append(f"{name}={value}") return f"<{type(self).__name__} {' '.join(items)}>" class State(Response[StateDict]): """Device state.""" @property def model(self) -> Optional[Model]: """Device model.""" value = self.data.get("model") return None if value is None else Model(value) @property def main_firmware(self) -> Optional[str]: """Version of the main board firmware.""" value = self.data.get("firmware") if value is None: return None return ".".join(str(x) for x in value) @property def power(self) -> Optional[bool]: """Power on/off.""" return self.data.get("power") @property def mode(self) -> Optional[Mode]: """Mode of operation.""" value = self.data.get("mode") return None if value is None else Mode(value) @property def speed(self) -> Optional[Speed]: """Fan speed.""" value = self.data.get("speed") return None if value is None else Speed(value) @property def quality(self) -> Optional[Quality]: """Air quality.""" value = self.data.get("quality") if value is None: return None if self.model is Model.BioGS: return Quality(value - 1) else: return Quality(value) @property def sensitivity(self) -> Optional[Sensitivity]: """Sensitivity level of the sensors.""" value = self.data.get("sensitivity") return None if value is None else Sensitivity(value) @property def ionizer(self) -> Optional[bool]: """Negative ion on/off.""" return self.data.get("ionizer") @property def idle(self) -> Optional[bool]: """Device is in idle mode.""" value = self.data.get("idle") return None if value is None else bool(value) @property def moodlight(self) -> Optional[Moodlight]: """Mood Light mode.""" value = self.data.get("moodlight") return None if value is None else Moodlight(value) @property def sleep(self) -> Optional[bool]: """Device is in sleep mode.""" return self.data.get("sleep") @property def filter_cleaning(self) -> Optional[bool]: """Filter cleaning required.""" return self.data.get("filter_cleaning") @property def filter_replacement(self) -> Optional[bool]: """Filter replacement required.""" return self.data.get("filter_replacement") @property def filter_life(self) -> Optional[int]: """Remaining filter lifetime.""" return self.data.get("filter_life") @property def light_sensor(self) -> Optional[bool]: """Light sensor readings.""" return self.data.get("light_sensor") @property def particulate_sensor(self) -> Optional[int]: """Particle sensor readings.""" return self.data.get("particulate_sensor") @property def filter_timer(self) -> Optional[int]: """Nominal filter lifetime.""" return self.data.get("filter_timer") @property def lights(self) -> Optional[Lights]: """Turn all light on/off.""" value = self.data.get("all_light_off") return None if value is None else Lights(value) @property def error(self) -> Optional[Error]: """Internal error codes.""" # noqa: D401 value = self.data.get("error") return None if value is None else Error(value) @property def tag_state(self) -> Optional[bool]: """Filter tag state.""" value = self.data.get("tag_state") return None if value is None else bool(value) @property def tag_uid(self) -> Optional[List[int]]: """Filter tag unique identifier.""" return self.data.get("tag_uid") @property def filter_type(self) -> Optional[FilterType]: """Filter type.""" value = self.data.get("filter_type") return None if value is None else FilterType(value) @property def pm_sensor(self) -> Optional[List[int]]: """Extended particle sensor readings.""" # noqa: D401 return self.data.get("pm_sensor") @property def color(self) -> Optional[List[int]]: """Color palette for Mood Light.""" return self.data.get("color") @property def light_sensor_ctl(self) -> Optional[bool]: """Activate/deactivate light sensor.""" return self.data.get("lsens_ctl") @property def filter_ctl(self) -> Optional[bool]: """Activate/deactivate notification about filter replacement condition.""" return self.data.get("filter_ctl") @property def buzzer(self) -> Optional[bool]: """Buzzer sound on/off.""" return self.data.get("buzzer") @property def gas(self) -> Optional[Gas]: """Gas sensor readings.""" value = self.data.get("gas") return None if value is None else Gas(value) @property def child_lock(self) -> Optional[bool]: """Child lock on/off.""" return self.data.get("lock") @property def open(self) -> Optional[bool]: """Front panel is removed or open.""" return self.data.get("open") @property def timer_mode(self) -> Optional[TimerMode]: """Timer mode.""" value = self.data.get("timer_mode") return None if value is None else TimerMode(value) @property def timer(self) -> Optional[int]: """Time, in minutes, remaining until the unit is turned off.""" return self.data.get("timer") @property def schedule(self) -> Optional[str]: """24-h schedule. This is a 24-character string in which each character specifies the speed for a specific hour. Acceptable values are 1-5 and A (auto). The time is in UTC. """ return self.data.get("schedule") @property def rssi(self) -> Optional[int]: """Wi-Fi RSSI value averaged over an hour.""" return self.data.get("rssi") @property def wifi_firmware(self) -> Optional[str]: """Version of the Wi-Fi board firmware.""" return self.data.get("v") class RSSI(Response[RssiDict]): """Detailed information about Wi-Fi RSSI.""" @property def current(self) -> Optional[int]: """Current RSSI value.""" # noqa: D401 return self.data.get("cur") @property def min(self) -> Optional[int]: """Minimal RSSI value for an hour.""" return self.data.get("min") @property def max(self) -> Optional[int]: """Maximal RSSI value for an hour.""" return self.data.get("max") @property def average(self) -> Optional[int]: """RSSI value averaged over an hour.""" return self.data.get("avg") class Info(Response[InfoDict]): """Information about the device.""" @property def name(self) -> str: """Device ID.""" return self.data["name"] @property def wifi_firmware(self) -> str: """Version of the Wi-Fi board firmware.""" return self.data["mcu"] @property def build(self) -> str: """Build date of the Wi-Fi board firmware.""" return self.data["build"] @property def mac(self) -> str: """MAC address.""" return self.data["mac"] @property def time(self) -> Optional[str]: """Current time.""" # noqa: D401 return self.data.get("time") @property def uptime(self) -> int: """Total uptime.""" return self.data["uptime"] @property def motor_uptime(self) -> int: """Total motor runtime.""" return self.data["mup"] @property def wifi_uptime(self) -> int: """Total Wi-Fi uptime.""" return self.data["wup"] @property def internet_uptime(self) -> Optional[int]: """Total internet uptime.""" return self.data.get("iup") @property def cloud_uptime(self) -> Optional[int]: """Total cloud uptime.""" return self.data.get("cup") @property def main_firmware(self) -> Optional[str]: """Version of the main board firmware.""" return self.data.get("fv") @property def rssi(self) -> Optional[RSSI]: """Detailed information about Wi-Fi RSSI.""" value = self.data.get("rssi") return None if value is None else RSSI(value) rabbit-air-python-rabbitair-ffc5866/rabbitair/tcp.py000066400000000000000000000026121421250042300225240ustar00rootroot00000000000000"""Rabbit Air TCP-based client.""" import asyncio import socket import struct from typing import Any, Dict from .client import Client from .exceptions import NetworkError class TcpClient(Client): """TCP-based client.""" timeout: float = 5.0 @classmethod def _create_socket(cls) -> socket.socket: return socket.socket(type=socket.SOCK_STREAM) @staticmethod async def _recvall(sock: socket.socket, size: int) -> bytes: data = b"" loop = asyncio.get_running_loop() while len(data) < size: chunk = await loop.sock_recv(sock, size - len(data)) if not chunk: break data += chunk if len(data) != size: raise NetworkError("Connection was unexpectedly closed") return data async def _recvmsg(self) -> bytes: assert self._sock is not None header = await self._recvall(self._sock, 2) size = struct.unpack(" None: assert self._sock is not None loop = asyncio.get_running_loop() await loop.sock_sendall(self._sock, struct.pack(" Dict[str, Any]: return await asyncio.wait_for(super()._exchange(request_id, data), self.timeout) rabbit-air-python-rabbitair-ffc5866/rabbitair/udp.py000066400000000000000000000020531421250042300225250ustar00rootroot00000000000000"""Rabbit Air UDP-based client.""" import asyncio import socket from typing import Any, Dict from .client import Client class UdpClient(Client): """UDP-based client.""" retry_count: int = 3 timeout: float = 2.0 @classmethod def _create_socket(cls) -> socket.socket: return socket.socket(type=socket.SOCK_DGRAM) async def _recvmsg(self) -> bytes: assert self._sock is not None loop = asyncio.get_running_loop() return await loop.sock_recv(self._sock, 2048) async def _sendmsg(self, data: bytes) -> None: assert self._sock is not None loop = asyncio.get_running_loop() await loop.sock_sendall(self._sock, data) async def _exchange(self, request_id: int, data: bytes) -> Dict[str, Any]: for i in range(self.retry_count): try: return await asyncio.wait_for( super()._exchange(request_id, data), self.timeout ) except asyncio.TimeoutError as e: error = e raise error rabbit-air-python-rabbitair-ffc5866/setup.cfg000066400000000000000000000021621421250042300212460ustar00rootroot00000000000000[metadata] name = python-rabbitair version = 0.0.8 author = Rabbit Air author_email = developer@rabbitair.com description = Python library for local control of Rabbit Air air purifiers long_description = file: README.md long_description_content_type = text/markdown license = Apache-2.0 platforms = any url = https://github.com/rabbit-air/python-rabbitair project_urls = Bug Tracker = https://github.com/rabbit-air/python-rabbitair/issues classifiers = Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: Apache Software License Operating System :: POSIX :: Linux Programming Language :: Python :: 3 Topic :: Home Automation [options] zip_safe = False packages = find: python_requires = >=3.7 install_requires = cryptography typing-extensions;python_version<'3.8' zeroconf [options.package_data] * = py.typed [flake8] # To work with Black # 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 rabbit-air-python-rabbitair-ffc5866/tests/000077500000000000000000000000001421250042300205665ustar00rootroot00000000000000rabbit-air-python-rabbitair-ffc5866/tests/test_client.py000066400000000000000000000306071421250042300234630ustar00rootroot00000000000000"""Test the RabbitAir client.""" import asyncio import json from typing import Any, Callable, Coroutine, Dict, Optional from unittest.mock import Mock, patch import pytest from rabbitair import ( Error, FilterType, Gas, Lights, Mode, Model, Moodlight, ProtocolError, Quality, Sensitivity, Speed, TimerMode, UdpClient, ) TEST_IP = "192.0.2.1" TEST_TOKEN = "0123456789ABCDEF0123456789ABCDEF" TEST_STATE_RESPONSE_A2 = """{ "id": 0, "data": { "model": 1, "firmware": [3], "power": true, "mode": 0, "speed": 1, "quality": 4, "sensitivity": 0, "ionizer": false, "moodlight": 0, "sleep": false, "filter_replacement": false, "filter_life": 525600, "light_sensor": true, "particulate_sensor": 0, "filter_timer": 525580, "all_light_off": 2, "error": 0, "timer_mode": 0, "timer": 1, "schedule": "AAAAAAAAAAAAAAAAAAAAAAAA", "rssi": -61, "v": "2.3.17" } }""" TEST_STATE_RESPONSE_A3 = """{ "id": 0, "data": { "model": 3, "firmware": [1, 0, 0, 4], "power": true, "mode": 2, "speed": 4, "quality": 3, "sensitivity": 2, "ionizer": true, "idle": 0, "moodlight": 1, "filter_cleaning": false, "filter_replacement": false, "filter_life": 525600, "light_sensor": true, "filter_timer": 525580, "all_light_off": 1, "error": 0, "tag_state": 0, "tag_uid": [0, 0, 0, 0, 0, 0, 0], "filter_type": 0, "pm_sensor": [19, 29, 31], "color": [31, 0, 20, 0, 22, 40, 22, 30, 6], "lsens_ctl": false, "filter_ctl": false, "buzzer": false, "gas": 0, "lock": false, "open": false, "timer_mode": 0, "timer": 0, "schedule": "AAAAAAAAAAAAAAAAAAAAAAAA", "rssi": -52, "v": "2.3.17" } }""" TEST_TS_RESPONSE = """{ "id": 0, "data": { "v": 1, "ts": 103431 } }""" TEST_INFO_RESPONSE_A2 = """{ "id": 0, "data": { "name": "1234567890_123456789012345678", "mcu": "2.3.17", "build": "Nov 29 2021 21:41:45", "wifi": "v3.3.2", "mac": "01:23:45:67:89:AB", "time": "22:35:29", "heap": 69304, "hmin": 57004, "stack": 1372, "clk": 80000000, "uptime": 314070, "mup": 65174, "wup": 306293, "iup": 294213, "cup": 297758, "flc": 1, "ftc": 0, "errb": 8, "errw": 1263, "rssi": { "cur": -68, "min": -78, "max": -58, "avg": -66 } } }""" TEST_INFO_RESPONSE_A3 = """{ "id": 0, "data": { "name": "1234567890_123456789012345678", "mcu": "2.3.17", "build": "Nov 29 2021 21:41:45", "wifi": "v3.3.2", "mac": "01:23:45:67:89:AB", "time": "22:35:29", "heap": 69304, "hmin": 57004, "stack": 1372, "clk": 80000000, "uptime": 314070, "mup": 65174, "wup": 306293, "iup": 294213, "cup": 297758, "flc": 1, "ftc": 0, "errb": 8, "errw": 1263, "fv":"1.0.0.4", "rssi": { "cur": -68, "min": -78, "max": -58, "avg": -66 } } }""" def mock_command( model: Optional[Model], ) -> Callable[[Any, Dict[str, Any]], Coroutine[Any, Any, Dict[str, Any]]]: """Mock command.""" if model is Model.MinusA2: state_response = TEST_STATE_RESPONSE_A2 info_response = TEST_INFO_RESPONSE_A2 elif model is Model.A3: state_response = TEST_STATE_RESPONSE_A3 info_response = TEST_INFO_RESPONSE_A3 else: state_response = "{}" info_response = "{}" async def command(self: Any, request: Dict[str, Any]) -> Dict[str, Any]: if request["cmd"] == 4: response = state_response elif request["cmd"] == 9: response = TEST_TS_RESPONSE elif request["cmd"] == 255: response = info_response else: assert False result: Dict[str, Any] = json.loads(response) result["id"] = request["id"] return result return command @pytest.mark.parametrize("token", [TEST_TOKEN.lower(), TEST_TOKEN.upper(), "", None]) def test_create(token: str) -> None: """Instance creation test.""" UdpClient(TEST_IP, token) @pytest.mark.parametrize( "token", [TEST_TOKEN[:30], TEST_TOKEN + TEST_TOKEN, TEST_TOKEN.replace("1", "x")] ) def test_create_fail(token: str) -> None: """Test cases where instance creation fails.""" with pytest.raises(ValueError): UdpClient(TEST_IP, token) async def test_zeroconf() -> None: """Test mDNS resolver.""" info = Mock() info.parsed_addresses.return_value = [TEST_IP] async def async_get_service_info(type: str, name: str) -> Mock: return info zc = Mock() zc.async_get_service_info = async_get_service_info with patch( "rabbitair.Client._command", new_callable=mock_command, model=Model.MinusA2 ): with UdpClient("test.local", TEST_TOKEN, zeroconf=zc) as client: await client.get_state() assert len(info.parsed_addresses.mock_calls) == 1 async def test_state_a2() -> None: """Test state response for MinusA2.""" with patch( "rabbitair.Client._command", new_callable=mock_command, model=Model.MinusA2 ): with UdpClient(TEST_IP, TEST_TOKEN) as client: state = await client.get_state() assert state.model is Model.MinusA2 assert state.main_firmware == "3" assert state.power is True assert state.mode is Mode.Auto assert state.speed is Speed.Silent assert state.quality is Quality.Highest assert state.sensitivity is Sensitivity.High assert state.ionizer is False assert state.idle is None assert state.moodlight is Moodlight.Off assert state.sleep is False assert state.filter_cleaning is None assert state.filter_replacement is False assert state.filter_life == 525600 assert state.light_sensor is True assert state.particulate_sensor == 0 assert state.filter_timer == 525580 assert state.lights is Lights.Auto assert state.error is Error.NoError assert state.tag_state is None assert state.tag_uid is None assert state.filter_type is None assert state.pm_sensor is None assert state.color is None assert state.light_sensor_ctl is None assert state.filter_ctl is None assert state.buzzer is None assert state.gas is None assert state.child_lock is None assert state.open is None assert state.timer_mode is TimerMode.Off assert state.timer == 1 assert state.schedule == "AAAAAAAAAAAAAAAAAAAAAAAA" assert state.rssi == -61 assert state.wifi_firmware == "2.3.17" async def test_state_a3() -> None: """Test state response for A3.""" with patch("rabbitair.Client._command", new_callable=mock_command, model=Model.A3): with UdpClient(TEST_IP, TEST_TOKEN) as client: state = await client.get_state() assert state.model is Model.A3 assert state.main_firmware == "1.0.0.4" assert state.power is True assert state.mode is Mode.Manual assert state.speed is Speed.High assert state.quality is Quality.High assert state.sensitivity is Sensitivity.Low assert state.ionizer is True assert state.idle is False assert state.moodlight is Moodlight.On assert state.sleep is None assert state.filter_cleaning is False assert state.filter_replacement is False assert state.filter_life == 525600 assert state.light_sensor is True assert state.particulate_sensor is None assert state.filter_timer == 525580 assert state.lights is Lights.On assert state.error is Error.NoError assert state.tag_state is False assert state.tag_uid == [0, 0, 0, 0, 0, 0, 0] assert state.filter_type is FilterType.Unknown assert state.pm_sensor == [19, 29, 31] assert state.color == [31, 0, 20, 0, 22, 40, 22, 30, 6] assert state.light_sensor_ctl is False assert state.filter_ctl is False assert state.buzzer is False assert state.gas is Gas.Preheat assert state.child_lock is False assert state.open is False assert state.timer_mode is TimerMode.Off assert state.timer == 0 assert state.schedule == "AAAAAAAAAAAAAAAAAAAAAAAA" assert state.rssi == -52 assert state.wifi_firmware == "2.3.17" @pytest.mark.parametrize("model,fv", [(Model.MinusA2, None), (Model.A3, "1.0.0.4")]) async def test_info(model: Model, fv: str) -> None: """Test info response.""" with patch("rabbitair.Client._command", new_callable=mock_command, model=model): with UdpClient(TEST_IP, TEST_TOKEN) as client: info = await client.get_info() assert info.name == "1234567890_123456789012345678" assert info.wifi_firmware == "2.3.17" assert info.build == "Nov 29 2021 21:41:45" assert info.mac == "01:23:45:67:89:AB" assert info.time == "22:35:29" assert info.uptime == 314070 assert info.motor_uptime == 65174 assert info.wifi_uptime == 306293 assert info.internet_uptime == 294213 assert info.cloud_uptime == 297758 assert info.main_firmware == fv assert info.rssi is not None assert info.rssi.current == -68 assert info.rssi.min == -78 assert info.rssi.max == -58 assert info.rssi.average == -66 async def test_no_response() -> None: """Test no response.""" with patch("rabbitair.Client._command", side_effect=asyncio.TimeoutError): with UdpClient(TEST_IP, TEST_TOKEN) as client: with pytest.raises(asyncio.TimeoutError): await client.get_state() async def test_protocol_error() -> None: """Test protocol error response.""" with patch("rabbitair.Client._command", side_effect=ProtocolError): with UdpClient(TEST_IP, TEST_TOKEN) as client: with pytest.raises(ProtocolError): await client.get_state() async def test_sequential_requests() -> None: """Test sequential requests.""" with patch( "rabbitair.Client._command", new_callable=mock_command, model=Model.MinusA2 ): with UdpClient(TEST_IP, TEST_TOKEN) as client: await client.get_state() await client.get_info() async def test_set_state() -> None: """Test set state.""" with patch("rabbitair.Client._command", new_callable=mock_command, model=Model.A3): with UdpClient(TEST_IP, TEST_TOKEN) as client: await client.set_state( power=True, mode=Mode.Manual, speed=Speed.Medium, sensitivity=Sensitivity.Medium, ionizer=True, moodlight=Moodlight.On, filter_cleaning=False, filter_replacement=False, filter_life=525600, filter_timer=0, lights=Lights.Off, color=[31, 0, 20, 0, 22, 40, 22, 30, 6], light_sensor_ctl=True, filter_ctl=True, buzzer=True, child_lock=False, timer_mode=TimerMode.Schedule, timer=60, schedule="A012345A012345A012345A01", ) with pytest.raises(ValueError): await client.set_state(filter_life=-1) with pytest.raises(ValueError): await client.set_state(filter_life=525601) with pytest.raises(ValueError): await client.set_state(filter_timer=-1) with pytest.raises(ValueError): await client.set_state(filter_timer=525601) with pytest.raises(ValueError): await client.set_state(color=[]) with pytest.raises(ValueError): await client.set_state(color=[41, -1, 20, 0, 22, 40, 22, 30, 6]) with pytest.raises(ValueError): await client.set_state(timer=-1) with pytest.raises(ValueError): await client.set_state(timer=1441) with pytest.raises(ValueError): await client.set_state(schedule="A12") with pytest.raises(ValueError): await client.set_state(schedule="ABC6789AAAAAAAAAAAAAAAAA") rabbit-air-python-rabbitair-ffc5866/tests/test_response.py000066400000000000000000000011001421250042300240250ustar00rootroot00000000000000"""Test response parsing.""" import pytest from rabbitair import Model, Quality, State def test_state_invalid_value() -> None: """Test invalid value in state response.""" state = State({"mode": 10}) assert repr(state) with pytest.raises(ValueError): assert state.mode is not None def test_state_biogs_case() -> None: """This case is only defined in the protocol specification, but will never occur in real life.""" state = State({"model": 2, "quality": 1}) assert state.model is Model.BioGS assert state.quality is Quality.Lowest rabbit-air-python-rabbitair-ffc5866/tox.ini000066400000000000000000000005731421250042300207440ustar00rootroot00000000000000# tox (https://tox.readthedocs.io/) is a tool for running tests # in multiple virtualenvs. This configuration file will run the # test suite on all supported python versions. To use it, "pip install tox" # and then run "tox" from this directory. [tox] envlist = py37, py38, py39, py310 isolated_build = True [testenv] deps = pytest pytest-asyncio commands = pytest