././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/LICENSE0000644000000000000000000010451314265350055012017 0ustar00 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/LICENSE.md0000644000000000000000000010414514265350055012417 0ustar00# GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 [Free Software Foundation, Inc.](http://fsf.org/) Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. ## Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: 1. assert copyright on the software, and 2. offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. ## TERMS AND CONDITIONS ### 0. Definitions. *This License* refers to version 3 of the GNU General Public License. *Copyright* also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. *The Program* refers to any copyrightable work licensed under this License. Each licensee is addressed as *you*. *Licensees* and *recipients* may be individuals or organizations. To *modify* a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a *modified version* of the earlier work or a work *based on* the earlier work. A *covered work* means either the unmodified Program or a work based on the Program. To *propagate* a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To *convey* a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays *Appropriate Legal Notices* to the extent that it includes a convenient and prominently visible feature that 1. displays an appropriate copyright notice, and 2. tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. ### 1. Source Code. The *source code* for a work means the preferred form of the work for making modifications to it. *Object code* means any non-source form of a work. A *Standard Interface* means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The *System Libraries* of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A *Major Component*, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The *Corresponding Source* for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. ### 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. ### 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. ### 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to *keep intact all notices*. - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an *aggregate* if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. ### 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either 1. a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or 2. access to copy the Corresponding Source from a network server at no charge. - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A *User Product* is either 1. a *consumer product*, which means any tangible personal property which is normally used for personal, family, or household purposes, or 2. anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, *normally used* refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. *Installation Information* for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. ### 7. Additional Terms. *Additional permissions* are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered *further restrictions* within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. ### 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated - a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and - b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. ### 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. ### 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An *entity transaction* is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. ### 11. Patents. A *contributor* is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's *contributor version*. A contributor's *essential patent claims* are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, *control* includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a *patent license* is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To *grant* such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either 1. cause the Corresponding Source to be so available, or 2. arrange to deprive yourself of the benefit of the patent license for this particular work, or 3. arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. *Knowingly relying* means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is *discriminatory* if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license - a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or - b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. ### 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. ### 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. ### 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License *or any later version* applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. ### 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. ### 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. ### 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. ## END OF TERMS AND CONDITIONS ### ### How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the *copyright* line and a pointer to where the full notice is found. Python library & console tool for controlling Xiaomi smart appliances Copyright (C) 2017 Teemu R. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: python-miio Copyright (C) 2017 Teemu R. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w` and `show c` should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an *about box*. You should also get your employer (if you work as a programmer) or school, if any, to sign a *copyright disclaimer* for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see [http://www.gnu.org/licenses/](http://www.gnu.org/licenses/). The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read [http://www.gnu.org/philosophy/why-not-lgpl.html](http://www.gnu.org/philosophy/why-not-lgpl.html). ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/README.rst0000644000000000000000000002440614265350055012503 0ustar00python-miio =========== |Chat| |PyPI version| |PyPI downloads| |Build Status| |Coverage Status| |Docs| |Black| This library (and its accompanying cli tool) can be used to interface with devices using Xiaomi's `miIO `__ and MIoT protocols. Getting started --------------- If you already have a token for your device and the device type, you can directly start using `miiocli` tool. If you don't have a token for your device, refer to `Getting started `__ section of `the manual `__ for instructions how to obtain it. The `miiocli` is the main way to execute commands from command line. You can always use `--help` to get more information about the available commands. For example, executing it without any extra arguments will print out options and available commands:: $ miiocli --help Usage: miiocli [OPTIONS] COMMAND [ARGS]... Options: -d, --debug -o, --output [default|json|json_pretty] --help Show this message and exit. Commands: airconditioningcompanion .. You can get some information from any miIO/MIoT device, including its device model, using the `info` command:: miiocli device --ip --token info Model: some.device.model1 Hardware version: esp8285 Firmware version: 1.0.1_0012 Network: {'localIp': '', 'mask': '255.255.255.0', 'gw': ''} AP: {'rssi': -73, 'ssid': '', 'primary': 11, 'bssid': ''} Different devices are supported by their corresponding modules (e.g., `roborockvacuum` or `fan`). You can get the list of available commands for any given module by passing `--help` argument to it:: $ miiocli roborockvacuum --help Usage: miiocli roborockvacuum [OPTIONS] COMMAND [ARGS]... Options: --ip TEXT [required] --token TEXT [required] --id-file FILE --help Show this message and exit. Commands: add_timer Add a timer. .. Each command invocation will automatically detect the device model necessary for some actions by querying the device. You can avoid this by specifying the model manually:: miiocli roborockvacuum --model roborock.vacuum.s5 --ip --token start API usage --------- All functionality is accessible through the `miio` module:: from miio import RoborockVacuum vac = RoborockVacuum("", "") vac.start() Each separate device type inherits from `miio.Device` (and in case of MIoT devices, `miio.MiotDevice`) which provides a common API. Each command invocation will automatically detect (and cache) the device model necessary for some actions by querying the device. You can avoid this by specifying the model manually:: from miio import RoborockVacuum vac = RoborockVacuum("", "", model="roborock.vacuum.s5") Please refer to `API documentation `__ for more information. Troubleshooting --------------- You can find some solutions for the most common problems can be found in `Troubleshooting `__ section. If you have any questions, or simply want to join up for a chat, check `our Matrix room `__. Contributing ------------ We welcome all sorts of contributions from patches to add improvements or fixing bugs to improving the documentation. To ease the process of setting up a development environment we have prepared `a short guide `__ for getting you started. Supported devices ----------------- - Xiaomi Mi Robot Vacuum V1, S4, S4 MAX, S5, S5 MAX, S6 Pure, M1S, S7 - Xiaomi Mi Home Air Conditioner Companion - Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5) - Xiaomi Mi Air Purifier 2, 3H, 3C, 4, Pro, Pro H, 4 Pro (zhimi.airpurifier.m2, mb3, mb4, mb5, v7, vb2, va2), 4 Lite - Xiaomi Mi Air (Purifier) Dog X3, X5, X7SM (airdog.airpurifier.x3, airdog.airpurifier.x5, airdog.airpurifier.x7sm) - Xiaomi Mi Air Humidifier - Smartmi Air Purifier - Xiaomi Aqara Camera - Xiaomi Aqara Gateway (basic implementation, alarm, lights) - Xiaomi Mijia 360 1080p - Xiaomi Mijia STYJ02YM (Viomi) - Xiaomi Mijia 1C STYTJ01ZHM (Dreame) - Dreame F9, D9, Z10 Pro - Xiaomi Mi Home (Mijia) G1 Robot Vacuum Mop MJSTG1 - Xiaomi Roidmi Eve - Xiaomi Mi Smart WiFi Socket - Xiaomi Chuangmi Plug V1 (1 Socket, 1 USB Port) - Xiaomi Chuangmi Plug V3 (1 Socket, 2 USB Ports) - Xiaomi Smart Power Strip V1 and V2 (WiFi, 6 Ports) - Xiaomi Philips Eyecare Smart Lamp 2 - Xiaomi Philips RW Read (philips.light.rwread) - Xiaomi Philips LED Ceiling Lamp - Xiaomi Philips LED Ball Lamp (philips.light.bulb) - Xiaomi Philips LED Ball Lamp White (philips.light.hbulb) - Xiaomi Philips Zhirui Smart LED Bulb E14 Candle Lamp - Xiaomi Philips Zhirui Bedroom Smart Lamp - Huayi Huizuo Lamps - Xiaomi Universal IR Remote Controller (Chuangmi IR) - Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, ZA5 1C, P5, P9, P10, P11, P33 - Xiaomi Rosou SS4 Ventilator (leshow.fan.ss4) - Xiaomi Mi Air Humidifier V1, CA1, CA4, CB1, MJJSQ, JSQ, JSQ1, JSQ001 - Xiaomi Mi Water Purifier (Basic support: Turn on & off) - Xiaomi Mi Water Purifier D1, C1 (Triple Setting) - Xiaomi PM2.5 Air Quality Monitor V1, B1, S1 - Xiaomi Smart WiFi Speaker - Xiaomi Mi WiFi Repeater 2 - Xiaomi Mi Smart Rice Cooker - Xiaomi Smartmi Fresh Air System VA2 (zhimi.airfresh.va2), VA4 (zhimi.airfresh.va4), A1 (dmaker.airfresh.a1), T2017 (dmaker.airfresh.t2017) - Yeelight lights (basic support, we recommend using `python-yeelight `__) - Xiaomi Mi Air Dehumidifier - Xiaomi Tinymu Smart Toilet Cover - Xiaomi 16 Relays Module - Xiaomi Xiao AI Smart Alarm Clock - Smartmi Radiant Heater Smart Version (ZA1 version) - Xiaomi Mi Smart Space Heater - Xiaomiyoupin Curtain Controller (Wi-Fi) (lumi.curtain.hagl05) - Xiaomi Xiaomi Mi Smart Space Heater S (zhimi.heater.mc2) - Xiaomi Xiaomi Mi Smart Space Heater 1S (zhimi.heater.za2) - Yeelight Dual Control Module (yeelink.switch.sw1) - Scishare coffee maker (scishare.coffee.s1102) - Qingping Air Monitor Lite (cgllc.airm.cgdn1) - Xiaomi Walkingpad A1 (ksmb.walkingpad.v3) - Xiaomi Smart Pet Water Dispenser (mmgg.pet_waterer.s1, s4) - Xiaomi Mi Smart Humidifer S (jsqs, jsq5) - Xiaomi Mi Robot Vacuum Mop 2 (Pro+, Ultra) *Feel free to create a pull request to add support for new devices as well as additional features for supported devices.* Projects using this library --------------------------- This library is used by various projects to support MiIO/MiOT devices. If you are using this library for your project, feel free to open a PR to get it listed here! Home Assistant (official) ^^^^^^^^^^^^^^^^^^^^^^^^^ Home Assistant uses this library to support several platforms out-of-the-box. This list is incomplete as the platforms (in parentheses) may also support other devices listed above. - `Xiaomi Mi Robot Vacuum `__ (vacuum) - `Xiaomi Philips Light `__ (light) - `Xiaomi Mi Air Purifier and Air Humidifier `__ (fan) - `Xiaomi Smart WiFi Socket and Smart Power Strip `__ (switch) - `Xiaomi Universal IR Remote Controller `__ (remote) - `Xiaomi Mi Air Quality Monitor (PM2.5) `__ (sensor) - `Xiaomi Aqara Gateway Alarm `__ (alarm_control_panel) - `Xiaomi Mi WiFi Repeater 2 `__ (device_tracker) Home Assistant (custom) ^^^^^^^^^^^^^^^^^^^^^^^ - `Xiaomi Mi Home Air Conditioner Companion `__ - `Xiaomi Mi Smart Pedestal Fan `__ - `Xiaomi Mi Smart Rice Cooker `__ - `Xiaomi Raw Sensor `__ - `Xiaomi MIoT Devices `__ - `Xiaomi Miot Auto `__ Other related projects ---------------------- This is a list of other projects around the Xiaomi ecosystem that you can find interesting. Feel free to submit more related projects. - `dustcloud `__ (reverse engineering and rooting xiaomi devices) - `Valetudo `__ (cloud free vacuum firmware) - `micloud `__ (library to access xiaomi cloud services, can be used to obtain device tokens) - `micloudfaker `__ (dummy cloud server, can be used to fix powerstrip status requests when without internet access) - `Your project here? Feel free to open a PR! `__ .. |Chat| image:: https://img.shields.io/matrix/python-miio-chat:matrix.org :target: https://matrix.to/#/#python-miio-chat:matrix.org .. |PyPI version| image:: https://badge.fury.io/py/python-miio.svg :target: https://badge.fury.io/py/python-miio .. |PyPI downloads| image:: https://img.shields.io/pypi/dw/python-miio :target: https://pypi.org/project/python-miio/ .. |Build Status| image:: https://github.com/rytilahti/python-miio/actions/workflows/ci.yml/badge.svg :target: https://github.com/rytilahti/python-miio/actions/workflows/ci.yml .. |Coverage Status| image:: https://codecov.io/gh/rytilahti/python-miio/branch/master/graph/badge.svg?token=lYKWubxkLU :target: https://codecov.io/gh/rytilahti/python-miio .. |Docs| image:: https://readthedocs.org/projects/python-miio/badge/?version=latest :alt: Documentation status :target: https://python-miio.readthedocs.io/en/latest/?badge=latest .. |Black| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/__init__.py0000644000000000000000000000601714265350055014060 0ustar00# flake8: noqa try: # python 3.7 and earlier from importlib_metadata import version # type: ignore except ImportError: # python 3.8 and later from importlib.metadata import version # type: ignore # Library imports need to be on top to avoid problems with # circular dependencies. As these do not change that often # they can be marked to be skipped for isort runs. from miio.device import Device, DeviceStatus # isort: skip from miio.exceptions import DeviceError, DeviceException # isort: skip from miio.miot_device import MiotDevice # isort: skip from miio.deviceinfo import DeviceInfo # isort: skip # Integration imports from miio.airconditioner_miot import AirConditionerMiot from miio.airconditioningcompanion import ( AirConditioningCompanion, AirConditioningCompanionV3, ) from miio.airconditioningcompanionMCN import AirConditioningCompanionMcn02 from miio.airdehumidifier import AirDehumidifier from miio.airqualitymonitor import AirQualityMonitor from miio.airqualitymonitor_miot import AirQualityMonitorCGDN1 from miio.aqaracamera import AqaraCamera from miio.chuangmi_camera import ChuangmiCamera from miio.chuangmi_ir import ChuangmiIr from miio.chuangmi_plug import ChuangmiPlug from miio.cloud import CloudInterface from miio.cooker import Cooker from miio.curtain_youpin import CurtainMiot from miio.gateway import Gateway from miio.heater import Heater from miio.heater_miot import HeaterMiot from miio.huizuo import Huizuo, HuizuoLampFan, HuizuoLampHeater, HuizuoLampScene from miio.integrations.airpurifier import ( AirDogX3, AirFresh, AirFreshA1, AirFreshT2017, AirPurifier, AirPurifierMiot, ) from miio.integrations.fan import Fan, Fan1C, FanLeshow, FanMiot, FanP5, FanZA5 from miio.integrations.humidifier import ( AirHumidifier, AirHumidifierJsq, AirHumidifierJsqs, AirHumidifierMiot, AirHumidifierMjjsq, ) from miio.integrations.light import ( Ceil, PhilipsBulb, PhilipsEyecare, PhilipsMoonlight, PhilipsRwread, PhilipsWhiteBulb, Yeelight, ) from miio.integrations.petwaterdispenser import PetWaterDispenser from miio.integrations.vacuum import ( DreameVacuum, G1Vacuum, RoborockVacuum, RoidmiVacuumMiot, VacuumException, ViomiVacuum, ) from miio.integrations.vacuum.roborock.vacuumcontainers import ( CleaningDetails, CleaningSummary, ConsumableStatus, DNDStatus, Timer, VacuumStatus, ) from miio.powerstrip import PowerStrip from miio.protocol import Message, Utils from miio.push_server import EventInfo, PushServer from miio.pwzn_relay import PwznRelay from miio.scishare_coffeemaker import ScishareCoffee from miio.toiletlid import Toiletlid from miio.walkingpad import Walkingpad from miio.waterpurifier import WaterPurifier from miio.waterpurifier_yunmi import WaterPurifierYunmi from miio.wifirepeater import WifiRepeater from miio.wifispeaker import WifiSpeaker from miio.yeelight_dual_switch import YeelightDualControlModule from miio.discovery import Discovery __version__ = version("python-miio") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/airconditioner_miot.py0000644000000000000000000003652114265350055016365 0ustar00import enum import logging from datetime import timedelta from typing import Any, Dict import click from .click_common import EnumType, command, format_output from .exceptions import DeviceException from .miot_device import DeviceStatus, MiotDevice _LOGGER = logging.getLogger(__name__) SUPPORTED_MODELS = [ "xiaomi.aircondition.mc1", "xiaomi.aircondition.mc2", "xiaomi.aircondition.mc4", "xiaomi.aircondition.mc5", ] _MAPPING = { # Source http://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-mc4:1 # Air Conditioner (siid=2) "power": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 2}, "target_temperature": {"siid": 2, "piid": 4}, "eco": {"siid": 2, "piid": 7}, "heater": {"siid": 2, "piid": 9}, "dryer": {"siid": 2, "piid": 10}, "sleep_mode": {"siid": 2, "piid": 11}, # Fan Control (siid=3) "fan_speed": {"siid": 3, "piid": 2}, "vertical_swing": {"siid": 3, "piid": 4}, # Environment (siid=4) "temperature": {"siid": 4, "piid": 7}, # Alarm (siid=5) "buzzer": {"siid": 5, "piid": 1}, # Indicator Light (siid=6) "led": {"siid": 6, "piid": 1}, # Electricity (siid=8) "electricity": {"siid": 8, "piid": 1}, # Maintenance (siid=9) "clean": {"siid": 9, "piid": 1}, "running_duration": {"siid": 9, "piid": 5}, # Enhance (siid=10) "fan_speed_percent": {"siid": 10, "piid": 1}, "timer": {"siid": 10, "piid": 3}, } _MAPPINGS = {model: _MAPPING for model in SUPPORTED_MODELS} CLEANING_STAGES = [ "Stopped", "Condensing water", "Frosting the surface", "Defrosting the surface", "Drying", ] class AirConditionerMiotException(DeviceException): pass class CleaningStatus(DeviceStatus): def __init__(self, status: str): """Auto clean mode indicator. Value format: ,,, Integer 1: whether auto cleaning mode started. Integer 2: current progress in percent. Integer 3: which stage it is currently under (see CLEANING_STAGE list). Integer 4: if current operation could be cancelled. Example auto clean indicator 1: 0,100,0,1 indicates the auto clean mode has finished or not started yet. Example auto clean indicator 2: 1,22,1,1 indicates auto clean mode finished 22%, it is condensing water and can be cancelled. Example auto clean indicator 3: 1,72,4,0 indicates auto clean mode finished 72%, it is drying and cannot be cancelled. Only write 1 or 0 to it would start or abort the auto clean mode. """ self.status = [int(value) for value in status.split(",")] @property def cleaning(self) -> bool: return bool(self.status[0]) @property def progress(self) -> int: return int(self.status[1]) @property def stage(self) -> str: try: return CLEANING_STAGES[self.status[2]] except KeyError: return "Unknown stage" @property def cancellable(self) -> bool: return bool(self.status[3]) class OperationMode(enum.Enum): Cool = 2 Dry = 3 Fan = 4 Heat = 5 class FanSpeed(enum.Enum): Auto = 0 Level1 = 1 Level2 = 2 Level3 = 3 Level4 = 4 Level5 = 5 Level6 = 6 Level7 = 7 class TimerStatus(DeviceStatus): def __init__(self, status): """Countdown timer indicator. Value format: ,,, Integer 1: whether the timer is enabled. Integer 2: countdown timer setting value in minutes. Integer 3: the device would be powered on (1) or powered off (0) after timeout. Integer 4: the remaining countdown time in minutes. Example timer value 1: 1,120,0,103 indicates the device would be turned off after 120 minutes, remaining 103 minutes. Example timer value 2: 1,60,1,60 indicates the device would be turned on after 60 minutes, remaining 60 minutes. Example timer value 3: 0,0,0,0 indicates countdown timer not set. Write the first three integers would set the correct countdown timer. Also, if the countdown minutes set to 0, the timer would be disabled. """ self.status = [int(value) for value in status.split(",")] @property def enabled(self) -> bool: return bool(self.status[0]) @property def countdown(self) -> timedelta: return timedelta(minutes=self.status[1]) @property def power_on(self) -> bool: return bool(self.status[2]) @property def time_left(self) -> timedelta: return timedelta(minutes=self.status[3]) class AirConditionerMiotStatus(DeviceStatus): """Container for status reports from the air conditioner (MIoT).""" def __init__(self, data: Dict[str, Any]) -> None: """ Response (MIoT format) of a Mi Smart Air Conditioner A (xiaomi.aircondition.mc4) [ {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': False}, {'did': 'mode', 'siid': 2, 'piid': 2, 'code': 0, 'value': 2}, {'did': 'target_temperature', 'siid': 2, 'piid': 4, 'code': 0, 'value': 26.5}, {'did': 'eco', 'siid': 2, 'piid': 7, 'code': 0, 'value': False}, {'did': 'heater', 'siid': 2, 'piid': 9, 'code': 0, 'value': True}, {'did': 'dryer', 'siid': 2, 'piid': 10, 'code': 0, 'value': True}, {'did': 'sleep_mode', 'siid': 2, 'piid': 11, 'code': 0, 'value': False}, {'did': 'fan_speed', 'siid': 3, 'piid': 2, 'code': 0, 'value': 0}, {'did': 'vertical_swing', 'siid': 3, 'piid': 4, 'code': 0, 'value': True}, {'did': 'temperature', 'siid': 4, 'piid': 7, 'code': 0, 'value': 28.4}, {'did': 'buzzer', 'siid': 5, 'piid': 1, 'code': 0, 'value': False}, {'did': 'led', 'siid': 6, 'piid': 1, 'code': 0, 'value': False}, {'did': 'electricity', 'siid': 8, 'piid': 1, 'code': 0, 'value': 0.0}, {'did': 'clean', 'siid': 9, 'piid': 1, 'code': 0, 'value': '0,100,1,1'}, {'did': 'running_duration', 'siid': 9, 'piid': 5, 'code': 0, 'value': 151.0}, {'did': 'fan_speed_percent', 'siid': 10, 'piid': 1, 'code': 0, 'value': 101}, {'did': 'timer', 'siid': 10, 'piid': 3, 'code': 0, 'value': '0,0,0,0'} ] """ self.data = data @property def is_on(self) -> bool: """True if the device is turned on.""" return self.data["power"] @property def power(self) -> str: """Current power state.""" return "on" if self.is_on else "off" @property def mode(self) -> OperationMode: """Current operation mode.""" return OperationMode(self.data["mode"]) @property def target_temperature(self) -> float: """Target temperature in Celsius.""" return self.data["target_temperature"] @property def eco(self) -> bool: """True if ECO mode is on.""" return self.data["eco"] @property def heater(self) -> bool: """True if aux heat mode is on.""" return self.data["heater"] @property def dryer(self) -> bool: """True if aux dryer mode is on.""" return self.data["dryer"] @property def sleep_mode(self) -> bool: """True if sleep mode is on.""" return self.data["sleep_mode"] @property def fan_speed(self) -> FanSpeed: """Current Fan speed.""" return FanSpeed(self.data["fan_speed"]) @property def vertical_swing(self) -> bool: """True if vertical swing is on.""" return self.data["vertical_swing"] @property def temperature(self) -> float: """Current ambient temperature in Celsius.""" return self.data["temperature"] @property def buzzer(self) -> bool: """True if buzzer is on.""" return self.data["buzzer"] @property def led(self) -> bool: """True if LED is on.""" return self.data["led"] @property def electricity(self) -> float: """Power consumption accumulation in kWh.""" return self.data["electricity"] @property def clean(self) -> CleaningStatus: """Auto clean mode indicator.""" return CleaningStatus(self.data["clean"]) @property def total_running_duration(self) -> timedelta: """Total running duration in hours.""" return timedelta(hours=self.data["running_duration"]) @property def fan_speed_percent(self) -> int: """Current fan speed in percent.""" return self.data["fan_speed_percent"] @property def timer(self) -> TimerStatus: """Countdown timer indicator.""" return TimerStatus(self.data["timer"]) class AirConditionerMiot(MiotDevice): """Main class representing the air conditioner which uses MIoT protocol.""" _mappings = _MAPPINGS @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "Target Temperature: {result.target_temperature} ℃\n" "ECO Mode: {result.eco}\n" "Heater: {result.heater}\n" "Dryer: {result.dryer}\n" "Sleep Mode: {result.sleep_mode}\n" "Fan Speed: {result.fan_speed}\n" "Vertical Swing: {result.vertical_swing}\n" "Room Temperature: {result.temperature} ℃\n" "Buzzer: {result.buzzer}\n" "LED: {result.led}\n" "Electricity: {result.electricity}kWh\n" "Clean: {result.clean}\n" "Running Duration: {result.total_running_duration}\n" "Fan percent: {result.fan_speed_percent}\n" "Timer: {result.timer}\n", ) ) def status(self) -> AirConditionerMiotStatus: """Retrieve properties.""" return AirConditionerMiotStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting operation mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set operation mode.""" return self.set_property("mode", mode.value) @command( click.argument("target_temperature", type=float), default_output=format_output( "Setting target temperature to {target_temperature}" ), ) def set_target_temperature(self, target_temperature: float): """Set target temperature in Celsius.""" if ( target_temperature < 16.0 or target_temperature > 31.0 or target_temperature % 0.5 != 0 ): raise AirConditionerMiotException( "Invalid target temperature: %s" % target_temperature ) return self.set_property("target_temperature", target_temperature) @command( click.argument("eco", type=bool), default_output=format_output( lambda eco: "Turning on ECO mode" if eco else "Turning off ECO mode" ), ) def set_eco(self, eco: bool): """Turn ECO mode on/off.""" return self.set_property("eco", eco) @command( click.argument("heater", type=bool), default_output=format_output( lambda heater: "Turning on heater" if heater else "Turning off heater" ), ) def set_heater(self, heater: bool): """Turn aux heater mode on/off.""" return self.set_property("heater", heater) @command( click.argument("dryer", type=bool), default_output=format_output( lambda dryer: "Turning on dryer" if dryer else "Turning off dryer" ), ) def set_dryer(self, dryer: bool): """Turn aux dryer mode on/off.""" return self.set_property("dryer", dryer) @command( click.argument("sleep_mode", type=bool), default_output=format_output( lambda sleep_mode: "Turning on sleep mode" if sleep_mode else "Turning off sleep mode" ), ) def set_sleep_mode(self, sleep_mode: bool): """Turn sleep mode on/off.""" return self.set_property("sleep_mode", sleep_mode) @command( click.argument("fan_speed", type=EnumType(FanSpeed)), default_output=format_output("Setting fan speed to {fan_speed}"), ) def set_fan_speed(self, fan_speed: FanSpeed): """Set fan speed.""" return self.set_property("fan_speed", fan_speed.value) @command( click.argument("vertical_swing", type=bool), default_output=format_output( lambda vertical_swing: "Turning on vertical swing" if vertical_swing else "Turning off vertical swing" ), ) def set_vertical_swing(self, vertical_swing: bool): """Turn vertical swing on/off.""" return self.set_property("vertical_swing", vertical_swing) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" return self.set_property("led", led) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.set_property("buzzer", buzzer) @command( click.argument("percent", type=int), default_output=format_output("Setting fan percent to {percent}%"), ) def set_fan_speed_percent(self, fan_speed_percent): """Set fan speed in percent, should be between 1 to 100 or 101(auto).""" if fan_speed_percent < 1 or fan_speed_percent > 101: raise AirConditionerMiotException( "Invalid fan percent: %s" % fan_speed_percent ) return self.set_property("fan_speed_percent", fan_speed_percent) @command( click.argument("minutes", type=int), click.argument("delay_on", type=bool), default_output=format_output( lambda minutes, delay_on: "Setting timer to delay on after " + str(minutes) + " minutes" if delay_on else "Setting timer to delay off after " + str(minutes) + " minutes" ), ) def set_timer(self, minutes, delay_on): """Set countdown timer minutes and if it would be turned on after timeout. Set minutes to 0 would disable the timer. """ return self.set_property( "timer", ",".join(["1", str(minutes), str(int(delay_on))]) ) @command( click.argument("clean", type=bool), default_output=format_output( lambda clean: "Begin auto cleanning" if clean else "Abort auto cleaning" ), ) def set_clean(self, clean): """Start or abort clean mode.""" return self.set_property("clean", str(int(clean))) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/airconditioningcompanion.py0000644000000000000000000003415114265350055017405 0ustar00import enum import logging from typing import Optional import click from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) MODEL_ACPARTNER_V1 = "lumi.acpartner.v1" MODEL_ACPARTNER_V2 = "lumi.acpartner.v2" MODEL_ACPARTNER_V3 = "lumi.acpartner.v3" MODELS_SUPPORTED = [MODEL_ACPARTNER_V1, MODEL_ACPARTNER_V2, MODEL_ACPARTNER_V3] class AirConditioningCompanionException(DeviceException): pass class OperationMode(enum.Enum): Heat = 0 Cool = 1 Auto = 2 Dehumidify = 3 Ventilate = 4 class FanSpeed(enum.Enum): Low = 0 Medium = 1 High = 2 Auto = 3 class SwingMode(enum.Enum): On = "0" Off = "1" Unknown2 = "2" Unknown7 = "7" ChigoOn = "C" ChigoOff = "D" class Power(enum.Enum): On = 1 Off = 0 class Led(enum.Enum): On = "0" Off = "A" STORAGE_SLOT_ID = 30 POWER_OFF = "off" # Command templates per model number (f.e. 0180111111) # [po], [mo], [wi], [sw], [tt], [tt1], [tt4] and [tt7] are markers which will be replaced DEVICE_COMMAND_TEMPLATES = { "fallback": {"deviceType": "generic", "base": "[po][mo][wi][sw][tt][li]"}, "0100010727": { "deviceType": "gree_2", "base": "[po][mo][wi][sw][tt]1100190[tt1]205002102000[tt7]0190[tt1]207002000000[tt4]", "off": "01011101004000205002112000D04000207002000000A0", }, "0100004795": { "deviceType": "gree_8", "base": "[po][mo][wi][sw][tt][li]10009090000500", }, "0180333331": {"deviceType": "haier_1", "base": "[po][mo][wi][sw][tt]1"}, "0180666661": {"deviceType": "aux_1", "base": "[po][mo][wi][sw][tt]1"}, "0180777771": {"deviceType": "chigo_1", "base": "[po][mo][wi][sw][tt]1"}, } class AirConditioningCompanionStatus(DeviceStatus): """Container for status reports of the Xiaomi AC Companion.""" def __init__(self, data): """Device model: lumi.acpartner.v2. Response of "get_model_and_state": ['010500978022222102', '010201190280222221', '2'] AC turned on by set_power=on: ['010507950000257301', '011001160100002573', '807'] AC turned off by set_power=off: ['010507950000257301', '010001160100002573', '6'] ... ['010507950000257301', '010001160100002573', '1'] Example data payload: { 'model_and_state': ['010500978022222102', '010201190280222221', '2'], 'power_socket': 'on' } """ self.data = data self.model = data["model_and_state"][0] self.state = data["model_and_state"][1] @property def load_power(self) -> int: """Current power load of the air conditioner.""" return int(self.data["model_and_state"][2]) @property def power_socket(self) -> Optional[str]: """Current socket power state.""" if "power_socket" in self.data and self.data["power_socket"] is not None: return self.data["power_socket"] return None @property def air_condition_model(self) -> bytes: """Model of the air conditioner.""" return bytes.fromhex(self.model) @property def model_format(self) -> int: """Version number of the model format.""" return self.air_condition_model[0] @property def device_type(self) -> int: """Device type identifier.""" return self.air_condition_model[1] @property def air_condition_brand(self) -> int: """Brand of the air conditioner. Known brand ids are 0x0182, 0x0097, 0x0037, 0x0202, 0x02782, 0x0197, 0x0192. """ return int(self.air_condition_model[2:4].hex(), 16) @property def air_condition_remote(self) -> int: """Remote id. Known remote ids: * 0x80111111, 0x80111112 (brand: 0x0182) * 0x80222221 (brand: 0x0097) * 0x80333331 (brand: 0x0037) * 0x80444441 (brand: 0x0202) * 0x80555551 (brand: 0x2782) * 0x80777771 (brand: 0x0197) * 0x80666661 (brand: 0x0192) """ return int(self.air_condition_model[4:8].hex(), 16) @property def state_format(self) -> int: """Version number of the state format. Known values are: 1, 2, 3 """ return int(self.air_condition_model[8]) @property def air_condition_configuration(self) -> int: return self.state[2:10] @property def power(self) -> str: """Current power state.""" return "on" if int(self.state[2:3]) == Power.On.value else "off" @property def led(self) -> Optional[bool]: """Current LED state.""" state = self.state[8:9] if state == Led.On.value: return True if state == Led.Off.value: return False _LOGGER.info("Unsupported LED state: %s", state) return None @property def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property def target_temperature(self) -> Optional[int]: """Target temperature.""" try: return int(self.state[6:8], 16) except TypeError: return None @property def swing_mode(self) -> Optional[SwingMode]: """Current swing mode.""" try: mode = self.state[5:6] return SwingMode(mode) except TypeError: return None @property def fan_speed(self) -> Optional[FanSpeed]: """Current fan speed.""" try: speed = int(self.state[4:5]) return FanSpeed(speed) except TypeError: return None @property def mode(self) -> Optional[OperationMode]: """Current operation mode.""" try: mode = int(self.state[3:4]) return OperationMode(mode) except TypeError: return None class AirConditioningCompanion(Device): """Main class representing Xiaomi Air Conditioning Companion V1 and V2.""" _supported_models = MODELS_SUPPORTED def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, model: str = MODEL_ACPARTNER_V2, ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover, model=model) if self.model not in MODELS_SUPPORTED: _LOGGER.error( "Device model %s unsupported. Falling back to %s.", model, self.model ) @command( default_output=format_output( "", "Power: {result.power}\n" "Load power: {result.load_power}\n" "Air Condition model: {result.air_condition_model}\n" "LED: {result.led}\n" "Target temperature: {result.target_temperature} °C\n" "Swing mode: {result.swing_mode}\n" "Fan speed: {result.fan_speed}\n" "Mode: {result.mode}\n", ) ) def status(self) -> AirConditioningCompanionStatus: """Return device status.""" status = self.send("get_model_and_state") return AirConditioningCompanionStatus(dict(model_and_state=status)) @command(default_output=format_output("Powering the air condition on")) def on(self): """Turn the air condition on by infrared.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering the air condition off")) def off(self): """Turn the air condition off by infrared.""" return self.send("set_power", ["off"]) @command( click.argument("slot", type=int), default_output=format_output( "Learning infrared command into storage slot {slot}" ), ) def learn(self, slot: int = STORAGE_SLOT_ID): """Learn an infrared command.""" return self.send("start_ir_learn", [slot]) @command(default_output=format_output("Reading learned infrared commands")) def learn_result(self): """Read the learned command.""" return self.send("get_ir_learn_result") @command( click.argument("slot", type=int), default_output=format_output( "Learning infrared command into storage slot {slot} stopped" ), ) def learn_stop(self, slot: int = STORAGE_SLOT_ID): """Stop learning of a infrared command.""" return self.send("end_ir_learn", [slot]) @command( click.argument("model", type=str), click.argument("code", type=str), default_output=format_output("Sending the supplied infrared command"), ) def send_ir_code(self, model: str, code: str, slot: int = 0): """Play a captured command. :param str model: Air condition model :param str code: Command to execute :param int slot: Unknown internal register or slot """ try: model_bytes = bytes.fromhex(model) except ValueError: raise AirConditioningCompanionException( "Invalid model. A hexadecimal string must be provided" ) try: code_bytes = bytes.fromhex(code) except ValueError: raise AirConditioningCompanionException( "Invalid code. A hexadecimal string must be provided" ) if slot < 0 or slot > 134: raise AirConditioningCompanionException("Invalid slot: %s" % slot) slot_bytes = bytes([121 + slot]) # FE + 0487 + 00007145 + 9470 + 1FFF + 7F + FF + 06 + 0042 + 27 + 4E + 0025002D008500AC01... command_bytes = ( code_bytes[0:1] + model_bytes[2:8] + b"\x94\x70\x1F\xFF" + slot_bytes + b"\xFF" + code_bytes[13:16] + b"\x27" ) checksum = sum(command_bytes) & 0xFF command_bytes = command_bytes + bytes([checksum]) + code_bytes[18:] return self.send("send_ir_code", [command_bytes.hex().upper()]) @command( click.argument("command", type=str), default_output=format_output("Sending a command to the air conditioner"), ) def send_command(self, command: str): """Send a command to the air conditioner. :param str command: Command to execute """ return self.send("send_cmd", [str(command)]) @command( click.argument("model", type=str), click.argument("power", type=EnumType(Power)), click.argument("operation_mode", type=EnumType(OperationMode)), click.argument("target_temperature", type=int), click.argument("fan_speed", type=EnumType(FanSpeed)), click.argument("swing_mode", type=EnumType(SwingMode)), click.argument("led", type=EnumType(Led)), default_output=format_output("Sending a configuration to the air conditioner"), ) def send_configuration( self, model: str, power: Power, operation_mode: OperationMode, target_temperature: int, fan_speed: FanSpeed, swing_mode: SwingMode, led: Led, ): prefix = str(model[0:2] + model[8:16]) suffix = model[-1:] # Static turn off command available? if ( (power is Power.Off) and (prefix in DEVICE_COMMAND_TEMPLATES) and (POWER_OFF in DEVICE_COMMAND_TEMPLATES[prefix]) ): return self.send_command( prefix + DEVICE_COMMAND_TEMPLATES[prefix][POWER_OFF] ) if prefix in DEVICE_COMMAND_TEMPLATES: configuration = prefix + DEVICE_COMMAND_TEMPLATES[prefix]["base"] else: configuration = prefix + DEVICE_COMMAND_TEMPLATES["fallback"]["base"] configuration = configuration.replace("[po]", str(power.value)) configuration = configuration.replace("[mo]", str(operation_mode.value)) configuration = configuration.replace("[wi]", str(fan_speed.value)) configuration = configuration.replace("[sw]", str(swing_mode.value)) configuration = configuration.replace("[tt]", format(target_temperature, "X")) configuration = configuration.replace("[li]", str(led.value)) temperature = format((1 + target_temperature - 17) % 16, "X") configuration = configuration.replace("[tt1]", temperature) temperature = format((4 + target_temperature - 17) % 16, "X") configuration = configuration.replace("[tt4]", temperature) temperature = format((7 + target_temperature - 17) % 16, "X") configuration = configuration.replace("[tt7]", temperature) configuration = configuration + suffix return self.send_command(configuration) class AirConditioningCompanionV3(AirConditioningCompanion): def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, ) -> None: super().__init__( ip, token, start_id, debug, lazy_discover, model=MODEL_ACPARTNER_V3 ) @command(default_output=format_output("Powering socket on")) def socket_on(self): """Socket power on.""" return self.send("toggle_plug", ["on"]) @command(default_output=format_output("Powering socket off")) def socket_off(self): """Socket power off.""" return self.send("toggle_plug", ["off"]) @command( default_output=format_output( "", "Power: {result.power}\n" "Power socket: {result.power_socket}\n" "Load power: {result.load_power}\n" "Air Condition model: {result.air_condition_model}\n" "LED: {result.led}\n" "Target temperature: {result.target_temperature} °C\n" "Swing mode: {result.swing_mode}\n" "Fan speed: {result.fan_speed}\n" "Mode: {result.mode}\n", ) ) def status(self) -> AirConditioningCompanionStatus: """Return device status.""" status = self.send("get_model_and_state") power_socket = self.send("get_device_prop", ["lumi.0", "plug_state"]) return AirConditioningCompanionStatus( dict(model_and_state=status, power_socket=power_socket[0]) ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/airconditioningcompanionMCN.py0000644000000000000000000001062314265350055017741 0ustar00import enum import logging import random from typing import Any, Optional from .click_common import command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) MODEL_ACPARTNER_MCN02 = "lumi.acpartner.mcn02" class AirConditioningCompanionException(DeviceException): pass class OperationMode(enum.Enum): Cool = "cool" Heat = "heat" Auto = "auto" Ventilate = "wind" Dehumidify = "dry" class FanSpeed(enum.Enum): Auto = "auto_fan" Low = "small_fan" Medium = "medium_fan" High = "large_fan" class SwingMode(enum.Enum): On = "on" Off = "off" class AirConditioningCompanionStatus(DeviceStatus): """Container for status reports of the Xiaomi AC Companion.""" def __init__(self, data): """Status constructor. Example response (lumi.acpartner.mcn02): * ['power', 'mode', 'tar_temp', 'fan_level', 'ver_swing', 'load_power'] * ['on', 'dry', 16, 'small_fan', 'off', 84.0] """ self.data = data @property def load_power(self) -> int: """Current power load of the air conditioner.""" return int(self.data[-1]) @property def power(self) -> str: """Current power state.""" return self.data[0] @property def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property def mode(self) -> Optional[OperationMode]: """Current operation mode.""" try: mode = self.data[1] return OperationMode(mode) except TypeError: return None @property def target_temperature(self) -> Optional[int]: """Target temperature.""" try: return self.data[2] except TypeError: return None @property def fan_speed(self) -> Optional[FanSpeed]: """Current fan speed.""" try: speed = self.data[3] return FanSpeed(speed) except TypeError: return None @property def swing_mode(self) -> Optional[SwingMode]: """Current swing mode.""" try: mode = self.data[4] return SwingMode(mode) except TypeError: return None class AirConditioningCompanionMcn02(Device): """Main class representing Xiaomi Air Conditioning Companion V1 and V2.""" _supported_models = [MODEL_ACPARTNER_MCN02] def __init__( self, ip: str = None, token: str = None, start_id: int = None, debug: int = 0, lazy_discover: bool = True, model: str = MODEL_ACPARTNER_MCN02, ) -> None: if start_id is None: start_id = random.randint(0, 999) # nosec super().__init__(ip, token, start_id, debug, lazy_discover, model=model) if model != MODEL_ACPARTNER_MCN02: _LOGGER.error( "Device model %s unsupported. Please use AirConditioningCompanion", model, ) @command( default_output=format_output( "", "Power: {result.power}\n" "Load power: {result.load_power}\n" "Target temperature: {result.target_temperature} °C\n" "Swing mode: {result.swing_mode}\n" "Fan speed: {result.fan_speed}\n" "Mode: {result.mode}\n", ) ) def status(self) -> AirConditioningCompanionStatus: """Return device status.""" data = self.send( "get_prop", ["power", "mode", "tar_temp", "fan_level", "ver_swing", "load_power"], ) return AirConditioningCompanionStatus(data) @command(default_output=format_output("Powering the air condition on")) def on(self): """Turn the air condition on by infrared.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering the air condition off")) def off(self): """Turn the air condition off by infrared.""" return self.send("set_power", ["off"]) @command( default_output=format_output("Sending a command to the air conditioner"), ) def send_command(self, command: str, parameters: Any = None) -> Any: """Send a command to the air conditioner. :param str command: Command to execute """ return self.send(command, parameters) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/airdehumidifier.py0000644000000000000000000002000414265350055015443 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from .click_common import EnumType, command, format_output from .device import Device, DeviceInfo, DeviceStatus from .exceptions import DeviceError, DeviceException _LOGGER = logging.getLogger(__name__) MODEL_DEHUMIDIFIER_V1 = "nwt.derh.wdh318efw1" AVAILABLE_PROPERTIES = { MODEL_DEHUMIDIFIER_V1: [ "on_off", "mode", "fan_st", "buzzer", "led", "child_lock", "humidity", "temp", "compressor_status", "fan_speed", "tank_full", "defrost_status", "alarm", "auto", ] } class AirDehumidifierException(DeviceException): pass class OperationMode(enum.Enum): On = "on" Auto = "auto" DryCloth = "dry_cloth" class FanSpeed(enum.Enum): Sleep = 0 Low = 1 Medium = 2 High = 3 Strong = 4 class AirDehumidifierStatus(DeviceStatus): """Container for status reports from the air dehumidifier.""" def __init__(self, data: Dict[str, Any], device_info: DeviceInfo) -> None: """Response of a Air Dehumidifier (nwt.derh.wdh318efw1): {'on_off': 'on', 'mode': 'auto', 'fan_st': 2, 'buzzer': 'off', 'led': 'on', 'child_lock': 'off', 'humidity': 47, 'temp': 34, 'compressor_status': 'off', 'fan_speed': 0, 'tank_full': 'off', 'defrost_status': 'off, 'alarm': 'ok','auto': 50} """ self.data = data self.device_info = device_info @property def power(self) -> str: """Power state.""" return self.data["on_off"] @property def is_on(self) -> bool: """True if device is turned on.""" return self.power == "on" @property def mode(self) -> OperationMode: """Operation mode. Can be either on, auth or dry_cloth. """ return OperationMode(self.data["mode"]) @property def temperature(self) -> Optional[float]: """Current temperature, if available.""" if "temp" in self.data and self.data["temp"] is not None: return self.data["temp"] return None @property def humidity(self) -> int: """Current humidity.""" return self.data["humidity"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] == "on" @property def led(self) -> bool: """LED brightness if available.""" return self.data["led"] == "on" @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["child_lock"] == "on" @property def target_humidity(self) -> Optional[int]: """Target humiditiy. Can be either 40, 50, 60 percent. """ if "auto" in self.data and self.data["auto"] is not None: return self.data["auto"] return None @property def fan_speed(self) -> Optional[FanSpeed]: """Current fan speed.""" if "fan_speed" in self.data and self.data["fan_speed"] is not None: return FanSpeed(self.data["fan_speed"]) return None @property def tank_full(self) -> bool: """The remaining amount of water in percent.""" return self.data["tank_full"] == "on" @property def compressor_status(self) -> bool: """Compressor status.""" return self.data["compressor_status"] == "on" @property def defrost_status(self) -> bool: """Defrost status.""" return self.data["defrost_status"] == "on" @property def fan_st(self) -> int: """Fan st.""" return self.data["fan_st"] @property def alarm(self) -> str: """Alarm.""" return self.data["alarm"] class AirDehumidifier(Device): """Implementation of Xiaomi Mi Air Dehumidifier.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "Temperature: {result.temperature} °C\n" "Humidity: {result.humidity} %\n" "Buzzer: {result.buzzer}\n" "LED : {result.led}\n" "Child lock: {result.child_lock}\n" "Target humidity: {result.target_humidity} %\n" "Fan speed: {result.fan_speed}\n" "Tank Full: {result.tank_full}\n" "Compressor Status: {result.compressor_status}\n" "Defrost Status: {result.defrost_status}\n" "Fan st: {result.fan_st}\n" "Alarm: {result.alarm}\n", ) ) def status(self) -> AirDehumidifierStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_DEHUMIDIFIER_V1] ) values = self.get_properties(properties, max_properties=1) return AirDehumidifierStatus( defaultdict(lambda: None, zip(properties, values)), self.info() ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" try: return self.send("set_mode", [mode.value]) except DeviceError as error: # {'code': -6011, 'message': 'device_poweroff'} if error.code == -6011: self.on() return self.send("set_mode", [mode.value]) raise @command( click.argument("fan_speed", type=EnumType(FanSpeed)), default_output=format_output("Setting fan level to {fan_speed}"), ) def set_fan_speed(self, fan_speed: FanSpeed): """Set the fan speed.""" try: return self.send("set_fan_level", [fan_speed.value]) except DeviceError as ex: if ex.code == -10000: raise AirDehumidifierException( "Unable to set fan speed, this can happen if device is turned off." ) from ex raise @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" if led: return self.send("set_led", ["on"]) else: return self.send("set_led", ["off"]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if buzzer: return self.send("set_buzzer", ["on"]) else: return self.send("set_buzzer", ["off"]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if lock: return self.send("set_child_lock", ["on"]) else: return self.send("set_child_lock", ["off"]) @command( click.argument("humidity", type=int), default_output=format_output("Setting target humidity to {humidity}"), ) def set_target_humidity(self, humidity: int): """Set the auto target humidity.""" if humidity not in [40, 50, 60]: raise AirDehumidifierException( "Invalid auto target humidity: %s" % humidity ) return self.send("set_auto", [humidity]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/airqualitymonitor.py0000644000000000000000000002101314265350055016106 0ustar00import logging from collections import defaultdict from typing import Optional import click from .click_common import command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) MODEL_AIRQUALITYMONITOR_V1 = "zhimi.airmonitor.v1" MODEL_AIRQUALITYMONITOR_B1 = "cgllc.airmonitor.b1" MODEL_AIRQUALITYMONITOR_S1 = "cgllc.airmonitor.s1" AVAILABLE_PROPERTIES_COMMON = [ "power", "aqi", "battery", "usb_state", "time_state", "night_state", "night_beg_time", "night_end_time", "sensor_state", ] AVAILABLE_PROPERTIES_B1 = ["co2e", "humidity", "pm25", "temperature", "tvoc"] AVAILABLE_PROPERTIES_S1 = ["battery", "co2", "humidity", "pm25", "temperature", "tvoc"] AVAILABLE_PROPERTIES = { MODEL_AIRQUALITYMONITOR_V1: AVAILABLE_PROPERTIES_COMMON, MODEL_AIRQUALITYMONITOR_B1: AVAILABLE_PROPERTIES_B1, MODEL_AIRQUALITYMONITOR_S1: AVAILABLE_PROPERTIES_S1, } class AirQualityMonitorException(DeviceException): pass class AirQualityMonitorStatus(DeviceStatus): """Container of air quality monitor status.""" def __init__(self, data): """Response of a Xiaomi Air Quality Monitor (zhimi.airmonitor.v1): {'power': 'on', 'aqi': 34, 'battery': 100, 'usb_state': 'off', 'time_state': 'on'} Response of a Xiaomi Air Quality Monitor (cgllc.airmonitor.b1): {'co2e': 1466, 'humidity': 59.79999923706055, 'pm25': 2, 'temperature': 19.799999237060547, 'temperature_unit': 'c', 'tvoc': 1.3948699235916138, 'tvoc_unit': 'mg_m3'} Response of a Xiaomi Air Quality Monitor (cgllc.airmonitor.s1): {'battery': 100, 'co2': 695, 'humidity': 62.1, 'pm25': 19.4, 'temperature': 27.4, 'tvoc': 254} """ self.data = data @property def power(self) -> Optional[str]: """Current power state.""" return self.data.get("power", None) @property def is_on(self) -> bool: """Return True if the device is turned on.""" return self.power == "on" @property def usb_power(self) -> Optional[bool]: """Return True if the device's usb is on.""" if "usb_state" in self.data and self.data["usb_state"] is not None: return self.data["usb_state"] == "on" return None @property def aqi(self) -> Optional[int]: """Air quality index value (0..600).""" return self.data.get("aqi", None) @property def battery(self) -> Optional[int]: """Current battery level (0..100).""" return self.data.get("battery", None) @property def display_clock(self) -> Optional[bool]: """Display a clock instead the AQI.""" if "time_state" in self.data and self.data["time_state"] is not None: return self.data["time_state"] == "on" return None @property def night_mode(self) -> Optional[bool]: """Return True if the night mode is on.""" if "night_state" in self.data and self.data["night_state"] is not None: return self.data["night_state"] == "on" return None @property def night_time_begin(self) -> Optional[str]: """Return the begin of the night time.""" return self.data.get("night_beg_time", None) @property def night_time_end(self) -> Optional[str]: """Return the end of the night time.""" return self.data.get("night_end_time", None) @property def sensor_state(self) -> Optional[str]: """Sensor state.""" return self.data.get("sensor_state", None) @property def co2(self) -> Optional[int]: """Return co2 value (400...9999ppm).""" return self.data.get("co2", None) @property def co2e(self) -> Optional[int]: """Return co2e value (400...9999ppm).""" return self.data.get("co2e", None) @property def humidity(self) -> Optional[float]: """Return humidity value (0...100%).""" return self.data.get("humidity", None) @property def pm25(self) -> Optional[float]: """Return pm2.5 value (0...999μg/m³).""" return self.data.get("pm25", None) @property def temperature(self) -> Optional[float]: """Return temperature value (-10...50°C).""" return self.data.get("temperature", None) @property def tvoc(self) -> Optional[int]: """Return tvoc value.""" return self.data.get("tvoc", None) class AirQualityMonitor(Device): """Xiaomi PM2.5 Air Quality Monitor.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "USB power: {result.usb_power}\n" "Battery: {result.battery}\n" "AQI: {result.aqi}\n" "Temperature: {result.temperature}\n" "Humidity: {result.humidity}\n" "CO2: {result.co2}\n" "CO2e: {result.co2e}\n" "PM2.5: {result.pm25}\n" "TVOC: {result.tvoc}\n" "Display clock: {result.display_clock}\n", ) ) def status(self) -> AirQualityMonitorStatus: """Return device status.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_AIRQUALITYMONITOR_V1] ) if self.model == MODEL_AIRQUALITYMONITOR_B1: values = self.send("get_air_data") else: values = self.send("get_prop", properties) properties_count = len(properties) values_count = len(values) if properties_count != values_count: _LOGGER.debug( "Count (%s) of requested properties does not match the " "count (%s) of received values.", properties_count, values_count, ) if ( self.model == MODEL_AIRQUALITYMONITOR_S1 or self.model == MODEL_AIRQUALITYMONITOR_B1 ): return AirQualityMonitorStatus(defaultdict(lambda: None, values)) else: return AirQualityMonitorStatus( defaultdict(lambda: None, zip(properties, values)) ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("display_clock", type=bool), default_output=format_output( lambda led: "Turning on display clock" if led else "Turning off display clock" ), ) def set_display_clock(self, display_clock: bool): """Enable/disable displaying a clock instead the AQI.""" if display_clock: self.send("set_time_state", ["on"]) else: self.send("set_time_state", ["off"]) @command( click.argument("auto_close", type=bool), default_output=format_output( lambda led: "Turning on auto close" if led else "Turning off auto close" ), ) def set_auto_close(self, auto_close: bool): """Purpose unknown.""" if auto_close: self.send("set_auto_close", ["on"]) else: self.send("set_auto_close", ["off"]) @command( click.argument("night_mode", type=bool), default_output=format_output( lambda led: "Turning on night mode" if led else "Turning off night mode" ), ) def set_night_mode(self, night_mode: bool): """Decrease the brightness of the display.""" if night_mode: self.send("set_night_state", ["on"]) else: self.send("set_night_state", ["off"]) @command( click.argument("begin_hour", type=int), click.argument("begin_minute", type=int), click.argument("end_hour", type=int), click.argument("end_minute", type=int), default_output=format_output( "Setting night time to {begin_hour}:{begin_minute} - {end_hour}:{end_minute}" ), ) def set_night_time( self, begin_hour: int, begin_minute: int, end_hour: int, end_minute: int ): """Enable night mode daily at bedtime.""" begin = begin_hour * 3600 + begin_minute * 60 end = end_hour * 3600 + end_minute * 60 if begin < 0 or begin > 86399 or end < 0 or end > 86399: AirQualityMonitorException("Begin or/and end time invalid.") self.send("set_night_time", [begin, end]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/airqualitymonitor_miot.py0000644000000000000000000001667614265350055017161 0ustar00import enum import logging import click from .click_common import command, format_output from .exceptions import DeviceException from .miot_device import DeviceStatus, MiotDevice _LOGGER = logging.getLogger(__name__) MODEL_AIRQUALITYMONITOR_CGDN1 = "cgllc.airm.cgdn1" _MAPPINGS = { MODEL_AIRQUALITYMONITOR_CGDN1: { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-monitor:0000A008:cgllc-cgdn1:1 # Environment "humidity": {"siid": 3, "piid": 1}, # [0, 100] step 1 "pm25": {"siid": 3, "piid": 4}, # [0, 1000] step 1 "pm10": {"siid": 3, "piid": 5}, # [0, 1000] step 1 "temperature": {"siid": 3, "piid": 7}, # [-30, 100] step 0.00001 "co2": {"siid": 3, "piid": 8}, # [0, 9999] step 1 # Battery "battery": {"siid": 4, "piid": 1}, # [0, 100] step 1 "charging_state": { "siid": 4, "piid": 2, }, # 1 - Charging, 2 - Not charging, 3 - Not chargeable "voltage": {"siid": 4, "piid": 3}, # [0, 65535] step 1 # Settings "start_time": {"siid": 9, "piid": 2}, # [0, 2147483647] step 1 "end_time": {"siid": 9, "piid": 3}, # [0, 2147483647] step 1 "monitoring_frequency": { "siid": 9, "piid": 4, }, # 1, 60, 300, 600, 0; device accepts [0..600] "screen_off": { "siid": 9, "piid": 5, }, # 15, 30, 60, 300, 0; device accepts [0..300], 0 means never "device_off": { "siid": 9, "piid": 6, }, # 15, 30, 60, 0; device accepts [0..60], 0 means never "temperature_unit": {"siid": 9, "piid": 7}, } } class AirQualityMonitorMiotException(DeviceException): pass class ChargingState(enum.Enum): Unplugged = 0 # Not mentioned in the spec Charging = 1 NotCharging = 2 NotChargable = 3 class MonitoringFrequencyCGDN1(enum.Enum): # Official spec options Every1Second = 1 Every1Minute = 60 Every5Minutes = 300 Every10Minutes = 600 NotSet = 0 class ScreenOffCGDN1(enum.Enum): # Official spec options After15Seconds = 15 After30Seconds = 30 After1Minute = 60 After5Minutes = 300 Never = 0 class DeviceOffCGDN1(enum.Enum): # Official spec options After15Minutes = 15 After30Minutes = 30 After1Hour = 60 Never = 0 class DisplayTemperatureUnitCGDN1(enum.Enum): Celcius = "c" Fahrenheit = "f" class AirQualityMonitorCGDN1Status(DeviceStatus): """ Container of air quality monitor CGDN1 status. { 'humidity': 34, 'pm25': 18, 'pm10': 21, 'temperature': 22.8, 'co2': 468, 'battery': 37, 'charging_state': 0, 'voltage': 3564, 'start_time': 0, 'end_time': 0, 'monitoring_frequency': 1, 'screen_off': 300, 'device_off': 60, 'temperature_unit': 'c' } """ def __init__(self, data): self.data = data @property def humidity(self) -> int: """Return humidity value (0...100%).""" return self.data["humidity"] @property def pm25(self) -> int: """Return PM 2.5 value (0...1000ppm).""" return self.data["pm25"] @property def pm10(self) -> int: """Return PM 10 value (0...1000ppm).""" return self.data["pm10"] @property def temperature(self) -> float: """Return temperature value (-30...100°C).""" return self.data["temperature"] @property def co2(self) -> int: """Return co2 value (0...9999ppm).""" return self.data["co2"] @property def battery(self) -> int: """Return battery level (0...100%).""" return self.data["battery"] @property def charging_state(self) -> ChargingState: """Return charging state.""" return ChargingState(self.data["charging_state"]) @property def monitoring_frequency(self) -> int: """Return monitoring frequency time (0..600 s).""" return self.data["monitoring_frequency"] @property def screen_off(self) -> int: """Return screen off time (0..300 s).""" return self.data["screen_off"] @property def device_off(self) -> int: """Return device off time (0..60 min).""" return self.data["device_off"] @property def display_temperature_unit(self): """Return display temperature unit.""" return DisplayTemperatureUnitCGDN1(self.data["temperature_unit"]) class AirQualityMonitorCGDN1(MiotDevice): """Qingping Air Monitor Lite.""" _mappings = _MAPPINGS @command( default_output=format_output( "", "Humidity: {result.humidity} %\n" "PM 2.5: {result.pm25} μg/m³\n" "PM 10: {result.pm10} μg/m³\n" "Temperature: {result.temperature} °C\n" "CO₂: {result.co2} μg/m³\n" "Battery: {result.battery} %\n" "Charging state: {result.charging_state.name}\n" "Monitoring frequency: {result.monitoring_frequency} s\n" "Screen off: {result.screen_off} s\n" "Device off: {result.device_off} min\n" "Display temperature unit: {result.display_temperature_unit.name}\n", ) ) def status(self) -> AirQualityMonitorCGDN1Status: """Retrieve properties.""" return AirQualityMonitorCGDN1Status( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command( click.argument("duration", type=int), default_output=format_output("Setting monitoring frequency to {duration} s"), ) def set_monitoring_frequency_duration(self, duration): """Set monitoring frequency.""" if duration < 0 or duration > 600: raise AirQualityMonitorMiotException( "Invalid duration: %s. Must be between 0 and 600" % duration ) return self.set_property("monitoring_frequency", duration) @command( click.argument("duration", type=int), default_output=format_output("Setting device off duration to {duration} min"), ) def set_device_off_duration(self, duration): """Set device off duration.""" if duration < 0 or duration > 60: raise AirQualityMonitorMiotException( "Invalid duration: %s. Must be between 0 and 60" % duration ) return self.set_property("device_off", duration) @command( click.argument("duration", type=int), default_output=format_output("Setting screen off duration to {duration} s"), ) def set_screen_off_duration(self, duration): """Set screen off duration.""" if duration < 0 or duration > 300: raise AirQualityMonitorMiotException( "Invalid duration: %s. Must be between 0 and 300" % duration ) return self.set_property("screen_off", duration) @command( click.argument( "unit", type=click.Choice(DisplayTemperatureUnitCGDN1.__members__), callback=lambda c, p, v: getattr(DisplayTemperatureUnitCGDN1, v), ), default_output=format_output("Setting display temperature unit to {unit.name}"), ) def set_display_temperature_unit(self, unit: DisplayTemperatureUnitCGDN1): """Set display temperature unit.""" return self.set_property("temperature_unit", unit.value) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/alarmclock.py0000644000000000000000000002121614265350055014427 0ustar00import enum import time import click from .click_common import EnumType, command from .device import Device, DeviceStatus class HourlySystem(enum.Enum): TwentyFour = 24 Twelve = 12 class AlarmType(enum.Enum): Alarm = "alarm" Reminder = "reminder" Timer = "timer" # TODO names for the tones class Tone(enum.Enum): First = "a1.mp3" Second = "a2.mp3" Third = "a3.mp3" Fourth = "a4.mp3" Fifth = "a5.mp3" Sixth = "a6.mp3" Seventh = "a7.mp3" class Nightmode(DeviceStatus): def __init__(self, data): self._enabled = bool(data[0]) self._start = data[1] self._end = data[2] @property def enabled(self) -> bool: return self._enabled @property def start(self): return self._start @property def end(self): return self._end class RingTone(DeviceStatus): def __init__(self, data): # {'type': 'reminder', 'ringtone': 'a2.mp3', 'smart_clock': 0}] self.type = AlarmType(data["type"]) self.tone = Tone(data["ringtone"]) self.smart_clock = data["smart_clock"] class AlarmClock(Device): """Implementation of Xiao AI Smart Alarm Clock. Note, this device is not very responsive to the requests, so it may take several seconds /tries to get an answer. """ _supported_models = ["zimi.clock.myk01"] @command() def get_config_version(self): """ # values unknown {'result': [4], 'id': 203} :return: """ return self.send("get_config_version", ["audio"]) @command() def clock_system(self) -> HourlySystem: """Returns either 12 or 24 depending on which system is in use.""" return HourlySystem(self.send("get_hourly_system")[0]) @command(click.argument("brightness", type=EnumType(HourlySystem))) def set_hourly_system(self, hs: HourlySystem): return self.send("set_hourly_system", [hs.value]) @command() def get_button_light(self): """Get button's light state.""" # ['normal', 'mute', 'offline'] or [] return self.send("get_enabled_key_light") @command(click.argument("on", type=bool)) def set_button_light(self, on): """Enable or disable the button light.""" if on: return self.send("enable_key_light") == ["OK"] else: return self.send("disable_key_light") == ["OK"] @command() def volume(self) -> int: """Return the volume. -> 192.168.0.128 data= {"id":251,"method":"set_volume","params":[17]} <- 192.168.0.57 data= {"result":["OK"],"id":251} """ return int(self.send("get_volume")[0]) @command(click.argument("volume", type=int)) def set_volume(self, volume): """Set volume [1,100].""" return self.send("set_volume", [volume]) == ["OK"] @command( click.argument( "alarm_type", type=EnumType(AlarmType), default=AlarmType.Alarm.name ) ) def get_ring(self, alarm_type: AlarmType): """Get current ring tone settings.""" return RingTone(self.send("get_ring", [{"type": alarm_type.value}]).pop()) @command( click.argument("alarm_type", type=EnumType(AlarmType)), click.argument("tone", type=EnumType(Tone)), ) def set_ring(self, alarm_type: AlarmType, ring: RingTone): """Set alarm tone. -> 192.168.0.128 data= {"id":236,"method":"set_ring", "params":[{"ringtone":"a1.mp3","smart_clock":"","type":"alarm"}]} <- 192.168.0.57 data= {"result":["OK"],"id":236} """ raise NotImplementedError() # return self.send("set_ring", ) == ["OK"] @command() def night_mode(self): """Get night mode status. -> 192.168.0.128 data= {"id":234,"method":"get_night_mode","params":[]} <- 192.168.0.57 data= {"result":[0],"id":234} """ return Nightmode(self.send("get_night_mode")) @command() def set_night_mode(self): """Set the night mode. # enable -> 192.168.0.128 data= {"id":248,"method":"set_night_mode", "params":[1,"21:00","6:00"]} <- 192.168.0.57 data= {"result":["OK"],"id":248} # disable -> 192.168.0.128 data= {"id":249,"method":"set_night_mode", "params":[0,"21:00","6:00"]} <- 192.168.0.57 data= {"result":["OK"],"id":249} """ raise NotImplementedError() @command() def near_wakeup(self): """Status for near wakeup. -> 192.168.0.128 data= {"id":235,"method":"get_near_wakeup_status", "params":[]} <- 192.168.0.57 data= {"result":["disable"],"id":235} # setters -> 192.168.0.128 data= {"id":254,"method":"set_near_wakeup_status", "params":["enable"]} <- 192.168.0.57 data= {"result":["OK"],"id":254} -> 192.168.0.128 data= {"id":255,"method":"set_near_wakeup_status", "params":["disable"]} <- 192.168.0.57 data= {"result":["OK"],"id":255} """ return self.send("get_near_wakeup_status") @command() def countdown(self): """ -> 192.168.0.128 data= {"id":258,"method":"get_count_down_v2","params":[]} """ return self.send("get_count_down_v2") @command() def alarmops(self): """ NOTE: the alarm_ops method is the one used to create, query and delete all types of alarms (reminders, alarms, countdowns). -> 192.168.0.128 data= {"id":263,"method":"alarm_ops", "params":{"operation":"create","data":[ {"type":"alarm","event":"testlabel","reminder":"","smart_clock":0, "ringtone":"a2.mp3","volume":100,"circle":"once","status":"on", "repeat_ringing":0,"delete_datetime":1564291980000, "disable_datetime":"","circle_extra":"", "datetime":1564291980000} ],"update_datetime":1564205639326}} <- 192.168.0.57 data= {"result":[{"id":1,"ack":"OK"}],"id":263} # query per index, starts from 0 instead of 1 as the ids it seems -> 192.168.0.128 data= {"id":264,"method":"alarm_ops", "params":{"operation":"query","req_type":"alarm", "update_datetime":1564205639593,"index":0}} <- 192.168.0.57 data= {"result": [0,[ {"i":"1","c":"once","d":"2019-07-28T13:33:00+0800","s":"on", "n":"testlabel","a":"a2.mp3","dd":1} ], "America/New_York" ],"id":264} # result [code, list of alarms, timezone] -> 192.168.0.128 data= {"id":265,"method":"alarm_ops", "params":{"operation":"query","index":0,"update_datetime":1564205639596, "req_type":"reminder"}} <- 192.168.0.57 data= {"result":[0,[],"America/New_York"],"id":265} """ raise NotImplementedError() @command(click.argument("url")) def start_countdown(self, url): """Start countdown timer playing the given media. {"id":354,"method":"alarm_ops", "params":{"operation":"create","update_datetime":1564206432733, "data":[{"type":"timer", "background":"http://host.invalid/testfile.mp3", "offset":1800, "circle":"once", "volume":100, "datetime":1564208232733}]}} """ current_ts = int(time.time() * 1000) payload = { "operation": "create", "update_datetime": current_ts, "data": [ { "type": "timer", "background": "http://url_here_for_mp3", "offset": 30, "circle": "once", "volume": 30, "datetime": current_ts, } ], } return self.send("alarm_ops", payload) @command() def query(self): """ -> 192.168.0.128 data= {"id":227,"method":"alarm_ops","params": {"operation":"query","index":0,"update_datetime":1564205198413,"req_type":"reminder"}} """ payload = { "operation": "query", "index": 0, "update_datetime": int(time.time() * 1000), "req_type": "timer", } return self.send("alarm_ops", payload) @command() def cancel(self): """Cancel alarm of the defined type. "params":{"operation":"cancel","update_datetime":1564206332603,"data":[{"type":"timer"}]}} """ import time payload = { "operation": "pause", "update_datetime": int(time.time() * 1000), "data": [{"type": "timer"}], } return self.send("alarm_ops", payload) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/aqaracamera.py0000644000000000000000000002172614265350055014563 0ustar00"""Aqara camera support. Support for lumi.camera.aq1 .. todo:: add alarm/sound parts (get_music_info, {get,set}_alarming_volume, set_default_music, play_music_new, set_sound_playing) .. todo:: add sdcard status & fix all TODOS .. todo:: add tests """ import logging from enum import IntEnum from typing import Any, Dict import attr import click from .click_common import command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) class CameraException(DeviceException): pass @attr.s class CameraOffset: """Container for camera offset data.""" x = attr.ib() y = attr.ib() radius = attr.ib() @attr.s class ArmStatus: """Container for arm statuses.""" is_armed: bool = attr.ib(converter=bool) arm_wait_time: int = attr.ib(converter=int) alarm_volume: int = attr.ib(converter=int) class SDCardStatus(IntEnum): """State of the SD card.""" NoCardInserted = 0 Ok = 1 FormatRequired = 2 Formating = 3 class MotionDetectionSensitivity(IntEnum): """'Default' values for md sensitivity. Currently unused as the value can also be set arbitrarily. """ High = 6000000 Medium = 10000000 Low = 11000000 class CameraStatus(DeviceStatus): """Container for status reports from the Aqara Camera.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a lumi.camera.aq1: {"p2p_id":"#################","app_type":"celing", "offset_x":"0","offset_y":"0","offset_radius":"0", "md_status":1,"video_state":1,"fullstop":0, "led_status":1,"ir_status":1,"mdsensitivity":6000000, "channel_id":0,"flip_state":0, "avID":"####","avPass":"####","id":65001} """ self.data = data @property def type(self) -> str: """TODO: Type of the camera? Name?""" return self.data["app_type"] @property def video_status(self) -> bool: """Video state.""" return bool(self.data["video_state"]) @property def is_on(self) -> bool: """True if device is currently on.""" return self.video_status == 1 @property def md(self) -> bool: """Motion detection state.""" return bool(self.data["md_status"]) @property def md_sensitivity(self): """Motion detection sensitivity.""" return self.data["mdsensitivity"] @property def ir(self): """IR mode.""" return bool(self.data["ir_status"]) @property def led(self): """LED status.""" return bool(self.data["led_status"]) @property def flipped(self) -> bool: """TODO: If camera is flipped?""" return self.data["flip_state"] @property def offsets(self) -> CameraOffset: """Camera offset information.""" return CameraOffset( x=self.data["offset_x"], y=self.data["offset_y"], radius=self.data["offset_radius"], ) @property def channel_id(self) -> int: """TODO: Zigbee channel?""" return self.data["channel_id"] @property def fullstop(self) -> bool: """Is alarm triggered by MD.""" return self.data["fullstop"] != 0 @property def p2p_id(self) -> str: """P2P ID for video and audio.""" return self.data["p2p_id"] @property def av_id(self) -> str: """TODO: What is this? ID for the cloud?""" return self.data["avID"] @property def av_password(self) -> str: """TODO: What is this? Password for the cloud?""" return self.data["avPass"] class AqaraCamera(Device): """Main class representing the Xiaomi Aqara Camera.""" _supported_models = ["lumi.camera.aq1", "lumi.camera.aq2"] @command( default_output=format_output( "", "Type: {result.type}\n" "Video: {result.is_on}\n" "Offsets: {result.offsets}\n" "IR: {result.ir_status} %\n" "MD: {result.md_status} (sensitivity: {result.md_sensitivity}\n" "LED: {result.led}\n" "Flipped: {result.flipped}\n" "Full stop: {result.fullstop}\n" "P2P ID: {result.p2p_id}\n" "AV ID: {result.av_id}\n" "AV password: {result.av_password}\n" "\n", ) ) def status(self) -> CameraStatus: """Camera status.""" return CameraStatus(self.send("get_ipcprop", ["all"])) @command(default_output=format_output("Camera on")) def on(self): """Camera on.""" return self.send("set_video", ["on"]) @command(default_output=format_output("Camera off")) def off(self): """Camera off.""" return self.send("set_video", ["off"]) @command(default_output=format_output("IR on")) def ir_on(self): """IR on.""" return self.send("set_ir", ["on"]) @command(default_output=format_output("IR off")) def ir_off(self): """IR off.""" return self.send("set_ir", ["off"]) @command(default_output=format_output("MD on")) def md_on(self): """IR on.""" return self.send("set_md", ["on"]) @command(default_output=format_output("MD off")) def md_off(self): """MD off.""" return self.send("set_md", ["off"]) @command(click.argument("sensitivity", type=int, required=False)) def md_sensitivity(self, sensitivity): """Get or set the motion detection sensitivity.""" if sensitivity: click.echo("Setting MD sensitivity to %s" % sensitivity) return self.send("set_mdsensitivity", [sensitivity])[0] == "ok" else: return self.send("get_mdsensitivity") @command(default_output=format_output("LED on")) def led_on(self): """LED on.""" return self.send("set_led", ["on"]) @command(default_output=format_output("LED off")) def led_off(self): """LED off.""" return self.send("set_led", ["off"]) @command(default_output=format_output("Flip on")) def flip_on(self): """Flip on.""" return self.send("set_flip", ["on"]) @command(default_output=format_output("Flip off")) def flip_off(self): """Flip off.""" return self.send("set_flip", ["off"]) @command(default_output=format_output("Fullstop on")) def fullstop_on(self): """Fullstop on.""" return self.send("set_fullstop", ["on"]) @command(default_output=format_output("Fullstop off")) def fullstop_off(self): """Fullstop off.""" return self.send("set_fullstop", ["off"]) @command( click.argument("time", type=int, default=30), default_output=format_output("Start pairing for {time} seconds"), ) def pair(self, timeout: int): """Start (or stop with "0") pairing.""" if timeout < 0: raise CameraException("Invalid timeout: %s" % timeout) return self.send("start_zigbee_join", [timeout]) @command() def sd_status(self): """SD card status.""" return SDCardStatus(self.send("get_sdstatus")) @command() def sd_format(self): """Format the SD card. Returns True when formating has started successfully. """ return bool(self.send("sdformat")) @command() def arm_status(self): """Return arming information.""" is_armed = self.send("get_arming") arm_wait_time = self.send("get_arm_wait_time") alarm_volume = self.send("get_alarming_volume") return ArmStatus( is_armed=bool(is_armed), arm_wait_time=arm_wait_time, alarm_volume=alarm_volume, ) @command( click.argument("volume", type=int, default=100), default_output=format_output("Setting alarm volume to {volume}"), ) def set_alarm_volume(self, volume): """Set alarm volume.""" if volume < 0 or volume > 100: raise CameraException("Volume has to be [0,100], was %s" % volume) return self.send("set_alarming_volume", [volume])[0] == "ok" @command(click.argument("sound_id", type=str, required=False, default=None)) def alarm_sound(self, sound_id): """List or set the alarm sound.""" if id is None: sound_status = self.send("get_music_info", [0]) # TODO: make a list out from this. @attr.s class SoundList: default = attr.ib() total = attr.ib(type=int) sounds = attr.ib(type=list) return sound_status click.echo("Setting alarm sound to %s" % sound_id) return self.send("set_default_music", [0, sound_id])[0] == "ok" @command(default_output=format_output("Arming")) def arm(self): """Arm the camera?""" return self.send("set_arming", ["on"]) @command(default_output=format_output("Disarming")) def disarm(self): """Disarm the camera?""" return self.send("set_arming", ["off"]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/chuangmi_camera.py0000644000000000000000000002632114265350055015424 0ustar00"""Xiaomi Chuangmi camera (chuangmi.camera.ipc009, ipc019) support.""" import enum import logging from typing import Any, Dict import click from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus _LOGGER = logging.getLogger(__name__) class Direction(enum.Enum): """Rotation direction.""" Left = 1 Right = 2 Up = 3 Down = 4 class MotionDetectionSensitivity(enum.IntEnum): """Motion detection sensitivity.""" High = 3 Low = 1 class HomeMonitoringMode(enum.IntEnum): """Home monitoring mode.""" Off = 0 AllDay = 1 Custom = 2 class NASState(enum.IntEnum): """NAS state.""" Off = 2 On = 3 class NASSyncInterval(enum.IntEnum): """NAS sync interval.""" Realtime = 300 Hour = 3600 Day = 86400 class NASVideoRetentionTime(enum.IntEnum): """NAS video retention time.""" Week = 604800 Month = 2592000 Quarter = 7776000 HalfYear = 15552000 Year = 31104000 CONST_HIGH_SENSITIVITY = [MotionDetectionSensitivity.High] * 32 CONST_LOW_SENSITIVITY = [MotionDetectionSensitivity.Low] * 32 SUPPORTED_MODELS = [ "chuangmi.camera.ipc009", "chuangmi.camera.ipc019", "chuangmi.camera.038a2", ] class CameraStatus(DeviceStatus): """Container for status reports from the Xiaomi Chuangmi Camera.""" def __init__(self, data: Dict[str, Any]) -> None: """ Request: ["power", "motion_record", "light", "full_color", "flip", "improve_program", "wdr", "track", "sdcard_status", "watermark", "max_client", "night_mode", "mini_level"] Response: ["on","on","on","on","off","on","on","off","0","off","0","0","1"] """ self.data = data @property def power(self) -> bool: """Camera power.""" return self.data["power"] == "on" @property def motion_record(self) -> bool: """Motion record status.""" return self.data["motion_record"] == "on" @property def light(self) -> bool: """Camera light status.""" return self.data["light"] == "on" @property def full_color(self) -> bool: """Full color with bad lighting conditions.""" return self.data["full_color"] == "on" @property def flip(self) -> bool: """Image 180 degrees flip status.""" return self.data["flip"] == "on" @property def improve_program(self) -> bool: """Customer experience improvement program status.""" return self.data["improve_program"] == "on" @property def wdr(self) -> bool: """Wide dynamic range status.""" return self.data["wdr"] == "on" @property def track(self) -> bool: """Tracking status.""" return self.data["track"] == "on" @property def watermark(self) -> bool: """Apply watermark to video.""" return self.data["watermark"] == "on" @property def sdcard_status(self) -> int: """SD card status.""" return self.data["sdcard_status"] @property def max_client(self) -> int: """Unknown.""" return self.data["max_client"] @property def night_mode(self) -> int: """Night mode.""" return self.data["night_mode"] @property def mini_level(self) -> int: """Unknown.""" return self.data["mini_level"] class ChuangmiCamera(Device): """Main class representing the Xiaomi Chuangmi Camera.""" _supported_models = SUPPORTED_MODELS @command( default_output=format_output( "", "Power: {result.power}\n" "Motion record: {result.motion_record}\n" "Light: {result.light}\n" "Full color: {result.full_color}\n" "Flip: {result.flip}\n" "Improve program: {result.improve_program}\n" "Wdr: {result.wdr}\n" "Track: {result.track}\n" "SD card status: {result.sdcard_status}\n" "Watermark: {result.watermark}\n" "Max client: {result.max_client}\n" "Night mode: {result.night_mode}\n" "Mini level: {result.mini_level}\n" "\n", ) ) def status(self) -> CameraStatus: """Retrieve properties.""" properties = [ "power", "motion_record", "light", "full_color", "flip", "improve_program", "wdr", "track", "sdcard_status", "watermark", "max_client", "night_mode", "mini_level", ] values = self.get_properties(properties) return CameraStatus(dict(zip(properties, values))) @command(default_output=format_output("Power on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Power off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command(default_output=format_output("MotionRecord on")) def motion_record_on(self): """Start recording when motion detected.""" return self.send("set_motion_record", ["on"]) @command(default_output=format_output("MotionRecord off")) def motion_record_off(self): """Motion record off, always record video.""" return self.send("set_motion_record", ["off"]) @command(default_output=format_output("MotionRecord stop")) def motion_record_stop(self): """Motion record off, video recording stopped.""" return self.send("set_motion_record", ["stop"]) @command(default_output=format_output("Light on")) def light_on(self): """Light on.""" return self.send("set_light", ["on"]) @command(default_output=format_output("Light off")) def light_off(self): """Light off.""" return self.send("set_light", ["off"]) @command(default_output=format_output("FullColor on")) def full_color_on(self): """Full color on.""" return self.send("set_full_color", ["on"]) @command(default_output=format_output("FullColor off")) def full_color_off(self): """Full color off.""" return self.send("set_full_color", ["off"]) @command(default_output=format_output("Flip on")) def flip_on(self): """Flip image 180 degrees on.""" return self.send("set_flip", ["on"]) @command(default_output=format_output("Flip off")) def flip_off(self): """Flip image 180 degrees off.""" return self.send("set_flip", ["off"]) @command(default_output=format_output("ImproveProgram on")) def improve_program_on(self): """Improve program on.""" return self.send("set_improve_program", ["on"]) @command(default_output=format_output("ImproveProgram off")) def improve_program_off(self): """Improve program off.""" return self.send("set_improve_program", ["off"]) @command(default_output=format_output("Watermark on")) def watermark_on(self): """Watermark on.""" return self.send("set_watermark", ["on"]) @command(default_output=format_output("Watermark off")) def watermark_off(self): """Watermark off.""" return self.send("set_watermark", ["off"]) @command(default_output=format_output("WideDynamicRange on")) def wdr_on(self): """Wide dynamic range on.""" return self.send("set_wdr", ["on"]) @command(default_output=format_output("WideDynamicRange off")) def wdr_off(self): """Wide dynamic range off.""" return self.send("set_wdr", ["off"]) @command(default_output=format_output("NightMode auto")) def night_mode_auto(self): """Auto switch to night mode.""" return self.send("set_night_mode", [0]) @command(default_output=format_output("NightMode off")) def night_mode_off(self): """Night mode off.""" return self.send("set_night_mode", [1]) @command(default_output=format_output("NightMode on")) def night_mode_on(self): """Night mode always on.""" return self.send("set_night_mode", [2]) @command( click.argument("direction", type=EnumType(Direction)), default_output=format_output("Rotating to direction '{direction.name}'"), ) def rotate(self, direction: Direction): """Rotate camera to given direction (left, right, up, down).""" return self.send("set_motor", {"operation": direction.value}) @command() def alarm(self): """Sound a loud alarm for 10 seconds.""" return self.send("alarm_sound") @command( click.argument("sensitivity", type=EnumType(MotionDetectionSensitivity)), default_output=format_output("Setting motion sensitivity '{sensitivity.name}'"), ) def set_motion_sensitivity(self, sensitivity: MotionDetectionSensitivity): """Set motion sensitivity (high, low).""" return self.send( "set_motion_region", CONST_HIGH_SENSITIVITY if sensitivity == MotionDetectionSensitivity.High else CONST_LOW_SENSITIVITY, ) @command( click.argument("mode", type=EnumType(HomeMonitoringMode)), click.argument("start-hour", default=10), click.argument("start-minute", default=0), click.argument("end-hour", default=17), click.argument("end-minute", default=0), click.argument("notify", default=1), click.argument("interval", default=5), default_output=format_output("Setting alarm config to '{mode.name}'"), ) def set_home_monitoring_config( self, mode: HomeMonitoringMode = HomeMonitoringMode.AllDay, start_hour: int = 10, start_minute: int = 0, end_hour: int = 17, end_minute: int = 0, notify: int = 1, interval: int = 5, ): """Set home monitoring configuration.""" return self.send( "setAlarmConfig", [mode, start_hour, start_minute, end_hour, end_minute, notify, interval], ) @command(default_output=format_output("Clearing NAS directory")) def clear_nas_dir(self): """Clear NAS directory.""" return self.send("nas_clear_dir", [[]]) @command(default_output=format_output("Getting NAS config info")) def get_nas_config(self): """Get NAS config info.""" return self.send("nas_get_config", {}) @command( click.argument("state", type=EnumType(NASState)), click.argument("share"), click.argument("sync-interval", type=EnumType(NASSyncInterval)), click.argument("video-retention-time", type=EnumType(NASVideoRetentionTime)), default_output=format_output("Setting NAS config to '{state.name}'"), ) def set_nas_config( self, state: NASState, share=None, sync_interval: NASSyncInterval = NASSyncInterval.Realtime, video_retention_time: NASVideoRetentionTime = NASVideoRetentionTime.Week, ): """Set NAS configuration.""" if share is None: share = {} return self.send( "nas_set_config", { "state": state, "sync_interval": sync_interval, "video_retention_time": video_retention_time, }, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/chuangmi_ir.py0000644000000000000000000001657114265350055014614 0ustar00import base64 import re from typing import Callable, Set, Tuple import click from construct import ( Adapter, Array, BitsInteger, BitStruct, Computed, Const, Int16ub, Int16ul, Int32ul, Rebuild, Struct, len_, this, ) from .click_common import command, format_output from .device import Device from .exceptions import DeviceException class ChuangmiIrException(DeviceException): pass class ChuangmiIr(Device): """Main class representing Chuangmi IR Remote Controller.""" _supported_models = [ "chuangmi.ir.v2", "chuangmi.remote.v2", "chuangmi-remote-h102a03", # maybe? ] PRONTO_RE = re.compile(r"^([\da-f]{4}\s?){3,}([\da-f]{4})$", re.IGNORECASE) @command( click.argument("key", type=int), default_output=format_output("Learning command into storage key {key}"), ) def learn(self, key: int = 1): """Learn an infrared command. :param int key: Storage slot, must be between 1 and 1000000 """ if key < 1 or key > 1000000: raise ChuangmiIrException("Invalid storage slot.") return self.send("miIO.ir_learn", {"key": str(key)}) @command( click.argument("key", type=int), default_output=format_output("Reading infrared command from storage key {key}"), ) def read(self, key: int = 1): """Read a learned command. Positive response (chuangmi.ir.v2): {'key': '1', 'code': 'Z6WPAasBAAA3BQAA4AwJAEA....AAABAAEBAQAAAQAA=='} Negative response (chuangmi.ir.v2): {'error': {'code': -5002, 'message': 'no code for this key'}, 'id': 5} Negative response (chuangmi.ir.v2): {'error': {'code': -5003, 'message': 'learn timeout'}, 'id': 17} :param int key: Slot to read from """ if key < 1 or key > 1000000: raise ChuangmiIrException("Invalid storage slot.") return self.send("miIO.ir_read", {"key": str(key)}) def play_raw(self, command: str, frequency: int = 38400, length: int = -1): """Play a captured command. :param str command: Command to execute :param int frequency: Execution frequency :param int length: Length of the command. -1 means not sending the length parameter. """ if length < 0: return self.send("miIO.ir_play", {"freq": frequency, "code": command}) else: return self.send( "miIO.ir_play", {"freq": frequency, "code": command, "length": length} ) def play_pronto(self, pronto: str, repeats: int = 1, length: int = -1): """Play a Pronto Hex encoded IR command. Supports only raw Pronto format, starting with 0000. :param str pronto: Pronto Hex string. :param int repeats: Number of extra signal repeats. :param int length: Length of the command. -1 means not sending the length parameter. """ command, frequency = self.pronto_to_raw(pronto, repeats) return self.play_raw(command, frequency, length) @classmethod def pronto_to_raw(cls, pronto: str, repeats: int = 1) -> Tuple[str, int]: """Play a Pronto Hex encoded IR command. Supports only raw Pronto format, starting with 0000. :param str pronto: Pronto Hex string. :param int repeats: Number of extra signal repeats. """ if repeats < 0: raise ChuangmiIrException("Invalid repeats value") try: pronto_data = Pronto.parse(bytearray.fromhex(pronto)) except Exception as ex: raise ChuangmiIrException("Invalid Pronto command") from ex if len(pronto_data.intro) == 0: repeats += 1 times: Set[int] = set() for pair in pronto_data.intro + pronto_data.repeat * (1 if repeats else 0): times.add(pair.pulse) times.add(pair.gap) times_sorted = sorted(times) times_map = {t: idx for idx, t in enumerate(times_sorted)} edge_pairs = [] for pair in pronto_data.intro + pronto_data.repeat * repeats: edge_pairs.append( {"pulse": times_map[pair.pulse], "gap": times_map[pair.gap]} ) signal_code = base64.b64encode( ChuangmiIrSignal.build( { "times_index": times_sorted + [0] * (16 - len(times)), "edge_pairs": edge_pairs, } ) ).decode() return signal_code, int(round(pronto_data.frequency)) @command( click.argument("command", type=str), default_output=format_output("Playing the supplied command"), ) def play(self, command: str): """Plays a command in one of the supported formats.""" if ":" not in command: if self.PRONTO_RE.match(command): command_type = "pronto" else: command_type = "raw" command_args = [] else: command_type, command, *command_args = command.split(":") arg_types = [int, int] if len(command_args) > len(arg_types): raise ChuangmiIrException("Invalid command arguments count") if command_type not in ["raw", "pronto"]: raise ChuangmiIrException("Invalid command type") play_method: Callable if command_type == "raw": play_method = self.play_raw elif command_type == "pronto": play_method = self.play_pronto try: converted_command_args = [t(v) for v, t in zip(command_args, arg_types)] except Exception as ex: raise ChuangmiIrException("Invalid command arguments") from ex return play_method(command, *converted_command_args) @command( click.argument("indicator_led", type=bool), default_output=format_output( lambda indicator_led: "Turning on indicator LED" if indicator_led else "Turning off indicator LED" ), ) def set_indicator_led(self, indicator_led: bool): """Set the indicator led on/off.""" if indicator_led: return self.send("set_indicatorLamp", ["on"]) else: return self.send("set_indicatorLamp", ["off"]) @command(default_output=format_output("Indicator LED status: {result}")) def get_indicator_led(self): """Get the indicator led status.""" return self.send("get_indicatorLamp") class ProntoPulseAdapter(Adapter): def _decode(self, obj, context, path): return int(obj * context._.modulation_period) def _encode(self, obj, context, path): raise RuntimeError("Not implemented") ChuangmiIrSignal = Struct( Const(0xA567, Int16ul), "edge_count" / Rebuild(Int16ul, len_(this.edge_pairs) * 2 - 1), "times_index" / Array(16, Int32ul), "edge_pairs" / Array( (this.edge_count + 1) // 2, BitStruct("gap" / BitsInteger(4), "pulse" / BitsInteger(4)), ), ) ProntoBurstPair = Struct( "pulse" / ProntoPulseAdapter(Int16ub), "gap" / ProntoPulseAdapter(Int16ub) ) Pronto = Struct( Const(0, Int16ub), "_ticks" / Int16ub, "modulation_period" / Computed(this._ticks * 0.241246), "frequency" / Computed(1000000 / this.modulation_period), "intro_len" / Int16ub, "repeat_len" / Int16ub, "intro" / Array(this.intro_len, ProntoBurstPair), "repeat" / Array(this.repeat_len, ProntoBurstPair), ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/chuangmi_plug.py0000644000000000000000000001312114265350055015135 0ustar00import logging from collections import defaultdict from typing import Any, Dict, Optional import click from .click_common import command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException from .utils import deprecated _LOGGER = logging.getLogger(__name__) MODEL_CHUANGMI_PLUG_V3 = "chuangmi.plug.v3" MODEL_CHUANGMI_PLUG_V1 = "chuangmi.plug.v1" MODEL_CHUANGMI_PLUG_M1 = "chuangmi.plug.m1" MODEL_CHUANGMI_PLUG_M3 = "chuangmi.plug.m3" MODEL_CHUANGMI_PLUG_V2 = "chuangmi.plug.v2" MODEL_CHUANGMI_PLUG_HMI205 = "chuangmi.plug.hmi205" MODEL_CHUANGMI_PLUG_HMI206 = "chuangmi.plug.hmi206" MODEL_CHUANGMI_PLUG_HMI208 = "chuangmi.plug.hmi208" AVAILABLE_PROPERTIES = { MODEL_CHUANGMI_PLUG_V1: ["on", "usb_on", "temperature"], MODEL_CHUANGMI_PLUG_V3: ["on", "usb_on", "temperature", "wifi_led"], MODEL_CHUANGMI_PLUG_M1: ["power", "temperature"], MODEL_CHUANGMI_PLUG_M3: ["power", "temperature"], MODEL_CHUANGMI_PLUG_V2: ["power", "temperature"], MODEL_CHUANGMI_PLUG_HMI205: ["power", "temperature"], MODEL_CHUANGMI_PLUG_HMI206: ["power", "temperature"], MODEL_CHUANGMI_PLUG_HMI208: ["power", "usb_on", "temperature"], } class ChuangmiPlugStatus(DeviceStatus): """Container for status reports from the plug.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Chuangmi Plug V1 (chuangmi.plug.v1) { 'power': True, 'usb_on': True, 'temperature': 32 } Response of a Chuangmi Plug V3 (chuangmi.plug.v3): { 'on': True, 'usb_on': True, 'temperature': 32, 'wifi_led': True } """ self.data = data @property def power(self) -> bool: """Current power state.""" if "on" in self.data: return self.data["on"] is True or self.data["on"] == "on" elif "power" in self.data: return self.data["power"] == "on" raise DeviceException("There was neither 'on' or 'power' in data") @property def is_on(self) -> bool: """True if device is on.""" return self.power @property def temperature(self) -> int: return self.data["temperature"] @property def usb_power(self) -> Optional[bool]: """True if USB is on.""" if "usb_on" in self.data and self.data["usb_on"] is not None: return self.data["usb_on"] return None @property def load_power(self) -> Optional[float]: """Current power load, if available.""" if "load_power" in self.data and self.data["load_power"] is not None: return float(self.data["load_power"]) return None @property # type: ignore @deprecated("Use led()") def wifi_led(self) -> Optional[bool]: """True if the wifi led is turned on.""" return self.led @property def led(self) -> Optional[bool]: """True if the wifi led is turned on.""" if "wifi_led" in self.data and self.data["wifi_led"] is not None: return self.data["wifi_led"] == "on" return None class ChuangmiPlug(Device): """Main class representing the Chuangmi Plug.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "USB Power: {result.usb_power}\n" "Temperature: {result.temperature} °C\n" "Load power: {result.load_power}\n" "WiFi LED: {result.wifi_led}\n", ) ) def status(self) -> ChuangmiPlugStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_CHUANGMI_PLUG_M1] ).copy() values = self.get_properties(properties) if self.model == MODEL_CHUANGMI_PLUG_V3: load_power = self.send("get_power") # Response: [300] if len(load_power) == 1: properties.append("load_power") values.append(load_power[0] * 0.01) return ChuangmiPlugStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" if self.model == MODEL_CHUANGMI_PLUG_V1: return self.send("set_on") return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" if self.model == MODEL_CHUANGMI_PLUG_V1: return self.send("set_off") return self.send("set_power", ["off"]) @command(default_output=format_output("Powering USB on")) def usb_on(self): """Power on.""" return self.send("set_usb_on") @command(default_output=format_output("Powering USB off")) def usb_off(self): """Power off.""" return self.send("set_usb_off") @deprecated("Use set_led instead of set_wifi_led") @command( click.argument("wifi_led", type=bool), default_output=format_output( lambda wifi_led: "Turning on WiFi LED" if wifi_led else "Turning off WiFi LED" ), ) def set_wifi_led(self, wifi_led: bool): """Set the wifi led on/off.""" self.set_led(wifi_led) @command( click.argument("wifi_led", type=bool), default_output=format_output( lambda wifi_led: "Turning on LED" if wifi_led else "Turning off LED" ), ) def set_led(self, wifi_led: bool): """Set the led on/off.""" if wifi_led: return self.send("set_wifi_led", ["on"]) else: return self.send("set_wifi_led", ["off"]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/cli.py0000644000000000000000000000331314265350055013064 0ustar00import logging import click from miio import Discovery from miio.click_common import ( DeviceGroupMeta, ExceptionHandlerGroup, GlobalContextObject, json_output, ) from miio.miioprotocol import MiIOProtocol from .cloud import cloud _LOGGER = logging.getLogger(__name__) @click.group(cls=ExceptionHandlerGroup) @click.option("-d", "--debug", default=False, count=True) @click.option( "-o", "--output", type=click.Choice(["default", "json", "json_pretty"]), default="default", ) @click.version_option() @click.pass_context def cli(ctx, debug: int, output: str): if debug: logging.basicConfig(level=logging.DEBUG) _LOGGER.info("Debug mode active") else: logging.basicConfig(level=logging.INFO) if output in ("json", "json_pretty"): output_func = json_output(pretty=output == "json_pretty") else: output_func = None ctx.obj = GlobalContextObject(debug=debug, output=output_func) for device_class in DeviceGroupMeta._device_classes: cli.add_command(device_class.get_device_group()) @click.command() @click.option("--mdns/--no-mdns", default=True, is_flag=True) @click.option("--handshake/--no-handshake", default=True, is_flag=True) @click.option("--network", default=None) @click.option("--timeout", type=int, default=5) def discover(mdns, handshake, network, timeout): """Discover devices using both handshake and mdns methods.""" if handshake: MiIOProtocol.discover(addr=network, timeout=timeout) if mdns: Discovery.discover_mdns(timeout=timeout) cli.add_command(discover) cli.add_command(cloud) def create_cli(): return cli(auto_envvar_prefix="MIIO") if __name__ == "__main__": create_cli() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/click_common.py0000644000000000000000000002452414265350055014761 0ustar00"""Click commons. This file contains common functions for cli tools. """ import ast import ipaddress import json import logging import re from functools import partial, wraps from typing import Callable, Set, Type, Union import click import miio from .exceptions import DeviceError _LOGGER = logging.getLogger(__name__) def validate_ip(ctx, param, value): if value is None: return None try: ipaddress.ip_address(value) return value except ValueError as ex: raise click.BadParameter("Invalid IP: %s" % ex) def validate_token(ctx, param, value): if value is None: return None token_len = len(value) if token_len != 32: raise click.BadParameter("Token length != 32 chars: %s" % token_len) return value class ExceptionHandlerGroup(click.Group): """Add a simple group for catching the miio-related exceptions. This simplifies catching the exceptions from different click commands. Idea from https://stackoverflow.com/a/44347763 """ def __call__(self, *args, **kwargs): try: return self.main(*args, **kwargs) except miio.DeviceException as ex: _LOGGER.debug("Exception: %s", ex, exc_info=True) click.echo(click.style("Error: %s" % ex, fg="red", bold=True)) class EnumType(click.Choice): def __init__(self, enumcls, casesensitive=False): choices = enumcls.__members__ if not casesensitive: choices = (_.lower() for _ in choices) self._enumcls = enumcls self._casesensitive = casesensitive super().__init__(list(sorted(set(choices)))) def convert(self, value, param, ctx): if not self._casesensitive: value = value.lower() value = super().convert(value, param, ctx) if not self._casesensitive: return next(_ for _ in self._enumcls if _.name.lower() == value.lower()) else: return next(_ for _ in self._enumcls if _.name == value) def get_metavar(self, param): word = self._enumcls.__name__ # Stolen from jpvanhal/inflection word = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", word) word = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", word) word = word.replace("-", "_").lower().split("_") if word[-1] == "enum": word.pop() return ("_".join(word)).upper() class LiteralParamType(click.ParamType): name = "literal" def convert(self, value, param, ctx): try: return ast.literal_eval(value) except ValueError: self.fail("%s is not a valid literal" % value, param, ctx) class GlobalContextObject: def __init__(self, debug: int = 0, output: Callable = None): self.debug = debug self.output = output class DeviceGroupMeta(type): _device_classes: Set[Type] = set() def __new__(mcs, name, bases, namespace): commands = {} def _get_commands_for_namespace(namespace): commands = {} for _, val in namespace.items(): if not callable(val): continue device_group_command = getattr(val, "_device_group_command", None) if device_group_command is None: continue commands[device_group_command.command_name] = device_group_command return commands # 1. Go through base classes for commands for base in bases: commands.update(getattr(base, "_device_group_commands", {})) # 2. Add commands from the current class commands.update(_get_commands_for_namespace(namespace)) namespace["_device_group_commands"] = commands if "get_device_group" not in namespace: def get_device_group(dcls): return DeviceGroup(dcls) namespace["get_device_group"] = classmethod(get_device_group) cls = super().__new__(mcs, name, bases, namespace) mcs._device_classes.add(cls) return cls @property def supported_models(cls): """Return list of supported models.""" return cls._mappings.keys() or cls._supported_models class DeviceGroup(click.MultiCommand): class Command: def __init__(self, name, decorators, *, default_output=None, **kwargs): self.name = name self.decorators = list(decorators) self.decorators.reverse() self.default_output = default_output self.kwargs = kwargs def __call__(self, func): self.func = func func._device_group_command = self self.kwargs.setdefault("help", self.func.__doc__) def _autodetect_model_if_needed(func): def _wrap(self, *args, **kwargs): skip_autodetect = func._device_group_command.kwargs.pop( "skip_autodetect", False ) if ( not skip_autodetect and self._model is None and self._info is None ): _LOGGER.debug( "Unknown model, trying autodetection. %s %s" % (self._model, self._info) ) self._fetch_info() return func(self, *args, **kwargs) # TODO HACK to make the command visible to cli _wrap._device_group_command = func._device_group_command return _wrap func = _autodetect_model_if_needed(func) return func @property def command_name(self): return self.name or self.func.__name__.lower() def wrap(self, ctx, func): gco = ctx.find_object(GlobalContextObject) if gco is not None and gco.output is not None: output = gco.output elif self.default_output: output = self.default_output else: output = format_output(f"Running command {self.command_name}") # Remove skip_autodetect before constructing the click.command self.kwargs.pop("skip_autodetect", None) func = output(func) for decorator in self.decorators: func = decorator(func) return click.command(self.command_name, **self.kwargs)(func) def call(self, owner, *args, **kwargs): method = getattr(owner, self.func.__name__) return method(*args, **kwargs) DEFAULT_PARAMS = [ click.Option(["--ip"], required=True, callback=validate_ip), click.Option(["--token"], required=True, callback=validate_token), click.Option(["--model"], required=False), ] def __init__( self, device_class, name=None, invoke_without_command=False, no_args_is_help=None, subcommand_metavar=None, chain=False, result_callback=None, result_callback_pass_device=True, **attrs, ): self.commands = getattr(device_class, "_device_group_commands", None) if self.commands is None: raise RuntimeError( "Class {} doesn't use DeviceGroupMeta meta class." " It can't be used with DeviceGroup." ) self.device_class = device_class self.device_pass = click.make_pass_decorator(device_class) attrs.setdefault("params", self.DEFAULT_PARAMS) attrs.setdefault("callback", click.pass_context(self.group_callback)) if result_callback_pass_device and callable(result_callback): result_callback = self.device_pass(result_callback) super().__init__( name or device_class.__name__.lower(), invoke_without_command, no_args_is_help, subcommand_metavar, chain, result_callback, **attrs, ) def group_callback(self, ctx, *args, **kwargs): gco = ctx.find_object(GlobalContextObject) if gco: kwargs["debug"] = gco.debug ctx.obj = self.device_class(*args, **kwargs) def command_callback(self, miio_command, miio_device, *args, **kwargs): return miio_command.call(miio_device, *args, **kwargs) def get_command(self, ctx, cmd_name): if cmd_name not in self.commands: ctx.fail("Unknown command (%s)" % cmd_name) cmd = self.commands[cmd_name] return self.commands[cmd_name].wrap( ctx, self.device_pass(partial(self.command_callback, cmd)) ) def list_commands(self, ctx): return sorted(self.commands.keys()) def command(*decorators, name=None, default_output=None, **kwargs): return DeviceGroup.Command( name, decorators, default_output=default_output, **kwargs ) def format_output( msg_fmt: Union[str, Callable] = "", result_msg_fmt: Union[str, Callable] = "{result}", ): def decorator(func): @wraps(func) def wrap(*args, **kwargs): if msg_fmt: if callable(msg_fmt): msg = msg_fmt(**kwargs) else: msg = msg_fmt.format(**kwargs) if msg: click.echo(msg.strip()) kwargs["result"] = func(*args, **kwargs) if result_msg_fmt: if callable(result_msg_fmt): result_msg = result_msg_fmt(**kwargs) else: result_msg = result_msg_fmt.format(**kwargs) if result_msg: click.echo(result_msg.strip()) return wrap return decorator def json_output(pretty=False): indent = 2 if pretty else None def decorator(func): @wraps(func) def wrap(*args, **kwargs): try: result = func(*args, **kwargs) except DeviceError as ex: click.echo(json.dumps(ex.args[0], indent=indent)) return get_json_data_func = getattr(result, "__json__", None) data_variable = getattr(result, "data", None) if get_json_data_func is not None: result = get_json_data_func() elif data_variable is not None: result = data_variable click.echo(json.dumps(result, indent=indent)) return wrap return decorator ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5226135 python-miio-0.5.12/miio/cloud.py0000644000000000000000000001344014265350055013425 0ustar00import logging from pprint import pprint from typing import TYPE_CHECKING, Dict, List, Optional import attr import click _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: from micloud import MiCloud # noqa: F401 AVAILABLE_LOCALES = ["cn", "de", "i2", "ru", "sg", "us"] class CloudException(Exception): """Exception raised for cloud connectivity issues.""" @attr.s(auto_attribs=True) class CloudDeviceInfo: """Container for device data from the cloud. Note that only some selected information is directly exposed, but you can access the raw data using `raw_data`. """ did: str token: str name: str model: str ip: str description: str parent_id: str ssid: str mac: str locale: List[str] raw_data: str = attr.ib(repr=False) @classmethod def from_micloud(cls, response, locale): micloud_to_info = { "did": "did", "token": "token", "name": "name", "model": "model", "ip": "localip", "description": "desc", "ssid": "ssid", "parent_id": "parent_id", "mac": "mac", } data = {k: response[v] for k, v in micloud_to_info.items()} return cls(raw_data=response, locale=[locale], **data) class CloudInterface: """Cloud interface using micloud library. Currently used only for obtaining the list of registered devices. Example:: ci = CloudInterface(username="foo", password=...) devs = ci.get_devices() for did, dev in devs.items(): print(dev) """ def __init__(self, username, password): self.username = username self.password = password self._micloud = None def _login(self): if self._micloud is not None: _LOGGER.debug("Already logged in, skipping login") return try: from micloud import MiCloud # noqa: F811 from micloud.micloudexception import MiCloudAccessDenied except ImportError: raise CloudException( "You need to install 'micloud' package to use cloud interface" ) self._micloud = MiCloud = MiCloud( username=self.username, password=self.password ) try: # login() can either return False or raise an exception on failure if not self._micloud.login(): raise CloudException("Login failed") except MiCloudAccessDenied as ex: raise CloudException("Login failed") from ex def _parse_device_list(self, data, locale): """Parse device list response from micloud.""" devs = {} for single_entry in data: devinfo = CloudDeviceInfo.from_micloud(single_entry, locale) devs[devinfo.did] = devinfo return devs def get_devices(self, locale: Optional[str] = None) -> Dict[str, CloudDeviceInfo]: """Return a list of available devices keyed with a device id. If no locale is given, all known locales are browsed. If a device id is already seen in another locale, it is excluded from the results. """ self._login() if locale is not None: return self._parse_device_list( self._micloud.get_devices(country=locale), locale=locale ) all_devices: Dict[str, CloudDeviceInfo] = {} for loc in AVAILABLE_LOCALES: devs = self.get_devices(locale=loc) for did, dev in devs.items(): if did in all_devices: _LOGGER.debug("Already seen device with %s, appending", did) all_devices[did].locale.extend(dev.locale) continue all_devices[did] = dev return all_devices @click.group(invoke_without_command=True) @click.option("--username", prompt=True) @click.option("--password", prompt=True) @click.pass_context def cloud(ctx: click.Context, username, password): """Cloud commands.""" try: import micloud # noqa: F401 except ImportError: _LOGGER.error("micloud is not installed, no cloud access available") raise CloudException("install micloud for cloud access") ctx.obj = CloudInterface(username=username, password=password) if ctx.invoked_subcommand is None: ctx.invoke(cloud_list) @cloud.command(name="list") @click.pass_context @click.option("--locale", prompt=True, type=click.Choice(AVAILABLE_LOCALES + ["all"])) @click.option("--raw", is_flag=True, default=False) def cloud_list(ctx: click.Context, locale: Optional[str], raw: bool): """List devices connected to the cloud account.""" ci = ctx.obj if locale == "all": locale = None devices = ci.get_devices(locale=locale) if raw: click.echo(f"Printing devices for {locale}") click.echo("===================================") for dev in devices.values(): pprint(dev.raw_data) # noqa: T203 click.echo("===================================") for dev in devices.values(): if dev.parent_id: continue # we handle children separately click.echo(f"== {dev.name} ({dev.description}) ==") click.echo(f"\tModel: {dev.model}") click.echo(f"\tToken: {dev.token}") click.echo(f"\tIP: {dev.ip} (mac: {dev.mac})") click.echo(f"\tDID: {dev.did}") click.echo(f"\tLocale: {', '.join(dev.locale)}") childs = [x for x in devices.values() if x.parent_id == dev.did] if childs: click.echo("\tSub devices:") for c in childs: click.echo(f"\t\t{c.name}") click.echo(f"\t\t\tDID: {c.did}") click.echo(f"\t\t\tModel: {c.model}") if not devices: click.echo(f"Unable to find devices for locale {locale}") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/cooker.py0000644000000000000000000005463514265350055013614 0ustar00import enum import logging import string from collections import defaultdict from datetime import time from typing import List, Optional import click from .click_common import command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) MODEL_PRESSURE1 = "chunmi.cooker.press1" MODEL_PRESSURE2 = "chunmi.cooker.press2" MODEL_NORMAL1 = "chunmi.cooker.normal1" MODEL_NORMAL2 = "chunmi.cooker.normal2" MODEL_NORMAL3 = "chunmi.cooker.normal3" MODEL_NORMAL4 = "chunmi.cooker.normal4" MODEL_NORMAL5 = "chunmi.cooker.normal5" MODEL_PRESSURE = [MODEL_PRESSURE1, MODEL_PRESSURE2] MODEL_NORMAL = [ MODEL_NORMAL1, MODEL_NORMAL2, MODEL_NORMAL3, MODEL_NORMAL4, MODEL_NORMAL5, ] MODEL_NORMAL_GROUP1 = [MODEL_NORMAL2, MODEL_NORMAL5] MODEL_NORMAL_GROUP2 = [MODEL_NORMAL3, MODEL_NORMAL4] COOKING_STAGES = { 0: { "name": "Quickly preheat", "description": "Increase temperature in a controlled manner to soften rice gradually", }, 1: { "name": "Water-absorbing", "description": "Increase temperature, to flesh grains with water", }, 2: {"name": "Boiling", "description": "Last high heating, to cook rice evenly"}, 3: { "name": "Gelantinizing", "description": "Steaming under high temperature, to bring sweetness to grains", }, 4: {"name": "Braising", "description": "Absorb water at moderate temperature"}, 5: { "name": "Boiling", "description": "Operate at full load to boil rice", # Keep heating at high temperature. Let rice to receive }, 7: { "name": "Boiling", "description": "Operate at full load to boil rice", # Keep heating at high temperature. Let rice to receive }, 8: { "name": "Warm up rice", "description": "Temperature control adjustment and cyclic heating " "achieve combination of taste, dolor and nutrition", }, 10: { "name": "High temperature gelatinization", "description": "High-temperature steam generates crystal clear rice g...", }, 16: {"name": "Cooking finished", "description": ""}, } class CookerException(DeviceException): pass class OperationMode(enum.Enum): # Observed Running = "running" Waiting = "waiting" AutoKeepWarm = "autokeepwarm" # Potential candidates Cooking = "cooking" Finish = "finish" FinishA = "finisha" KeepWarm = "keepwarm" KeepTemp = "keep_temp" Notice = "notice" Offline = "offline" Online = "online" PreCook = "precook" Resume = "resume" ResumeP = "resumep" Start = "start" StartP = "startp" Cancel = "Отмена" class TemperatureHistory(DeviceStatus): def __init__(self, data: str): """Container of temperatures recorded every 10-15 seconds while cooking. Example values: Status waiting: 0 2 minutes: 161515161c242a3031302f2eaa2f2f2e2f 12 minutes: 161515161c242a3031302f2eaa2f2f2e2f2e302f2e2d302f2f2e2f2f2f2f343a3f3f3d3e3c3d3c3f3d3d3d3f3d3d3d3d3e3d3e3c3f3f3d3e3d3e3e3d3f3d3c3e3d3d3e3d3f3e3d3f3e3d3c 32 minutes: 161515161c242a3031302f2eaa2f2f2e2f2e302f2e2d302f2f2e2f2f2f2f343a3f3f3d3e3c3d3c3f3d3d3d3f3d3d3d3d3e3d3e3c3f3f3d3e3d3e3e3d3f3d3c3e3d3d3e3d3f3e3d3f3e3d3c3f3e3d3c3f3e3d3c3f3f3d3d3e3d3d3f3f3d3d3f3f3e3d3d3d3e3e3d3daa3f3f3f3f3f414446474a4e53575e5c5c5b59585755555353545454555554555555565656575757575858585859595b5b5c5c5c5c5d5daa5d5e5f5f606061 55 minutes: 161515161c242a3031302f2eaa2f2f2e2f2e302f2e2d302f2f2e2f2f2f2f343a3f3f3d3e3c3d3c3f3d3d3d3f3d3d3d3d3e3d3e3c3f3f3d3e3d3e3e3d3f3d3c3e3d3d3e3d3f3e3d3f3e3d3c3f3e3d3c3f3e3d3c3f3f3d3d3e3d3d3f3f3d3d3f3f3e3d3d3d3e3e3d3daa3f3f3f3f3f414446474a4e53575e5c5c5b59585755555353545454555554555555565656575757575858585859595b5b5c5c5c5c5d5daa5d5e5f5f60606161616162626263636363646464646464646464646464646464646464646364646464646464646464646464646464646464646464646464646464aa5a59585756555554545453535352525252525151515151 Data structure: Octet 1 (16): First temperature measurement in hex (22 °C) Octet 2 (15): Second temperature measurement in hex (21 °C) Octet 3 (15): Third temperature measurement in hex (21 °C) ... """ if not len(data) % 2: self.data = [int(data[i : i + 2], 16) for i in range(0, len(data), 2)] else: self.data = [] @property def temperatures(self) -> List[int]: return self.data @property def raw(self) -> str: return "".join([f"{value:02x}" for value in self.data]) def __str__(self) -> str: return str(self.data) class CookerCustomizations(DeviceStatus): def __init__(self, custom: str): """Container of different user customizations. Example values: ffffffffffff011effff010000001d1f, ffffffffffff011effff010004026460, ffffffffffff011effff01000a015559, ffffffffffff011effff01000000535d Data structure: Octet 1 (ff): Jingzhu Appointment Hour in hex Octet 2 (ff): Jingzhu Appointment Minute in hex Octet 3 (ff): Kuaizhu Appointment Hour in hex Octet 4 (ff): Kuaizhu Appointment Minute in hex Octet 5 (ff): Zhuzhou Appointment Hour in hex Octet 6 (ff): Zhuzhou Appointment Minute in hex Octet 7 (01): Favorite Appointment Hour in hex (1 hour) Octet 8 (1e): Favorite Appointment Minute in hex (30 minutes) Octet 9 (ff): Favorite Cooking Hour in hex Octet 10 (ff): Favorite Cooking Minute in hex Octet 11-16 (01 00 00 00 1d 1f): Meaning unknown """ self.custom = [int(custom[i : i + 2], 16) for i in range(0, len(custom), 2)] @property def jingzhu_appointment(self) -> time: return time(hour=self.custom[0], minute=self.custom[1]) @property def kuaizhu_appointment(self) -> time: return time(hour=self.custom[2], minute=self.custom[3]) @property def zhuzhou_appointment(self) -> time: return time(hour=self.custom[4], minute=self.custom[5]) @property def zhuzhou_cooking(self) -> time: return time(hour=self.custom[6], minute=self.custom[7]) @property def favorite_appointment(self) -> time: return time(hour=self.custom[8], minute=self.custom[9]) @property def favorite_cooking(self) -> time: return time(hour=self.custom[10], minute=self.custom[11]) def __str__(self) -> str: return "".join([f"{value:02x}" for value in self.custom]) class CookingStage(DeviceStatus): def __init__(self, stage: str): """Container of cooking stages. Example timeouts: 'null', 02000000ff, 03000000ff, 0a000000ff, 1000000000 Data structure: Octet 1 (02): State in hex Octet 2-3 (0000): Rice ID in hex Octet 4 (00): Taste i n hex Octet 5 (ff): Meaning unknown. """ self.stage = stage @property def state(self) -> int: """ 10: Cooking finished 11: Cooking finished 12: Cooking finished """ return int(self.stage[0:2], 16) @property def rice_id(self) -> int: return int(self.stage[2:6], 16) @property def taste(self) -> int: return int(self.stage[6:8], 16) @property def taste_phase(self) -> int: phase = int(self.taste / 33) if phase > 2: return 2 return phase @property def name(self) -> str: try: return COOKING_STAGES[self.state]["name"] except KeyError: return "Unknown stage" @property def description(self) -> str: try: return COOKING_STAGES[self.state]["description"] except KeyError: return "" @property def raw(self) -> str: return self.stage class InteractionTimeouts(DeviceStatus): def __init__(self, timeouts: str = None): """Example timeouts: 05040f, 05060f. Data structure: Octet 1 (05): LED off timeout in hex (5 seconds) Octet 2 (04): Lid open timeout in hex (4 seconds) Octet 3 (0f): Lid open warning timeout (15 seconds) """ if timeouts is None: self.timeouts = [5, 4, 15] else: self.timeouts = [ int(timeouts[i : i + 2], 16) for i in range(0, len(timeouts), 2) ] @property def led_off(self) -> int: return self.timeouts[0] @led_off.setter def led_off(self, delay: int): self.timeouts[0] = delay @property def lid_open(self) -> int: return self.timeouts[1] @lid_open.setter def lid_open(self, timeout: int): self.timeouts[1] = timeout @property def lid_open_warning(self) -> int: return self.timeouts[2] @lid_open_warning.setter def lid_open_warning(self, timeout: int): self.timeouts[2] = timeout def __str__(self) -> str: return "".join([f"{value:02x}" for value in self.timeouts]) class CookerSettings(DeviceStatus): def __init__(self, settings: str = None): """Example settings: 1407, 0607, 0207. Data structure: Octet 1 (14): Bitmask of setting flags Bit 1: Pressure supported Bit 2: LED on Bit 3: Auto keep warm Bit 4: Lid open warning Bit 5: Lid open warning delayed Bit 6-8: Unused Octet 2 (07): Second bitmask of setting flags Bit 1: Jingzhu auto keep warm Bit 2: Kuaizhu auto keep warm Bit 3: Zhuzhou auto keep warm Bit 4: Favorite auto keep warm Bit 5-8: Unused """ if settings is None: self.settings = [0, 4] else: self.settings = [ int(settings[i : i + 2], 16) for i in range(0, len(settings), 2) ] @property def pressure_supported(self) -> bool: return self.settings[0] & 1 != 0 @pressure_supported.setter def pressure_supported(self, supported: bool): if supported: self.settings[0] |= 1 else: self.settings[0] &= 254 @property def led_on(self) -> bool: return self.settings[0] & 2 != 0 @led_on.setter def led_on(self, on: bool): if on: self.settings[0] |= 2 else: self.settings[0] &= 253 @property def auto_keep_warm(self) -> bool: return self.settings[0] & 4 != 0 @auto_keep_warm.setter def auto_keep_warm(self, keep_warm: bool): if keep_warm: self.settings[0] |= 4 else: self.settings[0] &= 251 @property def lid_open_warning(self) -> bool: return self.settings[0] & 8 != 0 @lid_open_warning.setter def lid_open_warning(self, alarm: bool): if alarm: self.settings[0] |= 8 else: self.settings[0] &= 247 @property def lid_open_warning_delayed(self) -> bool: return self.settings[0] & 16 != 0 @lid_open_warning_delayed.setter def lid_open_warning_delayed(self, alarm: bool): if alarm: self.settings[0] |= 16 else: self.settings[0] &= 239 @property def jingzhu_auto_keep_warm(self) -> bool: return self.settings[1] & 1 != 0 @jingzhu_auto_keep_warm.setter def jingzhu_auto_keep_warm(self, auto_keep_warm: bool): if auto_keep_warm: self.settings[1] |= 1 else: self.settings[1] &= 254 @property def kuaizhu_auto_keep_warm(self) -> bool: return self.settings[1] & 2 != 0 @kuaizhu_auto_keep_warm.setter def kuaizhu_auto_keep_warm(self, auto_keep_warm: bool): if auto_keep_warm: self.settings[1] |= 2 else: self.settings[1] &= 253 @property def zhuzhou_auto_keep_warm(self) -> bool: return self.settings[1] & 4 != 0 @zhuzhou_auto_keep_warm.setter def zhuzhou_auto_keep_warm(self, auto_keep_warm: bool): if auto_keep_warm: self.settings[1] |= 4 else: self.settings[1] &= 251 @property def favorite_auto_keep_warm(self) -> bool: return self.settings[1] & 8 != 0 @favorite_auto_keep_warm.setter def favorite_auto_keep_warm(self, auto_keep_warm: bool): if auto_keep_warm: self.settings[1] |= 8 else: self.settings[1] &= 247 def __str__(self) -> str: return "".join([f"{value:02x}" for value in self.settings]) class CookerStatus(DeviceStatus): def __init__(self, data): """Responses of a chunmi.cooker.normal2 (fw_ver: 1.2.8): { 'func': 'precook', 'menu': '0001', 'stage': '009ce63cff', 'temp': 21, 't_func': '769', 't_precook': '1180', 't_cook': 60, 'setting': '1407', 'delay': '05060f', 'version': '00030017', 'favorite': '0100', 'custom': '13281323ffff011effff010000001516'} { 'func': 'waiting', 'menu': '0001', 'stage': 'null', 'temp': 22, 't_func': 60, 't_precook': -1, 't_cook': 60, 'setting': '1407', 'delay': '05060f', 'version': '00030017', 'favorite': '0100', 'custom': '13281323ffff011effff010000001617'} func , menu , stage , temp , t_func, t_precook, t_cook, setting, delay , version , favorite, custom idle: ['waiting', '0001', 'null', '29', '60', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010000001d1f'] quickly preheat: ['running', '0001', '00000000ff', '031e0b23', '60', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010000001d1f'] absorb water at moderate temp: ['running', '0001', '02000000ff', '031e0b23', '54', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010002013e23'] absorb water at moderate temp: ['running', '0001', '02000000ff', '031e0b23', '48', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010002013f29'] operate at full load to boil rice: ['running', '0001', '03000000ff', '031e0b23', '39', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010003055332'] operate at full load to boil rice: ['running', '0001', '04000000ff', '031e0b23', '35', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010004026460'] operate at full load to boil rice: ['running', '0001', '06000000ff', '031e0b23', '29', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010006015c64'] high temperature gelatinization: ['running', '0001', '07000000ff', '031e0b23', '22', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff010007015d64'] temperature gelatinization: ['running', '0001', '0a000000ff', '031e0b23', '2', '-1', '60', '0607', '05040f', '00030017', '0100', 'ffffffffffff011effff01000a015559'] meal is ready: ['autokeepwarm', '0001', '1000000000', '031e0b23031e', '1', '750', '60', '0207', '05040f', '00030017', '0100', 'ffffffffffff011effff01000000535d'] """ self.data = data @property def mode(self) -> OperationMode: """Current operation mode.""" return OperationMode(self.data["func"]) @property def menu(self) -> int: """Selected recipe id.""" return int(self.data["menu"], 16) @property def stage(self) -> Optional[CookingStage]: """Current stage if cooking.""" stage = self.data["stage"] if len(stage) == 10: return CookingStage(stage) return None @property def temperature(self) -> Optional[int]: """Current temperature, if idle. Example values: *29*, 031e0b23, 031e0b23031e """ value = self.data["temp"] if len(value) == 2 and value.isdigit(): return int(value) return None @property def start_time(self) -> Optional[time]: """Start time of cooking? The property "temp" is used for different purposes. Example values: 29, *031e0b23*, 031e0b23031e """ value = self.data["temp"] if len(value) == 8: return time(hour=int(value[4:6], 16), minute=int(value[6:8], 16)) return None @property def remaining(self) -> int: """Remaining minutes of the cooking process.""" return int(self.data["t_func"]) @property def cooking_delayed(self) -> Optional[int]: """Wait n minutes before cooking / scheduled cooking.""" delay = int(self.data["t_precook"]) if delay >= 0: return delay return None @property def duration(self) -> int: """Duration of the cooking process.""" return int(self.data["t_cook"]) @property def settings(self) -> CookerSettings: """Settings of the cooker.""" return CookerSettings(self.data["setting"]) @property def interaction_timeouts(self) -> InteractionTimeouts: """Interaction timeouts.""" return InteractionTimeouts(self.data["delay"]) @property def hardware_version(self) -> int: """Hardware version.""" return int(self.data["version"][0:4], 16) @property def firmware_version(self) -> int: """Firmware version.""" return int(self.data["version"][4:8], 16) @property def favorite(self) -> int: """Favored recipe id. Can be compared with the menu property. """ return int(self.data["favorite"], 16) @property def custom(self) -> Optional[CookerCustomizations]: custom = self.data["custom"] if len(custom) > 31: return CookerCustomizations(custom) return None class Cooker(Device): """Main class representing the chunmi.cooker.*.""" _supported_models = [*MODEL_NORMAL, *MODEL_PRESSURE] @command( default_output=format_output( "", "Mode: {result.mode}\n" "Menu: {result.menu}\n" "Stage: {result.stage}\n" "Temperature: {result.temperature}\n" "Start time: {result.start_time}\n" "Remaining: {result.remaining}\n" "Cooking delayed: {result.cooking_delayed}\n" "Duration: {result.duration}\n" "Settings: {result.settings}\n" "Interaction timeouts: {result.interaction_timeouts}\n" "Hardware version: {result.hardware_version}\n" "Firmware version: {result.firmware_version}\n" "Favorite: {result.favorite}\n" "Custom: {result.custom}\n", ) ) def status(self) -> CookerStatus: """Retrieve properties.""" properties = [ "func", "menu", "stage", "temp", "t_func", "t_precook", "t_cook", "setting", "delay", "version", "favorite", "custom", ] """ Some cookers doesn't support a list of properties here. Therefore "all" properties are requested. If the property count or order changes the property list above must be updated. """ # noqa: B018 values = self.send("get_prop", ["all"]) properties_count = len(properties) values_count = len(values) if properties_count != values_count: _LOGGER.debug( "Count (%s) of requested properties does not match the " "count (%s) of received values.", properties_count, values_count, ) return CookerStatus(defaultdict(lambda: None, zip(properties, values))) @command( click.argument("profile", type=str), default_output=format_output("Cooking profile started"), ) def start(self, profile: str): """Start cooking a profile.""" if not self._validate_profile(profile): raise CookerException("Invalid cooking profile: %s" % profile) self.send("set_start", [profile]) @command(default_output=format_output("Cooking stopped")) def stop(self): """Stop cooking.""" self.send("set_func", ["end02"]) @command(default_output=format_output("Cooking stopped")) def stop_outdated_firmware(self): """Stop cooking (obsolete).""" self.send("set_func", ["end"]) @command(default_output=format_output("Setting no warnings")) def set_no_warnings(self): """Disable warnings.""" self.send("set_func", ["nowarn"]) @command(default_output=format_output("Setting acknowledge")) def set_acknowledge(self): """Enable warnings?""" self.send("set_func", ["ack"]) # FIXME: Add unified CLI support def set_interaction(self, settings: CookerSettings, timeouts: InteractionTimeouts): """Set interaction. Supported by all cookers except MODEL_PRESS1 """ self.send( "set_interaction", [ str(settings), f"{timeouts.led_off:x}", f"{timeouts.lid_open:x}", f"{timeouts.lid_open_warning:x}", ], ) @command( click.argument("profile", type=str), default_output=format_output("Setting menu to {profile}"), ) def set_menu(self, profile: str): """Select one of the default(?) cooking profiles.""" if not self._validate_profile(profile): raise CookerException("Invalid cooking profile: %s" % profile) self.send("set_menu", [profile]) @command(default_output=format_output("", "Temperature history: {result}\n")) def get_temperature_history(self) -> TemperatureHistory: """Retrieves a temperature history. The temperature is only available while cooking. Approx. six data points per minute. """ data = self.send("get_temp_history") return TemperatureHistory(data[0]) @staticmethod def _validate_profile(profile): return all(c in string.hexdigits for c in profile) and len(profile) in [ 228, 242, ] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/curtain_youpin.py0000644000000000000000000001606114265350055015371 0ustar00import enum import logging from typing import Any, Dict import click from .click_common import EnumType, command, format_output from .miot_device import DeviceStatus, MiotDevice _LOGGER = logging.getLogger(__name__) # Model: ZNCLDJ21LM (also known as "Xiaomiyoupin Curtain Controller (Wi-Fi)" MODEL_CURTAIN_HAGL05 = "lumi.curtain.hagl05" _MAPPINGS = { MODEL_CURTAIN_HAGL05: { # # Source http://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:curtain:0000A00C:lumi-hagl05:1 # Curtain "motor_control": { "siid": 2, "piid": 2, }, # 0 - Pause, 1 - Open, 2 - Close, 3 - auto "current_position": {"siid": 2, "piid": 3}, # Range: [0, 100, 1] "status": {"siid": 2, "piid": 6}, # 0 - Stopped, 1 - Opening, 2 - Closing "target_position": {"siid": 2, "piid": 7}, # Range: [0, 100, 1] # curtain_cfg "is_manual_enabled": {"siid": 4, "piid": 1}, # "polarity": {"siid": 4, "piid": 2}, "is_position_limited": {"siid": 4, "piid": 3}, "night_tip_light": {"siid": 4, "piid": 4}, "run_time": {"siid": 4, "piid": 5}, # Range: [0, 255, 1] # motor_controller "adjust_value": {"siid": 5, "piid": 1}, # Range: [-100, 100, 1] } } class MotorControl(enum.Enum): Pause = 0 Open = 1 Close = 2 Auto = 3 class Status(enum.Enum): Stopped = 0 Opening = 1 Closing = 2 class Polarity(enum.Enum): Positive = 0 Reverse = 1 class CurtainStatus(DeviceStatus): def __init__(self, data: Dict[str, Any]) -> None: """Response from device. {'id': 1, 'result': [ {'did': 'current_position', 'siid': 2, 'piid': 3, 'code': 0, 'value': 0}, {'did': 'status', 'siid': 2, 'piid': 6, 'code': 0, 'value': 0}, {'did': 'target_position', 'siid': 2, 'piid': 7, 'code': 0, 'value': 0}, {'did': 'is_manual_enabled', 'siid': 4, 'piid': 1, 'code': 0, 'value': 1}, {'did': 'polarity', 'siid': 4, 'piid': 2, 'code': 0, 'value': 0}, {'did': 'is_position_limited', 'siid': 4, 'piid': 3, 'code': 0, 'value': 0}, {'did': 'night_tip_light', 'siid': 4, 'piid': 4, 'code': 0, 'value': 1}, {'did': 'run_time', 'siid': 4, 'piid': 5, 'code': 0, 'value': 0}, {'did': 'adjust_value', 'siid': 5, 'piid': 1, 'code': -4000} ]} """ self.data = data @property def status(self) -> Status: """Device status.""" return Status(self.data["status"]) @property def is_manual_enabled(self) -> bool: """True if manual controls are enabled.""" return bool(self.data["is_manual_enabled"]) @property def polarity(self) -> Polarity: """Motor rotation polarity.""" return Polarity(self.data["polarity"]) @property def is_position_limited(self) -> bool: """Position limit.""" return bool(self.data["is_position_limited"]) @property def night_tip_light(self) -> bool: """Night tip light status.""" return bool(self.data["night_tip_light"]) @property def run_time(self) -> int: """Run time of the motor.""" return self.data["run_time"] @property def current_position(self) -> int: """Current curtain position.""" return self.data["current_position"] @property def target_position(self) -> int: """Target curtain position.""" return self.data["target_position"] @property def adjust_value(self) -> int: """Adjust value.""" return self.data["adjust_value"] class CurtainMiot(MiotDevice): """Main class representing the lumi.curtain.hagl05 curtain.""" _mappings = _MAPPINGS @command( default_output=format_output( "", "Device status: {result.status}\n" "Manual enabled: {result.is_manual_enabled}\n" "Motor polarity: {result.polarity}\n" "Position limit: {result.is_position_limited}\n" "Enabled night tip light: {result.night_tip_light}\n" "Run time: {result.run_time}\n" "Current position: {result.current_position}\n" "Target position: {result.target_position}\n" "Adjust value: {result.adjust_value}\n", ) ) def status(self) -> CurtainStatus: """Retrieve properties.""" return CurtainStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command( click.argument("motor_control", type=EnumType(MotorControl)), default_output=format_output("Set motor control to {motor_control}"), ) def set_motor_control(self, motor_control: MotorControl): """Set motor control.""" return self.set_property("motor_control", motor_control.value) @command( click.argument("target_position", type=int), default_output=format_output("Set target position to {target_position}"), ) def set_target_position(self, target_position: int): """Set target position.""" if target_position < 0 or target_position > 100: raise ValueError( "Value must be between [0, 100] value, was %s" % target_position ) return self.set_property("target_position", target_position) @command( click.argument("manual_enabled", type=bool), default_output=format_output("Set manual control {manual_enabled}"), ) def set_manual_enabled(self, manual_enabled: bool): """Set manual control of curtain.""" return self.set_property("is_manual_enabled", manual_enabled) @command( click.argument("polarity", type=EnumType(Polarity)), default_output=format_output("Set polarity to {polarity}"), ) def set_polarity(self, polarity: Polarity): """Set polarity of the motor.""" return self.set_property("polarity", polarity.value) @command( click.argument("pos_limit", type=bool), default_output=format_output("Set position limit to {pos_limit}"), ) def set_position_limit(self, pos_limit: bool): """Set position limit parameter.""" return self.set_property("is_position_limited", pos_limit) @command( click.argument("night_tip_light", type=bool), default_output=format_output("Setting night tip light {night_tip_light"), ) def set_night_tip_light(self, night_tip_light: bool): """Set night tip light.""" return self.set_property("night_tip_light", night_tip_light) @command( click.argument("adjust_value", type=int), default_output=format_output("Set adjust value to {adjust_value}"), ) def set_adjust_value(self, adjust_value: int): """Adjust to preferred position.""" if adjust_value < -100 or adjust_value > 100: raise ValueError( "Value must be between [-100, 100] value, was %s" % adjust_value ) return self.set_property("adjust_value", adjust_value) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/data/cooker_profiles.json0000644000000000000000000003160714265350055016743 0ustar00{ "MODEL_PRESSURE": [ { "title": "Jingzhu", "description": "60 minutes cooking for tasty rice", "profile": "0001E10100000000000080026E10082B126E1412698CAA555555550014280A6E0C02050506050505055A14040A0C0C0D00040505060A0F086E6E20000C0A5A28036468686A0004040500000000000000010202020204040506070708001212180C1E2D2D37000000000000000000000099A5" }, { "title": "Kuaizhu", "description": "Quick 40 minutes cooking", "profile": "0002E10028000000000080026E10082B056E1412698CAA55555555001428145A10070707070C0E0E105A14060A0C0C0E00090909091E14046E6E200010065A28035050505000040405000000000000030203030302040405070C0C0C00121218100F0F0F3200000000000000000000008914" }, { "title": "Zhuzhou", "description": "Cooking on slow fire from 40 minutes to 4 hours", "profile": "0003E2011E04000028008000145A46736E140F200000027382736E14002000001E695F736E140C200000017882736E1400200000F07D82735A2300200000000000000000000000000000000000000000000001507896030303035F624B085555555580191028036E0000000000000000DDA4" }, { "title": "Baowen", "description": "Keeping warm at 73 degrees", "profile": "00040C1800180000010000107891826E6E14002000001E464B6E6E140A2000000000000000000000000000000000000000000000F08282446E140020080800000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000005093" }, { "title": "Cake", "description": "Baking for 40-60 minutes", "profile": "010088003201000028000012000000000000000000000846822A6E14002018000F6E82736E140A201810000000000000000000003C8782716E1400200A100000000000000000000000000000000000000000000000000000000000003C0A000000008700000000000000000000000000424D" }, { "title": "Yoghurt", "description": "6-12 hours for yogurt fermentation", "profile": "01010908000C0006000000101E23736E6E1405200000000000000000000000000000000000000000000000000000000000000000F06E73246E140020000C0000000000000000000000000000000000000000020000000000000000000000000000004900000000290000000000000000424D" }, { "title": "Refan", "description": "Cooking rice at 90 degrees", "profile": "010264001e0023001900800000000000000000000000000000000000000000000f5582736e140a20180000000000000000000000148273735a1408201800000000000000000000000982735a6e140020100a000000000808080869694b0a000000008700000000000000000000000000ddf2" }, { "title": "Cooking", "description": "Steaming at 100 degrees", "profile": "010326001e0100000a00800000000000000000000000000000000000000000001e695f736e140f200000000000000000000000003c80736e5a0d081400000000000000000000000000000000000000000000015078960808080873694b0e545656568000000000000000000000000000fa31" }, { "title": "Sweet rice", "description": "75 minutes cooking to preserve taste of the food", "profile": "010461010F000000000080026E10082B126E1412698CAA55555555001428145A1005070708090909095A14060D0D0D0F001E1E1E1E0A14066E6E20000C0A5A2803505050500004040500000000000003020303030202020206070708001212180C1E2D2D370000000000000000000000424D" }, { "title": "Quick rice", "description": "Cooking for 30 minutes", "profile": "010561001e000000000080026e10082b006e141278a0be55555555001428145a1005070708090909095a140614140e0e00050505050a14076e6e20000c035a2803505050500004040500000000000003020303030202020212120c0c121212180c1e2d2d370000000000000000000000918b" } ], "MODEL_NORMAL_GROUP1": [ { "title": "Jingzhu", "description": "60 minutes cooking for tasty rice", "profile": "0001E101000000000000800200A00069030103730000085A020000EB006B040103740000095A0400012D006E0501037400000A5A0401FFFF00700601047600000C5A0401052D0A0F3C0A1E91FF820E01FF05FF78826EFF10FF786E02690F0DFF826EFF691000FF826EFF69100069FF5AFF00000000000081E9" }, { "title": "Kuaizhu", "description": "Quick 40 minutes cooking", "profile": "0002E100280000000000800200D20069030103730000075A0200012D006B040103740000075A02000182006E050003740000095A0401FFFF0070060004760000095A040100280A063C0D1E91FF820E01FF05FF78826EFF10FF786E02690F0FFF826EFF69100082826EFF69100069FF5AFF00000000000015B6" }, { "title": "Zhuzhou", "description": "Cooking on slow fire from 40 minutes to 4 hours", "profile": "0003E2011E040000280080000190551C0601001E00000000000001B8551C0601002300000000000001E0561C0600002E000000000000FFFF571C0600003000000000000000280A0082001E914E730E01001E82FF736E0610FF756E02690A1E75826E0269100F75826E0269100069005A0000000000000000CB" }, { "title": "Baowen", "description": "Keeping warm at 73 degrees", "profile": "00040C180018000001000045000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A0082001E914E730801001E82FF736E0610FF756E02690A0F75826E0169101E75826E0169100069005A000000000000001BA2" }, { "title": "Cake", "description": "Baking for 40-60 minutes", "profile": "010088010001000028000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C00089178320600001E8278666E041000826E00690FFF96666E0469100082826E0069100069005A00000000000000B267" }, { "title": "Yoghurt", "description": "6-12 hours for yogurt fermentation", "profile": "01010908000C000600000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A0F270300915573000000008278736E001000756E0028050075826E002810FF75276E0228100069005A00000000000000B9BF" }, { "title": "Refan", "description": "Cooking rice at 90 degrees", "profile": "010264001E00230019010540000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C000F91416E080000008278736E001000756E00690F0C75506E0669100082826E0869100069025004000000000000F5C6" }, { "title": "Cooking", "description": "Steaming at 100 degrees", "profile": "010366001E0100000A010540000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C001E9155730E0000008278736E001000756E00690A0075826E006910FF756E6E0869100069005A00000000000000F462" }, { "title": "Sweet rice", "description": "70 minutes cooking to preserve taste of the food", "profile": "0104E1010A0000000000800200A00069030102780000085A020000EB006B040102780000085A0400012D006E0501027D0000065A0400FFFF00700601027D0000065A0400052D0A0F3C0A1E91FF820E01FF05FF78826EFF10FF786E02690F1EFF826EFF691400FF826EFF69100069FF5AFF00000000000042A7" } ], "MODEL_NORMAL_GROUP2": [ { "title": "Jingzhu", "description": "60 minutes cooking for tasty rice", "profile": "0001E101000000000000800200A00069030103730000085A020000EB006B040103740000095A0400012D006E0501037400000A5A0401FFFF00700601047600000C5A0401052D0A0F3C0A1E91FF820E01FF05FF78826EFF10FF786E02690F0DFF826EFF691000FF826EFF69100069FF5AFF00000000000081E9" }, { "title": "Kuaizhu", "description": "Quick 40 minutes cooking", "profile": "0002E100280000000000800200D20069030103730000075A0200012D006B040103740000075A02000182006E050003740000095A0401FFFF0070060004760000095A040100280A063C0D1E91FF820E01FF05FF78826EFF10FF786E02690F0FFF826EFF69100082826EFF69100069FF5AFF00000000000015B6" }, { "title": "Zhuzhou", "description": "Cooking on slow fire from 40 minutes to 4 hours", "profile": "0003E2011E04000028008000019055140601001600000000000001B855140601001900000000000001E0561406000020000000000000FFFF57140600002200000000000000280A0082001E914E730E01001E82FF736E0610FF756E02690A1E75826E0269100F75826E0269100069005A000000000000001A2B" }, { "title": "Baowen", "description": "Keeping warm at 73 degrees", "profile": "00040C180018000001000045000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A0082001E914E730801001E82FF736E0610FF756E02690A0F75826E0169101E75826E0169100069005A000000000000001BA2" }, { "title": "Cake", "description": "Baking for 40-60 minutes", "profile": "010088010001000028000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C00089178320600001E8278666E041000826E00690FFF96666E0469100082826E0069100069005A00000000000000B267" }, { "title": "Yoghurt", "description": "6-12 hours for yogurt fermentation", "profile": "01010908000C000600000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A0F270300915573000000008278736E001000756E0028050075826E002810FF75276E0228100069005A00000000000000B9BF" }, { "title": "Refan", "description": "Cooking rice at 90 degrees", "profile": "010264001E00230019010540000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C000F91416E080000008278736E001000756E00690F0C75506E0669100082826E0869100069025004000000000000F5C6" }, { "title": "Cooking", "description": "Steaming at 100 degrees", "profile": "0103E6001E0100000A0105400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C00089155730E0000008278736E001000756E00690A0075826E006910FF756E6E0869100069005A00000000000000000000009EB6" }, { "title": "Sweet rice", "description": "70 minutes cooking to preserve taste of the food", "profile": "0104E1010A0000000000800200A00069030102780000085A020000EB006B040102780000085A0400012D006E0501027D0000065A0400FFFF00700601027D0000065A0400052D0A0F3C0A1E91FF820E01FF05FF78826EFF10FF786E02690F1EFF826EFF691400FF826EFF69100069FF5AFF00000000000042A7" } ], "MODEL_NORMAL1": [ { "title": "Jingzhu", "description": "60 minutes cooking for tasty rice", "profile": "0001E101000000000000800200A00069030103730000085A020000EB006B040103740000095A0400012D006E0501037400000A5A0401FFFF00700601047600000C5A0401052D0A0F3C0A1E91FF820E01FF05FF78826EFF10FF786E02690F0DFF826EFF691000FF826EFF69100069FF5AFF00000000000081E9" }, { "title": "Kuaizhu", "description": "Quick 40 minutes cooking", "profile": "0002E100280000000000800200D20069030103730000075A0200012D006B040103740000075A02000182006E050003740000095A0401FFFF0070060004760000095A040100280A063C0D1E91FF820E01FF05FF78826EFF10FF786E02690F0FFF826EFF69100082826EFF69100069FF5AFF00000000000015B6" }, { "title": "Zhuzhou", "description": "Cooking on slow fire from 40 minutes to 4 hours", "profile": "0003E2011E04000028008000019055140601001600000000000001B855140601001900000000000001E0561406000020000000000000FFFF57140600002200000000000000280A0082001E914E730E01001E82FF736E0610FF756E02690A1E75826E0269100F75826E0269100069005A000000000000001A2B" }, { "title": "Baowen", "description": "Keeping warm at 73 degrees", "profile": "00040C180018000001000045000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A0082001E914E730801001E82FF736E0610FF756E02690A0F75826E0169101E75826E0169100069005A000000000000001BA2" }, { "title": "Cake", "description": "Baking for 40-60 minutes", "profile": "010088010001000028000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C00089178320600001E8278666E041000826E00690FFF96666E0469100082826E0069100069005A00000000000000B267" }, { "title": "Yoghurt", "description": "6-12 hours for yogurt fermentation", "profile": "01010908000C000600000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A0F270300915573000000008278736E001000756E0028050075826E002810FF75276E0228100069005A00000000000000B9BF" }, { "title": "Refan", "description": "Cooking rice at 90 degrees", "profile": "010264001E00230019010540000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C000F91416E080000008278736E001000756E00690F0C75506E0669100082826E0869100069025004000000000000F5C6" }, { "title": "Cooking", "description": "Steaming at 100 degrees", "profile": "010366001E0100000A010540000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000280A003C001E9155730E0000008278736E001000756E00690A0075826E006910FF756E6E0869100069005A00000000000000F462" }, { "title": "Sweet rice", "description": "70 minutes cooking to preserve taste of the food", "profile": "0104E1010A0000000000800200A00069030102780000085A020000EB006B040102780000085A0400012D006E0501027D0000065A0400FFFF00700601027D0000065A0400052D0A0F3C0A1E91FF820E01FF05FF78826EFF10FF786E02690F1EFF826EFF691400FF826EFF69100069FF5AFF00000000000042A7" } ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/device.py0000644000000000000000000003005614265350055013560 0ustar00import inspect import logging import warnings from enum import Enum from pprint import pformat as pf from typing import Any, Dict, List, Optional # noqa: F401 import click from .click_common import DeviceGroupMeta, LiteralParamType, command, format_output from .deviceinfo import DeviceInfo from .exceptions import DeviceInfoUnavailableException, PayloadDecodeException from .miioprotocol import MiIOProtocol _LOGGER = logging.getLogger(__name__) class UpdateState(Enum): Downloading = "downloading" Installing = "installing" Failed = "failed" Idle = "idle" class DeviceStatus: """Base class for status containers. All status container classes should inherit from this class. The __repr__ implementation returns all defined properties and their values. """ def __repr__(self): props = inspect.getmembers(self.__class__, lambda o: isinstance(o, property)) s = f"<{self.__class__.__name__}" for prop_tuple in props: name, prop = prop_tuple try: # ignore deprecation warnings with warnings.catch_warnings(): prop_value = prop.fget(self) except Exception as ex: prop_value = ex.__class__.__name__ s += f" {name}={prop_value}" s += ">" return s class Device(metaclass=DeviceGroupMeta): """Base class for all device implementations. This is the main class providing the basic protocol handling for devices using the ``miIO`` protocol. This class should not be initialized directly but a device- specific class inheriting it should be used instead of it. """ retry_count = 3 timeout = 5 _mappings: Dict[str, Any] = {} _supported_models: List[str] = [] def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, timeout: int = None, *, model: str = None, ) -> None: self.ip = ip self.token: Optional[str] = token self._model: Optional[str] = model self._info: Optional[DeviceInfo] = None timeout = timeout if timeout is not None else self.timeout self._protocol = MiIOProtocol( ip, token, start_id, debug, lazy_discover, timeout ) def send( self, command: str, parameters: Any = None, retry_count: int = None, *, extra_parameters=None, ) -> Any: """Send a command to the device. Basic format of the request: {"id": 1234, "method": command, "parameters": parameters} `extra_parameters` allows passing elements to the top-level of the request. This is necessary for some devices, such as gateway devices, which expect the sub-device identifier to be on the top-level. :param str command: Command to send :param dict parameters: Parameters to send :param int retry_count: How many times to retry on error :param dict extra_parameters: Extra top-level parameters :param str model: Force model to avoid autodetection """ retry_count = retry_count if retry_count is not None else self.retry_count return self._protocol.send( command, parameters, retry_count, extra_parameters=extra_parameters ) def send_handshake(self): """Send initial handshake to the device.""" return self._protocol.send_handshake() @command( click.argument("command", type=str, required=True), click.argument("parameters", type=LiteralParamType(), required=False), ) def raw_command(self, command, parameters): """Send a raw command to the device. This is mostly useful when trying out commands which are not implemented by a given device instance. :param str command: Command to send :param dict parameters: Parameters to send """ return self.send(command, parameters) @command( default_output=format_output( "", "Model: {result.model}\n" "Hardware version: {result.hardware_version}\n" "Firmware version: {result.firmware_version}\n", ), skip_autodetect=True, ) def info(self, *, skip_cache=False) -> DeviceInfo: """Get (and cache) miIO protocol information from the device. This includes information about connected wlan network, and hardware and software versions. :param skip_cache bool: Skip the cache """ if self._info is not None and not skip_cache: return self._info return self._fetch_info() def _fetch_info(self) -> DeviceInfo: """Perform miIO.info query on the device and cache the result.""" try: devinfo = DeviceInfo(self.send("miIO.info")) self._info = devinfo _LOGGER.debug("Detected model %s", devinfo.model) cls = self.__class__.__name__ bases = ["Device", "MiotDevice"] if devinfo.model not in self.supported_models and cls not in bases: _LOGGER.warning( "Found an unsupported model '%s' for class '%s'. If this is working for you, please open an issue at https://github.com/rytilahti/python-miio/", devinfo.model, cls, ) return devinfo except PayloadDecodeException as ex: raise DeviceInfoUnavailableException( "Unable to request miIO.info from the device" ) from ex @property def device_id(self) -> int: """Return device id (did), if available.""" if not self._protocol._device_id: self.send_handshake() return int.from_bytes(self._protocol._device_id, byteorder="big") @property def raw_id(self) -> int: """Return the last used protocol sequence id.""" return self._protocol.raw_id @property def supported_models(self) -> List[str]: """Return a list of supported models.""" return list(self._mappings.keys()) or self._supported_models @property def model(self) -> str: """Return device model.""" if self._model is not None: return self._model return self.info().model def update(self, url: str, md5: str): """Start an OTA update.""" payload = { "mode": "normal", "install": "1", "app_url": url, "file_md5": md5, "proc": "dnld install", } return self.send("miIO.ota", payload)[0] == "ok" def update_progress(self) -> int: """Return current update progress [0-100].""" return self.send("miIO.get_ota_progress")[0] def update_state(self): """Return current update state.""" return UpdateState(self.send("miIO.get_ota_state")[0]) def configure_wifi(self, ssid, password, uid=0, extra_params=None): """Configure the wifi settings.""" if extra_params is None: extra_params = {} params = {"ssid": ssid, "passwd": password, "uid": uid, **extra_params} return self.send("miIO.config_router", params)[0] def get_properties( self, properties, *, property_getter="get_prop", max_properties=None ): """Request properties in slices based on given max_properties. This is necessary as some devices have limitation on how many properties can be queried at once. If `max_properties` is None, all properties are requested at once. :param list properties: List of properties to query from the device. :param int max_properties: Number of properties that can be requested at once. :return List of property values. """ _props = properties.copy() values = [] while _props: values.extend(self.send(property_getter, _props[:max_properties])) if max_properties is None: break _props[:] = _props[max_properties:] properties_count = len(properties) values_count = len(values) if properties_count != values_count: _LOGGER.debug( "Count (%s) of requested properties does not match the " "count (%s) of received values.", properties_count, values_count, ) return values @command( click.argument("properties", type=str, nargs=-1, required=True), ) def test_properties(self, properties): """Helper to test device properties.""" def ok(x): click.echo(click.style(str(x), fg="green", bold=True)) def fail(x): click.echo(click.style(str(x), fg="red", bold=True)) try: model = self.info().model except Exception as ex: _LOGGER.warning("Unable to obtain device model: %s", ex) model = "" click.echo(f"Testing properties {properties} for {model}") valid_properties = {} max_property_len = max(len(p) for p in properties) for property in properties: try: click.echo(f"Testing {property:{max_property_len+2}} ", nl=False) value = self.get_properties([property]) # Handle list responses if isinstance(value, list): # unwrap single-element lists if len(value) == 1: value = value.pop() # report on unexpected multi-element lists elif len(value) > 1: _LOGGER.error("Got an array as response: %s", value) # otherwise we received an empty list, which we consider here as None else: value = None if value is None: fail("None") else: valid_properties[property] = value ok(f"{repr(value)} {type(value)}") except Exception as ex: _LOGGER.warning("Unable to request %s: %s", property, ex) click.echo( f"Found {len(valid_properties)} valid properties, testing max_properties.." ) props_to_test = list(valid_properties.keys()) max_properties = -1 while len(props_to_test) > 0: try: click.echo( f"Testing {len(props_to_test)} properties at once ({' '.join(props_to_test)}): ", nl=False, ) resp = self.get_properties(props_to_test) if len(resp) == len(props_to_test): max_properties = len(props_to_test) ok(f"OK for {max_properties} properties") break else: removed_property = props_to_test.pop() fail( f"Got different amount of properties ({len(props_to_test)}) than requested ({len(resp)}), removing {removed_property}" ) except Exception as ex: removed_property = props_to_test.pop() msg = f"Unable to request properties: {ex} - removing {removed_property} for next try" _LOGGER.warning(msg) fail(ex) non_empty_properties = { k: v for k, v in valid_properties.items() if v is not None } click.echo( click.style("\nPlease copy the results below to your report", bold=True) ) click.echo("### Results ###") click.echo(f"Model: {model}") _LOGGER.debug(f"All responsive properties:\n{pf(valid_properties)}") click.echo(f"Total responsives: {len(valid_properties)}") click.echo(f"Total non-empty: {len(non_empty_properties)}") click.echo(f"All non-empty properties:\n{pf(non_empty_properties)}") click.echo(f"Max properties: {max_properties}") return "Done" def __repr__(self): return f"<{self.__class__.__name__ }: {self.ip} (token: {self.token})>" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/deviceinfo.py0000644000000000000000000000506514265350055014436 0ustar00from typing import Dict, Optional class DeviceInfo: """Container of miIO device information. Hardware properties such as device model, MAC address, memory information, and hardware and software information is contained here. """ def __init__(self, data): """Response of a Xiaomi Smart WiFi Plug. {'ap': {'bssid': 'FF:FF:FF:FF:FF:FF', 'rssi': -68, 'ssid': 'network'}, 'cfg_time': 0, 'fw_ver': '1.2.4_16', 'hw_ver': 'MW300', 'life': 24, 'mac': '28:FF:FF:FF:FF:FF', 'mmfree': 30312, 'model': 'chuangmi.plug.m1', 'netif': {'gw': '192.168.xxx.x', 'localIp': '192.168.xxx.x', 'mask': '255.255.255.0'}, 'ot': 'otu', 'ott_stat': [0, 0, 0, 0], 'otu_stat': [320, 267, 3, 0, 3, 742], 'token': '2b00042f7481c7b056c4b410d28f33cf', 'wifi_fw_ver': 'SD878x-14.76.36.p84-702.1.0-WM'} """ self.data = data def __repr__(self): return "{} v{} ({}) @ {} - token: {}".format( self.model, self.firmware_version, self.mac_address, self.ip_address, self.token, ) @property def network_interface(self) -> Dict: """Information about network configuration. If unavailable, returns an empty dictionary. """ return self.data.get("netif", {}) @property def accesspoint(self): """Information about connected wlan accesspoint. If unavailable, returns an empty dictionary. """ return self.data.get("ap", {}) @property def model(self) -> Optional[str]: """Model string if available.""" return self.data.get("model") @property def firmware_version(self) -> Optional[str]: """Firmware version if available.""" return self.data.get("fw_ver") @property def hardware_version(self) -> Optional[str]: """Hardware version if available.""" return self.data.get("hw_ver") @property def mac_address(self) -> Optional[str]: """MAC address, if available.""" return self.data.get("mac") @property def ip_address(self) -> Optional[str]: """IP address, if available.""" return self.network_interface.get("localIp") @property def token(self) -> Optional[str]: """Return the current device token.""" return self.data.get("token") @property def raw(self): """Raw data as returned by the device.""" return self.data ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/discovery.py0000644000000000000000000002545714265350055014341 0ustar00import codecs import inspect import logging import time from functools import partial from ipaddress import ip_address from typing import Callable, Dict, Optional, Type, Union # noqa: F401 import zeroconf from miio.integrations.airpurifier import ( AirDogX3, AirFresh, AirFreshT2017, AirPurifier, AirPurifierMiot, ) from miio.integrations.humidifier import ( AirHumidifier, AirHumidifierJsq, AirHumidifierJsqs, AirHumidifierMjjsq, ) from miio.integrations.vacuum import DreameVacuum, RoborockVacuum, ViomiVacuum from . import ( AirConditionerMiot, AirConditioningCompanion, AirConditioningCompanionMcn02, AirQualityMonitor, AqaraCamera, Ceil, ChuangmiCamera, ChuangmiIr, ChuangmiPlug, Cooker, Device, Gateway, Heater, PowerStrip, Toiletlid, WaterPurifier, WaterPurifierYunmi, WifiRepeater, WifiSpeaker, ) from .airconditioningcompanion import ( MODEL_ACPARTNER_V1, MODEL_ACPARTNER_V2, MODEL_ACPARTNER_V3, ) from .airconditioningcompanionMCN import MODEL_ACPARTNER_MCN02 from .airqualitymonitor import ( MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_S1, MODEL_AIRQUALITYMONITOR_V1, ) from .alarmclock import AlarmClock from .chuangmi_plug import ( MODEL_CHUANGMI_PLUG_HMI205, MODEL_CHUANGMI_PLUG_HMI206, MODEL_CHUANGMI_PLUG_M1, MODEL_CHUANGMI_PLUG_M3, MODEL_CHUANGMI_PLUG_V1, MODEL_CHUANGMI_PLUG_V2, MODEL_CHUANGMI_PLUG_V3, ) from .heater import MODEL_HEATER_MA1, MODEL_HEATER_ZA1 from .integrations.fan import Fan, FanLeshow, FanMiot, FanZA5 from .integrations.light import ( PhilipsBulb, PhilipsEyecare, PhilipsMoonlight, PhilipsRwread, PhilipsWhiteBulb, Yeelight, ) from .powerstrip import MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2 from .toiletlid import MODEL_TOILETLID_V1 _LOGGER = logging.getLogger(__name__) DEVICE_MAP: Dict[str, Union[Type[Device], partial]] = { "rockrobo-vacuum-v1": RoborockVacuum, "roborock-vacuum-s5": RoborockVacuum, "roborock-vacuum-m1s": RoborockVacuum, "roborock-vacuum-a10": RoborockVacuum, "chuangmi-plug-m1": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_M1), "chuangmi-plug-m3": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_M3), "chuangmi-plug-v1": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_V1), "chuangmi-plug-v2": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_V2), "chuangmi-plug-v3": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_V3), "chuangmi-plug-hmi205": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_HMI205), "chuangmi-plug-hmi206": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_HMI206), "chuangmi-plug_": partial(ChuangmiPlug, model=MODEL_CHUANGMI_PLUG_V1), "qmi-powerstrip-v1": partial(PowerStrip, model=MODEL_POWER_STRIP_V1), "zimi-powerstrip-v2": partial(PowerStrip, model=MODEL_POWER_STRIP_V2), "zimi-clock-myk01": AlarmClock, "xiaomi.aircondition.mc1": AirConditionerMiot, "xiaomi.aircondition.mc2": AirConditionerMiot, "xiaomi.aircondition.mc4": AirConditionerMiot, "xiaomi.aircondition.mc5": AirConditionerMiot, "airdog-airpurifier-x3": AirDogX3, "airdog-airpurifier-x5": AirDogX3, "airdog-airpurifier-x7sm": AirDogX3, "zhimi-airpurifier-m1": AirPurifier, # mini model "zhimi-airpurifier-m2": AirPurifier, # mini model 2 "zhimi-airpurifier-ma1": AirPurifier, # ms model "zhimi-airpurifier-ma2": AirPurifier, # ms model 2 "zhimi-airpurifier-sa1": AirPurifier, # super model "zhimi-airpurifier-sa2": AirPurifier, # super model 2 "zhimi-airpurifier-v1": AirPurifier, # v1 "zhimi-airpurifier-v2": AirPurifier, # v2 "zhimi-airpurifier-v3": AirPurifier, # v3 "zhimi-airpurifier-v5": AirPurifier, # v5 "zhimi-airpurifier-v6": AirPurifier, # v6 "zhimi-airpurifier-v7": AirPurifier, # v7 "zhimi-airpurifier-mc1": AirPurifier, # mc1 "zhimi-airpurifier-mb3": AirPurifierMiot, # mb3 (3/3H) "zhimi-airpurifier-ma4": AirPurifierMiot, # ma4 (3) "zhimi-airpurifier-vb2": AirPurifierMiot, # vb2 (Pro H) "chuangmi-camera-ipc009": ChuangmiCamera, "chuangmi-camera-ipc019": ChuangmiCamera, "chuangmi-ir-v2": ChuangmiIr, "chuangmi-remote-h102a03_": ChuangmiIr, "zhimi-humidifier-v1": AirHumidifier, "zhimi-humidifier-ca1": AirHumidifier, "zhimi-humidifier-cb1": AirHumidifier, "shuii-humidifier-jsq001": AirHumidifierJsq, "deerma-humidifier-mjjsq": AirHumidifierMjjsq, "deerma-humidifier-jsq1": AirHumidifierMjjsq, "deerma-humidifier-jsqs": AirHumidifierJsqs, "yunmi-waterpuri-v2": WaterPurifier, "yunmi.waterpuri.lx9": WaterPurifierYunmi, "yunmi.waterpuri.lx11": WaterPurifierYunmi, "philips-light-bulb": PhilipsBulb, # cannot be discovered via mdns "philips-light-hbulb": PhilipsWhiteBulb, # cannot be discovered via mdns "philips-light-candle": PhilipsBulb, # cannot be discovered via mdns "philips-light-candle2": PhilipsBulb, # cannot be discovered via mdns "philips-light-ceiling": Ceil, "philips-light-zyceiling": Ceil, "philips-light-sread1": PhilipsEyecare, # name needs to be checked "philips-light-moonlight": PhilipsMoonlight, # name needs to be checked "philips-light-rwread": PhilipsRwread, # name needs to be checked "xiaomi-wifispeaker-v1": WifiSpeaker, # name needs to be checked "xiaomi-repeater-v1": WifiRepeater, # name needs to be checked "xiaomi-repeater-v3": WifiRepeater, # name needs to be checked "chunmi-cooker-press1": Cooker, "chunmi-cooker-press2": Cooker, "chunmi-cooker-normal1": Cooker, "chunmi-cooker-normal2": Cooker, "chunmi-cooker-normal3": Cooker, "chunmi-cooker-normal4": Cooker, "chunmi-cooker-normal5": Cooker, "lumi-acpartner-v1": partial(AirConditioningCompanion, model=MODEL_ACPARTNER_V1), "lumi-acpartner-v2": partial(AirConditioningCompanion, model=MODEL_ACPARTNER_V2), "lumi-acpartner-v3": partial(AirConditioningCompanion, model=MODEL_ACPARTNER_V3), "lumi-acpartner-mcn02": partial( AirConditioningCompanionMcn02, model=MODEL_ACPARTNER_MCN02 ), "lumi-camera-aq2": AqaraCamera, "yeelink-light-": Yeelight, "leshow-fan-ss4": FanLeshow, "zhimi-fan-v2": Fan, "zhimi-fan-v3": Fan, "zhimi-fan-sa1": Fan, "zhimi-fan-za1": Fan, "zhimi-fan-za3": Fan, "zhimi-fan-za4": Fan, "dmaker-fan-1c": FanMiot, "dmaker-fan-p5": Fan, "dmaker-fan-p9": FanMiot, "dmaker-fan-p10": FanMiot, "dmaker-fan-p11": FanMiot, "zhimi-fan-za5": FanZA5, "tinymu-toiletlid-v1": partial(Toiletlid, model=MODEL_TOILETLID_V1), "zhimi-airfresh-va2": AirFresh, "zhimi-airfresh-va4": AirFresh, "dmaker-airfresh-t2017": AirFreshT2017, "zhimi-airmonitor-v1": partial(AirQualityMonitor, model=MODEL_AIRQUALITYMONITOR_V1), "cgllc-airmonitor-b1": partial(AirQualityMonitor, model=MODEL_AIRQUALITYMONITOR_B1), "cgllc-airmonitor-s1": partial(AirQualityMonitor, model=MODEL_AIRQUALITYMONITOR_S1), "lumi-gateway-": Gateway, "viomi-vacuum-v7": ViomiVacuum, "viomi-vacuum-v8": ViomiVacuum, "zhimi.heater.za1": partial(Heater, model=MODEL_HEATER_ZA1), "zhimi.elecheater.ma1": partial(Heater, model=MODEL_HEATER_MA1), "dreame-vacuum-mc1808": DreameVacuum, "dreame-vacuum-p2008": DreameVacuum, "dreame-vacuum-p2028": DreameVacuum, "dreame-vacuum-p2009": DreameVacuum, } def pretty_token(token): """Return a pretty string presentation for a token.""" return codecs.encode(token, "hex").decode() def get_addr_from_info(info): addrs = info.addresses if len(addrs) > 1: _LOGGER.warning( "More than single IP address in the advertisement, using the first one" ) return str(ip_address(addrs[0])) def other_package_info(info, desc): """Return information about another package supporting the device.""" return f"Found {info.name} at {get_addr_from_info(info)}, check {desc}" def create_device(name: str, addr: str, device_cls: partial) -> Device: """Return a device object for a zeroconf entry.""" _LOGGER.debug( "Found a supported '%s', using '%s' class", name, device_cls.func.__name__ ) dev = device_cls(ip=addr) m = dev.send_handshake() dev.token = m.checksum _LOGGER.info( "Found a supported '%s' at %s - token: %s", device_cls.func.__name__, addr, pretty_token(dev.token), ) return dev class Listener(zeroconf.ServiceListener): """mDNS listener creating Device objects based on detected devices.""" def __init__(self): self.found_devices = {} # type: Dict[str, Device] def check_and_create_device(self, info, addr) -> Optional[Device]: """Create a corresponding :class:`Device` implementation for a given info and address..""" name = info.name for identifier, v in DEVICE_MAP.items(): if name.startswith(identifier): if inspect.isclass(v): return create_device(name, addr, partial(v)) elif isinstance(v, partial) and inspect.isclass(v.func): return create_device(name, addr, v) elif callable(v): dev = Device(ip=addr) _LOGGER.info( "%s: token: %s", v(info), pretty_token(dev.send_handshake().checksum), ) return None _LOGGER.warning( "Found unsupported device %s at %s, " "please report to developers", name, addr, ) return None def add_service(self, zeroconf: "zeroconf.Zeroconf", type_: str, name: str) -> None: """Callback for discovery responses.""" info = zeroconf.get_service_info(type_, name) addr = get_addr_from_info(info) if addr not in self.found_devices: dev = self.check_and_create_device(info, addr) if dev is not None: self.found_devices[addr] = dev def update_service(self, zc: "zeroconf.Zeroconf", type_: str, name: str) -> None: """Callback for state updates, which we ignore for now.""" class Discovery: """mDNS discoverer for miIO based devices (_miio._udp.local). Calling :func:`discover_mdns` will cause this to subscribe for updates on ``_miio._udp.local`` until any key is pressed, after which a dict of detected devices is returned. """ @staticmethod def discover_mdns(*, timeout=5) -> Dict[str, Device]: """Discover devices with mdns until any keyboard input.""" _LOGGER.info("Discovering devices with mDNS for %s seconds...", timeout) listener = Listener() browser = zeroconf.ServiceBrowser( zeroconf.Zeroconf(), "_miio._udp.local.", listener ) time.sleep(timeout) browser.cancel() return listener.found_devices ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/exceptions.py0000644000000000000000000000204514265350055014477 0ustar00class DeviceException(Exception): """Exception wrapping any communication errors with the device.""" class PayloadDecodeException(DeviceException): """Exception for failures in payload decoding. This is raised when the json payload cannot be decoded, indicating invalid response from a device. """ class DeviceInfoUnavailableException(DeviceException): """Exception raised when requesting miio.info fails. This allows users to gracefully handle cases where the information unavailable. This can happen, for instance, when the device has no cloud access. """ class DeviceError(DeviceException): """Exception communicating an error delivered by the target device. The device given error code and message can be accessed with `code` and `message` variables. """ def __init__(self, error): self.code = error.get("code") self.message = error.get("message") class RecoverableError(DeviceError): """Exception communicating an recoverable error delivered by the target device.""" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/extract_tokens.py0000644000000000000000000001665514265350055015367 0ustar00import json import logging import sqlite3 import tempfile from pprint import pformat as pf from typing import Iterator import attr import click import defusedxml.ElementTree as ET from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes logging.basicConfig(level=logging.INFO) _LOGGER = logging.getLogger(__name__) @attr.s class DeviceConfig: """A presentation of a device including its name, model, ip etc.""" name = attr.ib() mac = attr.ib() ip = attr.ib() token = attr.ib() model = attr.ib() everything = attr.ib(default=None) def read_android_yeelight(db) -> Iterator[DeviceConfig]: """Read tokens from Yeelight's android backup.""" _LOGGER.info("Reading tokens from Yeelight Android DB") xml = ET.parse(db) devicelist = xml.find(".//set[@name='deviceList']") if not devicelist: _LOGGER.warning("Unable to find deviceList") return [] for dev_elem in list(devicelist): dev = json.loads(dev_elem.text) ip = dev["localip"] mac = dev["mac"] model = dev["model"] name = dev["name"] token = dev["token"] config = DeviceConfig( name=name, ip=ip, mac=mac, model=model, token=token, everything=dev ) yield config class BackupDatabaseReader: """Main class for reading backup files. Example: .. code-block:: python r = BackupDatabaseReader() devices = r.read_tokens("/tmp/database.sqlite") for dev in devices: print("Got %s with token %s" % (dev.ip, dev.token) """ def __init__(self, dump_raw=False): self.dump_raw = dump_raw @staticmethod def dump_raw(dev): """Dump whole database.""" raw = {k: dev[k] for k in dev.keys()} _LOGGER.info(pf(raw)) @staticmethod def decrypt_ztoken(ztoken): """Decrypt the given ztoken, used by apple.""" if ztoken is None or len(ztoken) <= 32: return str(ztoken) keystring = "00000000000000000000000000000000" key = bytes.fromhex(keystring) cipher = Cipher( # nosec algorithms.AES(key), modes.ECB(), backend=default_backend() ) decryptor = cipher.decryptor() token = decryptor.update(bytes.fromhex(ztoken[:64])) + decryptor.finalize() return token.decode() def read_apple(self) -> Iterator[DeviceConfig]: """Read Apple-specific database file.""" _LOGGER.info("Reading tokens from Apple DB") c = self.conn.execute("SELECT * FROM ZDEVICE WHERE ZTOKEN IS NOT '';") for dev in c.fetchall(): if self.dump_raw: BackupDatabaseReader.dump_raw(dev) ip = dev["ZLOCALIP"] mac = dev["ZMAC"] model = dev["ZMODEL"] name = dev["ZNAME"] token = BackupDatabaseReader.decrypt_ztoken(dev["ZTOKEN"]) config = DeviceConfig( name=name, mac=mac, ip=ip, model=model, token=token, everything=dev ) yield config def read_android(self) -> Iterator[DeviceConfig]: """Read Android-specific database file.""" _LOGGER.info("Reading tokens from Android DB") c = self.conn.execute("SELECT * FROM devicerecord WHERE token IS NOT '';") for dev in c.fetchall(): if self.dump_raw: BackupDatabaseReader.dump_raw(dev) ip = dev["localIP"] mac = dev["mac"] model = dev["model"] name = dev["name"] token = dev["token"] config = DeviceConfig( name=name, ip=ip, mac=mac, model=model, token=token, everything=dev ) yield config def read_tokens(self, db) -> Iterator[DeviceConfig]: """Read device information out from a given database file. :param str db: Database file """ self.db = db _LOGGER.info("Reading database from %s" % db) self.conn = sqlite3.connect(db) self.conn.row_factory = sqlite3.Row with self.conn: is_android = ( self.conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='devicerecord';" ).fetchone() is not None ) is_apple = ( self.conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='ZDEVICE'" ).fetchone() is not None ) if is_android: yield from self.read_android() elif is_apple: yield from self.read_apple() else: _LOGGER.error("Error, unknown database type!") @click.command() @click.argument("backup") @click.option( "--write-to-disk", type=click.File("wb"), help="writes sqlite3 db to a file for debugging", ) @click.option( "--password", type=str, help="password if the android database is encrypted" ) @click.option( "--dump-all", is_flag=True, default=False, help="dump devices without ip addresses" ) @click.option("--dump-raw", is_flag=True, help="dumps raw rows") def main(backup, write_to_disk, password, dump_all, dump_raw): """Reads device information out from an sqlite3 DB. If the given file is an Android backup (.ab), the database will be extracted automatically. If the given file is an iOS backup, the tokens will be extracted (and decrypted if needed) automatically. """ def read_miio_database(tar): DBFILE = "apps/com.xiaomi.smarthome/db/miio2.db" try: db = tar.extractfile(DBFILE) except KeyError as ex: click.echo(f"Unable to find miio database file {DBFILE}: {ex}") return [] if write_to_disk: file = write_to_disk else: file = tempfile.NamedTemporaryFile() with file as fp: click.echo("Saving database to %s" % fp.name) fp.write(db.read()) return list(reader.read_tokens(fp.name)) def read_yeelight_database(tar): DBFILE = "apps/com.yeelight.cherry/sp/miot.xml" _LOGGER.info("Trying to read %s", DBFILE) try: db = tar.extractfile(DBFILE) except KeyError as ex: click.echo(f"Unable to find yeelight database file {DBFILE}: {ex}") return [] return list(read_android_yeelight(db)) devices = [] reader = BackupDatabaseReader(dump_raw) if backup.endswith(".ab"): try: from android_backup import AndroidBackup except ModuleNotFoundError: click.echo( "You need to install android_backup to extract " "tokens from Android backup files." ) return with AndroidBackup(backup, stream=False) as f: tar = f.read_data(password) devices.extend(read_miio_database(tar)) devices.extend(read_yeelight_database(tar)) else: devices = list(reader.read_tokens(backup)) for dev in devices: if dev.ip or dump_all: click.echo( "%s\n" "\tModel: %s\n" "\tIP address: %s\n" "\tToken: %s\n" "\tMAC: %s" % (dev.name, dev.model, dev.ip, dev.token, dev.mac) ) if dump_raw: click.echo(dev) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/fan_common.py0000644000000000000000000000050414265350055014430 0ustar00import enum from .exceptions import DeviceException class FanException(DeviceException): pass class OperationMode(enum.Enum): Normal = "normal" Nature = "nature" class LedBrightness(enum.Enum): Bright = 0 Dim = 1 Off = 2 class MoveDirection(enum.Enum): Left = "left" Right = "right" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/__init__.py0000644000000000000000000000014614265350055015516 0ustar00"""Xiaomi Gateway implementation using Miio protecol.""" # flake8: noqa from .gateway import Gateway ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/alarm.py0000644000000000000000000000672714265350055015066 0ustar00"""Xiaomi Gateway Alarm implementation.""" import logging from datetime import datetime from ..exceptions import DeviceException from ..push_server import EventInfo from .gatewaydevice import GatewayDevice _LOGGER = logging.getLogger(__name__) class Alarm(GatewayDevice): """Class representing the Xiaomi Gateway Alarm.""" def status(self) -> str: """Return the alarm status from the device.""" # Response: 'on', 'off', 'oning' return self._gateway.send("get_arming").pop() def on(self): """Turn alarm on.""" return self._gateway.send("set_arming", ["on"]) def off(self): """Turn alarm off.""" return self._gateway.send("set_arming", ["off"]) def arming_time(self) -> int: """Return time in seconds the alarm stays 'oning' before transitioning to 'on'.""" # Response: 5, 15, 30, 60 return self._gateway.send("get_arm_wait_time").pop() def set_arming_time(self, seconds): """Set time the alarm stays at 'oning' before transitioning to 'on'.""" return self._gateway.send("set_arm_wait_time", [seconds]) def triggering_time(self) -> int: """Return the time in seconds the alarm is going off when triggered.""" # Response: 30, 60, etc. return self._gateway.get_prop("alarm_time_len").pop() def set_triggering_time(self, seconds): """Set the time in seconds the alarm is going off when triggered.""" return self._gateway.set_prop("alarm_time_len", seconds) def triggering_light(self) -> int: """Return the time the gateway light blinks when the alarm is triggerd.""" # Response: 0=do not blink, 1=always blink, x>1=blink for x seconds return self._gateway.get_prop("en_alarm_light").pop() def set_triggering_light(self, seconds): """Set the time the gateway light blinks when the alarm is triggerd.""" # values: 0=do not blink, 1=always blink, x>1=blink for x seconds return self._gateway.set_prop("en_alarm_light", seconds) def triggering_volume(self) -> int: """Return the volume level at which alarms go off [0-100].""" return self._gateway.send("get_alarming_volume").pop() def set_triggering_volume(self, volume): """Set the volume level at which alarms go off [0-100].""" return self._gateway.send("set_alarming_volume", [volume]) def last_status_change_time(self) -> datetime: """Return the last time the alarm changed status.""" return datetime.fromtimestamp(self._gateway.send("get_arming_time").pop()) def subscribe_events(self): """subscribe to the alarm events using the push server.""" if self._gateway._push_server is None: raise DeviceException( "Can not install push callback without a PushServer instance" ) event_info = EventInfo( action="alarm_triggering", extra="[1,19,1,111,[0,1],2,0]", trigger_token=self._gateway.token, ) event_id = self._gateway._push_server.subscribe_event(self._gateway, event_info) if event_id is None: return False self._event_ids.append(event_id) return True def unsubscribe_events(self): """Unsubscibe from events registered in the gateway memory.""" for event_id in self._event_ids: self._gateway._push_server.unsubscribe_event(self._gateway, event_id) self._event_ids.remove(event_id) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/devices/__init__.py0000644000000000000000000000032014265350055017132 0ustar00"""Xiaomi Gateway subdevice base class.""" # flake8: noqa from .light import LightBulb from .sensor import Vibration from .switch import Switch from .subdevice import SubDevice, SubDeviceInfo # isort:skip ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/devices/light.py0000644000000000000000000000151314265350055016507 0ustar00"""Xiaomi Zigbee lights.""" import click from ...click_common import command from .subdevice import SubDevice class LightBulb(SubDevice): """Base class for subdevice light bulbs.""" @command() def on(self): """Turn bulb on.""" return self.send_arg("set_power", ["on"]).pop() @command() def off(self): """Turn bulb off.""" return self.send_arg("set_power", ["off"]).pop() @command(click.argument("ctt", type=int)) def set_color_temp(self, ctt): """Set the color temperature of the bulb ctt_min-ctt_max.""" return self.send_arg("set_ct", [ctt]).pop() @command(click.argument("brightness", type=int)) def set_brightness(self, brightness): """Set the brightness of the bulb 1-100.""" return self.send_arg("set_bright", [brightness]).pop() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/devices/sensor.py0000644000000000000000000000074014265350055016712 0ustar00"""Xiaomi Zigbee sensors.""" import click from ...click_common import command from .subdevice import SubDevice class Vibration(SubDevice): """Base class for subdevice vibration sensor.""" @command(click.argument("vibration_level", type=int)) def set_vibration_sensitivity(self, vibration_level): """Set the sensitivity of the vibration sensor, low = 21, medium = 11, high = 1.""" return self.set_property("vibration_level", vibration_level).pop() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/devices/subdevice.py0000644000000000000000000002603314265350055017355 0ustar00"""Xiaomi Gateway subdevice base class.""" import logging from typing import TYPE_CHECKING, Dict, List, Optional import attr import click from ...click_common import command from ...exceptions import DeviceException from ...push_server import EventInfo from ..gateway import ( GATEWAY_MODEL_EU, GATEWAY_MODEL_ZIG3, GatewayCallback, GatewayException, ) _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: from ..gateway import Gateway @attr.s(auto_attribs=True) class SubDeviceInfo: """SubDevice discovery info.""" sid: str type_id: int unknown: int unknown2: int fw_ver: int class SubDevice: """Base class for all subdevices of the gateway these devices are connected through zigbee.""" def __init__( self, gw: "Gateway", dev_info: SubDeviceInfo, model_info: Optional[Dict] = None, ) -> None: self._gw = gw self.sid = dev_info.sid if model_info is None: model_info = {} self._model_info = model_info self._battery_powered = model_info.get("battery_powered", True) self._battery = None self._voltage = None self._fw_ver = dev_info.fw_ver self._model = model_info.get("model", "unknown") self._name = model_info.get("name", "unknown") self._zigbee_model = model_info.get("zigbee_id", "unknown") self._props = {} self.get_prop_exp_dict = {} for prop in model_info.get("properties", []): prop_name = prop.get("name", prop["property"]) self._props[prop_name] = prop.get("default", None) if prop.get("get") == "get_property_exp": self.get_prop_exp_dict[prop["property"]] = prop self.setter = model_info.get("setter") self.push_events = model_info.get("push_properties", []) self._event_ids: List[str] = [] self._registered_callbacks: Dict[str, GatewayCallback] = {} def __repr__(self): return "".format( self.device_type, self.sid, self.model, self.zigbee_model, self.firmware_version, self.get_battery(), self.get_voltage(), self.status, ) @property def status(self): """Return sub-device status as a dict containing all properties.""" return self._props @property def device_type(self): """Return the device type name.""" return self._model_info.get("type") @property def name(self): """Return the name of the device.""" return f"{self._name} ({self.sid})" @property def model(self): """Return the device model.""" return self._model @property def zigbee_model(self): """Return the zigbee device model.""" return self._zigbee_model @property def firmware_version(self): """Return the firmware version.""" return self._fw_ver @property def battery(self): """Return the battery level in %.""" return self._battery @property def voltage(self): """Return the battery voltage in V.""" return self._voltage @command() def update(self): """Update all device properties.""" if self.get_prop_exp_dict: values = self.get_property_exp(list(self.get_prop_exp_dict.keys())) try: i = 0 for prop in self.get_prop_exp_dict.values(): result = values[i] if prop.get("devisor"): result = values[i] / prop.get("devisor") prop_name = prop.get("name", prop["property"]) self._props[prop_name] = result i = i + 1 except Exception as ex: raise GatewayException( "One or more unexpected results while " "fetching properties %s: %s on model %s" % (self.get_prop_exp_dict, values, self.model) ) from ex @command() def send(self, command): """Send a command/query to the subdevice.""" try: return self._gw.send(command, [self.sid]) except Exception as ex: raise GatewayException( "Got an exception while sending command %s on model %s" % (command, self.model) ) from ex @command() def send_arg(self, command, arguments): """Send a command/query including arguments to the subdevice.""" try: return self._gw.send(command, arguments, extra_parameters={"sid": self.sid}) except Exception as ex: raise GatewayException( "Got an exception while sending " "command '%s' with arguments '%s' on model %s" % (command, str(arguments), self.model) ) from ex @command(click.argument("property")) def get_property(self, property): """Get the value of a property of the subdevice.""" try: response = self._gw.send("get_device_prop", [self.sid, property]) except Exception as ex: raise GatewayException( "Got an exception while fetching property %s on model %s" % (property, self.model) ) from ex if not response: raise GatewayException( f"Empty response while fetching property '{property}': {response} on model {self.model}" ) return response @command(click.argument("properties", nargs=-1)) def get_property_exp(self, properties): """Get the value of a bunch of properties of the subdevice.""" try: response = self._gw.send( "get_device_prop_exp", [[self.sid] + list(properties)] ).pop() except Exception as ex: raise GatewayException( "Got an exception while fetching properties %s on model %s" % (properties, self.model) ) from ex if len(list(properties)) != len(response): raise GatewayException( "unexpected result while fetching properties %s: %s on model %s" % (properties, response, self.model) ) return response @command(click.argument("property"), click.argument("value")) def set_property(self, property, value): """Set a device property of the subdevice.""" try: return self._gw.send("set_device_prop", {"sid": self.sid, property: value}) except Exception as ex: raise GatewayException( "Got an exception while setting propertie %s to value %s on model %s" % (property, str(value), self.model) ) from ex @command() def unpair(self): """Unpair this device from the gateway.""" return self.send("remove_device") @command() def get_battery(self) -> Optional[int]: """Update the battery level, if available.""" if not self._battery_powered: _LOGGER.debug( "%s is not battery powered, get_battery not supported", self.name, ) return None if self._gw.model not in [GATEWAY_MODEL_EU, GATEWAY_MODEL_ZIG3]: self._battery = self.send("get_battery").pop() else: _LOGGER.info( "Gateway model '%s' does not (yet) support get_battery", self._gw.model, ) return self._battery @command() def get_voltage(self) -> Optional[float]: """Update the battery voltage, if available.""" if not self._battery_powered: _LOGGER.debug( "%s is not battery powered, get_voltage not supported", self.name, ) return None if self._gw.model in [GATEWAY_MODEL_EU, GATEWAY_MODEL_ZIG3]: self._voltage = self.get_property("voltage").pop() / 1000 else: _LOGGER.info( "Gateway model '%s' does not (yet) support get_voltage", self._gw.model, ) return self._voltage @command() def get_firmware_version(self) -> Optional[int]: """Returns firmware version.""" try: self._fw_ver = self.get_property("fw_ver").pop() except Exception as ex: _LOGGER.info( "get_firmware_version failed, returning firmware version from discovery info: %s", ex, ) return self._fw_ver def register_callback(self, id: str, callback: GatewayCallback): """Register a external callback function for updates of this subdevice.""" if id in self._registered_callbacks: _LOGGER.error( "A callback with id '%s' was already registed, overwriting previous callback", id, ) self._registered_callbacks[id] = callback def remove_callback(self, id: str): """Remove a external callback using its id.""" self._registered_callbacks.pop(id) def push_callback(self, action: str, params: str): """Push callback received from the push server.""" if action not in self.push_events: _LOGGER.error( "Received unregistered action '%s' callback for sid '%s' model '%s'", action, self.sid, self.model, ) event = self.push_events[action] prop = event.get("property") value = event.get("value") if prop is not None and value is not None: self._props[prop] = value for callback in self._registered_callbacks.values(): callback(action, params) def subscribe_events(self): """subscribe to all subdevice events using the push server.""" if self._gw._push_server is None: raise DeviceException( "Can not install push callback without a PushServer instance" ) result = True for action in self.push_events: event_info = EventInfo( action=action, extra=self.push_events[action]["extra"], source_sid=self.sid, source_model=self.zigbee_model, event=self.push_events[action].get("event", None), command_extra=self.push_events[action].get("command_extra", ""), trigger_value=self.push_events[action].get("trigger_value"), ) event_id = self._gw._push_server.subscribe_event(self._gw, event_info) if event_id is None: result = False continue self._event_ids.append(event_id) return result def unsubscribe_events(self): """Unsubscibe from events registered in the gateway memory.""" for event_id in self._event_ids: self._gw._push_server.unsubscribe_event(self._gw, event_id) self._event_ids.remove(event_id) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/devices/subdevices.yaml0000644000000000000000000006131414265350055020053 0ustar00# Default - zigbee_id: unknown model: unknown type_id: -1 name: unknown type: unknown class: SubDevice # Gateway - zigbee_id: lumi.0 model: Gateway type_id: 0 name: Gateway type: Gateway class: None # Explanation push properties: # push_properties: # l_click_ch0: = action event that you receive back from the gateway (can be changed to any arbitrary string) # property: last_press = name of property to wich this event is coupled # value: "long_click_ch0" = the value to wich the coupled property schould be set upon receiving this event # extra: "[1,13,1,85,[0,0],0,0]" = "[a,b,c,d,[e,f],g,h]" # c = part of the device that caused the event (1 = left switch, 2 = right switch, 3 = both switches) # f = event number on which this event is fired (0 = long_click/close, 1 = click/open, 2 = double_click) # Weather sensor - zigbee_id: lumi.sensor_ht.v1 model: WSDCGQ01LM type_id: 10 name: Weather sensor type: SensorHT class: SubDevice getter: get_prop_sensor_ht properties: - property: temperature unit: degrees celsius get: get_property_exp devisor: 100 - property: humidity unit: percent get: get_property_exp devisor: 100 - zigbee_id: lumi.weather.v1 model: WSDCGQ11LM type_id: 19 name: Weather sensor type: SensorHT class: SubDevice getter: get_prop_sensor_ht properties: - property: temperature unit: degrees celsius get: get_property_exp devisor: 100 - property: humidity unit: percent get: get_property_exp devisor: 100 - property: pressure unit: hpa get: get_property_exp devisor: 100 # Door sensor - zigbee_id: lumi.sensor_magnet.v2 model: MCCGQ01LM type_id: 3 name: Door sensor type: Magnet class: SubDevice properties: - property: is_open default: False push_properties: open: property: is_open value: True extra: "[1,6,1,0,[0,1],2,0]" close: property: is_open value: False extra: "[1,6,1,0,[0,0],2,0]" - zigbee_id: lumi.sensor_magnet.aq2 model: MCCGQ11LM type_id: 53 name: Door sensor type: Magnet class: SubDevice properties: - property: is_open default: False push_properties: open: property: is_open value: True extra: "[1,6,1,0,[0,1],2,0]" close: property: is_open value: False extra: "[1,6,1,0,[0,0],2,0]" # Motion sensor - zigbee_id: lumi.sensor_motion.v2 model: RTCGQ01LM type_id: 2 name: Motion sensor type: Motion class: SubDevice properties: - property: motion default: False push_properties: motion: property: motion value: True extra: "[1,1030,1,0,[0,1],0,0]" no_motion: property: motion value: False extra: "[1,1030,1,8,[4,120],2,0]" - zigbee_id: lumi.sensor_motion.aq2 model: RTCGQ11LM type_id: 52 name: Motion sensor type: Motion class: SubDevice properties: - property: motion default: False push_properties: motion: property: motion value: True extra: "[1,1030,1,0,[0,1],0,0]" no_motion: property: motion value: False extra: "[1,1030,1,8,[4,120],2,0]" #illumination: # extra: "[1,1024,1,0,[3,20],0,0]" # trigger_value: {"max":20, "min":0} # Cube - zigbee_id: lumi.sensor_cube.v1 model: MFKZQ01LM type_id: 8 name: Cube type: Cube class: SubDevice properties: - property: last_event default: "none" push_properties: move: property: last_event value: "move" extra: "[1,18,2,85,[6,256],0,0]" flip90: property: last_event value: "flip90" extra: "[1,18,2,85,[6,64],0,0]" flip180: property: last_event value: "flip180" extra: "[1,18,2,85,[6,128],0,0]" taptap: property: last_event value: "taptap" extra: "[1,18,2,85,[6,512],0,0]" shakeair: property: last_event value: "shakeair" extra: "[1,18,2,85,[0,0],0,0]" rotate: property: last_event value: "rotate" extra: "[1,12,3,85,[1,0],0,0]" event: "rotate" command_extra: "[1,19,7,1006,[42,[6066005667474548,12,3,85,0]],0,0]" - zigbee_id: lumi.sensor_cube.aqgl01 model: MFKZQ01LM type_id: 68 name: Cube type: Cube class: SubDevice properties: - property: last_event default: "none" push_properties: move: property: last_event value: "move" extra: "[1,18,2,85,[6,256],0,0]" flip90: property: last_event value: "flip90" extra: "[1,18,2,85,[6,64],0,0]" flip180: property: last_event value: "flip180" extra: "[1,18,2,85,[6,128],0,0]" taptap: property: last_event value: "taptap" extra: "[1,18,2,85,[6,512],0,0]" shakeair: property: last_event value: "shakeair" extra: "[1,18,2,85,[0,0],0,0]" rotate: property: last_event value: "rotate" extra: "[1,12,3,85,[1,0],0,0]" event: "rotate" command_extra: "[1,19,7,1006,[42,[6066005667474548,12,3,85,0]],0,0]" # Curtain - zigbee_id: lumi.curtain model: ZNCLDJ11LM type_id: 13 name: Curtain type: Curtain class: SubDevice - zigbee_id: lumi.curtain.aq2 model: ZNGZDJ11LM type_id: 71 name: Curtain type: Curtain class: SubDevice - zigbee_id: lumi.curtain.hagl04 model: ZNCLDJ12LM type_id: 72 name: Curtain B1 type: Curtain class: SubDevice # LightBulb - zigbee_id: lumi.light.aqcn02 model: ZNLDP12LM type_id: 66 name: Smart bulb E27 type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 370 - zigbee_id: ikea.light.led1545g12 model: LED1545G12 type_id: 82 name: Ikea smart bulb E27 white type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 500 - zigbee_id: ikea.light.led1546g12 model: LED1546G12 type_id: 83 name: Ikea smart bulb E27 white type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 500 - zigbee_id: ikea.light.led1536g5 model: LED1536G5 type_id: 84 name: Ikea smart bulb E12 white type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 500 - zigbee_id: ikea.light.led1537r6 model: LED1537R6 type_id: 85 name: Ikea smart bulb GU10 white type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 500 - zigbee_id: ikea.light.led1623g12 model: LED1623G12 type_id: 86 name: Ikea smart bulb E27 white type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 500 - zigbee_id: ikea.light.led1650r5 model: LED1650R5 type_id: 87 name: Ikea smart bulb GU10 white type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 500 - zigbee_id: ikea.light.led1649c5 model: LED1649C5 type_id: 88 name: Ikea smart bulb E12 white type: LightBulb class: LightBulb battery_powered: false properties: - property: power_status # 'on' / 'off' name: status get: get_property_exp - property: light_level name: brightness unit: percent get: get_property_exp - property: colour_temperature name: color_temp unit: cct get: get_property_exp - property: cct_min unit: cct default: 153 - property: cct_max unit: cct default: 500 # Lock - zigbee_id: lumi.lock.aq1 model: ZNMS11LM type_id: 59 name: Door lock S1 type: Lock class: SubDevice properties: - property: status # 'locked' / 'unlocked' - zigbee_id: lumi.lock.acn02 model: ZNMS12LM type_id: 70 name: Door lock S2 type: Lock class: SubDevice properties: - property: status # 'locked' / 'unlocked' - zigbee_id: lumi.lock.v1 model: A6121 type_id: 81 name: Vima cylinder lock type: Lock class: SubDevice properties: - property: status # 'locked' / 'unlocked' - zigbee_id: lumi.lock.acn03 model: ZNMS13LM type_id: 163 name: Door lock S2 pro type: Lock class: SubDevice properties: - property: status # 'locked' / 'unlocked' # Sensors - zigbee_id: lumi.sensor_smoke model: JTYJ-GD-01LM/BW type_id: 15 name: Honeywell smoke detector type: SmokeSensor class: SubDevice - zigbee_id: lumi.sensor_natgas model: JTQJ-BF-01LM/BW type_id: 18 name: Honeywell natural gas detector type: NatgasSensor class: SubDevice - zigbee_id: lumi.sensor_wleak.aq1 model: SJCGQ11LM type_id: 55 name: Water leak sensor type: WaterLeakSensor class: SubDevice - zigbee_id: lumi.vibration.aq1 model: DJT11LM type_id: 56 name: Vibration sensor type: VibrationSensor class: Vibration properties: - property: last_event default: "none" push_properties: vibrate: property: last_event value: "vibrate" extra: "[1,257,1,85,[0,1],0,0]" tilt: property: last_event value: "tilt" extra: "[1,257,1,85,[0,2],0,0]" free_fall: property: last_event value: "free_fall" extra: "[1,257,1,85,[0,3],0,0]" # Thermostats - zigbee_id: lumi.airrtc.tcpecn02 model: KTWKQ03ES type_id: 207 name: Thermostat S2 type: Thermostat class: SubDevice # Remote Switch - zigbee_id: lumi.sensor_86sw2.v1 model: WXKG02LM 2016 type_id: 12 name: Remote switch double type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" l_click_ch1: property: last_press value: "long_click_ch1" extra: "[1,13,2,85,[0,0],0,0]" click_ch1: property: last_press value: "click_ch1" extra: "[1,13,2,85,[0,1],0,0]" d_click_ch1: property: last_press value: "double_click_ch1" extra: "[1,13,2,85,[0,2],0,0]" both_l_click: property: last_press value: "both_long_click" extra: "[1,13,3,85,[0,0],0,0]" both_click: property: last_press value: "both_click" extra: "[1,13,3,85,[0,1],0,0]" both_d_click: property: last_press value: "both_double_click" extra: "[1,13,3,85,[0,2],0,0]" - zigbee_id: lumi.sensor_86sw1.v1 model: WXKG03LM 2016 type_id: 14 name: Remote switch single type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.remote.b186acn01 model: WXKG03LM 2018 type_id: 134 name: Remote switch single type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.remote.b286acn01 model: WXKG02LM 2018 type_id: 135 name: Remote switch double type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" l_click_ch1: property: last_press value: "long_click_ch1" extra: "[1,13,2,85,[0,0],0,0]" click_ch1: property: last_press value: "click_ch1" extra: "[1,13,2,85,[0,1],0,0]" d_click_ch1: property: last_press value: "double_click_ch1" extra: "[1,13,2,85,[0,2],0,0]" both_l_click: property: last_press value: "both_long_click" extra: "[1,13,3,85,[0,0],0,0]" both_click: property: last_press value: "both_click" extra: "[1,13,3,85,[0,1],0,0]" both_d_click: property: last_press value: "both_double_click" extra: "[1,13,3,85,[0,2],0,0]" - zigbee_id: lumi.remote.b186acn02 model: WXKG06LM type_id: 171 name: D1 remote switch single type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.remote.b286acn02 model: WXKG07LM type_id: 172 name: D1 remote switch double type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" l_click_ch1: property: last_press value: "long_click_ch1" extra: "[1,13,2,85,[0,0],0,0]" click_ch1: property: last_press value: "click_ch1" extra: "[1,13,2,85,[0,1],0,0]" d_click_ch1: property: last_press value: "double_click_ch1" extra: "[1,13,2,85,[0,2],0,0]" both_l_click: property: last_press value: "both_long_click" extra: "[1,13,3,85,[0,0],0,0]" both_click: property: last_press value: "both_click" extra: "[1,13,3,85,[0,1],0,0]" both_d_click: property: last_press value: "both_double_click" extra: "[1,13,3,85,[0,2],0,0]" - zigbee_id: lumi.sensor_switch.v2 model: WXKG01LM type_id: 1 name: Button type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.sensor_switch.aq2 model: WXKG11LM 2015 type_id: 51 name: Button type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.sensor_switch.aq3 model: WXKG12LM type_id: 62 name: Button type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" l_click_pres: property: last_press value: "long_click_press" extra: "[1,13,1,85,[0,16],0,0]" shake: property: last_press value: "shake" extra: "[1,13,1,85,[0,18],0,0]" - zigbee_id: lumi.remote.b1acn01 model: WXKG11LM 2018 type_id: 133 name: Button type: RemoteSwitch class: SubDevice properties: - property: last_press default: "none" push_properties: l_click_ch0: property: last_press value: "long_click_ch0" extra: "[1,13,1,85,[0,0],0,0]" click_ch0: property: last_press value: "click_ch0" extra: "[1,13,1,85,[0,1],0,0]" d_click_ch0: property: last_press value: "double_click_ch0" extra: "[1,13,1,85,[0,2],0,0]" # Switches - zigbee_id: lumi.ctrl_neutral2 model: QBKG03LM type_id: 7 name: Wall switch double no neutral type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: neutral_1 # 'on' / 'off' name: status_ch1 get: get_property_exp - zigbee_id: lumi.ctrl_neutral1.v1 model: QBKG04LM type_id: 9 name: Wall switch no neutral type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - zigbee_id: lumi.ctrl_ln1 model: QBKG11LM type_id: 20 name: Wall switch single type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.ctrl_ln2 model: QBKG12LM type_id: 21 name: Wall switch double type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: neutral_1 # 'on' / 'off' name: status_ch1 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.ctrl_ln1.aq1 model: QBKG11LM type_id: 63 name: Wall switch single type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.ctrl_ln2.aq1 model: QBKG12LM type_id: 64 name: Wall switch double type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: neutral_1 # 'on' / 'off' name: status_ch1 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.switch.n3acn3 model: QBKG26LM type_id: 176 name: D1 wall switch triple type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: neutral_1 # 'on' / 'off' name: status_ch1 get: get_property_exp - property: neutral_2 # 'on' / 'off' name: status_ch2 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.switch.l3acn3 model: QBKG25LM type_id: 177 name: D1 wall switch triple no neutral type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: neutral_1 # 'on' / 'off' name: status_ch1 get: get_property_exp - property: neutral_2 # 'on' / 'off' name: status_ch2 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.plug model: ZNCZ02LM type_id: 11 name: Plug type: Switch class: Switch getter: get_prop_plug setter: toggle_plug battery_powered: false properties: - property: neutral_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.plug.mmeu01 model: ZNCZ04LM type_id: -2 name: Plug type: Switch class: Switch getter: get_prop_plug setter: toggle_plug battery_powered: false properties: - property: channel_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.ctrl_86plug.v1 model: QBCZ11LM type_id: 17 name: Wall outlet type: Switch class: Switch setter: toggle_plug battery_powered: false properties: - property: channel_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - zigbee_id: lumi.ctrl_86plug.aq1 model: QBCZ11LM type_id: 65 name: Wall outlet type: Switch class: Switch setter: toggle_plug battery_powered: false properties: - property: channel_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: load_power unit: Watt get: get_property_exp - zigbee_id: lumi.relay.c2acn01 model: LLKZMK11LM type_id: 54 name: Relay type: Switch class: Switch setter: toggle_ctrl_neutral battery_powered: false properties: - property: channel_0 # 'on' / 'off' name: status_ch0 get: get_property_exp - property: channel_1 # 'on' / 'off' name: status_ch1 get: get_property_exp - property: load_power unit: Watt get: get_property_exp # from https://github.com/aholstenson/miio/issues/26 # 166 - lumi.lock.acn05 # 167 - lumi.switch.b1lacn02 # 168 - lumi.switch.b2lacn02 # 169 - lumi.switch.b1nacn02 # 170 - lumi.switch.b2nacn02 # 202 - lumi.dimmer.rgbegl01 # 203 - lumi.dimmer.c3egl01 # 204 - lumi.dimmer.cwegl01 # 205 - lumi.airrtc.vrfegl01 # 206 - lumi.airrtc.tcpecn01 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/devices/switch.py0000644000000000000000000000217614265350055016707 0ustar00"""Xiaomi Zigbee switches.""" from enum import IntEnum import click from ...click_common import command from .subdevice import SubDevice class Switch(SubDevice): """Base class for one channel switch subdevice that supports on/off.""" class ChannelMap(IntEnum): """Option to select wich channel to control.""" channel_0 = 0 channel_1 = 1 channel_2 = 2 @command(click.argument("channel", type=int)) def toggle(self, channel: int = 0): """Toggle a channel of the switch, default channel_0.""" return self.send_arg( self.setter, [self.ChannelMap(channel).name, "toggle"] ).pop() @command(click.argument("channel", type=int)) def on(self, channel: int = 0): """Turn on a channel of the switch, default channel_0.""" return self.send_arg(self.setter, [self.ChannelMap(channel).name, "on"]).pop() @command(click.argument("channel", type=int)) def off(self, channel: int = 0): """Turn off a channel of the switch, default channel_0.""" return self.send_arg(self.setter, [self.ChannelMap(channel).name, "off"]).pop() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/gateway.py0000644000000000000000000003522014265350055015421 0ustar00"""Xiaomi Gateway implementation using Miio protecol.""" import logging import os import sys from typing import Callable, Dict, List import click import yaml from ..click_common import command from ..device import Device from ..exceptions import DeviceError, DeviceException from .alarm import Alarm from .light import Light from .radio import Radio from .zigbee import Zigbee _LOGGER = logging.getLogger(__name__) GATEWAY_MODEL_CHINA = "lumi.gateway.v3" GATEWAY_MODEL_EU = "lumi.gateway.mieu01" GATEWAY_MODEL_ZIG3 = "lumi.gateway.mgl03" GATEWAY_MODEL_AQARA = "lumi.gateway.aqhm01" GATEWAY_MODEL_AC_V1 = "lumi.acpartner.v1" GATEWAY_MODEL_AC_V2 = "lumi.acpartner.v2" GATEWAY_MODEL_AC_V3 = "lumi.acpartner.v3" SUPPORTED_MODELS = [ GATEWAY_MODEL_CHINA, GATEWAY_MODEL_EU, GATEWAY_MODEL_ZIG3, GATEWAY_MODEL_AQARA, GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, GATEWAY_MODEL_AC_V3, ] GatewayCallback = Callable[[str, str], None] class GatewayException(DeviceException): """Exception for the Xioami Gateway communication.""" from .devices import SubDevice, SubDeviceInfo # noqa: E402 isort:skip class Gateway(Device): """Main class representing the Xiaomi Gateway. Use the given property getters to access specific functionalities such as `alarm` (for alarm controls) or `light` (for lights). Commands whose functionality or parameters are unknown, feel free to implement! * toggle_device * toggle_plug * remove_all_bind * list_bind [0] * bind_page * bind * remove_bind * self.get_prop("used_for_public") # Return the 'used_for_public' status, return value: [0] or [1], probably this has to do with developer mode. * self.set_prop("used_for_public", state) # Set the 'used_for_public' state, value: 0 or 1, probably this has to do with developer mode. * welcome * set_curtain_level * get_corridor_on_time * set_corridor_light ["off"] * get_corridor_light -> "on" * set_default_sound * set_doorbell_push, get_doorbell_push ["off"] * set_doorbell_volume [100], get_doorbell_volume * set_gateway_volume, get_gateway_volume * set_clock_volume * set_clock * get_sys_data * update_neighbor_token [{"did":x, "token":x, "ip":x}] ## property getters * ctrl_device_prop * get_device_prop_exp [[sid, list, of, properties]] ## scene * get_lumi_bind ["scene", ] for rooms/devices """ _supported_models = SUPPORTED_MODELS def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, *, model: str = None, push_server=None, ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover, model=model) self._alarm = Alarm(parent=self) self._radio = Radio(parent=self) self._zigbee = Zigbee(parent=self) self._light = Light(parent=self) self._devices: Dict[str, SubDevice] = {} self._info = None self._subdevice_model_map = None self._push_server = push_server self._event_ids: List[str] = [] self._registered_callbacks: Dict[str, GatewayCallback] = {} if self._push_server is not None: self._push_server.register_miio_device(self, self.push_callback) def _get_unknown_model(self): for model_info in self.subdevice_model_map: if model_info.get("type_id") == -1: return model_info @property def alarm(self) -> Alarm: """Return alarm control interface.""" # example: gateway.alarm.on() return self._alarm @property def radio(self) -> Radio: """Return radio control interface.""" return self._radio @property def zigbee(self) -> Zigbee: """Return zigbee control interface.""" return self._zigbee @property def light(self) -> Light: """Return light control interface.""" return self._light @property def devices(self): """Return a dict of the already discovered devices.""" return self._devices @property def mac(self): """Return the mac address of the gateway.""" if self._info is None: self._info = self.info() return self._info.mac_address @property def subdevice_model_map(self): """Return the subdevice model map.""" if self._subdevice_model_map is None: subdevice_file = os.path.dirname(__file__) + "/devices/subdevices.yaml" with open(subdevice_file) as filedata: self._subdevice_model_map = yaml.safe_load(filedata) return self._subdevice_model_map @command() def discover_devices(self): """Discovers SubDevices and returns a list of the discovered devices.""" self._devices = {} # Skip the models which do not support getting the device list if self.model == GATEWAY_MODEL_EU: _LOGGER.warning( "Gateway model '%s' does not (yet) support getting the device list, " "try using the get_devices_from_dict function with micloud", self.model, ) return self._devices if self.model == GATEWAY_MODEL_ZIG3: # self.get_prop("device_list") does not work for the GATEWAY_MODEL_ZIG3 # self.send("get_device_list") does work for the GATEWAY_MODEL_ZIG3 but gives slightly diffrent return values devices_raw = self.send("get_device_list") if type(devices_raw) != list: _LOGGER.debug( "Gateway response to 'get_device_list' not a list type, no zigbee devices connected." ) return self._devices for device in devices_raw: # Match 'model' to get the model_info model_info = self.match_zigbee_model(device["model"], device["did"]) # Extract discovered information dev_info = SubDeviceInfo( device["did"], model_info["type_id"], -1, -1, -1 ) # Setup the device self.setup_device(dev_info, model_info) else: devices_raw = self.get_prop("device_list") for x in range(0, len(devices_raw), 5): # Extract discovered information dev_info = SubDeviceInfo(*devices_raw[x : x + 5]) # Match 'type_id' to get the model_info model_info = self.match_type_id(dev_info.type_id, dev_info.sid) # Setup the device self.setup_device(dev_info, model_info) return self._devices def _get_device_by_did(self, device_dict, device_did): """Get a device by its did from a device dict.""" for device in device_dict: if device["did"] == device_did: return device return None @command() def get_devices_from_dict(self, device_dict): """Get SubDevices from a dict containing at least "mac", "did", "parent_id" and "model". This dict can be obtained with the micloud package: https://github.com/squachen/micloud """ self._devices = {} # find the gateway gateway = self._get_device_by_did(device_dict, str(self.device_id)) if gateway is None: _LOGGER.error( "Could not find gateway with ip '%s', mac '%s', did '%i', model '%s' in the cloud device list response", self.ip, self.mac, self.device_id, self.model, ) return self._devices if gateway["mac"] != self.mac: _LOGGER.error( "Mac and device id of gateway with ip '%s', mac '%s', did '%i', model '%s' did not match in the cloud device list response", self.ip, self.mac, self.device_id, self.model, ) return self._devices # find the subdevices belonging to this gateway for device in device_dict: if device.get("parent_id") != str(self.device_id): continue # Match 'model' to get the type_id model_info = self.match_zigbee_model(device["model"], device["did"]) # Extract discovered information dev_info = SubDeviceInfo(device["did"], model_info["type_id"], -1, -1, -1) # Setup the device self.setup_device(dev_info, model_info) return self._devices @command(click.argument("zigbee_model", "sid")) def match_zigbee_model(self, zigbee_model, sid): """Match the zigbee_model to obtain the model_info.""" for model_info in self.subdevice_model_map: if model_info.get("zigbee_id") == zigbee_model: return model_info _LOGGER.warning( "Unknown subdevice discovered, could not match zigbee_model '%s' " "of subdevice sid '%s' from Xiaomi gateway with ip: %s", zigbee_model, sid, self.ip, ) return self._get_unknown_model() @command(click.argument("type_id", "sid")) def match_type_id(self, type_id, sid): """Match the type_id to obtain the model_info.""" for model_info in self.subdevice_model_map: if model_info.get("type_id") == type_id: return model_info _LOGGER.warning( "Unknown subdevice discovered, could not match type_id '%i' " "of subdevice sid '%s' from Xiaomi gateway with ip: %s", type_id, sid, self.ip, ) return self._get_unknown_model() @command(click.argument("dev_info", "model_info")) def setup_device(self, dev_info, model_info): """Setup a device using the SubDeviceInfo and model_info.""" if model_info.get("type") == "Gateway": # ignore the gateway itself return # Obtain the correct subdevice class subdevice_cls = getattr( sys.modules["miio.gateway.devices"], model_info.get("class") ) if subdevice_cls is None: subdevice_cls = SubDevice _LOGGER.info( "Gateway device type '%s' " "does not have device specific methods defined, " "only basic default methods will be available", model_info.get("type"), ) # Initialize and save the subdevice self._devices[dev_info.sid] = subdevice_cls(self, dev_info, model_info) if self._devices[dev_info.sid].status == {}: _LOGGER.info( "Discovered subdevice type '%s', has no device specific properties defined, " "this device has not been fully implemented yet (model: %s, name: %s).", model_info.get("type"), self._devices[dev_info.sid].model, self._devices[dev_info.sid].name, ) return self._devices[dev_info.sid] @command(click.argument("property")) def get_prop(self, property): """Get the value of a property for given sid.""" return self.send("get_device_prop", ["lumi.0", property]) @command(click.argument("properties", nargs=-1)) def get_prop_exp(self, properties): """Get the value of a bunch of properties for given sid.""" return self.send("get_device_prop_exp", [["lumi.0"] + list(properties)]) @command(click.argument("property"), click.argument("value")) def set_prop(self, property, value): """Set the device property.""" return self.send("set_device_prop", {"sid": "lumi.0", property: value}) @command() def clock(self): """Alarm clock.""" # payload of clock volume ("get_clock_volume") # already in get_clock response return self.send("get_clock") # Developer key @command() def get_developer_key(self): """Return the developer API key.""" return self.send("get_lumi_dpf_aes_key")[0] @command(click.argument("key")) def set_developer_key(self, key): """Set the developer API key.""" if len(key) != 16: click.echo("Key must be of length 16, was %s" % len(key)) return self.send("set_lumi_dpf_aes_key", [key]) @command() def enable_telnet(self): """Enable root telnet acces to the operating system, use login "admin" or "app", no password.""" try: return self.send("enable_telnet_service") except DeviceError: _LOGGER.error( "Gateway model '%s' does not (yet) support enabling the telnet interface", self.model, ) return None @command() def timezone(self): """Get current timezone.""" return self.get_prop("tzone_sec") @command() def get_illumination(self): """Get illumination. In lux? """ try: return self.send("get_illumination").pop() except Exception as ex: raise GatewayException( "Got an exception while getting gateway illumination" ) from ex def register_callback(self, id: str, callback: GatewayCallback): """Register a external callback function for updates of this subdevice.""" if id in self._registered_callbacks: _LOGGER.error( "A callback with id '%s' was already registed, overwriting previous callback", id, ) self._registered_callbacks[id] = callback def remove_callback(self, id: str): """Remove a external callback using its id.""" self._registered_callbacks.pop(id) def gateway_push_callback(self, action: str, params: str): """Callback from the push server regarding the gateway itself.""" for callback in self._registered_callbacks.values(): callback(action, params) def push_callback(self, source_device: str, action: str, params: str): """Callback from the push server.""" if source_device == str(self.device_id): self.gateway_push_callback(action, params) return if source_device not in self.devices: _LOGGER.error( "'%s' callback from device '%s' not from a known device", action, source_device, ) return device = self.devices[source_device] device.push_callback(action, params) def close(self): """Cleanup all subscribed events and registered callbacks.""" if self._push_server is not None: self._push_server.unregister_miio_device(self) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/gatewaydevice.py0000644000000000000000000000133314265350055016577 0ustar00"""Xiaomi Gateway device base class.""" import logging from typing import TYPE_CHECKING, List from ..exceptions import DeviceException _LOGGER = logging.getLogger(__name__) # Necessary due to circular deps if TYPE_CHECKING: from .gateway import Gateway class GatewayDevice: """GatewayDevice class Specifies the init method for all gateway device functionalities.""" _supported_models = ["dummy.device"] def __init__( self, parent: "Gateway" = None, ) -> None: if parent is None: raise DeviceException( "This should never be initialized without gateway object." ) self._gateway = parent self._event_ids: List[str] = [] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/light.py0000644000000000000000000001305014265350055015064 0ustar00"""Xiaomi Gateway Light implementation.""" from typing import Tuple from ..utils import brightness_and_color_to_int, int_to_brightness, int_to_rgb from .gatewaydevice import GatewayDevice color_map = { "red": (255, 0, 0), "green": (0, 255, 0), "blue": (0, 0, 255), "white": (255, 255, 255), "yellow": (255, 255, 0), "orange": (255, 165, 0), "aqua": (0, 255, 255), "olive": (128, 128, 0), "purple": (128, 0, 128), } class Light(GatewayDevice): """Light controls for the gateway. The gateway LEDs can be controlled using 'rgb' or 'night_light' methods. The 'night_light' methods control the same light as the 'rgb' methods, but has a separate memory for brightness and color. Changing the 'rgb' light does not affect the stored state of the 'night_light', while changing the 'night_light' does effect the state of the 'rgb' light. """ def rgb_status(self): """Get current status of the light. Always represents the current status of the light as opposed to 'night_light_status'. Example: {"is_on": false, "brightness": 0, "rgb": (0, 0, 0)} """ # Returns {"is_on": false, "brightness": 0, "rgb": (0, 0, 0)} when light is off state_int = self._gateway.send("get_rgb").pop() brightness = int_to_brightness(state_int) rgb = int_to_rgb(state_int) is_on = brightness > 0 return {"is_on": is_on, "brightness": brightness, "rgb": rgb} def night_light_status(self): """Get status of the night light. This command only gives the correct status of the LEDs if the last command was a 'night_light' command and not a 'rgb' light command, otherwise it gives the stored values of the 'night_light'. Example: {"is_on": false, "brightness": 0, "rgb": (0, 0, 0)} """ state_int = self._gateway.send("get_night_light_rgb").pop() brightness = int_to_brightness(state_int) rgb = int_to_rgb(state_int) is_on = brightness > 0 return {"is_on": is_on, "brightness": brightness, "rgb": rgb} def set_rgb(self, brightness: int, rgb: Tuple[int, int, int]): """Set gateway light using brightness and rgb tuple.""" brightness_and_color = brightness_and_color_to_int(brightness, rgb) return self._gateway.send("set_rgb", [brightness_and_color]) def set_night_light(self, brightness: int, rgb: Tuple[int, int, int]): """Set gateway night light using brightness and rgb tuple.""" brightness_and_color = brightness_and_color_to_int(brightness, rgb) return self._gateway.send("set_night_light_rgb", [brightness_and_color]) def set_rgb_brightness(self, brightness: int): """Set gateway light brightness (0-100).""" if 100 < brightness < 0: raise Exception("Brightness must be between 0 and 100") current_color = self.rgb_status()["rgb"] return self.set_rgb(brightness, current_color) def set_night_light_brightness(self, brightness: int): """Set night light brightness (0-100).""" if 100 < brightness < 0: raise Exception("Brightness must be between 0 and 100") current_color = self.night_light_status()["rgb"] return self.set_night_light(brightness, current_color) def set_rgb_color(self, color_name: str): """Set gateway light color using color name ('color_map' variable in the source holds the valid values).""" if color_name not in color_map.keys(): raise Exception( "Cannot find {color} in {colors}".format( color=color_name, colors=color_map.keys() ) ) current_brightness = self.rgb_status()["brightness"] return self.set_rgb(current_brightness, color_map[color_name]) def set_night_light_color(self, color_name: str): """Set night light color using color name ('color_map' variable in the source holds the valid values).""" if color_name not in color_map.keys(): raise Exception( "Cannot find {color} in {colors}".format( color=color_name, colors=color_map.keys() ) ) current_brightness = self.night_light_status()["brightness"] return self.set_night_light(current_brightness, color_map[color_name]) def set_rgb_using_name(self, color_name: str, brightness: int): """Set gateway light color (using color name, 'color_map' variable in the source holds the valid values) and brightness (0-100).""" if 100 < brightness < 0: raise Exception("Brightness must be between 0 and 100") if color_name not in color_map.keys(): raise Exception( "Cannot find {color} in {colors}".format( color=color_name, colors=color_map.keys() ) ) return self.set_rgb(brightness, color_map[color_name]) def set_night_light_using_name(self, color_name: str, brightness: int): """Set night light color (using color name, 'color_map' variable in the source holds the valid values) and brightness (0-100).""" if 100 < brightness < 0: raise Exception("Brightness must be between 0 and 100") if color_name not in color_map.keys(): raise Exception( "Cannot find {color} in {colors}".format( color=color_name, colors=color_map.keys() ) ) return self.set_night_light(brightness, color_map[color_name]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/radio.py0000644000000000000000000000634214265350055015061 0ustar00"""Xiaomi Gateway Radio implementation.""" import click from .gatewaydevice import GatewayDevice class Radio(GatewayDevice): """Radio controls for the gateway.""" def get_radio_info(self): """Radio play info.""" return self._gateway.send("get_prop_fm") def set_radio_volume(self, volume): """Set radio volume.""" return self._gateway.send("set_fm_volume", [volume]) def play_music_new(self): """Unknown.""" # {'from': '4', 'id': 9514, # 'method': 'set_default_music', 'params': [2, '21']} # {'from': '4', 'id': 9515, # 'method': 'play_music_new', 'params': ['21', 0]} raise NotImplementedError() def play_specify_fm(self): """play specific stream?""" raise NotImplementedError() # {"from": "4", "id": 65055, "method": "play_specify_fm", # "params": {"id": 764, "type": 0, # "url": "http://live.xmcdn.com/live/764/64.m3u8"}} return self._gateway.send("play_specify_fm") def play_fm(self): """radio on/off?""" raise NotImplementedError() # play_fm","params":["off"]} return self._gateway.send("play_fm") def volume_ctrl_fm(self): """Unknown.""" raise NotImplementedError() return self._gateway.send("volume_ctrl_fm") def get_channels(self): """Unknown.""" raise NotImplementedError() # "method": "get_channels", "params": {"start": 0}} return self._gateway.send("get_channels") def add_channels(self): """Unknown.""" raise NotImplementedError() return self._gateway.send("add_channels") def remove_channels(self): """Unknown.""" raise NotImplementedError() return self._gateway.send("remove_channels") def get_default_music(self): """seems to timeout (w/o internet).""" # params [0,1,2] raise NotImplementedError() return self._gateway.send("get_default_music") def get_music_info(self): """Unknown.""" info = self._gateway.send("get_music_info") click.echo("info: %s" % info) free_space = self._gateway.send("get_music_free_space") click.echo("free space: %s" % free_space) def get_mute(self): """mute of what?""" return self._gateway.send("get_mute") def download_music(self): """Unknown.""" raise NotImplementedError() return self._gateway.send("download_music") def delete_music(self): """delete music.""" raise NotImplementedError() return self._gateway.send("delete_music") def download_user_music(self): """Unknown.""" raise NotImplementedError() return self._gateway.send("download_user_music") def get_download_progress(self): """progress for music downloads or updates?""" # returns [':0'] raise NotImplementedError() return self._gateway.send("get_download_progress") def set_sound_playing(self): """stop playing?""" return self._gateway.send("set_sound_playing", ["off"]) def set_default_music(self): """Unknown.""" raise NotImplementedError() # method":"set_default_music","params":[0,"2"]} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/gateway/zigbee.py0000644000000000000000000000324714265350055015231 0ustar00"""Xiaomi Gateway Zigbee control implementation.""" from .gatewaydevice import GatewayDevice class Zigbee(GatewayDevice): """Zigbee controls.""" def get_zigbee_version(self): """timeouts on device.""" return self._gateway.send("get_zigbee_device_version") def get_zigbee_channel(self): """Return currently used zigbee channel.""" return self._gateway.send("get_zigbee_channel")[0] def set_zigbee_channel(self, channel): """Set zigbee channel.""" return self._gateway.send("set_zigbee_channel", [channel]) def zigbee_pair(self, timeout): """Start pairing, use 0 to disable.""" return self._gateway.send("start_zigbee_join", [timeout]) def send_to_zigbee(self): """How does this differ from writing? Unknown. """ raise NotImplementedError() return self._gateway.send("send_to_zigbee") def read_zigbee_eep(self): """Read eeprom?""" raise NotImplementedError() return self._gateway.send("read_zig_eep", [0]) # 'ok' def read_zigbee_attribute(self): """Read zigbee data?""" raise NotImplementedError() return self._gateway.send("read_zigbee_attribute", [0x0000, 0x0080]) def write_zigbee_attribute(self): """Unknown parameters.""" raise NotImplementedError() return self._gateway.send("write_zigbee_attribute") def zigbee_unpair_all(self): """Unpair all devices.""" return self._gateway.send("remove_all_device") def zigbee_unpair(self, sid): """Unpair a device.""" # get a device obj an call dev.unpair() raise NotImplementedError() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/heater.py0000644000000000000000000001663514265350055013600 0ustar00import enum import logging from typing import Any, Dict, Optional import click from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) MODEL_HEATER_ZA1 = "zhimi.heater.za1" MODEL_HEATER_MA1 = "zhimi.elecheater.ma1" AVAILABLE_PROPERTIES_COMMON = [ "power", "target_temperature", "brightness", "buzzer", "child_lock", "temperature", "use_time", ] AVAILABLE_PROPERTIES_ZA1 = ["poweroff_time", "relative_humidity"] AVAILABLE_PROPERTIES_MA1 = ["poweroff_level", "poweroff_value"] SUPPORTED_MODELS: Dict[str, Dict[str, Any]] = { MODEL_HEATER_ZA1: { "available_properties": AVAILABLE_PROPERTIES_COMMON + AVAILABLE_PROPERTIES_ZA1, "temperature_range": (16, 32), "delay_off_range": (0, 9 * 3600), }, MODEL_HEATER_MA1: { "available_properties": AVAILABLE_PROPERTIES_COMMON + AVAILABLE_PROPERTIES_MA1, "temperature_range": (20, 32), "delay_off_range": (0, 5 * 3600), }, } class HeaterException(DeviceException): pass class Brightness(enum.Enum): Bright = 0 Dim = 1 Off = 2 class HeaterStatus(DeviceStatus): """Container for status reports from the Smartmi Zhimi Heater.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Heater (zhimi.heater.za1): {'power': 'off', 'target_temperature': 24, 'brightness': 1, 'buzzer': 'on', 'child_lock': 'off', 'temperature': 22.3, 'use_time': 43117, 'poweroff_time': 0, 'relative_humidity': 34} """ self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if device is currently on.""" return self.power == "on" @property def humidity(self) -> Optional[int]: """Current humidity.""" if ( "relative_humidity" in self.data and self.data["relative_humidity"] is not None ): return self.data["relative_humidity"] return None @property def temperature(self) -> float: """Current temperature.""" return self.data["temperature"] @property def target_temperature(self) -> int: """Target temperature.""" return self.data["target_temperature"] @property def brightness(self) -> Brightness: """Display brightness.""" return Brightness(self.data["brightness"]) @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] in ["on", 1, 2] @property def child_lock(self) -> bool: """True if child lock is on.""" return self.data["child_lock"] == "on" @property def use_time(self) -> int: """How long the device has been active in seconds.""" return self.data["use_time"] @property def delay_off_countdown(self) -> Optional[int]: """Countdown until turning off in seconds.""" if "poweroff_time" in self.data and self.data["poweroff_time"] is not None: return self.data["poweroff_time"] if "poweroff_level" in self.data and self.data["poweroff_level"] is not None: return self.data["poweroff_level"] return None class Heater(Device): """Main class representing the Smartmi Zhimi Heater.""" _supported_models = list(SUPPORTED_MODELS.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Target temperature: {result.target_temperature} °C\n" "Temperature: {result.temperature} °C\n" "Humidity: {result.humidity} %\n" "Display brightness: {result.brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Power-off time: {result.delay_off_countdown}\n", ) ) def status(self) -> HeaterStatus: """Retrieve properties.""" properties = SUPPORTED_MODELS.get( self.model, SUPPORTED_MODELS[MODEL_HEATER_ZA1] )["available_properties"] # A single request is limited to 16 properties. Therefore the # properties are divided into multiple requests _props_per_request = 15 # The MA1, ZA1 is limited to a single property per request if self.model in [MODEL_HEATER_MA1, MODEL_HEATER_ZA1]: _props_per_request = 1 values = self.get_properties(properties, max_properties=_props_per_request) return HeaterStatus(dict(zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("temperature", type=int), default_output=format_output("Setting target temperature to {temperature}"), ) def set_target_temperature(self, temperature: int): """Set target temperature.""" min_temp: int max_temp: int min_temp, max_temp = SUPPORTED_MODELS.get( self.model, SUPPORTED_MODELS[MODEL_HEATER_ZA1] )["temperature_range"] if not min_temp <= temperature <= max_temp: raise HeaterException("Invalid target temperature: %s" % temperature) return self.send("set_target_temperature", [temperature]) @command( click.argument("brightness", type=EnumType(Brightness)), default_output=format_output("Setting display brightness to {brightness}"), ) def set_brightness(self, brightness: Brightness): """Set display brightness.""" return self.send("set_brightness", [brightness.value]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if buzzer: return self.send("set_buzzer", ["on"]) else: return self.send("set_buzzer", ["off"]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if lock: return self.send("set_child_lock", ["on"]) else: return self.send("set_child_lock", ["off"]) @command( click.argument("seconds", type=int), default_output=format_output("Setting delayed turn off to {seconds} seconds"), ) def delay_off(self, seconds: int): """Set delay off seconds.""" min_delay: int max_delay: int min_delay, max_delay = SUPPORTED_MODELS.get( self.model, SUPPORTED_MODELS[MODEL_HEATER_ZA1] )["delay_off_range"] if not min_delay <= seconds <= max_delay: raise HeaterException("Invalid delay time: %s" % seconds) if self.model == MODEL_HEATER_ZA1: return self.send("set_poweroff_time", [seconds]) elif self.model == MODEL_HEATER_MA1: return self.send("set_poweroff_level", [seconds // 3600]) return None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/heater_miot.py0000644000000000000000000002041114265350055014613 0ustar00import enum import logging from typing import Any, Dict, Optional import click from .click_common import EnumType, command, format_output from .exceptions import DeviceException from .miot_device import DeviceStatus, MiotDevice _LOGGER = logging.getLogger(__name__) _MAPPINGS = { "zhimi.heater.mc2": { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:heater:0000A01A:zhimi-mc2:1 # Heater (siid=2) "power": {"siid": 2, "piid": 1}, "target_temperature": {"siid": 2, "piid": 5}, # Countdown (siid=3) "countdown_time": {"siid": 3, "piid": 1}, # Environment (siid=4) "temperature": {"siid": 4, "piid": 7}, # Physical Control Locked (siid=5) "child_lock": {"siid": 5, "piid": 1}, # Alarm (siid=6) "buzzer": {"siid": 6, "piid": 1}, # Indicator light (siid=7) "led_brightness": {"siid": 7, "piid": 3}, }, "zhimi.heater.za2": { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:heater:0000A01A:zhimi-za2:1 # Heater (siid=2) "power": {"siid": 2, "piid": 2}, "target_temperature": {"siid": 2, "piid": 6}, # Countdown (siid=4) "countdown_time": {"siid": 4, "piid": 1}, # Environment (siid=5) "temperature": {"siid": 5, "piid": 8}, "relative_humidity": {"siid": 5, "piid": 7}, # Physical Control Locked (siid=7) "child_lock": {"siid": 7, "piid": 1}, # Alarm (siid=3) "buzzer": {"siid": 3, "piid": 1}, # Indicator light (siid=7) "led_brightness": {"siid": 6, "piid": 1}, }, } HEATER_PROPERTIES = { "zhimi.heater.mc2": { "temperature_range": (18, 28), "delay_off_range": (0, 12 * 3600), }, "zhimi.heater.za2": { "temperature_range": (16, 28), "delay_off_range": (0, 8 * 3600), }, } class LedBrightness(enum.Enum): """Note that only Xiaomi Smart Space Heater 1S (zhimi.heater.za2) supports `Dim`.""" On = 0 Off = 1 Dim = 2 class HeaterMiotException(DeviceException): pass class HeaterMiotStatus(DeviceStatus): """Container for status reports from the Xiaomi Smart Space Heater S and 1S.""" def __init__(self, data: Dict[str, Any], model: str) -> None: """ Response (MIoT format) of Xiaomi Smart Space Heater S (zhimi.heater.mc2): [ { "did": "power", "siid": 2, "piid": 1, "code": 0, "value": False }, { "did": "target_temperature", "siid": 2, "piid": 5, "code": 0, "value": 18 }, { "did": "countdown_time", "siid": 3, "piid": 1, "code": 0, "value": 0 }, { "did": "temperature", "siid": 4, "piid": 7, "code": 0, "value": 22.6 }, { "did": "child_lock", "siid": 5, "piid": 1, "code": 0, "value": False }, { "did": "buzzer", "siid": 6, "piid": 1, "code": 0, "value": False }, { "did": "led_brightness", "siid": 7, "piid": 3, "code": 0, "value": 0 } ] """ self.data = data self.model = model @property def power(self) -> str: """Power state.""" return "on" if self.is_on else "off" @property def is_on(self) -> bool: """True if device is currently on.""" return self.data["power"] @property def target_temperature(self) -> int: """Target temperature.""" return self.data["target_temperature"] @property def delay_off_countdown(self) -> int: """Countdown until turning off in seconds.""" return self.data["countdown_time"] @property def temperature(self) -> float: """Current temperature.""" return self.data["temperature"] @property def relative_humidity(self) -> Optional[int]: """Current relative humidity.""" return self.data.get("relative_humidity") @property def child_lock(self) -> bool: """True if child lock is on, False otherwise.""" return self.data["child_lock"] is True @property def buzzer(self) -> bool: """True if buzzer is turned on, False otherwise.""" return self.data["buzzer"] is True @property def led_brightness(self) -> LedBrightness: """LED indicator brightness.""" value = self.data["led_brightness"] if self.model == "zhimi.heater.za2" and value: value = 3 - value return LedBrightness(value) class HeaterMiot(MiotDevice): """Main class representing the Xiaomi Smart Space Heater S (zhimi.heater.mc2) & 1S (zhimi.heater.za2).""" _mappings = _MAPPINGS @command( default_output=format_output( "", "Power: {result.power}\n" "Temperature: {result.temperature} °C\n" "Target Temperature: {result.target_temperature} °C\n" "LED indicator brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Power-off time: {result.delay_off_countdown} hours\n", ) ) def status(self) -> HeaterMiotStatus: """Retrieve properties.""" return HeaterMiotStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() }, self.model, ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("target_temperature", type=int), default_output=format_output( "Setting target temperature to '{target_temperature}'" ), ) def set_target_temperature(self, target_temperature: int): """Set target_temperature .""" min_temp, max_temp = HEATER_PROPERTIES.get( self.model, {"temperature_range": (18, 28)} )["temperature_range"] if target_temperature < min_temp or target_temperature > max_temp: raise HeaterMiotException( "Invalid temperature: %s. Must be between %s and %s." % (target_temperature, min_temp, max_temp) ) return self.set_property("target_temperature", target_temperature) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.set_property("child_lock", lock) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.set_property("buzzer", buzzer) @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output( "Setting LED indicator brightness to {brightness}" ), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" value = brightness.value if self.model == "zhimi.heater.za2" and value: value = 3 - value # Actually 1 means Dim, 2 means Off in za2 elif value == 2: raise ValueError("Unsupported brightness Dim for model '%s'.", self.model) return self.set_property("led_brightness", value) @command( click.argument("seconds", type=int), default_output=format_output("Setting delayed turn off to {seconds} seconds"), ) def set_delay_off(self, seconds: int): """Set delay off seconds.""" min_delay, max_delay = HEATER_PROPERTIES.get( self.model, {"delay_off_range": (0, 12 * 3600)} )["delay_off_range"] if seconds < min_delay or seconds > max_delay: raise HeaterMiotException( "Invalid scheduled turn off: %s. Must be between %s and %s" % (seconds, min_delay, max_delay) ) return self.set_property("countdown_time", seconds // 3600) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/huizuo.py0000644000000000000000000004647614265350055013661 0ustar00"""Basic implementation for HUAYI HUIZUO LAMPS (huayi.light.*) These lamps have a white color only and support dimming and control of the temperature from 3000K to 6400K """ import logging from typing import Any, Dict, Optional import click from .click_common import command, format_output from .exceptions import DeviceException from .miot_device import DeviceStatus, MiotDevice _LOGGER = logging.getLogger(__name__) # Lights with the basic support MODEL_HUIZUO_PIS123 = "huayi.light.pis123" MODEL_HUIZUO_ARI013 = "huayi.light.ari013" MODEL_HUIZUO_ARIES = "huayi.light.aries" MODEL_HUIZUO_PEG091 = "huayi.light.peg091" MODEL_HUIZUO_PEG093 = "huayi.light.peg093" MODEL_HUIZUO_PISCES = "huayi.light.pisces" MODEL_HUIZUO_TAU023 = "huayi.light.tau023" MODEL_HUIZUO_TAURUS = "huayi.light.taurus" MODEL_HUIZUO_VIR063 = "huayi.light.vir063" MODEL_HUIZUO_VIRGO = "huayi.light.virgo" MODEL_HUIZUO_WY = "huayi.light.wy" MODEL_HUIZUO_ZW131 = "huayi.light.zw131" # Lights: basic + fan MODEL_HUIZUO_FANWY = "huayi.light.fanwy" MODEL_HUIZUO_FANWY2 = "huayi.light.fanwy2" # Lights: basic + scene MODEL_HUIZUO_WY200 = "huayi.light.wy200" MODEL_HUIZUO_WY201 = "huayi.light.wy201" MODEL_HUIZUO_WY202 = "huayi.light.wy202" MODEL_HUIZUO_WY203 = "huayi.light.wy203" # Lights: basic + heater MODEL_HUIZUO_WYHEAT = "huayi.light.wyheat" BASIC_MODELS = [ MODEL_HUIZUO_PIS123, MODEL_HUIZUO_ARI013, MODEL_HUIZUO_ARIES, MODEL_HUIZUO_PEG091, MODEL_HUIZUO_PEG093, MODEL_HUIZUO_PISCES, MODEL_HUIZUO_TAU023, MODEL_HUIZUO_TAURUS, MODEL_HUIZUO_VIR063, MODEL_HUIZUO_VIRGO, MODEL_HUIZUO_WY, MODEL_HUIZUO_ZW131, ] MODELS_WITH_FAN_WY = [MODEL_HUIZUO_FANWY] MODELS_WITH_FAN_WY2 = [MODEL_HUIZUO_FANWY2] MODELS_WITH_SCENES = [ MODEL_HUIZUO_WY200, MODEL_HUIZUO_WY201, MODEL_HUIZUO_WY202, MODEL_HUIZUO_WY203, ] MODELS_WITH_HEATER = [MODEL_HUIZUO_WYHEAT] MODELS_SUPPORTED = BASIC_MODELS # Define a basic mapping for properties, which exists for all lights _MAPPING = { "power": {"siid": 2, "piid": 1}, # Boolean: True, False "brightness": {"siid": 2, "piid": 2}, # Percentage: 1-100 "color_temp": { "siid": 2, "piid": 3, }, # Kelvin: 3000-6400 (but for MODEL_HUIZUO_FANWY2: 3000-5700!) } _ADDITIONAL_MAPPING_FAN_WY2 = { # for MODEL_HUIZUO_FANWY2 "fan_power": {"siid": 3, "piid": 1}, # Boolean: True, False "fan_level": {"siid": 3, "piid": 2}, # Percentage: 1-100 "fan_mode": {"siid": 3, "piid": 3}, # Enum: 0 - Basic, 1 - Natural wind } _ADDITIONAL_MAPPING_FAN_WY = { # for MODEL_HUIZUO_FANWY "fan_power": {"siid": 3, "piid": 1}, # Boolean: True, False "fan_level": {"siid": 3, "piid": 2}, # Percentage: 1-100 "fan_motor_reverse": {"siid": 3, "piid": 3}, # Boolean: True, False "fan_mode": {"siid": 3, "piid": 4}, # Enum: 0 - Basic, 1 - Natural wind } _ADDITIONAL_MAPPING_HEATER = { "heater_power": {"siid": 3, "piid": 1}, # Boolean: True, False "heater_fault_code": {"siid": 3, "piid": 1}, # Fault code: 0 means "No fault" "heat_level": {"siid": 3, "piid": 1}, # Enum: 1-3 } _ADDITIONAL_MAPPING_SCENE = { # Only for write, send "0" to activate "on_off": {"siid": 3, "piid": 1}, "brightness_increase": {"siid": 3, "piid": 2}, "brightness_decrease": {"siid": 3, "piid": 3}, "brightness_switch": {"siid": 3, "piid": 4}, "colortemp_increase": {"siid": 3, "piid": 5}, "colortemp_decrease": {"siid": 3, "piid": 6}, "colortemp_switch": {"siid": 3, "piid": 7}, "on_or_increase_brightness": {"siid": 3, "piid": 8}, "on_or_increase_colortemp": {"siid": 3, "piid": 9}, } class HuizuoException(DeviceException): pass class HuizuoStatus(DeviceStatus): def __init__(self, data: Dict[str, Any]) -> None: self.data = data @property def is_on(self) -> bool: """Return True if device is on.""" return self.data["power"] @property def brightness(self) -> int: """Return current brightness.""" return self.data["brightness"] @property def color_temp(self) -> int: """Return current color temperature.""" return self.data["color_temp"] @property def is_fan_on(self) -> Optional[bool]: """Return True if Fan is on.""" if "fan_power" in self.data: return self.data["fan_power"] return None @property def fan_speed_level(self) -> Optional[int]: """Return current Fan speed level.""" if "fan_level" in self.data: return self.data["fan_level"] return None @property def is_fan_reverse(self) -> Optional[bool]: """Return True if Fan reverse is on.""" if "fan_motor_reverse" in self.data: return self.data["fan_motor_reverse"] return None @property def fan_mode(self) -> Optional[int]: """Return 0 if 'Basic' and 1 if 'Natural wind'.""" if "fan_mode" in self.data: return self.data["fan_mode"] return None @property def is_heater_on(self) -> Optional[bool]: """Return True if Heater is on.""" if "heater_power" in self.data: return self.data["heater_power"] return None @property def heater_fault_code(self) -> Optional[int]: """Return Heater's fault code. 0 - No Fault """ if "heater_fault_code" in self.data: return self.data["heater_fault_code"] return None @property def heat_level(self) -> Optional[int]: """Return Heater's heat level.""" if "heat_level" in self.data: return self.data["heat_level"] return None class Huizuo(MiotDevice): """A basic support for Huizuo Lamps. Example: response of a Huizuo Pisces For Bedroom (huayi.light.pis123) {'id': 1, 'result': [ {'did': '', 'siid': 2, 'piid': 1, 'code': 0, 'value': False}, {'did': '', 'siid': 2, 'piid': 2, 'code': 0, 'value': 94}, {'did': '', 'siid': 2, 'piid': 3, 'code': 0, 'value': 6400} ] } Explanation (line-by-line): power = '{"siid":2,"piid":1}' values = true,false brightless(%) = '{"siid":2,"piid":2}' values = 1-100 color temperature(Kelvin) = '{"siid":2,"piid":3}' values = 3000-6400 This is basic response for all HUIZUO lamps Also some models supports additional properties, like for Fan or Heating management. If your device does't support some properties, the 'None' will be returned """ mapping = _MAPPING _supported_models = MODELS_SUPPORTED def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, model: str = MODEL_HUIZUO_PIS123, ) -> None: if model in MODELS_WITH_FAN_WY: self.mapping.update(_ADDITIONAL_MAPPING_FAN_WY) if model in MODELS_WITH_FAN_WY2: self.mapping.update(_ADDITIONAL_MAPPING_FAN_WY2) if model in MODELS_WITH_SCENES: self.mapping.update(_ADDITIONAL_MAPPING_SCENE) if model in MODELS_WITH_HEATER: self.mapping.update(_ADDITIONAL_MAPPING_HEATER) super().__init__(ip, token, start_id, debug, lazy_discover, model=model) if model not in MODELS_SUPPORTED: self._model = MODEL_HUIZUO_PIS123 _LOGGER.error( "Device model %s unsupported. Falling back to %s.", model, self.model ) @command( default_output=format_output("Powering on"), ) def on(self): """Power on.""" return self.set_property("power", True) @command( default_output=format_output("Powering off"), ) def off(self): """Power off.""" return self.set_property("power", False) @command( default_output=format_output( "\n", "------------ Basic parameters for lamp -----------\n" "Power: {result.is_on}\n" "Brightness: {result.brightness}\n" "Color Temperature: {result.color_temp}\n" "\n", ), ) def status(self) -> HuizuoStatus: """Retrieve properties.""" return HuizuoStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command( click.argument("level", type=int), default_output=format_output("Setting brightness to {level}"), ) def set_brightness(self, level): """Set brightness.""" if level < 0 or level > 100: raise HuizuoException("Invalid brightness: %s" % level) return self.set_property("brightness", level) @command( click.argument("color_temp", type=int), default_output=format_output("Setting color temperature to {color_temp}"), ) def set_color_temp(self, color_temp): """Set color temp in kelvin.""" # I don't know why only one lamp has smaller color temperature (based on specs), # but let's process it correctly if self.model == MODELS_WITH_FAN_WY2: max_color_temp = 5700 else: max_color_temp = 6400 if color_temp < 3000 or color_temp > max_color_temp: raise HuizuoException("Invalid color temperature: %s" % color_temp) return self.set_property("color_temp", color_temp) class HuizuoLampFan(Huizuo): """Support for Huizuo Lamps with fan. The next section contains the fan management commands Right now I have no devices with the fan for live testing, so the following section generated based on device specitifations """ @command( default_output=format_output("Fan powering on"), ) def fan_on(self): """Power fan on (only for models with fan).""" if self.model in MODELS_WITH_FAN_WY or self.model in MODELS_WITH_FAN_WY2: return self.set_property("fan_power", True) raise HuizuoException("Your device doesn't support a fan management") @command( default_output=format_output("Fan powering off"), ) def fan_off(self): """Power fan off (only for models with fan).""" if self.model in MODELS_WITH_FAN_WY or self.model in MODELS_WITH_FAN_WY2: return self.set_property("fan_power", False) raise HuizuoException("Your device doesn't support a fan management") @command( click.argument("fan_level", type=int), default_output=format_output("Setting fan speed level to {fan_level}"), ) def set_fan_level(self, fan_level): """Set fan speed level (only for models with fan)""" if fan_level < 0 or fan_level > 100: raise HuizuoException("Invalid fan speed level: %s" % fan_level) if self.model in MODELS_WITH_FAN_WY or self.model in MODELS_WITH_FAN_WY2: return self.set_property("fan_level", fan_level) raise HuizuoException("Your device doesn't support a fan management") @command( default_output=format_output("Setting fan mode to 'Basic'"), ) def set_basic_fan_mode(self): """Set fan mode to 'Basic' (only for models with fan)""" if self.model in MODELS_WITH_FAN_WY or self.model in MODELS_WITH_FAN_WY2: return self.set_property("fan_mode", 0) raise HuizuoException("Your device doesn't support a fan management") @command( default_output=format_output("Setting fan mode to 'Natural wind'"), ) def set_natural_fan_mode(self): """Set fan mode to 'Natural wind' (only for models with fan)""" if self.model in MODELS_WITH_FAN_WY or self.model in MODELS_WITH_FAN_WY2: return self.set_property("fan_mode", 1) raise HuizuoException("Your device doesn't support a fan management") @command( default_output=format_output( "\n", "------------ Lamp parameters -----------\n" "Power: {result.is_on}\n" "Brightness: {result.brightness}\n" "Color Temperature: {result.color_temp}\n" "\n" "------------Fan parameters -------------\n" "Fan power: {result.is_fan_on}\n" "Fan level: {result.fan_speed_level}\n" "Fan mode: {result.fan_mode}\n" "Fan reverse: {result.is_fan_reverse}\n" "\n", ), ) def status(self) -> HuizuoStatus: """Retrieve properties.""" return HuizuoStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) # Fan Reverse option is not available for all models with fan @command( default_output=format_output("Enable fan reverse"), ) def fan_reverse_on(self): """Enable fan reverse (only for models which support this fan option)""" if self.model in MODELS_WITH_FAN_WY: return self.set_property("fan_motor_reverse", True) raise HuizuoException("Your device doesn't support a fan management") @command( default_output=format_output("Disable fan reverse"), ) def fan_reverse_off(self): """Disable fan reverse (only for models which support this fan option)""" if self.model in MODELS_WITH_FAN_WY: return self.set_property("fan_motor_reverse", False) raise HuizuoException("Your device doesn't support a fan management") class HuizuoLampHeater(Huizuo): """Support for Huizuo Lamps with heater. The next section contains the heater management commands Right now I have no devices with the heater for live testing, so the following section generated based on device specitifations """ @command( default_output=format_output("Heater powering on"), ) def heater_on(self): """Power heater on (only for models with heater).""" if self.model in MODELS_WITH_HEATER: return self.set_property("heater_power", True) raise HuizuoException("Your device doesn't support a heater management") @command( default_output=format_output("Heater powering off"), ) def heater_off(self): """Power heater off (only for models with heater).""" if self.model in MODELS_WITH_HEATER: return self.set_property("heater_power", False) raise HuizuoException("Your device doesn't support a heater management") @command( click.argument("heat_level", type=int), default_output=format_output("Setting heat level to {heat_level}"), ) def set_heat_level(self, heat_level): """Set heat level (only for models with heater)""" if heat_level not in [1, 2, 3]: raise HuizuoException("Invalid heat level: %s" % heat_level) if self.model in MODELS_WITH_HEATER: return self.set_property("heat_level", heat_level) raise HuizuoException("Your device doesn't support a heat management") @command( default_output=format_output( "\n", "------------ Lamp parameters -----------\n" "Power: {result.is_on}\n" "Brightness: {result.brightness}\n" "Color Temperature: {result.color_temp}\n" "\n" "---------- Heater parameters -----------\n" "Heater power: {result.is_heater_on}\n" "Heat level: {result.heat_level}\n" "Heat fault code (0 means 'OK'): {result.heater_fault_code}\n", ), ) def status(self) -> HuizuoStatus: """Retrieve properties.""" return HuizuoStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) class HuizuoLampScene(Huizuo): """Support for Huizuo Lamps with additional scene commands. The next section contains the scene management commands Right now I have no devices with the scenes for live testing, so the following section generated based on device specitifations """ @command( default_output=format_output("On/Off switch"), ) def scene_on_off(self): """Switch the on/off (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("on_off", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Increase the brightness"), ) def brightness_increase(self): """Increase the brightness (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("brightness_increase", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Decrease the brightness"), ) def brightness_decrease(self): """Decrease the brightness (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("brightness_decrease", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Switch between the brightnesses"), ) def brightness_switch(self): """Switch between the brightnesses (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("brightness_switch", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Increase the color temperature"), ) def colortemp_increase(self): """Increase the color temperature (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("colortemp_increase", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Decrease the color temperature"), ) def colortemp_decrease(self): """Decrease the color temperature (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("colortemp_decrease", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Switch between the color temperatures"), ) def colortemp_switch(self): """Switch between the color temperatures (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("colortemp_switch", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Switch on or increase brightness"), ) def on_or_increase_brightness(self): """Switch on or increase brightness (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("on_or_increase_brightness", 0) raise HuizuoException("Your device doesn't support scenes") @command( default_output=format_output("Switch on or increase color temperature"), ) def on_or_increase_colortemp(self): """Switch on or increase color temperature (only for models with scenes support).""" if self.model in MODELS_WITH_SCENES: return self.set_property("on_or_increase_colortemp", 0) raise HuizuoException("Your device doesn't support scenes") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/__init__.py0000644000000000000000000000000014265350055016550 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/__init__.py0000644000000000000000000000012014265350055021074 0ustar00# flake8: noqa from .airdog import * from .dmaker import * from .zhimi import * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/airdog/__init__.py0000644000000000000000000000007014265350055022345 0ustar00# flake8: noqa from .airpurifier_airdog import AirDogX3 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/airdog/airpurifier_airdog.py0000644000000000000000000001166214265350055024465 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) MODEL_AIRDOG_X3 = "airdog.airpurifier.x3" MODEL_AIRDOG_X5 = "airdog.airpurifier.x5" MODEL_AIRDOG_X7SM = "airdog.airpurifier.x7sm" MODEL_AIRDOG_COMMON = ["power", "mode", "speed", "lock", "clean", "pm"] AVAILABLE_PROPERTIES = { MODEL_AIRDOG_X3: MODEL_AIRDOG_COMMON, MODEL_AIRDOG_X5: MODEL_AIRDOG_COMMON, MODEL_AIRDOG_X7SM: MODEL_AIRDOG_COMMON + ["hcho"], } class AirDogException(DeviceException): pass class OperationMode(enum.Enum): Auto = "auto" Manual = "manual" Idle = "sleep" class OperationModeMapping(enum.Enum): Auto = 0 Manual = 1 Idle = 2 class AirDogStatus(DeviceStatus): """Container for status reports from the air dog x3.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Air Dog X3 (airdog.airpurifier.x3): {'power: 'on', 'mode': 'sleep', 'speed': 1, 'lock': 'unlock', 'clean': 'n', 'pm': 11, 'hcho': 0} """ self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if device is turned on.""" return self.power == "on" @property def mode(self) -> OperationMode: """Operation mode. Can be either auto, manual, sleep. """ return OperationMode(self.data["mode"]) @property def speed(self) -> int: """Current speed level.""" return self.data["speed"] @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["lock"] == "lock" @property def clean_filters(self) -> bool: """True if the display shows "-C-" and the filter must be cleaned.""" return self.data["clean"] == "y" @property def pm25(self) -> int: """Return particulate matter value (0...300μg/m³).""" return self.data["pm"] @property def hcho(self) -> Optional[int]: """Return formaldehyde value.""" if self.data["hcho"] is not None: return self.data["hcho"] return None class AirDogX3(Device): """Support for Airdog air purifiers (airdog.airpurifier.x*).""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "Speed: {result.speed}\n" "Child lock: {result.child_lock}\n" "Clean filters: {result.clean_filters}\n" "PM2.5: {result.pm25}\n" "Formaldehyde: {result.hcho}\n", ) ) def status(self) -> AirDogStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_AIRDOG_X3] ) values = self.get_properties(properties, max_properties=10) return AirDogStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", [1]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", [0]) @command( click.argument("mode", type=EnumType(OperationMode)), click.argument("speed", type=int, required=False, default=1), default_output=format_output( "Setting mode to '{mode.value}' and speed to {speed}" ), ) def set_mode_and_speed(self, mode: OperationMode, speed: int = 1): """Set mode and speed.""" if mode.value not in (om.value for om in OperationMode): raise AirDogException(f"{mode.value} is not a valid OperationMode value") if mode in [OperationMode.Auto, OperationMode.Idle]: speed = 1 if self.model == MODEL_AIRDOG_X3: max_speed = 4 else: # airdog.airpurifier.x7, airdog.airpurifier.x7sm max_speed = 5 if speed < 1 or speed > max_speed: raise AirDogException("Invalid speed: %s" % speed) return self.send("set_wind", [OperationModeMapping[mode.name].value, speed]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.send("set_lock", [int(lock)]) @command(default_output=format_output("Setting filters cleaned")) def set_filters_cleaned(self): """Set filters cleaned.""" return self.send("set_clean") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/airdog/tests/__init__.py0000644000000000000000000000000014265350055023500 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/airdog/tests/test_airpurifier_airdog.py0000644000000000000000000001330714265350055026664 0ustar00from unittest import TestCase import pytest from miio import AirDogX3 from miio.tests.dummies import DummyDevice from ..airpurifier_airdog import ( MODEL_AIRDOG_X3, MODEL_AIRDOG_X5, MODEL_AIRDOG_X7SM, AirDogException, AirDogStatus, OperationMode, OperationModeMapping, ) class DummyAirDogX3(DummyDevice, AirDogX3): def __init__(self, *args, **kwargs): self._model = MODEL_AIRDOG_X3 self.state = { "power": "on", "mode": "manual", "speed": 2, "lock": "unlock", "clean": "y", "pm": 11, "hcho": None, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state( "power", ["on" if x[0] == 1 else "off"] ), "set_lock": lambda x: self._set_state( "lock", ["lock" if x[0] == 1 else "unlock"] ), "set_clean": lambda x: self._set_state("clean", ["n"]), "set_wind": lambda x: ( self._set_state( "mode", [OperationMode[OperationModeMapping(x[0]).name].value] ), self._set_state("speed", [x[1]]), ), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def airdogx3(request): request.cls.device = DummyAirDogX3() # TODO add ability to test on a real device @pytest.mark.usefixtures("airdogx3") class TestAirDogX3(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(AirDogStatus(self.device.start_state)) assert self.is_on() is True assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().speed == self.device.start_state["speed"] assert self.state().child_lock is (self.device.start_state["lock"] == "lock") assert self.state().clean_filters is (self.device.start_state["clean"] == "y") assert self.state().pm25 == self.device.start_state["pm"] assert self.state().hcho == self.device.start_state["hcho"] def test_set_mode_and_speed(self): def mode(): return self.device.status().mode def speed(): return self.device.status().speed self.device.set_mode_and_speed(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode_and_speed(OperationMode.Auto, 2) assert mode() == OperationMode.Auto assert speed() == 1 self.device.set_mode_and_speed(OperationMode.Manual) assert mode() == OperationMode.Manual assert speed() == 1 self.device.set_mode_and_speed(OperationMode.Manual, 2) assert mode() == OperationMode.Manual assert speed() == 2 self.device.set_mode_and_speed(OperationMode.Manual, 4) assert mode() == OperationMode.Manual assert speed() == 4 with pytest.raises(AirDogException): self.device.set_mode_and_speed(OperationMode.Manual, 0) with pytest.raises(AirDogException): self.device.set_mode_and_speed(OperationMode.Manual, 5) self.device.set_mode_and_speed(OperationMode.Idle) assert mode() == OperationMode.Idle self.device.set_mode_and_speed(OperationMode.Idle, 2) assert mode() == OperationMode.Idle assert speed() == 1 def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_set_filters_cleaned(self): def clean_filters(): return self.device.status().clean_filters assert clean_filters() is True self.device.set_filters_cleaned() assert clean_filters() is False class DummyAirDogX5(DummyAirDogX3): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self._model = MODEL_AIRDOG_X5 self.state = { "power": "on", "mode": "manual", "speed": 2, "lock": "unlock", "clean": "y", "pm": 11, "hcho": None, } @pytest.fixture(scope="class") def airdogx5(request): request.cls.device = DummyAirDogX5() # TODO add ability to test on a real device class DummyAirDogX7SM(DummyAirDogX5): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self._model = MODEL_AIRDOG_X7SM self.state["hcho"] = 2 @pytest.fixture(scope="class") def airdogx7sm(request): request.cls.device = DummyAirDogX7SM() # TODO add ability to test on a real device @pytest.mark.usefixtures("airdogx5") @pytest.mark.usefixtures("airdogx7sm") class TestAirDogX5AndX7SM(TestCase): def test_set_mode_and_speed(self): def mode(): return self.device.status().mode def speed(): return self.device.status().speed self.device.set_mode_and_speed(OperationMode.Manual, 5) assert mode() == OperationMode.Manual assert speed() == 5 with pytest.raises(AirDogException): self.device.set_mode_and_speed(OperationMode.Manual, 6) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/dmaker/__init__.py0000644000000000000000000000010514265350055022342 0ustar00# flake8: noqa from .airfresh_t2017 import AirFreshA1, AirFreshT2017 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/dmaker/airfresh_t2017.py0000644000000000000000000003034014265350055023247 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) MODEL_AIRFRESH_A1 = "dmaker.airfresh.a1" MODEL_AIRFRESH_T2017 = "dmaker.airfresh.t2017" AVAILABLE_PROPERTIES_COMMON = [ "power", "mode", "pm25", "co2", "temperature_outside", "favourite_speed", "control_speed", "ptc_on", "ptc_status", "child_lock", "sound", "display", ] AVAILABLE_PROPERTIES = { MODEL_AIRFRESH_T2017: AVAILABLE_PROPERTIES_COMMON + [ "filter_intermediate", "filter_inter_day", "filter_efficient", "filter_effi_day", "ptc_level", "screen_direction", ], MODEL_AIRFRESH_A1: AVAILABLE_PROPERTIES_COMMON + [ "filter_rate", "filter_day", ], } class AirFreshException(DeviceException): pass class OperationMode(enum.Enum): Off = "off" Auto = "auto" Sleep = "sleep" Favorite = "favourite" class PtcLevel(enum.Enum): Low = "low" Medium = "medium" High = "high" class DisplayOrientation(enum.Enum): Portrait = "forward" LandscapeLeft = "left" LandscapeRight = "right" class AirFreshStatus(DeviceStatus): """Container for status reports from the air fresh t2017.""" def __init__(self, data: Dict[str, Any]) -> None: """ Response of a Air Fresh A1 (dmaker.airfresh.a1): { 'power': True, 'mode': 'auto', 'pm25': 2, 'co2': 554, 'temperature_outside': 12, 'favourite_speed': 150, 'control_speed': 60, 'filter_rate': 45, 'filter_day': 81, 'ptc_on': False, 'ptc_status': False, 'child_lock': False, 'sound': False, 'display': False, } Response of a Air Fresh T2017 (dmaker.airfresh.t2017): { 'power': True, 'mode': 'favourite', 'pm25': 1, 'co2': 550, 'temperature_outside': 24, 'favourite_speed': 241, 'control_speed': 241, 'filter_intermediate': 100, 'filter_inter_day': 90, 'filter_efficient': 100, 'filter_effi_day': 180, 'ptc_on': False, 'ptc_level': 'low', 'ptc_status': False, 'child_lock': False, 'sound': True, 'display': False, 'screen_direction': 'forward', } """ self.data = data @property def power(self) -> str: """Power state.""" return "on" if self.data["power"] else "off" @property def is_on(self) -> bool: """Return True if device is on.""" return self.data["power"] @property def mode(self) -> OperationMode: """Current operation mode.""" return OperationMode(self.data["mode"]) @property def pm25(self) -> int: """Fine particulate patter (PM2.5).""" return self.data["pm25"] @property def co2(self) -> int: """Carbon dioxide.""" return self.data["co2"] @property def temperature(self) -> int: """Current temperature in degree celsions.""" return self.data["temperature_outside"] @property def favorite_speed(self) -> int: """Favorite speed.""" return self.data["favourite_speed"] @property def control_speed(self) -> int: """Control speed.""" return self.data["control_speed"] @property def dust_filter_life_remaining(self) -> Optional[int]: """Remaining dust filter life in percent.""" return self.data.get("filter_intermediate", self.data.get("filter_rate")) @property def dust_filter_life_remaining_days(self) -> Optional[int]: """Remaining dust filter life in days.""" return self.data.get("filter_inter_day", self.data.get("filter_day")) @property def upper_filter_life_remaining(self) -> Optional[int]: """Remaining upper filter life in percent.""" return self.data.get("filter_efficient") @property def upper_filter_life_remaining_days(self) -> Optional[int]: """Remaining upper filter life in days.""" return self.data.get("filter_effi_day") @property def ptc(self) -> bool: """Return True if PTC is on.""" return self.data["ptc_on"] @property def ptc_level(self) -> Optional[PtcLevel]: """PTC level.""" try: return PtcLevel(self.data["ptc_level"]) except (KeyError, ValueError): return None @property def ptc_status(self) -> bool: """Return true if PTC status is on.""" return self.data["ptc_status"] @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["child_lock"] @property def buzzer(self) -> bool: """Return True if sound is on.""" return self.data["sound"] @property def display(self) -> bool: """Return True if the display is on.""" return self.data["display"] @property def display_orientation(self) -> Optional[DisplayOrientation]: """Display orientation.""" try: return DisplayOrientation(self.data["screen_direction"]) except (KeyError, ValueError): return None class AirFreshA1(Device): """Main class representing the air fresh a1.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "PM2.5: {result.pm25}\n" "CO2: {result.co2}\n" "Temperature: {result.temperature}\n" "Favorite speed: {result.favorite_speed}\n" "Control speed: {result.control_speed}\n" "Dust filter life: {result.dust_filter_life_remaining} %, " "{result.dust_filter_life_remaining_days} days\n" "PTC: {result.ptc}\n" "PTC status: {result.ptc_status}\n" "Child lock: {result.child_lock}\n" "Buzzer: {result.buzzer}\n" "Display: {result.display}\n", ) ) def status(self) -> AirFreshStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_AIRFRESH_A1] ) values = self.get_properties(properties, max_properties=15) return AirFreshStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", [True]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", [False]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("set_mode", [mode.value]) @command( click.argument("display", type=bool), default_output=format_output( lambda led: "Turning on display" if led else "Turning off display" ), ) def set_display(self, display: bool): """Turn led on/off.""" return self.send("set_display", [display]) @command( click.argument("ptc", type=bool), default_output=format_output( lambda ptc: "Turning on ptc" if ptc else "Turning off ptc" ), ) def set_ptc(self, ptc: bool): """Turn ptc on/off.""" return self.send("set_ptc_on", [ptc]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set sound on/off.""" return self.send("set_sound", [buzzer]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.send("set_child_lock", [lock]) @command(default_output=format_output("Resetting dust filter")) def reset_dust_filter(self): """Resets filter lifetime of the dust filter.""" return self.send("set_filter_rate", [100]) @command( click.argument("speed", type=int), default_output=format_output("Setting favorite speed to {speed}"), ) def set_favorite_speed(self, speed: int): """Sets the fan speed in favorite mode.""" if speed < 0 or speed > 150: raise AirFreshException("Invalid favorite speed: %s" % speed) return self.send("set_favourite_speed", [speed]) @command() def set_ptc_timer(self): """ value = time.index + '-' + time.hexSum + '-' + time.startTime + '-' + time.ptcTimer.endTime + '-' + time.level + '-' + time.status; return self.send("set_ptc_timer", [value]) """ raise NotImplementedError() @command() def get_ptc_timer(self): """Returns a list of PTC timers. Response unknown. """ return self.send("get_ptc_timer") @command() def get_timer(self): """Response unknown.""" return self.send("get_timer") class AirFreshT2017(AirFreshA1): """Main class representing the air fresh t2017.""" @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "PM2.5: {result.pm25}\n" "CO2: {result.co2}\n" "Temperature: {result.temperature}\n" "Favorite speed: {result.favorite_speed}\n" "Control speed: {result.control_speed}\n" "Dust filter life: {result.dust_filter_life_remaining} %, " "{result.dust_filter_life_remaining_days} days\n" "Upper filter life remaining: {result.upper_filter_life_remaining} %, " "{result.upper_filter_life_remaining_days} days\n" "PTC: {result.ptc}\n" "PTC level: {result.ptc_level}\n" "PTC status: {result.ptc_status}\n" "Child lock: {result.child_lock}\n" "Buzzer: {result.buzzer}\n" "Display: {result.display}\n" "Display orientation: {result.display_orientation}\n", ) ) @command( click.argument("speed", type=int), default_output=format_output("Setting favorite speed to {speed}"), ) def set_favorite_speed(self, speed: int): """Sets the fan speed in favorite mode.""" if speed < 60 or speed > 300: raise AirFreshException("Invalid favorite speed: %s" % speed) return self.send("set_favourite_speed", [speed]) @command(default_output=format_output("Resetting dust filter")) def reset_dust_filter(self): """Resets filter lifetime of the dust filter.""" return self.send("set_filter_reset", ["intermediate"]) @command(default_output=format_output("Resetting upper filter")) def reset_upper_filter(self): """Resets filter lifetime of the upper filter.""" return self.send("set_filter_reset", ["efficient"]) @command( click.argument("orientation", type=EnumType(DisplayOrientation)), default_output=format_output("Setting orientation to '{orientation.value}'"), ) def set_display_orientation(self, orientation: DisplayOrientation): """Set display orientation.""" return self.send("set_screen_direction", [orientation.value]) @command( click.argument("level", type=EnumType(PtcLevel)), default_output=format_output("Setting ptc level to '{level.value}'"), ) def set_ptc_level(self, level: PtcLevel): """Set PTC level.""" return self.send("set_ptc_level", [level.value]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/dmaker/tests/__init__.py0000644000000000000000000000000014265350055023476 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/dmaker/tests/test_airfresh_t2017.py0000644000000000000000000003156314265350055025460 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from .. import AirFreshA1, AirFreshT2017 from ..airfresh_t2017 import ( MODEL_AIRFRESH_A1, MODEL_AIRFRESH_T2017, AirFreshException, AirFreshStatus, DisplayOrientation, OperationMode, PtcLevel, ) class DummyAirFreshA1(DummyDevice, AirFreshA1): def __init__(self, *args, **kwargs): self._model = MODEL_AIRFRESH_A1 self.state = { "power": True, "mode": "auto", "pm25": 2, "co2": 554, "temperature_outside": 12, "favourite_speed": 150, "control_speed": 45, "filter_rate": 45, "filter_day": 81, "ptc_on": False, "ptc_status": False, "child_lock": False, "sound": True, "display": False, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_sound": lambda x: self._set_state("sound", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_display": lambda x: self._set_state("display", x), "set_ptc_on": lambda x: self._set_state("ptc_on", x), "set_favourite_speed": lambda x: self._set_state("favourite_speed", x), "set_filter_rate": lambda x: self._set_filter_rate(x), } super().__init__(args, kwargs) def _set_filter_rate(self, value: str): if value[0] == 100: self._set_state("filter_rate", [100]) self._set_state("filter_day", [180]) @pytest.fixture(scope="class") def airfresha1(request): request.cls.device = DummyAirFreshA1() # TODO add ability to test on a real device @pytest.mark.usefixtures("airfresha1") class TestAirFreshA1(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(AirFreshStatus(self.device.start_state)) assert self.is_on() is True assert ( self.state().temperature == self.device.start_state["temperature_outside"] ) assert self.state().co2 == self.device.start_state["co2"] assert self.state().pm25 == self.device.start_state["pm25"] assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().buzzer == self.device.start_state["sound"] assert self.state().child_lock == self.device.start_state["child_lock"] def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Off) assert mode() == OperationMode.Off self.device.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode(OperationMode.Sleep) assert mode() == OperationMode.Sleep self.device.set_mode(OperationMode.Favorite) assert mode() == OperationMode.Favorite def test_set_display(self): def display(): return self.device.status().display self.device.set_display(True) assert display() is True self.device.set_display(False) assert display() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_reset_dust_filter(self): def dust_filter_life_remaining(): return self.device.status().dust_filter_life_remaining def dust_filter_life_remaining_days(): return self.device.status().dust_filter_life_remaining_days self.device._reset_state() assert dust_filter_life_remaining() != 100 assert dust_filter_life_remaining_days() != 180 self.device.reset_dust_filter() assert dust_filter_life_remaining() == 100 assert dust_filter_life_remaining_days() == 180 def test_set_favorite_speed(self): def favorite_speed(): return self.device.status().favorite_speed self.device.set_favorite_speed(0) assert favorite_speed() == 0 self.device.set_favorite_speed(150) assert favorite_speed() == 150 with pytest.raises(AirFreshException): self.device.set_favorite_speed(-1) with pytest.raises(AirFreshException): self.device.set_favorite_speed(151) def test_set_ptc(self): def ptc(): return self.device.status().ptc self.device.set_ptc(True) assert ptc() is True self.device.set_ptc(False) assert ptc() is False class DummyAirFreshT2017(DummyDevice, AirFreshT2017): def __init__(self, *args, **kwargs): self._model = MODEL_AIRFRESH_T2017 self.state = { "power": True, "mode": "favourite", "pm25": 1, "co2": 550, "temperature_outside": 24, "favourite_speed": 241, "control_speed": 241, "filter_intermediate": 99, "filter_inter_day": 89, "filter_efficient": 99, "filter_effi_day": 179, "ptc_on": False, "ptc_level": "low", "ptc_status": False, "child_lock": False, "sound": True, "display": False, "screen_direction": "forward", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_sound": lambda x: self._set_state("sound", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_display": lambda x: self._set_state("display", x), "set_screen_direction": lambda x: self._set_state("screen_direction", x), "set_ptc_level": lambda x: self._set_state("ptc_level", x), "set_ptc_on": lambda x: self._set_state("ptc_on", x), "set_favourite_speed": lambda x: self._set_state("favourite_speed", x), "set_filter_reset": lambda x: self._set_filter_reset(x), } super().__init__(args, kwargs) def _set_filter_reset(self, value: str): if value[0] == "efficient": self._set_state("filter_efficient", [100]) self._set_state("filter_effi_day", [180]) if value[0] == "intermediate": self._set_state("filter_intermediate", [100]) self._set_state("filter_inter_day", [90]) @pytest.fixture(scope="class") def airfresht2017(request): request.cls.device = DummyAirFreshT2017() # TODO add ability to test on a real device @pytest.mark.usefixtures("airfresht2017") class TestAirFreshT2017(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(AirFreshStatus(self.device.start_state)) assert self.is_on() is True assert ( self.state().temperature == self.device.start_state["temperature_outside"] ) assert self.state().co2 == self.device.start_state["co2"] assert self.state().pm25 == self.device.start_state["pm25"] assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().buzzer == self.device.start_state["sound"] assert self.state().child_lock == self.device.start_state["child_lock"] def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Off) assert mode() == OperationMode.Off self.device.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode(OperationMode.Sleep) assert mode() == OperationMode.Sleep self.device.set_mode(OperationMode.Favorite) assert mode() == OperationMode.Favorite def test_set_display(self): def display(): return self.device.status().display self.device.set_display(True) assert display() is True self.device.set_display(False) assert display() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_reset_dust_filter(self): def dust_filter_life_remaining(): return self.device.status().dust_filter_life_remaining def dust_filter_life_remaining_days(): return self.device.status().dust_filter_life_remaining_days self.device._reset_state() assert dust_filter_life_remaining() != 100 assert dust_filter_life_remaining_days() != 90 self.device.reset_dust_filter() assert dust_filter_life_remaining() == 100 assert dust_filter_life_remaining_days() == 90 def test_reset_upper_filter(self): def upper_filter_life_remaining(): return self.device.status().upper_filter_life_remaining def upper_filter_life_remaining_days(): return self.device.status().upper_filter_life_remaining_days self.device._reset_state() assert upper_filter_life_remaining() != 100 assert upper_filter_life_remaining_days() != 180 self.device.reset_upper_filter() assert upper_filter_life_remaining() == 100 assert upper_filter_life_remaining_days() == 180 def test_set_favorite_speed(self): def favorite_speed(): return self.device.status().favorite_speed self.device.set_favorite_speed(60) assert favorite_speed() == 60 self.device.set_favorite_speed(120) assert favorite_speed() == 120 self.device.set_favorite_speed(240) assert favorite_speed() == 240 self.device.set_favorite_speed(300) assert favorite_speed() == 300 with pytest.raises(AirFreshException): self.device.set_favorite_speed(-1) with pytest.raises(AirFreshException): self.device.set_favorite_speed(59) with pytest.raises(AirFreshException): self.device.set_favorite_speed(301) def test_set_ptc(self): def ptc(): return self.device.status().ptc self.device.set_ptc(True) assert ptc() is True self.device.set_ptc(False) assert ptc() is False def test_set_ptc_level(self): def ptc_level(): return self.device.status().ptc_level self.device.set_ptc_level(PtcLevel.Low) assert ptc_level() == PtcLevel.Low self.device.set_ptc_level(PtcLevel.Medium) assert ptc_level() == PtcLevel.Medium self.device.set_ptc_level(PtcLevel.High) assert ptc_level() == PtcLevel.High def test_set_display_orientation(self): def display_orientation(): return self.device.status().display_orientation self.device.set_display_orientation(DisplayOrientation.Portrait) assert display_orientation() == DisplayOrientation.Portrait self.device.set_display_orientation(DisplayOrientation.LandscapeLeft) assert display_orientation() == DisplayOrientation.LandscapeLeft self.device.set_display_orientation(DisplayOrientation.LandscapeRight) assert display_orientation() == DisplayOrientation.LandscapeRight ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/__init__.py0000644000000000000000000000020114265350055022214 0ustar00# flake8: noqa from .airfresh import AirFresh from .airpurifier import AirPurifier from .airpurifier_miot import AirPurifierMiot ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airfilter_util.py0000644000000000000000000000254514265350055023510 0ustar00import enum import re from typing import Dict, Optional class FilterType(enum.Enum): Regular = "regular" AntiBacterial = "anti-bacterial" AntiFormaldehyde = "anti-formaldehyde" Unknown = "unknown" FILTER_TYPE_RE = ( (re.compile(r"^\d+:\d+:41:30$"), FilterType.AntiBacterial), (re.compile(r"^\d+:\d+:(30|0|00):31$"), FilterType.AntiFormaldehyde), (re.compile(r".*"), FilterType.Regular), ) class FilterTypeUtil: """Utility class for determining xiaomi air filter type.""" _filter_type_cache: Dict[str, Optional[FilterType]] = {} def determine_filter_type( self, rfid_tag: Optional[str], product_id: Optional[str] ) -> Optional[FilterType]: """Determine Xiaomi air filter type based on its product ID. :param rfid_tag: RFID tag value :param product_id: Product ID such as "0:0:30:33" """ if rfid_tag is None: return None if rfid_tag == "0:0:0:0:0:0:0": return FilterType.Unknown if product_id is None: return FilterType.Regular ft = self._filter_type_cache.get(product_id) if ft is None: for filter_re, filter_type in FILTER_TYPE_RE: if filter_re.match(product_id): ft = self._filter_type_cache[product_id] = filter_type break return ft ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airfresh.py0000644000000000000000000002312014265350055022265 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) MODEL_AIRFRESH_VA2 = "zhimi.airfresh.va2" MODEL_AIRFRESH_VA4 = "zhimi.airfresh.va4" AVAILABLE_PROPERTIES_COMMON = [ "power", "temp_dec", "aqi", "average_aqi", "co2", "buzzer", "child_lock", "humidity", "led_level", "mode", "motor1_speed", "use_time", "ntcT", "app_extra", "f1_hour_used", "filter_life", "f_hour", "favorite_level", "led", ] AVAILABLE_PROPERTIES = { MODEL_AIRFRESH_VA2: AVAILABLE_PROPERTIES_COMMON, MODEL_AIRFRESH_VA4: AVAILABLE_PROPERTIES_COMMON + ["ptc_state"], } class AirFreshException(DeviceException): pass class OperationMode(enum.Enum): # Supported modes of the Air Fresh VA2 (zhimi.airfresh.va2) Auto = "auto" Silent = "silent" Interval = "interval" Low = "low" Middle = "middle" Strong = "strong" class LedBrightness(enum.Enum): Bright = 0 Dim = 1 Off = 2 class AirFreshStatus(DeviceStatus): """Container for status reports from the air fresh.""" def __init__(self, data: Dict[str, Any], model: str) -> None: """ Response of a Air Fresh VA4 (zhimi.airfresh.va4): { 'power': 'on', 'temp_dec': 28.5, 'aqi': 1, 'average_aqi': 1, 'co2': 1081, 'buzzer': 'off', 'child_lock': 'off', 'humidity': 40, 'led_level': 1, 'mode': 'silent', 'motor1_speed': 400, 'use_time': 510000, 'ntcT': 33.53, 'app_extra': None, 'f1_hour_used': 141, 'filter_life': None, 'f_hour': None, 'favorite_level': None, 'led': None, 'ptc_state': 'off', } """ self.data = data self.model = model @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """Return True if device is on.""" return self.power == "on" @property def aqi(self) -> int: """Air quality index.""" return self.data["aqi"] @property def average_aqi(self) -> int: """Average of the air quality index.""" return self.data["average_aqi"] @property def co2(self) -> int: """Carbon dioxide.""" return self.data["co2"] @property def humidity(self) -> int: """Current humidity.""" return self.data["humidity"] @property def ptc(self) -> Optional[bool]: """Return True if PTC is on.""" if self.data["ptc_state"] is not None: return self.data["ptc_state"] == "on" return None @property def temperature(self) -> Optional[float]: """Current temperature, if available.""" if self.data["temp_dec"] is not None: if self.model == MODEL_AIRFRESH_VA4: return self.data["temp_dec"] else: return self.data["temp_dec"] / 10.0 return None @property def ntc_temperature(self) -> Optional[float]: """Current ntc temperature, if available.""" if self.data["ntcT"] is not None: return self.data["ntcT"] return None @property def mode(self) -> OperationMode: """Current operation mode.""" return OperationMode(self.data["mode"]) @property def led(self) -> bool: """Return True if LED is on.""" return self.data["led"] == "on" @property def led_brightness(self) -> Optional[LedBrightness]: """Brightness of the LED.""" if self.data["led_level"] is not None: try: return LedBrightness(self.data["led_level"]) except ValueError: _LOGGER.error( "Unsupported LED brightness discarded: %s", self.data["led_level"] ) return None return None @property def buzzer(self) -> Optional[bool]: """Return True if buzzer is on.""" if self.data["buzzer"] is not None: return self.data["buzzer"] == "on" return None @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["child_lock"] == "on" @property def filter_life_remaining(self) -> int: """Time until the filter should be changed.""" return self.data["filter_life"] @property def filter_hours_used(self) -> int: """How long the filter has been in use.""" return self.data["f1_hour_used"] @property def use_time(self) -> int: """How long the device has been active in seconds.""" return self.data["use_time"] @property def motor_speed(self) -> int: """Speed of the motor.""" return self.data["motor1_speed"] @property def extra_features(self) -> Optional[int]: return self.data["app_extra"] class AirFresh(Device): """Main class representing the air fresh.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Heater (PTC): {result.ptc}\n" "AQI: {result.aqi} μg/m³\n" "Average AQI: {result.average_aqi} μg/m³\n" "Temperature: {result.temperature} °C\n" "NTC temperature: {result.ntc_temperature} °C\n" "Humidity: {result.humidity} %\n" "CO2: {result.co2} %\n" "Mode: {result.mode.value}\n" "LED: {result.led}\n" "LED brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Filter life remaining: {result.filter_life_remaining} %\n" "Filter hours used: {result.filter_hours_used}\n" "Use time: {result.use_time} s\n" "Motor speed: {result.motor_speed} rpm\n", ) ) def status(self) -> AirFreshStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_AIRFRESH_VA2] ) values = self.get_properties(properties, max_properties=15) return AirFreshStatus( defaultdict(lambda: None, zip(properties, values)), self.model ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("set_mode", [mode.value]) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" if led: return self.send("set_led", ["on"]) else: return self.send("set_led", ["off"]) @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" return self.send("set_led_level", [brightness.value]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if buzzer: return self.send("set_buzzer", ["on"]) else: return self.send("set_buzzer", ["off"]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if lock: return self.send("set_child_lock", ["on"]) else: return self.send("set_child_lock", ["off"]) @command( click.argument("value", type=int), default_output=format_output("Setting extra to {value}"), ) def set_extra_features(self, value: int): """Storage register to enable extra features at the app.""" if value < 0: raise AirFreshException("Invalid app extra value: %s" % value) return self.send("set_app_extra", [value]) @command(default_output=format_output("Resetting filter")) def reset_filter(self): """Resets filter hours used and remaining life.""" return self.send("reset_filter1") @command( click.argument("ptc", type=bool), default_output=format_output( lambda buzzer: "Turning on PTC" if buzzer else "Turning off PTC" ), ) def set_ptc(self, ptc: bool): """Set PTC on/off.""" if ptc: return self.send("set_ptc_state", ["on"]) else: return self.send("set_ptc_state", ["off"]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airpurifier.py0000644000000000000000000004166114265350055023015 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output from .airfilter_util import FilterType, FilterTypeUtil _LOGGER = logging.getLogger(__name__) SUPPORTED_MODELS = [ "zhimi.airpurifier.v1", "zhimi.airpurifier.v2", "zhimi.airpurifier.v3", "zhimi.airpurifier.v5", "zhimi.airpurifier.v6", "zhimi.airpurifier.v7", "zhimi.airpurifier.m1", "zhimi.airpurifier.m2", "zhimi.airpurifier.ma1", "zhimi.airpurifier.ma2", "zhimi.airpurifier.sa1", "zhimi.airpurifier.sa2", "zhimi.airpurifier.mc1", "zhimi.airpurifier.mc2", ] class AirPurifierException(DeviceException): pass class OperationMode(enum.Enum): # Supported modes of the Air Purifier Pro, 2, V3 Auto = "auto" Silent = "silent" Favorite = "favorite" # Additional supported modes of the Air Purifier 2 and V3 Idle = "idle" # Additional supported modes of the Air Purifier V3 Medium = "medium" High = "high" Strong = "strong" # Additional supported modes of the Air Purifier Super 2 Low = "low" class SleepMode(enum.Enum): Off = "poweroff" Silent = "silent" Idle = "idle" class LedBrightness(enum.Enum): Bright = 0 Dim = 1 Off = 2 class AirPurifierStatus(DeviceStatus): """Container for status reports from the air purifier.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Air Purifier Pro (zhimi.airpurifier.v6): {'power': 'off', 'aqi': 7, 'average_aqi': 18, 'humidity': 45, 'temp_dec': 234, 'mode': 'auto', 'favorite_level': 17, 'filter1_life': 52, 'f1_hour_used': 1664, 'use_time': 2642700, 'motor1_speed': 0, 'motor2_speed': 800, 'purify_volume': 62180, 'f1_hour': 3500, 'led': 'on', 'led_b': None, 'bright': 83, 'buzzer': None, 'child_lock': 'off', 'volume': 50, 'rfid_product_id': '0:0:41:30', 'rfid_tag': '80:52:86:e2:d8:86:4', 'act_sleep': 'close'} Response of a Air Purifier Pro (zhimi.airpurifier.v7): {'power': 'on', 'aqi': 2, 'average_aqi': 3, 'humidity': 42, 'temp_dec': 223, 'mode': 'favorite', 'favorite_level': 3, 'filter1_life': 56, 'f1_hour_used': 1538, 'use_time': None, 'motor1_speed': 300, 'motor2_speed': 898, 'purify_volume': None, 'f1_hour': 3500, 'led': 'on', 'led_b': None, 'bright': 45, 'buzzer': None, 'child_lock': 'off', 'volume': 0, 'rfid_product_id': '0:0:30:33', 'rfid_tag': '80:6a:a9:e2:37:92:4', 'act_sleep': None, 'sleep_mode': None, 'sleep_time': None, 'sleep_data_num': None, 'app_extra': 0, 'act_det': None, 'button_pressed': None} Response of a Air Purifier 2 (zhimi.airpurifier.m1): {'power': 'on, 'aqi': 10, 'average_aqi': 8, 'humidity': 62, 'temp_dec': 186, 'mode': 'auto', 'favorite_level': 10, 'filter1_life': 80, 'f1_hour_used': 682, 'use_time': 2457000, 'motor1_speed': 354, 'motor2_speed': None, 'purify_volume': 25262, 'f1_hour': 3500, 'led': 'off', 'led_b': 2, 'bright': None, 'buzzer': 'off', 'child_lock': 'off', 'volume': None, 'rfid_product_id': None, 'rfid_tag': None, 'act_sleep': 'close'} Response of a Air Purifier 2 (zhimi.airpurifier.m2): {'power': 'on', 'aqi': 10, 'average_aqi': 8, 'humidity': 42, 'temp_dec': 223, 'mode': 'favorite', 'favorite_level': 2, 'filter1_life': 63, 'f1_hour_used': 1282, 'use_time': 16361416, 'motor1_speed': 747, 'motor2_speed': None, 'purify_volume': 421580, 'f1_hour': 3500, 'led': 'on', 'led_b': 1, 'bright': None, 'buzzer': 'off', 'child_lock': 'off', 'volume': None, 'rfid_product_id': None, 'rfid_tag': None, 'act_sleep': 'close', 'sleep_mode': 'idle', 'sleep_time': 86168, 'sleep_data_num': 30, 'app_extra': 0, 'act_det': None, 'button_pressed': None} Response of a Air Purifier V3 (zhimi.airpurifier.v3) {'power': 'off', 'aqi': 0, 'humidity': None, 'temp_dec': None, 'mode': 'idle', 'led': 'off', 'led_b': 10, 'buzzer': 'on', 'child_lock': 'off', 'bright': 43, 'favorite_level': None, 'filter1_life': 26, 'f1_hour_used': 2573, 'use_time': None, 'motor1_speed': 0} {'power': 'on', 'aqi': 18, 'humidity': None, 'temp_dec': None, 'mode': 'silent', 'led': 'off', 'led_b': 10, 'buzzer': 'on', 'child_lock': 'off', 'bright': 4, 'favorite_level': None, 'filter1_life': 26, 'f1_hour_used': 2574, 'use_time': None, 'motor1_speed': 648} A request is limited to 16 properties. """ self.filter_type_util = FilterTypeUtil() self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """Return True if device is on.""" return self.power == "on" @property def aqi(self) -> int: """Air quality index.""" return self.data["aqi"] @property def average_aqi(self) -> int: """Average of the air quality index.""" return self.data["average_aqi"] @property def humidity(self) -> int: """Current humidity.""" return self.data["humidity"] @property def temperature(self) -> Optional[float]: """Current temperature, if available.""" if self.data["temp_dec"] is not None: return self.data["temp_dec"] / 10.0 return None @property def mode(self) -> OperationMode: """Current operation mode.""" return OperationMode(self.data["mode"]) @property def sleep_mode(self) -> Optional[SleepMode]: """Operation mode of the sleep state. (Idle vs. Silent) """ if self.data["sleep_mode"] is not None: return SleepMode(self.data["sleep_mode"]) return None @property def led(self) -> bool: """Return True if LED is on.""" return self.data["led"] == "on" @property def led_brightness(self) -> Optional[LedBrightness]: """Brightness of the LED.""" if self.data["led_b"] is not None: try: return LedBrightness(self.data["led_b"]) except ValueError: return None return None @property def illuminance(self) -> Optional[int]: """Environment illuminance level in lux [0-200]. Sensor value is updated only when device is turned on. """ return self.data["bright"] @property def buzzer(self) -> Optional[bool]: """Return True if buzzer is on.""" if self.data["buzzer"] is not None: return self.data["buzzer"] == "on" return None @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["child_lock"] == "on" @property def favorite_level(self) -> int: """Return favorite level, which is used if the mode is ``favorite``.""" # Favorite level used when the mode is `favorite`. return self.data["favorite_level"] @property def filter_life_remaining(self) -> int: """Time until the filter should be changed.""" return self.data["filter1_life"] @property def filter_hours_used(self) -> int: """How long the filter has been in use.""" return self.data["f1_hour_used"] @property def use_time(self) -> int: """How long the device has been active in seconds.""" return self.data["use_time"] @property def purify_volume(self) -> int: """The volume of purified air in cubic meter.""" return self.data["purify_volume"] @property def motor_speed(self) -> int: """Speed of the motor.""" return self.data["motor1_speed"] @property def motor2_speed(self) -> Optional[int]: """Speed of the 2nd motor.""" return self.data["motor2_speed"] @property def volume(self) -> Optional[int]: """Volume of sound notifications [0-100].""" return self.data["volume"] @property def filter_rfid_product_id(self) -> Optional[str]: """RFID product ID of installed filter.""" return self.data["rfid_product_id"] @property def filter_rfid_tag(self) -> Optional[str]: """RFID tag ID of installed filter.""" return self.data["rfid_tag"] @property def filter_type(self) -> Optional[FilterType]: """Type of installed filter.""" return self.filter_type_util.determine_filter_type( self.filter_rfid_tag, self.filter_rfid_product_id ) @property def learn_mode(self) -> bool: """Return True if Learn Mode is enabled.""" return self.data["act_sleep"] == "single" @property def sleep_time(self) -> Optional[int]: return self.data["sleep_time"] @property def sleep_mode_learn_count(self) -> Optional[int]: return self.data["sleep_data_num"] @property def extra_features(self) -> Optional[int]: return self.data["app_extra"] @property def turbo_mode_supported(self) -> Optional[bool]: if self.data["app_extra"] is not None: return self.data["app_extra"] == 1 return None @property def auto_detect(self) -> Optional[bool]: """Return True if auto detect is enabled.""" if self.data["act_det"] is not None: return self.data["act_det"] == "on" return None @property def button_pressed(self) -> Optional[str]: """Last pressed button.""" return self.data["button_pressed"] class AirPurifier(Device): """Main class representing the air purifier.""" _supported_models = SUPPORTED_MODELS @command( default_output=format_output( "", "Power: {result.power}\n" "AQI: {result.aqi} μg/m³\n" "Average AQI: {result.average_aqi} μg/m³\n" "Temperature: {result.temperature} °C\n" "Humidity: {result.humidity} %\n" "Mode: {result.mode.value}\n" "LED: {result.led}\n" "LED brightness: {result.led_brightness}\n" "Illuminance: {result.illuminance} lx\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Favorite level: {result.favorite_level}\n" "Filter life remaining: {result.filter_life_remaining} %\n" "Filter hours used: {result.filter_hours_used}\n" "Use time: {result.use_time} s\n" "Purify volume: {result.purify_volume} m³\n" "Motor speed: {result.motor_speed} rpm\n" "Motor 2 speed: {result.motor2_speed} rpm\n" "Sound volume: {result.volume} %\n" "Filter RFID product id: {result.filter_rfid_product_id}\n" "Filter RFID tag: {result.filter_rfid_tag}\n" "Filter type: {result.filter_type}\n" "Learn mode: {result.learn_mode}\n" "Sleep mode: {result.sleep_mode}\n" "Sleep time: {result.sleep_time}\n" "Sleep mode learn count: {result.sleep_mode_learn_count}\n" "AQI sensor enabled on power off: {result.auto_detect}\n", ) ) def status(self) -> AirPurifierStatus: """Retrieve properties.""" properties = [ "power", "aqi", "average_aqi", "humidity", "temp_dec", "mode", "favorite_level", "filter1_life", "f1_hour_used", "use_time", "motor1_speed", "motor2_speed", "purify_volume", "f1_hour", "led", # Second request "led_b", "bright", "buzzer", "child_lock", "volume", "rfid_product_id", "rfid_tag", "act_sleep", "sleep_mode", "sleep_time", "sleep_data_num", "app_extra", "act_det", "button_pressed", ] values = self.get_properties(properties, max_properties=15) return AirPurifierStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("set_mode", [mode.value]) @command( click.argument("level", type=int), default_output=format_output("Setting favorite level to {level}"), ) def set_favorite_level(self, level: int): """Set favorite level.""" if level < 0 or level > 17: raise AirPurifierException("Invalid favorite level: %s" % level) # Possible alternative property: set_speed_favorite # Set the favorite level used when the mode is `favorite`, # should be between 0 and 17. return self.send("set_level_favorite", [level]) # 0 ... 17 @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" return self.send("set_led_b", [brightness.value]) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" if led: return self.send("set_led", ["on"]) else: return self.send("set_led", ["off"]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if buzzer: return self.send("set_buzzer", ["on"]) else: return self.send("set_buzzer", ["off"]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if lock: return self.send("set_child_lock", ["on"]) else: return self.send("set_child_lock", ["off"]) @command( click.argument("volume", type=int), default_output=format_output("Setting sound volume to {volume}"), ) def set_volume(self, volume: int): """Set volume of sound notifications [0-100].""" if volume < 0 or volume > 100: raise AirPurifierException("Invalid volume: %s" % volume) return self.send("set_volume", [volume]) @command( click.argument("learn_mode", type=bool), default_output=format_output( lambda learn_mode: "Turning on learn mode" if learn_mode else "Turning off learn mode" ), ) def set_learn_mode(self, learn_mode: bool): """Set the Learn Mode on/off.""" if learn_mode: return self.send("set_act_sleep", ["single"]) else: return self.send("set_act_sleep", ["close"]) @command( click.argument("auto_detect", type=bool), default_output=format_output( lambda auto_detect: "Turning on auto detect" if auto_detect else "Turning off auto detect" ), ) def set_auto_detect(self, auto_detect: bool): """Set auto detect on/off. It's a feature of the AirPurifier V1 & V3 """ if auto_detect: return self.send("set_act_det", ["on"]) else: return self.send("set_act_det", ["off"]) @command( click.argument("value", type=int), default_output=format_output("Setting extra to {value}"), ) def set_extra_features(self, value: int): """Storage register to enable extra features at the app. app_extra=1 unlocks a turbo mode supported feature """ if value < 0: raise AirPurifierException("Invalid app extra value: %s" % value) return self.send("set_app_extra", [value]) @command(default_output=format_output("Resetting filter")) def reset_filter(self): """Resets filter hours used and remaining life.""" return self.send("reset_filter1") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airpurifier_miot.py0000644000000000000000000006247614265350055024054 0ustar00import enum import logging from typing import Any, Dict, Optional import click from miio import DeviceException, DeviceStatus, MiotDevice from miio.click_common import EnumType, command, format_output from .airfilter_util import FilterType, FilterTypeUtil _LOGGER = logging.getLogger(__name__) _MAPPING = { # Air Purifier (siid=2) "power": {"siid": 2, "piid": 2}, "fan_level": {"siid": 2, "piid": 4}, "mode": {"siid": 2, "piid": 5}, # Environment (siid=3) "humidity": {"siid": 3, "piid": 7}, "temperature": {"siid": 3, "piid": 8}, "aqi": {"siid": 3, "piid": 6}, # Filter (siid=4) "filter_life_remaining": {"siid": 4, "piid": 3}, "filter_hours_used": {"siid": 4, "piid": 5}, # Alarm (siid=5) "buzzer": {"siid": 5, "piid": 1}, "buzzer_volume": {"siid": 5, "piid": 2}, # Indicator Light (siid=6) "led_brightness": {"siid": 6, "piid": 1}, "led": {"siid": 6, "piid": 6}, # Physical Control Locked (siid=7) "child_lock": {"siid": 7, "piid": 1}, # Motor Speed (siid=10) "favorite_level": {"siid": 10, "piid": 10}, "favorite_rpm": {"siid": 10, "piid": 7}, "motor_speed": {"siid": 10, "piid": 8}, # Use time (siid=12) "use_time": {"siid": 12, "piid": 1}, # AQI (siid=13) "purify_volume": {"siid": 13, "piid": 1}, "average_aqi": {"siid": 13, "piid": 2}, "aqi_realtime_update_duration": {"siid": 13, "piid": 9}, # RFID (siid=14) "filter_rfid_tag": {"siid": 14, "piid": 1}, "filter_rfid_product_id": {"siid": 14, "piid": 3}, # Other (siid=15) "app_extra": {"siid": 15, "piid": 1}, } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-mb4:2 _MAPPING_MB4 = { # Air Purifier "power": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 4}, # Environment "aqi": {"siid": 3, "piid": 4}, # Filter "filter_life_remaining": {"siid": 4, "piid": 1}, "filter_hours_used": {"siid": 4, "piid": 3}, # Alarm "buzzer": {"siid": 6, "piid": 1}, # Screen "led_brightness_level": {"siid": 7, "piid": 2}, # Physical Control Locked "child_lock": {"siid": 8, "piid": 1}, # custom-service "motor_speed": {"siid": 9, "piid": 1}, "favorite_rpm": {"siid": 9, "piid": 3}, } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-va2:2 # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-mb5:1 _MAPPING_VA2 = { # Air Purifier "power": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 4}, "fan_level": {"siid": 2, "piid": 5}, "anion": {"siid": 2, "piid": 6}, # Environment "humidity": {"siid": 3, "piid": 1}, "aqi": {"siid": 3, "piid": 4}, "temperature": {"siid": 3, "piid": 7}, # Filter "filter_life_remaining": {"siid": 4, "piid": 1}, "filter_hours_used": {"siid": 4, "piid": 3}, "filter_left_time": {"siid": 4, "piid": 4}, # Alarm "buzzer": {"siid": 6, "piid": 1}, # Physical Control Locked "child_lock": {"siid": 8, "piid": 1}, # custom-service "motor_speed": {"siid": 9, "piid": 1}, "favorite_rpm": {"siid": 9, "piid": 3}, "favorite_level": {"siid": 9, "piid": 5}, # aqi "purify_volume": {"siid": 11, "piid": 1}, "average_aqi": {"siid": 11, "piid": 2}, "aqi_realtime_update_duration": {"siid": 11, "piid": 4}, # RFID "filter_rfid_tag": {"siid": 12, "piid": 1}, "filter_rfid_product_id": {"siid": 12, "piid": 3}, # Screen "led_brightness": {"siid": 13, "piid": 2}, } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-vb4:1 _MAPPING_VB4 = { # Air Purifier "power": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 4}, "fan_level": {"siid": 2, "piid": 5}, "anion": {"siid": 2, "piid": 6}, # Environment "humidity": {"siid": 3, "piid": 1}, "aqi": {"siid": 3, "piid": 4}, "temperature": {"siid": 3, "piid": 7}, "pm10_density": {"siid": 3, "piid": 8}, # Filter "filter_life_remaining": {"siid": 4, "piid": 1}, "filter_hours_used": {"siid": 4, "piid": 3}, "filter_left_time": {"siid": 4, "piid": 4}, # Alarm "buzzer": {"siid": 6, "piid": 1}, # Physical Control Locked "child_lock": {"siid": 8, "piid": 1}, # custom-service "motor_speed": {"siid": 9, "piid": 1}, "favorite_rpm": {"siid": 9, "piid": 3}, "favorite_level": {"siid": 9, "piid": 5}, # aqi "purify_volume": {"siid": 11, "piid": 1}, "average_aqi": {"siid": 11, "piid": 2}, "aqi_realtime_update_duration": {"siid": 11, "piid": 4}, # RFID "filter_rfid_tag": {"siid": 12, "piid": 1}, "filter_rfid_product_id": {"siid": 12, "piid": 3}, # Screen "led_brightness": {"siid": 13, "piid": 2}, # Device Display Unit "device-display-unit": {"siid": 14, "piid": 1}, } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-rma1:1 _MAPPING_RMA1 = { # Air Purifier "power": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 4}, # Environment "humidity": {"siid": 3, "piid": 1}, "aqi": {"siid": 3, "piid": 4}, "temperature": {"siid": 3, "piid": 7}, # Filter "filter_life_remaining": {"siid": 4, "piid": 1}, "filter_hours_used": {"siid": 4, "piid": 3}, "filter_left_time": {"siid": 4, "piid": 4}, # Alarm "buzzer": {"siid": 6, "piid": 1}, # Physical Control Locked "child_lock": {"siid": 8, "piid": 1}, # custom-service "motor_speed": {"siid": 9, "piid": 1}, "favorite_level": {"siid": 9, "piid": 2}, # aqi "aqi_realtime_update_duration": {"siid": 11, "piid": 4}, # Screen "led_brightness": {"siid": 13, "piid": 2}, } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-rmb1:1 # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-rmb1:2 _MAPPING_RMB1 = { # Air Purifier "power": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 4}, # Environment "humidity": {"siid": 3, "piid": 1}, "aqi": {"siid": 3, "piid": 4}, "temperature": {"siid": 3, "piid": 7}, # Filter "filter_life_remaining": {"siid": 4, "piid": 1}, "filter_hours_used": {"siid": 4, "piid": 3}, "filter_left_time": {"siid": 4, "piid": 4}, # Alarm "buzzer": {"siid": 6, "piid": 1}, # Physical Control Locked "child_lock": {"siid": 8, "piid": 1}, # custom-service "motor_speed": {"siid": 9, "piid": 1}, "favorite_level": {"siid": 9, "piid": 5}, # aqi "aqi_realtime_update_duration": {"siid": 11, "piid": 4}, # Screen "led_brightness": {"siid": 13, "piid": 2}, # Device Display Unit "device-display-unit": {"siid": 14, "piid": 1}, } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-za1:2 _MAPPING_ZA1 = { # Air Purifier (siid=2) "power": {"siid": 2, "piid": 1}, "fan_level": {"siid": 2, "piid": 4}, "mode": {"siid": 2, "piid": 5}, # Environment (siid=3) "humidity": {"siid": 3, "piid": 7}, "temperature": {"siid": 3, "piid": 8}, "aqi": {"siid": 3, "piid": 6}, "tvoc": {"siid": 3, "piid": 1}, # Filter (siid=4) "filter_life_remaining": {"siid": 4, "piid": 3}, "filter_hours_used": {"siid": 4, "piid": 5}, # Alarm (siid=5) "buzzer": {"siid": 5, "piid": 1}, # Indicator Light (siid=6) "led_brightness": {"siid": 6, "piid": 1}, # Physical Control Locked (siid=7) "child_lock": {"siid": 7, "piid": 1}, # Motor Speed (siid=10) "favorite_level": {"siid": 10, "piid": 10}, "motor_speed": {"siid": 10, "piid": 11}, # Use time (siid=12) "use_time": {"siid": 12, "piid": 1}, # AQI (siid=13) "purify_volume": {"siid": 13, "piid": 1}, "average_aqi": {"siid": 13, "piid": 2}, "aqi_realtime_update_duration": {"siid": 13, "piid": 9}, # RFID (siid=14) "filter_rfid_tag": {"siid": 14, "piid": 1}, "filter_rfid_product_id": {"siid": 14, "piid": 3}, # Device Display Unit "device-display-unit": {"siid": 16, "piid": 1}, # Other "gestures": {"siid": 15, "piid": 13}, } _MAPPINGS = { "zhimi.airpurifier.ma4": _MAPPING, # airpurifier 3 "zhimi.airpurifier.mb3": _MAPPING, # airpurifier 3h "zhimi.airpurifier.mb3a": _MAPPING, # airpurifier 3h "zhimi.airpurifier.va1": _MAPPING, # airpurifier proh "zhimi.airpurifier.vb2": _MAPPING, # airpurifier proh "zhimi.airpurifier.mb4": _MAPPING_MB4, # airpurifier 3c "zhimi.airp.mb4a": _MAPPING_MB4, # airpurifier 3c "zhimi.airp.mb5": _MAPPING_VA2, # airpurifier 4 "zhimi.airp.va2": _MAPPING_VA2, # airpurifier 4 pro "zhimi.airp.vb4": _MAPPING_VB4, # airpurifier 4 pro "zhimi.airpurifier.rma1": _MAPPING_RMA1, # airpurifier 4 lite "zhimi.airp.rmb1": _MAPPING_RMB1, # airpurifier 4 lite "zhimi.airpurifier.za1": _MAPPING_ZA1, # smartmi air purifier } class AirPurifierMiotException(DeviceException): pass class OperationMode(enum.Enum): Unknown = -1 Auto = 0 Silent = 1 Favorite = 2 Fan = 3 class LedBrightness(enum.Enum): Bright = 0 Dim = 1 Off = 2 class AirPurifierMiotStatus(DeviceStatus): """Container for status reports from the air purifier. Mi Air Purifier 3/3H (zhimi.airpurifier.mb3) response (MIoT format) [ {'did': 'power', 'siid': 2, 'piid': 2, 'code': 0, 'value': True}, {'did': 'fan_level', 'siid': 2, 'piid': 4, 'code': 0, 'value': 1}, {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 2}, {'did': 'humidity', 'siid': 3, 'piid': 7, 'code': 0, 'value': 38}, {'did': 'temperature', 'siid': 3, 'piid': 8, 'code': 0, 'value': 22.299999}, {'did': 'aqi', 'siid': 3, 'piid': 6, 'code': 0, 'value': 2}, {'did': 'filter_life_remaining', 'siid': 4, 'piid': 3, 'code': 0, 'value': 45}, {'did': 'filter_hours_used', 'siid': 4, 'piid': 5, 'code': 0, 'value': 1915}, {'did': 'buzzer', 'siid': 5, 'piid': 1, 'code': 0, 'value': False}, {'did': 'buzzer_volume', 'siid': 5, 'piid': 2, 'code': -4001}, {'did': 'led_brightness', 'siid': 6, 'piid': 1, 'code': 0, 'value': 1}, {'did': 'led', 'siid': 6, 'piid': 6, 'code': 0, 'value': True}, {'did': 'child_lock', 'siid': 7, 'piid': 1, 'code': 0, 'value': False}, {'did': 'favorite_level', 'siid': 10, 'piid': 10, 'code': 0, 'value': 2}, {'did': 'favorite_rpm', 'siid': 10, 'piid': 7, 'code': 0, 'value': 770}, {'did': 'motor_speed', 'siid': 10, 'piid': 8, 'code': 0, 'value': 769}, {'did': 'use_time', 'siid': 12, 'piid': 1, 'code': 0, 'value': 6895800}, {'did': 'purify_volume', 'siid': 13, 'piid': 1, 'code': 0, 'value': 222564}, {'did': 'average_aqi', 'siid': 13, 'piid': 2, 'code': 0, 'value': 2}, {'did': 'filter_rfid_tag', 'siid': 14, 'piid': 1, 'code': 0, 'value': '81:6b:3f:32:84:4b:4'}, {'did': 'filter_rfid_product_id', 'siid': 14, 'piid': 3, 'code': 0, 'value': '0:0:31:31'}, {'did': 'app_extra', 'siid': 15, 'piid': 1, 'code': 0, 'value': 0} ] """ def __init__(self, data: Dict[str, Any], model: str) -> None: self.filter_type_util = FilterTypeUtil() self.data = data self.model = model @property def is_on(self) -> bool: """Return True if device is on.""" return self.data["power"] @property def power(self) -> str: """Power state.""" return "on" if self.is_on else "off" @property def aqi(self) -> Optional[int]: """Air quality index.""" return self.data.get("aqi") @property def mode(self) -> OperationMode: """Current operation mode.""" mode = self.data["mode"] try: return OperationMode(mode) except ValueError: _LOGGER.debug("Unknown mode: %s", mode) return OperationMode.Unknown @property def buzzer(self) -> Optional[bool]: """Return True if buzzer is on.""" return self.data.get("buzzer") @property def child_lock(self) -> Optional[bool]: """Return True if child lock is on.""" return self.data.get("child_lock") @property def filter_life_remaining(self) -> Optional[int]: """Time until the filter should be changed.""" return self.data.get("filter_life_remaining") @property def filter_hours_used(self) -> Optional[int]: """How long the filter has been in use.""" return self.data.get("filter_hours_used") @property def motor_speed(self) -> Optional[int]: """Speed of the motor.""" return self.data.get("motor_speed") @property def favorite_rpm(self) -> Optional[int]: """Return favorite rpm level.""" return self.data.get("favorite_rpm") @property def average_aqi(self) -> Optional[int]: """Average of the air quality index.""" return self.data.get("average_aqi") @property def humidity(self) -> Optional[int]: """Current humidity.""" return self.data.get("humidity") @property def tvoc(self) -> Optional[int]: """Current TVOC.""" return self.data.get("tvoc") @property def temperature(self) -> Optional[float]: """Current temperature, if available.""" temperate = self.data.get("temperature") return round(temperate, 1) if temperate is not None else None @property def pm10_density(self) -> Optional[float]: """Current temperature, if available.""" pm10_density = self.data.get("pm10_density") return round(pm10_density, 1) if pm10_density is not None else None @property def fan_level(self) -> Optional[int]: """Current fan level.""" return self.data.get("fan_level") @property def led(self) -> Optional[bool]: """Return True if LED is on.""" return self.data.get("led") @property def led_brightness(self) -> Optional[LedBrightness]: """Brightness of the LED.""" value = self.data.get("led_brightness") if value is not None: if self.model in ( "zhimi.airp.va2", "zhimi.airp.mb5", "zhimi.airp.vb4", "zhimi.airp.rmb1", ): value = 2 - value try: return LedBrightness(value) except ValueError: return None return None @property def buzzer_volume(self) -> Optional[int]: """Return buzzer volume.""" return self.data.get("buzzer_volume") @property def favorite_level(self) -> Optional[int]: """Return favorite level, which is used if the mode is ``favorite``.""" # Favorite level used when the mode is `favorite`. return self.data.get("favorite_level") @property def use_time(self) -> Optional[int]: """How long the device has been active in seconds.""" return self.data.get("use_time") @property def purify_volume(self) -> Optional[int]: """The volume of purified air in cubic meter.""" return self.data.get("purify_volume") @property def filter_rfid_product_id(self) -> Optional[str]: """RFID product ID of installed filter.""" return self.data.get("filter_rfid_product_id") @property def filter_rfid_tag(self) -> Optional[str]: """RFID tag ID of installed filter.""" return self.data.get("filter_rfid_tag") @property def filter_type(self) -> Optional[FilterType]: """Type of installed filter.""" return self.filter_type_util.determine_filter_type( self.filter_rfid_tag, self.filter_rfid_product_id ) @property def led_brightness_level(self) -> Optional[int]: """Return brightness level.""" return self.data.get("led_brightness_level") @property def anion(self) -> Optional[bool]: """Return whether anion is on.""" return self.data.get("anion") @property def filter_left_time(self) -> Optional[int]: """How many days can the filter still be used.""" return self.data.get("filter_left_time") @property def gestures(self) -> Optional[bool]: """Return True if gesture control is on.""" return self.data.get("gestures") class AirPurifierMiot(MiotDevice): """Main class representing the air purifier which uses MIoT protocol.""" _mappings = _MAPPINGS @command( default_output=format_output( "", "Power: {result.power}\n" "Anion: {result.anion}\n" "AQI: {result.aqi} μg/m³\n" "TVOC: {result.tvoc}\n" "Average AQI: {result.average_aqi} μg/m³\n" "Humidity: {result.humidity} %\n" "Temperature: {result.temperature} °C\n" "PM10 Density: {result.pm10_density} μg/m³\n" "Fan Level: {result.fan_level}\n" "Mode: {result.mode}\n" "LED: {result.led}\n" "LED brightness: {result.led_brightness}\n" "LED brightness level: {result.led_brightness_level}\n" "Gestures: {result.gestures}\n" "Buzzer: {result.buzzer}\n" "Buzzer vol.: {result.buzzer_volume}\n" "Child lock: {result.child_lock}\n" "Favorite level: {result.favorite_level}\n" "Filter life remaining: {result.filter_life_remaining} %\n" "Filter hours used: {result.filter_hours_used}\n" "Filter left time: {result.filter_left_time} days\n" "Use time: {result.use_time} s\n" "Purify volume: {result.purify_volume} m³\n" "Motor speed: {result.motor_speed} rpm\n" "Filter RFID product id: {result.filter_rfid_product_id}\n" "Filter RFID tag: {result.filter_rfid_tag}\n" "Filter type: {result.filter_type}\n", ) ) def status(self) -> AirPurifierMiotStatus: """Retrieve properties.""" # Some devices update the aqi information only every 30min. # This forces the device to poll the sensor for 5 seconds, # so that we get always the most recent values. See #1281. if self.model == "zhimi.airpurifier.mb3": self.set_property("aqi_realtime_update_duration", 5) return AirPurifierMiotStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() }, self.model, ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("rpm", type=int), default_output=format_output("Setting favorite motor speed '{rpm}' rpm"), ) def set_favorite_rpm(self, rpm: int): """Set favorite motor speed.""" if "favorite_rpm" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported favorite rpm for model '%s'" % self.model ) # Note: documentation says the maximum is 2300, however, the purifier may return an error for rpm over 2200. if rpm < 300 or rpm > 2300 or rpm % 10 != 0: raise AirPurifierMiotException( "Invalid favorite motor speed: %s. Must be between 300 and 2300 and divisible by 10" % rpm ) return self.set_property("favorite_rpm", rpm) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.set_property("mode", mode.value) @command( click.argument("anion", type=bool), default_output=format_output( lambda anion: "Turning on anion" if anion else "Turing off anion", ), ) def set_anion(self, anion: bool): """Set anion on/off.""" if "anion" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported anion for model '%s'" % self.model ) return self.set_property("anion", anion) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if "buzzer" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported buzzer for model '%s'" % self.model ) return self.set_property("buzzer", buzzer) @command( click.argument("gestures", type=bool), default_output=format_output( lambda gestures: "Turning on gestures" if gestures else "Turning off gestures" ), ) def set_gestures(self, gestures: bool): """Set gestures on/off.""" if "gestures" not in self._get_mapping(): raise AirPurifierMiotException( "Gestures not support for model '%s'" % self.model ) return self.set_property("gestures", gestures) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if "child_lock" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported child lock for model '%s'" % self.model ) return self.set_property("child_lock", lock) @command( click.argument("level", type=int), default_output=format_output("Setting fan level to '{level}'"), ) def set_fan_level(self, level: int): """Set fan level.""" if "fan_level" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported fan level for model '%s'" % self.model ) if level < 1 or level > 3: raise AirPurifierMiotException("Invalid fan level: %s" % level) return self.set_property("fan_level", level) @command( click.argument("volume", type=int), default_output=format_output("Setting sound volume to {volume}"), ) def set_volume(self, volume: int): """Set buzzer volume.""" if "volume" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported volume for model '%s'" % self.model ) if volume < 0 or volume > 100: raise AirPurifierMiotException( "Invalid volume: %s. Must be between 0 and 100" % volume ) return self.set_property("buzzer_volume", volume) @command( click.argument("level", type=int), default_output=format_output("Setting favorite level to {level}"), ) def set_favorite_level(self, level: int): """Set the favorite level used when the mode is `favorite`. Needs to be between 0 and 14. """ if "favorite_level" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported favorite level for model '%s'" % self.model ) if level < 0 or level > 14: raise AirPurifierMiotException("Invalid favorite level: %s" % level) return self.set_property("favorite_level", level) @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" if "led_brightness" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported led brightness for model '%s'" % self.model ) value = brightness.value if ( self.model in ("zhimi.airp.va2", "zhimi.airp.mb5", "zhimi.airp.vb4", "zhimi.airp.rmb1") and value ): value = 2 - value return self.set_property("led_brightness", value) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" if "led" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported led for model '%s'" % self.model ) return self.set_property("led", led) @command( click.argument("level", type=int), default_output=format_output("Setting LED brightness level to {level}"), ) def set_led_brightness_level(self, level: int): """Set led brightness level (0..8).""" if "led_brightness_level" not in self._get_mapping(): raise AirPurifierMiotException( "Unsupported led brightness level for model '%s'" % self.model ) if level < 0 or level > 8: raise AirPurifierMiotException("Invalid brightness level: %s" % level) return self.set_property("led_brightness_level", level) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/__init__.py0000644000000000000000000000000014265350055023353 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airfilter_util.py0000644000000000000000000000300114265350055025675 0ustar00from unittest import TestCase import pytest from ..airfilter_util import FilterType, FilterTypeUtil @pytest.fixture(scope="class") def airfilter_util(request): request.cls.filter_type_util = FilterTypeUtil() @pytest.mark.usefixtures("airfilter_util") class TestAirFilterUtil(TestCase): def test_determine_filter_type__recognises_unknown_filter(self): assert ( self.filter_type_util.determine_filter_type("0:0:0:0:0:0:0", None) is FilterType.Unknown ) def test_determine_filter_type__recognises_antibacterial_filter(self): assert ( self.filter_type_util.determine_filter_type( "80:64:d1:ba:4f:5f:4", "12:34:41:30" ) is FilterType.AntiBacterial ) def test_determine_filter_type__recognises_antiformaldehyde_filter(self): assert ( self.filter_type_util.determine_filter_type( "80:64:d1:ba:4f:5f:4", "12:34:00:31" ) is FilterType.AntiFormaldehyde ) def test_determine_filter_type__falls_back_to_regular_filter(self): regular_filters = [ "12:34:56:78", "12:34:56:31", "12:34:56:31:11:11", "CO:FF:FF:EE", None, ] for product_id in regular_filters: assert ( self.filter_type_util.determine_filter_type( "80:64:d1:ba:4f:5f:4", product_id ) is FilterType.Regular ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airfresh.py0000644000000000000000000002447414265350055024503 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from .. import AirFresh from ..airfresh import ( MODEL_AIRFRESH_VA2, MODEL_AIRFRESH_VA4, AirFreshException, AirFreshStatus, LedBrightness, OperationMode, ) class DummyAirFresh(DummyDevice, AirFresh): def __init__(self, *args, **kwargs): self._model = MODEL_AIRFRESH_VA2 self.state = { "power": "on", "ptc_state": None, "temp_dec": 186, "aqi": 10, "average_aqi": 8, "humidity": 62, "co2": 350, "buzzer": "off", "child_lock": "off", "led_level": 2, "mode": "auto", "motor1_speed": 354, "use_time": 2457000, "ntcT": None, "app_extra": 1, "f1_hour_used": 682, "filter_life": 80, "f_hour": 3500, "favorite_level": None, "led": "on", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_led": lambda x: self._set_state("led", x), "set_led_level": lambda x: self._set_state("led_level", x), "reset_filter1": lambda x: ( self._set_state("f1_hour_used", [0]), self._set_state("filter_life", [100]), ), "set_app_extra": lambda x: self._set_state("app_extra", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def airfresh(request): request.cls.device = DummyAirFresh() # TODO add ability to test on a real device @pytest.mark.usefixtures("airfresh") class TestAirFresh(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirFreshStatus(self.device.start_state, MODEL_AIRFRESH_VA2) ) assert self.is_on() is True assert self.state().ptc is None assert self.state().aqi == self.device.start_state["aqi"] assert self.state().average_aqi == self.device.start_state["average_aqi"] assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0 assert self.state().ntc_temperature is None assert self.state().humidity == self.device.start_state["humidity"] assert self.state().co2 == self.device.start_state["co2"] assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert ( self.state().filter_life_remaining == self.device.start_state["filter_life"] ) assert self.state().filter_hours_used == self.device.start_state["f1_hour_used"] assert self.state().use_time == self.device.start_state["use_time"] assert self.state().motor_speed == self.device.start_state["motor1_speed"] assert self.state().led == (self.device.start_state["led"] == "on") assert self.state().led_brightness == LedBrightness( self.device.start_state["led_level"] ) assert self.state().buzzer == (self.device.start_state["buzzer"] == "on") assert self.state().child_lock == ( self.device.start_state["child_lock"] == "on" ) assert self.state().extra_features == self.device.start_state["app_extra"] def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode(OperationMode.Silent) assert mode() == OperationMode.Silent self.device.set_mode(OperationMode.Interval) assert mode() == OperationMode.Interval self.device.set_mode(OperationMode.Low) assert mode() == OperationMode.Low self.device.set_mode(OperationMode.Middle) assert mode() == OperationMode.Middle self.device.set_mode(OperationMode.Strong) assert mode() == OperationMode.Strong def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright self.device.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_set_extra_features(self): def extra_features(): return self.device.status().extra_features self.device.set_extra_features(0) assert extra_features() == 0 self.device.set_extra_features(1) assert extra_features() == 1 self.device.set_extra_features(2) assert extra_features() == 2 with pytest.raises(AirFreshException): self.device.set_extra_features(-1) def test_reset_filter(self): def filter_hours_used(): return self.device.status().filter_hours_used def filter_life_remaining(): return self.device.status().filter_life_remaining self.device._reset_state() assert filter_hours_used() != 0 assert filter_life_remaining() != 100 self.device.reset_filter() assert filter_hours_used() == 0 assert filter_life_remaining() == 100 class DummyAirFreshVA4(DummyDevice, AirFresh): def __init__(self, *args, **kwargs): self._model = MODEL_AIRFRESH_VA4 self.state = { "power": "on", "ptc_state": "off", "temp_dec": 18.6, "aqi": 10, "average_aqi": 8, "humidity": 62, "co2": 350, "buzzer": "off", "child_lock": "off", "led_level": 2, "mode": "auto", "motor1_speed": 354, "use_time": 2457000, "ntcT": 33.53, "app_extra": 1, "f1_hour_used": 682, "filter_life": 80, "f_hour": 3500, "favorite_level": None, "led": "on", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_ptc_state": lambda x: self._set_state("ptc_state", x), "set_mode": lambda x: self._set_state("mode", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_led": lambda x: self._set_state("led", x), "set_led_level": lambda x: self._set_state("led_level", x), "reset_filter1": lambda x: ( self._set_state("f1_hour_used", [0]), self._set_state("filter_life", [100]), ), "set_app_extra": lambda x: self._set_state("app_extra", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def airfreshva4(request): request.cls.device = DummyAirFreshVA4() # TODO add ability to test on a real device @pytest.mark.usefixtures("airfreshva4") class TestAirFreshVA4(TestAirFresh): def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirFreshStatus(self.device.start_state, MODEL_AIRFRESH_VA4) ) assert self.is_on() is True assert self.state().ptc == (self.device.start_state["ptc_state"] == "on") assert self.state().aqi == self.device.start_state["aqi"] assert self.state().average_aqi == self.device.start_state["average_aqi"] assert self.state().temperature == self.device.start_state["temp_dec"] assert self.state().ntc_temperature == self.device.start_state["ntcT"] assert self.state().humidity == self.device.start_state["humidity"] assert self.state().co2 == self.device.start_state["co2"] assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert ( self.state().filter_life_remaining == self.device.start_state["filter_life"] ) assert self.state().filter_hours_used == self.device.start_state["f1_hour_used"] assert self.state().use_time == self.device.start_state["use_time"] assert self.state().motor_speed == self.device.start_state["motor1_speed"] assert self.state().led == (self.device.start_state["led"] == "on") assert self.state().led_brightness == LedBrightness( self.device.start_state["led_level"] ) assert self.state().buzzer == (self.device.start_state["buzzer"] == "on") assert self.state().child_lock == ( self.device.start_state["child_lock"] == "on" ) assert self.state().extra_features == self.device.start_state["app_extra"] def test_set_ptc(self): def ptc(): return self.device.status().ptc self.device.set_ptc(True) assert ptc() is True self.device.set_ptc(False) assert ptc() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airpurifier.py0000644000000000000000000003152714265350055025216 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from .. import AirPurifier from ..airpurifier import ( AirPurifierException, AirPurifierStatus, FilterType, LedBrightness, OperationMode, SleepMode, ) class DummyAirPurifier(DummyDevice, AirPurifier): def __init__(self, *args, **kwargs): self._model = "missing.model.airpurifier" self.state = { "power": "on", "aqi": 10, "average_aqi": 8, "humidity": 62, "temp_dec": 186, "mode": "auto", "favorite_level": 10, "filter1_life": 80, "f1_hour_used": 682, "use_time": 2457000, "motor1_speed": 354, "motor2_speed": 800, "purify_volume": 25262, "f1_hour": 3500, "led": "off", "led_b": 2, "bright": 83, "buzzer": "off", "child_lock": "off", "volume": 50, "rfid_product_id": "0:0:41:30", "rfid_tag": "10:20:30:40:50:60:7", "act_sleep": "close", "sleep_mode": "idle", "sleep_time": 83890, "sleep_data_num": 22, "app_extra": 1, "act_det": "off", "button_pressed": "power", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_led": lambda x: self._set_state("led", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_level_favorite": lambda x: self._set_state("favorite_level", x), "set_led_b": lambda x: self._set_state("led_b", x), "set_volume": lambda x: self._set_state("volume", x), "set_act_sleep": lambda x: self._set_state("act_sleep", x), "reset_filter1": lambda x: ( self._set_state("f1_hour_used", [0]), self._set_state("filter1_life", [100]), ), "set_act_det": lambda x: self._set_state("act_det", x), "set_app_extra": lambda x: self._set_state("app_extra", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def airpurifier(request): request.cls.device = DummyAirPurifier() # TODO add ability to test on a real device @pytest.mark.usefixtures("airpurifier") class TestAirPurifier(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(AirPurifierStatus(self.device.start_state)) assert self.is_on() is True assert self.state().aqi == self.device.start_state["aqi"] assert self.state().average_aqi == self.device.start_state["average_aqi"] assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0 assert self.state().humidity == self.device.start_state["humidity"] assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().favorite_level == self.device.start_state["favorite_level"] assert ( self.state().filter_life_remaining == self.device.start_state["filter1_life"] ) assert self.state().filter_hours_used == self.device.start_state["f1_hour_used"] assert self.state().use_time == self.device.start_state["use_time"] assert self.state().motor_speed == self.device.start_state["motor1_speed"] assert self.state().motor2_speed == self.device.start_state["motor2_speed"] assert self.state().purify_volume == self.device.start_state["purify_volume"] assert self.state().led == (self.device.start_state["led"] == "on") assert self.state().led_brightness == LedBrightness( self.device.start_state["led_b"] ) assert self.state().buzzer == (self.device.start_state["buzzer"] == "on") assert self.state().child_lock == ( self.device.start_state["child_lock"] == "on" ) assert self.state().illuminance == self.device.start_state["bright"] assert self.state().volume == self.device.start_state["volume"] assert ( self.state().filter_rfid_product_id == self.device.start_state["rfid_product_id"] ) assert self.state().sleep_mode == SleepMode( self.device.start_state["sleep_mode"] ) assert self.state().sleep_time == self.device.start_state["sleep_time"] assert ( self.state().sleep_mode_learn_count == self.device.start_state["sleep_data_num"] ) assert self.state().extra_features == self.device.start_state["app_extra"] assert self.state().turbo_mode_supported == ( self.device.start_state["app_extra"] == 1 ) assert self.state().auto_detect == (self.device.start_state["act_det"] == "on") assert self.state().button_pressed == self.device.start_state["button_pressed"] def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Silent) assert mode() == OperationMode.Silent self.device.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode(OperationMode.Favorite) assert mode() == OperationMode.Favorite self.device.set_mode(OperationMode.Idle) assert mode() == OperationMode.Idle def test_set_favorite_level(self): def favorite_level(): return self.device.status().favorite_level self.device.set_favorite_level(0) assert favorite_level() == 0 self.device.set_favorite_level(6) assert favorite_level() == 6 self.device.set_favorite_level(10) assert favorite_level() == 10 with pytest.raises(AirPurifierException): self.device.set_favorite_level(-1) with pytest.raises(AirPurifierException): self.device.set_favorite_level(18) def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright self.device.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_set_volume(self): def volume(): return self.device.status().volume self.device.set_volume(0) assert volume() == 0 self.device.set_volume(35) assert volume() == 35 self.device.set_volume(100) assert volume() == 100 with pytest.raises(AirPurifierException): self.device.set_volume(-1) with pytest.raises(AirPurifierException): self.device.set_volume(101) def test_set_learn_mode(self): def learn_mode(): return self.device.status().learn_mode self.device.set_learn_mode(True) assert learn_mode() is True self.device.set_learn_mode(False) assert learn_mode() is False def test_set_auto_detect(self): def auto_detect(): return self.device.status().auto_detect self.device.set_auto_detect(True) assert auto_detect() is True self.device.set_auto_detect(False) assert auto_detect() is False def test_set_extra_features(self): def extra_features(): return self.device.status().extra_features self.device.set_extra_features(0) assert extra_features() == 0 self.device.set_extra_features(1) assert extra_features() == 1 self.device.set_extra_features(2) assert extra_features() == 2 with pytest.raises(AirPurifierException): self.device.set_extra_features(-1) def test_reset_filter(self): def filter_hours_used(): return self.device.status().filter_hours_used def filter_life_remaining(): return self.device.status().filter_life_remaining self.device._reset_state() assert filter_hours_used() != 0 assert filter_life_remaining() != 100 self.device.reset_filter() assert filter_hours_used() == 0 assert filter_life_remaining() == 100 def test_status_without_volume(self): self.device._reset_state() # The Air Purifier 2 doesn't support volume self.device.state["volume"] = None assert self.state().volume is None def test_status_without_led_brightness(self): self.device._reset_state() # The Air Purifier Pro doesn't support LED brightness self.device.state["led_b"] = None assert self.state().led_brightness is None def test_status_unknown_led_brightness(self): self.device._reset_state() # The Air Purifier V3 returns a led brightness of 10 f.e. self.device.state["led_b"] = 10 assert self.state().led_brightness is None def test_status_without_temperature(self): self.device._reset_state() self.device.state["temp_dec"] = None assert self.state().temperature is None def test_status_without_illuminance(self): self.device._reset_state() # The Air Purifier 2 doesn't provide illuminance self.device.state["bright"] = None assert self.state().illuminance is None def test_status_without_buzzer(self): self.device._reset_state() # The Air Purifier Pro doesn't provide the buzzer property self.device.state["buzzer"] = None assert self.state().buzzer is None def test_status_without_motor2_speed(self): self.device._reset_state() # The Air Purifier Pro doesn't provide the buzzer property self.device.state["motor2_speed"] = None assert self.state().motor2_speed is None def test_status_without_filter_rfid_tag(self): self.device._reset_state() self.device.state["rfid_tag"] = None assert self.state().filter_rfid_tag is None assert self.state().filter_type is None def test_status_with_filter_rfid_tag_zeros(self): self.device._reset_state() self.device.state["rfid_tag"] = "0:0:0:0:0:0:0" assert self.state().filter_type is FilterType.Unknown def test_status_without_filter_rfid_product_id(self): self.device._reset_state() self.device.state["rfid_product_id"] = None assert self.state().filter_type is FilterType.Regular def test_status_filter_rfid_product_ids(self): self.device._reset_state() self.device.state["rfid_product_id"] = "0:0:30:31" assert self.state().filter_type is FilterType.AntiFormaldehyde self.device.state["rfid_product_id"] = "0:0:30:32" assert self.state().filter_type is FilterType.Regular self.device.state["rfid_product_id"] = "0:0:41:30" assert self.state().filter_type is FilterType.AntiBacterial def test_status_without_sleep_mode(self): self.device._reset_state() self.device.state["sleep_mode"] = None assert self.state().sleep_mode is None def test_status_without_app_extra(self): self.device._reset_state() self.device.state["app_extra"] = None assert self.state().extra_features is None assert self.state().turbo_mode_supported is None def test_status_without_auto_detect(self): self.device._reset_state() self.device.state["act_det"] = None assert self.state().auto_detect is None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airpurifier_miot.py0000644000000000000000000003030214265350055026234 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyMiotDevice from .. import AirPurifierMiot from ..airfilter_util import FilterType from ..airpurifier_miot import AirPurifierMiotException, LedBrightness, OperationMode _INITIAL_STATE = { "power": True, "aqi": 10, "average_aqi": 8, "humidity": 62, "temperature": 18.599999, "fan_level": 2, "mode": 0, "led": True, "led_brightness": 1, "buzzer": False, "buzzer_volume": 0, "child_lock": False, "favorite_level": 10, "filter_life_remaining": 80, "filter_hours_used": 682, "use_time": 2457000, "purify_volume": 25262, "motor_speed": 354, "filter_rfid_product_id": "0:0:41:30", "filter_rfid_tag": "10:20:30:40:50:60:7", "button_pressed": "power", } _INITIAL_STATE_MB4 = { "power": True, "aqi": 10, "mode": 0, "led_brightness_level": 1, "buzzer": False, "child_lock": False, "filter_life_remaining": 80, "filter_hours_used": 682, "motor_speed": 354, "button_pressed": "power", } _INITIAL_STATE_VA2 = { "power": True, "aqi": 10, "anion": True, "average_aqi": 8, "humidity": 62, "temperature": 18.599999, "fan_level": 2, "mode": 0, "led_brightness": 1, "buzzer": False, "child_lock": False, "favorite_level": 10, "filter_life_remaining": 80, "filter_hours_used": 682, "filter_left_time": 309, "purify_volume": 25262, "motor_speed": 354, "filter_rfid_product_id": "0:0:41:30", "filter_rfid_tag": "10:20:30:40:50:60:7", "button_pressed": "power", } class DummyAirPurifierMiot(DummyMiotDevice, AirPurifierMiot): def __init__(self, *args, **kwargs): if getattr(self, "state", None) is None: self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_led": lambda x: self._set_state("led", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_level_favorite": lambda x: self._set_state("favorite_level", x), "set_led_b": lambda x: self._set_state("led_b", x), "set_volume": lambda x: self._set_state("volume", x), "set_act_sleep": lambda x: self._set_state("act_sleep", x), "reset_filter1": lambda x: ( self._set_state("f1_hour_used", [0]), self._set_state("filter1_life", [100]), ), "set_act_det": lambda x: self._set_state("act_det", x), "set_app_extra": lambda x: self._set_state("app_extra", x), } super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def airpurifier(request): request.cls.device = DummyAirPurifierMiot() @pytest.mark.usefixtures("airpurifier") class TestAirPurifier(TestCase): def test_on(self): self.device.off() # ensure off assert self.device.status().is_on is False self.device.on() assert self.device.status().is_on is True def test_off(self): self.device.on() # ensure on assert self.device.status().is_on is True self.device.off() assert self.device.status().is_on is False def test_status(self): status = self.device.status() assert status.is_on is _INITIAL_STATE["power"] assert status.aqi == _INITIAL_STATE["aqi"] assert status.average_aqi == _INITIAL_STATE["average_aqi"] assert status.humidity == _INITIAL_STATE["humidity"] assert status.temperature == 18.6 assert status.fan_level == _INITIAL_STATE["fan_level"] assert status.mode == OperationMode(_INITIAL_STATE["mode"]) assert status.led == _INITIAL_STATE["led"] assert status.led_brightness == LedBrightness(_INITIAL_STATE["led_brightness"]) assert status.buzzer == _INITIAL_STATE["buzzer"] assert status.child_lock == _INITIAL_STATE["child_lock"] assert status.favorite_level == _INITIAL_STATE["favorite_level"] assert status.filter_life_remaining == _INITIAL_STATE["filter_life_remaining"] assert status.filter_hours_used == _INITIAL_STATE["filter_hours_used"] assert status.use_time == _INITIAL_STATE["use_time"] assert status.purify_volume == _INITIAL_STATE["purify_volume"] assert status.motor_speed == _INITIAL_STATE["motor_speed"] assert status.filter_rfid_product_id == _INITIAL_STATE["filter_rfid_product_id"] assert status.filter_type == FilterType.AntiBacterial def test_set_fan_level(self): def fan_level(): return self.device.status().fan_level self.device.set_fan_level(1) assert fan_level() == 1 self.device.set_fan_level(2) assert fan_level() == 2 self.device.set_fan_level(3) assert fan_level() == 3 with pytest.raises(AirPurifierMiotException): self.device.set_fan_level(0) with pytest.raises(AirPurifierMiotException): self.device.set_fan_level(4) def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode(OperationMode.Silent) assert mode() == OperationMode.Silent self.device.set_mode(OperationMode.Favorite) assert mode() == OperationMode.Favorite self.device.set_mode(OperationMode.Fan) assert mode() == OperationMode.Fan def test_set_favorite_level(self): def favorite_level(): return self.device.status().favorite_level self.device.set_favorite_level(0) assert favorite_level() == 0 self.device.set_favorite_level(6) assert favorite_level() == 6 self.device.set_favorite_level(14) assert favorite_level() == 14 with pytest.raises(AirPurifierMiotException): self.device.set_favorite_level(-1) with pytest.raises(AirPurifierMiotException): self.device.set_favorite_level(15) def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright self.device.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_set_anion(self): with pytest.raises(AirPurifierMiotException): self.device.set_anion(True) class DummyAirPurifierMiotMB4(DummyAirPurifierMiot): def __init__(self, *args, **kwargs): self._model = "zhimi.airpurifier.mb4" self.state = _INITIAL_STATE_MB4 super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def airpurifierMB4(request): request.cls.device = DummyAirPurifierMiotMB4() @pytest.mark.usefixtures("airpurifierMB4") class TestAirPurifierMB4(TestCase): def test_status(self): status = self.device.status() assert status.is_on is _INITIAL_STATE_MB4["power"] assert status.aqi == _INITIAL_STATE_MB4["aqi"] assert status.average_aqi is None assert status.humidity is None assert status.temperature is None assert status.fan_level is None assert status.mode == OperationMode(_INITIAL_STATE_MB4["mode"]) assert status.led is None assert status.led_brightness is None assert status.led_brightness_level == _INITIAL_STATE_MB4["led_brightness_level"] assert status.buzzer == _INITIAL_STATE_MB4["buzzer"] assert status.child_lock == _INITIAL_STATE_MB4["child_lock"] assert status.favorite_level is None assert ( status.filter_life_remaining == _INITIAL_STATE_MB4["filter_life_remaining"] ) assert status.filter_hours_used == _INITIAL_STATE_MB4["filter_hours_used"] assert status.use_time is None assert status.purify_volume is None assert status.motor_speed == _INITIAL_STATE_MB4["motor_speed"] assert status.filter_rfid_product_id is None assert status.filter_type is None def test_set_led_brightness_level(self): def led_brightness_level(): return self.device.status().led_brightness_level self.device.set_led_brightness_level(2) assert led_brightness_level() == 2 def test_set_fan_level(self): with pytest.raises(AirPurifierMiotException): self.device.set_fan_level(0) def test_set_favorite_level(self): with pytest.raises(AirPurifierMiotException): self.device.set_favorite_level(0) def test_set_led_brightness(self): with pytest.raises(AirPurifierMiotException): self.device.set_led_brightness(LedBrightness.Bright) def test_set_led(self): with pytest.raises(AirPurifierMiotException): self.device.set_led(True) class DummyAirPurifierMiotVA2(DummyAirPurifierMiot): def __init__(self, *args, **kwargs): self._model = "zhimi.airp.va2" self.state = _INITIAL_STATE_VA2 super().__init__(*args, **kwargs) class DummyAirPurifierMiotMB5(DummyAirPurifierMiot): def __init__(self, *args, **kwargs): self._model = "zhimi.airp.mb5" self.state = _INITIAL_STATE_VA2 super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def airpurifierVA2(request): request.cls.device = DummyAirPurifierMiotVA2() @pytest.mark.usefixtures("airpurifierVA2") class TestAirPurifierVA2(TestCase): def test_status(self): status = self.device.status() assert status.is_on is _INITIAL_STATE_VA2["power"] assert status.anion == _INITIAL_STATE_VA2["anion"] assert status.aqi == _INITIAL_STATE_VA2["aqi"] assert status.average_aqi == _INITIAL_STATE_VA2["average_aqi"] assert status.humidity == _INITIAL_STATE_VA2["humidity"] assert status.temperature == 18.6 assert status.fan_level == _INITIAL_STATE_VA2["fan_level"] assert status.mode == OperationMode(_INITIAL_STATE_VA2["mode"]) assert status.led is None assert status.led_brightness == LedBrightness( _INITIAL_STATE_VA2["led_brightness"] ) assert status.buzzer == _INITIAL_STATE_VA2["buzzer"] assert status.child_lock == _INITIAL_STATE_VA2["child_lock"] assert status.favorite_level == _INITIAL_STATE_VA2["favorite_level"] assert ( status.filter_life_remaining == _INITIAL_STATE_VA2["filter_life_remaining"] ) assert status.filter_hours_used == _INITIAL_STATE_VA2["filter_hours_used"] assert status.filter_left_time == _INITIAL_STATE_VA2["filter_left_time"] assert status.use_time is None assert status.purify_volume == _INITIAL_STATE_VA2["purify_volume"] assert status.motor_speed == _INITIAL_STATE_VA2["motor_speed"] assert ( status.filter_rfid_product_id == _INITIAL_STATE_VA2["filter_rfid_product_id"] ) assert status.filter_type == FilterType.AntiBacterial def test_set_anion(self): def anion(): return self.device.status().anion self.device.set_anion(True) assert anion() is True self.device.set_anion(False) assert anion() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/fan/__init__.py0000644000000000000000000000012014265350055017317 0ustar00# flake8: noqa from .dmaker import * from .leshow import * from .zhimi import * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5266137 python-miio-0.5.12/miio/integrations/fan/dmaker/__init__.py0000644000000000000000000000011314265350055020564 0ustar00# flake8: noqa from .fan import FanP5 from .fan_miot import Fan1C, FanMiot ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/dmaker/fan.py0000644000000000000000000001554414265350055017607 0ustar00from typing import Any, Dict import click from miio import Device, DeviceStatus from miio.click_common import EnumType, command, format_output from miio.fan_common import FanException, MoveDirection, OperationMode MODEL_FAN_P5 = "dmaker.fan.p5" AVAILABLE_PROPERTIES_P5 = [ "power", "mode", "speed", "roll_enable", "roll_angle", "time_off", "light", "beep_sound", "child_lock", ] AVAILABLE_PROPERTIES = { MODEL_FAN_P5: AVAILABLE_PROPERTIES_P5, } class FanStatusP5(DeviceStatus): """Container for status reports from the Xiaomi Mi Smart Pedestal Fan DMaker P5.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Fan (dmaker.fan.p5): {'power': False, 'mode': 'normal', 'speed': 35, 'roll_enable': False, 'roll_angle': 140, 'time_off': 0, 'light': True, 'beep_sound': False, 'child_lock': False} """ self.data = data @property def power(self) -> str: """Power state.""" return "on" if self.data["power"] else "off" @property def is_on(self) -> bool: """True if device is currently on.""" return self.data["power"] @property def mode(self) -> OperationMode: """Operation mode.""" return OperationMode(self.data["mode"]) @property def speed(self) -> int: """Speed of the motor.""" return self.data["speed"] @property def oscillate(self) -> bool: """True if oscillation is enabled.""" return self.data["roll_enable"] @property def angle(self) -> int: """Oscillation angle.""" return self.data["roll_angle"] @property def delay_off_countdown(self) -> int: """Countdown until turning off in seconds.""" return self.data["time_off"] @property def led(self) -> bool: """True if LED is turned on, if available.""" return self.data["light"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["beep_sound"] @property def child_lock(self) -> bool: """True if child lock is on.""" return self.data["child_lock"] class FanP5(Device): """Support for dmaker.fan.p5.""" _supported_models = [MODEL_FAN_P5] def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, model: str = MODEL_FAN_P5, ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover, model=model) @command( default_output=format_output( "", "Power: {result.power}\n" "Operation mode: {result.mode}\n" "Speed: {result.speed}\n" "Oscillate: {result.oscillate}\n" "Angle: {result.angle}\n" "LED: {result.led}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Power-off time: {result.delay_off_countdown}\n", ) ) def status(self) -> FanStatusP5: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES[self.model] values = self.get_properties(properties, max_properties=15) return FanStatusP5(dict(zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("s_power", [True]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("s_power", [False]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("s_mode", [mode.value]) @command( click.argument("speed", type=int), default_output=format_output("Setting speed to {speed}"), ) def set_speed(self, speed: int): """Set speed.""" if speed < 0 or speed > 100: raise FanException("Invalid speed: %s" % speed) return self.send("s_speed", [speed]) @command( click.argument("angle", type=int), default_output=format_output("Setting angle to {angle}"), ) def set_angle(self, angle: int): """Set the oscillation angle.""" if angle not in [30, 60, 90, 120, 140]: raise FanException( "Unsupported angle. Supported values: 30, 60, 90, 120, 140" ) return self.send("s_angle", [angle]) @command( click.argument("oscillate", type=bool), default_output=format_output( lambda oscillate: "Turning on oscillate" if oscillate else "Turning off oscillate" ), ) def set_oscillate(self, oscillate: bool): """Set oscillate on/off.""" if oscillate: return self.send("s_roll", [True]) else: return self.send("s_roll", [False]) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" if led: return self.send("s_light", [True]) else: return self.send("s_light", [False]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if buzzer: return self.send("s_sound", [True]) else: return self.send("s_sound", [False]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if lock: return self.send("s_lock", [True]) else: return self.send("s_lock", [False]) @command( click.argument("minutes", type=int), default_output=format_output("Setting delayed turn off to {minutes} minutes"), ) def delay_off(self, minutes: int): """Set delay off minutes.""" if minutes < 0: raise FanException("Invalid value for a delayed turn off: %s" % minutes) return self.send("s_t_off", [minutes]) @command( click.argument("direction", type=EnumType(MoveDirection)), default_output=format_output("Rotating the fan to the {direction}"), ) def set_rotate(self, direction: MoveDirection): """Rotate the fan by -5/+5 degrees left/right.""" return self.send("m_roll", [direction.value]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/dmaker/fan_miot.py0000644000000000000000000004170214265350055020632 0ustar00import enum from typing import Any, Dict import click from miio import DeviceStatus, MiotDevice from miio.click_common import EnumType, command, format_output from miio.fan_common import FanException, MoveDirection, OperationMode MODEL_FAN_P9 = "dmaker.fan.p9" MODEL_FAN_P10 = "dmaker.fan.p10" MODEL_FAN_P11 = "dmaker.fan.p11" MODEL_FAN_P15 = "dmaker.fan.p15" MODEL_FAN_P18 = "dmaker.fan.p18" MODEL_FAN_P33 = "dmaker.fan.p33" MODEL_FAN_1C = "dmaker.fan.1c" MIOT_MAPPING = { MODEL_FAN_P9: { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-p9:1 "power": {"siid": 2, "piid": 1}, "fan_level": {"siid": 2, "piid": 2}, "child_lock": {"siid": 3, "piid": 1}, "fan_speed": {"siid": 2, "piid": 11}, "swing_mode": {"siid": 2, "piid": 5}, "swing_mode_angle": {"siid": 2, "piid": 6}, "power_off_time": {"siid": 2, "piid": 8}, "buzzer": {"siid": 2, "piid": 7}, "light": {"siid": 2, "piid": 9}, "mode": {"siid": 2, "piid": 4}, "set_move": {"siid": 2, "piid": 10}, }, MODEL_FAN_P10: { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-p10:1 "power": {"siid": 2, "piid": 1}, "fan_level": {"siid": 2, "piid": 2}, "child_lock": {"siid": 3, "piid": 1}, "fan_speed": {"siid": 2, "piid": 10}, "swing_mode": {"siid": 2, "piid": 4}, "swing_mode_angle": {"siid": 2, "piid": 5}, "power_off_time": {"siid": 2, "piid": 6}, "buzzer": {"siid": 2, "piid": 8}, "light": {"siid": 2, "piid": 7}, "mode": {"siid": 2, "piid": 3}, "set_move": {"siid": 2, "piid": 9}, }, MODEL_FAN_P11: { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-p11:1 "power": {"siid": 2, "piid": 1}, "fan_level": {"siid": 2, "piid": 2}, "mode": {"siid": 2, "piid": 3}, "swing_mode": {"siid": 2, "piid": 4}, "swing_mode_angle": {"siid": 2, "piid": 5}, "fan_speed": {"siid": 2, "piid": 6}, "light": {"siid": 4, "piid": 1}, "buzzer": {"siid": 5, "piid": 1}, # "device_fault": {"siid": 6, "piid": 2}, "child_lock": {"siid": 7, "piid": 1}, "power_off_time": {"siid": 3, "piid": 1}, "set_move": {"siid": 6, "piid": 1}, }, MODEL_FAN_P33: { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-p33:1 "power": {"siid": 2, "piid": 1}, "fan_level": {"siid": 2, "piid": 2}, "mode": {"siid": 2, "piid": 3}, "swing_mode": {"siid": 2, "piid": 4}, "swing_mode_angle": {"siid": 2, "piid": 5}, "fan_speed": {"siid": 2, "piid": 6}, "light": {"siid": 4, "piid": 1}, "buzzer": {"siid": 5, "piid": 1}, # "device_fault": {"siid": 6, "piid": 2}, "child_lock": {"siid": 7, "piid": 1}, "power_off_time": {"siid": 3, "piid": 1}, "set_move": {"siid": 6, "piid": 1}, }, } # These mappings are based on user reports and may not cover all features MIOT_MAPPING[MODEL_FAN_P15] = MIOT_MAPPING[MODEL_FAN_P11] # see #1354 MIOT_MAPPING[MODEL_FAN_P18] = MIOT_MAPPING[MODEL_FAN_P10] # see #1341 FAN1C_MAPPINGS = { MODEL_FAN_1C: { # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-1c:1 "power": {"siid": 2, "piid": 1}, "fan_level": {"siid": 2, "piid": 2}, "child_lock": {"siid": 3, "piid": 1}, "swing_mode": {"siid": 2, "piid": 3}, "power_off_time": {"siid": 2, "piid": 10}, "buzzer": {"siid": 2, "piid": 11}, "light": {"siid": 2, "piid": 12}, "mode": {"siid": 2, "piid": 7}, } } SUPPORTED_ANGLES = { MODEL_FAN_P9: [30, 60, 90, 120, 150], MODEL_FAN_P10: [30, 60, 90, 120, 140], MODEL_FAN_P11: [30, 60, 90, 120, 140], MODEL_FAN_P33: [30, 60, 90, 120, 140], } class OperationModeMiot(enum.Enum): Normal = 0 Nature = 1 class FanStatusMiot(DeviceStatus): """Container for status reports for Xiaomi Mi Smart Pedestal Fan DMaker P9/P10.""" def __init__(self, data: Dict[str, Any]) -> None: """ Response of a FanMiot (dmaker.fan.p10): { 'id': 1, 'result': [ {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': False}, {'did': 'fan_level', 'siid': 2, 'piid': 2, 'code': 0, 'value': 2}, {'did': 'child_lock', 'siid': 3, 'piid': 1, 'code': 0, 'value': False}, {'did': 'fan_speed', 'siid': 2, 'piid': 10, 'code': 0, 'value': 54}, {'did': 'swing_mode', 'siid': 2, 'piid': 4, 'code': 0, 'value': False}, {'did': 'swing_mode_angle', 'siid': 2, 'piid': 5, 'code': 0, 'value': 30}, {'did': 'power_off_time', 'siid': 2, 'piid': 6, 'code': 0, 'value': 0}, {'did': 'buzzer', 'siid': 2, 'piid': 8, 'code': 0, 'value': False}, {'did': 'light', 'siid': 2, 'piid': 7, 'code': 0, 'value': True}, {'did': 'mode', 'siid': 2, 'piid': 3, 'code': 0, 'value': 0}, {'did': 'set_move', 'siid': 2, 'piid': 9, 'code': -4003} ], 'exe_time': 280 } """ self.data = data @property def power(self) -> str: """Power state.""" return "on" if self.data["power"] else "off" @property def is_on(self) -> bool: """True if device is currently on.""" return self.data["power"] @property def mode(self) -> OperationMode: """Operation mode.""" return OperationMode[OperationModeMiot(self.data["mode"]).name] @property def speed(self) -> int: """Speed of the motor.""" return self.data["fan_speed"] @property def oscillate(self) -> bool: """True if oscillation is enabled.""" return self.data["swing_mode"] @property def angle(self) -> int: """Oscillation angle.""" return self.data["swing_mode_angle"] @property def delay_off_countdown(self) -> int: """Countdown until turning off in minutes.""" return self.data["power_off_time"] @property def led(self) -> bool: """True if LED is turned on, if available.""" return self.data["light"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] @property def child_lock(self) -> bool: """True if child lock is on.""" return self.data["child_lock"] class FanStatus1C(DeviceStatus): """Container for status reports for Xiaomi Mi Smart Pedestal Fan DMaker 1C.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Fan1C (dmaker.fan.1c): { 'id': 1, 'result': [ {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, {'did': 'fan_level', 'siid': 2, 'piid': 2, 'code': 0, 'value': 2}, {'did': 'child_lock', 'siid': 3, 'piid': 1, 'code': 0, 'value': False}, {'did': 'swing_mode', 'siid': 2, 'piid': 3, 'code': 0, 'value': False}, {'did': 'power_off_time', 'siid': 2, 'piid': 10, 'code': 0, 'value': 0}, {'did': 'buzzer', 'siid': 2, 'piid': 11, 'code': 0, 'value': False}, {'did': 'light', 'siid': 2, 'piid': 12, 'code': 0, 'value': True}, {'did': 'mode', 'siid': 2, 'piid': 7, 'code': 0, 'value': 0}, ], 'exe_time': 280 } """ self.data = data @property def power(self) -> str: """Power state.""" return "on" if self.data["power"] else "off" @property def is_on(self) -> bool: """True if device is currently on.""" return self.data["power"] @property def mode(self) -> OperationMode: """Operation mode.""" return OperationMode[OperationModeMiot(self.data["mode"]).name] @property def speed(self) -> int: """Speed of the motor.""" return self.data["fan_level"] @property def oscillate(self) -> bool: """True if oscillation is enabled.""" return self.data["swing_mode"] @property def delay_off_countdown(self) -> int: """Countdown until turning off in minutes.""" return self.data["power_off_time"] @property def led(self) -> bool: """True if LED is turned on.""" return self.data["light"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] @property def child_lock(self) -> bool: """True if child lock is on.""" return self.data["child_lock"] class FanMiot(MiotDevice): _mappings = MIOT_MAPPING @command( default_output=format_output( "", "Power: {result.power}\n" "Operation mode: {result.mode}\n" "Speed: {result.speed}\n" "Oscillate: {result.oscillate}\n" "Angle: {result.angle}\n" "LED: {result.led}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Power-off time: {result.delay_off_countdown}\n", ) ) def status(self) -> FanStatusMiot: """Retrieve properties.""" return FanStatusMiot( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.set_property("mode", OperationModeMiot[mode.name].value) @command( click.argument("speed", type=int), default_output=format_output("Setting speed to {speed}"), ) def set_speed(self, speed: int): """Set speed.""" if speed < 0 or speed > 100: raise FanException("Invalid speed: %s" % speed) return self.set_property("fan_speed", speed) @command( click.argument("angle", type=int), default_output=format_output("Setting angle to {angle}"), ) def set_angle(self, angle: int): """Set the oscillation angle.""" if angle not in SUPPORTED_ANGLES[self.model]: raise FanException( "Unsupported angle. Supported values: " + ", ".join(f"{i}" for i in SUPPORTED_ANGLES[self.model]) ) return self.set_property("swing_mode_angle", angle) @command( click.argument("oscillate", type=bool), default_output=format_output( lambda oscillate: "Turning on oscillate" if oscillate else "Turning off oscillate" ), ) def set_oscillate(self, oscillate: bool): """Set oscillate on/off.""" return self.set_property("swing_mode", oscillate) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" return self.set_property("light", led) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.set_property("buzzer", buzzer) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.set_property("child_lock", lock) @command( click.argument("minutes", type=int), default_output=format_output("Setting delayed turn off to {minutes} minutes"), ) def delay_off(self, minutes: int): """Set delay off minutes.""" if minutes < 0 or minutes > 480: raise FanException("Invalid value for a delayed turn off: %s" % minutes) return self.set_property("power_off_time", minutes) @command( click.argument("direction", type=EnumType(MoveDirection)), default_output=format_output("Rotating the fan to the {direction}"), ) def set_rotate(self, direction: MoveDirection): """Rotate fan to given direction.""" # Values for: P9,P10,P11,P15,P18,... # { "value": 0, "description": "NONE" }, # { "value": 1, "description": "LEFT" }, # { "value": 2, "description": "RIGHT" } value = 0 if direction == MoveDirection.Left: value = 1 elif direction == MoveDirection.Right: value = 2 return self.set_property("set_move", value) class Fan1C(MiotDevice): # TODO Fan1C should be merged to FanMiot, or moved into its separate file _mappings = FAN1C_MAPPINGS def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, model: str = MODEL_FAN_1C, ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover, model=model) @command( default_output=format_output( "", "Power: {result.power}\n" "Operation mode: {result.mode}\n" "Speed: {result.speed}\n" "Oscillate: {result.oscillate}\n" "LED: {result.led}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Power-off time: {result.delay_off_countdown}\n", ) ) def status(self) -> FanStatus1C: """Retrieve properties.""" return FanStatus1C( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.set_property("mode", OperationModeMiot[mode.name].value) @command( click.argument("speed", type=int), default_output=format_output("Setting speed to {speed}"), ) def set_speed(self, speed: int): """Set speed.""" if speed not in (1, 2, 3): raise FanException("Invalid speed: %s" % speed) return self.set_property("fan_level", speed) @command( click.argument("oscillate", type=bool), default_output=format_output( lambda oscillate: "Turning on oscillate" if oscillate else "Turning off oscillate" ), ) def set_oscillate(self, oscillate: bool): """Set oscillate on/off.""" return self.set_property("swing_mode", oscillate) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" return self.set_property("light", led) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.set_property("buzzer", buzzer) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.set_property("child_lock", lock) @command( click.argument("minutes", type=int), default_output=format_output("Setting delayed turn off to {minutes} minutes"), ) def delay_off(self, minutes: int): """Set delay off minutes.""" if minutes < 0 or minutes > 480: raise FanException("Invalid value for a delayed turn off: %s" % minutes) return self.set_property("power_off_time", minutes) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/dmaker/test_fan.py0000644000000000000000000001306414265350055020641 0ustar00from unittest import TestCase import pytest from miio.fan_common import FanException, OperationMode from miio.tests.dummies import DummyDevice from .fan import MODEL_FAN_P5, FanP5, FanStatusP5 class DummyFanP5(DummyDevice, FanP5): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_P5 self.state = { "power": True, "mode": "normal", "speed": 35, "roll_enable": False, "roll_angle": 140, "time_off": 0, "light": True, "beep_sound": False, "child_lock": False, } self.return_values = { "get_prop": self._get_state, "s_power": lambda x: self._set_state("power", x), "s_mode": lambda x: self._set_state("mode", x), "s_speed": lambda x: self._set_state("speed", x), "s_roll": lambda x: self._set_state("roll_enable", x), "s_angle": lambda x: self._set_state("roll_angle", x), "s_t_off": lambda x: self._set_state("time_off", x), "s_light": lambda x: self._set_state("light", x), "s_sound": lambda x: self._set_state("beep_sound", x), "s_lock": lambda x: self._set_state("child_lock", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fanp5(request): request.cls.device = DummyFanP5() # TODO add ability to test on a real device @pytest.mark.usefixtures("fanp5") class TestFanP5(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(FanStatusP5(self.device.start_state)) assert self.is_on() is True assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().speed == self.device.start_state["speed"] assert self.state().oscillate is self.device.start_state["roll_enable"] assert self.state().angle == self.device.start_state["roll_angle"] assert self.state().delay_off_countdown == self.device.start_state["time_off"] assert self.state().led is self.device.start_state["light"] assert self.state().buzzer is self.device.start_state["beep_sound"] assert self.state().child_lock is self.device.start_state["child_lock"] def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Normal) assert mode() == OperationMode.Normal self.device.set_mode(OperationMode.Nature) assert mode() == OperationMode.Nature def test_set_speed(self): def speed(): return self.device.status().speed self.device.set_speed(0) assert speed() == 0 self.device.set_speed(1) assert speed() == 1 self.device.set_speed(100) assert speed() == 100 with pytest.raises(FanException): self.device.set_speed(-1) with pytest.raises(FanException): self.device.set_speed(101) def test_set_angle(self): def angle(): return self.device.status().angle self.device.set_angle(30) assert angle() == 30 self.device.set_angle(60) assert angle() == 60 self.device.set_angle(90) assert angle() == 90 self.device.set_angle(120) assert angle() == 120 self.device.set_angle(140) assert angle() == 140 with pytest.raises(FanException): self.device.set_angle(-1) with pytest.raises(FanException): self.device.set_angle(1) with pytest.raises(FanException): self.device.set_angle(31) with pytest.raises(FanException): self.device.set_angle(141) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 self.device.delay_off(0) assert delay_off_countdown() == 0 with pytest.raises(FanException): self.device.delay_off(-1) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/dmaker/test_fan_miot.py0000644000000000000000000002177514265350055021701 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyMiotDevice from .fan_miot import ( MODEL_FAN_1C, MODEL_FAN_P9, MODEL_FAN_P10, MODEL_FAN_P11, Fan1C, FanException, FanMiot, OperationMode, ) class DummyFanMiot(DummyMiotDevice, FanMiot): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_P9 self.state = { "power": True, "mode": 0, "fan_speed": 35, "swing_mode": False, "swing_mode_angle": 30, "power_off_time": 0, "light": True, "buzzer": False, "child_lock": False, } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fanmiot(request): request.cls.device = DummyFanMiot() @pytest.mark.usefixtures("fanmiot") class TestFanMiot(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Normal) assert mode() == OperationMode.Normal self.device.set_mode(OperationMode.Nature) assert mode() == OperationMode.Nature def test_set_speed(self): def speed(): return self.device.status().speed self.device.set_speed(0) assert speed() == 0 self.device.set_speed(1) assert speed() == 1 self.device.set_speed(100) assert speed() == 100 with pytest.raises(FanException): self.device.set_speed(-1) with pytest.raises(FanException): self.device.set_speed(101) def test_set_angle(self): def angle(): return self.device.status().angle self.device.set_angle(30) assert angle() == 30 self.device.set_angle(60) assert angle() == 60 self.device.set_angle(90) assert angle() == 90 self.device.set_angle(120) assert angle() == 120 self.device.set_angle(150) assert angle() == 150 with pytest.raises(FanException): self.device.set_angle(-1) with pytest.raises(FanException): self.device.set_angle(1) with pytest.raises(FanException): self.device.set_angle(31) with pytest.raises(FanException): self.device.set_angle(140) with pytest.raises(FanException): self.device.set_angle(151) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(0) assert delay_off_countdown() == 0 self.device.delay_off(1) assert delay_off_countdown() == 1 self.device.delay_off(480) assert delay_off_countdown() == 480 with pytest.raises(FanException): self.device.delay_off(-1) with pytest.raises(FanException): self.device.delay_off(481) class DummyFanMiotP10(DummyFanMiot, FanMiot): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self._model = MODEL_FAN_P10 @pytest.fixture(scope="class") def fanmiotp10(request): request.cls.device = DummyFanMiotP10() @pytest.mark.usefixtures("fanmiotp10") class TestFanMiotP10(TestCase): def test_set_angle(self): def angle(): return self.device.status().angle self.device.set_angle(30) assert angle() == 30 self.device.set_angle(60) assert angle() == 60 self.device.set_angle(90) assert angle() == 90 self.device.set_angle(120) assert angle() == 120 self.device.set_angle(140) assert angle() == 140 with pytest.raises(FanException): self.device.set_angle(-1) with pytest.raises(FanException): self.device.set_angle(1) with pytest.raises(FanException): self.device.set_angle(31) with pytest.raises(FanException): self.device.set_angle(150) with pytest.raises(FanException): self.device.set_angle(141) class DummyFanMiotP11(DummyFanMiot, FanMiot): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self._model = MODEL_FAN_P11 @pytest.fixture(scope="class") def fanmiotp11(request): request.cls.device = DummyFanMiotP11() @pytest.mark.usefixtures("fanmiotp11") class TestFanMiotP11(TestFanMiotP10, TestCase): pass class DummyFan1C(DummyMiotDevice, Fan1C): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_1C self.state = { "power": True, "mode": 0, "fan_level": 1, "swing_mode": False, "power_off_time": 0, "light": True, "buzzer": False, "child_lock": False, } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fan1c(request): request.cls.device = DummyFan1C() @pytest.mark.usefixtures("fan1c") class TestFan1C(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Normal) assert mode() == OperationMode.Normal self.device.set_mode(OperationMode.Nature) assert mode() == OperationMode.Nature def test_set_speed(self): def speed(): return self.device.status().speed self.device.set_speed(1) assert speed() == 1 self.device.set_speed(2) assert speed() == 2 self.device.set_speed(3) assert speed() == 3 with pytest.raises(FanException): self.device.set_speed(0) with pytest.raises(FanException): self.device.set_speed(4) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(0) assert delay_off_countdown() == 0 self.device.delay_off(1) assert delay_off_countdown() == 1 self.device.delay_off(480) assert delay_off_countdown() == 480 with pytest.raises(FanException): self.device.delay_off(-1) with pytest.raises(FanException): self.device.delay_off(481) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/leshow/__init__.py0000644000000000000000000000006114265350055020624 0ustar00# flake8: noqa from .fan_leshow import FanLeshow ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/leshow/fan_leshow.py0000644000000000000000000001166114265350055021222 0ustar00import enum import logging from typing import Any, Dict import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) MODEL_FAN_LESHOW_SS4 = "leshow.fan.ss4" AVAILABLE_PROPERTIES_COMMON = [ "power", "mode", "blow", "timer", "sound", "yaw", "fault", ] AVAILABLE_PROPERTIES = { MODEL_FAN_LESHOW_SS4: AVAILABLE_PROPERTIES_COMMON, } class FanLeshowException(DeviceException): pass class OperationMode(enum.Enum): Manual = 0 Sleep = 1 Strong = 2 Natural = 3 class FanLeshowStatus(DeviceStatus): """Container for status reports from the Xiaomi Rosou SS4 Ventilator.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Leshow Fan SS4 (leshow.fan.ss4): {'power': 1, 'mode': 2, 'blow': 100, 'timer': 0, 'sound': 1, 'yaw': 0, 'fault': 0} """ self.data = data @property def power(self) -> str: """Power state.""" return "on" if self.data["power"] == 1 else "off" @property def is_on(self) -> bool: """True if device is turned on.""" return self.data["power"] == 1 @property def mode(self) -> OperationMode: """Operation mode.""" return OperationMode(self.data["mode"]) @property def speed(self) -> int: """Speed of the fan in percent.""" return self.data["blow"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["sound"] == 1 @property def oscillate(self) -> bool: """True if oscillation is enabled.""" return self.data["yaw"] == 1 @property def delay_off_countdown(self) -> int: """Countdown until turning off in minutes.""" return self.data["timer"] @property def error_detected(self) -> bool: """True if a fault was detected.""" return self.data["fault"] == 1 class FanLeshow(Device): """Main class representing the Xiaomi Rosou SS4 Ventilator.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "Speed: {result.speed}\n" "Buzzer: {result.buzzer}\n" "Oscillate: {result.oscillate}\n" "Power-off time: {result.delay_off_countdown}\n" "Error detected: {result.error_detected}\n", ) ) def status(self) -> FanLeshowStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_FAN_LESHOW_SS4] ) values = self.get_properties(properties, max_properties=15) return FanLeshowStatus(dict(zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", [1]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", [0]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode (manual, natural, sleep, strong).""" return self.send("set_mode", [mode.value]) @command( click.argument("speed", type=int), default_output=format_output("Setting speed of the manual mode to {speed}"), ) def set_speed(self, speed: int): """Set a speed level between 0 and 100.""" if speed < 0 or speed > 100: raise FanLeshowException("Invalid speed: %s" % speed) return self.send("set_blow", [speed]) @command( click.argument("oscillate", type=bool), default_output=format_output( lambda oscillate: "Turning on oscillate" if oscillate else "Turning off oscillate" ), ) def set_oscillate(self, oscillate: bool): """Set oscillate on/off.""" return self.send("set_yaw", [int(oscillate)]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.send("set_sound", [int(buzzer)]) @command( click.argument("minutes", type=int), default_output=format_output("Setting delayed turn off to {minutes} minutes"), ) def delay_off(self, minutes: int): """Set delay off minutes.""" if minutes < 0 or minutes > 540: raise FanLeshowException( "Invalid value for a delayed turn off: %s" % minutes ) return self.send("set_timer", [minutes]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/leshow/tests/__init__.py0000644000000000000000000000000014265350055021757 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/leshow/tests/test_fan_leshow.py0000644000000000000000000000745414265350055023430 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from ..fan_leshow import ( MODEL_FAN_LESHOW_SS4, FanLeshow, FanLeshowException, FanLeshowStatus, OperationMode, ) class DummyFanLeshow(DummyDevice, FanLeshow): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_LESHOW_SS4 self.state = { "power": 1, "mode": 2, "blow": 100, "timer": 0, "sound": 1, "yaw": 0, "fault": 0, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_blow": lambda x: self._set_state("blow", x), "set_timer": lambda x: self._set_state("timer", x), "set_sound": lambda x: self._set_state("sound", x), "set_yaw": lambda x: self._set_state("yaw", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fanleshow(request): request.cls.device = DummyFanLeshow() # TODO add ability to test on a real device @pytest.mark.usefixtures("fanleshow") class TestFanLeshow(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(FanLeshowStatus(self.device.start_state)) assert self.is_on() is True assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().speed == self.device.start_state["blow"] assert self.state().buzzer is (self.device.start_state["sound"] == 1) assert self.state().oscillate is (self.device.start_state["yaw"] == 1) assert self.state().delay_off_countdown == self.device.start_state["timer"] assert self.state().error_detected is (self.device.start_state["fault"] == 1) def test_set_speed(self): def speed(): return self.device.status().speed self.device.set_speed(0) assert speed() == 0 self.device.set_speed(1) assert speed() == 1 self.device.set_speed(100) assert speed() == 100 with pytest.raises(FanLeshowException): self.device.set_speed(-1) with pytest.raises(FanLeshowException): self.device.set_speed(101) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 self.device.delay_off(0) assert delay_off_countdown() == 0 with pytest.raises(FanLeshowException): self.device.delay_off(-1) with pytest.raises(FanLeshowException): self.device.delay_off(541) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/zhimi/__init__.py0000644000000000000000000000010314265350055020440 0ustar00# flake8: noqa from .fan import Fan from .zhimi_miot import FanZA5 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/zhimi/fan.py0000644000000000000000000002752714265350055017470 0ustar00import logging from typing import Any, Dict, Optional import click from miio import Device, DeviceStatus from miio.click_common import EnumType, command, format_output from miio.fan_common import FanException, LedBrightness, MoveDirection _LOGGER = logging.getLogger(__name__) MODEL_FAN_V2 = "zhimi.fan.v2" MODEL_FAN_V3 = "zhimi.fan.v3" MODEL_FAN_SA1 = "zhimi.fan.sa1" MODEL_FAN_ZA1 = "zhimi.fan.za1" MODEL_FAN_ZA3 = "zhimi.fan.za3" MODEL_FAN_ZA4 = "zhimi.fan.za4" AVAILABLE_PROPERTIES_COMMON = [ "angle", "speed", "poweroff_time", "power", "ac_power", "angle_enable", "speed_level", "natural_level", "child_lock", "buzzer", "led_b", "use_time", ] AVAILABLE_PROPERTIES_COMMON_V2_V3 = [ "temp_dec", "humidity", "battery", "bat_charge", "button_pressed", ] + AVAILABLE_PROPERTIES_COMMON AVAILABLE_PROPERTIES = { MODEL_FAN_V3: AVAILABLE_PROPERTIES_COMMON_V2_V3, MODEL_FAN_V2: ["led", "bat_state"] + AVAILABLE_PROPERTIES_COMMON_V2_V3, MODEL_FAN_SA1: AVAILABLE_PROPERTIES_COMMON, MODEL_FAN_ZA1: AVAILABLE_PROPERTIES_COMMON, MODEL_FAN_ZA3: AVAILABLE_PROPERTIES_COMMON, MODEL_FAN_ZA4: AVAILABLE_PROPERTIES_COMMON, } class FanStatus(DeviceStatus): """Container for status reports from the Xiaomi Mi Smart Pedestal Fan.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Fan (zhimi.fan.v3): {'temp_dec': 232, 'humidity': 46, 'angle': 118, 'speed': 298, 'poweroff_time': 0, 'power': 'on', 'ac_power': 'off', 'battery': 98, 'angle_enable': 'off', 'speed_level': 1, 'natural_level': 0, 'child_lock': 'off', 'buzzer': 'on', 'led_b': 1, 'led': None, 'natural_enable': None, 'use_time': 0, 'bat_charge': 'complete', 'bat_state': None, 'button_pressed':'speed'} Response of a Fan (zhimi.fan.sa1): {'angle': 120, 'speed': 277, 'poweroff_time': 0, 'power': 'on', 'ac_power': 'on', 'angle_enable': 'off', 'speed_level': 1, 'natural_level': 2, 'child_lock': 'off', 'buzzer': 0, 'led_b': 0, 'use_time': 2318} Response of a Fan (zhimi.fan.sa4): {'angle': 120, 'speed': 327, 'poweroff_time': 0, 'power': 'on', 'ac_power': 'on', 'angle_enable': 'off', 'speed_level': 1, 'natural_level': 0, 'child_lock': 'off', 'buzzer': 2, 'led_b': 0, 'use_time': 85} """ self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if device is currently on.""" return self.power == "on" @property def humidity(self) -> Optional[int]: """Current humidity.""" if "humidity" in self.data and self.data["humidity"] is not None: return self.data["humidity"] return None @property def temperature(self) -> Optional[float]: """Current temperature, if available.""" if "temp_dec" in self.data and self.data["temp_dec"] is not None: return self.data["temp_dec"] / 10.0 return None @property def led(self) -> Optional[bool]: """True if LED is turned on, if available.""" if "led" in self.data and self.data["led"] is not None: return self.data["led"] == "on" return None @property def led_brightness(self) -> Optional[LedBrightness]: """LED brightness, if available.""" if self.data["led_b"] is not None: return LedBrightness(self.data["led_b"]) return None @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] in ["on", 1, 2] @property def child_lock(self) -> bool: """True if child lock is on.""" return self.data["child_lock"] == "on" @property def natural_speed(self) -> Optional[int]: """Speed level in natural mode.""" if "natural_level" in self.data and self.data["natural_level"] is not None: return self.data["natural_level"] return None @property def direct_speed(self) -> Optional[int]: """Speed level in direct mode.""" if "speed_level" in self.data and self.data["speed_level"] is not None: return self.data["speed_level"] return None @property def oscillate(self) -> bool: """True if oscillation is enabled.""" return self.data["angle_enable"] == "on" @property def battery(self) -> Optional[int]: """Current battery level.""" if "battery" in self.data and self.data["battery"] is not None: return self.data["battery"] return None @property def battery_charge(self) -> Optional[str]: """State of the battery charger, if available.""" if "bat_charge" in self.data and self.data["bat_charge"] is not None: return self.data["bat_charge"] return None @property def battery_state(self) -> Optional[str]: """State of the battery, if available.""" if "bat_state" in self.data and self.data["bat_state"] is not None: return self.data["bat_state"] return None @property def ac_power(self) -> bool: """True if powered by AC.""" return self.data["ac_power"] == "on" @property def delay_off_countdown(self) -> int: """Countdown until turning off in seconds.""" return self.data["poweroff_time"] @property def speed(self) -> int: """Speed of the motor.""" return self.data["speed"] @property def angle(self) -> int: """Current angle.""" return self.data["angle"] @property def use_time(self) -> int: """How long the device has been active in seconds.""" return self.data["use_time"] @property def button_pressed(self) -> Optional[str]: """Last pressed button.""" if "button_pressed" in self.data and self.data["button_pressed"] is not None: return self.data["button_pressed"] return None class Fan(Device): """Main class representing the Xiaomi Mi Smart Pedestal Fan.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Battery: {result.battery} %\n" "AC power: {result.ac_power}\n" "Temperature: {result.temperature} °C\n" "Humidity: {result.humidity} %\n" "LED: {result.led}\n" "LED brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Speed: {result.speed}\n" "Natural speed: {result.natural_speed}\n" "Direct speed: {result.direct_speed}\n" "Oscillate: {result.oscillate}\n" "Power-off time: {result.delay_off_countdown}\n" "Angle: {result.angle}\n", ) ) def status(self) -> FanStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES[self.model] # A single request is limited to 16 properties. Therefore the # properties are divided into multiple requests _props_per_request = 15 # The SA1, ZA1, ZA3 and ZA4 is limited to a single property per request if self.model in [MODEL_FAN_SA1, MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4]: _props_per_request = 1 values = self.get_properties(properties, max_properties=_props_per_request) return FanStatus(dict(zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("speed", type=int), default_output=format_output("Setting speed of the natural mode to {speed}"), ) def set_natural_speed(self, speed: int): """Set natural level.""" if speed < 0 or speed > 100: raise FanException("Invalid speed: %s" % speed) return self.send("set_natural_level", [speed]) @command( click.argument("speed", type=int), default_output=format_output("Setting speed of the direct mode to {speed}"), ) def set_direct_speed(self, speed: int): """Set speed of the direct mode.""" if speed < 0 or speed > 100: raise FanException("Invalid speed: %s" % speed) return self.send("set_speed_level", [speed]) @command( click.argument("direction", type=EnumType(MoveDirection)), default_output=format_output("Rotating the fan to the {direction}"), ) def set_rotate(self, direction: MoveDirection): """Rotate the fan by -5/+5 degrees left/right.""" return self.send("set_move", [direction.value]) @command( click.argument("angle", type=int), default_output=format_output("Setting angle to {angle}"), ) def set_angle(self, angle: int): """Set the oscillation angle.""" if angle < 0 or angle > 120: raise FanException("Invalid angle: %s" % angle) return self.send("set_angle", [angle]) @command( click.argument("oscillate", type=bool), default_output=format_output( lambda oscillate: "Turning on oscillate" if oscillate else "Turning off oscillate" ), ) def set_oscillate(self, oscillate: bool): """Set oscillate on/off.""" if oscillate: return self.send("set_angle_enable", ["on"]) else: return self.send("set_angle_enable", ["off"]) @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" return self.send("set_led_b", [brightness.value]) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off. Not supported by model SA1. """ if led: return self.send("set_led", ["on"]) else: return self.send("set_led", ["off"]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if self.model in [MODEL_FAN_SA1, MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4]: if buzzer: return self.send("set_buzzer", [2]) else: return self.send("set_buzzer", [0]) if buzzer: return self.send("set_buzzer", ["on"]) else: return self.send("set_buzzer", ["off"]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if lock: return self.send("set_child_lock", ["on"]) else: return self.send("set_child_lock", ["off"]) @command( click.argument("seconds", type=int), default_output=format_output("Setting delayed turn off to {seconds} seconds"), ) def delay_off(self, seconds: int): """Set delay off seconds.""" if seconds < 0: raise FanException("Invalid value for a delayed turn off: %s" % seconds) return self.send("set_poweroff_time", [seconds]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/zhimi/test_fan.py0000644000000000000000000005745114265350055020526 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from .fan import ( MODEL_FAN_SA1, MODEL_FAN_V2, MODEL_FAN_V3, Fan, FanException, FanStatus, LedBrightness, MoveDirection, ) class DummyFanV2(DummyDevice, Fan): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_V2 # This example response is just a guess. Please update! self.state = { "temp_dec": 232, "humidity": 46, "angle": 118, "speed": 298, "poweroff_time": 0, "power": "on", "ac_power": "off", "battery": 98, "angle_enable": "off", "speed_level": 1, "natural_level": 0, "child_lock": "off", "buzzer": "on", "led_b": 1, "led": "on", "natural_enable": None, "use_time": 0, "bat_charge": "complete", "bat_state": None, "button_pressed": "speed", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_speed_level": lambda x: self._set_state("speed_level", x), "set_natural_level": lambda x: self._set_state("natural_level", x), "set_move": lambda x: True, "set_angle": lambda x: self._set_state("angle", x), "set_angle_enable": lambda x: self._set_state("angle_enable", x), "set_led_b": lambda x: self._set_state("led_b", x), "set_led": lambda x: self._set_state("led", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_poweroff_time": lambda x: self._set_state("poweroff_time", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fanv2(request): request.cls.device = DummyFanV2() # TODO add ability to test on a real device @pytest.mark.usefixtures("fanv2") class TestFanV2(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(FanStatus(self.device.start_state)) assert self.is_on() is True assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0 assert self.state().humidity == self.device.start_state["humidity"] assert self.state().angle == self.device.start_state["angle"] assert self.state().speed == self.device.start_state["speed"] assert ( self.state().delay_off_countdown == self.device.start_state["poweroff_time"] ) assert self.state().ac_power is (self.device.start_state["ac_power"] == "on") assert self.state().battery == self.device.start_state["battery"] assert self.state().oscillate is ( self.device.start_state["angle_enable"] == "on" ) assert self.state().direct_speed == self.device.start_state["speed_level"] assert self.state().natural_speed == self.device.start_state["natural_level"] assert self.state().child_lock is ( self.device.start_state["child_lock"] == "on" ) assert self.state().buzzer is (self.device.start_state["buzzer"] == "on") assert self.state().led_brightness == LedBrightness( self.device.start_state["led_b"] ) assert self.state().led is (self.device.start_state["led"] == "on") assert self.state().use_time == self.device.start_state["use_time"] assert self.state().battery_charge == self.device.start_state["bat_charge"] assert self.state().battery_state == self.device.start_state["bat_state"] assert self.state().button_pressed == self.device.start_state["button_pressed"] def test_status_without_led_brightness(self): self.device._reset_state() self.device.state["led_b"] = None assert self.state().led_brightness is None def test_status_without_battery_charge(self): self.device._reset_state() self.device.state["bat_charge"] = None assert self.state().battery_charge is None def test_status_without_battery_state(self): self.device._reset_state() self.device.state["bat_state"] = None assert self.state().battery_state is None def test_status_without_button_pressed(self): self.device._reset_state() self.device.state["button_pressed"] = None assert self.state().button_pressed is None def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_direct_speed(self): def direct_speed(): return self.device.status().direct_speed self.device.set_direct_speed(0) assert direct_speed() == 0 self.device.set_direct_speed(1) assert direct_speed() == 1 self.device.set_direct_speed(100) assert direct_speed() == 100 with pytest.raises(FanException): self.device.set_direct_speed(-1) with pytest.raises(FanException): self.device.set_direct_speed(101) def test_set_rotate(self): """The method is open-loop. The new state cannot be retrieved. """ self.device.set_rotate(MoveDirection.Left) self.device.set_rotate(MoveDirection.Right) def test_set_angle(self): """This test doesn't implement the real behaviour of the device may be. The property "angle" doesn't provide the current setting. It's a measurement of the current position probably. """ def angle(): return self.device.status().angle self.device.set_angle(0) # TODO: Is this value allowed? assert angle() == 0 self.device.set_angle(1) # TODO: Is this value allowed? assert angle() == 1 self.device.set_angle(30) assert angle() == 30 self.device.set_angle(60) assert angle() == 60 self.device.set_angle(90) assert angle() == 90 self.device.set_angle(120) assert angle() == 120 with pytest.raises(FanException): self.device.set_angle(-1) with pytest.raises(FanException): self.device.set_angle(121) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright self.device.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 self.device.delay_off(0) assert delay_off_countdown() == 0 with pytest.raises(FanException): self.device.delay_off(-1) class DummyFanV3(DummyDevice, Fan): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_V3 self.state = { "temp_dec": 232, "humidity": 46, "angle": 118, "speed": 298, "poweroff_time": 0, "power": "on", "ac_power": "off", "battery": 98, "angle_enable": "off", "speed_level": 1, "natural_level": 0, "child_lock": "off", "buzzer": "on", "led_b": 1, "led": None, "natural_enable": None, "use_time": 0, "bat_charge": "complete", "bat_state": None, "button_pressed": "speed", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_speed_level": lambda x: self._set_state("speed_level", x), "set_natural_level": lambda x: self._set_state("natural_level", x), "set_move": lambda x: True, "set_angle": lambda x: self._set_state("angle", x), "set_angle_enable": lambda x: self._set_state("angle_enable", x), "set_led_b": lambda x: self._set_state("led_b", x), "set_led": lambda x: self._set_state("led", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_poweroff_time": lambda x: self._set_state("poweroff_time", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fanv3(request): request.cls.device = DummyFanV3() # TODO add ability to test on a real device @pytest.mark.usefixtures("fanv3") class TestFanV3(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(FanStatus(self.device.start_state)) assert self.is_on() is True assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0 assert self.state().humidity == self.device.start_state["humidity"] assert self.state().angle == self.device.start_state["angle"] assert self.state().speed == self.device.start_state["speed"] assert ( self.state().delay_off_countdown == self.device.start_state["poweroff_time"] ) assert self.state().ac_power is (self.device.start_state["ac_power"] == "on") assert self.state().battery == self.device.start_state["battery"] assert self.state().oscillate is ( self.device.start_state["angle_enable"] == "on" ) assert self.state().direct_speed == self.device.start_state["speed_level"] assert self.state().natural_speed == self.device.start_state["natural_level"] assert self.state().child_lock is ( self.device.start_state["child_lock"] == "on" ) assert self.state().buzzer is (self.device.start_state["buzzer"] == "on") assert self.state().led_brightness == LedBrightness( self.device.start_state["led_b"] ) assert self.state().led is None assert self.state().use_time == self.device.start_state["use_time"] assert self.state().battery_charge == self.device.start_state["bat_charge"] assert self.state().battery_state == self.device.start_state["bat_state"] assert self.state().button_pressed == self.device.start_state["button_pressed"] def test_status_without_led_brightness(self): self.device._reset_state() self.device.state["led_b"] = None assert self.state().led_brightness is None def test_status_without_battery_charge(self): self.device._reset_state() self.device.state["bat_charge"] = None assert self.state().battery_charge is None def test_status_without_battery_state(self): self.device._reset_state() self.device.state["bat_state"] = None assert self.state().battery_state is None def test_status_without_button_pressed(self): self.device._reset_state() self.device.state["button_pressed"] = None assert self.state().button_pressed is None def test_set_direct_speed(self): def direct_speed(): return self.device.status().direct_speed self.device.set_direct_speed(0) assert direct_speed() == 0 self.device.set_direct_speed(1) assert direct_speed() == 1 self.device.set_direct_speed(100) assert direct_speed() == 100 with pytest.raises(FanException): self.device.set_direct_speed(-1) with pytest.raises(FanException): self.device.set_direct_speed(101) def test_set_natural_speed(self): def natural_speed(): return self.device.status().natural_speed self.device.set_natural_speed(0) assert natural_speed() == 0 self.device.set_natural_speed(1) assert natural_speed() == 1 self.device.set_natural_speed(100) assert natural_speed() == 100 with pytest.raises(FanException): self.device.set_natural_speed(-1) with pytest.raises(FanException): self.device.set_natural_speed(101) def test_set_rotate(self): """The method is open-loop. The new state cannot be retrieved. """ self.device.set_rotate(MoveDirection.Left) self.device.set_rotate(MoveDirection.Right) def test_set_angle(self): """This test doesn't implement the real behaviour of the device may be. The property "angle" doesn't provide the current setting. It's a measurement of the current position probably. """ def angle(): return self.device.status().angle self.device.set_angle(0) # TODO: Is this value allowed? assert angle() == 0 self.device.set_angle(1) # TODO: Is this value allowed? assert angle() == 1 self.device.set_angle(30) assert angle() == 30 self.device.set_angle(60) assert angle() == 60 self.device.set_angle(90) assert angle() == 90 self.device.set_angle(120) assert angle() == 120 with pytest.raises(FanException): self.device.set_angle(-1) with pytest.raises(FanException): self.device.set_angle(121) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright self.device.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 self.device.delay_off(0) assert delay_off_countdown() == 0 with pytest.raises(FanException): self.device.delay_off(-1) class DummyFanSA1(DummyDevice, Fan): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_SA1 self.state = { "angle": 120, "speed": 277, "poweroff_time": 0, "power": "on", "ac_power": "on", "angle_enable": "off", "speed_level": 1, "natural_level": 2, "child_lock": "off", "buzzer": 0, "led_b": 0, "use_time": 2318, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_speed_level": lambda x: self._set_state("speed_level", x), "set_natural_level": lambda x: self._set_state("natural_level", x), "set_move": lambda x: True, "set_angle": lambda x: self._set_state("angle", x), "set_angle_enable": lambda x: self._set_state("angle_enable", x), "set_led_b": lambda x: self._set_state("led_b", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_poweroff_time": lambda x: self._set_state("poweroff_time", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fansa1(request): request.cls.device = DummyFanSA1() # TODO add ability to test on a real device @pytest.mark.usefixtures("fansa1") class TestFanSA1(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(FanStatus(self.device.start_state)) assert self.is_on() is True assert self.state().angle == self.device.start_state["angle"] assert self.state().speed == self.device.start_state["speed"] assert ( self.state().delay_off_countdown == self.device.start_state["poweroff_time"] ) assert self.state().ac_power is (self.device.start_state["ac_power"] == "on") assert self.state().oscillate is ( self.device.start_state["angle_enable"] == "on" ) assert self.state().direct_speed == self.device.start_state["speed_level"] assert self.state().natural_speed == self.device.start_state["natural_level"] assert self.state().child_lock is ( self.device.start_state["child_lock"] == "on" ) assert self.state().buzzer is (self.device.start_state["buzzer"] == "on") assert self.state().led_brightness == LedBrightness( self.device.start_state["led_b"] ) assert self.state().led is None assert self.state().use_time == self.device.start_state["use_time"] def test_set_direct_speed(self): def direct_speed(): return self.device.status().direct_speed self.device.set_direct_speed(0) assert direct_speed() == 0 self.device.set_direct_speed(1) assert direct_speed() == 1 self.device.set_direct_speed(100) assert direct_speed() == 100 with pytest.raises(FanException): self.device.set_direct_speed(-1) with pytest.raises(FanException): self.device.set_direct_speed(101) def test_set_natural_speed(self): def natural_speed(): return self.device.status().natural_speed self.device.set_natural_speed(0) assert natural_speed() == 0 self.device.set_natural_speed(1) assert natural_speed() == 1 self.device.set_natural_speed(100) assert natural_speed() == 100 with pytest.raises(FanException): self.device.set_natural_speed(-1) with pytest.raises(FanException): self.device.set_natural_speed(101) def test_set_rotate(self): """The method is open-loop. The new state cannot be retrieved. """ self.device.set_rotate(MoveDirection.Left) self.device.set_rotate(MoveDirection.Right) def test_set_angle(self): """This test doesn't implement the real behaviour of the device may be. The property "angle" doesn't provide the current setting. It's a measurement of the current position probably. """ def angle(): return self.device.status().angle self.device.set_angle(0) # TODO: Is this value allowed? assert angle() == 0 self.device.set_angle(1) # TODO: Is this value allowed? assert angle() == 1 self.device.set_angle(30) assert angle() == 30 self.device.set_angle(60) assert angle() == 60 self.device.set_angle(90) assert angle() == 90 self.device.set_angle(120) assert angle() == 120 with pytest.raises(FanException): self.device.set_angle(-1) with pytest.raises(FanException): self.device.set_angle(121) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright self.device.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 self.device.delay_off(0) assert delay_off_countdown() == 0 with pytest.raises(FanException): self.device.delay_off(-1) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/zhimi/test_zhimi_miot.py0000644000000000000000000001035314265350055022120 0ustar00from unittest import TestCase import pytest from miio.fan_common import FanException, OperationMode from miio.tests.dummies import DummyMiotDevice from . import FanZA5 from .zhimi_miot import MODEL_FAN_ZA5, OperationModeFanZA5 class DummyFanZA5(DummyMiotDevice, FanZA5): def __init__(self, *args, **kwargs): self._model = MODEL_FAN_ZA5 self.state = { "anion": True, "buzzer": False, "child_lock": False, "fan_speed": 42, "light": 44, "mode": OperationModeFanZA5.Normal.value, "power": True, "power_off_time": 0, "swing_mode": True, "swing_mode_angle": 60, } super().__init__(args, kwargs) @pytest.fixture(scope="class") def fanza5(request): request.cls.device = DummyFanZA5() @pytest.mark.usefixtures("fanza5") class TestFanZA5(TestCase): def is_on(self): return self.device.status().is_on def is_ionizer_enabled(self): return self.device.status().is_ionizer_enabled def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_ionizer(self): def ionizer(): return self.device.status().ionizer self.device.set_ionizer(True) assert ionizer() is True self.device.set_ionizer(False) assert ionizer() is False def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationModeFanZA5.Normal) assert mode() == OperationMode.Normal self.device.set_mode(OperationModeFanZA5.Nature) assert mode() == OperationMode.Nature def test_set_speed(self): def speed(): return self.device.status().speed for s in range(1, 101): self.device.set_speed(s) assert speed() == s for s in (-1, 0, 101): with pytest.raises(FanException): self.device.set_speed(s) def test_fan_speed_deprecation(self): with pytest.deprecated_call(): self.device.status().fan_speed def test_set_angle(self): def angle(): return self.device.status().angle for a in (30, 60, 90, 120): self.device.set_angle(a) assert angle() == a for a in (0, 45, 140): with pytest.raises(FanException): self.device.set_angle(a) def test_set_oscillate(self): def oscillate(): return self.device.status().oscillate self.device.set_oscillate(True) assert oscillate() is True self.device.set_oscillate(False) assert oscillate() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness for brightness in range(101): self.device.set_led_brightness(brightness) assert led_brightness() == brightness for brightness in (-1, 101): with pytest.raises(FanException): self.device.set_led_brightness(brightness) def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown for delay in (0, 1, 36000): self.device.delay_off(delay) assert delay_off_countdown() == delay for delay in (-1, 36001): with pytest.raises(FanException): self.device.delay_off(delay) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/fan/zhimi/zhimi_miot.py0000644000000000000000000002565114265350055021070 0ustar00import enum from typing import Any, Dict import click from miio import DeviceStatus, MiotDevice from miio.click_common import EnumType, command, format_output from miio.fan_common import FanException, MoveDirection, OperationMode from miio.utils import deprecated MODEL_FAN_ZA5 = "zhimi.fan.za5" MIOT_MAPPING = { MODEL_FAN_ZA5: { # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:zhimi-za5:1 "power": {"siid": 2, "piid": 1}, "fan_level": {"siid": 2, "piid": 2}, "swing_mode": {"siid": 2, "piid": 3}, "swing_mode_angle": {"siid": 2, "piid": 5}, "mode": {"siid": 2, "piid": 7}, "power_off_time": {"siid": 2, "piid": 10}, "anion": {"siid": 2, "piid": 11}, "child_lock": {"siid": 3, "piid": 1}, "light": {"siid": 4, "piid": 3}, "buzzer": {"siid": 5, "piid": 1}, "buttons_pressed": {"siid": 6, "piid": 1}, "battery_supported": {"siid": 6, "piid": 2}, "set_move": {"siid": 6, "piid": 3}, "speed_rpm": {"siid": 6, "piid": 4}, "powersupply_attached": {"siid": 6, "piid": 5}, "fan_speed": {"siid": 6, "piid": 8}, "humidity": {"siid": 7, "piid": 1}, "temperature": {"siid": 7, "piid": 7}, }, } SUPPORTED_ANGLES = { MODEL_FAN_ZA5: [30, 60, 90, 120], } class OperationModeFanZA5(enum.Enum): Nature = 0 Normal = 1 class FanStatusZA5(DeviceStatus): """Container for status reports for FanZA5.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of FanZA5 (zhimi.fan.za5): {'code': -4005, 'did': 'set_move', 'piid': 3, 'siid': 6}, {'code': 0, 'did': 'anion', 'piid': 11, 'siid': 2, 'value': True}, {'code': 0, 'did': 'battery_supported', 'piid': 2, 'siid': 6, 'value': False}, {'code': 0, 'did': 'buttons_pressed', 'piid': 1, 'siid': 6, 'value': 0}, {'code': 0, 'did': 'buzzer', 'piid': 1, 'siid': 5, 'value': False}, {'code': 0, 'did': 'child_lock', 'piid': 1, 'siid': 3, 'value': False}, {'code': 0, 'did': 'fan_level', 'piid': 2, 'siid': 2, 'value': 4}, {'code': 0, 'did': 'fan_speed', 'piid': 8, 'siid': 6, 'value': 100}, {'code': 0, 'did': 'humidity', 'piid': 1, 'siid': 7, 'value': 55}, {'code': 0, 'did': 'light', 'piid': 3, 'siid': 4, 'value': 100}, {'code': 0, 'did': 'mode', 'piid': 7, 'siid': 2, 'value': 0}, {'code': 0, 'did': 'power', 'piid': 1, 'siid': 2, 'value': False}, {'code': 0, 'did': 'power_off_time', 'piid': 10, 'siid': 2, 'value': 0}, {'code': 0, 'did': 'powersupply_attached', 'piid': 5, 'siid': 6, 'value': True}, {'code': 0, 'did': 'speed_rpm', 'piid': 4, 'siid': 6, 'value': 0}, {'code': 0, 'did': 'swing_mode', 'piid': 3, 'siid': 2, 'value': True}, {'code': 0, 'did': 'swing_mode_angle', 'piid': 5, 'siid': 2, 'value': 60}, {'code': 0, 'did': 'temperature', 'piid': 7, 'siid': 7, 'value': 26.4}, """ self.data = data @property def ionizer(self) -> bool: """True if negative ions generation is enabled.""" return self.data["anion"] @property def battery_supported(self) -> bool: """True if battery is supported.""" return self.data["battery_supported"] @property def buttons_pressed(self) -> str: """What buttons on the fan are pressed now.""" code = self.data["buttons_pressed"] if code == 0: return "None" if code == 1: return "Power" if code == 2: return "Swing" return "Unknown" @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] @property def child_lock(self) -> bool: """True if child lock if on.""" return self.data["child_lock"] @property def fan_level(self) -> int: """Fan level (1-4).""" return self.data["fan_level"] @property # type: ignore @deprecated("Use speed()") def fan_speed(self) -> int: """Fan speed (1-100).""" return self.speed @property def speed(self) -> int: """Fan speed (1-100).""" return self.data["fan_speed"] @property def humidity(self) -> int: """Air humidity in percent.""" return self.data["humidity"] @property def led_brightness(self) -> int: """LED brightness (1-100).""" return self.data["light"] @property def mode(self) -> OperationMode: """Operation mode (normal or nature).""" return OperationMode[OperationModeFanZA5(self.data["mode"]).name] @property def power(self) -> str: """Power state.""" return "on" if self.data["power"] else "off" @property def is_on(self) -> bool: """True if device is currently on.""" return self.data["power"] @property def delay_off_countdown(self) -> int: """Countdown until turning off in minutes.""" return self.data["power_off_time"] @property def powersupply_attached(self) -> bool: """True is power supply is attached.""" return self.data["powersupply_attached"] @property def speed_rpm(self) -> int: """Fan rotations per minute.""" return self.data["speed_rpm"] @property def oscillate(self) -> bool: """True if oscillation is enabled.""" return self.data["swing_mode"] @property def angle(self) -> int: """Oscillation angle.""" return self.data["swing_mode_angle"] @property def temperature(self) -> Any: """Air temperature (degree celsius).""" return self.data["temperature"] class FanZA5(MiotDevice): _mappings = MIOT_MAPPING @command( default_output=format_output( "", "Angle: {result.angle}\n" "Battery Supported: {result.battery_supported}\n" "Buttons Pressed: {result.buttons_pressed}\n" "Buzzer: {result.buzzer}\n" "Child Lock: {result.child_lock}\n" "Delay Off Countdown: {result.delay_off_countdown}\n" "Fan Level: {result.fan_level}\n" "Fan Speed: {result.fan_speed}\n" "Humidity: {result.humidity}\n" "Ionizer: {result.ionizer}\n" "LED Brightness: {result.led_brightness}\n" "Mode: {result.mode.name}\n" "Oscillate: {result.oscillate}\n" "Power: {result.power}\n" "Powersupply Attached: {result.powersupply_attached}\n" "Speed RPM: {result.speed_rpm}\n" "Temperature: {result.temperature}\n", ) ) def status(self): """Retrieve properties.""" return FanStatusZA5( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("on", type=bool), default_output=format_output( lambda on: "Turning on ionizer" if on else "Turning off ionizer" ), ) def set_ionizer(self, on: bool): """Set ionizer on/off.""" return self.set_property("anion", on) @command( click.argument("speed", type=int), default_output=format_output("Setting speed to {speed}%"), ) def set_speed(self, speed: int): """Set fan speed.""" if speed < 1 or speed > 100: raise FanException("Invalid speed: %s" % speed) return self.set_property("fan_speed", speed) @command( click.argument("angle", type=int), default_output=format_output("Setting angle to {angle}"), ) def set_angle(self, angle: int): """Set the oscillation angle.""" if angle not in SUPPORTED_ANGLES[self.model]: raise FanException( "Unsupported angle. Supported values: " + ", ".join(f"{i}" for i in SUPPORTED_ANGLES[self.model]) ) return self.set_property("swing_mode_angle", angle) @command( click.argument("oscillate", type=bool), default_output=format_output( lambda oscillate: "Turning on oscillate" if oscillate else "Turning off oscillate" ), ) def set_oscillate(self, oscillate: bool): """Set oscillate on/off.""" return self.set_property("swing_mode", oscillate) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.set_property("buzzer", buzzer) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.set_property("child_lock", lock) @command( click.argument("brightness", type=int), default_output=format_output("Setting LED brightness to {brightness}%"), ) def set_led_brightness(self, brightness: int): """Set LED brightness.""" if brightness < 0 or brightness > 100: raise FanException("Invalid brightness: %s" % brightness) return self.set_property("light", brightness) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.set_property("mode", OperationModeFanZA5[mode.name].value) @command( click.argument("seconds", type=int), default_output=format_output("Setting delayed turn off to {seconds} seconds"), ) def delay_off(self, seconds: int): """Set delay off seconds.""" if seconds < 0 or seconds > 10 * 60 * 60: raise FanException("Invalid value for a delayed turn off: %s" % seconds) return self.set_property("power_off_time", seconds) @command( click.argument("direction", type=EnumType(MoveDirection)), default_output=format_output("Rotating the fan to the {direction}"), ) def set_rotate(self, direction: MoveDirection): """Rotate fan 7.5 degrees horizontally to given direction.""" status = self.status() if status.oscillate: raise FanException( "Rotation requires oscillation to be turned off to function." ) return self.set_property("set_move", direction.name.lower()) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/__init__.py0000644000000000000000000000011714265350055020706 0ustar00# flake8: noqa from .deerma import * from .shuii import * from .zhimi import * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/deerma/__init__.py0000644000000000000000000000016514265350055022146 0ustar00# flake8: noqa from .airhumidifier_jsqs import AirHumidifierJsqs from .airhumidifier_mjjsq import AirHumidifierMjjsq ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/deerma/airhumidifier_jsqs.py0000644000000000000000000001675514265350055024304 0ustar00import enum import logging from typing import Any, Dict, Optional import click from miio.click_common import EnumType, command, format_output from miio.exceptions import DeviceException from miio.miot_device import DeviceStatus, MiotDevice _LOGGER = logging.getLogger(__name__) _MAPPING = { # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:humidifier:0000A00E:deerma-jsqs:2 # Air Humidifier (siid=2) "power": {"siid": 2, "piid": 1}, # bool "fault": {"siid": 2, "piid": 2}, # 0 "mode": {"siid": 2, "piid": 5}, # 1 - lvl1, 2 - lvl2, 3 - lvl3, 4 - auto "target_humidity": {"siid": 2, "piid": 6}, # [40, 80] step 1 # Environment (siid=3) "relative_humidity": {"siid": 3, "piid": 1}, # [0, 100] step 1 "temperature": {"siid": 3, "piid": 7}, # [-30, 100] step 1 # Alarm (siid=5) "buzzer": {"siid": 5, "piid": 1}, # bool # Light (siid=6) "led_light": {"siid": 6, "piid": 1}, # bool # Other (siid=7) "water_shortage_fault": {"siid": 7, "piid": 1}, # bool "tank_filed": {"siid": 7, "piid": 2}, # bool "overwet_protect": {"siid": 7, "piid": 3}, # bool } SUPPORTED_MODELS = ["deerma.humidifier.jsqs", "deerma.humidifier.jsq5"] MIOT_MAPPING = {model: _MAPPING for model in SUPPORTED_MODELS} class AirHumidifierJsqsException(DeviceException): pass class OperationMode(enum.Enum): Low = 1 Mid = 2 High = 3 Auto = 4 class AirHumidifierJsqsStatus(DeviceStatus): """Container for status reports from the air humidifier. Xiaomi Mi Smart Humidifer S (deerma.humidifier.[jsqs, jsq5]) respone (MIoT format) [ {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, {'did': 'fault', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0}, {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 1}, {'did': 'target_humidity', 'siid': 2, 'piid': 6, 'code': 0, 'value': 50}, {'did': 'relative_humidity', 'siid': 3, 'piid': 1, 'code': 0, 'value': 40}, {'did': 'temperature', 'siid': 3, 'piid': 7, 'code': 0, 'value': 22.7}, {'did': 'buzzer', 'siid': 5, 'piid': 1, 'code': 0, 'value': False}, {'did': 'led_light', 'siid': 6, 'piid': 1, 'code': 0, 'value': True}, {'did': 'water_shortage_fault', 'siid': 7, 'piid': 1, 'code': 0, 'value': False}, {'did': 'tank_filed', 'siid': 7, 'piid': 2, 'code': 0, 'value': False}, {'did': 'overwet_protect', 'siid': 7, 'piid': 3, 'code': 0, 'value': False} ] """ def __init__(self, data: Dict[str, Any]) -> None: self.data = data # Air Humidifier @property def is_on(self) -> bool: """Return True if device is on.""" return self.data["power"] @property def power(self) -> str: """Return power state.""" return "on" if self.is_on else "off" @property def error(self) -> int: """Return error state.""" return self.data["fault"] @property def mode(self) -> OperationMode: """Return current operation mode.""" try: mode = OperationMode(self.data["mode"]) except ValueError as e: _LOGGER.exception("Cannot parse mode: %s", e) return OperationMode.Auto return mode @property def target_humidity(self) -> Optional[int]: """Return target humidity.""" return self.data.get("target_humidity") # Environment @property def relative_humidity(self) -> Optional[int]: """Return current humidity.""" return self.data.get("relative_humidity") @property def temperature(self) -> Optional[float]: """Return current temperature, if available.""" return self.data.get("temperature") # Alarm @property def buzzer(self) -> Optional[bool]: """Return True if buzzer is on.""" return self.data.get("buzzer") # Indicator Light @property def led_light(self) -> Optional[bool]: """Return status of the LED.""" return self.data.get("led_light") # Other @property def tank_filed(self) -> Optional[bool]: """Return the tank filed.""" return self.data.get("tank_filed") @property def water_shortage_fault(self) -> Optional[bool]: """Return water shortage fault.""" return self.data.get("water_shortage_fault") @property def overwet_protect(self) -> Optional[bool]: """Return True if overwet mode is active.""" return self.data.get("overwet_protect") class AirHumidifierJsqs(MiotDevice): """Main class representing the air humidifier which uses MIoT protocol.""" _mappings = MIOT_MAPPING @command( default_output=format_output( "", "Power: {result.power}\n" "Error: {result.error}\n" "Target Humidity: {result.target_humidity} %\n" "Relative Humidity: {result.relative_humidity} %\n" "Temperature: {result.temperature} °C\n" "Water tank detached: {result.tank_filed}\n" "Mode: {result.mode}\n" "LED light: {result.led_light}\n" "Buzzer: {result.buzzer}\n" "Overwet protection: {result.overwet_protect}\n", ) ) def status(self) -> AirHumidifierJsqsStatus: """Retrieve properties.""" return AirHumidifierJsqsStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("humidity", type=int), default_output=format_output("Setting target humidity {humidity}%"), ) def set_target_humidity(self, humidity: int): """Set target humidity.""" if humidity < 40 or humidity > 80: raise AirHumidifierJsqsException( "Invalid target humidity: %s. Must be between 40 and 80" % humidity ) return self.set_property("target_humidity", humidity) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set working mode.""" return self.set_property("mode", mode.value) @command( click.argument("light", type=bool), default_output=format_output( lambda light: "Turning on LED light" if light else "Turning off LED light" ), ) def set_light(self, light: bool): """Set led light.""" return self.set_property("led_light", light) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.set_property("buzzer", buzzer) @command( click.argument("overwet", type=bool), default_output=format_output( lambda overwet: "Turning on overwet" if overwet else "Turning off overwet" ), ) def set_overwet_protect(self, overwet: bool): """Set overwet mode on/off.""" return self.set_property("overwet_protect", overwet) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/deerma/airhumidifier_mjjsq.py0000644000000000000000000001456314265350055024443 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) MODEL_HUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" MODEL_HUMIDIFIER_JSQ = "deerma.humidifier.jsq" MODEL_HUMIDIFIER_JSQ1 = "deerma.humidifier.jsq1" MODEL_HUMIDIFIER_JSQ_COMMON = [ "OnOff_State", "TemperatureValue", "Humidity_Value", "HumiSet_Value", "Humidifier_Gear", "Led_State", "TipSound_State", "waterstatus", "watertankstatus", ] AVAILABLE_PROPERTIES = { MODEL_HUMIDIFIER_MJJSQ: MODEL_HUMIDIFIER_JSQ_COMMON, MODEL_HUMIDIFIER_JSQ: MODEL_HUMIDIFIER_JSQ_COMMON, MODEL_HUMIDIFIER_JSQ1: MODEL_HUMIDIFIER_JSQ_COMMON + ["wet_and_protect"], } class AirHumidifierException(DeviceException): pass class OperationMode(enum.Enum): Low = 1 Medium = 2 High = 3 Humidity = 4 WetAndProtect = 5 class AirHumidifierStatus(DeviceStatus): """Container for status reports from the air humidifier mjjsq.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Air Humidifier (deerma.humidifier.mjjsq): {'Humidifier_Gear': 4, 'Humidity_Value': 44, 'HumiSet_Value': 54, 'Led_State': 1, 'OnOff_State': 0, 'TemperatureValue': 21, 'TipSound_State': 1, 'waterstatus': 1, 'watertankstatus': 1} """ self.data = data @property def power(self) -> str: """Power state.""" return "on" if self.data["OnOff_State"] == 1 else "off" @property def is_on(self) -> bool: """True if device is turned on.""" return self.power == "on" @property def mode(self) -> OperationMode: """Operation mode. Can be either low, medium, high or humidity. """ return OperationMode(self.data["Humidifier_Gear"]) @property def temperature(self) -> int: """Current temperature in degree celsius.""" return self.data["TemperatureValue"] @property def humidity(self) -> int: """Current humidity in percent.""" return self.data["Humidity_Value"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["TipSound_State"] == 1 @property def led(self) -> bool: """True if LED is turned on.""" return self.data["Led_State"] == 1 @property def target_humidity(self) -> int: """Target humiditiy in percent.""" return self.data["HumiSet_Value"] @property def no_water(self) -> bool: """True if the water tank is empty.""" return self.data["waterstatus"] == 0 @property def water_tank_detached(self) -> bool: """True if the water tank is detached.""" return self.data["watertankstatus"] == 0 @property def wet_protection(self) -> Optional[bool]: """True if wet protection is enabled.""" if self.data["wet_and_protect"] is not None: return self.data["wet_and_protect"] == 1 return None @property def use_time(self) -> Optional[int]: """How long the device has been active in seconds. Not supported by the device, so we return none here. """ return None class AirHumidifierMjjsq(Device): """Support for deerma.humidifier.(mj)jsq.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "Temperature: {result.temperature} °C\n" "Humidity: {result.humidity} %\n" "LED: {result.led}\n" "Buzzer: {result.buzzer}\n" "Target humidity: {result.target_humidity} %\n" "No water: {result.no_water}\n" "Water tank detached: {result.water_tank_detached}\n" "Wet protection: {result.wet_protection}\n", ) ) def status(self) -> AirHumidifierStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_MJJSQ] ) values = self.get_properties(properties, max_properties=1) return AirHumidifierStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("Set_OnOff", [1]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("Set_OnOff", [0]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" return self.send("Set_HumidifierGears", [mode.value]) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" return self.send("SetLedState", [int(led)]) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.send("SetTipSound_Status", [int(buzzer)]) @command( click.argument("humidity", type=int), default_output=format_output("Setting target humidity to {humidity}"), ) def set_target_humidity(self, humidity: int): """Set the target humidity in percent.""" if humidity < 0 or humidity > 99: raise AirHumidifierException("Invalid target humidity: %s" % humidity) return self.send("Set_HumiValue", [humidity]) @command( click.argument("protection", type=bool), default_output=format_output( lambda protection: "Turning on wet protection" if protection else "Turning off wet protection" ), ) def set_wet_protection(self, protection: bool): """Turn wet protection on/off.""" return self.send("Set_wet_and_protect", [int(protection)]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/deerma/tests/__init__.py0000644000000000000000000000000014265350055023274 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/deerma/tests/test_airhumidifier_jsqs.py0000644000000000000000000000720214265350055026470 0ustar00import pytest from miio import AirHumidifierJsqs from miio.tests.dummies import DummyMiotDevice from ..airhumidifier_jsqs import AirHumidifierJsqsException, OperationMode _INITIAL_STATE = { "power": True, "fault": 0, "mode": 4, "target_humidity": 60, "temperature": 21.6, "relative_humidity": 62, "buzzer": False, "led_light": True, "water_shortage_fault": False, "tank_filed": False, "overwet_protect": True, } class DummyAirHumidifierJsqs(DummyMiotDevice, AirHumidifierJsqs): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_target_humidity": lambda x: self._set_state("target_humidity", x), "set_mode": lambda x: self._set_state("mode", x), "set_led_light": lambda x: self._set_state("led_light", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_overwet_protect": lambda x: self._set_state("overwet_protect", x), } super().__init__(*args, **kwargs) @pytest.fixture() def dev(request): yield DummyAirHumidifierJsqs() def test_on(dev): dev.off() # ensure off assert dev.status().is_on is False dev.on() assert dev.status().is_on is True def test_off(dev): dev.on() # ensure on assert dev.status().is_on is True dev.off() assert dev.status().is_on is False def test_status(dev): status = dev.status() assert status.is_on is _INITIAL_STATE["power"] assert status.error == _INITIAL_STATE["fault"] assert status.mode == OperationMode(_INITIAL_STATE["mode"]) assert status.target_humidity == _INITIAL_STATE["target_humidity"] assert status.temperature == _INITIAL_STATE["temperature"] assert status.relative_humidity == _INITIAL_STATE["relative_humidity"] assert status.buzzer == _INITIAL_STATE["buzzer"] assert status.led_light == _INITIAL_STATE["led_light"] assert status.water_shortage_fault == _INITIAL_STATE["water_shortage_fault"] assert status.tank_filed == _INITIAL_STATE["tank_filed"] assert status.overwet_protect == _INITIAL_STATE["overwet_protect"] def test_set_target_humidity(dev): def target_humidity(): return dev.status().target_humidity dev.set_target_humidity(40) assert target_humidity() == 40 dev.set_target_humidity(80) assert target_humidity() == 80 with pytest.raises(AirHumidifierJsqsException): dev.set_target_humidity(39) with pytest.raises(AirHumidifierJsqsException): dev.set_target_humidity(81) def test_set_mode(dev): def mode(): return dev.status().mode dev.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto dev.set_mode(OperationMode.Low) assert mode() == OperationMode.Low dev.set_mode(OperationMode.Mid) assert mode() == OperationMode.Mid dev.set_mode(OperationMode.High) assert mode() == OperationMode.High def test_set_led_light(dev): def led_light(): return dev.status().led_light dev.set_light(True) assert led_light() is True dev.set_light(False) assert led_light() is False def test_set_buzzer(dev): def buzzer(): return dev.status().buzzer dev.set_buzzer(True) assert buzzer() is True dev.set_buzzer(False) assert buzzer() is False def test_set_overwet_protect(dev): def overwet_protect(): return dev.status().overwet_protect dev.set_overwet_protect(True) assert overwet_protect() is True dev.set_overwet_protect(False) assert overwet_protect() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/deerma/tests/test_airhumidifier_mjjsq.py0000644000000000000000000001147114265350055026637 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from .. import AirHumidifierMjjsq from ..airhumidifier_mjjsq import ( MODEL_HUMIDIFIER_JSQ1, AirHumidifierException, AirHumidifierStatus, OperationMode, ) class DummyAirHumidifierMjjsq(DummyDevice, AirHumidifierMjjsq): def __init__(self, *args, **kwargs): self._model = MODEL_HUMIDIFIER_JSQ1 self.state = { "Humidifier_Gear": 1, "Humidity_Value": 44, "HumiSet_Value": 11, "Led_State": 0, "OnOff_State": 1, "TemperatureValue": 21, "TipSound_State": 0, "waterstatus": 1, "watertankstatus": 1, "wet_and_protect": 1, } self.return_values = { "get_prop": self._get_state, "Set_OnOff": lambda x: self._set_state("OnOff_State", x), "Set_HumidifierGears": lambda x: self._set_state("Humidifier_Gear", x), "SetLedState": lambda x: self._set_state("Led_State", x), "SetTipSound_Status": lambda x: self._set_state("TipSound_State", x), "Set_HumiValue": lambda x: self._set_state("HumiSet_Value", x), "Set_wet_and_protect": lambda x: self._set_state("wet_and_protect", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def airhumidifiermjjsq(request): request.cls.device = DummyAirHumidifierMjjsq() # TODO add ability to test on a real device @pytest.mark.usefixtures("airhumidifiermjjsq") class TestAirHumidifierMjjsq(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(AirHumidifierStatus(self.device.start_state)) assert self.is_on() is True assert self.state().temperature == self.device.start_state["TemperatureValue"] assert self.state().humidity == self.device.start_state["Humidity_Value"] assert self.state().mode == OperationMode( self.device.start_state["Humidifier_Gear"] ) assert self.state().led is (self.device.start_state["Led_State"] == 1) assert self.state().buzzer is (self.device.start_state["TipSound_State"] == 1) assert self.state().target_humidity == self.device.start_state["HumiSet_Value"] assert self.state().no_water is (self.device.start_state["waterstatus"] == 0) assert self.state().water_tank_detached is ( self.device.start_state["watertankstatus"] == 0 ) def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Low) assert mode() == OperationMode.Low self.device.set_mode(OperationMode.Medium) assert mode() == OperationMode.Medium self.device.set_mode(OperationMode.High) assert mode() == OperationMode.High self.device.set_mode(OperationMode.Humidity) assert mode() == OperationMode.Humidity def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_target_humidity(self): def target_humidity(): return self.device.status().target_humidity self.device.set_target_humidity(0) assert target_humidity() == 0 self.device.set_target_humidity(50) assert target_humidity() == 50 self.device.set_target_humidity(99) assert target_humidity() == 99 with pytest.raises(AirHumidifierException): self.device.set_target_humidity(-1) with pytest.raises(AirHumidifierException): self.device.set_target_humidity(100) with pytest.raises(AirHumidifierException): self.device.set_target_humidity(101) def test_set_wet_protection(self): def wet_protection(): return self.device.status().wet_protection self.device.set_wet_protection(True) assert wet_protection() is True self.device.set_wet_protection(False) assert wet_protection() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/shuii/__init__.py0000644000000000000000000000007714265350055022034 0ustar00# flake8: noqa from .airhumidifier_jsq import AirHumidifierJsq ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/shuii/airhumidifier_jsq.py0000644000000000000000000001743514265350055024001 0ustar00import enum import logging from typing import Any, Dict, Optional import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) class AirHumidifierException(DeviceException): pass # Xiaomi Zero Fog Humidifier MODEL_HUMIDIFIER_JSQ001 = "shuii.humidifier.jsq001" # Array of properties in same order as in humidifier response AVAILABLE_PROPERTIES = { MODEL_HUMIDIFIER_JSQ001: [ "temperature", # (degrees, int) "humidity", # (percentage, int) "mode", # ( 0: Intelligent, 1: Level1, ..., 5:Level4) "buzzer", # (0: off, 1: on) "child_lock", # (0: off, 1: on) "led_brightness", # (0: off, 1: low, 2: high) "power", # (0: off, 1: on) "no_water", # (0: enough, 1: add water) "lid_opened", # (0: ok, 1: lid is opened) ] } class OperationMode(enum.Enum): Intelligent = 0 Level1 = 1 Level2 = 2 Level3 = 3 Level4 = 4 class LedBrightness(enum.Enum): Off = 0 Low = 1 High = 2 class AirHumidifierStatus(DeviceStatus): """Container for status reports from the air humidifier jsq.""" def __init__(self, data: Dict[str, Any]) -> None: """Status of an Air Humidifier (shuii.humidifier.jsq001): [24, 30, 1, 1, 0, 2, 0, 0, 0] Parsed by AirHumidifierJsq device as: {'temperature': 24, 'humidity': 29, 'mode': 1, 'buzzer': 1, 'child_lock': 0, 'led_brightness': 2, 'power': 0, 'no_water': 0, 'lid_opened': 0} """ self.data = data @property def power(self) -> str: """Power state.""" return "on" if self.data["power"] == 1 else "off" @property def is_on(self) -> bool: """True if device is turned on.""" return self.power == "on" @property def mode(self) -> OperationMode: """Operation mode. Can be either low, medium, high or humidity. """ try: mode = OperationMode(self.data["mode"]) except ValueError as e: _LOGGER.exception("Cannot parse mode: %s", e) return OperationMode.Intelligent return mode @property def temperature(self) -> int: """Current temperature in degree celsius.""" return self.data["temperature"] @property def humidity(self) -> int: """Current humidity in percent.""" return self.data["humidity"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] == 1 @property def led_brightness(self) -> LedBrightness: """Buttons illumination Brightness level.""" try: brightness = LedBrightness(self.data["led_brightness"]) except ValueError as e: _LOGGER.exception("Cannot parse brightness: %s", e) return LedBrightness.Off return brightness @property def led(self) -> bool: """True if LED is turned on.""" return self.led_brightness is not LedBrightness.Off @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["child_lock"] == 1 @property def no_water(self) -> bool: """True if the water tank is empty.""" return self.data["no_water"] == 1 @property def lid_opened(self) -> bool: """True if the water tank is detached.""" return self.data["lid_opened"] == 1 @property def use_time(self) -> Optional[int]: """How long the device has been active in seconds. Not supported by the device, so we return none here. """ return None class AirHumidifierJsq(Device): """Implementation of Xiaomi Zero Fog Humidifier: shuii.humidifier.jsq001.""" _supported_models = [MODEL_HUMIDIFIER_JSQ001] @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "Temperature: {result.temperature} °C\n" "Humidity: {result.humidity} %\n" "Buzzer: {result.buzzer}\n" "LED brightness: {result.led_brightness}\n" "Child lock: {result.child_lock}\n" "No water: {result.no_water}\n" "Lid opened: {result.lid_opened}\n", ) ) def status(self) -> AirHumidifierStatus: """Retrieve properties.""" values = self.send("get_props") # Response of an Air Humidifier (shuii.humidifier.jsq001): # [24, 37, 3, 1, 0, 2, 0, 0, 0] # # status[0] : temperature (degrees, int) # status[1]: humidity (percentage, int) # status[2]: mode ( 0: Intelligent, 1: Level1, ..., 5:Level4) # status[3]: buzzer (0: off, 1: on) # status[4]: lock (0: off, 1: on) # status[5]: brightness (0: off, 1: low, 2: high) # status[6]: power (0: off, 1: on) # status[7]: water level state (0: ok, 1: add water) # status[8]: lid state (0: ok, 1: lid is opened) properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_JSQ001] ) if len(properties) != len(values): _LOGGER.error( "Count (%s) of requested properties (%s) does not match the " "count (%s) of received values (%s).", len(properties), properties, len(values), values, ) return AirHumidifierStatus({k: v for k, v in zip(properties, values)}) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_start", [1]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_start", [0]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" value = mode.value if value not in (om.value for om in OperationMode): raise AirHumidifierException(f"{value} is not a valid OperationMode value") return self.send("set_mode", [value]) @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" value = brightness.value if value not in (lb.value for lb in LedBrightness): raise AirHumidifierException(f"{value} is not a valid LedBrightness value") return self.send("set_brightness", [value]) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" brightness = LedBrightness.High if led else LedBrightness.Off return self.set_led_brightness(brightness) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.send("set_buzzer", [int(bool(buzzer))]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.send("set_lock", [int(bool(lock))]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/shuii/tests/__init__.py0000644000000000000000000000000014265350055023160 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/shuii/tests/test_airhumidifier_jsq.py0000644000000000000000000002325614265350055026200 0ustar00from collections import OrderedDict from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from .. import AirHumidifierJsq from ..airhumidifier_jsq import ( MODEL_HUMIDIFIER_JSQ001, AirHumidifierException, AirHumidifierStatus, LedBrightness, OperationMode, ) class DummyAirHumidifierJsq(DummyDevice, AirHumidifierJsq): def __init__(self, *args, **kwargs): self._model = MODEL_HUMIDIFIER_JSQ001 self.dummy_device_info = { "life": 575661, "token": "68ffffffffffffffffffffffffffffff", "mac": "78:11:FF:FF:FF:FF", "fw_ver": "1.3.9", "hw_ver": "ESP8266", "uid": "1111111111", "model": self.model, "mcu_fw_ver": "0001", "wifi_fw_ver": "1.5.0-dev(7efd021)", "ap": {"rssi": -71, "ssid": "ap", "bssid": "FF:FF:FF:FF:FF:FF"}, "netif": { "gw": "192.168.0.1", "localIp": "192.168.0.25", "mask": "255.255.255.0", }, "mmfree": 228248, } self.device_info = None self.state = OrderedDict( ( ("temperature", 24), ("humidity", 29), ("mode", 3), ("buzzer", 1), ("child_lock", 1), ("led_brightness", 2), ("power", 1), ("no_water", 1), ("lid_opened", 1), ) ) self.start_state = self.state.copy() self.return_values = { "get_props": self._get_state, "set_start": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_brightness": lambda x: self._set_state("led_brightness", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_lock": lambda x: self._set_state("child_lock", x), "miIO.info": self._get_device_info, } super().__init__(args, kwargs) def _get_device_info(self, _): """Return dummy device info.""" return self.dummy_device_info def _get_state(self, props): """Return wanted properties.""" return list(self.state.values()) @pytest.fixture(scope="class") def airhumidifier_jsq(request): request.cls.device = DummyAirHumidifierJsq() # TODO add ability to test on a real device class Bunch: def __init__(self, **kwds): self.__dict__.update(kwds) @pytest.mark.usefixtures("airhumidifier_jsq") class TestAirHumidifierJsq(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(AirHumidifierStatus(self.device.start_state)) assert self.state().temperature == self.device.start_state["temperature"] assert self.state().humidity == self.device.start_state["humidity"] assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().buzzer == (self.device.start_state["buzzer"] == 1) assert self.state().child_lock == (self.device.start_state["child_lock"] == 1) assert self.state().led_brightness == LedBrightness( self.device.start_state["led_brightness"] ) assert self.is_on() is True assert self.state().no_water == (self.device.start_state["no_water"] == 1) assert self.state().lid_opened == (self.device.start_state["lid_opened"] == 1) def test_status_wrong_input(self): def mode(): return self.device.status().mode def led_brightness(): return self.device.status().led_brightness self.device._reset_state() self.device.state["mode"] = 10 assert mode() == OperationMode.Intelligent self.device.state["mode"] = "smth" assert mode() == OperationMode.Intelligent self.device.state["led_brightness"] = 10 assert led_brightness() == LedBrightness.Off self.device.state["led_brightness"] = "smth" assert led_brightness() == LedBrightness.Off def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Intelligent) assert mode() == OperationMode.Intelligent self.device.set_mode(OperationMode.Level1) assert mode() == OperationMode.Level1 self.device.set_mode(OperationMode.Level4) assert mode() == OperationMode.Level4 def test_set_mode_wrong_input(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Level3) assert mode() == OperationMode.Level3 with pytest.raises(AirHumidifierException) as excinfo: self.device.set_mode(Bunch(value=10)) assert str(excinfo.value) == "10 is not a valid OperationMode value" assert mode() == OperationMode.Level3 with pytest.raises(AirHumidifierException) as excinfo: self.device.set_mode(Bunch(value=-1)) assert str(excinfo.value) == "-1 is not a valid OperationMode value" assert mode() == OperationMode.Level3 with pytest.raises(AirHumidifierException) as excinfo: self.device.set_mode(Bunch(value="smth")) assert str(excinfo.value) == "smth is not a valid OperationMode value" assert mode() == OperationMode.Level3 def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off self.device.set_led_brightness(LedBrightness.Low) assert led_brightness() == LedBrightness.Low self.device.set_led_brightness(LedBrightness.High) assert led_brightness() == LedBrightness.High def test_set_led_brightness_wrong_input(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.Low) assert led_brightness() == LedBrightness.Low with pytest.raises(AirHumidifierException) as excinfo: self.device.set_led_brightness(Bunch(value=10)) assert str(excinfo.value) == "10 is not a valid LedBrightness value" assert led_brightness() == LedBrightness.Low with pytest.raises(AirHumidifierException) as excinfo: self.device.set_led_brightness(Bunch(value=-10)) assert str(excinfo.value) == "-10 is not a valid LedBrightness value" assert led_brightness() == LedBrightness.Low with pytest.raises(AirHumidifierException) as excinfo: self.device.set_led_brightness(Bunch(value="smth")) assert str(excinfo.value) == "smth is not a valid LedBrightness value" assert led_brightness() == LedBrightness.Low def test_set_led(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led(True) assert led_brightness() == LedBrightness.High self.device.set_led(False) assert led_brightness() == LedBrightness.Off def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False # if user uses wrong type for buzzer value self.device.set_buzzer(1) assert buzzer() is True self.device.set_buzzer(0) assert buzzer() is False self.device.set_buzzer("not_empty_str") assert buzzer() is True self.device.set_buzzer("on") assert buzzer() is True # all string values are considered to by True, even "off" self.device.set_buzzer("off") assert buzzer() is True self.device.set_buzzer("") assert buzzer() is False def test_status_without_temperature(self): self.device._reset_state() self.device.state["temperature"] = None assert self.state().temperature is None def test_status_without_led_brightness(self): self.device._reset_state() self.device.state["led_brightness"] = None assert self.state().led_brightness is LedBrightness.Off def test_status_without_mode(self): self.device._reset_state() self.device.state["mode"] = None assert self.state().mode is OperationMode.Intelligent def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False # if user uses wrong type for buzzer value self.device.set_child_lock(1) assert child_lock() is True self.device.set_child_lock(0) assert child_lock() is False self.device.set_child_lock("not_empty_str") assert child_lock() is True self.device.set_child_lock("on") assert child_lock() is True # all string values are considered to by True, even "off" self.device.set_child_lock("off") assert child_lock() is True self.device.set_child_lock("") assert child_lock() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/zhimi/__init__.py0000644000000000000000000000015214265350055022025 0ustar00# flake8: noqa from .airhumidifier import AirHumidifier from .airhumidifier_miot import AirHumidifierMiot ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/zhimi/airhumidifier.py0000644000000000000000000003016614265350055023117 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from miio import Device, DeviceError, DeviceException, DeviceInfo, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) MODEL_HUMIDIFIER_V1 = "zhimi.humidifier.v1" MODEL_HUMIDIFIER_CA1 = "zhimi.humidifier.ca1" MODEL_HUMIDIFIER_CB1 = "zhimi.humidifier.cb1" MODEL_HUMIDIFIER_CB2 = "zhimi.humidifier.cb2" SUPPORTED_MODELS = [ MODEL_HUMIDIFIER_V1, MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1, MODEL_HUMIDIFIER_CB2, ] AVAILABLE_PROPERTIES_COMMON = [ "power", "mode", "humidity", "buzzer", "led_b", "child_lock", "limit_hum", "use_time", "hw_version", ] AVAILABLE_PROPERTIES = { MODEL_HUMIDIFIER_V1: AVAILABLE_PROPERTIES_COMMON + ["temp_dec", "trans_level", "button_pressed"], MODEL_HUMIDIFIER_CA1: AVAILABLE_PROPERTIES_COMMON + ["temp_dec", "speed", "depth", "dry"], MODEL_HUMIDIFIER_CB1: AVAILABLE_PROPERTIES_COMMON + ["temperature", "speed", "depth", "dry"], MODEL_HUMIDIFIER_CB2: AVAILABLE_PROPERTIES_COMMON + ["temperature", "speed", "depth", "dry"], } class AirHumidifierException(DeviceException): pass class OperationMode(enum.Enum): Silent = "silent" Medium = "medium" High = "high" Auto = "auto" Strong = "strong" class LedBrightness(enum.Enum): Bright = 0 Dim = 1 Off = 2 class AirHumidifierStatus(DeviceStatus): """Container for status reports from the air humidifier.""" def __init__(self, data: Dict[str, Any], device_info: DeviceInfo) -> None: """Response of a Air Humidifier (zhimi.humidifier.v1): {'power': 'off', 'mode': 'high', 'temp_dec': 294, 'humidity': 33, 'buzzer': 'on', 'led_b': 0, 'child_lock': 'on', 'limit_hum': 40, 'trans_level': 85, 'speed': None, 'depth': None, 'dry': None, 'use_time': 941100, 'hw_version': 0, 'button_pressed': 'led'} """ self.data = data self.device_info = device_info @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if device is turned on.""" return self.power == "on" @property def mode(self) -> OperationMode: """Operation mode. Can be either silent, medium or high. """ return OperationMode(self.data["mode"]) @property def temperature(self) -> Optional[float]: """Current temperature, if available.""" if "temp_dec" in self.data and self.data["temp_dec"] is not None: return self.data["temp_dec"] / 10.0 if "temperature" in self.data and self.data["temperature"] is not None: return self.data["temperature"] return None @property def humidity(self) -> int: """Current humidity.""" return self.data["humidity"] @property def buzzer(self) -> bool: """True if buzzer is turned on.""" return self.data["buzzer"] == "on" @property def led_brightness(self) -> Optional[LedBrightness]: """LED brightness if available.""" if self.data["led_b"] is not None: return LedBrightness(self.data["led_b"]) return None @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["child_lock"] == "on" @property def target_humidity(self) -> int: """Target humidity. Can be either 30, 40, 50, 60, 70, 80 percent. """ return self.data["limit_hum"] @property def trans_level(self) -> Optional[int]: """The meaning of the property is unknown. The property is used to determine the strong mode is enabled on old firmware. """ if "trans_level" in self.data and self.data["trans_level"] is not None: return self.data["trans_level"] return None @property def strong_mode_enabled(self) -> bool: if self.firmware_version_minor == 25: if self.trans_level == 90: return True elif self.firmware_version_minor > 25 or self.firmware_version_minor == 0: return self.mode.value == "strong" return False @property def firmware_version(self) -> str: """Returns the fw_ver of miIO.info. For example 1.2.9_5033. """ if self.device_info.firmware_version is None: raise AirHumidifierException("Missing firmware information") return self.device_info.firmware_version @property def firmware_version_major(self) -> str: parts = self.firmware_version.rsplit("_", 1) return parts[0] @property def firmware_version_minor(self) -> int: parts = self.firmware_version.rsplit("_", 1) try: return int(parts[1]) except IndexError: return 0 @property def motor_speed(self) -> Optional[int]: """Current fan speed.""" if "speed" in self.data and self.data["speed"] is not None: return self.data["speed"] return None @property def depth(self) -> Optional[int]: """Return raw value of depth.""" _LOGGER.warning( "The 'depth' property is deprecated and will be removed in the future. Use 'water_level' and 'water_tank_detached' properties instead." ) if "depth" in self.data: return self.data["depth"] return None @property def water_level(self) -> Optional[int]: """Return current water level in percent. If water tank is full, depth is 120. If water tank is overfilled, depth is 125. """ depth = self.data.get("depth") if depth is None or depth > 125: return None if depth < 0: return 0 return int(min(depth / 1.2, 100)) @property def water_tank_detached(self) -> Optional[bool]: """True if the water tank is detached. If water tank is detached, depth is 127. """ if self.data.get("depth") is not None: return self.data["depth"] == 127 return None @property def dry(self) -> Optional[bool]: """Dry mode: The amount of water is not enough to continue to work for about 8 hours. Return True if dry mode is on if available. """ if "dry" in self.data and self.data["dry"] is not None: return self.data["dry"] == "on" return None @property def use_time(self) -> Optional[int]: """How long the device has been active in seconds.""" return self.data["use_time"] @property def hardware_version(self) -> Optional[str]: """The hardware version.""" return self.data["hw_version"] @property def button_pressed(self) -> Optional[str]: """Last pressed button.""" if "button_pressed" in self.data and self.data["button_pressed"] is not None: return self.data["button_pressed"] return None class AirHumidifier(Device): """Implementation of Xiaomi Mi Air Humidifier.""" _supported_models = SUPPORTED_MODELS @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "Temperature: {result.temperature} °C\n" "Humidity: {result.humidity} %\n" "LED brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Target humidity: {result.target_humidity} %\n" "Trans level: {result.trans_level}\n" "Speed: {result.motor_speed}\n" "Depth: {result.depth}\n" "Water Level: {result.water_level} %\n" "Water tank detached: {result.water_tank_detached}\n" "Dry: {result.dry}\n" "Use time: {result.use_time}\n" "Hardware version: {result.hardware_version}\n" "Button pressed: {result.button_pressed}\n", ) ) def status(self) -> AirHumidifierStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_V1] ) # A single request is limited to 16 properties. Therefore the # properties are divided into multiple requests _props_per_request = 15 # The CA1, CB1 and CB2 are limited to a single property per request if self.model in [ MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1, MODEL_HUMIDIFIER_CB2, ]: _props_per_request = 1 values = self.get_properties(properties, max_properties=_props_per_request) return AirHumidifierStatus( defaultdict(lambda: None, zip(properties, values)), self.info() ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set mode.""" try: return self.send("set_mode", [mode.value]) except DeviceError as error: # {'code': -6011, 'message': 'device_poweroff'} if error.code == -6011: self.on() return self.send("set_mode", [mode.value]) raise @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" if self.model in [MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1]: return self.send("set_led_b", [str(brightness.value)]) return self.send("set_led_b", [brightness.value]) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Turn led on/off.""" if led: return self.set_led_brightness(LedBrightness.Bright) else: return self.set_led_brightness(LedBrightness.Off) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" if buzzer: return self.send("set_buzzer", ["on"]) else: return self.send("set_buzzer", ["off"]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" if lock: return self.send("set_child_lock", ["on"]) else: return self.send("set_child_lock", ["off"]) @command( click.argument("humidity", type=int), default_output=format_output("Setting target humidity to {humidity}"), ) def set_target_humidity(self, humidity: int): """Set the target humidity.""" if humidity not in [30, 40, 50, 60, 70, 80]: raise AirHumidifierException("Invalid target humidity: %s" % humidity) return self.send("set_limit_hum", [humidity]) @command( click.argument("dry", type=bool), default_output=format_output( lambda dry: "Turning on dry mode" if dry else "Turning off dry mode" ), ) def set_dry(self, dry: bool): """Set dry mode on/off.""" if dry: return self.send("set_dry", ["on"]) else: return self.send("set_dry", ["off"]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/zhimi/airhumidifier_miot.py0000644000000000000000000003072414265350055024147 0ustar00import enum import logging from typing import Any, Dict, Optional import click from miio import DeviceException, DeviceStatus, MiotDevice from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) SMARTMI_EVAPORATIVE_HUMIDIFIER_2 = "zhimi.humidifier.ca4" _MAPPINGS = { SMARTMI_EVAPORATIVE_HUMIDIFIER_2: { # Source http://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:humidifier:0000A00E:zhimi-ca4:2 # Air Humidifier (siid=2) "power": {"siid": 2, "piid": 1}, # bool "fault": {"siid": 2, "piid": 2}, # [0, 15] step 1 "mode": {"siid": 2, "piid": 5}, # 0 - Auto, 1 - lvl1, 2 - lvl2, 3 - lvl3 "target_humidity": {"siid": 2, "piid": 6}, # [30, 80] step 1 "water_level": {"siid": 2, "piid": 7}, # [0, 128] step 1 "dry": {"siid": 2, "piid": 8}, # bool "use_time": {"siid": 2, "piid": 9}, # [0, 2147483600], step 1 "button_pressed": {"siid": 2, "piid": 10}, # 0 - none, 1 - led, 2 - power "speed_level": {"siid": 2, "piid": 11}, # [200, 2000], step 10 # Environment (siid=3) "temperature": {"siid": 3, "piid": 7}, # [-40, 125] step 0.1 "fahrenheit": {"siid": 3, "piid": 8}, # [-40, 257] step 0.1 "humidity": {"siid": 3, "piid": 9}, # [0, 100] step 1 # Alarm (siid=4) "buzzer": {"siid": 4, "piid": 1}, # Indicator Light (siid=5) "led_brightness": {"siid": 5, "piid": 2}, # 0 - Off, 1 - Dim, 2 - Brightest # Physical Control Locked (siid=6) "child_lock": {"siid": 6, "piid": 1}, # bool # Other (siid=7) "actual_speed": {"siid": 7, "piid": 1}, # [0, 2000] step 1 "power_time": {"siid": 7, "piid": 3}, # [0, 4294967295] step 1 "clean_mode": {"siid": 7, "piid": 5}, # bool } } class AirHumidifierMiotException(DeviceException): pass class OperationMode(enum.Enum): Auto = 0 Low = 1 Mid = 2 High = 3 class LedBrightness(enum.Enum): Off = 0 Dim = 1 Bright = 2 class PressedButton(enum.Enum): No = 0 Led = 1 Power = 2 class AirHumidifierMiotStatus(DeviceStatus): """Container for status reports from the air humidifier. Xiaomi Smartmi Evaporation Air Humidifier 2 (zhimi.humidifier.ca4) respone (MIoT format) [ {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, {'did': 'fault', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0}, {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 0}, {'did': 'target_humidity', 'siid': 2, 'piid': 6, 'code': 0, 'value': 50}, {'did': 'water_level', 'siid': 2, 'piid': 7, 'code': 0, 'value': 127}, {'did': 'dry', 'siid': 2, 'piid': 8, 'code': 0, 'value': False}, {'did': 'use_time', 'siid': 2, 'piid': 9, 'code': 0, 'value': 5140816}, {'did': 'button_pressed', 'siid': 2, 'piid': 10, 'code': 0, 'value': 2}, {'did': 'speed_level', 'siid': 2, 'piid': 11, 'code': 0, 'value': 790}, {'did': 'temperature', 'siid': 3, 'piid': 7, 'code': 0, 'value': 22.7}, {'did': 'fahrenheit', 'siid': 3, 'piid': 8, 'code': 0, 'value': 72.8}, {'did': 'humidity', 'siid': 3, 'piid': 9, 'code': 0, 'value': 39}, {'did': 'buzzer', 'siid': 4, 'piid': 1, 'code': 0, 'value': False}, {'did': 'led_brightness', 'siid': 5, 'piid': 2, 'code': 0, 'value': 2}, {'did': 'child_lock', 'siid': 6, 'piid': 1, 'code': 0, 'value': False}, {'did': 'actual_speed', 'siid': 7, 'piid': 1, 'code': 0, 'value': 0}, {'did': 'power_time', 'siid': 7, 'piid': 3, 'code': 0, 'value': 18520}, {'did': 'clean_mode', 'siid': 7, 'piid': 5, 'code': 0, 'value': True} ] """ def __init__(self, data: Dict[str, Any]) -> None: self.data = data # Air Humidifier @property def is_on(self) -> bool: """Return True if device is on.""" return self.data["power"] @property def power(self) -> str: """Return power state.""" return "on" if self.is_on else "off" @property def error(self) -> int: """Return error state.""" return self.data["fault"] @property def mode(self) -> OperationMode: """Return current operation mode.""" try: mode = OperationMode(self.data["mode"]) except ValueError as e: _LOGGER.exception("Cannot parse mode: %s", e) return OperationMode.Auto return mode @property def target_humidity(self) -> int: """Return target humidity.""" return self.data["target_humidity"] @property def water_level(self) -> Optional[int]: """Return current water level in percent. If water tank is full, raw water_level value is 120. If water tank is overfilled, raw water_level value is 125. """ water_level = self.data["water_level"] if water_level > 125: return None if water_level < 0: return 0 return int(min(water_level / 1.2, 100)) @property def water_tank_detached(self) -> bool: """True if the water tank is detached. If water tank is detached, water_level is 127. """ return self.data["water_level"] == 127 @property def dry(self) -> Optional[bool]: """Return True if dry mode is on.""" if self.data["dry"] is not None: return self.data["dry"] return None @property def use_time(self) -> int: """Return how long the device has been active in seconds.""" return self.data["use_time"] @property def button_pressed(self) -> PressedButton: """Return last pressed button.""" try: button = PressedButton(self.data["button_pressed"]) except ValueError as e: _LOGGER.exception("Cannot parse button_pressed: %s", e) return PressedButton.No return button @property def motor_speed(self) -> int: """Return target speed of the motor.""" return self.data["speed_level"] # Environment @property def humidity(self) -> int: """Return current humidity.""" return self.data["humidity"] @property def temperature(self) -> Optional[float]: """Return current temperature, if available.""" if self.data["temperature"] is not None: return round(self.data["temperature"], 1) return None @property def fahrenheit(self) -> Optional[float]: """Return current temperature in fahrenheit, if available.""" if self.data["fahrenheit"] is not None: return round(self.data["fahrenheit"], 1) return None # Alarm @property def buzzer(self) -> Optional[bool]: """Return True if buzzer is on.""" if self.data["buzzer"] is not None: return self.data["buzzer"] return None # Indicator Light @property def led_brightness(self) -> Optional[LedBrightness]: """Return brightness of the LED.""" if self.data["led_brightness"] is not None: try: return LedBrightness(self.data["led_brightness"]) except ValueError as e: _LOGGER.exception("Cannot parse led_brightness: %s", e) return None return None # Physical Control Locked @property def child_lock(self) -> bool: """Return True if child lock is on.""" return self.data["child_lock"] # Other @property def actual_speed(self) -> int: """Return real speed of the motor.""" return self.data["actual_speed"] @property def power_time(self) -> int: """Return how long the device has been powered in seconds.""" return self.data["power_time"] @property def clean_mode(self) -> bool: """Return True if clean mode is active.""" return self.data["clean_mode"] class AirHumidifierMiot(MiotDevice): """Main class representing the air humidifier which uses MIoT protocol.""" _mappings = _MAPPINGS @command( default_output=format_output( "", "Power: {result.power}\n" "Error: {result.error}\n" "Target Humidity: {result.target_humidity} %\n" "Humidity: {result.humidity} %\n" "Temperature: {result.temperature} °C\n" "Temperature: {result.fahrenheit} °F\n" "Water Level: {result.water_level} %\n" "Water tank detached: {result.water_tank_detached}\n" "Mode: {result.mode}\n" "LED brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" "Dry mode: {result.dry}\n" "Button pressed {result.button_pressed}\n" "Target motor speed: {result.motor_speed} rpm\n" "Actual motor speed: {result.actual_speed} rpm\n" "Use time: {result.use_time} s\n" "Power time: {result.power_time} s\n" "Clean mode: {result.clean_mode}\n", ) ) def status(self) -> AirHumidifierMiotStatus: """Retrieve properties.""" return AirHumidifierMiotStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.set_property("power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.set_property("power", False) @command( click.argument("rpm", type=int), default_output=format_output("Setting motor speed '{rpm}' rpm"), ) def set_speed(self, rpm: int): """Set motor speed.""" if rpm < 200 or rpm > 2000 or rpm % 10 != 0: raise AirHumidifierMiotException( "Invalid motor speed: %s. Must be between 200 and 2000 and divisible by 10" % rpm ) return self.set_property("speed_level", rpm) @command( click.argument("humidity", type=int), default_output=format_output("Setting target humidity {humidity}%"), ) def set_target_humidity(self, humidity: int): """Set target humidity.""" if humidity < 30 or humidity > 80: raise AirHumidifierMiotException( "Invalid target humidity: %s. Must be between 30 and 80" % humidity ) return self.set_property("target_humidity", humidity) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): """Set working mode.""" return self.set_property("mode", mode.value) @command( click.argument("brightness", type=EnumType(LedBrightness)), default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" return self.set_property("led_brightness", brightness.value) @command( click.argument("buzzer", type=bool), default_output=format_output( lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" ), ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" return self.set_property("buzzer", buzzer) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.set_property("child_lock", lock) @command( click.argument("dry", type=bool), default_output=format_output( lambda dry: "Turning on dry mode" if dry else "Turning off dry mode" ), ) def set_dry(self, dry: bool): """Set dry mode on/off.""" return self.set_property("dry", dry) @command( click.argument("clean_mode", type=bool), default_output=format_output( lambda clean_mode: "Turning on clean mode" if clean_mode else "Turning off clean mode" ), ) def set_clean_mode(self, clean_mode: bool): """Set clean mode on/off.""" return self.set_property("clean_mode", clean_mode) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/zhimi/tests/__init__.py0000644000000000000000000000000014265350055023157 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/zhimi/tests/test_airhumidifier.py0000644000000000000000000002224714265350055025321 0ustar00import pytest from miio import DeviceException, DeviceInfo from miio.tests.dummies import DummyDevice from .. import AirHumidifier from ..airhumidifier import ( MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1, MODEL_HUMIDIFIER_V1, AirHumidifierException, LedBrightness, OperationMode, ) class DummyAirHumidifier(DummyDevice, AirHumidifier): def __init__(self, model, *args, **kwargs): self._model = model self.dummy_device_info = { "token": "68ffffffffffffffffffffffffffffff", "otu_stat": [101, 74, 5343, 0, 5327, 407], "mmfree": 228248, "netif": { "gw": "192.168.0.1", "localIp": "192.168.0.25", "mask": "255.255.255.0", }, "ott_stat": [0, 0, 0, 0], "model": "zhimi.humidifier.v1", "cfg_time": 0, "life": 575661, "ap": {"rssi": -35, "ssid": "ap", "bssid": "FF:FF:FF:FF:FF:FF"}, "wifi_fw_ver": "SD878x-14.76.36.p84-702.1.0-WM", "hw_ver": "MW300", "ot": "otu", "mac": "78:11:FF:FF:FF:FF", } # Special version handling for CA1 self.dummy_device_info["fw_ver"] = ( "1.6.6" if self._model == MODEL_HUMIDIFIER_CA1 else "1.2.9_5033" ) self.state = { "power": "on", "mode": "medium", "temp_dec": 294, "humidity": 33, "buzzer": "off", "led_b": 2, "child_lock": "on", "limit_hum": 40, "use_time": 941100, "hw_version": 0, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_led_b": lambda x: self._set_state("led_b", [int(x[0])]), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_limit_hum": lambda x: self._set_state("limit_hum", x), "set_dry": lambda x: self._set_state("dry", x), "miIO.info": self._get_device_info, } if model == MODEL_HUMIDIFIER_V1: # V1 has some extra properties that are not currently tested self.state["trans_level"] = 85 self.state["button_pressed"] = "led" # V1 doesn't support try, so return an error def raise_error(): raise DeviceException("v1 does not support set_dry") self.return_values["set_dry"] = lambda x: raise_error() elif model in [MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1]: # Additional attributes of the CA1 & CB1 extra_states = { "speed": 100, "depth": 60, "dry": "off", } self.state.update(extra_states) # CB1 reports temperature differently if self._model == MODEL_HUMIDIFIER_CB1: self.state["temperature"] = self.state["temp_dec"] / 10.0 del self.state["temp_dec"] super().__init__(args, kwargs) def _get_device_info(self, _): """Return dummy device info.""" return self.dummy_device_info @pytest.fixture( params=[MODEL_HUMIDIFIER_V1, MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1] ) def dev(request): yield DummyAirHumidifier(model=request.param) # TODO add ability to test on a real device def test_on(dev): dev.off() # ensure off assert dev.status().is_on is False dev.on() assert dev.status().is_on is True def test_off(dev): dev.on() # ensure on assert dev.status().is_on is True dev.off() assert dev.status().is_on is False def test_set_mode(dev): def mode(): return dev.status().mode dev.set_mode(OperationMode.Silent) assert mode() == OperationMode.Silent dev.set_mode(OperationMode.Medium) assert mode() == OperationMode.Medium dev.set_mode(OperationMode.High) assert mode() == OperationMode.High def test_set_led(dev): def led_brightness(): return dev.status().led_brightness dev.set_led(True) assert led_brightness() == LedBrightness.Bright dev.set_led(False) assert led_brightness() == LedBrightness.Off def test_set_buzzer(dev): def buzzer(): return dev.status().buzzer dev.set_buzzer(True) assert buzzer() is True dev.set_buzzer(False) assert buzzer() is False def test_status_without_temperature(dev): key = "temperature" if dev.model == MODEL_HUMIDIFIER_CB1 else "temp_dec" dev.state[key] = None assert dev.status().temperature is None def test_status_without_led_brightness(dev): dev.state["led_b"] = None assert dev.status().led_brightness is None def test_set_target_humidity(dev): def target_humidity(): return dev.status().target_humidity dev.set_target_humidity(30) assert target_humidity() == 30 dev.set_target_humidity(60) assert target_humidity() == 60 dev.set_target_humidity(80) assert target_humidity() == 80 with pytest.raises(AirHumidifierException): dev.set_target_humidity(-1) with pytest.raises(AirHumidifierException): dev.set_target_humidity(20) with pytest.raises(AirHumidifierException): dev.set_target_humidity(90) with pytest.raises(AirHumidifierException): dev.set_target_humidity(110) def test_set_child_lock(dev): def child_lock(): return dev.status().child_lock dev.set_child_lock(True) assert child_lock() is True dev.set_child_lock(False) assert child_lock() is False def test_status(dev): assert dev.status().is_on is True assert dev.status().humidity == dev.start_state["humidity"] assert dev.status().mode == OperationMode(dev.start_state["mode"]) assert dev.status().led_brightness == LedBrightness(dev.start_state["led_b"]) assert dev.status().buzzer == (dev.start_state["buzzer"] == "on") assert dev.status().child_lock == (dev.start_state["child_lock"] == "on") assert dev.status().target_humidity == dev.start_state["limit_hum"] if dev.model == MODEL_HUMIDIFIER_CB1: assert dev.status().temperature == dev.start_state["temperature"] else: assert dev.status().temperature == dev.start_state["temp_dec"] / 10.0 if dev.model == MODEL_HUMIDIFIER_V1: # Extra props only on v1 assert dev.status().trans_level == dev.start_state["trans_level"] assert dev.status().button_pressed == dev.start_state["button_pressed"] assert dev.status().motor_speed is None assert dev.status().depth is None assert dev.status().dry is None assert dev.status().water_level is None assert dev.status().water_tank_detached is None if dev.model in [MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_CB1]: assert dev.status().motor_speed == dev.start_state["speed"] assert dev.status().depth == dev.start_state["depth"] assert dev.status().water_level == int(dev.start_state["depth"] / 1.2) assert dev.status().water_tank_detached == (dev.start_state["depth"] == 127) assert dev.status().dry == (dev.start_state["dry"] == "on") # Extra props only on v1 should be none now assert dev.status().trans_level is None assert dev.status().button_pressed is None assert dev.status().use_time == dev.start_state["use_time"] assert dev.status().hardware_version == dev.start_state["hw_version"] device_info = DeviceInfo(dev.dummy_device_info) assert dev.status().firmware_version == device_info.firmware_version assert ( dev.status().firmware_version_major == device_info.firmware_version.rsplit("_", 1)[0] ) try: version_minor = int(device_info.firmware_version.rsplit("_", 1)[1]) except IndexError: version_minor = 0 assert dev.status().firmware_version_minor == version_minor assert dev.status().strong_mode_enabled is False def test_set_led_brightness(dev): def led_brightness(): return dev.status().led_brightness dev.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright dev.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim dev.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_dry(dev): def dry(): return dev.status().dry # set_dry is not supported on V1 if dev.model == MODEL_HUMIDIFIER_V1: assert dry() is None with pytest.raises(DeviceException): dev.set_dry(True) return dev.set_dry(True) assert dry() is True dev.set_dry(False) assert dry() is False @pytest.mark.parametrize( "depth,expected", [(-1, 0), (0, 0), (60, 50), (120, 100), (125, 100), (127, None)] ) def test_water_level(dev, depth, expected): """Test the water level conversions.""" if dev.model == MODEL_HUMIDIFIER_V1: # Water level is always none for v1 assert dev.status().water_level is None return dev.state["depth"] = depth assert dev.status().water_level == expected ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/humidifier/zhimi/tests/test_airhumidifier_miot.py0000644000000000000000000001316314265350055026346 0ustar00import pytest from miio.tests.dummies import DummyMiotDevice from .. import AirHumidifierMiot from ..airhumidifier_miot import ( AirHumidifierMiotException, LedBrightness, OperationMode, PressedButton, ) _INITIAL_STATE = { "power": True, "fault": 0, "mode": 0, "target_humidity": 60, "water_level": 32, "dry": True, "use_time": 2426773, "button_pressed": 1, "speed_level": 810, "temperature": 21.6, "fahrenheit": 70.9, "humidity": 62, "buzzer": False, "led_brightness": 1, "child_lock": False, "motor_speed": 354, "actual_speed": 820, "power_time": 4272468, "clean_mode": False, } class DummyAirHumidifierMiot(DummyMiotDevice, AirHumidifierMiot): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_speed": lambda x: self._set_state("speed_level", x), "set_target_humidity": lambda x: self._set_state("target_humidity", x), "set_mode": lambda x: self._set_state("mode", x), "set_led_brightness": lambda x: self._set_state("led_brightness", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_dry": lambda x: self._set_state("dry", x), "set_clean_mode": lambda x: self._set_state("clean_mode", x), } super().__init__(*args, **kwargs) @pytest.fixture() def dev(request): yield DummyAirHumidifierMiot() def test_on(dev): dev.off() # ensure off assert dev.status().is_on is False dev.on() assert dev.status().is_on is True def test_off(dev): dev.on() # ensure on assert dev.status().is_on is True dev.off() assert dev.status().is_on is False def test_status(dev): status = dev.status() assert status.is_on is _INITIAL_STATE["power"] assert status.error == _INITIAL_STATE["fault"] assert status.mode == OperationMode(_INITIAL_STATE["mode"]) assert status.target_humidity == _INITIAL_STATE["target_humidity"] assert status.water_level == int(_INITIAL_STATE["water_level"] / 1.2) assert status.water_tank_detached == (_INITIAL_STATE["water_level"] == 127) assert status.dry == _INITIAL_STATE["dry"] assert status.use_time == _INITIAL_STATE["use_time"] assert status.button_pressed == PressedButton(_INITIAL_STATE["button_pressed"]) assert status.motor_speed == _INITIAL_STATE["speed_level"] assert status.temperature == _INITIAL_STATE["temperature"] assert status.fahrenheit == _INITIAL_STATE["fahrenheit"] assert status.humidity == _INITIAL_STATE["humidity"] assert status.buzzer == _INITIAL_STATE["buzzer"] assert status.led_brightness == LedBrightness(_INITIAL_STATE["led_brightness"]) assert status.child_lock == _INITIAL_STATE["child_lock"] assert status.actual_speed == _INITIAL_STATE["actual_speed"] assert status.power_time == _INITIAL_STATE["power_time"] def test_set_speed(dev): def speed_level(): return dev.status().motor_speed dev.set_speed(200) assert speed_level() == 200 dev.set_speed(2000) assert speed_level() == 2000 with pytest.raises(AirHumidifierMiotException): dev.set_speed(199) with pytest.raises(AirHumidifierMiotException): dev.set_speed(2001) def test_set_target_humidity(dev): def target_humidity(): return dev.status().target_humidity dev.set_target_humidity(30) assert target_humidity() == 30 dev.set_target_humidity(80) assert target_humidity() == 80 with pytest.raises(AirHumidifierMiotException): dev.set_target_humidity(29) with pytest.raises(AirHumidifierMiotException): dev.set_target_humidity(81) def test_set_mode(dev): def mode(): return dev.status().mode dev.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto dev.set_mode(OperationMode.Low) assert mode() == OperationMode.Low dev.set_mode(OperationMode.Mid) assert mode() == OperationMode.Mid dev.set_mode(OperationMode.High) assert mode() == OperationMode.High def test_set_led_brightness(dev): def led_brightness(): return dev.status().led_brightness dev.set_led_brightness(LedBrightness.Bright) assert led_brightness() == LedBrightness.Bright dev.set_led_brightness(LedBrightness.Dim) assert led_brightness() == LedBrightness.Dim dev.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_buzzer(dev): def buzzer(): return dev.status().buzzer dev.set_buzzer(True) assert buzzer() is True dev.set_buzzer(False) assert buzzer() is False def test_set_child_lock(dev): def child_lock(): return dev.status().child_lock dev.set_child_lock(True) assert child_lock() is True dev.set_child_lock(False) assert child_lock() is False def test_set_dry(dev): def dry(): return dev.status().dry dev.set_dry(True) assert dry() is True dev.set_dry(False) assert dry() is False def test_set_clean_mode(dev): def clean_mode(): return dev.status().clean_mode dev.set_clean_mode(True) assert clean_mode() is True dev.set_clean_mode(False) assert clean_mode() is False @pytest.mark.parametrize( "depth,expected", [(-1, 0), (0, 0), (60, 50), (120, 100), (125, 100), (127, None)] ) def test_water_level(dev, depth, expected): dev.set_property("water_level", depth) assert dev.status().water_level == expected ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/__init__.py0000644000000000000000000000007614265350055017674 0ustar00# flake8: noqa from .philips import * from .yeelight import * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/__init__.py0000644000000000000000000000034414265350055021342 0ustar00# flake8: noqa from .ceil import Ceil from .philips_bulb import PhilipsBulb, PhilipsWhiteBulb from .philips_eyecare import PhilipsEyecare from .philips_moonlight import PhilipsMoonlight from .philips_rwread import PhilipsRwread ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/ceil.py0000644000000000000000000001377614265350055020534 0ustar00import logging from collections import defaultdict from typing import Any, Dict import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import command, format_output _LOGGER = logging.getLogger(__name__) SUPPORTED_MODELS = ["philips.light.ceiling", "philips.light.zyceiling"] class CeilException(DeviceException): pass class CeilStatus(DeviceStatus): """Container for status reports from Xiaomi Philips LED Ceiling Lamp.""" def __init__(self, data: Dict[str, Any]) -> None: # {'power': 'off', 'bright': 0, 'snm': 4, 'dv': 0, # 'cctsw': [[0, 3], [0, 2], [0, 1]], 'bl': 1, # 'mb': 1, 'ac': 1, 'mssw': 1, 'cct': 99} # NOTE: Only 8 properties can be requested at the same time self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property def brightness(self) -> int: """Current brightness.""" return self.data["bright"] @property def scene(self) -> int: """Current fixed scene (brightness & colortemp).""" return self.data["snm"] @property def delay_off_countdown(self) -> int: """Countdown until turning off in seconds.""" return self.data["dv"] @property def color_temperature(self) -> int: """Current color temperature.""" return self.data["cct"] @property def smart_night_light(self) -> bool: """Smart night mode state.""" return self.data["bl"] == 1 @property def automatic_color_temperature(self) -> bool: """Automatic color temperature state.""" return self.data["ac"] == 1 class Ceil(Device): """Main class representing Xiaomi Philips LED Ceiling Lamp.""" # TODO: - Auto On/Off Not Supported # - Adjust Scenes with Wall Switch Not Supported _supported_models = SUPPORTED_MODELS @command( default_output=format_output( "", "Power: {result.power}\n" "Brightness: {result.brightness}\n" "Color temperature: {result.color_temperature}\n" "Scene: {result.scene}\n" "Delayed turn off: {result.delay_off_countdown}\n" "Smart night light: {result.smart_night_light}\n" "Automatic color temperature: {result.automatic_color_temperature}\n", ) ) def status(self) -> CeilStatus: """Retrieve properties.""" properties = ["power", "bright", "cct", "snm", "dv", "bl", "ac"] values = self.get_properties(properties) return CeilStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering on")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("level", type=int), default_output=format_output("Setting brightness to {level}"), ) def set_brightness(self, level: int): """Set brightness level.""" if level < 1 or level > 100: raise CeilException("Invalid brightness: %s" % level) return self.send("set_bright", [level]) @command( click.argument("level", type=int), default_output=format_output("Setting color temperature to {level}"), ) def set_color_temperature(self, level: int): """Set Correlated Color Temperature.""" if level < 1 or level > 100: raise CeilException("Invalid color temperature: %s" % level) return self.send("set_cct", [level]) @command( click.argument("brightness", type=int), click.argument("cct", type=int), default_output=format_output( "Setting brightness to {brightness} and color temperature to {cct}" ), ) def set_brightness_and_color_temperature(self, brightness: int, cct: int): """Set brightness level and the correlated color temperature.""" if brightness < 1 or brightness > 100: raise CeilException("Invalid brightness: %s" % brightness) if cct < 1 or cct > 100: raise CeilException("Invalid color temperature: %s" % cct) return self.send("set_bricct", [brightness, cct]) @command( click.argument("seconds", type=int), default_output=format_output("Setting delayed turn off to {seconds} seconds"), ) def delay_off(self, seconds: int): """Turn off delay in seconds.""" if seconds < 1: raise CeilException("Invalid value for a delayed turn off: %s" % seconds) return self.send("delay_off", [seconds]) @command( click.argument("number", type=int), default_output=format_output("Setting fixed scene to {number}"), ) def set_scene(self, number: int): """Set a fixed scene (1-4).""" if number < 1 or number > 4: raise CeilException("Invalid fixed scene number: %s" % number) return self.send("apply_fixed_scene", [number]) @command(default_output=format_output("Turning on smart night light")) def smart_night_light_on(self): """Smart Night Light On.""" return self.send("enable_bl", [1]) @command(default_output=format_output("Turning off smart night light")) def smart_night_light_off(self): """Smart Night Light off.""" return self.send("enable_bl", [0]) @command(default_output=format_output("Turning on automatic color temperature")) def automatic_color_temperature_on(self): """Automatic color temperature on.""" return self.send("enable_ac", [1]) @command(default_output=format_output("Turning off automatic color temperature")) def automatic_color_temperature_off(self): """Automatic color temperature off.""" return self.send("enable_ac", [0]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/philips_bulb.py0000644000000000000000000001301114265350055022252 0ustar00import logging from collections import defaultdict from typing import Any, Dict, Optional import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import command, format_output _LOGGER = logging.getLogger(__name__) MODEL_PHILIPS_LIGHT_BULB = "philips.light.bulb" MODEL_PHILIPS_LIGHT_HBULB = "philips.light.hbulb" MODEL_PHILIPS_ZHIRUI_DOWNLIGHT = "philips.light.downlight" MODEL_PHILIPS_CANDLE = "philips.light.candle" MODEL_PHILIPS_CANDLE2 = "philips.light.candle2" AVAILABLE_PROPERTIES_COMMON = ["power", "dv"] AVAILABLE_PROPERTIES_COLORTEMP = AVAILABLE_PROPERTIES_COMMON + ["bright", "cct", "snm"] AVAILABLE_PROPERTIES = { MODEL_PHILIPS_LIGHT_HBULB: AVAILABLE_PROPERTIES_COMMON + ["bri"], MODEL_PHILIPS_LIGHT_BULB: AVAILABLE_PROPERTIES_COLORTEMP, MODEL_PHILIPS_ZHIRUI_DOWNLIGHT: AVAILABLE_PROPERTIES_COLORTEMP, MODEL_PHILIPS_CANDLE: AVAILABLE_PROPERTIES_COLORTEMP, MODEL_PHILIPS_CANDLE2: AVAILABLE_PROPERTIES_COLORTEMP, } class PhilipsBulbException(DeviceException): pass class PhilipsBulbStatus(DeviceStatus): """Container for status reports from Xiaomi Philips LED Ceiling Lamp.""" def __init__(self, data: Dict[str, Any]) -> None: # {'power': 'on', 'bright': 85, 'cct': 9, 'snm': 0, 'dv': 0} self.data = data @property def power(self) -> str: return self.data["power"] @property def is_on(self) -> bool: return self.power == "on" @property def brightness(self) -> Optional[int]: if "bright" in self.data: return self.data["bright"] if "bri" in self.data: return self.data["bri"] return None @property def color_temperature(self) -> Optional[int]: if "cct" in self.data: return self.data["cct"] return None @property def scene(self) -> Optional[int]: if "snm" in self.data: return self.data["snm"] return None @property def delay_off_countdown(self) -> int: return self.data["dv"] class PhilipsWhiteBulb(Device): """Main class representing Xiaomi Philips White LED Ball Lamp.""" _supported_models = [MODEL_PHILIPS_LIGHT_HBULB] @command( default_output=format_output( "", "Power: {result.power}\n" "Brightness: {result.brightness}\n" "Delayed turn off: {result.delay_off_countdown}\n" "Color temperature: {result.color_temperature}\n" "Scene: {result.scene}\n", ) ) def status(self) -> PhilipsBulbStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_PHILIPS_LIGHT_BULB] ) values = self.get_properties(properties) return PhilipsBulbStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("level", type=int), default_output=format_output("Setting brightness to {level}"), ) def set_brightness(self, level: int): """Set brightness level.""" if level < 1 or level > 100: raise PhilipsBulbException("Invalid brightness: %s" % level) return self.send("set_bright", [level]) @command( click.argument("seconds", type=int), default_output=format_output("Setting delayed turn off to {seconds} seconds"), ) def delay_off(self, seconds: int): """Set delay off seconds.""" if seconds < 1: raise PhilipsBulbException( "Invalid value for a delayed turn off: %s" % seconds ) return self.send("delay_off", [seconds]) class PhilipsBulb(PhilipsWhiteBulb): """Support for philips bulbs that support color temperature and scenes.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( click.argument("level", type=int), default_output=format_output("Setting color temperature to {level}"), ) def set_color_temperature(self, level: int): """Set Correlated Color Temperature.""" if level < 1 or level > 100: raise PhilipsBulbException("Invalid color temperature: %s" % level) return self.send("set_cct", [level]) @command( click.argument("brightness", type=int), click.argument("cct", type=int), default_output=format_output( "Setting brightness to {brightness} and color temperature to {cct}" ), ) def set_brightness_and_color_temperature(self, brightness: int, cct: int): """Set brightness level and the correlated color temperature.""" if brightness < 1 or brightness > 100: raise PhilipsBulbException("Invalid brightness: %s" % brightness) if cct < 1 or cct > 100: raise PhilipsBulbException("Invalid color temperature: %s" % cct) return self.send("set_bricct", [brightness, cct]) @command( click.argument("number", type=int), default_output=format_output("Setting fixed scene to {number}"), ) def set_scene(self, number: int): """Set scene number.""" if number < 1 or number > 4: raise PhilipsBulbException("Invalid fixed scene number: %s" % number) return self.send("apply_fixed_scene", [number]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/philips_eyecare.py0000644000000000000000000001525014265350055022752 0ustar00import logging from collections import defaultdict from typing import Any, Dict import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import command, format_output _LOGGER = logging.getLogger(__name__) class PhilipsEyecareException(DeviceException): pass class PhilipsEyecareStatus(DeviceStatus): """Container for status reports from Xiaomi Philips Eyecare Smart Lamp 2.""" def __init__(self, data: Dict[str, Any]) -> None: # ['power': 'off', 'bright': 5, 'notifystatus': 'off', # 'ambstatus': 'off', 'ambvalue': 41, 'eyecare': 'on', # 'scene_num': 3, 'bls': 'on', 'dvalue': 0] self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property def brightness(self) -> int: """Current brightness of the primary light.""" return self.data["bright"] @property def reminder(self) -> bool: """Indicates the eye fatigue notification is enabled or not.""" return self.data["notifystatus"] == "on" @property def ambient(self) -> bool: """True if the ambient light (second light source) is on.""" return self.data["ambstatus"] == "on" @property def ambient_brightness(self) -> int: """Brightness of the ambient light.""" return self.data["ambvalue"] @property def eyecare(self) -> bool: """True if the eyecare mode is on.""" return self.data["eyecare"] == "on" @property def scene(self) -> int: """Current fixed scene.""" return self.data["scene_num"] @property def smart_night_light(self) -> bool: """True if the smart night light mode is on.""" return self.data["bls"] == "on" @property def delay_off_countdown(self) -> int: """Countdown until turning off in minutes.""" return self.data["dvalue"] class PhilipsEyecare(Device): """Main class representing Xiaomi Philips Eyecare Smart Lamp 2.""" _supported_models = ["philips.light.sread1", "philips.light.sread2"] @command( default_output=format_output( "", "Power: {result.power}\n" "Brightness: {result.brightness}\n" "Ambient light: {result.ambient}\n" "Ambient light brightness: {result.ambient_brightness}\n" "Eyecare mode: {result.eyecare}\n" "Scene: {result.scene}\n" "Eye fatigue reminder: {result.reminder}\n" "Smart night light: {result.smart_night_light}\n" "Delayed turn off: {result.delay_off_countdown}\n", ) ) def status(self) -> PhilipsEyecareStatus: """Retrieve properties.""" properties = [ "power", "bright", "notifystatus", "ambstatus", "ambvalue", "eyecare", "scene_num", "bls", "dvalue", ] values = self.get_properties(properties) return PhilipsEyecareStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command(default_output=format_output("Turning on eyecare mode")) def eyecare_on(self): """Turn the eyecare mode on.""" return self.send("set_eyecare", ["on"]) @command(default_output=format_output("Turning off eyecare mode")) def eyecare_off(self): """Turn the eyecare mode off.""" return self.send("set_eyecare", ["off"]) @command( click.argument("level", type=int), default_output=format_output("Setting brightness to {level}"), ) def set_brightness(self, level: int): """Set brightness level of the primary light.""" if level < 1 or level > 100: raise PhilipsEyecareException("Invalid brightness: %s" % level) return self.send("set_bright", [level]) @command( click.argument("number", type=int), default_output=format_output("Setting fixed scene to {number}"), ) def set_scene(self, number: int): """Set one of the fixed eyecare user scenes.""" if number < 1 or number > 4: raise PhilipsEyecareException("Invalid fixed scene number: %s" % number) return self.send("set_user_scene", [number]) @command( click.argument("minutes", type=int), default_output=format_output("Setting delayed turn off to {minutes} minutes"), ) def delay_off(self, minutes: int): """Set delay off minutes.""" if minutes < 0: raise PhilipsEyecareException( "Invalid value for a delayed turn off: %s" % minutes ) return self.send("delay_off", [minutes]) @command(default_output=format_output("Turning on smart night light")) def smart_night_light_on(self): """Turn the smart night light mode on.""" return self.send("enable_bl", ["on"]) @command(default_output=format_output("Turning off smart night light")) def smart_night_light_off(self): """Turn the smart night light mode off.""" return self.send("enable_bl", ["off"]) @command(default_output=format_output("Turning on eye fatigue reminder")) def reminder_on(self): """Enable the eye fatigue reminder / notification.""" return self.send("set_notifyuser", ["on"]) @command(default_output=format_output("Turning off eye fatigue reminder")) def reminder_off(self): """Disable the eye fatigue reminder / notification.""" return self.send("set_notifyuser", ["off"]) @command(default_output=format_output("Turning on ambient light")) def ambient_on(self): """Turn the ambient light on.""" return self.send("enable_amb", ["on"]) @command(default_output=format_output("Turning off ambient light")) def ambient_off(self): """Turn the ambient light off.""" return self.send("enable_amb", ["off"]) @command( click.argument("level", type=int), default_output=format_output("Setting brightness to {level}"), ) def set_ambient_brightness(self, level: int): """Set the brightness of the ambient light.""" if level < 1 or level > 100: raise PhilipsEyecareException("Invalid ambient brightness: %s" % level) return self.send("set_amb_bright", [level]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/philips_moonlight.py0000644000000000000000000001576614265350055023351 0ustar00import logging from collections import defaultdict from typing import Any, Dict, List, Tuple import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import command, format_output from miio.utils import int_to_rgb _LOGGER = logging.getLogger(__name__) class PhilipsMoonlightException(DeviceException): pass class PhilipsMoonlightStatus(DeviceStatus): """Container for status reports from Xiaomi Philips Zhirui Bedside Lamp.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a Moonlight (philips.light.moonlight): {'pow': 'off', 'sta': 0, 'bri': 1, 'rgb': 16741971, 'cct': 1, 'snm': 0, 'spr': 0, 'spt': 15, 'wke': 0, 'bl': 1, 'ms': 1, 'mb': 1, 'wkp': [0, 24, 0]} """ self.data = data @property def power(self) -> str: return self.data["pow"] @property def is_on(self) -> bool: return self.power == "on" @property def brightness(self) -> int: return self.data["bri"] @property def color_temperature(self) -> int: return self.data["cct"] @property def rgb(self) -> Tuple[int, int, int]: """Return color in RGB.""" return int_to_rgb(int(self.data["rgb"])) @property def scene(self) -> int: return self.data["snm"] @property def sleep_assistant(self) -> int: """Example values: 0: Unknown 1: Unknown 2: Sleep assistant enabled 3: Awake """ return self.data["sta"] @property def sleep_off_time(self) -> int: return self.data["spr"] @property def total_assistant_sleep_time(self) -> int: return self.data["spt"] @property def brand_sleep(self) -> bool: # sp_sleep_open? return self.data["ms"] == 1 @property def brand(self) -> bool: # sp_xm_bracelet? return self.data["mb"] == 1 @property def wake_up_time(self) -> List[int]: # Example: [weekdays?, hour, minute] return self.data["wkp"] class PhilipsMoonlight(Device): """Main class representing Xiaomi Philips Zhirui Bedside Lamp. Not yet implemented features/methods: add_mb # Add miband get_band_period # Bracelet work time get_mb_rssi # Miband RSSI get_mb_mac # Miband MAC address enable_mibs set_band_period miIO.bleStartSearchBand miIO.bleGetNearbyBandList enable_sub_voice # Sub voice control? enable_voice # Voice control skip_breath set_sleep_time set_wakeup_time en_sleep en_wakeup go_night # Night light / read mode get_wakeup_time enable_bl # Night light """ _supported_models = ["philips.light.moonlight"] @command( default_output=format_output( "", "Power: {result.power}\n" "Brightness: {result.brightness}\n" "Color temperature: {result.color_temperature}\n" "RGB: {result.rgb}\n" "Scene: {result.scene}\n", ) ) def status(self) -> PhilipsMoonlightStatus: """Retrieve properties.""" properties = [ "pow", "sta", "bri", "rgb", "cct", "snm", "spr", "spt", "wke", "bl", "ms", "mb", "wkp", ] values = self.get_properties(properties) return PhilipsMoonlightStatus( defaultdict(lambda: None, zip(properties, values)) ) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("rgb", default=[255] * 3, type=click.Tuple([int, int, int])), default_output=format_output("Setting color to {rgb}"), ) def set_rgb(self, rgb: Tuple[int, int, int]): """Set color in RGB.""" for color in rgb: if color < 0 or color > 255: raise PhilipsMoonlightException("Invalid color: %s" % color) return self.send("set_rgb", [*rgb]) @command( click.argument("level", type=int), default_output=format_output("Setting brightness to {level}"), ) def set_brightness(self, level: int): """Set brightness level.""" if level < 1 or level > 100: raise PhilipsMoonlightException("Invalid brightness: %s" % level) return self.send("set_bright", [level]) @command( click.argument("level", type=int), default_output=format_output("Setting color temperature to {level}"), ) def set_color_temperature(self, level: int): """Set Correlated Color Temperature.""" if level < 1 or level > 100: raise PhilipsMoonlightException("Invalid color temperature: %s" % level) return self.send("set_cct", [level]) @command( click.argument("brightness", type=int), click.argument("cct", type=int), default_output=format_output( "Setting brightness to {brightness} and color temperature to {cct}" ), ) def set_brightness_and_color_temperature(self, brightness: int, cct: int): """Set brightness level and the correlated color temperature.""" if brightness < 1 or brightness > 100: raise PhilipsMoonlightException("Invalid brightness: %s" % brightness) if cct < 1 or cct > 100: raise PhilipsMoonlightException("Invalid color temperature: %s" % cct) return self.send("set_bricct", [brightness, cct]) @command( click.argument("brightness", type=int), click.argument("rgb", default=[255] * 3, type=click.Tuple([int, int, int])), default_output=format_output( "Setting brightness to {brightness} and color to {rgb}" ), ) def set_brightness_and_rgb(self, brightness: int, rgb: Tuple[int, int, int]): """Set brightness level and the color.""" if brightness < 1 or brightness > 100: raise PhilipsMoonlightException("Invalid brightness: %s" % brightness) for color in rgb: if color < 0 or color > 255: raise PhilipsMoonlightException("Invalid color: %s" % color) return self.send("set_brirgb", [*rgb, brightness]) @command( click.argument("number", type=int), default_output=format_output("Setting fixed scene to {number}"), ) def set_scene(self, number: int): """Set scene number.""" if number < 1 or number > 6: raise PhilipsMoonlightException("Invalid fixed scene number: %s" % number) if number == 6: return self.send("go_night") return self.send("apply_fixed_scene", [number]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/philips_rwread.py0000644000000000000000000001310014265350055022611 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict import click from miio import Device, DeviceException, DeviceStatus from miio.click_common import EnumType, command, format_output _LOGGER = logging.getLogger(__name__) MODEL_PHILIPS_LIGHT_RWREAD = "philips.light.rwread" AVAILABLE_PROPERTIES = { MODEL_PHILIPS_LIGHT_RWREAD: ["power", "bright", "dv", "snm", "flm", "chl", "flmv"] } class PhilipsRwreadException(DeviceException): pass class MotionDetectionSensitivity(enum.Enum): Low = 1 Medium = 2 High = 3 class PhilipsRwreadStatus(DeviceStatus): """Container for status reports from Xiaomi Philips RW Read.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a RW Read (philips.light.rwread): {'power': 'on', 'bright': 53, 'dv': 0, 'snm': 1, 'flm': 0, 'chl': 0, 'flmv': 0} """ self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property def brightness(self) -> int: """Current brightness.""" return self.data["bright"] @property def delay_off_countdown(self) -> int: """Countdown until turning off in seconds.""" return self.data["dv"] @property def scene(self) -> int: """Current fixed scene.""" return self.data["snm"] @property def motion_detection(self) -> bool: """True if motion detection is enabled.""" return self.data["flm"] == 1 @property def motion_detection_sensitivity(self) -> MotionDetectionSensitivity: """The sensitivity of the motion detection.""" return MotionDetectionSensitivity(self.data["flmv"]) @property def child_lock(self) -> bool: """True if child lock is enabled.""" return self.data["chl"] == 1 class PhilipsRwread(Device): """Main class representing Xiaomi Philips RW Read.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Power: {result.power}\n" "Brightness: {result.brightness}\n" "Delayed turn off: {result.delay_off_countdown}\n" "Scene: {result.scene}\n" "Motion detection: {result.motion_detection}\n" "Motion detection sensitivity: {result.motion_detection_sensitivity}\n" "Child lock: {result.child_lock}\n", ) ) def status(self) -> PhilipsRwreadStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_PHILIPS_LIGHT_RWREAD] ) values = self.get_properties(properties) return PhilipsRwreadStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("level", type=int), default_output=format_output("Setting brightness to {level}"), ) def set_brightness(self, level: int): """Set brightness level of the primary light.""" if level < 1 or level > 100: raise PhilipsRwreadException("Invalid brightness: %s" % level) return self.send("set_bright", [level]) @command( click.argument("number", type=int), default_output=format_output("Setting fixed scene to {number}"), ) def set_scene(self, number: int): """Set one of the fixed eyecare user scenes.""" if number < 1 or number > 4: raise PhilipsRwreadException("Invalid fixed scene number: %s" % number) return self.send("apply_fixed_scene", [number]) @command( click.argument("seconds", type=int), default_output=format_output("Setting delayed turn off to {seconds} seconds"), ) def delay_off(self, seconds: int): """Set delay off in seconds.""" if seconds < 0: raise PhilipsRwreadException( "Invalid value for a delayed turn off: %s" % seconds ) return self.send("delay_off", [seconds]) @command( click.argument("motion_detection", type=bool), default_output=format_output( lambda motion_detection: "Turning on motion detection" if motion_detection else "Turning off motion detection" ), ) def set_motion_detection(self, motion_detection: bool): """Set motion detection on/off.""" return self.send("enable_flm", [int(motion_detection)]) @command( click.argument("sensitivity", type=EnumType(MotionDetectionSensitivity)), default_output=format_output( "Setting motion detection sensitivity to {sensitivity}" ), ) def set_motion_detection_sensitivity(self, sensitivity: MotionDetectionSensitivity): """Set motion detection sensitivity.""" return self.send("set_flmvalue", [sensitivity.value]) @command( click.argument("lock", type=bool), default_output=format_output( lambda lock: "Turning on child lock" if lock else "Turning off child lock" ), ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" return self.send("enable_chl", [int(lock)]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/tests/__init__.py0000644000000000000000000000000014265350055022471 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/tests/test_ceil.py0000644000000000000000000001427314265350055022726 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from ..ceil import Ceil, CeilException, CeilStatus class DummyCeil(DummyDevice, Ceil): def __init__(self, *args, **kwargs): self.state = { "power": "on", "bright": 50, "snm": 4, "dv": 0, "cctsw": [[0, 3], [0, 2], [0, 1]], "bl": 1, "mb": 1, "ac": 1, "mssw": 1, "cct": 99, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_bright": lambda x: self._set_state("bright", x), "apply_fixed_scene": lambda x: self._set_state("snm", x), "delay_off": lambda x: self._set_state("dv", x), "enable_bl": lambda x: self._set_state("bl", x), "enable_ac": lambda x: self._set_state("ac", x), "set_cct": lambda x: self._set_state("cct", x), "set_bricct": lambda x: ( self._set_state("bright", [x[0]]), self._set_state("cct", [x[1]]), ), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def ceil(request): request.cls.device = DummyCeil() # TODO add ability to test on a real device @pytest.mark.usefixtures("ceil") class TestCeil(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(CeilStatus(self.device.start_state)) assert self.is_on() is True assert self.state().brightness == self.device.start_state["bright"] assert self.state().color_temperature == self.device.start_state["cct"] assert self.state().scene == self.device.start_state["snm"] assert self.state().delay_off_countdown == self.device.start_state["dv"] assert self.state().smart_night_light is (self.device.start_state["bl"] == 1) assert self.state().automatic_color_temperature is ( self.device.start_state["ac"] == 1 ) def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(10) assert brightness() == 10 self.device.set_brightness(20) assert brightness() == 20 with pytest.raises(CeilException): self.device.set_brightness(-1) with pytest.raises(CeilException): self.device.set_brightness(101) def test_set_color_temperature(self): def color_temperature(): return self.device.status().color_temperature self.device.set_color_temperature(30) assert color_temperature() == 30 self.device.set_color_temperature(20) assert color_temperature() == 20 with pytest.raises(CeilException): self.device.set_color_temperature(-1) with pytest.raises(CeilException): self.device.set_color_temperature(101) def test_set_brightness_and_color_temperature(self): def color_temperature(): return self.device.status().color_temperature def brightness(): return self.device.status().brightness self.device.set_brightness_and_color_temperature(20, 21) assert brightness() == 20 assert color_temperature() == 21 self.device.set_brightness_and_color_temperature(31, 30) assert brightness() == 31 assert color_temperature() == 30 self.device.set_brightness_and_color_temperature(10, 11) assert brightness() == 10 assert color_temperature() == 11 with pytest.raises(CeilException): self.device.set_brightness_and_color_temperature(-1, 10) with pytest.raises(CeilException): self.device.set_brightness_and_color_temperature(10, -1) with pytest.raises(CeilException): self.device.set_brightness_and_color_temperature(0, 10) with pytest.raises(CeilException): self.device.set_brightness_and_color_temperature(10, 0) with pytest.raises(CeilException): self.device.set_brightness_and_color_temperature(101, 10) with pytest.raises(CeilException): self.device.set_brightness_and_color_temperature(10, 101) def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 with pytest.raises(CeilException): self.device.delay_off(0) with pytest.raises(CeilException): self.device.delay_off(-1) def test_set_scene(self): def scene(): return self.device.status().scene self.device.set_scene(1) assert scene() == 1 self.device.set_scene(4) assert scene() == 4 with pytest.raises(CeilException): self.device.set_scene(0) with pytest.raises(CeilException): self.device.set_scene(5) def test_smart_night_light_on(self): def smart_night_light(): return self.device.status().smart_night_light self.device.smart_night_light_off() assert smart_night_light() is False self.device.smart_night_light_on() assert smart_night_light() is True def test_automatic_color_temperature_on(self): def automatic_color_temperature(): return self.device.status().automatic_color_temperature self.device.automatic_color_temperature_on() assert automatic_color_temperature() is True self.device.automatic_color_temperature_off() assert automatic_color_temperature() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_bulb.py0000644000000000000000000002046514265350055024466 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from ..philips_bulb import ( MODEL_PHILIPS_LIGHT_BULB, MODEL_PHILIPS_LIGHT_HBULB, PhilipsBulb, PhilipsBulbException, PhilipsBulbStatus, PhilipsWhiteBulb, ) class DummyPhilipsBulb(DummyDevice, PhilipsBulb): def __init__(self, *args, **kwargs): self._model = MODEL_PHILIPS_LIGHT_BULB self.state = {"power": "on", "bright": 100, "cct": 10, "snm": 0, "dv": 0} self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_bright": lambda x: self._set_state("bright", x), "set_cct": lambda x: self._set_state("cct", x), "delay_off": lambda x: self._set_state("dv", x), "apply_fixed_scene": lambda x: self._set_state("snm", x), "set_bricct": lambda x: ( self._set_state("bright", [x[0]]), self._set_state("cct", [x[1]]), ), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def philips_bulb(request): request.cls.device = DummyPhilipsBulb() # TODO add ability to test on a real device @pytest.mark.usefixtures("philips_bulb") class TestPhilipsBulb(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(PhilipsBulbStatus(self.device.start_state)) assert self.is_on() is True assert self.state().brightness == self.device.start_state["bright"] assert self.state().color_temperature == self.device.start_state["cct"] assert self.state().scene == self.device.start_state["snm"] assert self.state().delay_off_countdown == self.device.start_state["dv"] def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(1) assert brightness() == 1 self.device.set_brightness(50) assert brightness() == 50 self.device.set_brightness(100) with pytest.raises(PhilipsBulbException): self.device.set_brightness(-1) with pytest.raises(PhilipsBulbException): self.device.set_brightness(0) with pytest.raises(PhilipsBulbException): self.device.set_brightness(101) def test_set_color_temperature(self): def color_temperature(): return self.device.status().color_temperature self.device.set_color_temperature(20) assert color_temperature() == 20 self.device.set_color_temperature(30) assert color_temperature() == 30 self.device.set_color_temperature(10) with pytest.raises(PhilipsBulbException): self.device.set_color_temperature(-1) with pytest.raises(PhilipsBulbException): self.device.set_color_temperature(0) with pytest.raises(PhilipsBulbException): self.device.set_color_temperature(101) def test_set_brightness_and_color_temperature(self): def color_temperature(): return self.device.status().color_temperature def brightness(): return self.device.status().brightness self.device.set_brightness_and_color_temperature(20, 21) assert brightness() == 20 assert color_temperature() == 21 self.device.set_brightness_and_color_temperature(31, 30) assert brightness() == 31 assert color_temperature() == 30 self.device.set_brightness_and_color_temperature(10, 11) assert brightness() == 10 assert color_temperature() == 11 with pytest.raises(PhilipsBulbException): self.device.set_brightness_and_color_temperature(-1, 10) with pytest.raises(PhilipsBulbException): self.device.set_brightness_and_color_temperature(10, -1) with pytest.raises(PhilipsBulbException): self.device.set_brightness_and_color_temperature(0, 10) with pytest.raises(PhilipsBulbException): self.device.set_brightness_and_color_temperature(10, 0) with pytest.raises(PhilipsBulbException): self.device.set_brightness_and_color_temperature(101, 10) with pytest.raises(PhilipsBulbException): self.device.set_brightness_and_color_temperature(10, 101) def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 with pytest.raises(PhilipsBulbException): self.device.delay_off(-1) with pytest.raises(PhilipsBulbException): self.device.delay_off(0) def test_set_scene(self): def scene(): return self.device.status().scene self.device.set_scene(1) assert scene() == 1 self.device.set_scene(2) assert scene() == 2 with pytest.raises(PhilipsBulbException): self.device.set_scene(-1) with pytest.raises(PhilipsBulbException): self.device.set_scene(0) with pytest.raises(PhilipsBulbException): self.device.set_scene(5) class DummyPhilipsWhiteBulb(DummyDevice, PhilipsWhiteBulb): def __init__(self, *args, **kwargs): self._model = MODEL_PHILIPS_LIGHT_HBULB self.state = {"power": "on", "bri": 100, "dv": 0} self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_bright": lambda x: self._set_state("bri", x), "delay_off": lambda x: self._set_state("dv", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def philips_white_bulb(request): request.cls.device = DummyPhilipsWhiteBulb() # TODO add ability to test on a real device @pytest.mark.usefixtures("philips_white_bulb") class TestPhilipsWhiteBulb(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(PhilipsBulbStatus(self.device.start_state)) assert self.is_on() is True assert self.state().brightness == self.device.start_state["bri"] assert self.state().delay_off_countdown == self.device.start_state["dv"] assert self.state().color_temperature is None assert self.state().scene is None def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(1) assert brightness() == 1 self.device.set_brightness(50) assert brightness() == 50 self.device.set_brightness(100) with pytest.raises(PhilipsBulbException): self.device.set_brightness(-1) with pytest.raises(PhilipsBulbException): self.device.set_brightness(0) with pytest.raises(PhilipsBulbException): self.device.set_brightness(101) def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 with pytest.raises(PhilipsBulbException): self.device.delay_off(-1) with pytest.raises(PhilipsBulbException): self.device.delay_off(0) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_eyecare.py0000644000000000000000000001400214265350055025145 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from ..philips_eyecare import ( PhilipsEyecare, PhilipsEyecareException, PhilipsEyecareStatus, ) class DummyPhilipsEyecare(DummyDevice, PhilipsEyecare): def __init__(self, *args, **kwargs): self.state = { "power": "on", "bright": 100, "notifystatus": "off", "ambstatus": "off", "ambvalue": 100, "eyecare": "on", "scene_num": 3, "bls": "on", "dvalue": 0, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_eyecare": lambda x: self._set_state("eyecare", x), "set_bright": lambda x: self._set_state("bright", x), "set_user_scene": lambda x: self._set_state("scene_num", x), "delay_off": lambda x: self._set_state("dvalue", x), "enable_bl": lambda x: self._set_state("bls", x), "set_notifyuser": lambda x: self._set_state("notifystatus", x), "enable_amb": lambda x: self._set_state("ambstatus", x), "set_amb_bright": lambda x: self._set_state("ambvalue", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def philips_eyecare(request): request.cls.device = DummyPhilipsEyecare() # TODO add ability to test on a real device @pytest.mark.usefixtures("philips_eyecare") class TestPhilipsEyecare(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(PhilipsEyecareStatus(self.device.start_state)) assert self.is_on() is True assert self.state().brightness == self.device.start_state["bright"] assert self.state().reminder is ( self.device.start_state["notifystatus"] == "on" ) assert self.state().ambient is (self.device.start_state["ambstatus"] == "on") assert self.state().ambient_brightness == self.device.start_state["ambvalue"] assert self.state().eyecare is (self.device.start_state["eyecare"] == "on") assert self.state().scene == self.device.start_state["scene_num"] assert self.state().smart_night_light is ( self.device.start_state["bls"] == "on" ) assert self.state().delay_off_countdown == self.device.start_state["dvalue"] def test_eyecare(self): def eyecare(): return self.device.status().eyecare self.device.eyecare_on() assert eyecare() is True self.device.eyecare_off() assert eyecare() is False def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(1) assert brightness() == 1 self.device.set_brightness(50) assert brightness() == 50 self.device.set_brightness(100) with pytest.raises(PhilipsEyecareException): self.device.set_brightness(-1) with pytest.raises(PhilipsEyecareException): self.device.set_brightness(0) with pytest.raises(PhilipsEyecareException): self.device.set_brightness(101) def test_set_scene(self): def scene(): return self.device.status().scene self.device.set_scene(1) assert scene() == 1 self.device.set_scene(2) assert scene() == 2 with pytest.raises(PhilipsEyecareException): self.device.set_scene(-1) with pytest.raises(PhilipsEyecareException): self.device.set_scene(0) with pytest.raises(PhilipsEyecareException): self.device.set_scene(5) def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(1) assert delay_off_countdown() == 1 self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 with pytest.raises(PhilipsEyecareException): self.device.delay_off(-1) def test_smart_night_light(self): def smart_night_light(): return self.device.status().smart_night_light self.device.smart_night_light_on() assert smart_night_light() is True self.device.smart_night_light_off() assert smart_night_light() is False def test_reminder(self): def reminder(): return self.device.status().reminder self.device.reminder_on() assert reminder() is True self.device.reminder_off() assert reminder() is False def test_ambient(self): def ambient(): return self.device.status().ambient self.device.ambient_on() assert ambient() is True self.device.ambient_off() assert ambient() is False def test_set_ambient_brightness(self): def ambient_brightness(): return self.device.status().ambient_brightness self.device.set_ambient_brightness(1) assert ambient_brightness() == 1 self.device.set_ambient_brightness(50) assert ambient_brightness() == 50 self.device.set_ambient_brightness(100) with pytest.raises(PhilipsEyecareException): self.device.set_ambient_brightness(-1) with pytest.raises(PhilipsEyecareException): self.device.set_ambient_brightness(0) with pytest.raises(PhilipsEyecareException): self.device.set_ambient_brightness(101) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_moonlight.py0000644000000000000000000002043414265350055025536 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from miio.utils import int_to_rgb, rgb_to_int from ..philips_moonlight import ( PhilipsMoonlight, PhilipsMoonlightException, PhilipsMoonlightStatus, ) class DummyPhilipsMoonlight(DummyDevice, PhilipsMoonlight): def __init__(self, *args, **kwargs): self.state = { "pow": "on", "sta": 0, "bri": 1, "rgb": 16741971, "cct": 1, "snm": 0, "spr": 0, "spt": 15, "wke": 0, "bl": 1, "ms": 1, "mb": 1, "wkp": [0, 24, 0], } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("pow", x), "set_bright": lambda x: self._set_state("bri", x), "set_cct": lambda x: self._set_state("cct", x), "set_rgb": lambda x: self._set_state("rgb", [rgb_to_int(x)]), "apply_fixed_scene": lambda x: self._set_state("snm", x), "go_night": lambda x: self._set_state("snm", [6]), "set_bricct": lambda x: ( self._set_state("bri", [x[0]]), self._set_state("cct", [x[1]]), ), "set_brirgb": lambda x: ( self._set_state("rgb", [rgb_to_int((x[0], x[1], x[2]))]), self._set_state("bri", [x[3]]), ), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def philips_moonlight(request): request.cls.device = DummyPhilipsMoonlight() # TODO add ability to test on a real device @pytest.mark.usefixtures("philips_moonlight") class TestPhilipsMoonlight(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( PhilipsMoonlightStatus(self.device.start_state) ) assert self.is_on() is True assert self.state().brightness == self.device.start_state["bri"] assert self.state().color_temperature == self.device.start_state["cct"] assert self.state().rgb == int_to_rgb(int(self.device.start_state["rgb"])) assert self.state().scene == self.device.start_state["snm"] def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(1) assert brightness() == 1 self.device.set_brightness(50) assert brightness() == 50 self.device.set_brightness(100) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness(-1) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness(0) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness(101) def test_set_rgb(self): def rgb(): return self.device.status().rgb self.device.set_rgb((0, 0, 1)) assert rgb() == (0, 0, 1) self.device.set_rgb((255, 255, 0)) assert rgb() == (255, 255, 0) self.device.set_rgb((255, 255, 255)) assert rgb() == (255, 255, 255) with pytest.raises(PhilipsMoonlightException): self.device.set_rgb((-1, 0, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_rgb((256, 0, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_rgb((0, -1, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_rgb((0, 256, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_rgb((0, 0, -1)) with pytest.raises(PhilipsMoonlightException): self.device.set_rgb((0, 0, 256)) def test_set_color_temperature(self): def color_temperature(): return self.device.status().color_temperature self.device.set_color_temperature(20) assert color_temperature() == 20 self.device.set_color_temperature(30) assert color_temperature() == 30 self.device.set_color_temperature(10) with pytest.raises(PhilipsMoonlightException): self.device.set_color_temperature(-1) with pytest.raises(PhilipsMoonlightException): self.device.set_color_temperature(0) with pytest.raises(PhilipsMoonlightException): self.device.set_color_temperature(101) def test_set_brightness_and_color_temperature(self): def color_temperature(): return self.device.status().color_temperature def brightness(): return self.device.status().brightness self.device.set_brightness_and_color_temperature(20, 21) assert brightness() == 20 assert color_temperature() == 21 self.device.set_brightness_and_color_temperature(31, 30) assert brightness() == 31 assert color_temperature() == 30 self.device.set_brightness_and_color_temperature(10, 11) assert brightness() == 10 assert color_temperature() == 11 with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_color_temperature(-1, 10) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_color_temperature(10, -1) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_color_temperature(0, 10) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_color_temperature(10, 0) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_color_temperature(101, 10) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_color_temperature(10, 101) def test_set_brightness_and_rgb(self): def brightness(): return self.device.status().brightness def rgb(): return self.device.status().rgb self.device.set_brightness_and_rgb(20, (0, 0, 0)) assert brightness() == 20 assert rgb() == (0, 0, 0) self.device.set_brightness_and_rgb(31, (255, 0, 0)) assert brightness() == 31 assert rgb() == (255, 0, 0) self.device.set_brightness_and_rgb(100, (255, 255, 255)) assert brightness() == 100 assert rgb() == (255, 255, 255) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(-1, 10) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(0, 10) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(101, 10) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(10, (-1, 0, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(10, (256, 0, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(10, (0, -1, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(10, (0, 256, 0)) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(10, (0, 0, -1)) with pytest.raises(PhilipsMoonlightException): self.device.set_brightness_and_rgb(10, (0, 0, 256)) def test_set_scene(self): def scene(): return self.device.status().scene self.device.set_scene(1) assert scene() == 1 self.device.set_scene(6) assert scene() == 6 with pytest.raises(PhilipsMoonlightException): self.device.set_scene(-1) with pytest.raises(PhilipsMoonlightException): self.device.set_scene(0) with pytest.raises(PhilipsMoonlightException): self.device.set_scene(7) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_rwread.py0000644000000000000000000001237114265350055025023 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from ..philips_rwread import ( MODEL_PHILIPS_LIGHT_RWREAD, MotionDetectionSensitivity, PhilipsRwread, PhilipsRwreadException, PhilipsRwreadStatus, ) class DummyPhilipsRwread(DummyDevice, PhilipsRwread): def __init__(self, *args, **kwargs): self._model = MODEL_PHILIPS_LIGHT_RWREAD self.state = { "power": "on", "bright": 53, "dv": 0, "snm": 1, "flm": 0, "flmv": 2, "chl": 0, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_bright": lambda x: self._set_state("bright", x), "apply_fixed_scene": lambda x: self._set_state("snm", x), "delay_off": lambda x: self._set_state("dv", x), "enable_flm": lambda x: self._set_state("flm", x), "set_flmvalue": lambda x: self._set_state("flmv", x), "enable_chl": lambda x: self._set_state("chl", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def philips_eyecare(request): request.cls.device = DummyPhilipsRwread() # TODO add ability to test on a real device @pytest.mark.usefixtures("philips_eyecare") class TestPhilipsRwread(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(PhilipsRwreadStatus(self.device.start_state)) assert self.is_on() is True assert self.state().brightness == self.device.start_state["bright"] assert self.state().delay_off_countdown == self.device.start_state["dv"] assert self.state().scene == self.device.start_state["snm"] assert self.state().motion_detection is (self.device.start_state["flm"] == 1) assert self.state().motion_detection_sensitivity == MotionDetectionSensitivity( self.device.start_state["flmv"] ) assert self.state().child_lock is (self.device.start_state["chl"] == 1) def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(1) assert brightness() == 1 self.device.set_brightness(50) assert brightness() == 50 self.device.set_brightness(100) with pytest.raises(PhilipsRwreadException): self.device.set_brightness(-1) with pytest.raises(PhilipsRwreadException): self.device.set_brightness(0) with pytest.raises(PhilipsRwreadException): self.device.set_brightness(101) def test_set_scene(self): def scene(): return self.device.status().scene self.device.set_scene(1) assert scene() == 1 self.device.set_scene(2) assert scene() == 2 with pytest.raises(PhilipsRwreadException): self.device.set_scene(-1) with pytest.raises(PhilipsRwreadException): self.device.set_scene(0) with pytest.raises(PhilipsRwreadException): self.device.set_scene(5) def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(1) assert delay_off_countdown() == 1 self.device.delay_off(100) assert delay_off_countdown() == 100 self.device.delay_off(200) assert delay_off_countdown() == 200 with pytest.raises(PhilipsRwreadException): self.device.delay_off(-1) def test_set_motion_detection(self): def motion_detection(): return self.device.status().motion_detection self.device.set_motion_detection(True) assert motion_detection() is True self.device.set_motion_detection(False) assert motion_detection() is False def test_set_motion_detection_sensitivity(self): def motion_detection_sensitivity(): return self.device.status().motion_detection_sensitivity self.device.set_motion_detection_sensitivity(MotionDetectionSensitivity.Low) assert motion_detection_sensitivity() == MotionDetectionSensitivity.Low self.device.set_motion_detection_sensitivity(MotionDetectionSensitivity.Medium) assert motion_detection_sensitivity() == MotionDetectionSensitivity.Medium self.device.set_motion_detection_sensitivity(MotionDetectionSensitivity.High) assert motion_detection_sensitivity() == MotionDetectionSensitivity.High def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/yeelight/__init__.py0000644000000000000000000000013714265350055021504 0ustar00# flake8: noqa from .yeelight import Yeelight, YeelightException, YeelightMode, YeelightStatus ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/yeelight/spec_helper.py0000644000000000000000000000461214265350055022240 0ustar00import logging import os from enum import IntEnum from typing import Dict, NamedTuple import attr import yaml _LOGGER = logging.getLogger(__name__) class YeelightSubLightType(IntEnum): Main = 0 Background = 1 class ColorTempRange(NamedTuple): """Color temperature range.""" min: int max: int @attr.s(auto_attribs=True) class YeelightLampInfo: color_temp: ColorTempRange supports_color: bool @attr.s(auto_attribs=True) class YeelightModelInfo: model: str night_light: bool lamps: Dict[YeelightSubLightType, YeelightLampInfo] class YeelightSpecHelper: _models: Dict[str, YeelightModelInfo] = {} def __init__(self): if not YeelightSpecHelper._models: self._parse_specs_yaml() def _parse_specs_yaml(self): generic_info = YeelightModelInfo( "generic", False, { YeelightSubLightType.Main: YeelightLampInfo( ColorTempRange(1700, 6500), False ) }, ) YeelightSpecHelper._models["generic"] = generic_info # read the yaml file to populate the internal model cache with open(os.path.dirname(__file__) + "/specs.yaml") as filedata: models = yaml.safe_load(filedata) for key, value in models.items(): lamps = { YeelightSubLightType.Main: YeelightLampInfo( ColorTempRange(*value["color_temp"]), value["supports_color"], ) } if "background" in value: lamps[YeelightSubLightType.Background] = YeelightLampInfo( ColorTempRange(*value["background"]["color_temp"]), value["background"]["supports_color"], ) info = YeelightModelInfo(key, value["night_light"], lamps) YeelightSpecHelper._models[key] = info @property def supported_models(self): return self._models.keys() def get_model_info(self, model) -> YeelightModelInfo: if model not in self._models: _LOGGER.warning( "Unknown model %s, please open an issue and supply features for this light. Returning generic information.", model, ) return self._models["generic"] return self._models[model] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/yeelight/specs.yaml0000644000000000000000000001012714265350055021374 0ustar00yeelink.light.bslamp1: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.bslamp2: night_light: True color_temp: [1700, 6500] supports_color: True yeelink.light.bslamp3: night_light: True color_temp: [1700, 6500] supports_color: True yeelink.light.ceil26: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceila: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling1: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling2: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling3: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling4: night_light: True color_temp: [2700, 6500] supports_color: False background: color_temp: [1700, 6500] supports_color: True yeelink.light.ceiling5: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling6: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling10: night_light: True color_temp: [2700, 6500] supports_color: False background: color_temp: [1700, 6500] supports_color: True yeelink.light.ceiling13: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling15: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling18: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.ceiling19: night_light: True color_temp: [2700, 6500] supports_color: False background: color_temp: [1700, 6500] supports_color: True yeelink.light.ceiling20: night_light: True color_temp: [2700, 6500] supports_color: False background: color_temp: [1700, 6500] supports_color: True yeelink.light.ceiling22: night_light: True color_temp: [2600, 6100] supports_color: False yeelink.light.ceiling24: night_light: True color_temp: [2700, 6500] supports_color: False yeelink.light.color1: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.color2: night_light: False color_temp: [2700, 6500] supports_color: True yeelink.light.color3: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.color4: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.color5: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.color7: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.colorc: night_light: False color_temp: [2700, 6500] supports_color: True yeelink.light.color: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.ct_bulb: night_light: False color_temp: [2700, 6500] supports_color: False yeelink.light.ct2: night_light: False color_temp: [2700, 6500] supports_color: False yeelink.light.lamp1: night_light: False color_temp: [2700, 5000] supports_color: False yeelink.light.lamp4: night_light: False color_temp: [2600, 5000] supports_color: False yeelink.light.lamp15: night_light: False color_temp: [2700, 6500] supports_color: False background: color_temp: [1700, 6500] supports_color: True yeelink.light.mono1: night_light: False color_temp: [2700, 2700] supports_color: False yeelink.light.mono5: night_light: False color_temp: [2700, 2700] supports_color: False yeelink.light.mono: night_light: False color_temp: [2700, 2700] supports_color: False yeelink.light.monob: night_light: False color_temp: [2700, 2700] supports_color: False yeelink.light.strip1: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.strip2: night_light: False color_temp: [1700, 6500] supports_color: True yeelink.light.strip4: night_light: False color_temp: [2700, 6500] supports_color: True yeelink.bhf_light.v2: night_light: False color_temp: [0, 0] supports_color: False yeelink.light.lamp22: night_light: False color_temp: [2700, 6500] supports_color: True ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/yeelight/tests/__init__.py0000644000000000000000000000000014265350055022633 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/yeelight/tests/test_yeelight.py0000644000000000000000000004073714265350055023772 0ustar00from unittest import TestCase import pytest from miio.tests.dummies import DummyDevice from .. import Yeelight, YeelightException, YeelightMode, YeelightStatus from ..spec_helper import YeelightSpecHelper, YeelightSubLightType class DummyLight(DummyDevice, Yeelight): def __init__(self, *args, **kwargs): self._model = "missing.model.yeelight" self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_bright": lambda x: self._set_state("bright", x), "set_ct_abx": lambda x: self._set_state("ct", x), "set_rgb": lambda x: self._set_state("rgb", x), "set_hsv": lambda x: self._set_state("hsv", x), "set_name": lambda x: self._set_state("name", x), "set_ps": lambda x: self.set_config(x), "toggle": self.toggle_power, "set_default": lambda x: "ok", } super().__init__(*args, **kwargs) if Yeelight._spec_helper is None: Yeelight._spec_helper = YeelightSpecHelper() Yeelight._supported_models = Yeelight._spec_helper.supported_models self._model_info = Yeelight._spec_helper.get_model_info(self.model) self._light_type = YeelightSubLightType.Main self._light_info = self._model_info.lamps[self._light_type] self._color_temp_range = self._light_info.color_temp def set_config(self, x): key, value = x config_mapping = {"cfg_lan_ctrl": "lan_ctrl", "cfg_save_state": "save_state"} self._set_state(config_mapping[key], [value]) def toggle_power(self, _): if self.state["power"] == "on": self.state["power"] = "off" else: self.state["power"] = "on" class DummyCommonBulb(DummyLight): def __init__(self, *args, **kwargs): self.state = { "name": "test name", "lan_ctrl": "1", "save_state": "1", "delayoff": "0", "music_on": "1", "power": "off", "bright": "100", "color_mode": "2", "rgb": "", "hue": "", "sat": "", "ct": "3584", "flowing": "", "flow_params": "", "active_mode": "", "nl_br": "", "bg_power": "", "bg_bright": "", "bg_lmode": "", "bg_rgb": "", "bg_hue": "", "bg_sat": "", "bg_ct": "", "bg_flowing": "", "bg_flow_params": "", } super().__init__(*args, **kwargs) @pytest.fixture(scope="class") def dummycommonbulb(request): request.cls.device = DummyCommonBulb() # TODO add ability to test on a real device @pytest.mark.usefixtures("dummycommonbulb") class TestYeelightCommon(TestCase): def test_on(self): self.device.off() # make sure we are off assert self.device.status().is_on is False self.device.on() assert self.device.status().is_on is True def test_off(self): self.device.on() # make sure we are on assert self.device.status().is_on is True self.device.off() assert self.device.status().is_on is False def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(50) assert brightness() == 50 self.device.set_brightness(0) assert brightness() == 0 self.device.set_brightness(100) with pytest.raises(YeelightException): self.device.set_brightness(-100) with pytest.raises(YeelightException): self.device.set_brightness(200) def test_set_color_temp(self): def color_temp(): return self.device.status().color_temp self.device.set_color_temp(2000) assert color_temp() == 2000 self.device.set_color_temp(6500) assert color_temp() == 6500 with pytest.raises(YeelightException): self.device.set_color_temp(1000) with pytest.raises(YeelightException): self.device.set_color_temp(7000) def test_set_developer_mode(self): def dev_mode(): return self.device.status().developer_mode orig_mode = dev_mode() self.device.set_developer_mode(not orig_mode) new_mode = dev_mode() assert new_mode is not orig_mode self.device.set_developer_mode(not new_mode) assert new_mode is not dev_mode() def test_set_save_state_on_change(self): def save_state(): return self.device.status().save_state_on_change orig_state = save_state() self.device.set_save_state_on_change(not orig_state) new_state = save_state() assert new_state is not orig_state self.device.set_save_state_on_change(not new_state) new_state = save_state() assert new_state is orig_state def test_set_name(self): def name(): return self.device.status().name assert name() == "test name" self.device.set_name("new test name") assert name() == "new test name" def test_toggle(self): def is_on(): return self.device.status().is_on orig_state = is_on() self.device.toggle() new_state = is_on() assert orig_state != new_state self.device.toggle() new_state = is_on() assert new_state == orig_state @pytest.mark.skip("cannot be tested easily") def test_set_default(self): self.fail() @pytest.mark.skip("set_scene is not implemented") def test_set_scene(self): self.fail() class DummyLightСolor(DummyLight): def __init__(self, *args, **kwargs): self.state = { "name": "test name", "lan_ctrl": "1", "save_state": "1", "delayoff": "0", "music_on": "1", "power": "off", "bright": "100", "color_mode": "2", "rgb": "16711680", "hue": "359", "sat": "100", "ct": "3584", "flowing": "0", "flow_params": "0,0,1000,1,16711680,100,1000,1,65280,100,1000,1,255,100", "active_mode": "", "nl_br": "", "bg_power": "", "bg_bright": "", "bg_lmode": "", "bg_rgb": "", "bg_hue": "", "bg_sat": "", "bg_ct": "", "bg_flowing": "", "bg_flow_params": "", } super().__init__(*args, **kwargs) @pytest.fixture(scope="class") def dummylightcolor(request): request.cls.device = DummyLightСolor() # TODO add ability to test on a real device @pytest.mark.usefixtures("dummylightcolor") class TestYeelightLightColor(TestCase): def test_status(self): self.device._reset_state() status = self.device.status() # type: YeelightStatus assert repr(status) == repr(YeelightStatus(self.device.start_state)) assert status.name == self.device.start_state["name"] assert status.developer_mode is True assert status.save_state_on_change is True assert status.delay_off == 0 assert status.music_mode is True assert len(status.lights) == 1 assert status.is_on is False and status.is_on == status.lights[0].is_on assert ( status.brightness == 100 and status.brightness == status.lights[0].brightness ) assert ( status.color_mode == YeelightMode.ColorTemperature and status.color_mode == status.lights[0].color_mode ) assert ( status.color_temp == 3584 and status.color_temp == status.lights[0].color_temp ) assert status.rgb is None and status.rgb == status.lights[0].rgb assert status.hsv is None and status.hsv == status.lights[0].hsv # following are tested in set mode tests # assert status.rgb == 16711680 # assert status.hsv == (359, 100, 100) assert ( status.color_flowing is False and status.color_flowing == status.lights[0].color_flowing ) assert ( status.color_flow_params is None and status.color_flow_params == status.lights[0].color_flow_params ) # color_flow_params will be tested after future implementation # assert status.color_flow_params == "0,0,1000,1,16711680,100,1000,1,65280,100,1000,1,255,100" and status.color_flow_params == status.lights[0].color_flow_params assert status.moonlight_mode is None assert status.moonlight_mode_brightness is None def test_set_rgb(self): def rgb(): return self.device.status().rgb self.device._reset_state() self.device._set_state("color_mode", [1]) assert rgb() == (255, 0, 0) self.device.set_rgb((0, 0, 1)) assert rgb() == (0, 0, 1) self.device.set_rgb((255, 255, 0)) assert rgb() == (255, 255, 0) self.device.set_rgb((255, 255, 255)) assert rgb() == (255, 255, 255) with pytest.raises(YeelightException): self.device.set_rgb((-1, 0, 0)) with pytest.raises(YeelightException): self.device.set_rgb((256, 0, 0)) with pytest.raises(YeelightException): self.device.set_rgb((0, -1, 0)) with pytest.raises(YeelightException): self.device.set_rgb((0, 256, 0)) with pytest.raises(YeelightException): self.device.set_rgb((0, 0, -1)) with pytest.raises(YeelightException): self.device.set_rgb((0, 0, 256)) @pytest.mark.skip("hsv is not properly implemented") def test_set_hsv(self): self.reset_state() hue, sat, val = self.device.status().hsv assert hue == 359 assert sat == 100 assert val == 100 self.device.set_hsv() class DummyLightCeilingV1(DummyLight): # without background light def __init__(self, *args, **kwargs): self.state = { "name": "test name", "lan_ctrl": "1", "save_state": "1", "delayoff": "0", "music_on": "", "power": "off", "bright": "100", "color_mode": "2", "rgb": "", "hue": "", "sat": "", "ct": "3584", "flowing": "0", "flow_params": "0,0,2000,3,0,33,2000,3,0,100", "active_mode": "1", "nl_br": "100", "bg_power": "", "bg_bright": "", "bg_lmode": "", "bg_rgb": "", "bg_hue": "", "bg_sat": "", "bg_ct": "", "bg_flowing": "", "bg_flow_params": "", } super().__init__(*args, **kwargs) @pytest.fixture(scope="class") def dummylightceilingv1(request): request.cls.device = DummyLightCeilingV1() # TODO add ability to test on a real device @pytest.mark.usefixtures("dummylightceilingv1") class TestYeelightLightCeilingV1(TestCase): def test_status(self): self.device._reset_state() status = self.device.status() # type: YeelightStatus assert repr(status) == repr(YeelightStatus(self.device.start_state)) assert status.name == self.device.start_state["name"] assert status.developer_mode is True assert status.save_state_on_change is True assert status.delay_off == 0 assert status.music_mode is None assert len(status.lights) == 1 assert status.is_on is False and status.is_on == status.lights[0].is_on assert ( status.brightness == 100 and status.brightness == status.lights[0].brightness ) assert ( status.color_mode == YeelightMode.ColorTemperature and status.color_mode == status.lights[0].color_mode ) assert ( status.color_temp == 3584 and status.color_temp == status.lights[0].color_temp ) assert status.rgb is None and status.rgb == status.lights[0].rgb assert status.hsv is None and status.hsv == status.lights[0].hsv # following are tested in set mode tests # assert status.rgb == 16711680 # assert status.hsv == (359, 100, 100) assert ( status.color_flowing is False and status.color_flowing == status.lights[0].color_flowing ) assert ( status.color_flow_params is None and status.color_flow_params == status.lights[0].color_flow_params ) # color_flow_params will be tested after future implementation # assert status.color_flow_params == "0,0,1000,1,16711680,100,1000,1,65280,100,1000,1,255,100" and status.color_flow_params == status.lights[0].color_flow_params assert status.moonlight_mode is True assert status.moonlight_mode_brightness == 100 class DummyLightCeilingV2(DummyLight): # without background light def __init__(self, *args, **kwargs): self.state = { "name": "test name", "lan_ctrl": "1", "save_state": "1", "delayoff": "0", "music_on": "", "power": "off", "bright": "100", "color_mode": "2", "rgb": "", "hue": "", "sat": "", "ct": "3584", "flowing": "0", "flow_params": "0,0,2000,3,0,33,2000,3,0,100", "active_mode": "1", "nl_br": "100", "bg_power": "off", "bg_bright": "100", "bg_lmode": "2", "bg_rgb": "15531811", "bg_hue": "65", "bg_sat": "86", "bg_ct": "4000", "bg_flowing": "0", "bg_flow_params": "0,0,3000,4,16711680,100,3000,4,65280,100,3000,4,255,100", } super().__init__(*args, **kwargs) @pytest.fixture(scope="class") def dummylightceilingv2(request): request.cls.device = DummyLightCeilingV2() # TODO add ability to test on a real device @pytest.mark.usefixtures("dummylightceilingv2") class TestYeelightLightCeilingV2(TestCase): def test_status(self): self.device._reset_state() status = self.device.status() # type: YeelightStatus assert repr(status) == repr(YeelightStatus(self.device.start_state)) assert status.name == self.device.start_state["name"] assert status.developer_mode is True assert status.save_state_on_change is True assert status.delay_off == 0 assert status.music_mode is None assert len(status.lights) == 2 assert status.is_on is False and status.is_on == status.lights[0].is_on assert ( status.brightness == 100 and status.brightness == status.lights[0].brightness ) assert ( status.color_mode == YeelightMode.ColorTemperature and status.color_mode == status.lights[0].color_mode ) assert ( status.color_temp == 3584 and status.color_temp == status.lights[0].color_temp ) assert status.rgb is None and status.rgb == status.lights[0].rgb assert status.hsv is None and status.hsv == status.lights[0].hsv # following are tested in set mode tests # assert status.rgb == 16711680 # assert status.hsv == (359, 100, 100) assert ( status.color_flowing is False and status.color_flowing == status.lights[0].color_flowing ) assert ( status.color_flow_params is None and status.color_flow_params == status.lights[0].color_flow_params ) # color_flow_params will be tested after future implementation # assert status.color_flow_params == "0,0,1000,1,16711680,100,1000,1,65280,100,1000,1,255,100" and status.color_flow_params == status.lights[0].color_flow_params assert status.lights[1].is_on is False assert status.lights[1].brightness == 100 assert status.lights[1].color_mode == YeelightMode.ColorTemperature assert status.lights[1].color_temp == 4000 assert status.lights[1].rgb is None assert status.lights[1].hsv is None # following are tested in set mode tests # assert status.rgb == 15531811 # assert status.hsv == (65, 86, 100) assert status.lights[1].color_flowing is False assert status.lights[1].color_flow_params is None assert status.moonlight_mode is True assert status.moonlight_mode_brightness == 100 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/yeelight/tests/test_yeelight_spec_helper.py0000644000000000000000000000202414265350055026326 0ustar00from ..spec_helper import ColorTempRange, YeelightSpecHelper, YeelightSubLightType def test_get_model_info(): spec_helper = YeelightSpecHelper() model_info = spec_helper.get_model_info("yeelink.light.bslamp1") assert model_info.model == "yeelink.light.bslamp1" assert model_info.night_light is False assert model_info.lamps[YeelightSubLightType.Main].color_temp == ColorTempRange( 1700, 6500 ) assert model_info.lamps[YeelightSubLightType.Main].supports_color is True assert YeelightSubLightType.Background not in model_info.lamps def test_get_unknown_model_info(): spec_helper = YeelightSpecHelper() model_info = spec_helper.get_model_info("notreal") assert model_info.model == "generic" assert model_info.night_light is False assert model_info.lamps[YeelightSubLightType.Main].color_temp == ColorTempRange( 1700, 6500 ) assert model_info.lamps[YeelightSubLightType.Main].supports_color is False assert YeelightSubLightType.Background not in model_info.lamps ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/light/yeelight/yeelight.py0000644000000000000000000004202614265350055021562 0ustar00from enum import IntEnum from typing import List, Optional, Tuple import click from miio.click_common import command, format_output from miio.device import Device, DeviceStatus from miio.exceptions import DeviceException from miio.utils import int_to_rgb, rgb_to_int from .spec_helper import ColorTempRange, YeelightSpecHelper, YeelightSubLightType class YeelightException(DeviceException): pass SUBLIGHT_PROP_PREFIX = { YeelightSubLightType.Main: "", YeelightSubLightType.Background: "bg_", } SUBLIGHT_COLOR_MODE_PROP = { YeelightSubLightType.Main: "color_mode", YeelightSubLightType.Background: "bg_lmode", } class YeelightMode(IntEnum): RGB = 1 ColorTemperature = 2 HSV = 3 class YeelightSubLight(DeviceStatus): def __init__(self, data, type): self.data = data self.type = type def get_prop_name(self, prop) -> str: if prop == "color_mode": return SUBLIGHT_COLOR_MODE_PROP[self.type] else: return SUBLIGHT_PROP_PREFIX[self.type] + prop @property def is_on(self) -> bool: """Return whether the light is on or off.""" return self.data[self.get_prop_name("power")] == "on" @property def brightness(self) -> int: """Return current brightness.""" return int(self.data[self.get_prop_name("bright")]) @property def rgb(self) -> Optional[Tuple[int, int, int]]: """Return color in RGB if RGB mode is active.""" rgb = self.data[self.get_prop_name("rgb")] if self.color_mode == YeelightMode.RGB and rgb: return int_to_rgb(int(rgb)) return None @property def color_mode(self) -> YeelightMode: """Return current color mode.""" return YeelightMode(int(self.data[self.get_prop_name("color_mode")])) @property def hsv(self) -> Optional[Tuple[int, int, int]]: """Return current color in HSV if HSV mode is active.""" hue = self.data[self.get_prop_name("hue")] sat = self.data[self.get_prop_name("sat")] brightness = self.data[self.get_prop_name("bright")] if self.color_mode == YeelightMode.HSV and (hue or sat or brightness): return hue, sat, brightness return None @property def color_temp(self) -> Optional[int]: """Return current color temperature, if applicable.""" ct = self.data[self.get_prop_name("ct")] if self.color_mode == YeelightMode.ColorTemperature and ct: return int(ct) return None @property def color_flowing(self) -> bool: """Return whether the color flowing is active.""" return bool(int(self.data[self.get_prop_name("flowing")])) @property def color_flow_params(self) -> Optional[str]: """Return color flowing params.""" if self.color_flowing: return self.data[self.get_prop_name("flow_params")] return None class YeelightStatus(DeviceStatus): def __init__(self, data): # yeelink.light.ceiling4, yeelink.light.ceiling20 # {'name': '', 'lan_ctrl': '1', 'save_state': '1', 'delayoff': '0', 'music_on': '', 'power': 'off', 'bright': '1', 'color_mode': '2', 'rgb': '', 'hue': '', 'sat': '', 'ct': '4115', 'flowing': '0', 'flow_params': '0,0,2000,3,0,33,2000,3,0,100', 'active_mode': '1', 'nl_br': '1', 'bg_power': 'off', 'bg_bright': '100', 'bg_lmode': '1', 'bg_rgb': '15531811', 'bg_hue': '65', 'bg_sat': '86', 'bg_ct': '4000', 'bg_flowing': '0', 'bg_flow_params': '0,0,3000,4,16711680,100,3000,4,65280,100,3000,4,255,100'} # yeelink.light.ceiling1 # {'name': '', 'lan_ctrl': '1', 'save_state': '1', 'delayoff': '0', 'music_on': '', 'power': 'off', 'bright': '100', 'color_mode': '2', 'rgb': '', 'hue': '', 'sat': '', 'ct': '5200', 'flowing': '0', 'flow_params': '', 'active_mode': '0', 'nl_br': '0', 'bg_power': '', 'bg_bright': '', 'bg_lmode': '', 'bg_rgb': '', 'bg_hue': '', 'bg_sat': '', 'bg_ct': '', 'bg_flowing': '', 'bg_flow_params': ''} # yeelink.light.ceiling22 - like yeelink.light.ceiling1 but without "lan_ctrl" # {'name': '', 'lan_ctrl': '', 'save_state': '1', 'delayoff': '0', 'music_on': '', 'power': 'off', 'bright': '84', 'color_mode': '2', 'rgb': '', 'hue': '', 'sat': '', 'ct': '4000', 'flowing': '0', 'flow_params': '0,0,800,2,2700,50,800,2,2700,30,1200,2,2700,80,800,2,2700,60,1200,2,2700,90,2400,2,2700,50,1200,2,2700,80,800,2,2700,60,400,2,2700,70', 'active_mode': '0', 'nl_br': '0', 'bg_power': '', 'bg_bright': '', 'bg_lmode': '', 'bg_rgb': '', 'bg_hue': '', 'bg_sat': '', 'bg_ct': '', 'bg_flowing': '', 'bg_flow_params': ''} # yeelink.light.color3, yeelink.light.color4, yeelink.light.color5, yeelink.light.strip2 # {'name': '', 'lan_ctrl': '1', 'save_state': '1', 'delayoff': '0', 'music_on': '0', 'power': 'off', 'bright': '100', 'color_mode': '1', 'rgb': '2353663', 'hue': '186', 'sat': '86', 'ct': '6500', 'flowing': '0', 'flow_params': '0,0,1000,1,16711680,100,1000,1,65280,100,1000,1,255,100', 'active_mode': '', 'nl_br': '', 'bg_power': '', 'bg_bright': '', 'bg_lmode': '', 'bg_rgb': '', 'bg_hue': '', 'bg_sat': '', 'bg_ct': '', 'bg_flowing': '', 'bg_flow_params': ''} self.data = data @property def is_on(self) -> bool: """Return whether the light is on or off.""" return self.lights[0].is_on @property def brightness(self) -> int: """Return current brightness.""" return self.lights[0].brightness @property def rgb(self) -> Optional[Tuple[int, int, int]]: """Return color in RGB if RGB mode is active.""" return self.lights[0].rgb @property def color_mode(self) -> YeelightMode: """Return current color mode.""" return self.lights[0].color_mode @property def hsv(self) -> Optional[Tuple[int, int, int]]: """Return current color in HSV if HSV mode is active.""" return self.lights[0].hsv @property def color_temp(self) -> Optional[int]: """Return current color temperature, if applicable.""" return self.lights[0].color_temp @property def color_flowing(self) -> bool: """Return whether the color flowing is active.""" return self.lights[0].color_flowing @property def color_flow_params(self) -> Optional[str]: """Return color flowing params.""" return self.lights[0].color_flow_params @property def developer_mode(self) -> Optional[bool]: """Return whether the developer mode is active.""" lan_ctrl = self.data["lan_ctrl"] if lan_ctrl: return bool(int(lan_ctrl)) return None @property def save_state_on_change(self) -> bool: """Return whether the bulb state is saved on change.""" return bool(int(self.data["save_state"])) @property def name(self) -> str: """Return the internal name of the bulb.""" return self.data["name"] @property def delay_off(self) -> int: """Return delay in minute before bulb is off.""" return int(self.data["delayoff"]) @property def music_mode(self) -> Optional[bool]: """Return whether the music mode is active.""" music_on = self.data["music_on"] if music_on: return bool(int(music_on)) return None @property def moonlight_mode(self) -> Optional[bool]: """Return whether the moonlight mode is active.""" active_mode = self.data["active_mode"] if active_mode: return bool(int(active_mode)) return None @property def moonlight_mode_brightness(self) -> Optional[int]: """Return current moonlight brightness.""" nl_br = self.data["nl_br"] if nl_br: return int(self.data["nl_br"]) return None @property def lights(self) -> List[YeelightSubLight]: """Return list of sub lights.""" sub_lights = list({YeelightSubLight(self.data, YeelightSubLightType.Main)}) bg_power = self.data[ "bg_power" ] # to do: change this to model spec in the future. if bg_power: sub_lights.append( YeelightSubLight(self.data, YeelightSubLightType.Background) ) return sub_lights @property def cli_format(self) -> str: """Return human readable sub lights string.""" s = f"Name: {self.name}\n" s += f"Update default on change: {self.save_state_on_change}\n" s += f"Delay in minute before off: {self.delay_off}\n" if self.music_mode is not None: s += f"Music mode: {self.music_mode}\n" if self.developer_mode is not None: s += f"Developer mode: {self.developer_mode}\n" for light in self.lights: s += f"{light.type.name} light\n" s += f" Power: {light.is_on}\n" s += f" Brightness: {light.brightness}\n" s += f" Color mode: {light.color_mode.name}\n" if light.color_mode == YeelightMode.RGB: s += f" RGB: {light.rgb}\n" elif light.color_mode == YeelightMode.HSV: s += f" HSV: {light.hsv}\n" else: s += f" Temperature: {light.color_temp}\n" s += f" Color flowing mode: {light.color_flowing}\n" if light.color_flowing: s += f" Color flowing parameters: {light.color_flow_params}\n" if self.moonlight_mode is not None: s += "Moonlight\n" s += f" Is in mode: {self.moonlight_mode}\n" s += f" Moonlight mode brightness: {self.moonlight_mode_brightness}\n" s += "\n" return s class Yeelight(Device): """A rudimentary support for Yeelight bulbs. The API is the same as defined in https://www.yeelight.com/download/Yeelight_Inter-Operation_Spec.pdf and only partially implmented here. For a more complete implementation please refer to python-yeelight package (https://yeelight.readthedocs.io/en/latest/), which however requires enabling the developer mode on the bulbs. """ _supported_models: List[str] = [] _spec_helper = None def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, model: str = None, ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover, model=model) if Yeelight._spec_helper is None: Yeelight._spec_helper = YeelightSpecHelper() Yeelight._supported_models = Yeelight._spec_helper.supported_models self._model_info = Yeelight._spec_helper.get_model_info(self.model) self._light_type = YeelightSubLightType.Main self._light_info = self._model_info.lamps[self._light_type] self._color_temp_range = self._light_info.color_temp @command(default_output=format_output("", "{result.cli_format}")) def status(self) -> YeelightStatus: """Retrieve properties.""" properties = [ # general properties "name", "lan_ctrl", "save_state", "delayoff", "music_on", # light properties "power", "bright", "color_mode", "rgb", "hue", "sat", "ct", "flowing", "flow_params", # moonlight properties "active_mode", "nl_br", # background light properties "bg_power", "bg_bright", "bg_lmode", "bg_rgb", "bg_hue", "bg_sat", "bg_ct", "bg_flowing", "bg_flow_params", ] values = self.get_properties(properties) return YeelightStatus(dict(zip(properties, values))) @property def valid_temperature_range(self) -> ColorTempRange: return self._color_temp_range @command( click.option("--transition", type=int, required=False, default=0), click.option("--mode", type=int, required=False, default=0), default_output=format_output("Powering on"), ) def on(self, transition=0, mode=0): """Power on. set_power ["on|off", "sudden|smooth", time_in_ms, mode] where mode: 0: last mode 1: normal mode 2: rgb mode 3: hsv mode 4: color flow 5: moonlight """ if transition > 0 or mode > 0: return self.send("set_power", ["on", "smooth", transition, mode]) return self.send("set_power", ["on"]) @command( click.option("--transition", type=int, required=False, default=0), default_output=format_output("Powering off"), ) def off(self, transition=0): """Power off.""" if transition > 0: return self.send("set_power", ["off", "smooth", transition]) return self.send("set_power", ["off"]) @command( click.argument("level", type=int), click.option("--transition", type=int, required=False, default=0), default_output=format_output("Setting brightness to {level}"), ) def set_brightness(self, level, transition=0): """Set brightness.""" if level < 0 or level > 100: raise YeelightException("Invalid brightness: %s" % level) if transition > 0: return self.send("set_bright", [level, "smooth", transition]) return self.send("set_bright", [level]) @command( click.argument("level", type=int), click.option("--transition", type=int, required=False, default=0), default_output=format_output("Setting color temperature to {level}"), ) def set_color_temp(self, level, transition=500): """Set color temp in kelvin.""" if ( level > self.valid_temperature_range.max or level < self.valid_temperature_range.min ): raise YeelightException("Invalid color temperature: %s" % level) if transition > 0: return self.send("set_ct_abx", [level, "smooth", transition]) else: # Bedside lamp requires transition return self.send("set_ct_abx", [level, "sudden", 0]) @command( click.argument("rgb", default=[255] * 3, type=click.Tuple([int, int, int])), default_output=format_output("Setting color to {rgb}"), ) def set_rgb(self, rgb: Tuple[int, int, int]): """Set color in RGB.""" for color in rgb: if color < 0 or color > 255: raise YeelightException("Invalid color: %s" % color) return self.send("set_rgb", [rgb_to_int(rgb)]) def set_hsv(self, hsv): """Set color in HSV.""" return self.send("set_hsv", [hsv]) @command( click.argument("enable", type=bool), default_output=format_output("Setting developer mode to {enable}"), ) def set_developer_mode(self, enable: bool) -> bool: """Enable or disable the developer mode.""" return self.send("set_ps", ["cfg_lan_ctrl", str(int(enable))]) @command( click.argument("enable", type=bool), default_output=format_output("Setting save state on change {enable}"), ) def set_save_state_on_change(self, enable: bool) -> bool: """Enable or disable saving the state on changes.""" return self.send("set_ps", ["cfg_save_state", str(int(enable))]) @command( click.argument("name", type=str), default_output=format_output("Setting name to {name}"), ) def set_name(self, name: str) -> bool: """Set an internal name for the bulb.""" return self.send("set_name", [name]) @command(default_output=format_output("Toggling the bulb")) def toggle(self): """Toggle bulb state.""" return self.send("toggle") @command(default_output=format_output("Setting current settings to default")) def set_default(self): """Set current state as default.""" return self.send("set_default") @command(click.argument("table", default="evtRuleTbl")) def dump_ble_debug(self, table): """Dump the BLE debug table, defaults to evtRuleTbl. Some Yeelight devices offer support for BLE remotes. This command allows dumping the information about paired remotes, that can be used to decrypt the beacon payloads from these devices. Example: [{'mac': 'xxx', 'evtid': 4097, 'pid': 950, 'beaconkey': 'xxx'}, {'mac': 'xxx', 'evtid': 4097, 'pid': 339, 'beaconkey': 'xxx'}] """ return self.send("ble_dbg_tbl_dump", {"table": table}) def set_scene(self, scene, *vals): """Set the scene.""" raise NotImplementedError("Setting the scene is not implemented yet.") # return self.send("set_scene", [scene, *vals]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/petwaterdispenser/__init__.py0000644000000000000000000000006514265350055022333 0ustar00# flake8: noqa from .device import PetWaterDispenser ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/petwaterdispenser/device.py0000644000000000000000000001257714265350055022046 0ustar00import logging from typing import Any, Dict, List import click from miio.click_common import EnumType, command, format_output from miio.miot_device import MiotDevice from .status import OperatingMode, PetWaterDispenserStatus _LOGGER = logging.getLogger(__name__) MODEL_MMGG_PET_WATERER_S1 = "mmgg.pet_waterer.s1" MODEL_MMGG_PET_WATERER_S4 = "mmgg.pet_waterer.s4" SUPPORTED_MODELS: List[str] = [MODEL_MMGG_PET_WATERER_S1, MODEL_MMGG_PET_WATERER_S4] _MAPPING: Dict[str, Dict[str, int]] = { # https://home.miot-spec.com/spec/mmgg.pet_waterer.s1 # https://home.miot-spec.com/spec/mmgg.pet_waterer.s4 "cotton_left_time": {"siid": 5, "piid": 1}, "reset_cotton_life": {"siid": 5, "aiid": 1}, "reset_clean_time": {"siid": 6, "aiid": 1}, "fault": {"siid": 2, "piid": 1}, "filter_left_time": {"siid": 3, "piid": 1}, "indicator_light": {"siid": 4, "piid": 1}, "lid_up_flag": {"siid": 7, "piid": 4}, # missing on mmgg.pet_waterer.s4 "location": {"siid": 9, "piid": 2}, "mode": {"siid": 2, "piid": 3}, "no_water_flag": {"siid": 7, "piid": 1}, "no_water_time": {"siid": 7, "piid": 2}, "on": {"siid": 2, "piid": 2}, "pump_block_flag": {"siid": 7, "piid": 3}, "remain_clean_time": {"siid": 6, "piid": 1}, "reset_filter_life": {"siid": 3, "aiid": 1}, "reset_device": {"siid": 8, "aiid": 1}, "timezone": {"siid": 9, "piid": 1}, } MIOT_MAPPING = {model: _MAPPING for model in SUPPORTED_MODELS} class PetWaterDispenser(MiotDevice): """Main class representing the Pet Waterer / Pet Drinking Fountain / Smart Pet Water Dispenser.""" _mappings = MIOT_MAPPING @command( default_output=format_output( "", "On: {result.is_on}\n" "Mode: {result.mode}\n" "LED on: {result.is_led_on}\n" "Lid up: {result.is_lid_up}\n" "No water: {result.is_no_water}\n" "Time without water: {result.no_water_minutes}\n" "Pump blocked: {result.is_pump_blocked}\n" "Error detected: {result.is_error_detected}\n" "Days before cleaning left: {result.before_cleaning_days}\n" "Cotton filter live left: {result.cotton_left_days}\n" "Sponge filter live left: {result.sponge_filter_left_days}\n" "Location: {result.location}\n" "Timezone: {result.timezone}\n", ) ) def status(self) -> PetWaterDispenserStatus: """Retrieve properties.""" data = { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() } _LOGGER.debug(data) return PetWaterDispenserStatus(data) @command(default_output=format_output("Turning device on")) def on(self) -> List[Dict[str, Any]]: """Turn device on.""" return self.set_property("on", True) @command(default_output=format_output("Turning device off")) def off(self) -> List[Dict[str, Any]]: """Turn device off.""" return self.set_property("on", False) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning LED on" if led else "Turning LED off" ), ) def set_led(self, led: bool) -> List[Dict[str, Any]]: """Toggle indicator light on/off.""" if led: return self.set_property("indicator_light", True) return self.set_property("indicator_light", False) @command( click.argument("mode", type=EnumType(OperatingMode)), default_output=format_output('Changing mode to "{mode.name}"'), ) def set_mode(self, mode: OperatingMode) -> List[Dict[str, Any]]: """Switch operation mode.""" return self.set_property("mode", mode.value) @command(default_output=format_output("Resetting sponge filter")) def reset_sponge_filter(self) -> Dict[str, Any]: """Reset sponge filter.""" return self.call_action("reset_filter_life") @command(default_output=format_output("Resetting cotton filter")) def reset_cotton_filter(self) -> Dict[str, Any]: """Reset cotton filter.""" return self.call_action("reset_cotton_life") @command(default_output=format_output("Resetting all filters")) def reset_all_filters(self) -> List[Dict[str, Any]]: """Reset all filters [cotton, sponge].""" return [self.reset_cotton_filter(), self.reset_sponge_filter()] @command(default_output=format_output("Resetting cleaning time")) def reset_cleaning_time(self) -> Dict[str, Any]: """Reset cleaning time counter.""" return self.call_action("reset_clean_time") @command(default_output=format_output("Resetting device")) def reset(self) -> Dict[str, Any]: """Reset device.""" return self.call_action("reset_device") @command( click.argument("timezone", type=click.IntRange(-12, 12)), default_output=format_output('Changing timezone to "{timezone}"'), ) def set_timezone(self, timezone: int) -> List[Dict[str, Any]]: """Change timezone.""" return self.set_property("timezone", timezone) @command( click.argument("location", type=str), default_output=format_output('Changing location to "{location}"'), ) def set_location(self, location: str) -> List[Dict[str, Any]]: """Change location.""" return self.set_property("location", location) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5306137 python-miio-0.5.12/miio/integrations/petwaterdispenser/status.py0000644000000000000000000000650214265350055022121 0ustar00import enum from datetime import timedelta from typing import Any, Dict from miio.miot_device import DeviceStatus class OperatingMode(enum.Enum): Normal = 1 Smart = 2 class PetWaterDispenserStatus(DeviceStatus): """Container for status reports from Pet Water Dispenser.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of Pet Water Dispenser (mmgg.pet_waterer.s1) [ {'code': 0, 'did': 'cotton_left_time', 'piid': 1, 'siid': 5, 'value': 10}, {'code': 0, 'did': 'fault', 'piid': 1, 'siid': 2, 'value': 0}, {'code': 0, 'did': 'filter_left_time', 'piid': 1, 'siid': 3, 'value': 10}, {'code': 0, 'did': 'indicator_light', 'piid': 1, 'siid': 4, 'value': True}, {'code': 0, 'did': 'lid_up_flag', 'piid': 4, 'siid': 7, 'value': False}, {'code': 0, 'did': 'location', 'piid': 2, 'siid': 9, 'value': 'ru'}, {'code': 0, 'did': 'mode', 'piid': 3, 'siid': 2, 'value': 1}, {'code': 0, 'did': 'no_water_flag', 'piid': 1, 'siid': 7, 'value': True}, {'code': 0, 'did': 'no_water_time', 'piid': 2, 'siid': 7, 'value': 0}, {'code': 0, 'did': 'on', 'piid': 2, 'siid': 2, 'value': True}, {'code': 0, 'did': 'pump_block_flag', 'piid': 3, 'siid': 7, 'value': False}, {'code': 0, 'did': 'remain_clean_time', 'piid': 1, 'siid': 6, 'value': 4}, {'code': 0, 'did': 'timezone', 'piid': 1, 'siid': 9, 'value': 3} ] """ self.data = data @property def sponge_filter_left_days(self) -> timedelta: """Filter life time remaining in days.""" return timedelta(days=self.data["filter_left_time"]) @property def is_on(self) -> bool: """True if device is on.""" return self.data["on"] @property def mode(self) -> OperatingMode: """OperatingMode.""" return OperatingMode(self.data["mode"]) @property def is_led_on(self) -> bool: """True if enabled.""" return self.data["indicator_light"] @property def cotton_left_days(self) -> timedelta: """Cotton filter life time remaining in days.""" return timedelta(days=self.data["cotton_left_time"]) @property def before_cleaning_days(self) -> timedelta: """Days before cleaning.""" return timedelta(days=self.data["remain_clean_time"]) @property def is_no_water(self) -> bool: """True if there is no water left.""" if self.data["no_water_flag"]: return False return True @property def no_water_minutes(self) -> timedelta: """Minutes without water.""" return timedelta(minutes=self.data["no_water_time"]) @property def is_pump_blocked(self) -> bool: """True if pump is blocked.""" return self.data["pump_block_flag"] @property def is_lid_up(self) -> bool: """True if lid is up.""" return self.data["lid_up_flag"] @property def timezone(self) -> int: """Timezone from -12 to +12.""" return self.data["timezone"] @property def location(self) -> str: """Device location string.""" return self.data["location"] @property def is_error_detected(self) -> bool: """True if fault detected.""" return self.data["fault"] > 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/petwaterdispenser/tests/__init__.py0000644000000000000000000000000014265350055023462 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/petwaterdispenser/tests/test_status.py0000644000000000000000000000204314265350055024316 0ustar00from datetime import timedelta from ..status import OperatingMode, PetWaterDispenserStatus data = { "cotton_left_time": 10, "fault": 0, "filter_left_time": 10, "indicator_light": True, "lid_up_flag": False, "location": "ru", "mode": 1, "no_water_flag": True, "no_water_time": 0, "on": True, "pump_block_flag": False, "remain_clean_time": 2, "timezone": 3, } def test_status(): status = PetWaterDispenserStatus(data) assert status.is_on is True assert status.sponge_filter_left_days == timedelta(days=10) assert status.mode == OperatingMode(1) assert status.is_led_on is True assert status.cotton_left_days == timedelta(days=10) assert status.before_cleaning_days == timedelta(days=2) assert status.is_no_water is False assert status.no_water_minutes == timedelta(minutes=0) assert status.is_pump_blocked is False assert status.is_lid_up is False assert status.timezone == 3 assert status.location == "ru" assert status.is_error_detected is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/__init__.py0000644000000000000000000000017514265350055020065 0ustar00# flake8: noqa from .dreame import * from .mijia import * from .roborock import * from .roidmi import * from .viomi import * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/dreame/__init__.py0000644000000000000000000000007314265350055021317 0ustar00# flake8: noqa from .dreamevacuum_miot import DreameVacuum ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/dreame/dreamevacuum_miot.py0000644000000000000000000005241014265350055023270 0ustar00"""Dreame Vacuum.""" import logging import threading from enum import Enum from typing import Dict, Optional import click from miio.click_common import command, format_output from miio.exceptions import DeviceException from miio.interfaces import FanspeedPresets, VacuumInterface from miio.miot_device import DeviceStatus as DeviceStatusContainer from miio.miot_device import MiotDevice, MiotMapping from miio.updater import OneShotServer _LOGGER = logging.getLogger(__name__) DREAME_1C = "dreame.vacuum.mc1808" DREAME_F9 = "dreame.vacuum.p2008" DREAME_D9 = "dreame.vacuum.p2009" DREAME_Z10_PRO = "dreame.vacuum.p2028" DREAME_MOP_2_PRO_PLUS = "dreame.vacuum.p2041o" DREAME_MOP_2_ULTRA = "dreame.vacuum.p2150a" DREAME_MOP_2 = "dreame.vacuum.p2150o" _DREAME_1C_MAPPING: MiotMapping = { # https://home.miot-spec.com/spec/dreame.vacuum.mc1808 "battery_level": {"siid": 2, "piid": 1}, "charging_state": {"siid": 2, "piid": 2}, "device_fault": {"siid": 3, "piid": 1}, "device_status": {"siid": 3, "piid": 2}, "brush_left_time": {"siid": 26, "piid": 1}, "brush_life_level": {"siid": 26, "piid": 2}, "filter_life_level": {"siid": 27, "piid": 1}, "filter_left_time": {"siid": 27, "piid": 2}, "brush_left_time2": {"siid": 28, "piid": 1}, "brush_life_level2": {"siid": 28, "piid": 2}, "operating_mode": {"siid": 18, "piid": 1}, "cleaning_mode": {"siid": 18, "piid": 6}, "delete_timer": {"siid": 18, "piid": 8}, "cleaning_time": {"siid": 18, "piid": 2}, "cleaning_area": {"siid": 18, "piid": 4}, "first_clean_time": {"siid": 18, "piid": 12}, "total_clean_time": {"siid": 18, "piid": 13}, "total_clean_times": {"siid": 18, "piid": 14}, "total_clean_area": {"siid": 18, "piid": 15}, "life_sieve": {"siid": 19, "piid": 1}, "life_brush_side": {"siid": 19, "piid": 2}, "life_brush_main": {"siid": 19, "piid": 3}, "timer_enable": {"siid": 20, "piid": 1}, "start_time": {"siid": 20, "piid": 2}, "stop_time": {"siid": 20, "piid": 3}, "deg": {"siid": 21, "piid": 1, "access": ["write"]}, "speed": {"siid": 21, "piid": 2, "access": ["write"]}, "map_view": {"siid": 23, "piid": 1}, "frame_info": {"siid": 23, "piid": 2}, "volume": {"siid": 24, "piid": 1}, "voice_package": {"siid": 24, "piid": 3}, "timezone": {"siid": 25, "piid": 1}, "home": {"siid": 2, "aiid": 1}, "locate": {"siid": 17, "aiid": 1}, "start_clean": {"siid": 3, "aiid": 1}, "stop_clean": {"siid": 3, "aiid": 2}, "reset_mainbrush_life": {"siid": 26, "aiid": 1}, "reset_filter_life": {"siid": 27, "aiid": 1}, "reset_sidebrush_life": {"siid": 28, "aiid": 1}, "move": {"siid": 21, "aiid": 1}, "play_sound": {"siid": 24, "aiid": 3}, "set_voice": {"siid": 24, "aiid": 2}, } _DREAME_F9_MAPPING: MiotMapping = { # https://home.miot-spec.com/spec/dreame.vacuum.p2008 # https://home.miot-spec.com/spec/dreame.vacuum.p2009 # https://home.miot-spec.com/spec/dreame.vacuum.p2028 # https://home.miot-spec.com/spec/dreame.vacuum.p2041o # https://home.miot-spec.com/spec/dreame.vacuum.p2150a # https://home.miot-spec.com/spec/dreame.vacuum.p2150o "battery_level": {"siid": 3, "piid": 1}, "charging_state": {"siid": 3, "piid": 2}, "device_fault": {"siid": 2, "piid": 2}, "device_status": {"siid": 2, "piid": 1}, "brush_left_time": {"siid": 9, "piid": 1}, "brush_life_level": {"siid": 9, "piid": 2}, "filter_life_level": {"siid": 11, "piid": 1}, "filter_left_time": {"siid": 11, "piid": 2}, "brush_left_time2": {"siid": 10, "piid": 1}, "brush_life_level2": {"siid": 10, "piid": 2}, "operating_mode": {"siid": 4, "piid": 1}, "cleaning_mode": {"siid": 4, "piid": 4}, "delete_timer": {"siid": 18, "piid": 8}, "timer_enable": {"siid": 5, "piid": 1}, "cleaning_time": {"siid": 4, "piid": 2}, "cleaning_area": {"siid": 4, "piid": 3}, "first_clean_time": {"siid": 12, "piid": 1}, "total_clean_time": {"siid": 12, "piid": 2}, "total_clean_times": {"siid": 12, "piid": 3}, "total_clean_area": {"siid": 12, "piid": 4}, "start_time": {"siid": 5, "piid": 2}, "stop_time": {"siid": 5, "piid": 3}, "map_view": {"siid": 6, "piid": 1}, "frame_info": {"siid": 6, "piid": 2}, "volume": {"siid": 7, "piid": 1}, "voice_package": {"siid": 7, "piid": 2}, "water_flow": {"siid": 4, "piid": 5}, "water_box_carriage_status": {"siid": 4, "piid": 6}, "timezone": {"siid": 8, "piid": 1}, "home": {"siid": 3, "aiid": 1}, "locate": {"siid": 7, "aiid": 1}, "start_clean": {"siid": 4, "aiid": 1}, "stop_clean": {"siid": 4, "aiid": 2}, "reset_mainbrush_life": {"siid": 9, "aiid": 1}, "reset_filter_life": {"siid": 11, "aiid": 1}, "reset_sidebrush_life": {"siid": 10, "aiid": 1}, "move": {"siid": 21, "aiid": 1}, "play_sound": {"siid": 7, "aiid": 2}, } MIOT_MAPPING: Dict[str, MiotMapping] = { DREAME_1C: _DREAME_1C_MAPPING, DREAME_F9: _DREAME_F9_MAPPING, DREAME_D9: _DREAME_F9_MAPPING, DREAME_Z10_PRO: _DREAME_F9_MAPPING, DREAME_MOP_2_PRO_PLUS: _DREAME_F9_MAPPING, DREAME_MOP_2_ULTRA: _DREAME_F9_MAPPING, DREAME_MOP_2: _DREAME_F9_MAPPING, } class FormattableEnum(Enum): def __str__(self): return f"{self.name}" class ChargingState(FormattableEnum): Charging = 1 Discharging = 2 Charging2 = 4 GoCharging = 5 class CleaningModeDreame1C(FormattableEnum): Quiet = 0 Default = 1 Medium = 2 Strong = 3 class CleaningModeDreameF9(FormattableEnum): Quiet = 0 Standart = 1 Strong = 2 Turbo = 3 class OperatingMode(FormattableEnum): Paused = 1 Cleaning = 2 GoCharging = 3 Charging = 6 ManualCleaning = 13 Sleeping = 14 ManualPaused = 17 ZonedCleaning = 19 class FaultStatus(FormattableEnum): NoFaults = 0 class DeviceStatus(FormattableEnum): Sweeping = 1 Idle = 2 Paused = 3 Error = 4 GoCharging = 5 Charging = 6 Mopping = 7 ManualSweeping = 13 class WaterFlow(FormattableEnum): Low = 1 Medium = 2 High = 3 def _enum_as_dict(cls): return {x.name: x.value for x in list(cls)} def _get_cleaning_mode_enum_class(model): """Return cleaning mode enum class for model if found or None.""" if model == DREAME_1C: return CleaningModeDreame1C elif model in ( DREAME_F9, DREAME_D9, DREAME_Z10_PRO, DREAME_MOP_2_PRO_PLUS, DREAME_MOP_2_ULTRA, DREAME_MOP_2, ): return CleaningModeDreameF9 return None class DreameVacuumStatus(DeviceStatusContainer): """Container for status reports from the dreame vacuum. Dreame vacuum respone { 'battery_level': 100, 'brush_left_time': 260, 'brush_left_time2': 200, 'brush_life_level': 90, 'brush_life_level2': 90, 'charging_state': 1, 'cleaning_area': 22, 'cleaning_mode': 2, 'cleaning_time': 17, 'device_fault': 0, 'device_status': 6, 'filter_left_time': 120, 'filter_life_level': 40, 'first_clean_time': 1620154830, 'operating_mode': 6, 'start_time': '22:00', 'stop_time': '08:00', 'timer_enable': True, 'timezone': 'Europe/Berlin', 'total_clean_area': 205, 'total_clean_time': 186, 'total_clean_times': 21, 'voice_package': 'DR0', 'volume': 65, 'water_box_carriage_status': 0, 'water_flow': 3 } """ def __init__(self, data, model): self.data = data self.model = model @property def battery_level(self) -> str: return self.data["battery_level"] @property def brush_left_time(self) -> str: return self.data["brush_left_time"] @property def brush_left_time2(self) -> str: return self.data["brush_left_time2"] @property def brush_life_level2(self) -> str: return self.data["brush_life_level2"] @property def brush_life_level(self) -> str: return self.data["brush_life_level"] @property def filter_left_time(self) -> str: return self.data["filter_left_time"] @property def filter_life_level(self) -> str: return self.data["filter_life_level"] @property def device_fault(self) -> Optional[FaultStatus]: try: return FaultStatus(self.data["device_fault"]) except ValueError: _LOGGER.error("Unknown FaultStatus (%s)", self.data["device_fault"]) return None @property def charging_state(self) -> Optional[ChargingState]: try: return ChargingState(self.data["charging_state"]) except ValueError: _LOGGER.error("Unknown ChargingStats (%s)", self.data["charging_state"]) return None @property def operating_mode(self) -> Optional[OperatingMode]: try: return OperatingMode(self.data["operating_mode"]) except ValueError: _LOGGER.error("Unknown OperatingMode (%s)", self.data["operating_mode"]) return None @property def device_status(self) -> Optional[DeviceStatus]: try: return DeviceStatus(self.data["device_status"]) except TypeError: _LOGGER.error("Unknown DeviceStatus (%s)", self.data["device_status"]) return None @property def timer_enable(self) -> str: return self.data["timer_enable"] @property def start_time(self) -> str: return self.data["start_time"] @property def stop_time(self) -> str: return self.data["stop_time"] @property def map_view(self) -> str: return self.data["map_view"] @property def volume(self) -> str: return self.data["volume"] @property def voice_package(self) -> str: return self.data["voice_package"] @property def timezone(self) -> str: return self.data["timezone"] @property def cleaning_time(self) -> str: return self.data["cleaning_time"] @property def cleaning_area(self) -> str: return self.data["cleaning_area"] @property def first_clean_time(self) -> str: return self.data["first_clean_time"] @property def total_clean_time(self) -> str: return self.data["total_clean_time"] @property def total_clean_times(self) -> str: return self.data["total_clean_times"] @property def total_clean_area(self) -> str: return self.data["total_clean_area"] @property def cleaning_mode(self): cleaning_mode = self.data["cleaning_mode"] cleaning_mode_enum_class = _get_cleaning_mode_enum_class(self.model) if not cleaning_mode_enum_class: _LOGGER.error(f"Unknown model for cleaning mode ({self.model})") return None try: return cleaning_mode_enum_class(cleaning_mode) except ValueError: _LOGGER.error(f"Unknown CleaningMode ({cleaning_mode})") return None @property def life_sieve(self) -> Optional[str]: return self.data.get("life_sieve") @property def life_brush_side(self) -> Optional[str]: return self.data.get("life_brush_side") @property def life_brush_main(self) -> Optional[str]: return self.data.get("life_brush_main") # TODO: get/set water flow for Dreame 1C @property def water_flow(self) -> Optional[WaterFlow]: try: water_flow = self.data["water_flow"] except KeyError: return None try: return WaterFlow(water_flow) except ValueError: _LOGGER.error("Unknown WaterFlow (%s)", self.data["water_flow"]) return None @property def is_water_box_carriage_attached(self) -> Optional[bool]: """Return True if water box carriage (mop) is installed, None if sensor not present.""" if "water_box_carriage_status" in self.data: return self.data["water_box_carriage_status"] == 1 return None class DreameVacuum(MiotDevice, VacuumInterface): _mappings = MIOT_MAPPING @command( default_output=format_output( "\n", "Battery level: {result.battery_level}\n" "Brush life level: {result.brush_life_level}\n" "Brush left time: {result.brush_left_time}\n" "Charging state: {result.charging_state}\n" "Cleaning mode: {result.cleaning_mode}\n" "Device fault: {result.device_fault}\n" "Device status: {result.device_status}\n" "Filter left level: {result.filter_left_time}\n" "Filter life level: {result.filter_life_level}\n" "Life brush main: {result.life_brush_main}\n" "Life brush side: {result.life_brush_side}\n" "Life sieve: {result.life_sieve}\n" "Map view: {result.map_view}\n" "Operating mode: {result.operating_mode}\n" "Side cleaning brush left time: {result.brush_left_time2}\n" "Side cleaning brush life level: {result.brush_life_level2}\n" "Time zone: {result.timezone}\n" "Timer enabled: {result.timer_enable}\n" "Timer start time: {result.start_time}\n" "Timer stop time: {result.stop_time}\n" "Voice package: {result.voice_package}\n" "Volume: {result.volume}\n" "Water flow: {result.water_flow}\n" "Water box attached: {result.is_water_box_carriage_attached} \n" "Cleaning time: {result.cleaning_time}\n" "Cleaning area: {result.cleaning_area}\n" "First clean time: {result.first_clean_time}\n" "Total clean time: {result.total_clean_time}\n" "Total clean times: {result.total_clean_times}\n" "Total clean area: {result.total_clean_area}\n", ) ) def status(self) -> DreameVacuumStatus: """State of the vacuum.""" return DreameVacuumStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping(max_properties=10) }, self.model, ) # TODO: check the actual limit for this MANUAL_ROTATION_MAX = 120 MANUAL_ROTATION_MIN = -MANUAL_ROTATION_MAX MANUAL_DISTANCE_MAX = 300 MANUAL_DISTANCE_MIN = -300 @command() def start(self) -> None: """Start cleaning.""" return self.call_action("start_clean") @command() def stop(self) -> None: """Stop cleaning.""" return self.call_action("stop_clean") @command() def home(self) -> None: """Return to home.""" return self.call_action("home") @command() def identify(self) -> None: """Locate the device (i am here).""" return self.call_action("locate") @command() def reset_mainbrush_life(self) -> None: """Reset main brush life.""" return self.call_action("reset_mainbrush_life") @command() def reset_filter_life(self) -> None: """Reset filter life.""" return self.call_action("reset_filter_life") @command() def reset_sidebrush_life(self) -> None: """Reset side brush life.""" return self.call_action("reset_sidebrush_life") @command() def play_sound(self) -> None: """Play sound.""" return self.call_action("play_sound") @command() def fan_speed(self): """Return fan speed.""" dreame_vacuum_status = self.status() fanspeed = dreame_vacuum_status.cleaning_mode if not fanspeed or fanspeed.value == -1: _LOGGER.warning("Unknown fanspeed value received") return return {fanspeed.name: fanspeed.value} @command(click.argument("speed", type=int)) def set_fan_speed(self, speed: int): """Set fan speed. :param int speed: Fan speed to set """ fanspeeds_enum = _get_cleaning_mode_enum_class(self.model) fanspeed = None if not fanspeeds_enum: return try: fanspeed = fanspeeds_enum(speed) except ValueError: _LOGGER.error(f"Unknown fanspeed value passed {speed}") return None click.echo(f"Setting fanspeed to {fanspeed.name}") return self.set_property("cleaning_mode", fanspeed.value) @command() def fan_speed_presets(self) -> FanspeedPresets: """Return available fan speed presets.""" fanspeeds_enum = _get_cleaning_mode_enum_class(self.model) if not fanspeeds_enum: return {} return _enum_as_dict(fanspeeds_enum) @command(click.argument("speed", type=int)) def set_fan_speed_preset(self, speed_preset: int) -> None: """Set fan speed preset speed.""" if speed_preset not in self.fan_speed_presets().values(): raise ValueError( f"Invalid preset speed {speed_preset}, not in: {self.fan_speed_presets().values()}" ) self.set_fan_speed(speed_preset) @command() def waterflow(self): """Get water flow setting.""" dreame_vacuum_status = self.status() waterflow = dreame_vacuum_status.water_flow if not waterflow or waterflow.value == -1: _LOGGER.warning("Unknown waterflow value received") return return {waterflow.name: waterflow.value} @command(click.argument("value", type=int)) def set_waterflow(self, value: int): """Set water flow. :param int value: Water flow value to set """ mapping = self._get_mapping() if "water_flow" not in mapping: return None waterflow = None try: waterflow = WaterFlow(value) except ValueError: _LOGGER.error(f"Unknown waterflow value passed {value}") return None click.echo(f"Setting waterflow to {waterflow.name}") return self.set_property("water_flow", waterflow.value) @command() def waterflow_presets(self) -> Dict[str, int]: """Return dictionary containing supported water flow.""" mapping = self._get_mapping() if "water_flow" not in mapping: return {} return _enum_as_dict(WaterFlow) @command( click.argument("distance", default=30, type=int), ) def forward(self, distance: int) -> None: """Move forward.""" if distance < self.MANUAL_DISTANCE_MIN or distance > self.MANUAL_DISTANCE_MAX: raise DeviceException( "Given distance is invalid, should be [%s, %s], was: %s" % (self.MANUAL_DISTANCE_MIN, self.MANUAL_DISTANCE_MAX, distance) ) self.call_action( "move", [ { "piid": 1, "value": "0", }, { "piid": 2, "value": f"{distance}", }, ], ) @command( click.argument("rotatation", default=90, type=int), ) def rotate(self, rotatation: int) -> None: """Rotate vacuum.""" if ( rotatation < self.MANUAL_ROTATION_MIN or rotatation > self.MANUAL_ROTATION_MAX ): raise DeviceException( "Given rotation is invalid, should be [%s, %s], was %s" % (self.MANUAL_ROTATION_MIN, self.MANUAL_ROTATION_MAX, rotatation) ) self.call_action( "move", [ { "piid": 1, "value": f"{rotatation}", }, { "piid": 2, "value": "0", }, ], ) @command( click.argument("url", type=str), click.argument("md5sum", type=str, required=False), click.argument("size", type=int, default=0), click.argument("voice_id", type=str, default="CP"), ) def set_voice(self, url: str, md5sum: str, size: int, voice_id: str): """Upload voice package. :param str url: URL or path to language pack :param str md5sum: MD5 hash for file if URL used :param int size: File size in bytes if URL used :param str voice_id: In original it is country code for the selected voice pack. You can put here what you like, I guess it doesn't matter (default: CP - Custom Packet) """ local_url = None server = None if url.startswith("http"): if md5sum is None or size == 0: click.echo( "You need to pass md5 and file size when using URL for updating." ) return local_url = url else: server = OneShotServer(file=url) local_url = server.url() md5sum = server.md5 size = len(server.payload) t = threading.Thread(target=server.serve_once) t.start() click.echo(f"Hosting file at {local_url}") params = [ {"piid": 3, "value": voice_id}, {"piid": 4, "value": local_url}, {"piid": 5, "value": md5sum}, {"piid": 6, "value": size}, ] result_status = self.call_action("set_voice", params=params) if result_status["code"] == 0: click.echo("Installation complete!") return result_status ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/dreame/tests/__init__.py0000644000000000000000000000000014265350055022447 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/dreame/tests/test_dreamevacuum_miot.py0000644000000000000000000002345514265350055025500 0ustar00from unittest import TestCase import pytest from miio import DreameVacuum from miio.tests.dummies import DummyMiotDevice from ..dreamevacuum_miot import ( DREAME_1C, DREAME_F9, MIOT_MAPPING, ChargingState, CleaningModeDreame1C, CleaningModeDreameF9, DeviceStatus, FaultStatus, OperatingMode, WaterFlow, ) _INITIAL_STATE_1C = { "battery_level": 42, "charging_state": 1, "device_fault": 0, "device_status": 3, "brush_left_time": 235, "brush_life_level": 85, "filter_life_level": 66, "filter_left_time": 154, "brush_left_time2": 187, "brush_life_level2": 57, "operating_mode": 2, "cleaning_mode": 2, "delete_timer": 12, "life_sieve": "9000-9000", "life_brush_side": "12000-12000", "life_brush_main": "18000-18000", "timer_enable": "false", "start_time": "22:00", "stop_time": "8:00", "deg": 5, "speed": 5, "map_view": "tmp", "frame_info": 3, "volume": 4, "voice_package": "DE", "timezone": "Europe/London", "cleaning_time": 10, "cleaning_area": 20, "first_clean_time": 1640854830, "total_clean_time": 1000, "total_clean_times": 15, "total_clean_area": 500, } _INITIAL_STATE_F9 = { "battery_level": 42, "charging_state": 1, "device_fault": 0, "device_status": 3, "brush_left_time": 235, "brush_life_level": 85, "filter_life_level": 66, "filter_left_time": 154, "brush_left_time2": 187, "brush_life_level2": 57, "operating_mode": 2, "cleaning_mode": 1, "delete_timer": 12, "timer_enable": "false", "start_time": "22:00", "stop_time": "8:00", "map_view": "tmp", "frame_info": 3, "volume": 4, "voice_package": "DE", "water_flow": 2, "water_box_carriage_status": 1, "timezone": "Europe/London", "cleaning_time": 10, "cleaning_area": 20, "first_clean_time": 1640854830, "total_clean_time": 1000, "total_clean_times": 15, "total_clean_area": 500, } class DummyDreame1CVacuumMiot(DummyMiotDevice, DreameVacuum): def __init__(self, *args, **kwargs): self._model = DREAME_1C self.state = _INITIAL_STATE_1C super().__init__(*args, **kwargs) class DummyDreameF9VacuumMiot(DummyMiotDevice, DreameVacuum): def __init__(self, *args, **kwargs): self._model = DREAME_F9 self.state = _INITIAL_STATE_F9 super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def dummydreame1cvacuum(request): request.cls.device = DummyDreame1CVacuumMiot() @pytest.fixture(scope="function") def dummydreamef9vacuum(request): request.cls.device = DummyDreameF9VacuumMiot() @pytest.mark.usefixtures("dummydreame1cvacuum") class TestDreame1CVacuum(TestCase): def test_status(self): status = self.device.status() assert status.battery_level == _INITIAL_STATE_1C["battery_level"] assert status.brush_left_time == _INITIAL_STATE_1C["brush_left_time"] assert status.brush_left_time2 == _INITIAL_STATE_1C["brush_left_time2"] assert status.brush_life_level2 == _INITIAL_STATE_1C["brush_life_level2"] assert status.brush_life_level == _INITIAL_STATE_1C["brush_life_level"] assert status.filter_left_time == _INITIAL_STATE_1C["filter_left_time"] assert status.filter_life_level == _INITIAL_STATE_1C["filter_life_level"] assert status.timezone == _INITIAL_STATE_1C["timezone"] assert status.cleaning_time == _INITIAL_STATE_1C["cleaning_time"] assert status.cleaning_area == _INITIAL_STATE_1C["cleaning_area"] assert status.first_clean_time == _INITIAL_STATE_1C["first_clean_time"] assert status.total_clean_time == _INITIAL_STATE_1C["total_clean_time"] assert status.total_clean_times == _INITIAL_STATE_1C["total_clean_times"] assert status.total_clean_area == _INITIAL_STATE_1C["total_clean_area"] assert status.device_fault == FaultStatus(_INITIAL_STATE_1C["device_fault"]) assert repr(status.device_fault) == repr( FaultStatus(_INITIAL_STATE_1C["device_fault"]) ) assert status.charging_state == ChargingState( _INITIAL_STATE_1C["charging_state"] ) assert repr(status.charging_state) == repr( ChargingState(_INITIAL_STATE_1C["charging_state"]) ) assert status.operating_mode == OperatingMode( _INITIAL_STATE_1C["operating_mode"] ) assert repr(status.operating_mode) == repr( OperatingMode(_INITIAL_STATE_1C["operating_mode"]) ) assert status.cleaning_mode == CleaningModeDreame1C( _INITIAL_STATE_1C["cleaning_mode"] ) assert repr(status.cleaning_mode) == repr( CleaningModeDreame1C(_INITIAL_STATE_1C["cleaning_mode"]) ) assert status.device_status == DeviceStatus(_INITIAL_STATE_1C["device_status"]) assert repr(status.device_status) == repr( DeviceStatus(_INITIAL_STATE_1C["device_status"]) ) assert status.life_sieve == _INITIAL_STATE_1C["life_sieve"] assert status.life_brush_side == _INITIAL_STATE_1C["life_brush_side"] assert status.life_brush_main == _INITIAL_STATE_1C["life_brush_main"] assert status.timer_enable == _INITIAL_STATE_1C["timer_enable"] assert status.start_time == _INITIAL_STATE_1C["start_time"] assert status.stop_time == _INITIAL_STATE_1C["stop_time"] assert status.map_view == _INITIAL_STATE_1C["map_view"] assert status.volume == _INITIAL_STATE_1C["volume"] assert status.voice_package == _INITIAL_STATE_1C["voice_package"] def test_fanspeed_presets(self): presets = self.device.fan_speed_presets() for item in CleaningModeDreame1C: assert item.name in presets assert presets[item.name] == item.value def test_fan_speed(self): value = self.device.fan_speed() assert value == {"Medium": 2} def test_set_fan_speed_preset(self): for speed in self.device.fan_speed_presets().values(): self.device.set_fan_speed_preset(speed) @pytest.mark.usefixtures("dummydreamef9vacuum") class TestDreameF9Vacuum(TestCase): def test_status(self): status = self.device.status() assert status.battery_level == _INITIAL_STATE_F9["battery_level"] assert status.brush_left_time == _INITIAL_STATE_F9["brush_left_time"] assert status.brush_left_time2 == _INITIAL_STATE_F9["brush_left_time2"] assert status.brush_life_level2 == _INITIAL_STATE_F9["brush_life_level2"] assert status.brush_life_level == _INITIAL_STATE_F9["brush_life_level"] assert status.filter_left_time == _INITIAL_STATE_F9["filter_left_time"] assert status.filter_life_level == _INITIAL_STATE_F9["filter_life_level"] assert status.water_flow == WaterFlow(_INITIAL_STATE_F9["water_flow"]) assert status.timezone == _INITIAL_STATE_F9["timezone"] assert status.cleaning_time == _INITIAL_STATE_1C["cleaning_time"] assert status.cleaning_area == _INITIAL_STATE_1C["cleaning_area"] assert status.first_clean_time == _INITIAL_STATE_1C["first_clean_time"] assert status.total_clean_time == _INITIAL_STATE_1C["total_clean_time"] assert status.total_clean_times == _INITIAL_STATE_1C["total_clean_times"] assert status.total_clean_area == _INITIAL_STATE_1C["total_clean_area"] assert status.is_water_box_carriage_attached assert status.device_fault == FaultStatus(_INITIAL_STATE_F9["device_fault"]) assert repr(status.device_fault) == repr( FaultStatus(_INITIAL_STATE_F9["device_fault"]) ) assert status.charging_state == ChargingState( _INITIAL_STATE_F9["charging_state"] ) assert repr(status.charging_state) == repr( ChargingState(_INITIAL_STATE_F9["charging_state"]) ) assert status.operating_mode == OperatingMode( _INITIAL_STATE_F9["operating_mode"] ) assert repr(status.operating_mode) == repr( OperatingMode(_INITIAL_STATE_F9["operating_mode"]) ) assert status.cleaning_mode == CleaningModeDreameF9( _INITIAL_STATE_F9["cleaning_mode"] ) assert repr(status.cleaning_mode) == repr( CleaningModeDreameF9(_INITIAL_STATE_F9["cleaning_mode"]) ) assert status.device_status == DeviceStatus(_INITIAL_STATE_F9["device_status"]) assert repr(status.device_status) == repr( DeviceStatus(_INITIAL_STATE_F9["device_status"]) ) assert status.timer_enable == _INITIAL_STATE_F9["timer_enable"] assert status.start_time == _INITIAL_STATE_F9["start_time"] assert status.stop_time == _INITIAL_STATE_F9["stop_time"] assert status.map_view == _INITIAL_STATE_F9["map_view"] assert status.volume == _INITIAL_STATE_F9["volume"] assert status.voice_package == _INITIAL_STATE_F9["voice_package"] def test_fanspeed_presets(self): presets = self.device.fan_speed_presets() for item in CleaningModeDreameF9: assert item.name in presets assert presets[item.name] == item.value def test_fan_speed(self): value = self.device.fan_speed() assert value == {"Standart": 1} def test_waterflow_presets(self): presets = self.device.waterflow_presets() for item in WaterFlow: assert item.name in presets assert presets[item.name] == item.value def test_waterflow(self): value = self.device.waterflow() assert value == {"Medium": 2} @pytest.mark.parametrize("model", MIOT_MAPPING.keys()) def test_dreame_models(model: str): DreameVacuum(model=model) def test_invalid_dreame_model(): vac = DreameVacuum(model="model.invalid") fp = vac.fan_speed_presets() assert fp == {} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/mijia/__init__.py0000644000000000000000000000005614265350055021154 0ustar00# flake8: noqa from .g1vacuum import G1Vacuum ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/mijia/g1vacuum.py0000644000000000000000000003004114265350055021142 0ustar00import logging from datetime import timedelta from enum import Enum import click from miio.click_common import EnumType, command, format_output from miio.interfaces import FanspeedPresets, VacuumInterface from miio.miot_device import DeviceStatus, MiotDevice _LOGGER = logging.getLogger(__name__) MIJIA_VACUUM_V1 = "mijia.vacuum.v1" MIJIA_VACUUM_V2 = "mijia.vacuum.v2" SUPPORTED_MODELS = [MIJIA_VACUUM_V1, MIJIA_VACUUM_V2] MAPPING = { # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:vacuum:0000A006:mijia-v1:1 "battery": {"siid": 3, "piid": 1}, "charge_state": {"siid": 3, "piid": 2}, "error_code": {"siid": 2, "piid": 2}, "state": {"siid": 2, "piid": 1}, "fan_speed": {"siid": 2, "piid": 6}, "operating_mode": {"siid": 2, "piid": 4}, "mop_state": {"siid": 16, "piid": 1}, "water_level": {"siid": 2, "piid": 5}, "main_brush_life_level": {"siid": 14, "piid": 1}, "main_brush_time_left": {"siid": 14, "piid": 2}, "side_brush_life_level": {"siid": 15, "piid": 1}, "side_brush_time_left": {"siid": 15, "piid": 2}, "filter_life_level": {"siid": 11, "piid": 1}, "filter_time_left": {"siid": 11, "piid": 2}, "clean_area": {"siid": 9, "piid": 1}, "clean_time": {"siid": 9, "piid": 2}, # totals always return 0 "total_clean_area": {"siid": 9, "piid": 3}, "total_clean_time": {"siid": 9, "piid": 4}, "total_clean_count": {"siid": 9, "piid": 5}, "home": {"siid": 2, "aiid": 3}, "find": {"siid": 6, "aiid": 1}, "start": {"siid": 2, "aiid": 1}, "stop": {"siid": 2, "aiid": 2}, "reset_main_brush_life_level": {"siid": 14, "aiid": 1}, "reset_side_brush_life_level": {"siid": 15, "aiid": 1}, "reset_filter_life_level": {"siid": 11, "aiid": 1}, } MIOT_MAPPING = {model: MAPPING for model in SUPPORTED_MODELS} ERROR_CODES = { 0: "No error", 1: "Left Wheel stuck", 2: "Right Wheel stuck", 3: "Cliff error", 4: "Low battery", 5: "Bump error", 6: "Main Brush Error", 7: "Side Brush Error", 8: "Fan Motor Error", 9: "Dustbin Error", 10: "Charging Error", 11: "No Water Error", 12: "Pick Up Error", } class G1ChargeState(Enum): """Charging Status.""" Discharging = 0 Charging = 1 FullyCharged = 2 class G1State(Enum): """Vacuum Status.""" Idle = 1 Sweeping = 2 Paused = 3 Error = 4 Charging = 5 GoCharging = 6 class G1Consumable(Enum): """Consumables.""" MainBrush = "main_brush_life_level" SideBrush = "side_brush_life_level" Filter = "filter_life_level" class G1VacuumMode(Enum): """Vacuum Mode.""" GlobalClean = 1 SpotClean = 2 Wiping = 3 class G1WaterLevel(Enum): """Water Flow Level.""" Level1 = 1 Level2 = 2 Level3 = 3 class G1FanSpeed(Enum): """Fan speeds.""" Mute = 0 Standard = 1 Medium = 2 High = 3 class G1Languages(Enum): """Languages.""" Chinese = 0 English = 1 class G1MopState(Enum): """Mop Status.""" Off = 0 On = 1 class G1Status(DeviceStatus): """Container for status reports from Mijia Vacuum G1.""" def __init__(self, data): """Response (MIoT format) of a Mijia Vacuum G1 (mijia.vacuum.v2) [ {'did': 'battery', 'siid': 3, 'piid': 1, 'code': 0, 'value': 100}, {'did': 'charge_state', 'siid': 3, 'piid': 2, 'code': 0, 'value': 2}, {'did': 'error_code', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0}, {'did': 'state', 'siid': 2, 'piid': 1, 'code': 0, 'value': 5}, {'did': 'fan_speed', 'siid': 2, 'piid': 6, 'code': 0, 'value': 1}, {'did': 'operating_mode', 'siid': 2, 'piid': 4, 'code': 0, 'value': 1}, {'did': 'mop_state', 'siid': 16, 'piid': 1, 'code': 0, 'value': 0}, {'did': 'water_level', 'siid': 2, 'piid': 5, 'code': 0, 'value': 2}, {'did': 'main_brush_life_level', 'siid': 14, 'piid': 1, 'code': 0, 'value': 99}, {'did': 'main_brush_time_left', 'siid': 14, 'piid': 2, 'code': 0, 'value': 17959} {'did': 'side_brush_life_level', 'siid': 15, 'piid': 1, 'code': 0, 'value': 0 }, {'did': 'side_brush_time_left', 'siid': 15, 'piid': 2', 'code': 0, 'value': 0}, {'did': 'filter_life_level', 'siid': 11, 'piid': 1, 'code': 0, 'value': 99}, {'did': 'filter_time_left', 'siid': 11, 'piid': 2, 'code': 0, 'value': 8959}, {'did': 'clean_area', 'siid': 9, 'piid': 1, 'code': 0, 'value': 0}, {'did': 'clean_time', 'siid': 9, 'piid': 2, 'code': 0, 'value': 0} ]""" self.data = data @property def battery(self) -> int: """Battery Level.""" return self.data["battery"] @property def charge_state(self) -> G1ChargeState: """Charging State.""" return G1ChargeState(self.data["charge_state"]) @property def error_code(self) -> int: """Error code as returned by the device.""" return int(self.data["error_code"]) @property def error(self) -> str: """Human readable error description, see also :func:`error_code`.""" try: return ERROR_CODES[self.error_code] except KeyError: return "Definition missing for error %s" % self.error_code @property def state(self) -> G1State: """Vacuum Status.""" return G1State(self.data["state"]) @property def fan_speed(self) -> G1FanSpeed: """Fan Speed.""" return G1FanSpeed(self.data["fan_speed"]) @property def operating_mode(self) -> G1VacuumMode: """Operating Mode.""" return G1VacuumMode(self.data["operating_mode"]) @property def mop_state(self) -> G1MopState: """Mop State.""" return G1MopState(self.data["mop_state"]) @property def water_level(self) -> G1WaterLevel: """Water Level.""" return G1WaterLevel(self.data["water_level"]) @property def main_brush_life_level(self) -> int: """Main Brush Life Level in %.""" return self.data["main_brush_life_level"] @property def main_brush_time_left(self) -> timedelta: """Main Brush Remaining Time in Minutes.""" return timedelta(minutes=self.data["main_brush_time_left"]) @property def side_brush_life_level(self) -> int: """Side Brush Life Level in %.""" return self.data["side_brush_life_level"] @property def side_brush_time_left(self) -> timedelta: """Side Brush Remaining Time in Minutes.""" return timedelta(minutes=self.data["side_brush_time_left"]) @property def filter_life_level(self) -> int: """Filter Life Level in %.""" return self.data["filter_life_level"] @property def filter_time_left(self) -> timedelta: """Filter remaining time.""" return timedelta(minutes=self.data["filter_time_left"]) @property def clean_area(self) -> int: """Clean Area in cm2.""" return self.data["clean_area"] @property def clean_time(self) -> timedelta: """Clean time.""" return timedelta(minutes=self.data["clean_time"]) class G1CleaningSummary(DeviceStatus): """Container for cleaning summary from Mijia Vacuum G1. Response (MIoT format) of a Mijia Vacuum G1 (mijia.vacuum.v2) [ {'did': 'total_clean_area', 'siid': 9, 'piid': 3, 'code': 0, 'value': 0}, {'did': 'total_clean_time', 'siid': 9, 'piid': 4, 'code': 0, 'value': 0}, {'did': 'total_clean_count', 'siid': 9, 'piid': 5, 'code': 0, 'value': 0} ] """ def __init__(self, data) -> None: self.data = data @property def total_clean_count(self) -> int: """Total Number of Cleanings.""" return self.data["total_clean_count"] @property def total_clean_area(self) -> int: """Total Area Cleaned in m2.""" return self.data["total_clean_area"] @property def total_clean_time(self) -> timedelta: """Total Cleaning Time.""" return timedelta(hours=self.data["total_clean_area"]) class G1Vacuum(MiotDevice, VacuumInterface): """Support for G1 vacuum (G1, mijia.vacuum.v2).""" _mappings = MIOT_MAPPING @command( default_output=format_output( "", "State: {result.state}\n" "Error: {result.error}\n" "Battery: {result.battery}%\n" "Mode: {result.operating_mode}\n" "Mop State: {result.mop_state}\n" "Charge Status: {result.charge_state}\n" "Fan speed: {result.fan_speed}\n" "Water level: {result.water_level}\n" "Main Brush Life Level: {result.main_brush_life_level}%\n" "Main Brush Life Time: {result.main_brush_time_left}\n" "Side Brush Life Level: {result.side_brush_life_level}%\n" "Side Brush Life Time: {result.side_brush_time_left}\n" "Filter Life Level: {result.filter_life_level}%\n" "Filter Life Time: {result.filter_time_left}\n" "Clean Area: {result.clean_area}\n" "Clean Time: {result.clean_time}\n", ) ) def status(self) -> G1Status: """Retrieve properties.""" return G1Status( { # max_properties limited to 10 to avoid "Checksum error" # messages from the device. prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping(max_properties=10) } ) @command( default_output=format_output( "", "Total Cleaning Count: {result.total_clean_count}\n" "Total Cleaning Time: {result.total_clean_time}\n" "Total Cleaning Area: {result.total_clean_area}\n", ) ) def cleaning_summary(self) -> G1CleaningSummary: """Retrieve properties.""" return G1CleaningSummary( { # max_properties limited to 10 to avoid "Checksum error" # messages from the device. prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping(max_properties=10) } ) @command() def home(self): """Home.""" return self.call_action("home") @command() def start(self) -> None: """Start Cleaning.""" return self.call_action("start") @command() def stop(self): """Stop Cleaning.""" return self.call_action("stop") @command() def find(self) -> None: """Find the robot.""" return self.call_action("find") @command(click.argument("consumable", type=G1Consumable)) def consumable_reset(self, consumable: G1Consumable): """Reset consumable information. CONSUMABLE=main_brush_life_level|side_brush_life_level|filter_life_level """ if consumable.name == G1Consumable.MainBrush: return self.call_action("reset_main_brush_life_level") elif consumable.name == G1Consumable.SideBrush: return self.call_action("reset_side_brush_life_level") elif consumable.name == G1Consumable.Filter: return self.call_action("reset_filter_life_level") @command( click.argument("fan_speed", type=EnumType(G1FanSpeed)), default_output=format_output("Setting fan speed to {fan_speed}"), ) def set_fan_speed(self, fan_speed: G1FanSpeed): """Set fan speed.""" return self.set_property("fan_speed", fan_speed.value) @command() def fan_speed_presets(self) -> FanspeedPresets: """Return available fan speed presets.""" return {x.name: x.value for x in G1FanSpeed} @command(click.argument("speed", type=int)) def set_fan_speed_preset(self, speed_preset: int) -> None: """Set fan speed preset speed.""" if speed_preset not in self.fan_speed_presets().values(): raise ValueError( f"Invalid preset speed {speed_preset}, not in: {self.fan_speed_presets().values()}" ) return self.set_property("fan_speed", speed_preset) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/mijia/tests/__init__.py0000644000000000000000000000000014265350055022303 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/__init__.py0000644000000000000000000000012114265350055021674 0ustar00# flake8: noqa from .vacuum import RoborockVacuum, VacuumException, VacuumStatus ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/tests/__init__.py0000644000000000000000000000000014265350055023032 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/tests/test_mirobo.py0000644000000000000000000000065314265350055023637 0ustar00from click.testing import CliRunner from ..vacuum_cli import cli def test_config_read(mocker): """Make sure config file is being read.""" x = mocker.patch("miio.integrations.vacuum.roborock.vacuum_cli._read_config") mocker.patch("miio.device.Device.send") runner = CliRunner() runner.invoke( cli, ["--ip", "127.0.0.1", "--token", "ffffffffffffffffffffffffffffffff"] ) x.assert_called() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/tests/test_vacuum.py0000644000000000000000000002744214265350055023655 0ustar00import datetime from unittest import TestCase from unittest.mock import patch import pytest from miio import RoborockVacuum, VacuumStatus from miio.tests.dummies import DummyDevice from ..vacuum import ( ROCKROBO_S7, CarpetCleaningMode, MopIntensity, MopMode, VacuumException, ) class DummyVacuum(DummyDevice, RoborockVacuum): STATE_CHARGING = 8 STATE_CLEANING = 5 STATE_ZONED_CLEAN = 9 STATE_IDLE = 3 STATE_HOME = 6 STATE_SPOT = 11 STATE_GOTO = 4 STATE_ERROR = 12 STATE_PAUSED = 10 STATE_MANUAL = 7 def __init__(self, *args, **kwargs): self._model = "missing.model.vacuum" self.state = { "state": 8, "dnd_enabled": 1, "clean_time": 0, "msg_ver": 4, "map_present": 1, "error_code": 0, "in_cleaning": 0, "clean_area": 0, "battery": 100, "fan_power": 20, "msg_seq": 320, "water_box_status": 1, } self.return_values = { "get_status": self.vacuum_state, "app_start": lambda x: self.change_mode("start"), "app_stop": lambda x: self.change_mode("stop"), "app_pause": lambda x: self.change_mode("pause"), "app_spot": lambda x: self.change_mode("spot"), "app_goto_target": lambda x: self.change_mode("goto"), "app_zoned_clean": lambda x: self.change_mode("zoned clean"), "app_charge": lambda x: self.change_mode("charge"), "miIO.info": "dummy info", } super().__init__(args, kwargs) def change_mode(self, new_mode): if new_mode == "spot": self.state["state"] = DummyVacuum.STATE_SPOT elif new_mode == "home": self.state["state"] = DummyVacuum.STATE_HOME elif new_mode == "pause": self.state["state"] = DummyVacuum.STATE_PAUSED elif new_mode == "start": self.state["state"] = DummyVacuum.STATE_CLEANING elif new_mode == "stop": self.state["state"] = DummyVacuum.STATE_IDLE elif new_mode == "goto": self.state["state"] = DummyVacuum.STATE_GOTO elif new_mode == "zoned clean": self.state["state"] = DummyVacuum.STATE_ZONED_CLEAN elif new_mode == "charge": self.state["state"] = DummyVacuum.STATE_CHARGING def vacuum_state(self, _): return [self.state] @pytest.fixture(scope="class") def dummyvacuum(request): request.cls.device = DummyVacuum() # TODO add ability to test on a real device @pytest.mark.usefixtures("dummyvacuum") class TestVacuum(TestCase): def status(self): return self.device.status() def test_status(self): self.device._reset_state() assert repr(self.status()) == repr(VacuumStatus(self.device.start_state)) status = self.status() assert status.is_on is False assert status.clean_time == datetime.timedelta() assert status.error_code == 0 assert status.error == "No error" assert status.fanspeed == self.device.start_state["fan_power"] assert status.battery == self.device.start_state["battery"] assert status.is_water_box_attached is True def test_status_with_errors(self): errors = {5: "Clean main brush", 19: "Unpowered charging station"} for errcode, error in errors.items(): self.device.state["state"] = self.device.STATE_ERROR self.device.state["error_code"] = errcode assert self.status().is_on is False assert self.status().got_error is True assert self.status().error_code == errcode assert self.status().error == error def test_start_and_stop(self): assert self.status().is_on is False self.device.start() assert self.status().is_on is True assert self.status().state_code == self.device.STATE_CLEANING self.device.stop() assert self.status().is_on is False def test_spot(self): assert self.status().is_on is False self.device.spot() assert self.status().is_on is True assert self.status().state_code == self.device.STATE_SPOT self.device.stop() assert self.status().is_on is False def test_pause(self): self.device.start() assert self.status().is_on is True self.device.pause() assert self.status().state_code == self.device.STATE_PAUSED def test_home(self): self.device.start() assert self.status().is_on is True self.device.home() assert self.status().state_code == self.device.STATE_CHARGING # TODO pause here and update to idle/charging and assert for that? # Another option is to mock that app_stop mode is entered before # the charging is activated. def test_goto(self): self.device.start() assert self.status().is_on is True self.device.goto(24000, 24000) assert self.status().state_code == self.device.STATE_GOTO def test_zoned_clean(self): self.device.start() assert self.status().is_on is True self.device.zoned_clean( [[25000, 25000, 25500, 25500, 3], [23000, 23000, 22500, 22500, 1]] ) assert self.status().state_code == self.device.STATE_ZONED_CLEAN def test_timezone(self): with patch.object( self.device, "send", return_value=[ {"olson": "Europe/Berlin", "posix": "CET-1CEST,M3.5.0,M10.5.0/3"} ], ): assert self.device.timezone() == "Europe/Berlin" with patch.object(self.device, "send", return_value=["Europe/Berlin"]): assert self.device.timezone() == "Europe/Berlin" with patch.object(self.device, "send", return_value=0): assert self.device.timezone() == "UTC" def test_history(self): with patch.object( self.device, "send", return_value=[ 174145, 2410150000, 82, [ 1488240000, 1488153600, 1488067200, 1487980800, 1487894400, 1487808000, 1487548800, ], ], ): assert self.device.clean_history().total_duration == datetime.timedelta( days=2, seconds=1345 ) assert self.device.clean_history().dust_collection_count is None assert self.device.clean_history().ids[0] == 1488240000 def test_history_dict(self): with patch.object( self.device, "send", return_value={ "clean_time": 174145, "clean_area": 2410150000, "clean_count": 82, "dust_collection_count": 5, "records": [ 1488240000, 1488153600, 1488067200, 1487980800, 1487894400, 1487808000, 1487548800, ], }, ): assert self.device.clean_history().total_duration == datetime.timedelta( days=2, seconds=1345 ) assert self.device.clean_history().dust_collection_count == 5 assert self.device.clean_history().ids[0] == 1488240000 def test_history_details(self): with patch.object( self.device, "send", return_value=[[1488347071, 1488347123, 16, 0, 0, 0]], ): assert self.device.clean_details(123123).duration == datetime.timedelta( seconds=16 ) def test_history_details_dict(self): with patch.object( self.device, "send", return_value=[ { "begin": 1616757243, "end": 1616758193, "duration": 950, "area": 10852500, "error": 0, "complete": 1, "start_type": 2, "clean_type": 1, "finish_reason": 52, "dust_collection_status": 0, } ], ): assert self.device.clean_details(123123).duration == datetime.timedelta( seconds=950 ) def test_history_empty(self): with patch.object( self.device, "send", return_value={ "clean_time": 174145, "clean_area": 2410150000, "clean_count": 82, "dust_collection_count": 5, }, ): assert self.device.clean_history().total_duration == datetime.timedelta( days=2, seconds=1345 ) assert len(self.device.clean_history().ids) == 0 def test_info_no_cloud(self): """Test the info functionality for non-cloud connected device.""" from miio.exceptions import DeviceInfoUnavailableException with patch( "miio.Device._fetch_info", side_effect=DeviceInfoUnavailableException() ): assert self.device.info().model == "rockrobo.vacuum.v1" def test_carpet_cleaning_mode(self): assert self.device.carpet_cleaning_mode() is None with patch.object(self.device, "send", return_value=[{"carpet_clean_mode": 0}]): assert self.device.carpet_cleaning_mode() == CarpetCleaningMode.Avoid with patch.object(self.device, "send", return_value="unknown_method"): assert self.device.carpet_cleaning_mode() is None with patch.object(self.device, "send", return_value=["ok"]) as mock_method: assert self.device.set_carpet_cleaning_mode(CarpetCleaningMode.Rise) is True mock_method.assert_called_once_with( "set_carpet_clean_mode", {"carpet_clean_mode": 1} ) def test_mop_mode(self): with patch.object(self.device, "send", return_value=["ok"]) as mock_method: assert self.device.set_mop_mode(MopMode.Deep) is True mock_method.assert_called_once_with("set_mop_mode", [301]) with patch.object(self.device, "send", return_value=[300]): assert self.device.mop_mode() == MopMode.Standard with patch.object(self.device, "send", return_value=[32453]): assert self.device.mop_mode() is None def test_mop_intensity_model_check(self): """Test Roborock S7 check when getting mop intensity.""" with pytest.raises(VacuumException): self.device.mop_intensity() def test_set_mop_intensity_model_check(self): """Test Roborock S7 check when setting mop intensity.""" with pytest.raises(VacuumException): self.device.set_mop_intensity(MopIntensity.Intense) class DummyVacuumS7(DummyVacuum): def __init__(self, *args, **kwargs): self._model = ROCKROBO_S7 @pytest.fixture(scope="class") def dummyvacuums7(request): request.cls.device = DummyVacuumS7() @pytest.mark.usefixtures("dummyvacuums7") class TestVacuumS7(TestCase): def test_mop_intensity(self): """Test getting mop intensity.""" with patch.object(self.device, "send", return_value=[203]) as mock_method: assert self.device.mop_intensity() mock_method.assert_called_once_with("get_water_box_custom_mode") def test_set_mop_intensity(self): """Test setting mop intensity.""" with patch.object(self.device, "send", return_value=[203]) as mock_method: assert self.device.set_mop_intensity(MopIntensity.Intense) mock_method.assert_called_once_with("set_water_box_custom_mode", [203]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuum.py0000644000000000000000000007634214265350055021457 0ustar00import contextlib import datetime import enum import json import logging import math import os import pathlib import time from typing import List, Optional, Type, Union import click import pytz from appdirs import user_cache_dir from miio.click_common import ( DeviceGroup, EnumType, GlobalContextObject, LiteralParamType, command, ) from miio.device import Device, DeviceInfo from miio.exceptions import DeviceException, DeviceInfoUnavailableException from miio.interfaces import FanspeedPresets, VacuumInterface from .vacuumcontainers import ( CarpetModeStatus, CleaningDetails, CleaningSummary, ConsumableStatus, DNDStatus, SoundInstallStatus, SoundStatus, Timer, VacuumStatus, ) _LOGGER = logging.getLogger(__name__) class VacuumException(DeviceException): pass class TimerState(enum.Enum): On = "on" Off = "off" class Consumable(enum.Enum): MainBrush = "main_brush_work_time" SideBrush = "side_brush_work_time" Filter = "filter_work_time" SensorDirty = "sensor_dirty_time" class FanspeedEnum(enum.Enum): pass class FanspeedV1(FanspeedEnum): Silent = 38 Standard = 60 Medium = 77 Turbo = 90 class FanspeedV2(FanspeedEnum): Silent = 101 Standard = 102 Medium = 103 Turbo = 104 Gentle = 105 Auto = 106 class FanspeedV3(FanspeedEnum): Silent = 38 Standard = 60 Medium = 75 Turbo = 100 class FanspeedE2(FanspeedEnum): # Original names from the app: Gentle, Silent, Standard, Strong, Max Gentle = 41 Silent = 50 Standard = 68 Medium = 79 Turbo = 100 class FanspeedS7(FanspeedEnum): Silent = 101 Standard = 102 Medium = 103 Turbo = 104 class FanspeedS7_Maxv(FanspeedEnum): Silent = 101 Standard = 102 Medium = 103 Turbo = 104 Max = 108 class WaterFlow(enum.Enum): """Water flow strength on s5 max.""" Minimum = 200 Low = 201 High = 202 Maximum = 203 class MopMode(enum.Enum): """Mop routing on S7.""" Standard = 300 Deep = 301 class MopIntensity(enum.Enum): """Mop scrub intensity on S7 + S7MAXV.""" Close = 200 Mild = 201 Moderate = 202 Intense = 203 class CarpetCleaningMode(enum.Enum): """Type of carpet cleaning/avoidance.""" Avoid = 0 Rise = 1 Ignore = 2 class DustCollectionMode(enum.Enum): """Auto emptying mode (S7 + S7MAXV only)""" Smart = 0 Quick = 1 Daily = 2 Strong = 3 Max = 4 ROCKROBO_V1 = "rockrobo.vacuum.v1" ROCKROBO_S4 = "roborock.vacuum.s4" ROCKROBO_S4_MAX = "roborock.vacuum.a19" ROCKROBO_S5 = "roborock.vacuum.s5" ROCKROBO_S5_MAX = "roborock.vacuum.s5e" ROCKROBO_S6 = "roborock.vacuum.s6" ROCKROBO_T6 = "roborock.vacuum.t6" # cn s6 ROCKROBO_S6_PURE = "roborock.vacuum.a08" ROCKROBO_T7 = "roborock.vacuum.a11" # cn s7 ROCKROBO_T7S = "roborock.vacuum.a14" ROCKROBO_T7SPLUS = "roborock.vacuum.a23" ROCKROBO_S7_MAXV = "roborock.vacuum.a27" ROCKROBO_Q5 = "roborock.vacuum.a34" ROCKROBO_G10S = "roborock.vacuum.a46" ROCKROBO_S7 = "roborock.vacuum.a15" ROCKROBO_S6_MAXV = "roborock.vacuum.a10" ROCKROBO_E2 = "roborock.vacuum.e2" ROCKROBO_1S = "roborock.vacuum.m1s" ROCKROBO_C1 = "roborock.vacuum.c1" SUPPORTED_MODELS = [ ROCKROBO_V1, ROCKROBO_S4, ROCKROBO_S4_MAX, ROCKROBO_S5, ROCKROBO_S5_MAX, ROCKROBO_S6, ROCKROBO_T6, ROCKROBO_S6_PURE, ROCKROBO_T7, ROCKROBO_T7S, ROCKROBO_T7SPLUS, ROCKROBO_S7, ROCKROBO_S7_MAXV, ROCKROBO_Q5, ROCKROBO_G10S, ROCKROBO_S6_MAXV, ROCKROBO_E2, ROCKROBO_1S, ROCKROBO_C1, ] AUTO_EMPTY_MODELS = [ ROCKROBO_S7, ROCKROBO_S7_MAXV, ] class RoborockVacuum(Device, VacuumInterface): """Main class for roborock vacuums (roborock.vacuum.*).""" _supported_models = SUPPORTED_MODELS _auto_empty_models = AUTO_EMPTY_MODELS def __init__( self, ip: str, token: str = None, start_id: int = 0, debug: int = 0, *, model=None, ): super().__init__(ip, token, start_id, debug, model=model) self.manual_seqnum = -1 @command() def start(self): """Start cleaning.""" return self.send("app_start") @command() def stop(self): """Stop cleaning. Note, prefer 'pause' instead of this for wider support. Some newer vacuum models do not support this command. """ return self.send("app_stop") @command() def spot(self): """Start spot cleaning.""" return self.send("app_spot") @command() def pause(self): """Pause cleaning.""" return self.send("app_pause") @command() def resume_or_start(self): """A shortcut for resuming or starting cleaning.""" status = self.status() if status.in_zone_cleaning and (status.is_paused or status.got_error): return self.resume_zoned_clean() if status.in_segment_cleaning and (status.is_paused or status.got_error): return self.resume_segment_clean() return self.start() def _fetch_info(self) -> DeviceInfo: """Return info about the device. This is overrides the base class info to account for gen1 devices that do not respond to info query properly when not connected to the cloud. """ try: info = super()._fetch_info() return info except (TypeError, DeviceInfoUnavailableException): # cloud-blocked gen1 vacuums will not return proper payloads def create_dummy_mac(addr): """Returns a dummy mac for a given IP address. This squats the FF:FF: OUI for a dummy mac presentation to allow presenting a unique identifier for homeassistant. """ from ipaddress import ip_address ip_to_mac = ":".join( [f"{hex(x).replace('0x', ''):0>2}" for x in ip_address(addr).packed] ) return f"FF:FF:{ip_to_mac}" dummy_v1 = DeviceInfo( { "model": ROCKROBO_V1, "token": self.token, "netif": {"localIp": self.ip}, "mac": create_dummy_mac(self.ip), "fw_ver": "1.0_nocloud", "hw_ver": "1st gen non-cloud hw", } ) self._info = dummy_v1 _LOGGER.debug( "Unable to query info, falling back to dummy %s", dummy_v1.model ) return self._info @command() def home(self): """Stop cleaning and return home.""" PAUSE_BEFORE_HOME = [ ROCKROBO_V1, ] if self.model in PAUSE_BEFORE_HOME: self.send("app_pause") return self.send("app_charge") @command(click.argument("x_coord", type=int), click.argument("y_coord", type=int)) def goto(self, x_coord: int, y_coord: int): """Go to specific target. :param int x_coord: x coordinate :param int y_coord: y coordinate """ return self.send("app_goto_target", [x_coord, y_coord]) @command(click.argument("zones", type=LiteralParamType(), required=True)) def zoned_clean(self, zones: List): """Clean zones. :param List zones: List of zones to clean: [[x1,y1,x2,y2, iterations],[x1,y1,x2,y2, iterations]] """ return self.send("app_zoned_clean", zones) @command() def resume_zoned_clean(self): """Resume zone cleaning after being paused.""" return self.send("resume_zoned_clean") @command() def manual_start(self): """Start manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_start") @command() def manual_stop(self): """Stop manual control mode.""" self.manual_seqnum = 0 return self.send("app_rc_end") MANUAL_ROTATION_MAX = 180 MANUAL_ROTATION_MIN = -MANUAL_ROTATION_MAX MANUAL_VELOCITY_MAX = 0.3 MANUAL_VELOCITY_MIN = -MANUAL_VELOCITY_MAX MANUAL_DURATION_DEFAULT = 1500 @command( click.argument("rotation", type=int), click.argument("velocity", type=float), click.argument( "duration", type=int, required=False, default=MANUAL_DURATION_DEFAULT ), ) def manual_control_once( self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT ): """Starts the remote control mode and executes the action once before deactivating the mode.""" number_of_tries = 3 self.manual_start() while number_of_tries > 0: if self.status().state_code == 7: time.sleep(5) self.manual_control(rotation, velocity, duration) time.sleep(5) return self.manual_stop() time.sleep(2) number_of_tries -= 1 @command( click.argument("rotation", type=int), click.argument("velocity", type=float), click.argument( "duration", type=int, required=False, default=MANUAL_DURATION_DEFAULT ), ) def manual_control( self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT ): """Give a command over manual control interface.""" if rotation < self.MANUAL_ROTATION_MIN or rotation > self.MANUAL_ROTATION_MAX: raise DeviceException( "Given rotation is invalid, should be ]%s, %s[, was %s" % (self.MANUAL_ROTATION_MIN, self.MANUAL_ROTATION_MAX, rotation) ) if velocity < self.MANUAL_VELOCITY_MIN or velocity > self.MANUAL_VELOCITY_MAX: raise DeviceException( "Given velocity is invalid, should be ]%s, %s[, was: %s" % (self.MANUAL_VELOCITY_MIN, self.MANUAL_VELOCITY_MAX, velocity) ) self.manual_seqnum += 1 params = { "omega": round(math.radians(rotation), 1), "velocity": velocity, "duration": duration, "seqnum": self.manual_seqnum, } self.send("app_rc_move", [params]) @command() def status(self) -> VacuumStatus: """Return status of the vacuum.""" return VacuumStatus(self.send("get_status")[0]) def enable_log_upload(self): raise NotImplementedError("unknown parameters") # return self.send("enable_log_upload") @command() def log_upload_status(self): # {"result": [{"log_upload_status": 7}], "id": 1} return self.send("get_log_upload_status") @command() def consumable_status(self) -> ConsumableStatus: """Return information about consumables.""" return ConsumableStatus(self.send("get_consumable")[0]) @command(click.argument("consumable", type=Consumable)) def consumable_reset(self, consumable: Consumable): """Reset consumable information.""" return self.send("reset_consumable", [consumable.value]) @command() def map(self): """Return map token.""" # returns ['retry'] without internet return self.send("get_map_v1") @command(click.argument("start", type=bool)) def edit_map(self, start): """Start map editing?""" if start: return self.send("start_edit_map")[0] == "ok" else: return self.send("end_edit_map")[0] == "ok" @command(click.option("--version", default=1)) def fresh_map(self, version): """Return fresh map?""" if version not in [1, 2]: raise VacuumException("Unknown map version: %s" % version) if version == 1: return self.send("get_fresh_map") elif version == 2: return self.send("get_fresh_map_v2") @command(click.option("--version", default=1)) def persist_map(self, version): """Return fresh map?""" if version not in [1, 2]: raise VacuumException("Unknown map version: %s" % version) if version == 1: return self.send("get_persist_map") elif version == 2: return self.send("get_persist_map_v2") @command( click.argument("x1", type=int), click.argument("y1", type=int), click.argument("x2", type=int), click.argument("y2", type=int), ) def create_software_barrier(self, x1, y1, x2, y2): """Create software barrier (gen2 only?). NOTE: Multiple nogo zones and barriers could be added by passing a list of them to save_map. Requires new fw version. 3.3.9_001633+? """ # First parameter indicates the type, 1 = barrier payload = [1, x1, y1, x2, y2] return self.send("save_map", payload)[0] == "ok" @command( click.argument("x1", type=int), click.argument("y1", type=int), click.argument("x2", type=int), click.argument("y2", type=int), click.argument("x3", type=int), click.argument("y3", type=int), click.argument("x4", type=int), click.argument("y4", type=int), ) def create_nogo_zone(self, x1, y1, x2, y2, x3, y3, x4, y4): """Create a rectangular no-go zone (gen2 only?). NOTE: Multiple nogo zones and barriers could be added by passing a list of them to save_map. Requires new fw version. 3.3.9_001633+? """ # First parameter indicates the type, 0 = zone payload = [0, x1, y1, x2, y2, x3, y3, x4, y4] return self.send("save_map", payload)[0] == "ok" @command(click.argument("enable", type=bool)) def enable_lab_mode(self, enable): """Enable persistent maps and software barriers. This is required to use create_nogo_zone and create_software_barrier commands. """ return self.send("set_lab_status", int(enable))["ok"] @command() def clean_history(self) -> CleaningSummary: """Return generic cleaning history.""" return CleaningSummary(self.send("get_clean_summary")) @command() def last_clean_details(self) -> Optional[CleaningDetails]: """Return details from the last cleaning. Returns None if there has been no cleanups. """ history = self.clean_history() if not history.ids: return None last_clean_id = history.ids.pop(0) return self.clean_details(last_clean_id) @command( click.argument("id_", type=int, metavar="ID"), ) def clean_details( self, id_: int ) -> Union[List[CleaningDetails], Optional[CleaningDetails]]: """Return details about specific cleaning.""" details = self.send("get_clean_record", [id_]) if not details: _LOGGER.warning("No cleaning record found for id %s", id_) return None res = CleaningDetails(details.pop()) return res @command() def find(self): """Find the robot.""" return self.send("find_me", [""]) @command() def timer(self) -> List[Timer]: """Return a list of timers.""" timers: List[Timer] = list() res = self.send("get_timer", [""]) if not res: return timers timezone = pytz.timezone(self.timezone()) for rec in res: try: timers.append(Timer(rec, timezone=timezone)) except Exception as ex: _LOGGER.warning("Unable to add timer for %s: %s", rec, ex) return timers @command( click.argument("cron"), click.argument("command", required=False, default=""), click.argument("parameters", required=False, default=""), click.argument("timer_id", required=False, default=None), ) def add_timer(self, cron: str, command: str, parameters: str, timer_id: str): """Add a timer. :param cron: schedule in cron format :param command: ignored by the vacuum. :param parameters: ignored by the vacuum. """ if not timer_id: timer_id = str(int(round(time.time() * 1000))) return self.send("set_timer", [[timer_id, [cron, [command, parameters]]]]) @command(click.argument("timer_id", type=str)) def delete_timer(self, timer_id: str): """Delete a timer with given ID. :param str timer_id: Timer ID """ return self.send("del_timer", [timer_id]) @command( click.argument("timer_id", type=str), click.argument("mode", type=TimerState) ) def update_timer(self, timer_id: str, mode: TimerState): """Update a timer with given ID. :param str timer_id: Timer ID :param TimerState mode: either On or Off """ if mode != TimerState.On and mode != TimerState.Off: raise DeviceException("Only 'On' or 'Off' are allowed") return self.send("upd_timer", [timer_id, mode.value]) @command() def dnd_status(self): """Returns do-not-disturb status.""" # {'result': [{'enabled': 1, 'start_minute': 0, 'end_minute': 0, # 'start_hour': 22, 'end_hour': 8}], 'id': 1} return DNDStatus(self.send("get_dnd_timer")[0]) @command( click.argument("start_hr", type=int), click.argument("start_min", type=int), click.argument("end_hr", type=int), click.argument("end_min", type=int), ) def set_dnd(self, start_hr: int, start_min: int, end_hr: int, end_min: int): """Set do-not-disturb. :param int start_hr: Start hour :param int start_min: Start minute :param int end_hr: End hour :param int end_min: End minute """ return self.send("set_dnd_timer", [start_hr, start_min, end_hr, end_min]) @command() def disable_dnd(self): """Disable do-not-disturb.""" return self.send("close_dnd_timer", [""]) @command(click.argument("speed", type=int)) def set_fan_speed(self, speed: int): """Set fan speed. :param int speed: Fan speed to set """ # speed = [38, 60 or 77] return self.send("set_custom_mode", [speed]) @command() def fan_speed(self): """Return fan speed.""" return self.send("get_custom_mode")[0] @command() def fan_speed_presets(self) -> FanspeedPresets: """Return available fan speed presets.""" def _enum_as_dict(cls): return {x.name: x.value for x in list(cls)} if self.model is None: return _enum_as_dict(FanspeedV1) fanspeeds: Type[FanspeedEnum] = FanspeedV1 if self.model == ROCKROBO_V1: _LOGGER.debug("Got robov1, checking for firmware version") fw_version = self.info().firmware_version version, build = fw_version.split("_") version = tuple(map(int, version.split("."))) if version >= (3, 5, 8): fanspeeds = FanspeedV3 elif version == (3, 5, 7): fanspeeds = FanspeedV2 else: fanspeeds = FanspeedV1 elif self.model == ROCKROBO_E2: fanspeeds = FanspeedE2 elif self.model == ROCKROBO_S7: fanspeeds = FanspeedS7 elif self.model == ROCKROBO_S7_MAXV: fanspeeds = FanspeedS7_Maxv else: fanspeeds = FanspeedV2 _LOGGER.debug("Using fanspeeds %s for %s", fanspeeds, self.model) return _enum_as_dict(fanspeeds) @command(click.argument("speed", type=int)) def set_fan_speed_preset(self, speed_preset: int) -> None: """Set fan speed preset speed.""" if speed_preset not in self.fan_speed_presets().values(): raise ValueError( f"Invalid preset speed {speed_preset}, not in: {self.fan_speed_presets().values()}" ) return self.send("set_custom_mode", [speed_preset]) @command() def sound_info(self): """Get voice settings.""" return SoundStatus(self.send("get_current_sound")[0]) @command( click.argument("url"), click.argument("md5sum"), click.argument("sound_id", type=int), ) def install_sound(self, url: str, md5sum: str, sound_id: int): """Install sound from the given url.""" payload = {"url": url, "md5": md5sum, "sid": int(sound_id)} return SoundInstallStatus(self.send("dnld_install_sound", payload)[0]) @command() def sound_install_progress(self): """Get sound installation progress.""" return SoundInstallStatus(self.send("get_sound_progress")[0]) @command() def sound_volume(self) -> int: """Get sound volume.""" return self.send("get_sound_volume")[0] @command(click.argument("vol", type=int)) def set_sound_volume(self, vol: int): """Set sound volume [0-100].""" return self.send("change_sound_volume", [vol]) @command() def test_sound_volume(self): """Test current sound volume.""" return self.send("test_sound_volume") @command() def serial_number(self): """Get serial number.""" serial = self.send("get_serial_number") if isinstance(serial, list): return serial[0]["serial_number"] return serial @command() def locale(self): """Return locale information.""" return self.send("app_get_locale") @command() def timezone(self): """Get the timezone.""" res = self.send("get_timezone") def _fallback_timezone(data): fallback = "UTC" _LOGGER.error( "Unsupported timezone format (%s), falling back to %s", data, fallback ) return fallback if isinstance(res, int): return _fallback_timezone(res) res = res[0] if isinstance(res, dict): # Xiaowa E25 example # {'olson': 'Europe/Berlin', 'posix': 'CET-1CEST,M3.5.0,M10.5.0/3'} if "olson" not in res: return _fallback_timezone(res) return res["olson"] return res def set_timezone(self, new_zone): """Set the timezone.""" return self.send("set_timezone", [new_zone])[0] == "ok" def configure_wifi(self, ssid, password, uid=0, timezone=None): """Configure the wifi settings.""" extra_params = {} if timezone is not None: now = datetime.datetime.now(pytz.timezone(timezone)) offset_as_float = now.utcoffset().total_seconds() / 60 / 60 extra_params["tz"] = timezone extra_params["gmt_offset"] = offset_as_float return super().configure_wifi(ssid, password, uid, extra_params) @command() def carpet_mode(self): """Get carpet mode settings.""" return CarpetModeStatus(self.send("get_carpet_mode")[0]) @command( click.argument("enabled", required=True, type=bool), click.argument("stall_time", required=False, default=10, type=int), click.argument("low", required=False, default=400, type=int), click.argument("high", required=False, default=500, type=int), click.argument("integral", required=False, default=450, type=int), ) def set_carpet_mode( self, enabled: bool, stall_time: int = 10, low: int = 400, high: int = 500, integral: int = 450, ): """Set the carpet mode.""" click.echo("Setting carpet mode: %s" % enabled) data = { "enable": int(enabled), "stall_time": stall_time, "current_low": low, "current_high": high, "current_integral": integral, } return self.send("set_carpet_mode", [data])[0] == "ok" @command() def carpet_cleaning_mode(self) -> Optional[CarpetCleaningMode]: """Get carpet cleaning mode/avoidance setting.""" try: return CarpetCleaningMode( self.send("get_carpet_clean_mode")[0]["carpet_clean_mode"] ) except Exception as err: _LOGGER.warning("Error while requesting carpet clean mode: %s", err) return None @command(click.argument("mode", type=EnumType(CarpetCleaningMode))) def set_carpet_cleaning_mode(self, mode: CarpetCleaningMode): """Set carpet cleaning mode/avoidance setting.""" return ( self.send("set_carpet_clean_mode", {"carpet_clean_mode": mode.value})[0] == "ok" ) @command() def dust_collection_mode(self) -> Optional[DustCollectionMode]: """Get the dust collection mode setting.""" self._verify_auto_empty_support() try: return DustCollectionMode(self.send("get_dust_collection_mode")["mode"]) except Exception as err: _LOGGER.warning("Error while requesting dust collection mode: %s", err) return None @command(click.argument("enabled", required=True, type=bool)) def set_dust_collection(self, enabled: bool) -> bool: """Turn automatic dust collection on or off.""" self._verify_auto_empty_support() return ( self.send("set_dust_collection_switch_status", {"status": int(enabled)})[0] == "ok" ) @command(click.argument("mode", required=True, type=EnumType(DustCollectionMode))) def set_dust_collection_mode(self, mode: DustCollectionMode) -> bool: """Set dust collection mode setting.""" self._verify_auto_empty_support() return self.send("set_dust_collection_mode", {"mode": mode.value})[0] == "ok" @command() def start_dust_collection(self): """Activate automatic dust collection.""" self._verify_auto_empty_support() return self.send("app_start_collect_dust") @command() def stop_dust_collection(self): """Abort in progress dust collection.""" self._verify_auto_empty_support() return self.send("app_stop_collect_dust") def _verify_auto_empty_support(self) -> None: if self.model not in self._auto_empty_models: raise VacuumException("Device does not support auto emptying") @command() def stop_zoned_clean(self): """Stop cleaning a zone.""" return self.send("stop_zoned_clean") @command() def stop_segment_clean(self): """Stop cleaning a segment.""" return self.send("stop_segment_clean") @command() def resume_segment_clean(self): """Resuming cleaning a segment.""" return self.send("resume_segment_clean") @command(click.argument("segments", type=LiteralParamType(), required=True)) def segment_clean(self, segments: List): """Clean segments. :param List segments: List of segments to clean: [16,17,18] """ return self.send("app_segment_clean", segments) @command() def get_room_mapping(self): """Retrieves a list of segments.""" return self.send("get_room_mapping") @command() def get_backup_maps(self): """Get backup maps.""" return self.send("get_recover_maps") @command(click.argument("id", type=int)) def use_backup_map(self, id: int): """Set backup map.""" click.echo("Setting the map %s as active" % id) return self.send("recover_map", [id]) @command() def get_segment_status(self): """Get the status of a segment.""" return self.send("get_segment_status") def name_segment(self): raise NotImplementedError("unknown parameters") # return self.send("name_segment") def merge_segment(self): raise NotImplementedError("unknown parameters") # return self.send("merge_segment") def split_segment(self): raise NotImplementedError("unknown parameters") # return self.send("split_segment") @command() def waterflow(self) -> WaterFlow: """Get water flow setting.""" return WaterFlow(self.send("get_water_box_custom_mode")[0]) @command(click.argument("waterflow", type=EnumType(WaterFlow))) def set_waterflow(self, waterflow: WaterFlow): """Set water flow setting.""" return self.send("set_water_box_custom_mode", [waterflow.value]) @command() def mop_mode(self) -> Optional[MopMode]: """Get mop mode setting.""" try: return MopMode(self.send("get_mop_mode")[0]) except ValueError as err: _LOGGER.warning("Device returned unknown MopMode: %s", err) return None @command(click.argument("mop_mode", type=EnumType(MopMode))) def set_mop_mode(self, mop_mode: MopMode): """Set mop mode setting.""" return self.send("set_mop_mode", [mop_mode.value])[0] == "ok" @command() def mop_intensity(self) -> MopIntensity: """Get mop scrub intensity setting.""" if self.model != ROCKROBO_S7: raise VacuumException("Mop scrub intensity not supported by %s", self.model) return MopIntensity(self.send("get_water_box_custom_mode")[0]) @command(click.argument("mop_intensity", type=EnumType(MopIntensity))) def set_mop_intensity(self, mop_intensity: MopIntensity): """Set mop scrub intensity setting.""" if self.model != ROCKROBO_S7: raise VacuumException("Mop scrub intensity not supported by %s", self.model) return self.send("set_water_box_custom_mode", [mop_intensity.value]) @command() def child_lock(self) -> bool: """Get child lock setting.""" return self.send("get_child_lock_status")["lock_status"] == 1 @command(click.argument("lock", type=bool)) def set_child_lock(self, lock: bool) -> bool: """Set child lock setting.""" return self.send("set_child_lock_status", {"lock_status": int(lock)})[0] == "ok" @classmethod def get_device_group(cls): @click.pass_context def callback(ctx, *args, id_file, **kwargs): gco = ctx.find_object(GlobalContextObject) if gco: kwargs["debug"] = gco.debug start_id = manual_seq = 0 with contextlib.suppress(FileNotFoundError, TypeError, ValueError), open( id_file ) as f: x = json.load(f) start_id = x.get("seq", 0) manual_seq = x.get("manual_seq", 0) _LOGGER.debug("Read stored sequence ids: %s", x) ctx.obj = cls(*args, start_id=start_id, **kwargs) ctx.obj.manual_seqnum = manual_seq dg = DeviceGroup( cls, params=DeviceGroup.DEFAULT_PARAMS + [ click.Option( ["--id-file"], type=click.Path(dir_okay=False, writable=True), default=os.path.join( user_cache_dir("python-miio"), "python-mirobo.seq" ), ) ], callback=callback, ) @dg.result_callback() @dg.device_pass def cleanup(vac: RoborockVacuum, *args, **kwargs): if vac.ip is None: # dummy Device for discovery, skip teardown return id_file = kwargs["id_file"] seqs = {"seq": vac._protocol.raw_id, "manual_seq": vac.manual_seqnum} _LOGGER.debug("Writing %s to %s", seqs, id_file) path_obj = pathlib.Path(id_file) cache_dir = path_obj.parents[0] cache_dir.mkdir(parents=True, exist_ok=True) with open(id_file, "w") as f: json.dump(seqs, f) return dg ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuum_cli.py0000644000000000000000000004677314265350055022313 0ustar00import ast import contextlib import json import logging import pathlib import sys import threading import time from pprint import pformat as pf from typing import Any, List # noqa: F401 import click from appdirs import user_cache_dir from tqdm import tqdm from miio.click_common import ( ExceptionHandlerGroup, LiteralParamType, validate_ip, validate_token, ) from miio.device import Device, UpdateState from miio.exceptions import DeviceInfoUnavailableException from miio.miioprotocol import MiIOProtocol from miio.updater import OneShotServer from .vacuum import CarpetCleaningMode, Consumable, RoborockVacuum, TimerState from .vacuum_tui import VacuumTUI from miio.discovery import Discovery _LOGGER = logging.getLogger(__name__) pass_dev = click.make_pass_decorator(Device, ensure=True) def _read_config(file): """Return sequence id information.""" config = {"seq": 0, "manual_seq": 0} with contextlib.suppress(FileNotFoundError, TypeError, ValueError), open(file) as f: config = json.load(f) return config @click.group(invoke_without_command=True, cls=ExceptionHandlerGroup) @click.option("--ip", envvar="MIROBO_IP", callback=validate_ip) @click.option("--token", envvar="MIROBO_TOKEN", callback=validate_token) @click.option("-d", "--debug", default=False, count=True) @click.option( "--id-file", type=click.Path(dir_okay=False, writable=True), default=user_cache_dir("python-miio") + "/python-mirobo.seq", ) @click.version_option() @click.pass_context def cli(ctx, ip: str, token: str, debug: int, id_file: str): """A tool to command Xiaomi Vacuum robot.""" if debug: logging.basicConfig(level=logging.DEBUG) _LOGGER.info("Debug mode active") else: logging.basicConfig(level=logging.INFO) # if we are scanning, we do not try to connect. if ctx.invoked_subcommand == "discover": ctx.obj = "discover" return if ip is None or token is None: click.echo("You have to give ip and token!") sys.exit(-1) config = _read_config(id_file) start_id = config["seq"] manual_seq = config["manual_seq"] _LOGGER.debug("Using config: %s", config) vac = RoborockVacuum(ip, token, start_id, debug) vac.manual_seqnum = manual_seq _LOGGER.debug("Connecting to %s with token %s", ip, token) ctx.obj = vac if ctx.invoked_subcommand is None: ctx.invoke(status) cleanup(vac, id_file=id_file) @cli.result_callback() @pass_dev def cleanup(vac: RoborockVacuum, *args, **kwargs): if vac.ip is None: # dummy Device for discovery, skip teardown return id_file = kwargs["id_file"] seqs = {"seq": vac.raw_id, "manual_seq": vac.manual_seqnum} _LOGGER.debug("Writing %s to %s", seqs, id_file) path_obj = pathlib.Path(id_file) dir = path_obj.parents[0] dir.mkdir(parents=True, exist_ok=True) with open(id_file, "w") as f: json.dump(seqs, f) @cli.command() @click.option("--handshake", type=bool, default=False) def discover(handshake): """Search for robots in the network.""" if handshake: MiIOProtocol.discover() else: Discovery.discover_mdns() @cli.command() @pass_dev def status(vac: RoborockVacuum): """Returns the state information.""" res = vac.status() if not res: return # bail out if res.error_code: click.echo(click.style("Error: %s !" % res.error, bold=True, fg="red")) if res.is_water_shortage: click.echo(click.style("Water is running low!", bold=True, fg="blue")) click.echo(click.style("State: %s" % res.state, bold=True)) click.echo("Battery: %s %%" % res.battery) click.echo("Fanspeed: %s %%" % res.fanspeed) click.echo("Cleaning since: %s" % res.clean_time) click.echo("Cleaned area: %s m²" % res.clean_area) click.echo("Water box attached: %s" % res.is_water_box_attached) if res.is_water_box_carriage_attached is not None: click.echo("Mop attached: %s" % res.is_water_box_carriage_attached) @cli.command() @pass_dev def consumables(vac: RoborockVacuum): """Return consumables status.""" res = vac.consumable_status() click.echo(f"Main brush: {res.main_brush} (left {res.main_brush_left})") click.echo(f"Side brush: {res.side_brush} (left {res.side_brush_left})") click.echo(f"Filter: {res.filter} (left {res.filter_left})") click.echo(f"Sensor dirty: {res.sensor_dirty} (left {res.sensor_dirty_left})") @cli.command() @click.argument("name", type=str, required=True) @pass_dev def reset_consumable(vac: RoborockVacuum, name): """Reset consumable state. Allowed values: main_brush, side_brush, filter, sensor_dirty """ if name == "main_brush": consumable = Consumable.MainBrush elif name == "side_brush": consumable = Consumable.SideBrush elif name == "filter": consumable = Consumable.Filter elif name == "sensor_dirty": consumable = Consumable.SensorDirty else: click.echo("Unexpected state name: %s" % name) return click.echo(f"Resetting consumable '{name}': {vac.consumable_reset(consumable)}") @cli.command() @pass_dev def start(vac: RoborockVacuum): """Start cleaning.""" click.echo("Starting cleaning: %s" % vac.start()) @cli.command() @pass_dev def spot(vac: RoborockVacuum): """Start spot cleaning.""" click.echo("Starting spot cleaning: %s" % vac.spot()) @cli.command() @pass_dev def pause(vac: RoborockVacuum): """Pause cleaning.""" click.echo("Pausing: %s" % vac.pause()) @cli.command() @pass_dev def stop(vac: RoborockVacuum): """Stop cleaning.""" click.echo("Stop cleaning: %s" % vac.stop()) @cli.command() @pass_dev def home(vac: RoborockVacuum): """Return home.""" click.echo("Requesting return to home: %s" % vac.home()) @cli.command() @pass_dev @click.argument("x_coord", type=int) @click.argument("y_coord", type=int) def goto(vac: RoborockVacuum, x_coord: int, y_coord: int): """Go to specific target.""" click.echo("Going to target : %s" % vac.goto(x_coord, y_coord)) @cli.command() @pass_dev @click.argument("zones", type=LiteralParamType(), required=True) def zoned_clean(vac: RoborockVacuum, zones: List): """Clean zone.""" click.echo("Cleaning zone(s) : %s" % vac.zoned_clean(zones)) @cli.group() @pass_dev # @click.argument('command', required=False) def manual(vac: RoborockVacuum): """Control the robot manually.""" command = "" if command == "start": click.echo("Starting manual control") return vac.manual_start() if command == "stop": click.echo("Stopping manual control") return vac.manual_stop() # if not vac.manual_mode and command : @manual.command() @pass_dev def tui(vac: RoborockVacuum): """TUI for the manual mode.""" VacuumTUI(vac).run() @manual.command(name="start") @pass_dev def manual_start(vac: RoborockVacuum): # noqa: F811 # redef of start """Activate the manual mode.""" click.echo("Activating manual controls") return vac.manual_start() @manual.command(name="stop") @pass_dev def manual_stop(vac: RoborockVacuum): # noqa: F811 # redef of stop """Deactivate the manual mode.""" click.echo("Deactivating manual controls") return vac.manual_stop() @manual.command() @pass_dev @click.argument("degrees", type=int) def left(vac: RoborockVacuum, degrees: int): """Turn to left.""" click.echo("Turning %s degrees left" % degrees) return vac.manual_control(degrees, 0) @manual.command() @pass_dev @click.argument("degrees", type=int) def right(vac: RoborockVacuum, degrees: int): """Turn to right.""" click.echo("Turning right") return vac.manual_control(-degrees, 0) @manual.command() @click.argument("amount", type=float) @pass_dev def forward(vac: RoborockVacuum, amount: float): """Run forwards.""" click.echo("Moving forwards") return vac.manual_control(0, amount) @manual.command() @click.argument("amount", type=float) @pass_dev def backward(vac: RoborockVacuum, amount: float): """Run backwards.""" click.echo("Moving backwards") return vac.manual_control(0, -amount) @manual.command() @pass_dev @click.argument("rotation", type=float) @click.argument("velocity", type=float) @click.argument("duration", type=int) def move(vac: RoborockVacuum, rotation: int, velocity: float, duration: int): """Pass raw manual values.""" return vac.manual_control(rotation, velocity, duration) @cli.command() @click.argument("cmd", required=False) @click.argument("start_hr", type=int, required=False) @click.argument("start_min", type=int, required=False) @click.argument("end_hr", type=int, required=False) @click.argument("end_min", type=int, required=False) @pass_dev def dnd( vac: RoborockVacuum, cmd: str, start_hr: int, start_min: int, end_hr: int, end_min: int, ): """Query and adjust do-not-disturb mode.""" if cmd == "off": click.echo("Disabling DND..") click.echo(vac.disable_dnd()) elif cmd == "on": click.echo(f"Enabling DND {start_hr}:{start_min} to {end_hr}:{end_min}") click.echo(vac.set_dnd(start_hr, start_min, end_hr, end_min)) else: x = vac.dnd_status() click.echo( click.style( f"Between {x.start} and {x.end} (enabled: {x.enabled})", bold=x.enabled, ) ) @cli.command() @click.argument("speed", type=int, required=False) @pass_dev def fanspeed(vac: RoborockVacuum, speed): """Query and adjust the fan speed.""" if speed: click.echo("Setting fan speed to %s" % speed) vac.set_fan_speed(speed) else: click.echo("Current fan speed: %s" % vac.fan_speed()) @cli.group(invoke_without_command=True) @pass_dev @click.pass_context def timer(ctx, vac: RoborockVacuum): """List and modify existing timers.""" if ctx.invoked_subcommand is not None: return timers = vac.timer() click.echo("Timezone: %s\n" % vac.timezone()) for idx, timer in enumerate(timers): color = "green" if timer.enabled else "yellow" click.echo( click.style( f"Timer #{idx}, id {timer.id} (ts: {timer.ts})", bold=True, fg=color, ) ) click.echo(" %s" % timer.cron) min, hr, x, y, days = timer.cron.split(" ") cron = f"{min} {hr} {x} {y} {days}" click.echo(" %s" % cron) @timer.command() @click.option("--cron") @click.option("--command", default="", required=False) @click.option("--params", default="", required=False) @pass_dev def add(vac: RoborockVacuum, cron, command, params): """Add a timer.""" click.echo(vac.add_timer(cron, command, params)) @timer.command() @click.argument("timer_id", type=int, required=True) @pass_dev def delete(vac: RoborockVacuum, timer_id): """Delete a timer.""" click.echo(vac.delete_timer(timer_id)) @timer.command() @click.argument("timer_id", type=int, required=True) @click.option("--enable", is_flag=True) @click.option("--disable", is_flag=True) @pass_dev def update(vac: RoborockVacuum, timer_id, enable, disable): """Enable/disable a timer.""" if enable and not disable: vac.update_timer(timer_id, TimerState.On) elif disable and not enable: vac.update_timer(timer_id, TimerState.Off) else: click.echo("You need to specify either --enable or --disable") @cli.command() @pass_dev def find(vac: RoborockVacuum): """Find the robot.""" click.echo("Sending find the robot calls.") click.echo(vac.find()) @cli.command() @pass_dev def map(vac: RoborockVacuum): """Return the map token.""" click.echo(vac.map()) @cli.command() @pass_dev def info(vac: RoborockVacuum): """Return device information.""" try: res = vac.info() click.echo("%s" % res) _LOGGER.debug("Full response: %s", pf(res.raw)) except DeviceInfoUnavailableException: click.echo( "Unable to fetch info, this can happen when the vacuum " "is not connected to the Xiaomi cloud." ) @cli.command() @pass_dev def cleaning_history(vac: RoborockVacuum): """Query the cleaning history.""" res = vac.clean_history() click.echo("Total clean count: %s" % res.count) click.echo(f"Cleaned for: {res.total_duration} (area: {res.total_area} m²)") if res.dust_collection_count is not None: click.echo("Emptied dust collection bin: %s times" % res.dust_collection_count) click.echo() for idx, id_ in enumerate(res.ids): details = vac.clean_details(id_, return_list=False) color = "green" if details.complete else "yellow" click.echo( click.style( "Clean #%s: %s-%s (complete: %s, error: %s)" % (idx, details.start, details.end, details.complete, details.error), bold=True, fg=color, ) ) click.echo(" Area cleaned: %s m²" % details.area) click.echo(" Duration: (%s)" % details.duration) click.echo() @cli.command() @click.argument("volume", type=int, required=False) @click.option("--test", "test_mode", is_flag=True, help="play a test tune") @pass_dev def sound(vac: RoborockVacuum, volume: int, test_mode: bool): """Query and change sound settings.""" if volume is not None: click.echo("Setting sound volume to %s" % volume) vac.set_sound_volume(volume) if test_mode: vac.test_sound_volume() click.echo("Current sound: %s" % vac.sound_info()) click.echo("Current volume: %s" % vac.sound_volume()) click.echo("Install progress: %s" % vac.sound_install_progress()) @cli.command() @click.argument("url") @click.argument("md5sum", required=False, default=None) @click.option("--sid", type=int, required=False, default=10000) @click.option("--ip", required=False) @pass_dev def install_sound(vac: RoborockVacuum, url: str, md5sum: str, sid: int, ip: str): """Install a sound. When passing a local file this will create a self-hosting server for the given file and the md5sum will be calculated automatically. For URLs you have to specify the md5sum manually. `--ip` can be used to override automatically detected IP address for the device to contact for the update. """ click.echo(f"Installing from {url} (md5: {md5sum}) for id {sid}") local_url = None server = None if url.startswith("http"): if md5sum is None: click.echo("You need to pass md5 when using URL for updating.") return local_url = url else: server = OneShotServer(url) local_url = server.url(ip) md5sum = server.md5 t = threading.Thread(target=server.serve_once) t.start() click.echo("Hosting file at %s" % local_url) click.echo(vac.install_sound(local_url, md5sum, sid)) progress = vac.sound_install_progress() while progress.is_installing: progress = vac.sound_install_progress() click.echo(f"{progress.state.name} ({progress.progress} %)") time.sleep(1) progress = vac.sound_install_progress() if progress.is_errored: click.echo("Error during installation: %s" % progress.error) else: click.echo("Installation of sid '%s' complete!" % sid) if server is not None: t.join() @cli.command() @pass_dev def serial_number(vac: RoborockVacuum): """Query serial number.""" click.echo("Serial#: %s" % vac.serial_number()) @cli.command() @click.argument("tz", required=False) @pass_dev def timezone(vac: RoborockVacuum, tz=None): """Query or set the timezone.""" if tz is not None: click.echo("Setting timezone to: %s" % tz) click.echo(vac.set_timezone(tz)) else: click.echo("Timezone: %s" % vac.timezone()) @cli.command() @click.argument("enabled", required=False, type=bool) @pass_dev def carpet_mode(vac: RoborockVacuum, enabled=None): """Query or set the carpet mode.""" if enabled is None: click.echo(vac.carpet_mode()) else: click.echo(vac.set_carpet_mode(enabled)) @cli.command() @click.argument("mode", required=False, type=str) @pass_dev def carpet_cleaning_mode(vac: RoborockVacuum, mode=None): """Query or set the carpet cleaning/avoidance mode. Allowed values: Avoid, Rise, Ignore """ if mode is None: click.echo("Carpet cleaning mode: %s" % vac.carpet_cleaning_mode()) else: click.echo( "Setting carpet cleaning mode: %s" % vac.set_carpet_cleaning_mode(CarpetCleaningMode[mode]) ) @cli.command() @click.argument("ssid", required=True) @click.argument("password", required=True) @click.argument("uid", type=int, required=False) @click.option("--timezone", type=str, required=False, default=None) @pass_dev def configure_wifi( vac: RoborockVacuum, ssid: str, password: str, uid: int, timezone: str ): """Configure the wifi settings. Note that some newer firmwares may expect you to define the timezone by using --timezone. """ click.echo("Configuring wifi to SSID: %s" % ssid) click.echo(vac.configure_wifi(ssid, password, uid, timezone)) @cli.command() @pass_dev def update_status(vac: RoborockVacuum): """Return update state and progress.""" update_state = vac.update_state() click.echo("Update state: %s" % update_state) if update_state == UpdateState.Downloading: click.echo("Update progress: %s" % vac.update_progress()) @cli.command() @click.argument("url", required=True) @click.argument("md5", required=False, default=None) @click.option("--ip", required=False) @pass_dev def update_firmware(vac: RoborockVacuum, url: str, md5: str, ip: str): """Update device firmware. If `url` starts with http* it is expected to be an URL. In that case md5sum of the file has to be given. `--ip` can be used to override automatically detected IP address for the device to contact for the update. """ # TODO Check that the device is in updateable state. click.echo("Going to update from %s" % url) if url.lower().startswith("http"): if md5 is None: click.echo("You need to pass md5 when using URL for updating.") return click.echo(f"Using {url} (md5: {md5})") else: server = OneShotServer(url) url = server.url(ip) t = threading.Thread(target=server.serve_once) t.start() click.echo("Hosting file at %s" % url) md5 = server.md5 update_res = vac.update(url, md5) if update_res: click.echo("Update started!") else: click.echo("Starting the update failed: %s" % update_res) with tqdm(total=100) as pbar: state = vac.update_state() while state == UpdateState.Downloading: try: state = vac.update_state() progress = vac.update_progress() except: # noqa # nosec # we may not get our messages through during uploads continue if state == UpdateState.Installing: click.echo("Installation started, please wait until the vacuum reboots") break pbar.update(progress - pbar.n) pbar.set_description("%s" % state.name) time.sleep(1) @cli.command() @click.argument("cmd", required=True) @click.argument("parameters", required=False) @pass_dev def raw_command(vac: RoborockVacuum, cmd, parameters): """Run a raw command.""" params = [] # type: Any if parameters: params = ast.literal_eval(parameters) click.echo(f"Sending cmd {cmd} with params {params}") click.echo(vac.raw_command(cmd, params)) if __name__ == "__main__": cli() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuum_tui.py0000644000000000000000000000555314265350055022334 0ustar00try: import curses curses_available = True except ImportError: curses_available = False import enum from typing import Tuple from .vacuum import RoborockVacuum as Vacuum class Control(enum.Enum): Quit = "q" Forward = "w" ForwardFast = "W" Backward = "s" BackwardFast = "S" Left = "a" LeftFast = "A" Right = "d" RightFast = "D" class VacuumTUI: def __init__(self, vac: Vacuum): if not curses_available: raise ImportError("curses library is not available") self.vac = vac self.rot = 0 self.rot_delta = 30 self.rot_min = Vacuum.MANUAL_ROTATION_MIN self.rot_max = Vacuum.MANUAL_ROTATION_MAX self.vel = 0.0 self.vel_delta = 0.1 self.vel_min = Vacuum.MANUAL_VELOCITY_MIN self.vel_max = Vacuum.MANUAL_VELOCITY_MAX self.dur = 10 * 1000 def run(self) -> None: self.vac.manual_start() try: curses.wrapper(self.main) finally: self.vac.manual_stop() def main(self, screen) -> None: screen.addstr("Use wasd to control the device.\n") screen.addstr("Hold shift to enable fast mode.\n") screen.addstr("Press q to quit.\n") screen.refresh() self.loop(screen) def loop(self, win) -> None: done = False while not done: key = win.getkey() text, done = self.handle_key(key) win.clear() win.addstr(text) win.refresh() def handle_key(self, key: str) -> Tuple[str, bool]: try: ctl = Control(key) except ValueError as e: return f"Ignoring {key}: {e}.\n", False done = self.dispatch_control(ctl) return self.info(), done def dispatch_control(self, ctl: Control) -> bool: if ctl == Control.Quit: return True if ctl == Control.Forward: self.vel = min(self.vel + self.vel_delta, self.vel_max) elif ctl == Control.ForwardFast: self.vel = 0 if self.vel < 0 else self.vel_max elif ctl == Control.Backward: self.vel = max(self.vel - self.vel_delta, self.vel_min) elif ctl == Control.BackwardFast: self.vel = 0 if self.vel > 0 else self.vel_min elif ctl == Control.Left: self.rot = min(self.rot + self.rot_delta, self.rot_max) elif ctl == Control.LeftFast: self.rot = 0 if self.rot < 0 else self.rot_max elif ctl == Control.Right: self.rot = max(self.rot - self.rot_delta, self.rot_min) elif ctl == Control.RightFast: self.rot = 0 if self.rot > 0 else self.rot_min self.vac.manual_control(rotation=self.rot, velocity=self.vel, duration=self.dur) return False def info(self) -> str: return f"Rotation={self.rot}\nVelocity={self.vel}\n" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuumcontainers.py0000644000000000000000000004426314265350055023542 0ustar00from datetime import datetime, time, timedelta, tzinfo from enum import IntEnum from typing import Any, Dict, List, Optional, Union from croniter import croniter from miio.device import DeviceStatus from miio.utils import pretty_seconds, pretty_time def pretty_area(x: float) -> float: return int(x) / 1000000 error_codes = { # from vacuum_cleaner-EN.pdf 0: "No error", 1: "Laser distance sensor error", 2: "Collision sensor error", 3: "Wheels on top of void, move robot", 4: "Clean hovering sensors, move robot", 5: "Clean main brush", 6: "Clean side brush", 7: "Main wheel stuck?", 8: "Device stuck, clean area", 9: "Dust collector missing", 10: "Clean filter", 11: "Stuck in magnetic barrier", 12: "Low battery", 13: "Charging fault", 14: "Battery fault", 15: "Wall sensors dirty, wipe them", 16: "Place me on flat surface", 17: "Side brushes problem, reboot me", 18: "Suction fan problem", 19: "Unpowered charging station", 21: "Laser disance sensor blocked", 22: "Clean the dock charging contacts", 23: "Docking station not reachable", 24: "No-go zone or invisible wall detected", } class VacuumStatus(DeviceStatus): """Container for status reports from the vacuum.""" def __init__(self, data: Dict[str, Any]) -> None: # {'result': [{'state': 8, 'dnd_enabled': 1, 'clean_time': 0, # 'msg_ver': 4, 'map_present': 1, 'error_code': 0, 'in_cleaning': 0, # 'clean_area': 0, 'battery': 100, 'fan_power': 20, 'msg_seq': 320}], # 'id': 1} # v8 new items # clean_mode, begin_time, clean_trigger, # back_trigger, clean_strategy, and completed # TODO: create getters if wanted # # {"msg_ver":8,"msg_seq":60,"state":5,"battery":93,"clean_mode":0, # "fan_power":50,"error_code":0,"map_present":1,"in_cleaning":1, # "dnd_enabled":0,"begin_time":1534333389,"clean_time":21, # "clean_area":202500,"clean_trigger":2,"back_trigger":0, # "completed":0,"clean_strategy":1} # Example of S6 in the segment cleaning mode # new items: in_fresh_state, water_box_status, lab_status, map_status, lock_status # # [{'msg_ver': 2, 'msg_seq': 28, 'state': 18, 'battery': 95, # 'clean_time': 606, 'clean_area': 8115000, 'error_code': 0, # 'map_present': 1, 'in_cleaning': 3, 'in_returning': 0, # 'in_fresh_state': 0, 'lab_status': 1, 'water_box_status': 0, # 'fan_power': 102, 'dnd_enabled': 0, 'map_status': 3, 'lock_status': 0}] # Example of S7 in charging mode # new items: is_locating, water_box_mode, water_box_carriage_status, # mop_forbidden_enable, adbumper_status, water_shortage_status, # dock_type, dust_collection_status, auto_dust_collection, mop_mode, debug_mode # # [{'msg_ver': 2, 'msg_seq': 1839, 'state': 8, 'battery': 100, # 'clean_time': 2311, 'clean_area': 35545000, 'error_code': 0, # 'map_present': 1, 'in_cleaning': 0, 'in_returning': 0, # 'in_fresh_state': 1, 'lab_status': 3, 'water_box_status': 1, # 'fan_power': 102, 'dnd_enabled': 0, 'map_status': 3, 'is_locating': 0, # 'lock_status': 0, 'water_box_mode': 202, 'water_box_carriage_status': 0, # 'mop_forbidden_enable': 0, 'adbumper_status': [0, 0, 0], # 'water_shortage_status': 0, 'dock_type': 0, 'dust_collection_status': 0, # 'auto_dust_collection': 1, 'mop_mode': 300, 'debug_mode': 0}] self.data = data @property def state_code(self) -> int: """State code as returned by the device.""" return int(self.data["state"]) @property def state(self) -> str: """Human readable state description, see also :func:`state_code`.""" states = { 1: "Starting", 2: "Charger disconnected", 3: "Idle", 4: "Remote control active", 5: "Cleaning", 6: "Returning home", 7: "Manual mode", 8: "Charging", 9: "Charging problem", 10: "Paused", 11: "Spot cleaning", 12: "Error", 13: "Shutting down", 14: "Updating", 15: "Docking", 16: "Going to target", 17: "Zoned cleaning", 18: "Segment cleaning", 22: "Emptying the bin", # on s7+, see #1189 23: "Washing the mop", # on a46, #1435 26: "Going to wash the mop", # on a46, #1435 100: "Charging complete", 101: "Device offline", } try: return states[int(self.state_code)] except KeyError: return "Definition missing for state %s" % self.state_code @property def error_code(self) -> int: """Error code as returned by the device.""" return int(self.data["error_code"]) @property def error(self) -> str: """Human readable error description, see also :func:`error_code`.""" try: return error_codes[self.error_code] except KeyError: return "Definition missing for error %s" % self.error_code @property def battery(self) -> int: """Remaining battery in percentage.""" return int(self.data["battery"]) @property def fanspeed(self) -> int: """Current fan speed.""" return int(self.data["fan_power"]) @property def clean_time(self) -> timedelta: """Time used for cleaning (if finished, shows how long it took).""" return pretty_seconds(self.data["clean_time"]) @property def clean_area(self) -> float: """Cleaned area in m2.""" return pretty_area(self.data["clean_area"]) @property def map(self) -> bool: """Map token.""" return bool(self.data["map_present"]) @property def in_zone_cleaning(self) -> bool: """Return True if the vacuum is in zone cleaning mode.""" return self.data["in_cleaning"] == 2 @property def in_segment_cleaning(self) -> bool: """Return True if the vacuum is in segment cleaning mode.""" return self.data["in_cleaning"] == 3 @property def is_paused(self) -> bool: """Return True if vacuum is paused.""" return self.state_code == 10 @property def is_on(self) -> bool: """True if device is currently cleaning in any mode.""" return ( self.state_code == 5 or self.state_code == 7 or self.state_code == 11 or self.state_code == 17 or self.state_code == 18 ) @property def is_water_box_attached(self) -> Optional[bool]: """Return True is water box is installed.""" if "water_box_status" in self.data: return self.data["water_box_status"] == 1 return None @property def is_water_box_carriage_attached(self) -> Optional[bool]: """Return True if water box carriage (mop) is installed, None if sensor not present.""" if "water_box_carriage_status" in self.data: return self.data["water_box_carriage_status"] == 1 return None @property def is_water_shortage(self) -> Optional[bool]: """Returns True if water is low in the tank, None if sensor not present.""" if "water_shortage_status" in self.data: return self.data["water_shortage_status"] == 1 return None @property def got_error(self) -> bool: """True if an error has occured.""" return self.error_code != 0 class CleaningSummary(DeviceStatus): """Contains summarized information about available cleaning runs.""" def __init__(self, data: Union[List[Any], Dict[str, Any]]) -> None: # total duration, total area, amount of cleans # [ list, of, ids ] # { "result": [ 174145, 2410150000, 82, # [ 1488240000, 1488153600, 1488067200, 1487980800, # 1487894400, 1487808000, 1487548800 ] ], # "id": 1 } # newer models return a dict if isinstance(data, list): self.data = { "clean_time": data[0], "clean_area": data[1], "clean_count": data[2], } if len(data) > 3: self.data["records"] = data[3] else: self.data = data if "records" not in self.data: self.data["records"] = [] @property def total_duration(self) -> timedelta: """Total cleaning duration.""" return pretty_seconds(self.data["clean_time"]) @property def total_area(self) -> float: """Total cleaned area.""" return pretty_area(self.data["clean_area"]) @property def count(self) -> int: """Number of cleaning runs.""" return int(self.data["clean_count"]) @property def ids(self) -> List[int]: """A list of available cleaning IDs, see also :class:`CleaningDetails`.""" return list(self.data["records"]) @property def dust_collection_count(self) -> Optional[int]: """Total number of dust collections.""" if "dust_collection_count" in self.data: return int(self.data["dust_collection_count"]) else: return None class CleaningDetails(DeviceStatus): """Contains details about a specific cleaning run.""" def __init__(self, data: Union[List[Any], Dict[str, Any]]) -> None: # start, end, duration, area, unk, complete # { "result": [ [ 1488347071, 1488347123, 16, 0, 0, 0 ] ], "id": 1 } # newer models return a dict if isinstance(data, list): self.data = { "begin": data[0], "end": data[1], "duration": data[2], "area": data[3], "error": data[4], "complete": data[5], } else: self.data = data @property def start(self) -> datetime: """When cleaning was started.""" return pretty_time(self.data["begin"]) @property def end(self) -> datetime: """When cleaning was finished.""" return pretty_time(self.data["end"]) @property def duration(self) -> timedelta: """Total duration of the cleaning run.""" return pretty_seconds(self.data["duration"]) @property def area(self) -> float: """Total cleaned area.""" return pretty_area(self.data["area"]) @property def error_code(self) -> int: """Error code.""" return int(self.data["error"]) @property def error(self) -> str: """Error state of this cleaning run.""" return error_codes[self.data["error"]] @property def complete(self) -> bool: """Return True if the cleaning run was complete (e.g. without errors). see also :func:`error`. """ return self.data["complete"] == 1 class ConsumableStatus(DeviceStatus): """Container for consumable status information, including information about brushes and duration until they should be changed. The methods returning time left are based on the following lifetimes: - Sensor cleanup time: XXX FIXME - Main brush: 300 hours - Side brush: 200 hours - Filter: 150 hours """ def __init__(self, data: Dict[str, Any]) -> None: # {'id': 1, 'result': [{'filter_work_time': 32454, # 'sensor_dirty_time': 3798, # 'side_brush_work_time': 32454, # 'main_brush_work_time': 32454}]} # TODO this should be generalized to allow different time limits self.data = data self.main_brush_total = timedelta(hours=300) self.side_brush_total = timedelta(hours=200) self.filter_total = timedelta(hours=150) self.sensor_dirty_total = timedelta(hours=30) @property def main_brush(self) -> timedelta: """Main brush usage time.""" return pretty_seconds(self.data["main_brush_work_time"]) @property def main_brush_left(self) -> timedelta: """How long until the main brush should be changed.""" return self.main_brush_total - self.main_brush @property def side_brush(self) -> timedelta: """Side brush usage time.""" return pretty_seconds(self.data["side_brush_work_time"]) @property def side_brush_left(self) -> timedelta: """How long until the side brush should be changed.""" return self.side_brush_total - self.side_brush @property def filter(self) -> timedelta: """Filter usage time.""" return pretty_seconds(self.data["filter_work_time"]) @property def filter_left(self) -> timedelta: """How long until the filter should be changed.""" return self.filter_total - self.filter @property def sensor_dirty(self) -> timedelta: """Return ``sensor_dirty_time``""" return pretty_seconds(self.data["sensor_dirty_time"]) @property def sensor_dirty_left(self) -> timedelta: return self.sensor_dirty_total - self.sensor_dirty class DNDStatus(DeviceStatus): """A container for the do-not-disturb status.""" def __init__(self, data: Dict[str, Any]): # {'end_minute': 0, 'enabled': 1, 'start_minute': 0, # 'start_hour': 22, 'end_hour': 8} self.data = data @property def enabled(self) -> bool: """True if DnD is enabled.""" return bool(self.data["enabled"]) @property def start(self) -> time: """Start time of DnD.""" return time(hour=self.data["start_hour"], minute=self.data["start_minute"]) @property def end(self) -> time: """End time of DnD.""" return time(hour=self.data["end_hour"], minute=self.data["end_minute"]) class Timer(DeviceStatus): """A container for scheduling. The timers are accessed using an integer ID, which is based on the unix timestamp of the creation time. """ def __init__(self, data: List[Any], timezone: tzinfo) -> None: # id / timestamp, enabled, ['', ['command', 'params'] # [['1488667794112', 'off', ['49 22 * * 6', ['start_clean', '']]], # ['1488667777661', 'off', ['49 21 * * 3,4,5,6', ['start_clean', '']] # ], self.data = data self.timezone = timezone # ignoring the type here, as the localize is not provided directly by datetime.tzinfo localized_ts = timezone.localize(datetime.now()) # type: ignore # Initialize croniter to cause an exception on invalid entries (#847) self.croniter = croniter(self.cron, start_time=localized_ts) @property def id(self) -> str: """Unique identifier for timer. Usually a unix timestamp of when the timer was created, but it is not guaranteed. For example, valetudo apparently allows using arbitrary strings for this. """ return self.data[0] @property def ts(self) -> Optional[datetime]: """Timer creation time, if the id is a unix timestamp.""" try: return pretty_time(int(self.data[0]) / 1000) except ValueError: return None @property def enabled(self) -> bool: """True if the timer is active.""" return self.data[1] == "on" @property def cron(self) -> str: """Cron-formated timer string.""" return str(self.data[2][0]) @property def action(self) -> str: """The action to be taken on the given time. Note, this seems to be always 'start'. """ return str(self.data[2][1]) @property def next_schedule(self) -> datetime: """Next schedule for the timer.""" return self.croniter.get_next(ret_type=datetime) class SoundStatus(DeviceStatus): """Container for sound status.""" def __init__(self, data): # {'sid_in_progress': 0, 'sid_in_use': 1004} self.data = data @property def current(self): return self.data["sid_in_use"] @property def being_installed(self): return self.data["sid_in_progress"] class SoundInstallState(IntEnum): Unknown = 0 Downloading = 1 Installing = 2 Installed = 3 Error = 4 class SoundInstallStatus(DeviceStatus): """Container for sound installation status.""" def __init__(self, data): # {'progress': 0, 'sid_in_progress': 0, 'state': 0, 'error': 0} # error 0 = no error # error 1 = unknown 1 # error 2 = download error # error 3 = checksum error # error 4 = unknown 4 self.data = data @property def state(self) -> SoundInstallState: """Installation state.""" return SoundInstallState(self.data["state"]) @property def progress(self) -> int: """Progress in percentages.""" return self.data["progress"] @property def sid(self) -> int: """Sound ID for the sound being installed.""" # this is missing on install confirmation, so let's use get return self.data.get("sid_in_progress", None) @property def error(self) -> int: """Error code, 0 is no error, other values unknown.""" return self.data["error"] @property def is_installing(self) -> bool: """True if install is in progress.""" return ( self.state == SoundInstallState.Downloading or self.state == SoundInstallState.Installing ) @property def is_errored(self) -> bool: """True if the state has an error, use `error` to access it.""" return self.state == SoundInstallState.Error class CarpetModeStatus(DeviceStatus): """Container for carpet mode status.""" def __init__(self, data): # {'current_high': 500, 'enable': 1, 'current_integral': 450, # 'current_low': 400, 'stall_time': 10} self.data = data @property def enabled(self) -> bool: """True if carpet mode is enabled.""" return self.data["enable"] == 1 @property def stall_time(self) -> int: return self.data["stall_time"] @property def current_low(self) -> int: return self.data["current_low"] @property def current_high(self) -> int: return self.data["current_high"] @property def current_integral(self) -> int: return self.data["current_integral"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roidmi/__init__.py0000644000000000000000000000007714265350055021351 0ustar00# flake8: noqa from .roidmivacuum_miot import RoidmiVacuumMiot ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roidmi/roidmivacuum_miot.py0000644000000000000000000006403314265350055023350 0ustar00"""Vacuum Eve Plus (roidmi.vacuum.v60)""" import json import logging import math from datetime import timedelta from enum import Enum import click from miio.click_common import EnumType, command from miio.integrations.vacuum.roborock.vacuumcontainers import DNDStatus from miio.interfaces import FanspeedPresets, VacuumInterface from miio.miot_device import DeviceStatus, MiotDevice, MiotMapping _LOGGER = logging.getLogger(__name__) _MAPPINGS: MiotMapping = { "roidmi.vacuum.v60": { "battery_level": {"siid": 3, "piid": 1}, "charging_state": {"siid": 3, "piid": 2}, "error_code": {"siid": 2, "piid": 2}, "state": {"siid": 2, "piid": 1}, "filter_life_level": {"siid": 10, "piid": 1}, "filter_left_minutes": {"siid": 10, "piid": 2}, "main_brush_left_minutes": {"siid": 11, "piid": 1}, "main_brush_life_level": {"siid": 11, "piid": 2}, "side_brushes_left_minutes": {"siid": 12, "piid": 1}, "side_brushes_life_level": {"siid": 12, "piid": 2}, "sensor_dirty_time_left_minutes": { "siid": 15, "piid": 1, }, # named brush_left_time in the spec "sensor_dirty_remaning_level": {"siid": 15, "piid": 2}, "sweep_mode": {"siid": 14, "piid": 1}, "fanspeed_mode": {"siid": 2, "piid": 4}, "sweep_type": {"siid": 2, "piid": 8}, "path_mode": {"siid": 13, "piid": 8}, "mop_present": {"siid": 8, "piid": 1}, "work_station_freq": {"siid": 8, "piid": 2}, # Range: [0, 3, 1] "timing": {"siid": 8, "piid": 6}, "clean_area": {"siid": 8, "piid": 7}, # uint32 # "uid": {"siid": 8, "piid": 8}, # str - This UID is unknown "auto_boost": {"siid": 8, "piid": 9}, "forbid_mode": {"siid": 8, "piid": 10}, # str "water_level": {"siid": 8, "piid": 11}, "total_clean_time_sec": {"siid": 8, "piid": 13}, "total_clean_areas": {"siid": 8, "piid": 14}, "clean_counts": {"siid": 8, "piid": 18}, "clean_time_sec": {"siid": 8, "piid": 19}, "double_clean": {"siid": 8, "piid": 20}, # "edge_sweep": {"siid": 8, "piid": 21}, # 2021-07-11: Roidmi Eve is not changing behavior when this bool is changed "led_switch": {"siid": 8, "piid": 22}, "lidar_collision": {"siid": 8, "piid": 23}, "station_key": {"siid": 8, "piid": 24}, "station_led": {"siid": 8, "piid": 25}, "current_audio": {"siid": 8, "piid": 26}, # "progress": {"siid": 8, "piid": 28}, # 2021-07-11: this is part of the spec, but not implemented in Roidme Eve "station_type": {"siid": 8, "piid": 29}, # uint32 # "voice_conf": {"siid": 8, "piid": 30}, # Always return file not exist !!! # "switch_status": {"siid": 2, "piid": 10}, # Enum with only one value: Open "volume": {"siid": 9, "piid": 1}, "mute": {"siid": 9, "piid": 2}, "start": {"siid": 2, "aiid": 1}, "stop": {"siid": 2, "aiid": 2}, "start_room_sweep": {"siid": 2, "aiid": 3}, "start_sweep": {"siid": 14, "aiid": 1}, "home": {"siid": 3, "aiid": 1}, "identify": {"siid": 8, "aiid": 1}, "start_station_dust_collection": {"siid": 8, "aiid": 6}, "set_voice": {"siid": 8, "aiid": 12}, "reset_filter_life": {"siid": 10, "aiid": 1}, "reset_main_brush_life": {"siid": 11, "aiid": 1}, "reset_side_brushes_life": {"siid": 12, "aiid": 1}, "reset_sensor_dirty_life": {"siid": 15, "aiid": 1}, } } class ChargingState(Enum): Unknown = -1 Charging = 1 Discharging = 2 NotChargeable = 4 class FanSpeed(Enum): Unknown = -1 Silent = 1 Basic = 2 Strong = 3 FullSpeed = 4 Sweep = 0 class SweepType(Enum): Unknown = -1 Sweep = 0 Mop = 1 MopAndSweep = 2 class PathMode(Enum): Unknown = -1 Normal = 0 YMopping = 1 RepeatMopping = 2 class WaterLevel(Enum): Unknown = -1 First = 1 Second = 2 Three = 3 Fourth = 4 Mop = 0 class SweepMode(Enum): Unknown = -1 Total = 1 Area = 2 Curpoint = 3 Point = 4 Smart = 7 AmartArea = 8 DepthTotal = 9 AlongWall = 10 Idle = 0 error_codes = { 0: "NoFaults", 1: "LowBatteryFindCharger", 2: "LowBatteryAndPoweroff", 3: "WheelRap", 4: "CollisionError", 5: "TileDoTask", 6: "LidarPointError", 7: "FrontWallError", 8: "PsdDirty", 9: "MiddleBrushFatal", 10: "SideBrush", 11: "FanSpeedError", 12: "LidarCover", 13: "GarbageBoxFull", 14: "GarbageBoxOut", 15: "GarbageBoxFullOut", 16: "PhysicalTrapped", 17: "PickUpDoTask", 18: "NoWaterBoxDoTask", 19: "WaterBoxEmpty", 20: "CleanCannotArrive", 21: "StartFormForbid", 22: "Drop", 23: "KitWaterPump", 24: "FindChargerFailed", 25: "LowPowerClean", } class RoidmiState(Enum): Unknown = -1 Dormant = 1 Idle = 2 Paused = 3 Sweeping = 4 GoCharging = 5 Charging = 6 Error = 7 Rfctrl = 8 Fullcharge = 9 Shutdown = 10 FindChargerPause = 11 class RoidmiVacuumStatus(DeviceStatus): """Container for status reports from the vacuum.""" def __init__(self, data): """ Response (MIoT format) of a Roidme Eve Plus (roidmi.vacuum.v60) [ {'did': 'battery_level', 'siid': 3, 'piid': 1}, {'did': 'charging_state', 'siid': 3, 'piid': 2}, {'did': 'error_code', 'siid': 2, 'piid': 2}, {'did': 'state', 'siid': 2, 'piid': 1}, {'did': 'filter_life_level', 'siid': 10, 'piid': 1}, {'did': 'filter_left_minutes', 'siid': 10, 'piid': 2}, {'did': 'main_brush_left_minutes', 'siid': 11, 'piid': 1}, {'did': 'main_brush_life_level', 'siid': 11, 'piid': 2}, {'did': 'side_brushes_left_minutes', 'siid': 12, 'piid': 1}, {'did': 'side_brushes_life_level', 'siid': 12, 'piid': 2}, {'did': 'sensor_dirty_time_left_minutes', 'siid': 15, 'piid': 1}, {'did': 'sensor_dirty_remaning_level', 'siid': 15, 'piid': 2}, {'did': 'sweep_mode', 'siid': 14, 'piid': 1}, {'did': 'fanspeed_mode', 'siid': 2, 'piid': 4}, {'did': 'sweep_type', 'siid': 2, 'piid': 8} {'did': 'path_mode', 'siid': 13, 'piid': 8}, {'did': 'mop_present', 'siid': 8, 'piid': 1}, {'did': 'work_station_freq', 'siid': 8, 'piid': 2}, {'did': 'timing', 'siid': 8, 'piid': 6}, {'did': 'clean_area', 'siid': 8, 'piid': 7}, {'did': 'auto_boost', 'siid': 8, 'piid': 9}, {'did': 'forbid_mode', 'siid': 8, 'piid': 10}, {'did': 'water_level', 'siid': 8, 'piid': 11}, {'did': 'total_clean_time_sec', 'siid': 8, 'piid': 13}, {'did': 'total_clean_areas', 'siid': 8, 'piid': 14}, {'did': 'clean_counts', 'siid': 8, 'piid': 18}, {'did': 'clean_time_sec', 'siid': 8, 'piid': 19}, {'did': 'double_clean', 'siid': 8, 'piid': 20}, {'did': 'led_switch', 'siid': 8, 'piid': 22} {'did': 'lidar_collision', 'siid': 8, 'piid': 23}, {'did': 'station_key', 'siid': 8, 'piid': 24}, {'did': 'station_led', 'siid': 8, 'piid': 25}, {'did': 'current_audio', 'siid': 8, 'piid': 26}, {'did': 'station_type', 'siid': 8, 'piid': 29}, {'did': 'volume', 'siid': 9, 'piid': 1}, {'did': 'mute', 'siid': 9, 'piid': 2} ] """ self.data = data @property def battery(self) -> int: """Remaining battery in percentage.""" return self.data["battery_level"] @property def error_code(self) -> int: """Error code as returned by the device.""" return int(self.data["error_code"]) @property def error(self) -> str: """Human readable error description, see also :func:`error_code`.""" try: return error_codes[self.error_code] except KeyError: return "Definition missing for error %s" % self.error_code @property def charging_state(self) -> ChargingState: """Charging state (Charging/Discharging)""" try: return ChargingState(self.data["charging_state"]) except ValueError: _LOGGER.error("Unknown ChargingStats (%s)", self.data["charging_state"]) return ChargingState.Unknown @property def sweep_mode(self) -> SweepMode: """Sweep mode point/area/total etc.""" try: return SweepMode(self.data["sweep_mode"]) except ValueError: _LOGGER.error("Unknown SweepMode (%s)", self.data["sweep_mode"]) return SweepMode.Unknown @property def fan_speed(self) -> FanSpeed: """Current fan speed.""" try: return FanSpeed(self.data["fanspeed_mode"]) except ValueError: _LOGGER.error("Unknown FanSpeed (%s)", self.data["fanspeed_mode"]) return FanSpeed.Unknown @property def sweep_type(self) -> SweepType: """Current sweep type sweep/mop/sweep&mop.""" try: return SweepType(self.data["sweep_type"]) except ValueError: _LOGGER.error("Unknown SweepType (%s)", self.data["sweep_type"]) return SweepType.Unknown @property def path_mode(self) -> PathMode: """Current path-mode: normal/y-mopping etc.""" try: return PathMode(self.data["path_mode"]) except ValueError: _LOGGER.error("Unknown PathMode (%s)", self.data["path_mode"]) return PathMode.Unknown @property def is_mop_attached(self) -> bool: """Return True if mop is attached.""" return self.data["mop_present"] @property def dust_collection_frequency(self) -> int: """Frequency for emptying the dust bin. Example: 2 means the dust bin is emptied every second cleaning. """ return self.data["work_station_freq"] @property def timing(self) -> str: """Repeated cleaning Example: {"time":[[32400,1,3,0,[1,2,3,4,5],0,[12,10],null],[57600,0,1,2,[1,2,3,4,5,6,0],2,[],null]],"tz":2,"tzs":7200} Cleaning 1: 32400 = startTime(9:00) 1=Enabled 3=FanSpeed.Strong 0=SweepType.Sweep [1,2,3,4,5]=Monday-Friday 0=WaterLevel [12,10]=List of rooms null: ?Might be related to "Customize"? Cleaning 2: 57600 = startTime(16:00) 0=Disabled 1=FanSpeed.Silent 2=SweepType.MopAndSweep [1,2,3,4,5,6,0]=Monday-Sunday 2=WaterLevel.Second []=All rooms null: ?Might be related to "Customize"? tz/tzs= time-zone """ return self.data["timing"] @property def carpet_mode(self) -> bool: """Auto boost on carpet.""" return self.data["auto_boost"] def _parse_forbid_mode(self, val) -> DNDStatus: # Example data: {"time":[75600,21600,1],"tz":2,"tzs":7200} def _seconds_to_components(val): hour = math.floor(val / 3600) minut = math.floor((val - hour * 3600) / 60) return (hour, minut) as_dict = json.loads(val) enabled = bool(as_dict["time"][2]) start = _seconds_to_components(as_dict["time"][0]) end = _seconds_to_components(as_dict["time"][1]) return DNDStatus( dict( enabled=enabled, start_hour=start[0], start_minute=start[1], end_hour=end[0], end_minute=end[1], ) ) @property def dnd_status(self) -> DNDStatus: """Returns do-not-disturb status.""" return self._parse_forbid_mode(self.data["forbid_mode"]) @property def water_level(self) -> WaterLevel: """Get current water level.""" try: return WaterLevel(self.data["water_level"]) except ValueError: _LOGGER.error("Unknown WaterLevel (%s)", self.data["water_level"]) return WaterLevel.Unknown @property def double_clean(self) -> bool: """Is double clean enabled.""" return self.data["double_clean"] @property def led(self) -> bool: """Return True if led/display on vaccum is on.""" return self.data["led_switch"] @property def is_lidar_collision_sensor(self) -> bool: """When ON, the robot will use lidar as the main detection sensor to help reduce collisions.""" return self.data["lidar_collision"] @property def station_key(self) -> bool: """When ON: long press the display will turn on dust collection.""" return self.data["station_key"] @property def station_led(self) -> bool: """Return if station display is on.""" return self.data["station_led"] @property def current_audio(self) -> str: """Current voice setting. E.g. 'girl_en' """ return self.data["current_audio"] @property def clean_time(self) -> timedelta: """Time used for cleaning (if finished, shows how long it took).""" return timedelta(seconds=self.data["clean_time_sec"]) @property def clean_area(self) -> int: """Cleaned area in m2.""" return self.data["clean_area"] @property def state_code(self) -> int: """State code as returned by the device.""" return int(self.data["state"]) @property def state(self) -> RoidmiState: """Human readable state description, see also :func:`state_code`.""" try: return RoidmiState(self.state_code) except ValueError: _LOGGER.error("Unknown RoidmiState (%s)", self.state_code) return RoidmiState.Unknown @property def volume(self) -> int: """Return device sound volumen level.""" return self.data["volume"] @property def is_muted(self) -> bool: """True if device is muted.""" return bool(self.data["mute"]) @property def is_paused(self) -> bool: """Return True if vacuum is paused.""" return self.state in [RoidmiState.Paused, RoidmiState.FindChargerPause] @property def is_on(self) -> bool: """True if device is currently cleaning in any mode.""" return self.state == RoidmiState.Sweeping @property def got_error(self) -> bool: """True if an error has occured.""" return self.error_code != 0 class RoidmiCleaningSummary(DeviceStatus): """Contains summarized information about available cleaning runs.""" def __init__(self, data) -> None: self.data = data @property def total_duration(self) -> timedelta: """Total cleaning duration.""" return timedelta(seconds=self.data["total_clean_time_sec"]) @property def total_area(self) -> int: """Total cleaned area.""" return self.data["total_clean_areas"] @property def count(self) -> int: """Number of cleaning runs.""" return self.data["clean_counts"] class RoidmiConsumableStatus(DeviceStatus): """Container for consumable status information, including information about brushes and duration until they should be changed. The methods returning time left are based values returned from the device. """ def __init__(self, data): self.data = data def _calcUsageTime( self, renaning_time: timedelta, remaning_level: int ) -> timedelta: remaning_fraction = remaning_level / 100.0 original_total = renaning_time / remaning_fraction return original_total * (1 - remaning_fraction) @property def filter(self) -> timedelta: """Filter usage time.""" return self._calcUsageTime(self.filter_left, self.data["filter_life_level"]) @property def filter_left(self) -> timedelta: """How long until the filter should be changed.""" return timedelta(minutes=self.data["filter_left_minutes"]) @property def main_brush(self) -> timedelta: """Main brush usage time.""" return self._calcUsageTime( self.main_brush_left, self.data["main_brush_life_level"] ) @property def main_brush_left(self) -> timedelta: """How long until the main brush should be changed.""" return timedelta(minutes=self.data["main_brush_left_minutes"]) @property def side_brush(self) -> timedelta: """Main brush usage time.""" return self._calcUsageTime( self.side_brush_left, self.data["side_brushes_life_level"] ) @property def side_brush_left(self) -> timedelta: """How long until the side brushes should be changed.""" return timedelta(minutes=self.data["side_brushes_left_minutes"]) @property def sensor_dirty(self) -> timedelta: """Return time since last sensor clean.""" return self._calcUsageTime( self.sensor_dirty_left, self.data["sensor_dirty_remaning_level"] ) @property def sensor_dirty_left(self) -> timedelta: """How long until the sensors should be cleaned.""" return timedelta(minutes=self.data["sensor_dirty_time_left_minutes"]) class RoidmiVacuumMiot(MiotDevice, VacuumInterface): """Interface for Vacuum Eve Plus (roidmi.vacuum.v60)""" _mappings = _MAPPINGS @command() def status(self) -> RoidmiVacuumStatus: """State of the vacuum.""" return RoidmiVacuumStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None # max_properties limmit to 10 to avoid "Checksum error" messages from the device. for prop in self.get_properties_for_mapping() } ) @command() def consumable_status(self) -> RoidmiConsumableStatus: """Return information about consumables.""" return RoidmiConsumableStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None # max_properties limmit to 10 to avoid "Checksum error" messages from the device. for prop in self.get_properties_for_mapping() } ) @command() def cleaning_summary(self) -> RoidmiCleaningSummary: """Return information about cleaning runs.""" return RoidmiCleaningSummary( { prop["did"]: prop["value"] if prop["code"] == 0 else None # max_properties limmit to 10 to avoid "Checksum error" messages from the device. for prop in self.get_properties_for_mapping() } ) @command() def start(self) -> None: """Start cleaning.""" return self.call_action("start") # @command(click.argument("roomstr", type=str, required=False)) # def start_room_sweep_unknown(self, roomstr: str=None) -> None: # """Start room cleaning. # roomstr: empty means start room clean of all rooms. FIXME: the syntax of an non-empty roomstr is still unknown # """ # return self.call_action("start_room_sweep", roomstr) # @command( # click.argument("sweep_mode", type=EnumType(SweepMode)), # click.argument("clean_info", type=str), # ) # def start_sweep_unknown(self, sweep_mode: SweepMode, clean_info: str=None) -> None: # """Start sweep with mode. # FIXME: the syntax of start_sweep is unknown # """ # return self.call_action("start_sweep", [sweep_mode.value, clean_info]) @command() def stop(self) -> None: """Stop cleaning.""" return self.call_action("stop") @command() def home(self) -> None: """Return to home.""" return self.call_action("home") @command() def identify(self) -> None: """Locate the device (i am here).""" return self.call_action("identify") @command(click.argument("on", type=bool)) def set_station_led(self, on: bool): """Enable station led display.""" return self.set_property("station_led", on) @command(click.argument("on", type=bool)) def set_led(self, on: bool): """Enable vacuum led.""" return self.set_property("led_switch", on) @command(click.argument("vol", type=int)) def set_sound_volume(self, vol: int): """Set sound volume [0-100].""" return self.set_property("volume", vol) @command(click.argument("value", type=bool)) def set_sound_muted(self, value: bool): """Set sound volume muted.""" return self.set_property("mute", value) @command(click.argument("fanspeed_mode", type=EnumType(FanSpeed))) def set_fanspeed(self, fanspeed_mode: FanSpeed): """Set fan speed.""" return self.set_property("fanspeed_mode", fanspeed_mode.value) @command() def fan_speed_presets(self) -> FanspeedPresets: """Return available fan speed presets.""" return {"Sweep": 0, "Silent": 1, "Basic": 2, "Strong": 3, "FullSpeed": 4} @command(click.argument("speed", type=int)) def set_fan_speed_preset(self, speed_preset: int) -> None: """Set fan speed preset speed.""" if speed_preset not in self.fan_speed_presets().values(): raise ValueError( f"Invalid preset speed {speed_preset}, not in: {self.fan_speed_presets().values()}" ) return self.set_property("fanspeed_mode", speed_preset) @command(click.argument("sweep_type", type=EnumType(SweepType))) def set_sweep_type(self, sweep_type: SweepType): """Set sweep_type.""" return self.set_property("sweep_type", sweep_type.value) @command(click.argument("path_mode", type=EnumType(PathMode))) def set_path_mode(self, path_mode: PathMode): """Set path_mode.""" return self.set_property("path_mode", path_mode.value) @command(click.argument("dust_collection_frequency", type=int)) def set_dust_collection_frequency(self, dust_collection_frequency: int): """Set frequency for emptying the dust bin. Example: 2 means the dust bin is emptied every second cleaning. """ return self.set_property("work_station_freq", dust_collection_frequency) @command(click.argument("timing", type=str)) def set_timing(self, timing: str): """Set repeated clean timing. Set timing to 9:00 Monday-Friday, rooms:[12,10] timing = '{"time":[[32400,1,3,0,[1,2,3,4,5],0,[12,10],null]],"tz":2,"tzs":7200}' See also :func:`RoidmiVacuumStatus.timing` NOTE: setting timing will override existing settings """ return self.set_property("timing", timing) @command(click.argument("auto_boost", type=bool)) def set_carpet_mode(self, auto_boost: bool): """Set auto boost on carpet.""" return self.set_property("auto_boost", auto_boost) def _set_dnd(self, start_int: int, end_int: int, active: bool): value_str = json.dumps({"time": [start_int, end_int, int(active)]}) return self.set_property("forbid_mode", value_str) @command( click.argument("start_hr", type=int), click.argument("start_min", type=int), click.argument("end_hr", type=int), click.argument("end_min", type=int), ) def set_dnd(self, start_hr: int, start_min: int, end_hr: int, end_min: int): """Set do-not-disturb. :param int start_hr: Start hour :param int start_min: Start minute :param int end_hr: End hour :param int end_min: End minute """ start_int = int(timedelta(hours=start_hr, minutes=start_min).total_seconds()) end_int = int(timedelta(hours=end_hr, minutes=end_min).total_seconds()) return self._set_dnd(start_int, end_int, active=True) @command() def disable_dnd(self): """Disable do-not-disturb.""" # The current do not disturb is read back for a better user expierence, # as start/end time must be set together with enabled=False try: current_dnd_str = self.get_property_by( **self._get_mapping()["forbid_mode"] )[0]["value"] current_dnd_dict = json.loads(current_dnd_str) except Exception: # In case reading current DND back fails, DND is disabled anyway return self._set_dnd(0, 0, active=False) return self._set_dnd( current_dnd_dict["time"][0], current_dnd_dict["time"][1], active=False ) @command(click.argument("water_level", type=EnumType(WaterLevel))) def set_water_level(self, water_level: WaterLevel): """Set water_level.""" return self.set_property("water_level", water_level.value) @command(click.argument("double_clean", type=bool)) def set_double_clean(self, double_clean: bool): """Set double clean (True/False).""" return self.set_property("double_clean", double_clean) @command(click.argument("lidar_collision", type=bool)) def set_lidar_collision_sensor(self, lidar_collision: bool): """When ON, the robot will use lidar as the main detection sensor to help reduce collisions.""" return self.set_property("lidar_collision", lidar_collision) @command() def start_dust(self) -> None: """Start base dust collection.""" return self.call_action("start_station_dust_collection") # @command(click.argument("voice", type=str)) # def set_voice_unknown(self, voice: str) -> None: # """Set voice. # FIXME: the syntax of voice is unknown (assumed to be json format) # """ # return self.call_action("set_voice", voice) @command() def reset_filter_life(self) -> None: """Reset filter life.""" return self.call_action("reset_filter_life") @command() def reset_mainbrush_life(self) -> None: """Reset main brush life.""" return self.call_action("reset_main_brush_life") @command() def reset_sidebrush_life(self) -> None: """Reset side brushes life.""" return self.call_action("reset_side_brushes_life") @command() def reset_sensor_dirty_life(self) -> None: """Reset sensor dirty life.""" return self.call_action("reset_sensor_dirty_life") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roidmi/tests/__init__.py0000644000000000000000000000000014265350055022475 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/roidmi/tests/test_roidmivacuum_miot.py0000644000000000000000000001774314265350055025557 0ustar00from datetime import timedelta from unittest import TestCase import pytest from miio.integrations.vacuum.roborock.vacuumcontainers import DNDStatus from miio.tests.dummies import DummyMiotDevice from ..roidmivacuum_miot import ( ChargingState, FanSpeed, PathMode, RoidmiState, RoidmiVacuumMiot, SweepMode, SweepType, WaterLevel, ) _INITIAL_STATE = { "auto_boost": 1, "battery_level": 42, "main_brush_life_level": 85, "side_brushes_life_level": 57, "sensor_dirty_remaning_level": 60, "main_brush_left_minutes": 235, "side_brushes_left_minutes": 187, "sensor_dirty_time_left_minutes": 1096, "charging_state": ChargingState.Charging, "fanspeed_mode": FanSpeed.FullSpeed, "current_audio": "girl_en", "clean_area": 27, "error_code": 0, "state": RoidmiState.Paused.value, "double_clean": 0, "filter_left_minutes": 154, "filter_life_level": 66, "forbid_mode": '{"time":[75600,21600,1],"tz":2,"tzs":7200}', "led_switch": 0, "lidar_collision": 1, "mop_present": 1, "mute": 0, "station_key": 0, "station_led": 0, # "station_type": {"siid": 8, "piid": 29}, # uint32 # "switch_status": {"siid": 2, "piid": 10}, "sweep_mode": SweepMode.Smart, "sweep_type": SweepType.MopAndSweep, "timing": '{"time":[[32400,1,3,0,[1,2,3,4,5],0,[12,10],null],[57600,0,1,2,[1,2,3,4,5,6,0],2,[],null]],"tz":2,"tzs":7200}', "path_mode": PathMode.Normal, "work_station_freq": 1, # "uid": "12345678", "volume": 4, "water_level": WaterLevel.Mop, "total_clean_time_sec": 321456, "total_clean_areas": 345678, "clean_counts": 987, "clean_time_sec": 32, } class DummyRoidmiVacuumMiot(DummyMiotDevice, RoidmiVacuumMiot): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def dummyroidmivacuum(request): request.cls.device = DummyRoidmiVacuumMiot() @pytest.mark.usefixtures("dummyroidmivacuum") class TestRoidmiVacuum(TestCase): def test_vacuum_status(self): status = self.device.status() assert status.carpet_mode == _INITIAL_STATE["auto_boost"] assert status.battery == _INITIAL_STATE["battery_level"] assert status.charging_state == ChargingState(_INITIAL_STATE["charging_state"]) assert status.fan_speed == FanSpeed(_INITIAL_STATE["fanspeed_mode"]) assert status.current_audio == _INITIAL_STATE["current_audio"] assert status.clean_area == _INITIAL_STATE["clean_area"] assert status.clean_time.total_seconds() == _INITIAL_STATE["clean_time_sec"] assert status.error_code == _INITIAL_STATE["error_code"] assert status.error == "NoFaults" assert status.state == RoidmiState(_INITIAL_STATE["state"]) assert status.double_clean == _INITIAL_STATE["double_clean"] assert str(status.dnd_status) == str( status._parse_forbid_mode(_INITIAL_STATE["forbid_mode"]) ) assert status.led == _INITIAL_STATE["led_switch"] assert status.is_lidar_collision_sensor == _INITIAL_STATE["lidar_collision"] assert status.is_mop_attached == _INITIAL_STATE["mop_present"] assert status.is_muted == _INITIAL_STATE["mute"] assert status.station_key == _INITIAL_STATE["station_key"] assert status.station_led == _INITIAL_STATE["station_led"] assert status.sweep_mode == SweepMode(_INITIAL_STATE["sweep_mode"]) assert status.sweep_type == SweepType(_INITIAL_STATE["sweep_type"]) assert status.timing == _INITIAL_STATE["timing"] assert status.path_mode == PathMode(_INITIAL_STATE["path_mode"]) assert status.dust_collection_frequency == _INITIAL_STATE["work_station_freq"] assert status.volume == _INITIAL_STATE["volume"] assert status.water_level == WaterLevel(_INITIAL_STATE["water_level"]) assert status.is_paused is True assert status.is_on is False assert status.got_error is False def test_cleaning_summary(self): status = self.device.cleaning_summary() assert ( status.total_duration.total_seconds() == _INITIAL_STATE["total_clean_time_sec"] ) assert status.total_area == _INITIAL_STATE["total_clean_areas"] assert status.count == _INITIAL_STATE["clean_counts"] def test_consumable_status(self): status = self.device.consumable_status() assert ( status.main_brush_left.total_seconds() / 60 == _INITIAL_STATE["main_brush_left_minutes"] ) assert ( status.side_brush_left.total_seconds() / 60 == _INITIAL_STATE["side_brushes_left_minutes"] ) assert ( status.sensor_dirty_left.total_seconds() / 60 == _INITIAL_STATE["sensor_dirty_time_left_minutes"] ) assert status.main_brush == status._calcUsageTime( status.main_brush_left, _INITIAL_STATE["main_brush_life_level"] ) assert status.side_brush == status._calcUsageTime( status.side_brush_left, _INITIAL_STATE["side_brushes_life_level"] ) assert status.sensor_dirty == status._calcUsageTime( status.sensor_dirty_left, _INITIAL_STATE["sensor_dirty_remaning_level"] ) assert ( status.filter_left.total_seconds() / 60 == _INITIAL_STATE["filter_left_minutes"] ) assert status.filter == status._calcUsageTime( status.filter_left, _INITIAL_STATE["filter_life_level"] ) def test__calcUsageTime(self): status = self.device.consumable_status() orig_time = timedelta(minutes=500) remaning_level = 30 remaning_time = orig_time * 0.30 used_time = orig_time - remaning_time assert used_time == status._calcUsageTime(remaning_time, remaning_level) def test_parse_forbid_mode(self): status = self.device.status() value = '{"time":[75600,21600,1],"tz":2,"tzs":7200}' expected_value = DNDStatus( dict( enabled=True, start_hour=21, start_minute=0, end_hour=6, end_minute=0, ) ) assert str(status._parse_forbid_mode(value)) == str(expected_value) def test_parse_forbid_mode2(self): status = self.device.status() value = '{"time":[82080,33300,0],"tz":3,"tzs":10800}' expected_value = DNDStatus( dict( enabled=False, start_hour=22, start_minute=48, end_hour=9, end_minute=15, ) ) assert str(status._parse_forbid_mode(value)) == str(expected_value) def test_set_fan_speed_preset(self): for speed in self.device.fan_speed_presets().values(): self.device.set_fan_speed_preset(speed) class DummyRoidmiVacuumMiot2(DummyMiotDevice, RoidmiVacuumMiot): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE self.state["charging_state"] = -10 self.state["fanspeed_mode"] = -11 self.state["state"] = -12 self.state["sweep_mode"] = -13 self.state["sweep_type"] = -14 self.state["path_mode"] = -15 self.state["water_level"] = -16 super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def dummyroidmivacuum2(request): request.cls.device = DummyRoidmiVacuumMiot2() @pytest.mark.usefixtures("dummyroidmivacuum2") class TestRoidmiVacuum2(TestCase): def test_vacuum_status_unexpected_values(self): status = self.device.status() assert status.charging_state == ChargingState.Unknown assert status.fan_speed == FanSpeed.Unknown assert status.state == RoidmiState.Unknown assert status.sweep_mode == SweepMode.Unknown assert status.sweep_type == SweepType.Unknown assert status.path_mode == PathMode.Unknown assert status.water_level == WaterLevel.Unknown ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/viomi/__init__.py0000644000000000000000000000006414265350055021205 0ustar00# flake8: noqa from .viomivacuum import ViomiVacuum ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/integrations/vacuum/viomi/viomivacuum.py0000644000000000000000000007345314265350055022026 0ustar00"""Viomi Vacuum. # https://github.com/rytilahti/python-miio/issues/550#issuecomment-552780952 # https://github.com/homebridge-xiaomi-roborock-vacuum/homebridge-xiaomi-roborock-vacuum/blob/ee10cbb3e98dba75d9c97791a6e1fcafc1281591/miio/lib/devices/vacuum.js # https://github.com/homebridge-xiaomi-roborock-vacuum/homebridge-xiaomi-roborock-vacuum/blob/ee10cbb3e98dba75d9c97791a6e1fcafc1281591/miio/lib/devices/viomivacuum.js Features: Main: - Area/Duration - Missing (get_clean_summary/get_clean_record - Battery - battery_life - Dock - set_charge - Start/Pause - set_mode_withroom - Modes (Vacuum/Vacuum&Mop/Mop) - set_mop/id_mop - Fan Speed (Silent/Standard/Medium/Turbo) - set_suction/suction_grade - Water Level (Low/Medium/High) - set_suction/water_grade Settings: - Cleaning history - MISSING (cleanRecord) - Scheduled cleanup - get_ordertime - Vacuum along the edges - get_mode/set_mode - Secondary cleanup - set_repeat/repeat_cleaning - Mop or vacuum & mod mode - set_moproute/mop_route - DND(DoNotDisturb) - set_notdisturb/get_notdisturb - Voice On/Off - set_sound_volume/sound_volume - Remember Map - remember_map - Virtual wall/restricted area - MISSING - Map list - get_maps/rename_map/delete_map/set_map - Area editor - MISSING - Reset map - MISSING - Device leveling - MISSING - Looking for the vacuum-mop - MISSING (find_me) - Consumables statistics - get_properties - Remote Control - MISSING Misc: - Get Properties - Language - set_language - Led - set_light - Rooms - get_ordertime (hack) - Clean History Path - MISSING (historyPath) - Map plan - MISSING (map_plan) """ import itertools import logging import time from collections import defaultdict from datetime import timedelta from enum import Enum from typing import Any, Dict, List, Optional, Tuple import click from miio.click_common import EnumType, command, format_output from miio.device import Device, DeviceStatus from miio.exceptions import DeviceException from miio.integrations.vacuum.roborock.vacuumcontainers import ( ConsumableStatus, DNDStatus, ) from miio.interfaces import FanspeedPresets, VacuumInterface from miio.utils import pretty_seconds _LOGGER = logging.getLogger(__name__) SUPPORTED_MODELS = [ "viomi.vacuum.v6", "viomi.vacuum.v7", "viomi.vacuum.v8", "viomi.vacuum.v10", "viomi.vacuum.v13", ] ERROR_CODES = { 0: "Sleeping and not charging", 500: "Radar timed out", 501: "Wheels stuck", 502: "Low battery", 503: "Dust bin missing", 508: "Uneven ground", 509: "Cliff sensor error", 510: "Collision sensor error", 511: "Could not return to dock", 512: "Could not return to dock", 513: "Could not navigate", 514: "Vacuum stuck", 515: "Charging error", 516: "Mop temperature error", 521: "Water tank is not installed", 522: "Mop is not installed", 525: "Insufficient water in water tank", 527: "Remove mop", 528: "Dust bin missing", 529: "Mop and water tank missing", 530: "Mop and water tank missing", 531: "Water tank is not installed", 2101: "Unsufficient battery, continuing cleaning after recharge", 2103: "Charging", 2105: "Fully charged", } class ViomiVacuumException(DeviceException): """Exception raised by Viomi Vacuum.""" class ViomiPositionPoint: """Vacuum position coordinate.""" def __init__(self, pos_x, pos_y, phi, update, plan_multiplicator=1): self._pos_x = pos_x self._pos_y = pos_y self.phi = phi self.update = update self._plan_multiplicator = plan_multiplicator @property def pos_x(self): """X coordinate with multiplicator.""" return self._pos_x * self._plan_multiplicator @property def pos_y(self): """Y coordinate with multiplicator.""" return self._pos_y * self._plan_multiplicator def image_pos_x(self, offset, img_center): """X coordinate on an image.""" return self.pos_x - offset + img_center def image_pos_y(self, offset, img_center): """Y coordinate on an image.""" return self.pos_y - offset + img_center def __repr__(self) -> str: return "".format( self.pos_x, self.pos_y, self.phi, self.update ) def __eq__(self, value) -> bool: return ( self.pos_x == value.pos_x and self.pos_y == value.pos_y and self.phi == value.phi ) class ViomiConsumableStatus(ConsumableStatus): """Consumable container for viomi vacuums. Note that this exposes `mop` and `mop_left` that are not available in the base class, while returning zeroed timedeltas for `sensor_dirty` and `sensor_dirty_left` which it doesn't report. """ def __init__(self, data: List[int]) -> None: # [17, 17, 17, 17] self.data = { "main_brush_work_time": data[0] * 60 * 60, "side_brush_work_time": data[1] * 60 * 60, "filter_work_time": data[2] * 60 * 60, "mop_dirty_time": data[3] * 60 * 60, } self.side_brush_total = timedelta(hours=180) self.main_brush_total = timedelta(hours=360) self.filter_total = timedelta(hours=180) self.mop_total = timedelta(hours=180) self.sensor_dirty_total = timedelta(seconds=0) @property def mop(self) -> timedelta: """Return ``sensor_dirty_time``""" return pretty_seconds(self.data["mop_dirty_time"]) @property def mop_left(self) -> timedelta: """How long until the mop should be changed.""" return self.mop_total - self.mop @property def sensor_dirty(self) -> timedelta: """Viomi has no sensor dirty, so we return zero here.""" return timedelta(seconds=0) @property def sensor_dirty_left(self) -> timedelta: """Viomi has no sensor dirty, so we return zero here.""" return self.sensor_dirty_total - self.sensor_dirty class ViomiVacuumSpeed(Enum): Silent = 0 Standard = 1 Medium = 2 Turbo = 3 class ViomiVacuumState(Enum): Unknown = -1 IdleNotDocked = 0 Idle = 1 Idle2 = 2 Cleaning = 3 Returning = 4 Docked = 5 VacuumingAndMopping = 6 class ViomiMode(Enum): Vacuum = 0 # No Mop, Vacuum only VacuumAndMop = 1 Mop = 2 CleanZone = 3 CleanSpot = 4 class ViomiLanguage(Enum): CN = 1 # Chinese (default) EN = 2 # English class ViomiLedState(Enum): Off = 0 On = 1 class ViomiCarpetTurbo(Enum): Off = 0 Medium = 1 Turbo = 2 class ViomiMovementDirection(Enum): Forward = 1 Left = 2 # Rotate Right = 3 # Rotate Backward = 4 Stop = 5 Unknown = 10 class ViomiBinType(Enum): Vacuum = 1 Water = 2 VacuumAndWater = 3 NoBin = 0 class ViomiWaterGrade(Enum): Low = 11 Medium = 12 High = 13 class ViomiRoutePattern(Enum): """Mopping pattern.""" S = 0 Y = 1 class ViomiEdgeState(Enum): Off = 0 Unknown = 1 On = 2 # NOTE: When I got 5, the device was super slow # Shutdown and restart device fixed the issue Unknown2 = 5 class ViomiVacuumStatus(DeviceStatus): def __init__(self, data): # ["run_state","mode","err_state","battary_life","box_type","mop_type","s_time","s_area", # "suction_grade","water_grade","remember_map","has_map","is_mop","has_newmap"]' # 1, 11, 1, 1, 1, 0 ] self.data = data @property def state(self): """State of the vacuum.""" try: return ViomiVacuumState(self.data["run_state"]) except ValueError: _LOGGER.warning("Unknown vacuum state: %s", self.data["run_state"]) return ViomiVacuumState.Unknown @property def edge_state(self) -> ViomiEdgeState: """Vaccum along the edges. The settings is valid once 0: Off 1: Unknown 2: On 5: Unknown """ return ViomiEdgeState(self.data["mode"]) @property def mop_installed(self) -> bool: """True if the mop is installed.""" return bool(self.data["mop_type"]) @property def error_code(self) -> int: """Error code from vacuum.""" return self.data["err_state"] @property def error(self) -> Optional[str]: """String presentation for the error code.""" if self.error_code is None: return None return ERROR_CODES.get(self.error_code, f"Unknown error {self.error_code}") @property def battery(self) -> int: """Battery in percentage.""" return self.data["battary_life"] @property def bin_type(self) -> ViomiBinType: """Type of the inserted bin.""" return ViomiBinType(self.data["box_type"]) @property def clean_time(self) -> timedelta: """Cleaning time.""" return pretty_seconds(self.data["s_time"] * 60) @property def clean_area(self) -> float: """Cleaned area in square meters.""" return self.data["s_area"] @property def fanspeed(self) -> ViomiVacuumSpeed: """Current fan speed.""" return ViomiVacuumSpeed(self.data["suction_grade"]) @property def water_grade(self) -> ViomiWaterGrade: """Water grade.""" return ViomiWaterGrade(self.data["water_grade"]) @property def remember_map(self) -> bool: """True to remember the map.""" return bool(self.data["remember_map"]) @property def has_map(self) -> bool: """True if device has map?""" return bool(self.data["has_map"]) @property def has_new_map(self) -> bool: """True if the device has scanned a new map (like a new floor).""" return bool(self.data["has_newmap"]) @property def mop_mode(self) -> ViomiMode: """Whether mopping is enabled and if so which mode.""" return ViomiMode(self.data["is_mop"]) @property def current_map_id(self) -> float: """Current map id.""" return self.data["cur_mapid"] @property def hw_info(self) -> str: """Hardware info.""" return self.data["hw_info"] @property def charging(self) -> bool: """True if battery is charging. Note: When the battery is at 100%, device reports that it is not charging. """ return not bool(self.data["is_charge"]) @property def is_on(self) -> bool: """True if device is working.""" return not bool(self.data["is_work"]) @property def light_state(self) -> bool: """Led state. This seems doing nothing on STYJ02YM """ return bool(self.data["light_state"]) @property def map_number(self) -> int: """Number of saved maps.""" return self.data["map_num"] @property def mop_route(self) -> ViomiRoutePattern: """Pattern mode.""" return ViomiRoutePattern(self.data["mop_route"]) # @property # def order_time(self) -> int: # """FIXME: ??? int or bool.""" # return self.data["order_time"] @property def repeat_cleaning(self) -> bool: """Secondary clean up state. True if the cleaning is performed twice """ return self.data["repeat_state"] # @property # def start_time(self) -> int: # """FIXME: ??? int or bool.""" # return self.data["start_time"] @property def sound_volume(self) -> int: """Voice volume level (from 0 to 100%, 0 means Off).""" return self.data["v_state"] # @property # def water_percent(self) -> int: # """FIXME: ??? int or bool.""" # return self.data["water_percent"] # @property # def zone_data(self) -> int: # """FIXME: ??? int or bool.""" # return self.data["zone_data"] def _get_rooms_from_schedules(schedules: List[str]) -> Tuple[bool, Dict]: """Read the result of "get_ordertime" command to extract room names and ids. The `schedules` input needs to follow the following format * ['1_0_32_0_0_0_1_1_11_0_1594139992_2_11_room1_13_room2', ...] * [Id_Enabled_Repeatdays_Hour_Minute_?_? _?_?_?_?_NbOfRooms_RoomId_RoomName_RoomId_RoomName_..., ...] The function parse get_ordertime output to find room names and ids To use this function you need: 1. to create a scheduled cleanup with the following properties: * Hour: 00 * Minute: 00 * Select all (minus one) the rooms one by one * Set as inactive scheduled cleanup 2. then to create an other scheduled cleanup with the room missed at previous step with the following properties: * Hour: 00 * Minute: 00 * Select only the missed room * Set as inactive scheduled cleanup More information: * https://github.com/homebridge-xiaomi-roborock-vacuum/homebridge-xiaomi-roborock-vacuum/blob/d73925c0106984a995d290e91a5ba4fcfe0b6444/index.js#L969 * https://github.com/homebridge-xiaomi-roborock-vacuum/homebridge-xiaomi-roborock-vacuum#semi-automatic """ rooms = {} scheduled_found = False for raw_schedule in schedules: schedule = raw_schedule.split("_") # Scheduled cleanup needs to be scheduled for 00:00 and inactive if schedule[1] == "0" and schedule[3] == "0" and schedule[4] == "0": scheduled_found = True raw_rooms = schedule[12:] rooms_iter = iter(raw_rooms) rooms.update( dict(itertools.zip_longest(rooms_iter, rooms_iter, fillvalue=None)) ) return scheduled_found, rooms class ViomiVacuum(Device, VacuumInterface): """Interface for Viomi vacuums (viomi.vacuum.v7).""" _supported_models = SUPPORTED_MODELS timeout = 5 retry_count = 10 def __init__( self, ip: str, token: str = None, start_id: int = 0, debug: int = 0, *, model: str = None, ) -> None: super().__init__(ip, token, start_id, debug, model=model) self.manual_seqnum = -1 self._cache: Dict[str, Any] = {"edge_state": None, "rooms": {}, "maps": {}} @command( default_output=format_output( "\n", "General\n" "=======\n\n" "Hardware version: {result.hw_info}\n" "State: {result.state}\n" "Working: {result.is_on}\n" "Battery status: {result.error}\n" "Battery: {result.battery}\n" "Charging: {result.charging}\n" "Box type: {result.bin_type}\n" "Fan speed: {result.fanspeed}\n" "Water grade: {result.water_grade}\n" "Mop mode: {result.mop_mode}\n" "Mop installed: {result.mop_installed}\n" "Vacuum along the edges: {result.edge_state}\n" "Mop route pattern: {result.mop_route}\n" "Secondary Cleanup: {result.repeat_cleaning}\n" "Sound Volume: {result.sound_volume}\n" "Clean time: {result.clean_time}\n" "Clean area: {result.clean_area} m²\n" "\n" "Map\n" "===\n\n" "Current map ID: {result.current_map_id}\n" "Remember map: {result.remember_map}\n" "Has map: {result.has_map}\n" "Has new map: {result.has_new_map}\n" "Number of maps: {result.map_number}\n" "\n" "Unknown properties\n" "=================\n\n" "Light state: {result.light_state}\n" # "Order time: {result.order_time}\n" # "Start time: {result.start_time}\n" # "water_percent: {result.water_percent}\n" # "zone_data: {result.zone_data}\n", ) ) def status(self) -> ViomiVacuumStatus: """Retrieve properties.""" properties = [ "battary_life", "box_type", "cur_mapid", "err_state", "has_map", "has_newmap", "hw_info", "is_charge", "is_mop", "is_work", "light_state", "map_num", "mode", "mop_route", "mop_type", "remember_map", "repeat_state", "run_state", "s_area", "s_time", "suction_grade", "v_state", "water_grade", # The following list of properties existing but # there are not used in the code # "order_time", # "start_time", # "water_percent", # "zone_data", # "sw_info", # "main_brush_hours", # "main_brush_life", # "side_brush_hours", # "side_brush_life", # "mop_hours", # "mop_life", # "hypa_hours", # "hypa_life", ] values = self.get_properties(properties) return ViomiVacuumStatus(defaultdict(lambda: None, zip(properties, values))) @command() def home(self): """Return to home.""" self.send("set_charge", [1]) @command() def start(self): """Start cleaning.""" # params: [edge, 1, roomIds.length, *list_of_room_ids] # - edge: see ViomiEdgeState # - 1: start cleaning (2 pause, 0 stop) # - roomIds.length # - *room_id_list # 3rd param of set_mode_withroom is room_array_len and next are # room ids ([0, 1, 3, 11, 12, 13] = start cleaning rooms 11-13). # room ids are encoded in map and it's part of cloud api so best way # to get it is log between device <> mi home app # (before map format is supported). self._cache["edge_state"] = self.get_properties(["mode"]) self.send("set_mode_withroom", self._cache["edge_state"] + [1, 0]) @command( click.option( "--rooms", "-r", multiple=True, help="Rooms name or room id. Can be used multiple times", ) ) def start_with_room(self, rooms): """Start cleaning specific rooms.""" if not self._cache["rooms"]: self.get_rooms() reverse_rooms = {v: k for k, v in self._cache["rooms"].items()} room_ids = [] for room in rooms: if room in self._cache["rooms"]: room_ids.append(int(room)) elif room in reverse_rooms: room_ids.append(int(reverse_rooms[room])) else: room_keys = ", ".join(self._cache["rooms"].keys()) room_ids = ", ".join(self._cache["rooms"].values()) raise DeviceException( f"Room {room} is unknown, it must be in {room_keys} or {room_ids}" ) self._cache["edge_state"] = self.get_properties(["mode"]) self.send( "set_mode_withroom", self._cache["edge_state"] + [1, len(room_ids)] + room_ids, ) @command() def pause(self): """Pause cleaning.""" # params: [edge_state, 0] # - edge: see ViomiEdgeState # - 2: pause cleaning if not self._cache["edge_state"]: self._cache["edge_state"] = self.get_properties(["mode"]) self.send("set_mode", self._cache["edge_state"] + [2]) @command() def stop(self): """Validate that Stop cleaning.""" # params: [edge_state, 0] # - edge: see ViomiEdgeState # - 0: stop cleaning if not self._cache["edge_state"]: self._cache["edge_state"] = self.get_properties(["mode"]) self.send("set_mode", self._cache["edge_state"] + [0]) @command(click.argument("mode", type=EnumType(ViomiMode))) def clean_mode(self, mode: ViomiMode): """Set the cleaning mode. [vacuum, vacuumAndMop, mop, cleanzone, cleanspot] """ self.send("set_mop", [mode.value]) @command(click.argument("speed", type=EnumType(ViomiVacuumSpeed))) def set_fan_speed(self, speed: ViomiVacuumSpeed): """Set fanspeed [silent, standard, medium, turbo].""" self.send("set_suction", [speed.value]) @command() def fan_speed_presets(self) -> FanspeedPresets: """Return available fan speed presets.""" return {x.name: x.value for x in list(ViomiVacuumSpeed)} @command(click.argument("speed", type=int)) def set_fan_speed_preset(self, speed_preset: int) -> None: """Set fan speed preset speed.""" if speed_preset not in self.fan_speed_presets().values(): raise ValueError( f"Invalid preset speed {speed_preset}, not in: {self.fan_speed_presets().values()}" ) self.send("set_suction", [speed_preset]) @command(click.argument("watergrade", type=EnumType(ViomiWaterGrade))) def set_water_grade(self, watergrade: ViomiWaterGrade): """Set water grade. [low, medium, high] """ self.send("set_suction", [watergrade.value]) def get_positions(self, plan_multiplicator=1) -> List[ViomiPositionPoint]: """Return the last positions. plan_multiplicator scale up the coordinates values """ results = self.send("get_curpos", []) positions = [] # Group result 4 by 4 for res in [i for i in zip(*(results[i::4] for i in range(4)))]: # ignore type require for mypy error # "ViomiPositionPoint" gets multiple values for keyword argument "plan_multiplicator" positions.append( ViomiPositionPoint(*res, plan_multiplicator=plan_multiplicator) # type: ignore ) return positions @command() def get_current_position(self) -> Optional[ViomiPositionPoint]: """Return the current position.""" positions = self.get_positions() if positions: return positions[-1] return None # MISSING cleaning history @command() def get_scheduled_cleanup(self): """Not implemented yet.""" # Needs to reads and understand the return of: # self.send("get_ordertime", []) # [id, enabled, repeatdays, hour, minute, ?, ? , ?, ?, ?, ?, nb_of_rooms, room_id, room_name, room_id, room_name, ...] raise NotImplementedError() @command() def add_timer(self): """Not implemented yet.""" # Needs to reads and understand: # self.send("set_ordertime", [????]) raise NotImplementedError() @command() def delete_timer(self): """Not implemented yet.""" # Needs to reads and understand: # self.send("det_ordertime", [shedule_id]) raise NotImplementedError() @command(click.argument("state", type=EnumType(ViomiEdgeState))) def set_edge(self, state: ViomiEdgeState): """Vacuum along edges. This is valid for a single cleaning. """ return self.send("set_mode", [state.value]) @command(click.argument("state", type=bool)) def set_repeat(self, state: bool): """Set or Unset repeat mode (Secondary cleanup).""" return self.send("set_repeat", [int(state)]) @command(click.argument("mop_mode", type=EnumType(ViomiRoutePattern))) def set_route_pattern(self, mop_mode: ViomiRoutePattern): """Set the mop route pattern.""" self.send("set_moproute", [mop_mode.value]) @command() def dnd_status(self): """Returns do-not-disturb status.""" status = self.send("get_notdisturb") return DNDStatus( dict( enabled=status[0], start_hour=status[1], start_minute=status[2], end_hour=status[3], end_minute=status[4], ) ) @command( click.option("--disable", is_flag=True), click.argument("start_hr", type=int), click.argument("start_min", type=int), click.argument("end_hr", type=int), click.argument("end_min", type=int), ) def set_dnd( self, disable: bool, start_hr: int, start_min: int, end_hr: int, end_min: int ): """Set do-not-disturb. :param int start_hr: Start hour :param int start_min: Start minute :param int end_hr: End hour :param int end_min: End minute """ return self.send( "set_notdisturb", [0 if disable else 1, start_hr, start_min, end_hr, end_min], ) @command(click.argument("volume", type=click.IntRange(0, 10))) def set_sound_volume(self, volume: int): """Switch the voice on or off.""" enabled = 1 if volume == 0: enabled = 0 return self.send("set_voice", [enabled, volume]) @command(click.argument("state", type=bool)) def set_remember_map(self, state: bool): """Set remember map state.""" return self.send("set_remember", [int(state)]) # MISSING: Virtual wall/restricted area @command() def get_maps(self) -> List[Dict[str, Any]]: """Return map list. [{'name': 'MapName1', 'id': 1598622255, 'cur': False}, {'name': 'MapName2', 'id': 1599508355, 'cur': True}, ...] """ if not self._cache["maps"]: self._cache["maps"] = self.send("get_map") return self._cache["maps"] @command(click.argument("map_id", type=int)) def set_map(self, map_id: int): """Change current map.""" maps = self.get_maps() if map_id not in [m["id"] for m in maps]: raise ViomiVacuumException(f"Map id {map_id} doesn't exists") return self.send("set_map", [map_id]) @command(click.argument("map_id", type=int)) def delete_map(self, map_id: int): """Delete map.""" maps = self.get_maps() if map_id not in [m["id"] for m in maps]: raise ViomiVacuumException(f"Map id {map_id} doesn't exists") return self.send("del_map", [map_id]) @command( click.argument("map_id", type=int), click.argument("map_name", type=str), ) def rename_map(self, map_id: int, map_name: str): """Rename map.""" maps = self.get_maps() if map_id not in [m["id"] for m in maps]: raise ViomiVacuumException(f"Map id {map_id} doesn't exists") return self.send("rename_map", {"mapID": map_id, "name": map_name}) @command( click.option("--map-id", type=int, default=None), click.option("--map-name", type=str, default=None), click.option("--refresh", type=bool, default=False), ) def get_rooms( self, map_id: int = None, map_name: str = None, refresh: bool = False ): """Return room ids and names.""" if self._cache["rooms"] and not refresh: return self._cache["rooms"] # TODO: map_name and map_id are just dead code here? if map_name: maps = self.get_maps() map_ids = [map_["id"] for map_ in maps if map_["name"] == map_name] if not map_ids: map_names = ", ".join([m["name"] for m in maps]) raise ViomiVacuumException( f"Error: Bad map name, should be in {map_names}" ) elif map_id: maps = self.get_maps() if map_id not in [m["id"] for m in maps]: map_ids_str = ", ".join([str(m["id"]) for m in maps]) raise ViomiVacuumException( f"Error: Bad map id, should be in {map_ids_str}" ) # Get scheduled cleanup schedules = self.send("get_ordertime", []) scheduled_found, rooms = _get_rooms_from_schedules(schedules) if not scheduled_found: msg = ( "Fake schedule not found. " "Please create a scheduled cleanup with the " "following properties:\n" "* Hour: 00\n" "* Minute: 00\n" "* Select all (minus one) the rooms one by one\n" "* Set as inactive scheduled cleanup\n" "Then create a scheduled cleanup with the room missed at " "previous step with the following properties:\n" "* Hour: 00\n" "* Minute: 00\n" "* Select only the missed room\n" "* Set as inactive scheduled cleanup\n" ) raise ViomiVacuumException(msg) self._cache["rooms"] = rooms return rooms # MISSING Area editor # MISSING Reset map # MISSING Device leveling # MISSING Looking for the vacuum-mop @command() def consumable_status(self) -> ViomiConsumableStatus: """Return information about consumables.""" return ViomiConsumableStatus(self.send("get_consumables")) @command( click.argument("direction", type=EnumType(ViomiMovementDirection)), click.option( "--duration", type=float, default=0.5, help="number of seconds to perform this movement", ), ) def move(self, direction: ViomiMovementDirection, duration=0.5): """Manual movement.""" start = time.time() while time.time() - start < duration: self.send("set_direction", [direction.value]) time.sleep(0.1) self.send("set_direction", [ViomiMovementDirection.Stop.value]) @command(click.argument("language", type=EnumType(ViomiLanguage))) def set_language(self, language: ViomiLanguage): """Set the device's audio language. This seems doing nothing on STYJ02YM """ return self.send("set_language", [language.value]) @command(click.argument("state", type=EnumType(ViomiLedState))) def led(self, state: ViomiLedState): """Switch the button leds on or off. This seems doing nothing on STYJ02YM """ return self.send("set_light", [state.value]) @command(click.argument("mode", type=EnumType(ViomiCarpetTurbo))) def carpet_mode(self, mode: ViomiCarpetTurbo): """Set the carpet mode. This seems doing nothing on STYJ02YM """ return self.send("set_carpetturbo", [mode.value]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/interfaces/__init__.py0000644000000000000000000000020714265350055016176 0ustar00"""Interfaces API.""" from .vacuuminterface import FanspeedPresets, VacuumInterface __all__ = ["FanspeedPresets", "VacuumInterface"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/interfaces/vacuuminterface.py0000644000000000000000000000234014265350055017620 0ustar00"""`VacuumInterface` is an interface (abstract class) with shared API for all vacuum devices.""" from abc import abstractmethod from typing import Dict # Dictionary of predefined fan speeds FanspeedPresets = Dict[str, int] class VacuumInterface: """Vacuum API interface.""" @abstractmethod def home(self): """Return vacuum robot to home station/dock.""" @abstractmethod def start(self): """Start cleaning.""" @abstractmethod def stop(self): """Stop cleaning.""" def pause(self): """Pause cleaning. :raises RuntimeError: if the method is not supported by the device """ raise RuntimeError("`pause` not supported") @abstractmethod def fan_speed_presets(self) -> FanspeedPresets: """Return available fan speed presets. The returned object is a dictionary where the key is user-readable name and the value is input for :func:`set_fan_speed_preset()`. """ @abstractmethod def set_fan_speed_preset(self, speed_preset: int) -> None: """Set fan speed preset speed. :param speed_preset: a value from :func:`fan_speed_presets()` :raises ValueError: for invalid preset value """ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/miioprotocol.py0000644000000000000000000002306714265350055015044 0ustar00"""miIO protocol implementation. This module contains the implementation of routines to send handshakes, send commands and discover devices (MiIOProtocol). """ import binascii import codecs import logging import socket from datetime import datetime, timedelta from typing import Any, Dict, List import construct from .exceptions import DeviceError, DeviceException, RecoverableError from .protocol import Message _LOGGER = logging.getLogger(__name__) class MiIOProtocol: def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, timeout: int = 5, ) -> None: """Create a :class:`Device` instance. :param ip: IP address or a hostname for the device :param token: Token used for encryption :param start_id: Running message id sent to the device :param debug: Wanted debug level """ self.ip = ip self.port = 54321 if token is None: token = 32 * "0" self.token = bytes.fromhex(token) self.debug = debug self.lazy_discover = lazy_discover self._timeout = timeout self.__id = start_id self._discovered = False # these come from the device, but we initialize them here to make mypy happy self._device_ts: datetime = datetime.utcnow() self._device_id = bytes() def send_handshake(self, *, retry_count=3) -> Message: """Send a handshake to the device. This returns some information, such as device type and serial, as well as device's timestamp in response. The handshake must also be done regularly to enable communication with the device. :raises DeviceException: if the device could not be discovered after retries. """ try: m = MiIOProtocol.discover(self.ip) except DeviceException as ex: if retry_count > 0: return self.send_handshake(retry_count=retry_count - 1) raise ex if m is None: _LOGGER.debug("Unable to discover a device at address %s", self.ip) raise DeviceException("Unable to discover the device %s" % self.ip) header = m.header.value self._device_id = header.device_id self._device_ts = header.ts self._discovered = True if self.debug > 1: _LOGGER.debug(m) _LOGGER.debug( "Discovered %s with ts: %s, token: %s", binascii.hexlify(self._device_id).decode(), self._device_ts, codecs.encode(m.checksum, "hex"), ) return m @staticmethod def discover(addr: str = None, timeout: int = 5) -> Any: """Scan for devices in the network. This method is used to discover supported devices by sending a handshake message to the broadcast address on port 54321. If the target IP address is given, the handshake will be send as an unicast packet. :param str addr: Target IP address """ is_broadcast = addr is None seen_addrs = [] # type: List[str] if is_broadcast: addr = "" is_broadcast = True _LOGGER.info("Sending discovery to %s with timeout of %ss..", addr, timeout) # magic, length 32 helobytes = bytes.fromhex( "21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" ) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.settimeout(timeout) for _ in range(3): s.sendto(helobytes, (addr, 54321)) while True: try: data, recv_addr = s.recvfrom(1024) m = Message.parse(data) # type: Message _LOGGER.debug("Got a response: %s", m) if not is_broadcast: return m if recv_addr[0] not in seen_addrs: _LOGGER.info( " IP %s (ID: %s) - token: %s", recv_addr[0], binascii.hexlify(m.header.value.device_id).decode(), codecs.encode(m.checksum, "hex"), ) seen_addrs.append(recv_addr[0]) except socket.timeout: if is_broadcast: _LOGGER.info("Discovery done") return # ignore timeouts on discover except Exception as ex: _LOGGER.warning("error while reading discover results: %s", ex) break def send( self, command: str, parameters: Any = None, retry_count: int = 3, *, extra_parameters: Dict = None ) -> Any: """Build and send the given command. Note that this will implicitly call :func:`send_handshake` to do a handshake, and will re-try in case of errors while incrementing the `_id` by 100. :param str command: Command to send :param dict parameters: Parameters to send, or an empty list :param retry_count: How many times to retry in case of failure, how many handshakes to send :param dict extra_parameters: Extra top-level parameters :raises DeviceException: if an error has occurred during communication. """ if not self.lazy_discover or not self._discovered: self.send_handshake() request = self._create_request(command, parameters, extra_parameters) send_ts = self._device_ts + timedelta(seconds=1) header = { "length": 0, "unknown": 0x00000000, "device_id": self._device_id, "ts": send_ts, } msg = {"data": {"value": request}, "header": {"value": header}, "checksum": 0} m = Message.build(msg, token=self.token) _LOGGER.debug("%s:%s >>: %s", self.ip, self.port, request) if self.debug > 1: _LOGGER.debug( "send (timeout %s): %s", self._timeout, Message.parse(m, token=self.token), ) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(self._timeout) try: s.sendto(m, (self.ip, self.port)) except OSError as ex: _LOGGER.error("failed to send msg: %s", ex) raise DeviceException from ex try: data, addr = s.recvfrom(4096) m = Message.parse(data, token=self.token) if self.debug > 1: _LOGGER.debug("recv from %s: %s", addr[0], m) header = m.header.value payload = m.data.value self.__id = payload["id"] self._device_ts = header["ts"] # type: ignore # ts uses timeadapter _LOGGER.debug( "%s:%s (ts: %s, id: %s) << %s", self.ip, self.port, header["ts"], payload["id"], payload, ) if "error" in payload: self._handle_error(payload["error"]) try: return payload["result"] except KeyError: return payload except construct.core.ChecksumError as ex: raise DeviceException( "Got checksum error which indicates use " "of an invalid token. " "Please check your token!" ) from ex except OSError as ex: if retry_count > 0: _LOGGER.debug( "Retrying with incremented id, retries left: %s", retry_count ) self.__id += 100 self._discovered = False return self.send( command, parameters, retry_count - 1, extra_parameters=extra_parameters, ) _LOGGER.error("Got error when receiving: %s", ex) raise DeviceException("No response from the device") from ex except RecoverableError as ex: if retry_count > 0: _LOGGER.debug( "Retrying to send failed command, retries left: %s", retry_count ) return self.send( command, parameters, retry_count - 1, extra_parameters=extra_parameters, ) _LOGGER.error("Got error when receiving: %s", ex) raise DeviceException("Unable to recover failed command") from ex @property def _id(self) -> int: """Increment and return the sequence id.""" self.__id += 1 if self.__id >= 9999: self.__id = 1 return self.__id @property def raw_id(self): return self.__id def _handle_error(self, error): """Raise exception based on the given error code.""" RECOVERABLE_ERRORS = [-30001, -9999] if "code" in error and error["code"] in RECOVERABLE_ERRORS: raise RecoverableError(error) raise DeviceError(error) def _create_request( self, command: str, parameters: Any, extra_parameters: Dict = None ): """Create request payload.""" request = {"id": self._id, "method": command} if parameters is not None: request["params"] = parameters else: request["params"] = [] if extra_parameters is not None: request = {**request, **extra_parameters} return request ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/miot_device.py0000644000000000000000000001314214265350055014605 0ustar00import logging from enum import Enum from functools import partial from typing import Any, Dict, Union import click from .click_common import EnumType, LiteralParamType, command from .device import Device, DeviceStatus # noqa: F401 from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) # partial is required here for str2bool, see https://stackoverflow.com/a/40339397 class MiotValueType(Enum): def _str2bool(x): """Helper to convert string to boolean.""" return x.lower() in ("true", "1") Int = int Float = float Bool = partial(_str2bool) Str = str MiotMapping = Dict[str, Dict[str, Any]] class MiotDevice(Device): """Main class representing a MIoT device. The inheriting class should use the `_mappings` to set the `MiotMapping` keyed by the model names to inform which mapping is to be used for methods contained in this class. Defining the mappiong using `mapping` class variable is deprecated but remains in-place for backwards compatibility. """ mapping: MiotMapping # Deprecated, use _mappings instead _mappings: Dict[str, MiotMapping] = {} def __init__( self, ip: str = None, token: str = None, start_id: int = 0, debug: int = 0, lazy_discover: bool = True, timeout: int = None, *, model: str = None, mapping: MiotMapping = None, ): """Overloaded to accept keyword-only `mapping` parameter.""" super().__init__( ip, token, start_id, debug, lazy_discover, timeout, model=model ) if mapping is None and not hasattr(self, "mapping") and not self._mappings: _LOGGER.warning("Neither the class nor the parameter defines the mapping") if mapping is not None: self.mapping = mapping def get_properties_for_mapping(self, *, max_properties=15) -> list: """Retrieve raw properties based on mapping.""" # We send property key in "did" because it's sent back via response and we can identify the property. mapping = self._get_mapping() properties = [{"did": k, **v} for k, v in mapping.items() if "aiid" not in v] return self.get_properties( properties, property_getter="get_properties", max_properties=max_properties ) @command( click.argument("name", type=str), click.argument("params", type=LiteralParamType(), required=False), ) def call_action(self, name: str, params=None): """Call an action by a name in the mapping.""" mapping = self._get_mapping() if name not in mapping: raise DeviceException(f"Unable to find {name} in the mapping") action = mapping[name] if "siid" not in action or "aiid" not in action: raise DeviceException(f"{name} is not an action (missing siid or aiid)") return self.call_action_by(action["siid"], action["aiid"], params) @command( click.argument("siid", type=int), click.argument("aiid", type=int), click.argument("params", type=LiteralParamType(), required=False), ) def call_action_by(self, siid, aiid, params=None): """Call an action.""" if params is None: params = [] payload = { "did": f"call-{siid}-{aiid}", "siid": siid, "aiid": aiid, "in": params, } return self.send("action", payload) @command( click.argument("siid", type=int), click.argument("piid", type=int), ) def get_property_by(self, siid: int, piid: int): """Get a single property (siid/piid).""" return self.send( "get_properties", [{"did": f"{siid}-{piid}", "siid": siid, "piid": piid}] ) @command( click.argument("siid", type=int), click.argument("piid", type=int), click.argument("value"), click.argument( "value_type", type=EnumType(MiotValueType), required=False, default=None ), ) def set_property_by( self, siid: int, piid: int, value: Union[int, float, str, bool], value_type: Any = None, ): """Set a single property (siid/piid) to given value. value_type can be given to convert the value to wanted type, allowed types are: int, float, bool, str """ if value_type is not None: value = value_type.value(value) return self.send( "set_properties", [{"did": f"set-{siid}-{piid}", "siid": siid, "piid": piid, "value": value}], ) def set_property(self, property_key: str, value): """Sets property value using the existing mapping.""" mapping = self._get_mapping() return self.send( "set_properties", [{"did": property_key, **mapping[property_key], "value": value}], ) def _get_mapping(self) -> MiotMapping: """Return the protocol mapping to use. The logic is as follows: 1. Use device model as key to lookup _mappings for the mapping 2. If no match is found, but _mappings is defined, use the first item 3. Fallback to class-defined `mapping` for backwards compat """ if not self._mappings: return self.mapping mapping = self._mappings.get(self.model) if mapping is not None: return mapping first_model, first_mapping = list(self._mappings.items())[0] _LOGGER.warning( "Unable to find mapping for %s, falling back to %s", self.model, first_model ) return first_mapping ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/powerstrip.py0000644000000000000000000001607014265350055014537 0ustar00import enum import logging from collections import defaultdict from typing import Any, Dict, Optional import click from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException from .utils import deprecated _LOGGER = logging.getLogger(__name__) MODEL_POWER_STRIP_V1 = "qmi.powerstrip.v1" MODEL_POWER_STRIP_V2 = "zimi.powerstrip.v2" AVAILABLE_PROPERTIES = { MODEL_POWER_STRIP_V1: [ "power", "temperature", "current", "mode", "power_consume_rate", "voltage", "power_factor", "elec_leakage", ], MODEL_POWER_STRIP_V2: [ "power", "temperature", "current", "mode", "power_consume_rate", "wifi_led", "power_price", ], } class PowerStripException(DeviceException): pass class PowerMode(enum.Enum): Eco = "green" Normal = "normal" class PowerStripStatus(DeviceStatus): """Container for status reports from the power strip.""" def __init__(self, data: Dict[str, Any]) -> None: """Supported device models: qmi.powerstrip.v1, zimi.powerstrip.v2. Response of a Power Strip 2 (zimi.powerstrip.v2): {'power','on', 'temperature': 48.7, 'current': 0.05, 'mode': None, 'power_consume_rate': 4.09, 'wifi_led': 'on', 'power_price': 49} """ self.data = data @property def power(self) -> str: """Current power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property def temperature(self) -> float: """Current temperature.""" return self.data["temperature"] @property def current(self) -> Optional[float]: """Current, if available. Meaning and voltage reference unknown. """ if self.data["current"] is not None: return self.data["current"] return None @property def load_power(self) -> Optional[float]: """Current power load, if available.""" if self.data["power_consume_rate"] is not None: return self.data["power_consume_rate"] return None @property def mode(self) -> Optional[PowerMode]: """Current operation mode, can be either green or normal.""" if self.data["mode"] is not None: return PowerMode(self.data["mode"]) return None @property # type: ignore @deprecated("Use led instead of wifi_led") def wifi_led(self) -> Optional[bool]: """True if the wifi led is turned on.""" return self.led @property def led(self) -> Optional[bool]: """True if the wifi led is turned on.""" if "wifi_led" in self.data and self.data["wifi_led"] is not None: return self.data["wifi_led"] == "on" return None @property def power_price(self) -> Optional[int]: """The stored power price, if available.""" if "power_price" in self.data and self.data["power_price"] is not None: return self.data["power_price"] return None @property def leakage_current(self) -> Optional[int]: """The leakage current, if available.""" if "elec_leakage" in self.data and self.data["elec_leakage"] is not None: return self.data["elec_leakage"] return None @property def voltage(self) -> Optional[float]: """The voltage, if available.""" if "voltage" in self.data and self.data["voltage"] is not None: return self.data["voltage"] / 100.0 return None @property def power_factor(self) -> Optional[float]: """The power factor, if available.""" if "power_factor" in self.data and self.data["power_factor"] is not None: return self.data["power_factor"] return None class PowerStrip(Device): """Main class representing the smart power strip.""" _supported_models = [MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2] @command( default_output=format_output( "", "Power: {result.power}\n" "Temperature: {result.temperature} °C\n" "Voltage: {result.voltage} V\n" "Current: {result.current} A\n" "Load power: {result.load_power} W\n" "Power factor: {result.power_factor}\n" "Power price: {result.power_price}\n" "Leakage current: {result.leakage_current} A\n" "Mode: {result.mode}\n" "WiFi LED: {result.wifi_led}\n", ) ) def status(self) -> PowerStripStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_POWER_STRIP_V1] ) values = self.get_properties(properties) return PowerStripStatus(defaultdict(lambda: None, zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command( click.argument("mode", type=EnumType(PowerMode)), default_output=format_output("Setting mode to {mode}"), ) def set_power_mode(self, mode: PowerMode): """Set the power mode.""" # green, normal return self.send("set_power_mode", [mode.value]) @deprecated("use set_led instead of set_wifi_led") @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on WiFi LED" if led else "Turning off WiFi LED" ), ) def set_wifi_led(self, led: bool): """Set the wifi led on/off.""" self.set_led(led) @command( click.argument("led", type=bool), default_output=format_output( lambda led: "Turning on LED" if led else "Turning off LED" ), ) def set_led(self, led: bool): """Set the wifi led on/off.""" if led: return self.send("set_wifi_led", ["on"]) else: return self.send("set_wifi_led", ["off"]) @command( click.argument("price", type=int), default_output=format_output("Setting power price to {price}"), ) def set_power_price(self, price: int): """Set the power price.""" if price < 0 or price > 999: raise PowerStripException("Invalid power price: %s" % price) return self.send("set_power_price", [price]) @command( click.argument("power", type=bool), default_output=format_output( lambda led: "Turning on real-time power measurement" if led else "Turning off real-time power measurement" ), ) def set_realtime_power(self, power: bool): """Set the realtime power on/off.""" if power: return self.send("set_rt_power", [1]) else: return self.send("set_rt_power", [0]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/protocol.py0000644000000000000000000001704614265350055014166 0ustar00"""miIO protocol implementation. This module contains the implementation of the routines to encrypt and decrypt miIO payloads with a device-specific token. The payloads to be encrypted (to be passed to a device) are expected to be JSON objects, the same applies for decryption where they are converted automatically to JSON objects. If the decryption fails, raw bytes as returned by the device are returned. An usage example can be seen in the source of :func:`miio.Device.send`. If the decryption fails, raw bytes as returned by the device are returned. """ import calendar import datetime import hashlib import json import logging from typing import Any, Dict, Tuple from construct import ( Adapter, Bytes, Checksum, Const, Default, GreedyBytes, Hex, IfThenElse, Int16ub, Int32ub, Pointer, RawCopy, Rebuild, Struct, ) from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from miio.exceptions import PayloadDecodeException _LOGGER = logging.getLogger(__name__) class Utils: """This class is adapted from the original xpn.py code by gst666.""" @staticmethod def verify_token(token: bytes): """Checks if the given token is of correct type and length.""" if not isinstance(token, bytes): raise TypeError("Token must be bytes") if len(token) != 16: raise ValueError("Wrong token length") @staticmethod def md5(data: bytes) -> bytes: """Calculates a md5 hashsum for the given bytes object.""" checksum = hashlib.md5() # nosec checksum.update(data) return checksum.digest() @staticmethod def key_iv(token: bytes) -> Tuple[bytes, bytes]: """Generate an IV used for encryption based on given token.""" key = Utils.md5(token) iv = Utils.md5(key + token) return key, iv @staticmethod def encrypt(plaintext: bytes, token: bytes) -> bytes: """Encrypt plaintext with a given token. :param bytes plaintext: Plaintext (json) to encrypt :param bytes token: Token to use :return: Encrypted bytes """ if not isinstance(plaintext, bytes): raise TypeError("plaintext requires bytes") Utils.verify_token(token) key, iv = Utils.key_iv(token) padder = padding.PKCS7(128).padder() padded_plaintext = padder.update(plaintext) + padder.finalize() cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) encryptor = cipher.encryptor() return encryptor.update(padded_plaintext) + encryptor.finalize() @staticmethod def decrypt(ciphertext: bytes, token: bytes) -> bytes: """Decrypt ciphertext with a given token. :param bytes ciphertext: Ciphertext to decrypt :param bytes token: Token to use :return: Decrypted bytes object """ if not isinstance(ciphertext, bytes): raise TypeError("ciphertext requires bytes") Utils.verify_token(token) key, iv = Utils.key_iv(token) cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) decryptor = cipher.decryptor() padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() unpadded_plaintext = unpadder.update(padded_plaintext) unpadded_plaintext += unpadder.finalize() return unpadded_plaintext @staticmethod def checksum_field_bytes(ctx: Dict[str, Any]) -> bytearray: """Gather bytes for checksum calculation.""" x = bytearray(ctx["header"].data) x += ctx["_"]["token"] if "data" in ctx: x += ctx["data"].data # print("DATA: %s" % ctx["data"]) return x @staticmethod def get_length(x) -> int: """Return total packet length.""" datalen = x._.data.length # type: int return datalen + 32 @staticmethod def is_hello(x) -> bool: """Return if packet is a hello packet.""" # not very nice, but we know that hellos are 32b of length val = x.get("length", x.header.value["length"]) return val == 32 class TimeAdapter(Adapter): """Adapter for timestamp conversion.""" def _encode(self, obj, context, path): return calendar.timegm(obj.timetuple()) def _decode(self, obj, context, path): return datetime.datetime.utcfromtimestamp(obj) class EncryptionAdapter(Adapter): """Adapter to handle communication encryption.""" def _encode(self, obj, context, path): """Encrypt the given payload with the token stored in the context. :param obj: JSON object to encrypt """ # pp(context) return Utils.encrypt( json.dumps(obj).encode("utf-8") + b"\x00", context["_"]["token"] ) def _decode(self, obj, context, path): """Decrypts the given payload with the token stored in the context. :return str: JSON object """ try: # pp(context) decrypted = Utils.decrypt(obj, context["_"]["token"]) decrypted = decrypted.rstrip(b"\x00") except Exception: if obj: _LOGGER.debug("Unable to decrypt, returning raw bytes: %s", obj) return obj # list of adaption functions for malformed json payload (quirks) decrypted_quirks = [ # try without modifications first lambda decrypted_bytes: decrypted_bytes, # powerstrip returns malformed JSON if the device is not # connected to the cloud, so we try to fix it here carefully. lambda decrypted_bytes: decrypted_bytes.replace( b',,"otu_stat"', b',"otu_stat"' ), # xiaomi cloud returns malformed json when answering _sync.batch_gen_room_up_url # command so try to sanitize it lambda decrypted_bytes: decrypted_bytes[: decrypted_bytes.rfind(b"\x00")] if b"\x00" in decrypted_bytes else decrypted_bytes, # fix double-oh values for 090615.curtain.jldj03, ##1411 lambda decrypted_bytes: decrypted_bytes.replace( b'"value":00', b'"value":0' ), ] for i, quirk in enumerate(decrypted_quirks): try: decoded = quirk(decrypted).decode("utf-8") return json.loads(decoded) except Exception as ex: # log the error when decrypted bytes couldn't be loaded # after trying all quirk adaptions if i == len(decrypted_quirks) - 1: _LOGGER.debug("Unable to parse json '%s': %s", decoded, ex) raise PayloadDecodeException( "Unable to parse message payload" ) from ex return None Message = Struct( # for building we need data before anything else. "data" / Pointer(32, RawCopy(EncryptionAdapter(GreedyBytes))), "header" / RawCopy( Struct( Const(0x2131, Int16ub), "length" / Rebuild(Int16ub, Utils.get_length), "unknown" / Default(Int32ub, 0x00000000), "device_id" / Hex(Bytes(4)), "ts" / TimeAdapter(Default(Int32ub, datetime.datetime.utcnow())), ) ), "checksum" / IfThenElse( Utils.is_hello, Bytes(16), Checksum(Bytes(16), Utils.md5, Utils.checksum_field_bytes), ), ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/push_server/__init__.py0000644000000000000000000000031714265350055016422 0ustar00"""Async UDP push server acting as a fake miio device to handle event notifications from other devices.""" # flake8: noqa from .eventinfo import EventInfo from .server import PushServer, PushServerCallback ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/push_server/eventinfo.py0000644000000000000000000000266114265350055016664 0ustar00from typing import Any, Optional import attr @attr.s(auto_attribs=True) class EventInfo: """Event info to register to the push server. action: user friendly name of the event, can be set arbitrarily and will be received by the server as the name of the event. extra: the identification of this event, this determines on what event the callback is triggered. event: defaults to the action. command_extra: will be received by the push server, hopefully this will allow us to obtain extra information about the event for instance the vibration intesisty or light level that triggered the event (still experimental). trigger_value: Only needed if the trigger has a certain threshold value (like a temperature for a wheather sensor), a "value" key will be present in the first part of a scene packet capture. trigger_token: Only needed for protected events like the alarm feature of a gateway, equal to the "token" of the first part of of a scene packet caputure. source_sid: Normally not needed and obtained from device, only needed for zigbee devices: the "did" key. source_model: Normally not needed and obtained from device, only needed for zigbee devices: the "model" key. """ action: str extra: str event: Optional[str] = None command_extra: str = "" trigger_value: Optional[Any] = None trigger_token: str = "" source_sid: Optional[str] = None source_model: Optional[str] = None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/push_server/server.py0000644000000000000000000002370014265350055016172 0ustar00import asyncio import logging import socket from json import dumps from random import randint from typing import Callable, Optional from ..device import Device from ..protocol import Utils from .eventinfo import EventInfo from .serverprotocol import ServerProtocol _LOGGER = logging.getLogger(__name__) SERVER_PORT = 54321 FAKE_DEVICE_ID = "120009025" FAKE_DEVICE_MODEL = "chuangmi.plug.v3" PushServerCallback = Callable[[str, str, str], None] def calculated_token_enc(token): token_bytes = bytes.fromhex(token) encrypted_token = Utils.encrypt(token_bytes, token_bytes) encrypted_token_hex = encrypted_token.hex() return encrypted_token_hex[0:32] class PushServer: """Async UDP push server acting as a fake miio device to handle event notifications from other devices. Assuming you already have a miio_device class initialized: # First create the push server push_server = PushServer(miio_device.ip) # Then start the server await push_server.start() # Register the miio device to the server and specify a callback function to receive events for this device # The callback function schould have the form of "def callback_func(source_device, action, params):" push_server.register_miio_device(miio_device, callback_func) # create a EventInfo object with the information about the event you which to subscribe to (information taken from packet captures of automations in the mi home app) event_info = EventInfo( action="alarm_triggering", extra="[1,19,1,111,[0,1],2,0]", trigger_token=miio_device.token, ) # Send a message to the miio_device to subscribe for the event to receive messages on the push_server await loop.run_in_executor(None, push_server.subscribe_event, miio_device, event_info) # Now you will see the callback function beeing called whenever the event occurs await asyncio.sleep(30) # When done stop the push_server, this will send messages to all subscribed miio_devices to unsubscribe all events push_server.stop() """ def __init__(self, device_ip): """Initialize the class.""" self._device_ip = device_ip self._address = "0.0.0.0" # nosec self._server_ip = None self._server_id = int(FAKE_DEVICE_ID) self._server_model = FAKE_DEVICE_MODEL self._listen_couroutine = None self._registered_devices = {} self._event_id = 1000000 async def start(self): """Start Miio push server.""" if self._listen_couroutine is not None: _LOGGER.error("Miio push server already started, not starting another one.") return listen_task = self._create_udp_server() _, self._listen_couroutine = await listen_task def stop(self): """Stop Miio push server.""" if self._listen_couroutine is None: return for ip in list(self._registered_devices): self.unregister_miio_device(self._registered_devices[ip]["device"]) self._listen_couroutine.close() self._listen_couroutine = None def register_miio_device(self, device: Device, callback: PushServerCallback): """Register a miio device to this push server.""" if device.ip is None: _LOGGER.error( "Can not register miio device to push server since it has no ip" ) return if device.token is None: _LOGGER.error( "Can not register miio device to push server since it has no token" ) return event_ids = [] if device.ip in self._registered_devices: _LOGGER.error( "A device for ip '%s' was already registed, overwriting previous callback", device.ip, ) event_ids = self._registered_devices[device.ip]["event_ids"] self._registered_devices[device.ip] = { "callback": callback, "token": bytes.fromhex(device.token), "event_ids": event_ids, "device": device, } def unregister_miio_device(self, device: Device): """Unregister a miio device from this push server.""" device_info = self._registered_devices.get(device.ip) if device_info is None: _LOGGER.debug("Device with ip %s not registered, bailing out", device.ip) return for event_id in device_info["event_ids"]: self.unsubscribe_event(device, event_id) self._registered_devices.pop(device.ip) _LOGGER.debug("push server: unregistered miio device with ip %s", device.ip) def subscribe_event(self, device: Device, event_info: EventInfo) -> Optional[str]: """Subscribe to a event such that the device will start pushing data for that event.""" if device.ip not in self._registered_devices: _LOGGER.error("Can not subscribe event, miio device not yet registered") return None if self.server_ip is None: _LOGGER.error("Can not subscribe event withouth starting the push server") return None self._event_id = self._event_id + 1 event_id = f"x.scene.{self._event_id}" event_payload = self._construct_event(event_id, event_info, device) response = device.send( "send_data_frame", { "cur": 0, "data": event_payload, "data_tkn": 29576, "total": 1, "type": "scene", }, ) if response != ["ok"]: _LOGGER.error( "Error subscribing event, response %s, event_payload %s", response, event_payload, ) return None event_ids = self._registered_devices[device.ip]["event_ids"] event_ids.append(event_id) return event_id def unsubscribe_event(self, device: Device, event_id: str): """Unsubscribe from a event by id.""" result = device.send("miIO.xdel", [event_id]) if result == ["ok"]: event_ids = self._registered_devices[device.ip]["event_ids"] if event_id in event_ids: event_ids.remove(event_id) else: _LOGGER.error("Error removing event_id %s: %s", event_id, result) return result def _get_server_ip(self): """Connect to the miio device to get server_ip using a one time use socket.""" get_ip_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) get_ip_socket.bind((self._address, SERVER_PORT)) get_ip_socket.connect((self._device_ip, SERVER_PORT)) server_ip = get_ip_socket.getsockname()[0] get_ip_socket.close() _LOGGER.debug("Miio push server device ip=%s", server_ip) return server_ip def _create_udp_server(self): """Create the UDP socket and protocol.""" self._server_ip = self._get_server_ip() # Create a fresh socket that will be used for the push server udp_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) udp_socket.bind((self._address, SERVER_PORT)) loop = asyncio.get_event_loop() return loop.create_datagram_endpoint( lambda: ServerProtocol(loop, udp_socket, self), sock=udp_socket, ) def _construct_event( # nosec self, event_id: str, info: EventInfo, device: Device, ): """Construct the event data payload needed to subscribe to an event.""" if info.event is None: info.event = info.action if info.source_sid is None: info.source_sid = str(device.device_id) if info.source_model is None: info.source_model = device.model token_enc = calculated_token_enc(device.token) source_id = info.source_sid.replace(".", "_") command = f"{self.server_model}.{info.action}:{source_id}" key = f"event.{info.source_model}.{info.event}" message_id = 0 magic_number = randint( 1590161094, 1642025774 ) # nosec, min/max taken from packet captures, unknown use if len(command) > 49: _LOGGER.error( "push server event command can be max 49 chars long," " '%s' is %i chars, received callback command will be truncated", command, len(command), ) trigger_data = { "did": info.source_sid, "extra": info.extra, "key": key, "model": info.source_model, "src": "device", "timespan": [ "0 0 * * 0,1,2,3,4,5,6", "0 0 * * 0,1,2,3,4,5,6", ], "token": info.trigger_token, } if info.trigger_value is not None: trigger_data["value"] = info.trigger_value target_data = { "command": command, "did": str(self.server_id), "extra": info.command_extra, "id": message_id, "ip": self.server_ip, "model": self.server_model, "token": token_enc, "value": "", } event_data = [ [ event_id, [ "1.0", magic_number, [ "0", trigger_data, ], [target_data], ], ] ] event_payload = dumps(event_data, separators=(",", ":")) return event_payload @property def server_ip(self): """Return the IP of the device running this server.""" return self._server_ip @property def server_id(self): """Return the ID of the fake device beeing emulated.""" return self._server_id @property def server_model(self): """Return the model of the fake device beeing emulated.""" return self._server_model ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/push_server/serverprotocol.py0000644000000000000000000001010414265350055017746 0ustar00import calendar import datetime import logging import struct from ..protocol import Message _LOGGER = logging.getLogger(__name__) HELO_BYTES = bytes.fromhex( "21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" ) class ServerProtocol: """Handle responding to UDP packets.""" def __init__(self, loop, udp_socket, server): """Initialize the class.""" self.transport = None self._loop = loop self._sock = udp_socket self.server = server self._connected = False def _build_ack(self): # Original devices are using year 1970, but it seems current datetime is fine timestamp = calendar.timegm(datetime.datetime.now().timetuple()) # ACK packet not signed, 16 bytes header + 16 bytes of zeroes return struct.pack( ">HHIII16s", 0x2131, 32, 0, self.server.server_id, timestamp, bytes(16) ) def connection_made(self, transport): """Set the transport.""" self.transport = transport self._connected = True _LOGGER.info( "Miio push server started with address=%s server_id=%s", self.server._address, self.server.server_id, ) def connection_lost(self, exc): """Handle connection lost.""" if self._connected: _LOGGER.error("Connection unexpectedly lost in Miio push server: %s", exc) def send_ping_ACK(self, host, port): _LOGGER.debug("%s:%s=>PING", host, port) m = self._build_ack() self.transport.sendto(m, (host, port)) _LOGGER.debug("%s:%s<=ACK(server_id=%s)", host, port, self.server.server_id) def send_msg_OK(self, host, port, msg_id, token): # This result means OK, but some methods return ['ok'] instead of 0 # might be necessary to use different results for different methods result = {"result": 0, "id": msg_id} header = { "length": 0, "unknown": 0, "device_id": self.server.server_id, "ts": datetime.datetime.now(), } msg = { "data": {"value": result}, "header": {"value": header}, "checksum": 0, } response = Message.build(msg, token=token) self.transport.sendto(response, (host, port)) _LOGGER.debug(">> %s:%s: %s", host, port, result) def datagram_received(self, data, addr): """Handle received messages.""" try: (host, port) = addr if data == HELO_BYTES: self.send_ping_ACK(host, port) return if host not in self.server._registered_devices: _LOGGER.warning( "Datagram received from unknown device (%s:%s)", host, port, ) return token = self.server._registered_devices[host]["token"] callback = self.server._registered_devices[host]["callback"] msg = Message.parse(data, token=token) msg_value = msg.data.value msg_id = msg_value["id"] _LOGGER.debug("<< %s:%s: %s", host, port, msg_value) # Parse message action, device_call_id = msg_value["method"].rsplit(":", 1) source_device_id = device_call_id.replace("_", ".") callback(source_device_id, action, msg_value.get("params")) # Send OK self.send_msg_OK(host, port, msg_id, token) except Exception: _LOGGER.exception( "Cannot process Miio push server packet: '%s' from %s:%s", data, host, port, ) def error_received(self, exc): """Log UDP errors.""" _LOGGER.error("UDP error received in Miio push server: %s", exc) def close(self): """Stop the server.""" _LOGGER.debug("Miio push server shutting down") self._connected = False if self.transport: self.transport.close() self._sock.close() _LOGGER.info("Miio push server stopped") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/pwzn_relay.py0000644000000000000000000001003414265350055014505 0ustar00import logging from collections import defaultdict from typing import Any, Dict, Optional import click from .click_common import command, format_output from .device import Device, DeviceStatus _LOGGER = logging.getLogger(__name__) MODEL_PWZN_RELAY_APPLE = "pwzn.relay.apple" MODEL_PWZN_RELAY_BANANA = "pwzn.relay.banana" AVAILABLE_PROPERTIES = { MODEL_PWZN_RELAY_APPLE: [ "relay_status", "on_count", "name0", "name1", "name2", "name3", "name4", "name5", "name6", "name7", "name8", "name9", "name10", "name11", "name12", "name13", "name14", "name15", ], MODEL_PWZN_RELAY_BANANA: [ "relay_status", "on_count", "name0", "name1", "name2", "name3", "name4", "name5", "name6", "name7", "name8", "name9", "name10", "name11", "name12", "name13", "name14", "name15", ], } class PwznRelayStatus(DeviceStatus): """Container for status reports from the plug.""" def __init__(self, data: Dict[str, Any]) -> None: """Response of a PWZN Relay Apple (pwzn.relay.apple) { 'relay_status': 9, 'on_count': 2, 'name0': 'channel1', 'name1': '', 'name2': '', 'name3': '', 'name4': '', 'name5': '', 'name6': '', 'name7': '', 'name8': '', 'name9': '', 'name10': '', 'name11': '', 'name12': '', 'name13': '', 'name14': '', 'name15': '' } """ self.data = data @property def relay_state(self) -> Optional[int]: """Current relay state.""" if "relay_status" in self.data: return self.data["relay_status"] return None @property def relay_names(self) -> Dict[int, str]: def _extract_index_from_key(name) -> int: """extract the index from the variable.""" return int(name[4:]) return { _extract_index_from_key(name): value for name, value in self.data.items() if name.startswith("name") } @property def on_count(self) -> Optional[int]: """Number of on relay.""" if "on_count" in self.data: return self.data["on_count"] return None class PwznRelay(Device): """Main class representing the PWZN Relay.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command(default_output=format_output("", "on_count: {result.on_count}\n")) def status(self) -> PwznRelayStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_PWZN_RELAY_APPLE] ).copy() values = self.get_properties(properties) return PwznRelayStatus(defaultdict(lambda: None, zip(properties, values))) @command( click.argument("number", type=int), default_output=format_output("Turn on relay {number}"), ) def relay_on(self, number: int = 0): """Relay X on.""" if self.send("power_on", [number]) == [0]: return ["ok"] @command( click.argument("number", type=int), default_output=format_output("Turn off relay {number}"), ) def relay_off(self, number: int = 0): """Relay X off.""" if self.send("power_off", [number]) == [0]: return ["ok"] @command(default_output=format_output("Turn on all relay")) def all_relay_on(self): """Relay all on.""" return self.send("power_all", [1]) @command(default_output=format_output("Turn off all relay")) def all_relay_off(self): """Relay all off.""" return self.send("power_all", [0]) @command( click.argument("number", type=int), click.argument("name", type=str), default_output=format_output("Set relay {number} name to {name}"), ) def set_name(self, number: int = 0, name: str = ""): """Set relay X name.""" return self.send("set_name", [number, name]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/py.typed0000644000000000000000000000000014265350055013430 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/scishare_coffeemaker.py0000644000000000000000000000777114265350055016461 0ustar00import logging from enum import IntEnum import click from .click_common import command, format_output from .device import Device _LOGGER = logging.getLogger(__name__) MODEL = "scishare.coffee.s1102" class Status(IntEnum): Unknown = -1 Off = 1 On = 2 SelfCheck = 3 StopPreheat = 4 CoffeeReady = 5 StopDescaling = 6 Standby = 7 Preheating = 8 Brewing = 201 NoWater = 203 class ScishareCoffee(Device): """Main class for Scishare coffee maker (scishare.coffee.s1102).""" _supported_models = ["scishare.coffee.s1102"] @command() def status(self) -> int: """Device status.""" status_code = self.send("Query_Machine_Status")[1] try: return Status(status_code) except ValueError: _LOGGER.warning( "Status code unknown, please report the state of the machine for code %s", status_code, ) return Status.Unknown @command( click.argument("temperature", type=int), default_output=format_output("Setting preheat to {temperature}"), ) def preheat(self, temperature: int): """Pre-heat to given temperature.""" return self.send("Boiler_Preheating_Set", [temperature]) @command(default_output=format_output("Stopping pre-heating")) def stop_preheat(self) -> bool: """Stop pre-heating.""" return self.send("Stop_Boiler_Preheat")[0] == "ok" @command() def cancel_alarm(self) -> bool: """Unknown.""" raise NotImplementedError() return self.send("Cancel_Work_Alarm")[0] == "ok" @command( click.argument("amount", type=int), click.argument("temperature", type=int), default_output=format_output("Boiling {amount} ml water ({temperature}C)"), ) def boil_water(self, amount: int, temperature: int) -> bool: """Boil water. :param amount: in milliliters :param temperature: in degrees """ return self.send("Hot_Wate", [amount, temperature])[0] == "ok" @command( click.argument("amount", type=int), click.argument("temperature", type=int), default_output=format_output("Brewing {amount} ml espresso ({temperature}C)"), ) def brew_espresso(self, amount: int, temperature: int): """Brew espresso. :param amount: in milliliters :param temperature: in degrees """ return self.send("Espresso_Coffee", [amount, temperature])[0] == "ok" @command( click.argument("water_amount", type=int), click.argument("water_temperature", type=int), click.argument("coffee_amount", type=int), click.argument("coffee_temperature", type=int), default_output=format_output( "Brewing americano using {water_amount} ({water_temperature}C) water and {coffee_amount} ml ({coffee_temperature}C) coffee" ), ) def brew_americano( self, water_amount: int, water_temperature: int, coffee_amount: int, coffee_temperature: int, ) -> bool: """Brew americano. :param water_amount: water in milliliters :param water_temperature: water temperature :param coffee_amount: coffee amount in milliliters :param coffee_temperature: coffee temperature """ return ( self.send( "Americano_Coffee", [water_amount, water_temperature, coffee_amount, coffee_temperature], )[0] == "ok" ) @command(default_output=format_output("Powering on")) def on(self) -> bool: """Power on.""" return self.send("Machine_ON")[0] == "ok" @command(default_output=format_output("Powering off")) def off(self) -> bool: """Power off.""" return self.send("Machine_OFF")[0] == "ok" @command() def buzzer_frequency(self): """Unknown.""" raise NotImplementedError() return self.send("Buzzer_Frequency_Time")[0] == "ok" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/__init__.py0000644000000000000000000000000014265350055015204 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/dummies.py0000644000000000000000000000631714265350055015131 0ustar00class DummyMiIOProtocol: """DummyProtocol allows you mock MiIOProtocol.""" def __init__(self, dummy_device): # TODO: Ideally, return_values should be passed in here. Passing in dummy_device (which must have # return_values) is a temporary workaround to minimize diff size. self.dummy_device = dummy_device def send(self, command: str, parameters=None, retry_count=3, extra_parameters=None): """Overridden send() to return values from `self.return_values`.""" return self.dummy_device.return_values[command](parameters) class DummyDevice: """DummyDevice base class, you should inherit from this and call `super().__init__(args, kwargs)` to save the original state. This class provides helpers to test simple devices, for more complex ones you will want to extend the `return_values` accordingly. The basic idea is that the overloaded send() will read a wanted response based on the call from `return_values`. For changing values :func:`_set_state` will use :func:`pop()` to extract the first parameter and set the state accordingly. For a very simple device the following is enough, see :class:`TestPlug` for complete code. .. code-block:: self.return_values = { "get_prop": self._get_state, "power": lambda x: self._set_state("power", x) } """ def __init__(self, *args, **kwargs): self.start_state = self.state.copy() self._protocol = DummyMiIOProtocol(self) self._info = None # TODO: ugly hack to check for pre-existing _model if getattr(self, "_model", None) is None: self._model = "dummy.model" self.token = "ffffffffffffffffffffffffffffffff" # nosec self.ip = "192.0.2.1" def _reset_state(self): """Revert back to the original state.""" self.state = self.start_state.copy() def _set_state(self, var, value): """Set a state of a variable, the value is expected to be an array with length of 1.""" # print("setting %s = %s" % (var, value)) self.state[var] = value.pop(0) def _get_state(self, props): """Return wanted properties.""" return [self.state[x] for x in props if x in self.state] class DummyMiotDevice(DummyDevice): """Main class representing a MIoT device.""" def __init__(self, *args, **kwargs): # {prop["did"]: prop["value"] for prop in self.miot_client.get_properties()} self.state = [{"did": k, "value": v, "code": 0} for k, v in self.state.items()] super().__init__(*args, **kwargs) def get_properties_for_mapping(self, *, max_properties=15): return self.state def get_properties( self, properties, *, property_getter="get_prop", max_properties=None ): """Return values only for listed properties.""" keys = [p["did"] for p in properties] props = [] for prop in self.state: if prop["did"] in keys: props.append(prop) return props def set_property(self, property_key: str, value): for prop in self.state: if prop["did"] == property_key: prop["value"] = value return None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/test_airconditioner_miot.py0000644000000000000000000002053414265350055020563 0ustar00from unittest import TestCase import pytest from miio import AirConditionerMiot from miio.airconditioner_miot import ( AirConditionerMiotException, CleaningStatus, FanSpeed, OperationMode, TimerStatus, ) from .dummies import DummyMiotDevice _INITIAL_STATE = { "power": False, "mode": OperationMode.Cool, "target_temperature": 24, "eco": True, "heater": True, "dryer": False, "sleep_mode": False, "fan_speed": FanSpeed.Level7, "vertical_swing": True, "temperature": 27.5, "buzzer": True, "led": False, "electricity": 0.0, "clean": "0,100,1,1", "running_duration": 100.4, "fan_speed_percent": 90, "timer": "0,0,0,0", } class DummyAirConditionerMiot(DummyMiotDevice, AirConditionerMiot): def __init__(self, *args, **kwargs): self._model = "xiaomi.aircondition.mc1" self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_target_temperature": lambda x: self._set_state( "target_temperature", x ), "set_eco": lambda x: self._set_state("eco", x), "set_heater": lambda x: self._set_state("heater", x), "set_dryer": lambda x: self._set_state("dryer", x), "set_sleep_mode": lambda x: self._set_state("sleep_mode", x), "set_fan_speed": lambda x: self._set_state("fan_speed", x), "set_vertical_swing": lambda x: self._set_state("vertical_swing", x), "set_temperature": lambda x: self._set_state("temperature", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_led": lambda x: self._set_state("led", x), "set_clean": lambda x: self._set_state("clean", x), "set_fan_speed_percent": lambda x: self._set_state("fan_speed_percent", x), "set_timer": lambda x, y: self._set_state("timer", x, y), } super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def airconditionermiot(request): request.cls.device = DummyAirConditionerMiot() @pytest.mark.usefixtures("airconditionermiot") class TestAirConditioner(TestCase): def test_on(self): self.device.off() # ensure off assert self.device.status().is_on is False self.device.on() assert self.device.status().is_on is True def test_off(self): self.device.on() # ensure on assert self.device.status().is_on is True self.device.off() assert self.device.status().is_on is False def test_status(self): status = self.device.status() assert status.is_on == _INITIAL_STATE["power"] assert status.mode == OperationMode(_INITIAL_STATE["mode"]) assert status.target_temperature == _INITIAL_STATE["target_temperature"] assert status.eco == _INITIAL_STATE["eco"] assert status.heater == _INITIAL_STATE["heater"] assert status.dryer == _INITIAL_STATE["dryer"] assert status.sleep_mode == _INITIAL_STATE["sleep_mode"] assert status.fan_speed == FanSpeed(_INITIAL_STATE["fan_speed"]) assert status.vertical_swing == _INITIAL_STATE["vertical_swing"] assert status.temperature == _INITIAL_STATE["temperature"] assert status.buzzer == _INITIAL_STATE["buzzer"] assert status.led == _INITIAL_STATE["led"] assert repr(status.clean) == repr(CleaningStatus(_INITIAL_STATE["clean"])) assert status.fan_speed_percent == _INITIAL_STATE["fan_speed_percent"] assert repr(status.timer) == repr(TimerStatus(_INITIAL_STATE["timer"])) def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Cool) assert mode() == OperationMode.Cool self.device.set_mode(OperationMode.Dry) assert mode() == OperationMode.Dry self.device.set_mode(OperationMode.Fan) assert mode() == OperationMode.Fan self.device.set_mode(OperationMode.Heat) assert mode() == OperationMode.Heat def test_set_target_temperature(self): def target_temperature(): return self.device.status().target_temperature self.device.set_target_temperature(16.0) assert target_temperature() == 16.0 self.device.set_target_temperature(31.0) assert target_temperature() == 31.0 with pytest.raises(AirConditionerMiotException): self.device.set_target_temperature(15.5) with pytest.raises(AirConditionerMiotException): self.device.set_target_temperature(24.6) with pytest.raises(AirConditionerMiotException): self.device.set_target_temperature(31.5) def test_set_eco(self): def eco(): return self.device.status().eco self.device.set_eco(True) assert eco() is True self.device.set_eco(False) assert eco() is False def test_set_heater(self): def heater(): return self.device.status().heater self.device.set_heater(True) assert heater() is True self.device.set_heater(False) assert heater() is False def test_set_dryer(self): def dryer(): return self.device.status().dryer self.device.set_dryer(True) assert dryer() is True self.device.set_dryer(False) assert dryer() is False def test_set_sleep_mode(self): def sleep_mode(): return self.device.status().sleep_mode self.device.set_sleep_mode(True) assert sleep_mode() is True self.device.set_sleep_mode(False) assert sleep_mode() is False def test_set_fan_speed(self): def fan_speed(): return self.device.status().fan_speed self.device.set_fan_speed(FanSpeed.Auto) assert fan_speed() == FanSpeed.Auto self.device.set_fan_speed(FanSpeed.Level1) assert fan_speed() == FanSpeed.Level1 self.device.set_fan_speed(FanSpeed.Level2) assert fan_speed() == FanSpeed.Level2 self.device.set_fan_speed(FanSpeed.Level3) assert fan_speed() == FanSpeed.Level3 self.device.set_fan_speed(FanSpeed.Level4) assert fan_speed() == FanSpeed.Level4 self.device.set_fan_speed(FanSpeed.Level5) assert fan_speed() == FanSpeed.Level5 self.device.set_fan_speed(FanSpeed.Level6) assert fan_speed() == FanSpeed.Level6 self.device.set_fan_speed(FanSpeed.Level7) assert fan_speed() == FanSpeed.Level7 def test_set_vertical_swing(self): def vertical_swing(): return self.device.status().vertical_swing self.device.set_vertical_swing(True) assert vertical_swing() is True self.device.set_vertical_swing(False) assert vertical_swing() is False def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_fan_speed_percent(self): def fan_speed_percent(): return self.device.status().fan_speed_percent self.device.set_fan_speed_percent(1) assert fan_speed_percent() == 1 self.device.set_fan_speed_percent(101) assert fan_speed_percent() == 101 with pytest.raises(AirConditionerMiotException): self.device.set_fan_speed_percent(102) with pytest.raises(AirConditionerMiotException): self.device.set_fan_speed_percent(0) def test_set_timer(self): def timer(): return self.device.status().data["timer"] self.device.set_timer(60, True) assert timer() == "1,60,1" self.device.set_timer(120, False) assert timer() == "1,120,0" def test_set_clean(self): def clean(): return self.device.status().data["clean"] self.device.set_clean(True) assert clean() == "1" self.device.set_clean(False) assert clean() == "0" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/test_airconditioningcompanion.json0000644000000000000000000001317314265350055022130 0ustar00{ "test_send_ir_code_ok": [ { "in": [ "010504870000714501", "FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" ], "out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" }, { "in": [ "010504870000714501", "FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517", 1 ], "out": "FE04870000714594701FFF7AFF06004227490025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" }, { "in": [ "010504870000714501", "FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517", 134 ], "out": "FE04870000714594701FFFFFFF06004227CE0025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" } ], "test_send_ir_code_exception": [ { "in": [ "010504870000714501", "FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517", -1 ], "out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" }, { "in": [ "010504870000714501", "FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517", 135 ], "out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" }, { "in": [ "Y", "FE00000000000000000000000006004222680025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517", 0 ], "out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" }, { "in": [ "010504870000714501", "Z", 0 ], "out": "FE04870000714594701FFF79FF06004227480025002D008500AC015A138843010201020102010201010102010202010201010102020201020102010202010101010101010101010102010101010101020101010102010102010101020201020517" } ], "test_send_configuration_ok": [ { "in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}], "out": "010001072701011101004000205002112000D04000207002000000A0" }, { "in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}], "out": "010001072712001611001906205002102000C0190620700200000090" }, { "in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.High"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}], "out": "010001072712201611001906205002102000C0190620700200000090" }, { "in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.Off"}, {"__enum__": "Led.Off"}], "out": "010001072712011611001906205002102000C0190620700200000090" }, { "in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.On"}], "out": "010001072701011101004000205002112000D04000207002000000A0" }, { "in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.Off"}, {"__enum__": "Led.Off"}], "out": "010001072701011101004000205002112000D04000207002000000A0" }, { "in": ["010000000001072700", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 23, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}], "out": "010001072712001711001907205002102000D01907207002000000A0" }, { "in": ["010000000001072700", {"__enum__": "Power.Off"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}], "out": "010001072701011101004000205002112000D04000207002000000A0" }, { "in": ["010507950000257301", {"__enum__": "Power.On"}, {"__enum__": "OperationMode.Auto"}, 22, {"__enum__": "FanSpeed.Low"}, {"__enum__": "SwingMode.On"}, {"__enum__": "Led.Off"}], "out": "0100002573120016A1" } ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/test_airconditioningcompanion.py0000644000000000000000000002677014265350055021616 0ustar00import json import os import string from unittest import TestCase import pytest from miio import ( AirConditioningCompanion, AirConditioningCompanionMcn02, AirConditioningCompanionV3, ) from miio.airconditioningcompanion import ( MODEL_ACPARTNER_V3, STORAGE_SLOT_ID, AirConditioningCompanionException, AirConditioningCompanionStatus, FanSpeed, Led, OperationMode, Power, SwingMode, ) from miio.airconditioningcompanionMCN import MODEL_ACPARTNER_MCN02 from miio.airconditioningcompanionMCN import ( AirConditioningCompanionStatus as AirConditioningCompanionStatusMcn02, ) from miio.airconditioningcompanionMCN import FanSpeed as FanSpeedMcn02 from miio.airconditioningcompanionMCN import OperationMode as OperationModeMcn02 from miio.airconditioningcompanionMCN import SwingMode as SwingModeMcn02 from miio.tests.dummies import DummyDevice STATE_ON = ["on"] STATE_OFF = ["off"] PUBLIC_ENUMS = { "OperationMode": OperationMode, "FanSpeed": FanSpeed, "Power": Power, "SwingMode": SwingMode, "Led": Led, } def as_enum(d): if "__enum__" in d: name, member = d["__enum__"].split(".") return getattr(PUBLIC_ENUMS[name], member) else: return d with open( os.path.join(os.path.dirname(__file__), "test_airconditioningcompanion.json") ) as inp: test_data = json.load(inp, object_hook=as_enum) class EnumEncoder(json.JSONEncoder): def default(self, obj): if type(obj) in PUBLIC_ENUMS.values(): return {"__enum__": str(obj)} return json.JSONEncoder.default(self, obj) class DummyAirConditioningCompanion(DummyDevice, AirConditioningCompanion): def __init__(self, *args, **kwargs): self.state = ["010500978022222102", "01020119A280222221", "2"] self.last_ir_played = None self._model = "missing.model.airconditioningcompanion" self.return_values = { "get_model_and_state": self._get_state, "start_ir_learn": lambda x: True, "end_ir_learn": lambda x: True, "get_ir_learn_result": lambda x: True, "send_ir_code": lambda x: self._send_input_validation(x), "send_cmd": lambda x: self._send_input_validation(x), "set_power": lambda x: self._set_power(x), } self.start_state = self.state.copy() super().__init__(args, kwargs) def _reset_state(self): """Revert back to the original state.""" self.state = self.start_state.copy() def _get_state(self, props): """Return the requested data.""" return self.state def _set_power(self, value: str): """Set the requested power state.""" if value == STATE_ON: self.state[1] = self.state[1][:2] + "1" + self.state[1][3:] if value == STATE_OFF: self.state[1] = self.state[1][:2] + "0" + self.state[1][3:] @staticmethod def _hex_input_validation(payload): return all(c in string.hexdigits for c in payload[0]) def _send_input_validation(self, payload): if self._hex_input_validation(payload[0]): self.last_ir_played = payload[0] return True return False def get_last_ir_played(self): return self.last_ir_played @pytest.fixture(scope="class") def airconditioningcompanion(request): request.cls.device = DummyAirConditioningCompanion() # TODO add ability to test on a real device @pytest.mark.usefixtures("airconditioningcompanion") class TestAirConditioningCompanion(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirConditioningCompanionStatus( dict(model_and_state=self.device.start_state) ) ) assert self.is_on() is False assert self.state().power_socket is None assert self.state().load_power == 2 assert self.state().air_condition_model == bytes.fromhex("010500978022222102") assert self.state().model_format == 1 assert self.state().device_type == 5 assert self.state().air_condition_brand == int("0097", 16) assert self.state().air_condition_remote == int("80222221", 16) assert self.state().state_format == 2 assert self.state().air_condition_configuration == "020119A2" assert self.state().target_temperature == 25 assert self.state().swing_mode == SwingMode.Off assert self.state().fan_speed == FanSpeed.Low assert self.state().mode == OperationMode.Auto assert self.state().led is False def test_status_without_target_temperature(self): self.device._reset_state() self.device.state[1] = None assert self.state().target_temperature is None def test_status_without_swing_mode(self): self.device._reset_state() self.device.state[1] = None assert self.state().swing_mode is None def test_status_without_mode(self): self.device._reset_state() self.device.state[1] = None assert self.state().mode is None def test_status_without_fan_speed(self): self.device._reset_state() self.device.state[1] = None assert self.state().fan_speed is None def test_learn(self): assert self.device.learn(STORAGE_SLOT_ID) is True assert self.device.learn() is True def test_learn_result(self): assert self.device.learn_result() is True def test_learn_stop(self): assert self.device.learn_stop(STORAGE_SLOT_ID) is True assert self.device.learn_stop() is True def test_send_ir_code(self): for args in test_data["test_send_ir_code_ok"]: with self.subTest(): self.device._reset_state() self.assertTrue(self.device.send_ir_code(*args["in"])) self.assertSequenceEqual(self.device.get_last_ir_played(), args["out"]) for args in test_data["test_send_ir_code_exception"]: with pytest.raises(AirConditioningCompanionException): self.device.send_ir_code(*args["in"]) def test_send_command(self): assert self.device.send_command("0000000") is True def test_send_configuration(self): for args in test_data["test_send_configuration_ok"]: with self.subTest(): self.device._reset_state() self.assertTrue(self.device.send_configuration(*args["in"])) self.assertSequenceEqual(self.device.get_last_ir_played(), args["out"]) class DummyAirConditioningCompanionV3(DummyDevice, AirConditioningCompanionV3): def __init__(self, *args, **kwargs): self.state = ["010507950000257301", "011001160100002573", "807"] self.device_prop = {"lumi.0": {"plug_state": ["on"]}} self._model = MODEL_ACPARTNER_V3 self.last_ir_played = None self.return_values = { "get_model_and_state": self._get_state, "get_device_prop": self._get_device_prop, "toggle_plug": self._toggle_plug, } self.start_state = self.state.copy() self.start_device_prop = self.device_prop.copy() super().__init__(args, kwargs) def _reset_state(self): """Revert back to the original state.""" self.state = self.start_state.copy() def _get_state(self, props): """Return the requested data.""" return self.state def _get_device_prop(self, props): """Return the requested data.""" return self.device_prop[props[0]][props[1]] def _toggle_plug(self, props): """Toggle the lumi.0 plug state.""" self.device_prop["lumi.0"]["plug_state"] = [props.pop()] @pytest.fixture(scope="class") def airconditioningcompanionv3(request): request.cls.device = DummyAirConditioningCompanionV3() # TODO add ability to test on a real device @pytest.mark.usefixtures("airconditioningcompanionv3") class TestAirConditioningCompanionV3(TestCase): def state(self): return self.device.status() def is_on(self): return self.device.status().is_on def test_socket_on(self): self.device.socket_off() # ensure off assert self.state().power_socket == "off" self.device.socket_on() assert self.state().power_socket == "on" def test_socket_off(self): self.device.socket_on() # ensure on assert self.state().power_socket == "on" self.device.socket_off() assert self.state().power_socket == "off" def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirConditioningCompanionStatus( dict( model_and_state=self.device.start_state, power_socket=self.device.start_device_prop["lumi.0"]["plug_state"][ 0 ], ) ) ) assert self.is_on() is True assert self.state().power_socket == "on" assert self.state().load_power == 807 assert self.state().air_condition_model == bytes.fromhex("010507950000257301") assert self.state().model_format == 1 assert self.state().device_type == 5 assert self.state().air_condition_brand == int("0795", 16) assert self.state().air_condition_remote == int("00002573", 16) assert self.state().state_format == 1 assert self.state().air_condition_configuration == "10011601" assert self.state().target_temperature == 22 assert self.state().swing_mode == SwingMode.Off assert self.state().fan_speed == FanSpeed.Low assert self.state().mode == OperationMode.Heat assert self.state().led is True class DummyAirConditioningCompanionMcn02(DummyDevice, AirConditioningCompanionMcn02): def __init__(self, *args, **kwargs): self.state = ["on", "cool", 28, "small_fan", "on", 441.0] self._model = MODEL_ACPARTNER_MCN02 self.return_values = {"get_prop": self._get_state} self.start_state = self.state.copy() super().__init__(args, kwargs) def _reset_state(self): """Revert back to the original state.""" self.state = self.start_state.copy() def _get_state(self, props): """Return the requested data.""" return self.state @pytest.fixture(scope="class") def airconditioningcompanionMcn02(request): request.cls.device = DummyAirConditioningCompanionMcn02() # TODO add ability to test on a real device @pytest.mark.usefixtures("airconditioningcompanionMcn02") class TestAirConditioningCompanionMcn02(TestCase): def state(self): return self.device.status() def is_on(self): return self.device.status().is_on def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirConditioningCompanionStatusMcn02(self.device.start_state) ) assert self.is_on() is True assert self.state().target_temperature == 28 assert self.state().swing_mode == SwingModeMcn02.On assert self.state().fan_speed == FanSpeedMcn02.Low assert self.state().mode == OperationModeMcn02.Cool ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/test_airdehumidifier.py0000644000000000000000000001453714265350055017662 0ustar00from unittest import TestCase import pytest from miio import AirDehumidifier from miio.airdehumidifier import ( MODEL_DEHUMIDIFIER_V1, AirDehumidifierException, AirDehumidifierStatus, FanSpeed, OperationMode, ) from miio.device import DeviceInfo from .dummies import DummyDevice class DummyAirDehumidifierV1(DummyDevice, AirDehumidifier): def __init__(self, *args, **kwargs): self._model = MODEL_DEHUMIDIFIER_V1 self.dummy_device_info = { "life": 348202, "uid": 1759530000, "model": "nwt.derh.wdh318efw1", "token": "68ffffffffffffffffffffffffffffff", "fw_ver": "2.0.5", "mcu_fw_ver": "0018", "miio_ver": "0.0.5", "hw_ver": "esp32", "mmfree": 65476, "mac": "78:11:FF:FF:FF:FF", "wifi_fw_ver": "v3.1.4-56-g8ffb04960", "netif": { "gw": "192.168.0.1", "localIp": "192.168.0.25", "mask": "255.255.255.0", }, } self.device_info = None self.state = { "on_off": "on", "mode": "auto", "fan_st": 2, "buzzer": "off", "led": "on", "child_lock": "off", "humidity": 48, "temp": 34, "compressor_status": "off", "fan_speed": 0, "tank_full": "off", "defrost_status": "off", "alarm": "ok", "auto": 50, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("on_off", x), "set_mode": lambda x: self._set_state("mode", x), "set_led": lambda x: self._set_state("led", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_fan_speed": lambda x: self._set_state("fan_st", x), "set_auto": lambda x: self._set_state("auto", x), "miIO.info": self._get_device_info, } super().__init__(args, kwargs) def _get_device_info(self, _): """Return dummy device info.""" return self.dummy_device_info @pytest.fixture(scope="class") def airdehumidifierv1(request): request.cls.device = DummyAirDehumidifierV1() # TODO add ability to test on a real device @pytest.mark.usefixtures("airdehumidifierv1") class TestAirDehumidifierV1(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() device_info = DeviceInfo(self.device.dummy_device_info) assert repr(self.state()) == repr( AirDehumidifierStatus(self.device.start_state, device_info) ) assert self.is_on() is True assert self.state().temperature == self.device.start_state["temp"] assert self.state().humidity == self.device.start_state["humidity"] assert self.state().mode == OperationMode(self.device.start_state["mode"]) assert self.state().led == (self.device.start_state["led"] == "on") assert self.state().buzzer == (self.device.start_state["buzzer"] == "on") assert self.state().child_lock == ( self.device.start_state["child_lock"] == "on" ) assert self.state().target_humidity == self.device.start_state["auto"] assert self.state().fan_speed == FanSpeed(self.device.start_state["fan_speed"]) assert self.state().tank_full == (self.device.start_state["tank_full"] == "on") assert self.state().compressor_status == ( self.device.start_state["compressor_status"] == "on" ) assert self.state().defrost_status == ( self.device.start_state["defrost_status"] == "on" ) assert self.state().fan_st == self.device.start_state["fan_st"] assert self.state().alarm == self.device.start_state["alarm"] def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.On) assert mode() == OperationMode.On self.device.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode(OperationMode.DryCloth) assert mode() == OperationMode.DryCloth def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_status_without_temperature(self): self.device._reset_state() self.device.state["temp"] = None assert self.state().temperature is None def test_set_target_humidity(self): def target_humidity(): return self.device.status().target_humidity self.device.set_target_humidity(40) assert target_humidity() == 40 self.device.set_target_humidity(50) assert target_humidity() == 50 self.device.set_target_humidity(60) assert target_humidity() == 60 with pytest.raises(AirDehumidifierException): self.device.set_target_humidity(-1) with pytest.raises(AirDehumidifierException): self.device.set_target_humidity(30) with pytest.raises(AirDehumidifierException): self.device.set_target_humidity(70) with pytest.raises(AirDehumidifierException): self.device.set_target_humidity(110) def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/test_airqualitymonitor.py0000644000000000000000000001351614265350055020320 0ustar00from unittest import TestCase import pytest from miio import AirQualityMonitor from miio.airqualitymonitor import ( MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_S1, MODEL_AIRQUALITYMONITOR_V1, AirQualityMonitorStatus, ) from .dummies import DummyDevice class DummyAirQualityMonitorV1(DummyDevice, AirQualityMonitor): def __init__(self, *args, **kwargs): self._model = MODEL_AIRQUALITYMONITOR_V1 self.state = { "power": "on", "aqi": 34, "battery": 100, "usb_state": "off", "time_state": "on", "night_state": "on", "night_beg_time": "format unknown", "night_end_time": "format unknown", "sensor_state": "format unknown", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_time_state": lambda x: self._set_state("time_state", x), "set_night_state": lambda x: self._set_state("night_state", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def airqualitymonitorv1(request): request.cls.device = DummyAirQualityMonitorV1() # TODO add ability to test on a real device @pytest.mark.usefixtures("airqualitymonitorv1") class TestAirQualityMonitorV1(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirQualityMonitorStatus(self.device.start_state) ) assert self.is_on() is True assert self.state().aqi == self.device.start_state["aqi"] assert self.state().battery == self.device.start_state["battery"] assert self.state().usb_power is (self.device.start_state["usb_state"] == "on") assert self.state().display_clock is ( self.device.start_state["time_state"] == "on" ) assert self.state().night_mode is ( self.device.start_state["night_state"] == "on" ) class DummyAirQualityMonitorS1(DummyDevice, AirQualityMonitor): def __init__(self, *args, **kwargs): self._model = MODEL_AIRQUALITYMONITOR_S1 self.state = { "battery": 100, "co2": 695, "humidity": 62.1, "pm25": 19.4, "temperature": 27.4, "tvoc": 254, } self.return_values = {"get_prop": self._get_state} super().__init__(args, kwargs) def _get_state(self, props): """Return wanted properties.""" return self.state @pytest.fixture(scope="class") def airqualitymonitors1(request): request.cls.device = DummyAirQualityMonitorS1() # TODO add ability to test on a real device @pytest.mark.usefixtures("airqualitymonitors1") class TestAirQualityMonitorS1(TestCase): def state(self): return self.device.status() def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirQualityMonitorStatus(self.device.start_state) ) assert self.state().battery == self.device.start_state["battery"] assert self.state().co2 == self.device.start_state["co2"] assert self.state().humidity == self.device.start_state["humidity"] assert self.state().pm25 == self.device.start_state["pm25"] assert self.state().temperature == self.device.start_state["temperature"] assert self.state().tvoc == self.device.start_state["tvoc"] assert self.state().aqi is None assert self.state().usb_power is None assert self.state().display_clock is None assert self.state().night_mode is None class DummyAirQualityMonitorB1(DummyDevice, AirQualityMonitor): def __init__(self, *args, **kwargs): self._model = MODEL_AIRQUALITYMONITOR_B1 self.state = { "co2e": 1466, "humidity": 59.79999923706055, "pm25": 2, "temperature": 19.799999237060547, "temperature_unit": "c", "tvoc": 1.3948699235916138, "tvoc_unit": "mg_m3", } self.return_values = {"get_air_data": self._get_state} super().__init__(args, kwargs) def _get_state(self, props): """Return wanted properties.""" return self.state @pytest.fixture(scope="class") def airqualitymonitorb1(request): request.cls.device = DummyAirQualityMonitorB1() # TODO add ability to test on a real device @pytest.mark.usefixtures("airqualitymonitorb1") class TestAirQualityMonitorB1(TestCase): def state(self): return self.device.status() def test_status(self): self.device._reset_state() assert repr(self.state()) == repr( AirQualityMonitorStatus(self.device.start_state) ) assert self.state().power is None assert self.state().usb_power is None assert self.state().battery is None assert self.state().aqi is None assert self.state().temperature == self.device.start_state["temperature"] assert self.state().humidity == self.device.start_state["humidity"] assert self.state().co2 is None assert self.state().co2e == self.device.start_state["co2e"] assert self.state().pm25 == self.device.start_state["pm25"] assert self.state().tvoc == self.device.start_state["tvoc"] assert self.state().display_clock is None assert self.state().night_mode is None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5346136 python-miio-0.5.12/miio/tests/test_airqualitymonitor_miot.py0000644000000000000000000001132214265350055021341 0ustar00from unittest import TestCase import pytest from miio import AirQualityMonitorCGDN1 from miio.airqualitymonitor_miot import ( AirQualityMonitorMiotException, ChargingState, DisplayTemperatureUnitCGDN1, ) from .dummies import DummyMiotDevice _INITIAL_STATE = { "humidity": 34, "pm25": 10, "pm10": 15, "temperature": 18.599999, "co2": 620, "battery": 20, "charging_state": 2, "voltage": 26, "start_time": 0, "end_time": 0, "monitoring_frequency": 1, "screen_off": 15, "device_off": 30, "temperature_unit": "c", } class DummyAirQualityMonitorCGDN1(DummyMiotDevice, AirQualityMonitorCGDN1): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, "set_monitoring_frequency": lambda x: self._set_state( "monitoring_frequency", x ), "set_device_off_duration": lambda x: self._set_state("device_off", x), "set_screen_off_duration": lambda x: self._set_state("screen_off", x), "set_display_temperature_unit": lambda x: self._set_state( "temperature_unit", x ), } super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def airqualitymonitorcgdn1(request): request.cls.device = DummyAirQualityMonitorCGDN1() @pytest.mark.usefixtures("airqualitymonitorcgdn1") class TestAirQualityMonitor(TestCase): def test_status(self): status = self.device.status() assert status.humidity is _INITIAL_STATE["humidity"] assert status.pm25 is _INITIAL_STATE["pm25"] assert status.pm10 is _INITIAL_STATE["pm10"] assert status.temperature is _INITIAL_STATE["temperature"] assert status.co2 is _INITIAL_STATE["co2"] assert status.battery is _INITIAL_STATE["battery"] assert status.charging_state is ChargingState(_INITIAL_STATE["charging_state"]) assert status.monitoring_frequency is _INITIAL_STATE["monitoring_frequency"] assert status.screen_off is _INITIAL_STATE["screen_off"] assert status.device_off is _INITIAL_STATE["device_off"] assert status.display_temperature_unit is DisplayTemperatureUnitCGDN1( _INITIAL_STATE["temperature_unit"] ) def test_set_monitoring_frequency_duration(self): def monitoring_frequency(): return self.device.status().monitoring_frequency self.device.set_monitoring_frequency_duration(0) assert monitoring_frequency() == 0 self.device.set_monitoring_frequency_duration(290) assert monitoring_frequency() == 290 self.device.set_monitoring_frequency_duration(600) assert monitoring_frequency() == 600 with pytest.raises(AirQualityMonitorMiotException): self.device.set_monitoring_frequency_duration(-1) with pytest.raises(AirQualityMonitorMiotException): self.device.set_monitoring_frequency_duration(601) def test_set_device_off_duration(self): def device_off_duration(): return self.device.status().device_off self.device.set_device_off_duration(0) assert device_off_duration() == 0 self.device.set_device_off_duration(29) assert device_off_duration() == 29 self.device.set_device_off_duration(60) assert device_off_duration() == 60 with pytest.raises(AirQualityMonitorMiotException): self.device.set_device_off_duration(-1) with pytest.raises(AirQualityMonitorMiotException): self.device.set_device_off_duration(61) def test_set_screen_off_duration(self): def screen_off_duration(): return self.device.status().screen_off self.device.set_screen_off_duration(0) assert screen_off_duration() == 0 self.device.set_screen_off_duration(140) assert screen_off_duration() == 140 self.device.set_screen_off_duration(300) assert screen_off_duration() == 300 with pytest.raises(AirQualityMonitorMiotException): self.device.set_screen_off_duration(-1) with pytest.raises(AirQualityMonitorMiotException): self.device.set_screen_off_duration(301) def test_set_display_temperature_unit(self): def display_temperature_unit(): return self.device.status().display_temperature_unit self.device.set_display_temperature_unit(DisplayTemperatureUnitCGDN1.Celcius) assert display_temperature_unit() == DisplayTemperatureUnitCGDN1.Celcius self.device.set_display_temperature_unit(DisplayTemperatureUnitCGDN1.Fahrenheit) assert display_temperature_unit() == DisplayTemperatureUnitCGDN1.Fahrenheit ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_chuangmi_ir.json0000644000000000000000000001250614265350055017330 0ustar00{ "test_raw_ok": [ { "in": [ "Z6VPAAUCAABgAgAAxQYAAOUIAACUEQAAqyIAADSeAABwdQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFEBAQEBAQEBAgICAgICAgEBAgECAQEBAQIBAgECAgICBgNXA1cDUA" ], "out": [ "Z6VPAAUCAABgAgAAxQYAAOUIAACUEQAAqyIAADSeAABwdQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFEBAQEBAQEBAgICAgICAgEBAgECAQEBAQIBAgECAgICBgNXA1cDUA", 38400 ] }, { "in": [ "Z6VPAAUCAABgAgAAxQYAAOUIAACUEQAAqyIAADSeAABwdQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFEBAQEBAQEBAgICAgICAgEBAgECAQEBAQIBAgECAgICBgNXA1cDUA", 19200 ], "out": [ "Z6VPAAUCAABgAgAAxQYAAOUIAACUEQAAqyIAADSeAABwdQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFEBAQEBAQEBAgICAgICAgEBAgECAQEBAQIBAgECAgICBgNXA1cDUA", 19200 ] } ], "test_pronto_ok": [ { "desc": "NEC1, Dev 0, Subdev 127, Function 10 - with spaces", "in": [ "0000 006C 0022 0002 015B 00AD 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0016 0016 0016 0016 0041 0016 0016 0016 0041 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0016 0016 0041 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0622 015B 0057 0016 0E6C" ], "out": [ "Z6VHAD0CAACdBgAA2ggAAJsRAABQIwAAyZ8AAMF3AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAAAAAAAAQEBAQEBAQAAAQABAAAAAAEAAQABAQEBBQJGA=", 38381 ] }, { "desc": "NEC1, Dev 0, Subdev 127, Function 10 - without spaces", "in": [ "0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C" ], "out": [ "Z6VHAD0CAACdBgAA2ggAAJsRAABQIwAAyZ8AAMF3AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAAAAAAAAQEBAQEBAQAAAQABAAAAAAEAAQABAQEBBQJGA=", 38381 ] }, { "desc": "NEC1, Dev 0, Subdev 127, Function 10 - 0 repeat frames", "in": [ "0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C", 0 ], "out": [ "Z6VDAD0CAACdBgAAmxEAAFAjAADJnwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjAAAAAAAAAAAQEBAQEBAQAAAQABAAAAAAEAAQABAQEBBA", 38381 ] }, { "desc": "NEC1, Dev 0, Subdev 127, Function 10 - 2 repeat frames", "in": [ "0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C", 2 ], "out": [ "Z6VLAD0CAACdBgAA2ggAAJsRAABQIwAAyZ8AAMF3AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAAAAAAAAQEBAQEBAQAAAQABAAAAAAEAAQABAQEBBQJGAkYA==", 38381 ] }, { "desc": "Sony20, Dev 0, Subdev 0, Function 1", "in": [ "00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0" ], "out": [ "Z6VTAFoCAAC0BAAAaAkAAJBGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAQAAAAAAAAAAAAAAAAAAAAAAADACAQAAAAAAAAAAAAAAAAAAAAAAADA=", 39857 ] }, { "desc": "Sony20, Dev 0, Subdev 0, Function 1 - 0 repeats", "in": [ "00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0", 0 ], "out": [ "Z6UpAFoCAAC0BAAAaAkAAJBGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAQAAAAAAAAAAAAAAAAAAAAAAADA=", 39857 ] }, { "desc": "Sony20, Dev 0, Subdev 0, Function 1 - 2 repeats", "in": [ "00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0", 2 ], "out": [ "Z6V9AFoCAAC0BAAAaAkAAJBGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAQAAAAAAAAAAAAAAAAAAAAAAADACAQAAAAAAAAAAAAAAAAAAAAAAADACAQAAAAAAAAAAAAAAAAAAAAAAADA=", 39857 ] } ], "test_pronto_exception": [ { "desc": "NEC1, Dev 0, Subdev 127, Function 10 - invalid repeats value", "in": [ "0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C", -1 ] }, { "desc": "Invalid pronto command", "in": [ "FFFFFFFFFFFF", 0 ] } ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_chuangmi_ir.py0000644000000000000000000001043114265350055017002 0ustar00import base64 import json import os from unittest import TestCase import pytest from miio import ChuangmiIr from miio.chuangmi_ir import ChuangmiIrException from .dummies import DummyDevice with open(os.path.join(os.path.dirname(__file__), "test_chuangmi_ir.json")) as inp: test_data = json.load(inp) class DummyChuangmiIr(DummyDevice, ChuangmiIr): def __init__(self, *args, **kwargs): self.state = {"last_ir_played": None} self.return_values = { "miIO.ir_learn": lambda x: True, "miIO.ir_read": lambda x: True, "miIO.ir_play": self._ir_play_input_validation, } super().__init__(args, kwargs) def _ir_play_input_validation(self, payload): try: base64.b64decode(payload["code"]) self._set_state("last_ir_played", [[payload["code"], payload.get("freq")]]) return True except TypeError: return False @pytest.fixture(scope="class") def chuangmiir(request): request.cls.device = DummyChuangmiIr() # TODO add ability to test on a real device @pytest.mark.usefixtures("chuangmiir") class TestChuangmiIr(TestCase): def test_learn(self): assert self.device.learn() is True assert self.device.learn(30) is True with pytest.raises(ChuangmiIrException): self.device.learn(-1) with pytest.raises(ChuangmiIrException): self.device.learn(1000001) def test_read(self): assert self.device.read() is True assert self.device.read(30) is True with pytest.raises(ChuangmiIrException): self.device.read(-1) with pytest.raises(ChuangmiIrException): self.device.read(1000001) def test_play_raw(self): for args in test_data["test_raw_ok"]: with self.subTest(): self.device._reset_state() self.assertTrue(self.device.play_raw(*args["in"])) self.assertSequenceEqual( self.device.state["last_ir_played"], args["out"] ) def test_pronto_to_raw(self): for args in test_data["test_pronto_ok"]: with self.subTest(): self.assertSequenceEqual( ChuangmiIr.pronto_to_raw(*args["in"]), args["out"] ) for args in test_data["test_pronto_exception"]: with self.subTest(), pytest.raises(ChuangmiIrException): ChuangmiIr.pronto_to_raw(*args["in"]) def test_play_pronto(self): for args in test_data["test_pronto_ok"]: with self.subTest(): self.device._reset_state() self.assertTrue(self.device.play_pronto(*args["in"])) self.assertSequenceEqual( self.device.state["last_ir_played"], args["out"] ) for args in test_data["test_pronto_exception"]: with pytest.raises(ChuangmiIrException): self.device.play_pronto(*args["in"]) def test_play_auto(self): for args in test_data["test_raw_ok"] + test_data["test_pronto_ok"]: if len(args["in"]) > 1: # autodetect doesn't take any extra args continue with self.subTest(): self.device._reset_state() self.assertTrue(self.device.play(*args["in"])) self.assertSequenceEqual( self.device.state["last_ir_played"], args["out"] ) def test_play_with_type(self): for type_, tests in [ ("raw", test_data["test_raw_ok"]), ("pronto", test_data["test_pronto_ok"]), ]: for args in tests: with self.subTest(): command = "{}:{}".format(type_, ":".join(map(str, args["in"]))) self.assertTrue(self.device.play(command)) self.assertSequenceEqual( self.device.state["last_ir_played"], args["out"] ) with pytest.raises(ChuangmiIrException): self.device.play("invalid:command") with pytest.raises(ChuangmiIrException): self.device.play("pronto:command:invalid:argument:count") with pytest.raises(ChuangmiIrException): self.device.play("pronto:command:invalidargument") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_chuangmi_plug.py0000644000000000000000000001527314265350055017350 0ustar00from unittest import TestCase import pytest from miio import ChuangmiPlug from miio.chuangmi_plug import ( MODEL_CHUANGMI_PLUG_M1, MODEL_CHUANGMI_PLUG_V1, MODEL_CHUANGMI_PLUG_V3, ChuangmiPlugStatus, ) from .dummies import DummyDevice class DummyChuangmiPlugV1(DummyDevice, ChuangmiPlug): def __init__(self, *args, **kwargs): self._model = MODEL_CHUANGMI_PLUG_V1 self.state = {"on": True, "usb_on": True, "temperature": 32} self.return_values = { "get_prop": self._get_state, "set_on": lambda x: self._set_state_basic("on", True), "set_off": lambda x: self._set_state_basic("on", False), "set_usb_on": lambda x: self._set_state_basic("usb_on", True), "set_usb_off": lambda x: self._set_state_basic("usb_on", False), } self.start_state = self.state.copy() super().__init__(args, kwargs) def _set_state_basic(self, var, value): """Set a state of a variable.""" self.state[var] = value @pytest.fixture(scope="class") def chuangmiplugv1(request): request.cls.device = DummyChuangmiPlugV1() # TODO add ability to test on a real device @pytest.mark.usefixtures("chuangmiplugv1") class TestChuangmiPlugV1(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(ChuangmiPlugStatus(self.device.start_state)) assert self.is_on() is True assert self.state().usb_power is True assert self.state().temperature == self.device.start_state["temperature"] def test_usb_on(self): self.device.usb_off() # ensure off assert self.device.status().usb_power is False self.device.usb_on() assert self.device.status().usb_power is True def test_usb_off(self): self.device.usb_on() # ensure on assert self.device.status().usb_power is True self.device.usb_off() assert self.device.status().usb_power is False class DummyChuangmiPlugV3(DummyDevice, ChuangmiPlug): def __init__(self, *args, **kwargs): self._model = MODEL_CHUANGMI_PLUG_V3 self.state = {"on": True, "usb_on": True, "temperature": 32, "wifi_led": "off"} self.return_values = { "get_prop": self._get_state, "get_power": self._get_load_power, "set_power": lambda x: self._set_state_basic("on", x == ["on"]), "set_usb_on": lambda x: self._set_state_basic("usb_on", True), "set_usb_off": lambda x: self._set_state_basic("usb_on", False), "set_wifi_led": lambda x: self._set_state("wifi_led", x), } self.start_state = self.state.copy() super().__init__(args, kwargs) def _set_state_basic(self, var, value): """Set a state of a variable.""" self.state[var] = value def _get_load_power(self, props=None): """Return load power.""" return [300] @pytest.fixture(scope="class") def chuangmiplugv3(request): request.cls.device = DummyChuangmiPlugV3() # TODO add ability to test on a real device @pytest.mark.usefixtures("chuangmiplugv3") class TestChuangmiPlugV3(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() load_power = float(self.device._get_load_power().pop(0) * 0.01) start_state_extended = self.device.start_state.copy() start_state_extended["load_power"] = load_power assert repr(self.state()) == repr(ChuangmiPlugStatus(start_state_extended)) assert self.is_on() is True assert self.state().usb_power is True assert self.state().temperature == self.device.start_state["temperature"] assert self.state().load_power == load_power def test_usb_on(self): self.device.usb_off() # ensure off assert self.device.status().usb_power is False self.device.usb_on() assert self.device.status().usb_power is True def test_usb_off(self): self.device.usb_on() # ensure on assert self.device.status().usb_power is True self.device.usb_off() assert self.device.status().usb_power is False def test_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_wifi_led_deprecation(self): with pytest.deprecated_call(): self.device.set_wifi_led(True) with pytest.deprecated_call(): self.device.status().wifi_led class DummyChuangmiPlugM1(DummyDevice, ChuangmiPlug): def __init__(self, *args, **kwargs): self._model = MODEL_CHUANGMI_PLUG_M1 self.state = {"power": "on", "temperature": 32} self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def chuangmiplugm1(request): request.cls.device = DummyChuangmiPlugM1() # TODO add ability to test on a real device @pytest.mark.usefixtures("chuangmiplugm1") class TestChuangmiPlugM1(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(ChuangmiPlugStatus(self.device.start_state)) assert self.is_on() is True assert self.state().temperature == self.device.start_state["temperature"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_click_common.py0000644000000000000000000000033614265350055017155 0ustar00from miio.click_common import validate_ip, validate_token def test_validate_token_empty(): assert not validate_token(None, None, None) def test_validate_ip_empty(): assert validate_ip(None, None, None) is None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_device.py0000644000000000000000000001154414265350055015762 0ustar00import math import pytest from miio import Device, MiotDevice, RoborockVacuum from miio.exceptions import DeviceInfoUnavailableException, PayloadDecodeException DEVICE_CLASSES = Device.__subclasses__() + MiotDevice.__subclasses__() # type: ignore DEVICE_CLASSES.remove(MiotDevice) @pytest.mark.parametrize("max_properties", [None, 1, 15]) def test_get_properties_splitting(mocker, max_properties): properties = [i for i in range(20)] send = mocker.patch("miio.Device.send") d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") d.get_properties(properties, max_properties=max_properties) if max_properties is None: max_properties = len(properties) assert send.call_count == math.ceil(len(properties) / max_properties) def test_default_timeout_and_retry(mocker): send = mocker.patch("miio.miioprotocol.MiIOProtocol.send") d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") assert d._protocol._timeout == 5 d.send(command="fake_command", parameters=[]) send.assert_called_with("fake_command", [], 3, extra_parameters=None) def test_timeout_retry(mocker): send = mocker.patch("miio.miioprotocol.MiIOProtocol.send") d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff", timeout=4) assert d._protocol._timeout == 4 d.send("fake_command", [], 1) send.assert_called_with("fake_command", [], 1, extra_parameters=None) d.send("fake_command", []) send.assert_called_with("fake_command", [], 3, extra_parameters=None) class CustomDevice(Device): retry_count = 5 timeout = 1 d2 = CustomDevice("127.0.0.1", "68ffffffffffffffffffffffffffffff") assert d2._protocol._timeout == 1 d2.send("fake_command", []) send.assert_called_with("fake_command", [], 5, extra_parameters=None) def test_unavailable_device_info_raises(mocker): """Make sure custom exception is raised if the info payload is invalid.""" send = mocker.patch("miio.Device.send", side_effect=PayloadDecodeException) d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") with pytest.raises(DeviceInfoUnavailableException): d.info() assert send.call_count == 1 def test_device_id_handshake(mocker): """Make sure send_handshake() gets called if did is unknown.""" handshake = mocker.patch("miio.Device.send_handshake") _ = mocker.patch("miio.Device.send") d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") d.device_id handshake.assert_called() def test_device_id(mocker): """Make sure send_handshake() does not get called if did is already known.""" handshake = mocker.patch("miio.Device.send_handshake") _ = mocker.patch("miio.Device.send") d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") d._protocol._device_id = b"12345678" d.device_id handshake.assert_not_called() def test_model_autodetection(mocker): """Make sure info() gets called if the model is unknown.""" info = mocker.patch("miio.Device._fetch_info") _ = mocker.patch("miio.Device.send") d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff") d.raw_command("cmd", {}) info.assert_called() def test_forced_model(mocker): """Make sure info() does not get called automatically if model is given.""" info = mocker.patch("miio.Device.info") _ = mocker.patch("miio.Device.send") DUMMY_MODEL = "dummy.model" d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff", model=DUMMY_MODEL) d.raw_command("dummy", {}) assert d.model == DUMMY_MODEL info.assert_not_called() @pytest.mark.parametrize( "cls,hidden", [(Device, True), (MiotDevice, True), (RoborockVacuum, False)] ) def test_missing_supported(mocker, caplog, cls, hidden): """Make sure warning is logged if the device is unsupported for the class.""" _ = mocker.patch("miio.Device.send") d = cls("127.0.0.1", "68ffffffffffffffffffffffffffffff") d._fetch_info() if hidden: assert "Found an unsupported model" not in caplog.text assert f"for class '{cls.__name__}'" not in caplog.text else: assert "Found an unsupported model" in caplog.text assert f"for class '{cls.__name__}'" in caplog.text @pytest.mark.parametrize("cls", DEVICE_CLASSES) def test_device_ctor_model(cls): """Make sure that every device subclass ctor accepts model kwarg.""" # TODO Huizuo implements custom model fallback, so it needs to be ignored for now ignore_classes = ["GatewayDevice", "CustomDevice", "Huizuo"] if cls.__name__ in ignore_classes: return dummy_model = "dummy" dev = cls("127.0.0.1", "68ffffffffffffffffffffffffffffff", model=dummy_model) assert dev.model == dummy_model @pytest.mark.parametrize("cls", DEVICE_CLASSES) def test_device_supported_models(cls): """Make sure that every device subclass has a non-empty supported models.""" assert cls.supported_models ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_deviceinfo.py0000644000000000000000000000353414265350055016636 0ustar00import pytest from miio.deviceinfo import DeviceInfo @pytest.fixture() def info(): """Example response from Xiaomi Smart WiFi Plug (c&p from deviceinfo ctor).""" return DeviceInfo( { "ap": {"bssid": "FF:FF:FF:FF:FF:FF", "rssi": -68, "ssid": "network"}, "cfg_time": 0, "fw_ver": "1.2.4_16", "hw_ver": "MW300", "life": 24, "mac": "28:FF:FF:FF:FF:FF", "mmfree": 30312, "model": "chuangmi.plug.m1", "netif": { "gw": "192.168.xxx.x", "localIp": "192.168.xxx.x", "mask": "255.255.255.0", }, "ot": "otu", "ott_stat": [0, 0, 0, 0], "otu_stat": [320, 267, 3, 0, 3, 742], "token": "2b00042f7481c7b056c4b410d28f33cf", "wifi_fw_ver": "SD878x-14.76.36.p84-702.1.0-WM", } ) def test_properties(info): """Test that all deviceinfo properties are accessible.""" assert info.raw == info.data assert isinstance(info.accesspoint, dict) assert isinstance(info.network_interface, dict) ap_props = ["bssid", "ssid", "rssi"] for prop in ap_props: assert prop in info.accesspoint if_props = ["gw", "localIp", "mask"] for prop in if_props: assert prop in info.network_interface assert info.model is not None assert info.firmware_version is not None assert info.hardware_version is not None assert info.mac_address is not None def test_missing_fields(info): """Test that missing keys do not cause exceptions.""" for k in ["fw_ver", "hw_ver", "model", "token", "mac"]: del info.raw[k] assert info.model is None assert info.firmware_version is None assert info.hardware_version is None assert info.mac_address is None assert info.token is None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_devicestatus.py0000644000000000000000000000261014265350055017220 0ustar00from miio import DeviceStatus def test_multiple(): class MultipleProperties(DeviceStatus): @property def first(self): return "first" @property def second(self): return "second" assert ( repr(MultipleProperties()) == "" ) def test_empty(): class EmptyStatus(DeviceStatus): pass assert repr(EmptyStatus() == "") def test_exception(): class StatusWithException(DeviceStatus): @property def raise_exception(self): raise Exception("test") assert ( repr(StatusWithException()) == "" ) def test_inheritance(): class Parent(DeviceStatus): @property def from_parent(self): return True class Child(Parent): @property def from_child(self): return True assert repr(Child()) == "" def test_list(): class List(DeviceStatus): @property def return_list(self): return [0, 1, 2] assert repr(List()) == "" def test_none(): class NoneStatus(DeviceStatus): @property def return_none(self): return None assert repr(NoneStatus()) == "" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_heater.py0000644000000000000000000001145114265350055015770 0ustar00from unittest import TestCase import pytest from miio import Heater from miio.heater import MODEL_HEATER_ZA1, Brightness, HeaterException, HeaterStatus from .dummies import DummyDevice class DummyHeater(DummyDevice, Heater): def __init__(self, *args, **kwargs): self._model = MODEL_HEATER_ZA1 # This example response is just a guess. Please update! self.state = { "target_temperature": 24, "temperature": 22.1, "relative_humidity": 46, "poweroff_time": 0, "power": "on", "child_lock": "off", "buzzer": "on", "brightness": 1, "use_time": 0, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_target_temperature": lambda x: self._set_state( "target_temperature", x ), "set_brightness": lambda x: self._set_state("brightness", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_poweroff_time": lambda x: self._set_state("poweroff_time", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def heater(request): request.cls.device = DummyHeater() # TODO add ability to test on a real device @pytest.mark.usefixtures("heater") class TestHeater(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(HeaterStatus(self.device.start_state)) assert self.is_on() is True assert ( self.state().target_temperature == self.device.start_state["target_temperature"] ) assert self.state().temperature == self.device.start_state["temperature"] assert self.state().humidity == self.device.start_state["relative_humidity"] assert ( self.state().delay_off_countdown == self.device.start_state["poweroff_time"] ) assert self.state().child_lock is ( self.device.start_state["child_lock"] == "on" ) assert self.state().buzzer is (self.device.start_state["buzzer"] == "on") assert self.state().brightness == Brightness( self.device.start_state["brightness"] ) assert self.state().use_time == self.device.start_state["use_time"] def test_set_target_temperature(self): def target_temperature(): return self.device.status().target_temperature self.device.set_target_temperature(16) assert target_temperature() == 16 self.device.set_target_temperature(24) assert target_temperature() == 24 self.device.set_target_temperature(32) assert target_temperature() == 32 with pytest.raises(HeaterException): self.device.set_target_temperature(15) with pytest.raises(HeaterException): self.device.set_target_temperature(33) def test_set_brightness(self): def brightness(): return self.device.status().brightness self.device.set_brightness(Brightness.Bright) assert brightness() == Brightness.Bright self.device.set_brightness(Brightness.Dim) assert brightness() == Brightness.Dim self.device.set_brightness(Brightness.Off) assert brightness() == Brightness.Off def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.delay_off(0) assert delay_off_countdown() == 0 self.device.delay_off(9) assert delay_off_countdown() == 9 with pytest.raises(HeaterException): self.device.delay_off(-1) with pytest.raises(HeaterException): self.device.delay_off(9 * 3600 + 1) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_heater_miot.py0000644000000000000000000000726514265350055017030 0ustar00from unittest import TestCase import pytest from miio import HeaterMiot from miio.heater_miot import HeaterMiotException, LedBrightness from .dummies import DummyMiotDevice _INITIAL_STATE = { "power": True, "temperature": 21.6, "target_temperature": 23, "buzzer": False, "led_brightness": 1, "child_lock": False, "countdown_time": 0, } class DummyHeaterMiot(DummyMiotDevice, HeaterMiot): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_led_brightness": lambda x: self._set_state("led_brightness", x), "set_buzzer": lambda x: self._set_state("buzzer", x), "set_child_lock": lambda x: self._set_state("child_lock", x), "set_delay_off": lambda x: self._set_state("countdown_time", x), "set_target_temperature": lambda x: self._set_state( "target_temperature", x ), } super().__init__(*args, **kwargs) @pytest.fixture(scope="class") def heater(request): request.cls.device = DummyHeaterMiot() @pytest.mark.usefixtures("heater") class TestHeater(TestCase): def is_on(self): return self.device.status().is_on def test_on(self): self.device.off() assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() assert self.is_on() is True self.device.off() assert self.is_on() is False def test_set_led_brightness(self): def led_brightness(): return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.On) assert led_brightness() == LedBrightness.On self.device.set_led_brightness(LedBrightness.Off) assert led_brightness() == LedBrightness.Off def test_set_buzzer(self): def buzzer(): return self.device.status().buzzer self.device.set_buzzer(True) assert buzzer() is True self.device.set_buzzer(False) assert buzzer() is False def test_set_child_lock(self): def child_lock(): return self.device.status().child_lock self.device.set_child_lock(True) assert child_lock() is True self.device.set_child_lock(False) assert child_lock() is False def test_set_delay_off(self): def delay_off_countdown(): return self.device.status().delay_off_countdown self.device.set_delay_off(0) assert delay_off_countdown() == 0 self.device.set_delay_off(9 * 3600) assert delay_off_countdown() == 9 self.device.set_delay_off(12 * 3600) assert delay_off_countdown() == 12 self.device.set_delay_off(9 * 3600 + 1) assert delay_off_countdown() == 9 with pytest.raises(HeaterMiotException): self.device.set_delay_off(-1) with pytest.raises(HeaterMiotException): self.device.set_delay_off(13 * 3600) def test_set_target_temperature(self): def target_temperature(): return self.device.status().target_temperature self.device.set_target_temperature(18) assert target_temperature() == 18 self.device.set_target_temperature(23) assert target_temperature() == 23 self.device.set_target_temperature(28) assert target_temperature() == 28 with pytest.raises(HeaterMiotException): self.device.set_target_temperature(17) with pytest.raises(HeaterMiotException): self.device.set_target_temperature(29) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_huizuo.py0000644000000000000000000001553714265350055016054 0ustar00from unittest import TestCase import pytest from miio import Huizuo, HuizuoLampFan, HuizuoLampHeater from miio.huizuo import MODEL_HUIZUO_FANWY # Fan model extended from miio.huizuo import MODEL_HUIZUO_FANWY2 # Fan model basic from miio.huizuo import MODEL_HUIZUO_PIS123 # Basic model from miio.huizuo import MODEL_HUIZUO_WYHEAT # Heater model from miio.huizuo import HuizuoException from .dummies import DummyMiotDevice _INITIAL_STATE = { "power": True, "brightness": 60, "color_temp": 4000, } _INITIAL_STATE_FAN = { "power": True, "brightness": 60, "color_temp": 4000, "fan_power": False, "fan_level": 60, "fan_motor_reverse": True, "fan_mode": 1, } _INITIAL_STATE_HEATER = { "power": True, "brightness": 60, "color_temp": 4000, "heater_power": True, "heat_level": 2, } class DummyHuizuo(DummyMiotDevice, Huizuo): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE self._model = MODEL_HUIZUO_PIS123 super().__init__(*args, **kwargs) class DummyHuizuoFan(DummyMiotDevice, HuizuoLampFan): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE_FAN self._model = MODEL_HUIZUO_FANWY super().__init__(*args, **kwargs) class DummyHuizuoFan2(DummyMiotDevice, HuizuoLampFan): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE_FAN self._model = MODEL_HUIZUO_FANWY2 super().__init__(*args, **kwargs) class DummyHuizuoHeater(DummyMiotDevice, HuizuoLampHeater): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE_HEATER self._model = MODEL_HUIZUO_WYHEAT super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def huizuo(request): request.cls.device = DummyHuizuo() @pytest.fixture(scope="function") def huizuo_fan(request): request.cls.device = DummyHuizuoFan() @pytest.fixture(scope="function") def huizuo_fan2(request): request.cls.device = DummyHuizuoFan2() @pytest.fixture(scope="function") def huizuo_heater(request): request.cls.device = DummyHuizuoHeater() @pytest.mark.usefixtures("huizuo") class TestHuizuo(TestCase): def test_on(self): self.device.off() # ensure off assert self.device.status().is_on is False self.device.on() assert self.device.status().is_on is True def test_off(self): self.device.on() # ensure on assert self.device.status().is_on is True self.device.off() assert self.device.status().is_on is False def test_status(self): status = self.device.status() assert status.is_on is _INITIAL_STATE["power"] assert status.brightness is _INITIAL_STATE["brightness"] assert status.color_temp is _INITIAL_STATE["color_temp"] def test_brightness(self): def lamp_brightness(): return self.device.status().brightness self.device.set_brightness(1) assert lamp_brightness() == 1 self.device.set_brightness(64) assert lamp_brightness() == 64 self.device.set_brightness(100) assert lamp_brightness() == 100 with pytest.raises(HuizuoException): self.device.set_brightness(-1) with pytest.raises(HuizuoException): self.device.set_brightness(101) def test_color_temp(self): def lamp_color_temp(): return self.device.status().color_temp self.device.set_color_temp(3000) assert lamp_color_temp() == 3000 self.device.set_color_temp(4200) assert lamp_color_temp() == 4200 self.device.set_color_temp(6400) assert lamp_color_temp() == 6400 with pytest.raises(HuizuoException): self.device.set_color_temp(2999) with pytest.raises(HuizuoException): self.device.set_color_temp(6401) @pytest.mark.usefixtures("huizuo_fan") class TestHuizuoFan(TestCase): def test_fan_on(self): self.device.fan_off() # ensure off assert self.device.status().is_fan_on is False self.device.fan_on() assert self.device.status().is_fan_on is True def test_fan_off(self): self.device.fan_on() # ensure on assert self.device.status().is_fan_on is True self.device.fan_off() assert self.device.status().is_fan_on is False def test_fan_status(self): status = self.device.status() assert status.is_fan_on is _INITIAL_STATE_FAN["fan_power"] assert status.fan_speed_level is _INITIAL_STATE_FAN["fan_level"] assert status.is_fan_reverse is _INITIAL_STATE_FAN["fan_motor_reverse"] assert status.fan_mode is _INITIAL_STATE_FAN["fan_mode"] def test_fan_level(self): def fan_level(): return self.device.status().fan_speed_level self.device.set_fan_level(0) assert fan_level() == 0 self.device.set_fan_level(100) assert fan_level() == 100 with pytest.raises(HuizuoException): self.device.set_fan_level(-1) with pytest.raises(HuizuoException): self.device.set_fan_level(101) def test_fan_motor_reverse(self): def fan_reverse(): return self.device.status().is_fan_reverse self.device.fan_reverse_on() assert fan_reverse() is True self.device.fan_reverse_off() assert fan_reverse() is False def test_fan_mode(self): def fan_mode(): return self.device.status().fan_mode self.device.set_basic_fan_mode() assert fan_mode() == 0 self.device.set_natural_fan_mode() assert fan_mode() == 1 @pytest.mark.usefixtures("huizuo_fan2") class TestHuizuoFan2(TestCase): # This device has no 'reverse' mode, so let's check this def test_fan_motor_reverse(self): with pytest.raises(HuizuoException): self.device.fan_reverse_on() with pytest.raises(HuizuoException): self.device.fan_reverse_off() @pytest.mark.usefixtures("huizuo_heater") class TestHuizuoHeater(TestCase): def test_heater_on(self): self.device.heater_off() # ensure off assert self.device.status().is_heater_on is False self.device.heater_on() assert self.device.status().is_heater_on is True def test_heater_off(self): self.device.heater_on() # ensure on assert self.device.status().is_heater_on is True self.device.heater_off() assert self.device.status().is_heater_on is False def test_heat_level(self): def heat_level(): return self.device.status().heat_level self.device.set_heat_level(1) assert heat_level() == 1 self.device.set_heat_level(3) assert heat_level() == 3 with pytest.raises(HuizuoException): self.device.set_heat_level(0) with pytest.raises(HuizuoException): self.device.set_heat_level(4) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_miotdevice.py0000644000000000000000000001000014265350055016635 0ustar00import pytest from miio import Huizuo, MiotDevice from miio.miot_device import MiotValueType MIOT_DEVICES = MiotDevice.__subclasses__() # TODO: huizuo needs to be refactored to use _mappings, # until then, just disable the tests on it. MIOT_DEVICES.remove(Huizuo) # type: ignore @pytest.fixture(scope="module") def dev(module_mocker): DUMMY_MAPPING = {} device = MiotDevice( "127.0.0.1", "68ffffffffffffffffffffffffffffff", mapping=DUMMY_MAPPING ) module_mocker.patch.object(device, "send") return device def test_missing_mapping(caplog): """Make sure ctor raises exception if neither class nor parameter defines the mapping.""" _ = MiotDevice("127.0.0.1", "68ffffffffffffffffffffffffffffff") assert "Neither the class nor the parameter defines the mapping" in caplog.text def test_ctor_mapping(): """Make sure the constructor accepts the mapping parameter.""" test_mapping = {} dev2 = MiotDevice( "127.0.0.1", "68ffffffffffffffffffffffffffffff", mapping=test_mapping ) assert dev2.mapping == test_mapping def test_get_property_by(dev): siid = 1 piid = 2 _ = dev.get_property_by(siid, piid) dev.send.assert_called_with( "get_properties", [{"did": f"{siid}-{piid}", "siid": siid, "piid": piid}] ) @pytest.mark.parametrize( "value_type,value", [ (None, 1), (MiotValueType.Int, "1"), (MiotValueType.Float, "1.2"), (MiotValueType.Str, "str"), (MiotValueType.Bool, "1"), ], ) def test_set_property_by(dev, value_type, value): siid = 1 piid = 1 _ = dev.set_property_by(siid, piid, value, value_type) if value_type is not None: value = value_type.value(value) dev.send.assert_called_with( "set_properties", [{"did": f"set-{siid}-{piid}", "siid": siid, "piid": piid, "value": value}], ) def test_call_action_by(dev): siid = 1 aiid = 1 _ = dev.call_action_by(siid, aiid) dev.send.assert_called_with( "action", { "did": f"call-{siid}-{aiid}", "siid": siid, "aiid": aiid, "in": [], }, ) params = {"test_param": 1} _ = dev.call_action_by(siid, aiid, params) dev.send.assert_called_with( "action", { "did": f"call-{siid}-{aiid}", "siid": siid, "aiid": aiid, "in": params, }, ) @pytest.mark.parametrize( "model,expected_mapping,expected_log", [ ("some_model", {"x": {"y": 1}}, ""), ("unknown_model", {"x": {"y": 1}}, "Unable to find mapping"), ], ) def test_get_mapping(dev, caplog, model, expected_mapping, expected_log): """Test _get_mapping logic for fallbacks.""" dev._mappings["some_model"] = {"x": {"y": 1}} dev._model = model assert dev._get_mapping() == expected_mapping assert expected_log in caplog.text def test_get_mapping_backwards_compat(dev): """Test that the backwards compat works.""" # as dev is mocked on module level, need to empty manually dev._mappings = {} assert dev._get_mapping() == {} @pytest.mark.parametrize("cls", MIOT_DEVICES) def test_mapping_deprecation(cls): """Check that deprecated mapping is not used.""" # TODO: this can be removed in the future. assert not hasattr(cls, "mapping") @pytest.mark.parametrize("cls", MIOT_DEVICES) def test_mapping_structure(cls): """Check that mappings are structured correctly.""" assert cls._mappings model, contents = next(iter(cls._mappings.items())) # model must contain a dot assert "." in model method, piid_siid = next(iter(contents.items())) assert isinstance(method, str) # mapping should be a dict with piid, siid assert "piid" in piid_siid assert "siid" in piid_siid @pytest.mark.parametrize("cls", MIOT_DEVICES) def test_supported_models(cls): assert cls.supported_models == cls._mappings.keys() # make sure that that _supported_models is not defined assert not cls._supported_models ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_powerstrip.py0000644000000000000000000001670214265350055016742 0ustar00from unittest import TestCase import pytest from miio import PowerStrip from miio.powerstrip import ( MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2, PowerMode, PowerStripException, PowerStripStatus, ) from .dummies import DummyDevice class DummyPowerStripV1(DummyDevice, PowerStrip): def __init__(self, *args, **kwargs): self._model = MODEL_POWER_STRIP_V1 self.state = { "power": "on", "mode": "normal", "temperature": 32.5, "current": 25.5, "power_consume_rate": 12.5, "voltage": 23057, "power_factor": 12, "elec_leakage": 8, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_power_mode": lambda x: self._set_state("mode", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def powerstripv1(request): request.cls.device = DummyPowerStripV1() # TODO add ability to test on a real device @pytest.mark.usefixtures("powerstripv1") class TestPowerStripV1(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(PowerStripStatus(self.device.start_state)) assert self.is_on() is True assert self.state().mode == PowerMode(self.device.start_state["mode"]) assert self.state().temperature == self.device.start_state["temperature"] assert self.state().current == self.device.start_state["current"] assert self.state().load_power == self.device.start_state["power_consume_rate"] assert self.state().voltage == self.device.start_state["voltage"] / 100.0 assert self.state().power_factor == self.device.start_state["power_factor"] assert self.state().leakage_current == self.device.start_state["elec_leakage"] def test_status_without_power_consume_rate(self): self.device._reset_state() self.device.state["power_consume_rate"] = None assert self.state().load_power is None def test_status_without_current(self): self.device._reset_state() self.device.state["current"] = None assert self.state().current is None def test_status_without_mode(self): self.device._reset_state() # The Power Strip 2 doesn't support power modes self.device.state["mode"] = None assert self.state().mode is None def test_set_power_mode(self): def mode(): return self.device.status().mode self.device.set_power_mode(PowerMode.Eco) assert mode() == PowerMode.Eco self.device.set_power_mode(PowerMode.Normal) assert mode() == PowerMode.Normal class DummyPowerStripV2(DummyDevice, PowerStrip): def __init__(self, *args, **kwargs): self._model = MODEL_POWER_STRIP_V2 self.state = { "power": "on", "mode": "normal", "temperature": 32.5, "current": 25.5, "power_consume_rate": 12.5, "wifi_led": "off", "power_price": 49, } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_power_mode": lambda x: self._set_state("mode", x), "set_wifi_led": lambda x: self._set_state("wifi_led", x), "set_power_price": lambda x: self._set_state("power_price", x), "set_rt_power": lambda x: True, } super().__init__(args, kwargs) @pytest.fixture(scope="class") def powerstripv2(request): request.cls.device = DummyPowerStripV2() # TODO add ability to test on a real device @pytest.mark.usefixtures("powerstripv2") class TestPowerStripV2(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(PowerStripStatus(self.device.start_state)) assert self.is_on() is True assert self.state().mode == PowerMode(self.device.start_state["mode"]) assert self.state().temperature == self.device.start_state["temperature"] assert self.state().current == self.device.start_state["current"] assert self.state().load_power == self.device.start_state["power_consume_rate"] assert self.state().voltage is None assert self.state().power_factor is None assert self.state().leakage_current is None def test_status_without_power_consume_rate(self): self.device._reset_state() self.device.state["power_consume_rate"] = None assert self.state().load_power is None def test_status_without_current(self): self.device._reset_state() self.device.state["current"] = None assert self.state().current is None def test_status_without_mode(self): self.device._reset_state() # The Power Strip 2 doesn't support power modes self.device.state["mode"] = None assert self.state().mode is None def test_set_power_mode(self): def mode(): return self.device.status().mode self.device.set_power_mode(PowerMode.Eco) assert mode() == PowerMode.Eco self.device.set_power_mode(PowerMode.Normal) assert mode() == PowerMode.Normal def test_set_led(self): def led(): return self.device.status().led self.device.set_led(True) assert led() is True self.device.set_led(False) assert led() is False def test_set_wifi_led_deprecation(self): with pytest.deprecated_call(): self.device.set_wifi_led(True) with pytest.deprecated_call(): self.device.status().wifi_led def test_set_power_price(self): def power_price(): return self.device.status().power_price self.device.set_power_price(0) assert power_price() == 0 self.device.set_power_price(1) assert power_price() == 1 self.device.set_power_price(2) assert power_price() == 2 with pytest.raises(PowerStripException): self.device.set_power_price(-1) with pytest.raises(PowerStripException): self.device.set_power_price(1000) def test_status_without_power_price(self): self.device._reset_state() self.device.state["power_price"] = None assert self.state().power_price is None def test_set_realtime_power(self): """The method is open-loop. The new state cannot be retrieved. """ self.device.set_realtime_power(True) self.device.set_realtime_power(False) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_protocol.py0000644000000000000000000001073614265350055016366 0ustar00import binascii import pytest from miio.exceptions import DeviceError, PayloadDecodeException, RecoverableError from .. import Utils from ..miioprotocol import MiIOProtocol from ..protocol import Message METHOD = "method" PARAMS = "params" @pytest.fixture def proto() -> MiIOProtocol: return MiIOProtocol() @pytest.fixture def token() -> bytes: return bytes.fromhex(32 * "0") def build_msg(data, token): encrypted_data = Utils.encrypt(data, token) # header magic = binascii.unhexlify(b"2131") length = (32 + len(encrypted_data)).to_bytes(2, byteorder="big") unknown = binascii.unhexlify(b"00000000") did = binascii.unhexlify(b"01234567") epoch = binascii.unhexlify(b"00000000") checksum = Utils.md5( magic + length + unknown + did + epoch + token + encrypted_data ) return magic + length + unknown + did + epoch + checksum + encrypted_data def test_incrementing_id(proto): old_id = proto.raw_id proto._create_request("dummycmd", "dummy") assert proto.raw_id > old_id def test_id_loop(proto): proto.__id = 9999 proto._create_request("dummycmd", "dummy") assert proto.raw_id == 1 def test_request_with_none_param(proto): req = proto._create_request("dummy", None) assert isinstance(req["params"], list) assert len(req["params"]) == 0 def test_request_with_string_param(proto): req = proto._create_request("command", "single") assert req[METHOD] == "command" assert req[PARAMS] == "single" def test_request_with_list_param(proto): req = proto._create_request("command", ["item"]) assert req[METHOD] == "command" assert req[PARAMS] == ["item"] def test_request_extra_params(proto): req = proto._create_request("command", ["item"], extra_parameters={"sid": 1234}) assert "sid" in req assert req["sid"] == 1234 @pytest.mark.parametrize("retry_error", [-30001, -9999]) def test_device_error_handling(proto: MiIOProtocol, retry_error): with pytest.raises(RecoverableError): proto._handle_error({"code": retry_error}) with pytest.raises(DeviceError): proto._handle_error({"code": 1234}) def test_non_bytes_payload(token): payload = "hello world" with pytest.raises(TypeError): Utils.encrypt(payload, token) with pytest.raises(TypeError): Utils.decrypt(payload, token) def test_encrypt(token): payload = b"hello world" encrypted = Utils.encrypt(payload, token) decrypted = Utils.decrypt(encrypted, token) assert payload == decrypted def test_invalid_token(): payload = b"hello world" wrong_type = 1234 wrong_length = bytes.fromhex(16 * "0") with pytest.raises(TypeError): Utils.encrypt(payload, wrong_type) with pytest.raises(TypeError): Utils.decrypt(payload, wrong_type) with pytest.raises(ValueError): Utils.encrypt(payload, wrong_length) with pytest.raises(ValueError): Utils.decrypt(payload, wrong_length) def test_decode_json_payload(token): ctx = {"token": token} # can parse message with valid json serialized_msg = build_msg(b'{"id": 123456}', token) parsed_msg = Message.parse(serialized_msg, **ctx) assert parsed_msg.data.value assert isinstance(parsed_msg.data.value, dict) assert parsed_msg.data.value["id"] == 123456 def test_decode_json_quirk_powerstrip(token): ctx = {"token": token} # can parse message with invalid json for edge case powerstrip # when not connected to cloud serialized_msg = build_msg(b'{"id": 123456,,"otu_stat":0}', token) parsed_msg = Message.parse(serialized_msg, **ctx) assert parsed_msg.data.value assert isinstance(parsed_msg.data.value, dict) assert parsed_msg.data.value["id"] == 123456 assert parsed_msg.data.value["otu_stat"] == 0 def test_decode_json_quirk_cloud(token): ctx = {"token": token} # can parse message with invalid json for edge case xiaomi cloud # reply to _sync.batch_gen_room_up_url serialized_msg = build_msg(b'{"id": 123456}\x00k', token) parsed_msg = Message.parse(serialized_msg, **ctx) assert parsed_msg.data.value assert isinstance(parsed_msg.data.value, dict) assert parsed_msg.data.value["id"] == 123456 def test_decode_json_raises_for_invalid_json(token): ctx = {"token": token} # make sure PayloadDecodeDexception is raised for invalid json serialized_msg = build_msg(b'{"id": 123456,,"otu_stat":0', token) with pytest.raises(PayloadDecodeException): Message.parse(serialized_msg, **ctx) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_toiletlid.py0000644000000000000000000001156614265350055016520 0ustar00"""Unit tests for toilet lid. Response instance >> status Work: False State: 1 Ambient Light: Yellow Filter remaining: 100% Filter remaining time: 180 """ from unittest import TestCase import pytest from miio.toiletlid import ( MODEL_TOILETLID_V1, AmbientLightColor, Toiletlid, ToiletlidStatus, ) from .dummies import DummyDevice class DummyToiletlidV1(DummyDevice, Toiletlid): def __init__(self, *args, **kwargs): self._model = MODEL_TOILETLID_V1 self.state = { "is_on": False, "work_state": 1, "work_mode": "Vacant", "ambient_light": "Yellow", "filter_use_flux": "100", "filter_use_time": "180", } self.users = {} self.return_values = { "get_prop": self._get_state, "nozzle_clean": lambda x: self._set_state("work_state", [97]), "set_aled_v_of_uid": self.set_aled_v_of_uid, "get_aled_v_of_uid": self.get_aled_v_of_uid, "uid_mac_op": self.uid_mac_op, "get_all_user_info": self.get_all_user_info, } super().__init__(args, kwargs) def set_aled_v_of_uid(self, args): uid, color = args if uid: if uid not in self.users: raise ValueError("This user is not bind.") self.users.setdefault("ambient_light", AmbientLightColor(color).name) else: return self._set_state("ambient_light", [AmbientLightColor(color).name]) def get_aled_v_of_uid(self, args): uid = args[0] if uid: if uid not in self.users: raise ValueError("This user is not b.") color = self.users.get("ambient_light") else: color = self._get_state(["ambient_light"]) if not AmbientLightColor._member_map_.get(color[0]): raise ValueError(color) return AmbientLightColor._member_map_.get(color[0]).value def uid_mac_op(self, args): xiaomi_id, band_mac, alias, operating = args if operating not in ["bind", "unbind"]: raise ValueError("operating not bind or unbind, but %s" % operating) if operating == "bind": info = self.users.setdefault( xiaomi_id, {"rssi": -50, "set": "3-0-2-2-0-0-5-5"} ) info.update(mac=band_mac, name=alias) elif operating == "unbind": self.users.pop(xiaomi_id) def get_all_user_info(self): users = {} for index, (xiaomi_id, info) in enumerate(self.users.items(), start=1): user_id = "user%s" % index users[user_id] = {"uid": xiaomi_id, **info} return users @pytest.fixture(scope="class") def toiletlidv1(request): request.cls.device = DummyToiletlidV1() # TODO add ability to test on a real device @pytest.mark.usefixtures("toiletlidv1") class TestToiletlidV1(TestCase): MOCK_USER = { "11111111": { "mac": "ff:ff:ff:ff:ff:ff", "name": "myband", "rssi": -50, "set": "3-0-2-2-0-0-5-5", } } def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(ToiletlidStatus(self.device.start_state)) assert self.is_on() is False assert self.state().work_state == self.device.start_state["work_state"] assert self.state().ambient_light == self.device.start_state["ambient_light"] assert ( self.state().filter_use_percentage == "%s%%" % self.device.start_state["filter_use_flux"] ) assert ( self.state().filter_remaining_time == self.device.start_state["filter_use_time"] ) def test_set_ambient_light(self): for value, enum in AmbientLightColor._member_map_.items(): self.device.set_ambient_light(enum) assert self.device.status().ambient_light == value def test_nozzle_clean(self): self.device.nozzle_clean() assert self.is_on() is True self.device._reset_state() def test_get_all_user_info(self): users = self.device.get_all_user_info() for _name, info in users.items(): assert info["uid"] in self.MOCK_USER data = self.MOCK_USER[info["uid"]] assert info["name"] == data["name"] assert info["mac"] == data["mac"] def test_bind_xiaomi_band(self): for xiaomi_id, info in self.MOCK_USER.items(): self.device.bind_xiaomi_band(xiaomi_id, info["mac"], info["name"]) assert self.device.users == self.MOCK_USER def test_unbind_xiaomi_band(self): for xiaomi_id, info in self.MOCK_USER.items(): self.device.unbind_xiaomi_band(xiaomi_id, info["mac"]) assert self.device.users == {} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_vacuums.py0000644000000000000000000000445614265350055016212 0ustar00"""Test of vacuum devices.""" from collections.abc import Iterable from typing import List, Sequence, Tuple, Type import pytest from miio.device import Device from miio.integrations.vacuum.roborock.vacuum import ROCKROBO_V1 from miio.interfaces import VacuumInterface # list of all supported vacuum classes VACUUM_CLASSES: Tuple[Type[VacuumInterface], ...] = tuple( cl for cl in VacuumInterface.__subclasses__() # type: ignore ) def _all_vacuum_models() -> Sequence[Tuple[Type[Device], str]]: """:return: list of tuples with supported vacuum models with corresponding class""" result: List[Tuple[Type[Device], str]] = [] for cls in VACUUM_CLASSES: assert issubclass(cls, Device) vacuum_models = cls.supported_models assert isinstance(vacuum_models, Iterable) for model in vacuum_models: result.append((cls, model)) return result # type: ignore @pytest.mark.parametrize("cls, model", _all_vacuum_models()) def test_vacuum_fan_speed_presets(cls: Type[Device], model: str) -> None: """Test method VacuumInterface.fan_speed_presets()""" if model == ROCKROBO_V1: return # this model cannot be tested because presets depends on firmware dev = cls("127.0.0.1", "68ffffffffffffffffffffffffffffff", model=model) assert isinstance(dev, VacuumInterface) presets = dev.fan_speed_presets() assert presets is not None, "presets must be defined" assert bool(presets), "presets cannot be empty" assert isinstance(presets, dict), "presets must be dictionary" for name, value in presets.items(): assert isinstance(name, str), "presets key must be string" assert name, "presets key cannot be empty" assert isinstance(value, int), "presets value must be integer" assert value >= 0, "presets value must be >= 0" @pytest.mark.parametrize("cls, model", _all_vacuum_models()) def test_vacuum_set_fan_speed_presets_fails(cls: Type[Device], model: str) -> None: """Test method VacuumInterface.fan_speed_presets()""" if model == ROCKROBO_V1: return # this model cannot be tested because presets depends on firmware dev = cls("127.0.0.1", "68ffffffffffffffffffffffffffffff", model=model) assert isinstance(dev, VacuumInterface) with pytest.raises(ValueError): dev.set_fan_speed_preset(-1) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_walkingpad.py0000644000000000000000000001424414265350055016644 0ustar00from datetime import timedelta from unittest import TestCase import pytest from miio import Walkingpad from miio.walkingpad import ( OperationMode, OperationSensitivity, WalkingpadException, WalkingpadStatus, ) from .dummies import DummyDevice class DummyWalkingpad(DummyDevice, Walkingpad): def _get_state(self, props): """Return wanted properties.""" # Overriding here to deal with case of 'all' being requested if props[0] == "all": return self.state[props[0]] return [self.state[x] for x in props if x in self.state] def _set_state(self, var, value): """Set a state of a variable, the value is expected to be an array with length of 1.""" # Overriding here to deal with case of 'all' being set if var == "all": self.state[var] = value else: self.state[var] = value.pop(0) def __init__(self, *args, **kwargs): self.state = { "power": "on", "mode": OperationMode.Manual, "time": 1387, "step": 2117, "sensitivity": OperationSensitivity.Low, "dist": 1150, "sp": 3.15, "cal": 71710, "start_speed": 3.1, "all": [ "mode:" + str(OperationMode.Manual.value), "time:1387", "sp:3.15", "dist:1150", "cal:71710", "step:2117", ], } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), "set_mode": lambda x: self._set_state("mode", x), "set_speed": lambda x: ( self._set_state( "all", [ "mode:1", "time:1387", "sp:" + str(x[0]), "dist:1150", "cal:71710", "step:2117", ], ), self._set_state("sp", x), ), "set_step": lambda x: self._set_state("step", x), "set_sensitivity": lambda x: self._set_state("sensitivity", x), "set_start_speed": lambda x: self._set_state("start_speed", x), "set_time": lambda x: self._set_state("time", x), "set_distance": lambda x: self._set_state("dist", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def walkingpad(request): request.cls.device = DummyWalkingpad() @pytest.mark.usefixtures("walkingpad") class TestWalkingpad(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(WalkingpadStatus(self.device.start_state)) assert self.is_on() is True assert self.state().power == self.device.start_state["power"] assert self.state().mode == self.device.start_state["mode"] assert self.state().speed == self.device.start_state["sp"] assert self.state().step_count == self.device.start_state["step"] assert self.state().distance == self.device.start_state["dist"] assert self.state().sensitivity == self.device.start_state["sensitivity"] assert self.state().walking_time == timedelta( seconds=self.device.start_state["time"] ) def test_set_mode(self): def mode(): return self.device.status().mode self.device.set_mode(OperationMode.Auto) assert mode() == OperationMode.Auto self.device.set_mode(OperationMode.Manual) assert mode() == OperationMode.Manual with pytest.raises(WalkingpadException): self.device.set_mode(-1) with pytest.raises(WalkingpadException): self.device.set_mode(3) with pytest.raises(WalkingpadException): self.device.set_mode("blah") def test_set_speed(self): def speed(): return self.device.status().speed self.device.on() self.device.set_speed(3.055) assert speed() == 3.055 with pytest.raises(WalkingpadException): self.device.set_speed(7.6) with pytest.raises(WalkingpadException): self.device.set_speed(-1) with pytest.raises(WalkingpadException): self.device.set_speed("blah") with pytest.raises(WalkingpadException): self.device.off() self.device.set_speed(3.4) def test_set_start_speed(self): def speed(): return self.device.status().start_speed self.device.on() self.device.set_start_speed(3.055) assert speed() == 3.055 with pytest.raises(WalkingpadException): self.device.set_start_speed(7.6) with pytest.raises(WalkingpadException): self.device.set_start_speed(-1) with pytest.raises(WalkingpadException): self.device.set_start_speed("blah") with pytest.raises(WalkingpadException): self.device.off() self.device.set_start_speed(3.4) def test_set_sensitivity(self): def sensitivity(): return self.device.status().sensitivity self.device.set_sensitivity(OperationSensitivity.High) assert sensitivity() == OperationSensitivity.High self.device.set_sensitivity(OperationSensitivity.Medium) assert sensitivity() == OperationSensitivity.Medium with pytest.raises(WalkingpadException): self.device.set_sensitivity(-1) with pytest.raises(WalkingpadException): self.device.set_sensitivity(99) with pytest.raises(WalkingpadException): self.device.set_sensitivity("blah") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_waterpurifier.py0000644000000000000000000000352314265350055017411 0ustar00from unittest import TestCase import pytest from miio import WaterPurifier from miio.waterpurifier import WaterPurifierStatus from .dummies import DummyDevice class DummyWaterPurifier(DummyDevice, WaterPurifier): def __init__(self, *args, **kwargs): self.state = { "power": "on", "mode": "unknown", "tds": "unknown", "filter1_life": -1, "filter1_state": -1, "filter_life": -1, "filter_state": -1, "life": -1, "state": -1, "level": "unknown", "volume": "unknown", "filter": "unknown", "usage": "unknown", "temperature": "unknown", "uv_life": -1, "uv_state": -1, "elecval_state": "unknown", } self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), } super().__init__(args, kwargs) @pytest.fixture(scope="class") def waterpurifier(request): request.cls.device = DummyWaterPurifier() # TODO add ability to test on a real device @pytest.mark.usefixtures("waterpurifier") class TestWaterPurifier(TestCase): def is_on(self): return self.device.status().is_on def state(self): return self.device.status() def test_on(self): self.device.off() # ensure off assert self.is_on() is False self.device.on() assert self.is_on() is True def test_off(self): self.device.on() # ensure on assert self.is_on() is True self.device.off() assert self.is_on() is False def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(WaterPurifierStatus(self.device.start_state)) assert self.is_on() is True ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_wifirepeater.py0000644000000000000000000001353214265350055017210 0ustar00from unittest import TestCase import pytest from miio import WifiRepeater from miio.tests.dummies import DummyDevice from miio.wifirepeater import WifiRepeaterConfiguration, WifiRepeaterStatus class DummyWifiRepeater(DummyDevice, WifiRepeater): def __init__(self, *args, **kwargs): self._model = "xiaomi.repeater.v2" self.state = { "sta": {"count": 2, "access_policy": 0}, "mat": [ { "mac": "aa:aa:aa:aa:aa:aa", "ip": "192.168.1.133", "last_time": 54371873, }, { "mac": "bb:bb:bb:bb:bb:bb", "ip": "192.168.1.156", "last_time": 54371496, }, ], "access_list": {"mac": ""}, } self.config = {"ssid": "SSID", "pwd": "PWD", "hidden": 0} self.device_info = { "life": 543452, "cfg_time": 543452, "token": "ffffffffffffffffffffffffffffffff", "fw_ver": "2.2.14", "hw_ver": "R02", "uid": 1583412143, "api_level": 2, "mcu_fw_ver": "1000", "wifi_fw_ver": "1.0.0", "mac": "FF:FF:FF:FF:FF:FF", "model": "xiaomi.repeater.v2", "ap": { "rssi": -63, "ssid": "SSID", "bssid": "EE:EE:EE:EE:EE:EE", "rx": 136695922, "tx": 1779521233, }, "sta": { "count": 2, "ssid": "REPEATER-SSID", "hidden": 0, "assoclist": "cc:cc:cc:cc:cc:cc;bb:bb:bb:bb:bb:bb;", }, "netif": { "localIp": "192.168.1.170", "mask": "255.255.255.0", "gw": "192.168.1.1", }, "desc": { "wifi_explorer": 1, "sn": "14923 / 20191356", "color": 101, "channel": "release", }, } self.return_values = { "miIO.get_repeater_sta_info": self._get_state, "miIO.get_repeater_ap_info": self._get_configuration, "miIO.switch_wifi_explorer": self._set_wifi_explorer, "miIO.switch_wifi_ssid": self._set_configuration, "miIO.info": self._get_info, } self.start_state = self.state.copy() self.start_config = self.config.copy() self.start_device_info = self.device_info.copy() super().__init__(args, kwargs) def info(self): """This device has custom miIO.info response.""" from miio.deviceinfo import DeviceInfo return DeviceInfo(self.device_info) def _reset_state(self): """Revert back to the original state.""" self.state = self.start_state.copy() self.config = self.start_config.copy() self.device_info = self.start_device_info.copy() def _get_state(self, param): return self.state def _get_configuration(self, param): return self.config def _get_info(self, param): return self.device_info def _set_wifi_explorer(self, data): self.device_info["desc"]["wifi_explorer"] = data[0]["wifi_explorer"] def _set_configuration(self, data): self.config = { "ssid": data[0]["ssid"], "pwd": data[0]["pwd"], "hidden": data[0]["hidden"], } self.device_info["desc"]["wifi_explorer"] = data[0]["wifi_explorer"] return True @pytest.fixture(scope="class") def wifirepeater(request): request.cls.device = DummyWifiRepeater() # TODO add ability to test on a real device @pytest.mark.usefixtures("wifirepeater") class TestWifiRepeater(TestCase): def state(self): return self.device.status() def configuration(self): return self.device.configuration() def info(self): return self.device.info() def wifi_roaming(self): return self.device.wifi_roaming() def rssi_accesspoint(self): return self.device.rssi_accesspoint() def test_status(self): self.device._reset_state() assert repr(self.state()) == repr(WifiRepeaterStatus(self.device.start_state)) assert ( self.state().access_policy == self.device.start_state["sta"]["access_policy"] ) assert self.state().associated_stations == self.device.start_state["mat"] def test_set_wifi_roaming(self): self.device.set_wifi_roaming(True) assert self.wifi_roaming() is True self.device.set_wifi_roaming(False) assert self.wifi_roaming() is False def test_configuration(self): self.device._reset_state() assert repr(self.configuration()) == repr( WifiRepeaterConfiguration(self.device.start_config) ) assert self.configuration().ssid == self.device.start_config["ssid"] assert self.configuration().password == self.device.start_config["pwd"] assert self.configuration().ssid_hidden is ( self.device.start_config["hidden"] == 1 ) def test_set_configuration(self): def configuration(): return self.device.configuration() dummy_configuration = {"ssid": "SSID2", "password": "PASSWORD2", "hidden": True} self.device.set_configuration( dummy_configuration["ssid"], dummy_configuration["password"], dummy_configuration["hidden"], ) assert configuration().ssid == dummy_configuration["ssid"] assert configuration().password == dummy_configuration["password"] assert configuration().ssid_hidden is dummy_configuration["hidden"] def test_rssi_accesspoint(self): self.device._reset_state() assert self.rssi_accesspoint() is self.device.start_device_info["ap"]["rssi"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/tests/test_yeelight_dual_switch.py0000644000000000000000000000632414265350055020723 0ustar00from unittest import TestCase import pytest from miio import YeelightDualControlModule from miio.yeelight_dual_switch import Switch, YeelightDualControlModuleException from .dummies import DummyMiotDevice _INITIAL_STATE = { "switch_1_state": True, "switch_1_default_state": True, "switch_1_off_delay": 300, "switch_2_state": False, "switch_2_default_state": False, "switch_2_off_delay": 0, "interlock": False, "flex_mode": True, "rc_list": "[{'mac':'9db0eb4124f8','evtid':4097,'pid':339,'beaconkey':'3691bc0679eef9596bb63abf'}]", } class DummyYeelightDualControlModule(DummyMiotDevice, YeelightDualControlModule): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, } super().__init__(*args, **kwargs) @pytest.fixture(scope="function") def switch(request): request.cls.device = DummyYeelightDualControlModule() @pytest.mark.usefixtures("switch") class TestYeelightDualControlModule(TestCase): def test_1_on(self): self.device.off(Switch.First) # ensure off assert self.device.status().switch_1_state is False self.device.on(Switch.First) assert self.device.status().switch_1_state is True def test_2_on(self): self.device.off(Switch.Second) # ensure off assert self.device.status().switch_2_state is False self.device.on(Switch.Second) assert self.device.status().switch_2_state is True def test_1_off(self): self.device.on(Switch.First) # ensure on assert self.device.status().switch_1_state is True self.device.off(Switch.First) assert self.device.status().switch_1_state is False def test_2_off(self): self.device.on(Switch.Second) # ensure on assert self.device.status().switch_2_state is True self.device.off(Switch.Second) assert self.device.status().switch_2_state is False def test_status(self): status = self.device.status() assert status.switch_1_state is _INITIAL_STATE["switch_1_state"] assert status.switch_1_off_delay == _INITIAL_STATE["switch_1_off_delay"] assert status.switch_1_default_state == _INITIAL_STATE["switch_1_default_state"] assert status.switch_1_state is _INITIAL_STATE["switch_1_state"] assert status.switch_1_off_delay == _INITIAL_STATE["switch_1_off_delay"] assert status.switch_1_default_state == _INITIAL_STATE["switch_1_default_state"] assert status.interlock == _INITIAL_STATE["interlock"] assert status.flex_mode == _INITIAL_STATE["flex_mode"] assert status.rc_list == _INITIAL_STATE["rc_list"] def test_set_switch_off_delay(self): self.device.set_switch_off_delay(300, Switch.First) assert self.device.status().switch_1_off_delay == 300 self.device.set_switch_off_delay(200, Switch.Second) assert self.device.status().switch_2_off_delay == 200 with pytest.raises(YeelightDualControlModuleException): self.device.set_switch_off_delay(-2, Switch.First) with pytest.raises(YeelightDualControlModuleException): self.device.set_switch_off_delay(43300, Switch.Second) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/toiletlid.py0000644000000000000000000001137014265350055014310 0ustar00import enum import logging from typing import Any, Dict, List import click from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus _LOGGER = logging.getLogger(__name__) MODEL_TOILETLID_V1 = "tinymu.toiletlid.v1" AVAILABLE_PROPERTIES_COMMON = ["work_state", "filter_use_flux", "filter_use_time"] AVAILABLE_PROPERTIES = {MODEL_TOILETLID_V1: AVAILABLE_PROPERTIES_COMMON} class AmbientLightColor(enum.Enum): White = "0" Yellow = "1" Powder = "2" Green = "3" Purple = "4" Blue = "5" Orange = "6" Red = "7" class ToiletlidOperatingMode(enum.Enum): Vacant = 0 Occupied = 1 RearCleanse = 2 FrontCleanse = 3 NozzleClean = 6 class ToiletlidStatus(DeviceStatus): def __init__(self, data: Dict[str, Any]) -> None: # {"work_state": 1,"filter_use_flux": 100,"filter_use_time": 180, "ambient_light": "Red"} self.data = data @property def work_state(self) -> int: """Device state code.""" return self.data["work_state"] @property def work_mode(self) -> ToiletlidOperatingMode: """Device working mode.""" return ToiletlidOperatingMode((self.work_state - 1) // 16) @property def is_on(self) -> bool: return self.work_state != 1 @property def filter_use_percentage(self) -> str: """Filter percentage of remaining life.""" return "{}%".format(self.data["filter_use_flux"]) @property def filter_remaining_time(self) -> int: """Filter remaining life days.""" return self.data["filter_use_time"] @property def ambient_light(self) -> str: """Ambient light color.""" return self.data["ambient_light"] class Toiletlid(Device): """Support for tinymu.toiletlid.v1.""" _supported_models = list(AVAILABLE_PROPERTIES.keys()) @command( default_output=format_output( "", "Work: {result.is_on}\n" "State: {result.work_state}\n" "Work Mode: {result.work_mode}\n" "Ambient Light: {result.ambient_light}\n" "Filter remaining: {result.filter_use_percentage}\n" "Filter remaining time: {result.filter_remaining_time}\n", ) ) def status(self) -> ToiletlidStatus: """Retrieve properties.""" properties = AVAILABLE_PROPERTIES.get( self.model, AVAILABLE_PROPERTIES[MODEL_TOILETLID_V1] ) values = self.get_properties(properties) color = self.get_ambient_light() return ToiletlidStatus(dict(zip(properties, values), ambient_light=color)) @command(default_output=format_output("Nozzle clean")) def nozzle_clean(self): """Nozzle clean.""" return self.send("nozzle_clean", ["on"]) @command( click.argument("color", type=EnumType(AmbientLightColor)), click.argument("xiaomi_id", type=str, default=""), default_output=format_output( "Set the ambient light to {color} color the next time you start it." ), ) def set_ambient_light(self, color: AmbientLightColor, xiaomi_id: str = ""): """Set Ambient light color.""" return self.send("set_aled_v_of_uid", [xiaomi_id, color.value]) @command( click.argument("xiaomi_id", type=str, default=""), default_output=format_output("Get the Ambient light color."), ) def get_ambient_light(self, xiaomi_id: str = "") -> str: """Get Ambient light color.""" color = self.send("get_aled_v_of_uid", [xiaomi_id]) try: return AmbientLightColor(color[0]).name except ValueError: _LOGGER.warning( "Get ambient light response error, return unknown value: %s.", color[0] ) return "Unknown" @command(default_output=format_output("Get user list.")) def get_all_user_info(self) -> List[Dict]: """Get All bind user.""" users = self.send("get_all_user_info") return users @command( click.argument("xiaomi_id", type=str), click.argument("band_mac", type=str), click.argument("alias", type=str), default_output=format_output("Bind xiaomi band to xiaomi id."), ) def bind_xiaomi_band(self, xiaomi_id: str, band_mac: str, alias: str): """Bind xiaomi band to xiaomi id.""" return self.send("uid_mac_op", [xiaomi_id, band_mac, alias, "bind"]) @command( click.argument("xiaomi_id", type=str), click.argument("band_mac", type=str), default_output=format_output("Unbind xiaomi band to xiaomi id."), ) def unbind_xiaomi_band(self, xiaomi_id: str, band_mac: str): """Unbind xiaomi band to xiaomi id.""" return self.send("uid_mac_op", [xiaomi_id, band_mac, "", "unbind"]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/updater.py0000644000000000000000000000635314265350055013770 0ustar00import hashlib import logging from http.server import BaseHTTPRequestHandler, HTTPServer from os.path import basename _LOGGER = logging.getLogger(__name__) class SingleFileHandler(BaseHTTPRequestHandler): """A simplified handler just returning the contents of a buffer.""" def __init__(self, request, client_address, server): self.payload = server.payload self.server = server super().__init__(request, client_address, server) def handle_one_request(self): self.server.got_request = True self.raw_requestline = self.rfile.readline() if not self.parse_request(): _LOGGER.error("unable to parse request: %s" % self.raw_requestline) return self.send_response(200) self.send_header("Content-type", "application/octet-stream") self.send_header("Content-Length", len(self.payload)) self.end_headers() self.wfile.write(self.payload) class OneShotServer: """A simple HTTP server for serving an update file. The server will be started in an emphemeral port, and will only accept a single request to keep it simple. """ def __init__(self, file, interface=None): addr = ("", 0) self.server = HTTPServer(addr, SingleFileHandler) setattr(self.server, "got_request", False) # noqa: B010 self.addr, self.port = self.server.server_address self.server.timeout = 10 _LOGGER.info( f"Serving on {self.addr}:{self.port}, timeout {self.server.timeout}" ) self.file = basename(file) with open(file, "rb") as f: self.payload = f.read() self.server.payload = self.payload self.md5 = hashlib.md5(self.payload).hexdigest() # nosec _LOGGER.info(f"Using local {file} (md5: {self.md5})") @staticmethod def find_local_ip(): try: import netifaces except Exception: _LOGGER.error( "Unable to import netifaces, please install netifaces library" ) raise ifaces_without_lo = [ x for x in netifaces.interfaces() if not x.startswith("lo") ] _LOGGER.debug("available interfaces: %s" % ifaces_without_lo) for iface in ifaces_without_lo: addresses = netifaces.ifaddresses(iface) if netifaces.AF_INET not in addresses: _LOGGER.debug("%s has no ipv4 addresses, skipping" % iface) continue for entry in addresses[netifaces.AF_INET]: _LOGGER.debug("Got addr: %s" % entry["addr"]) return entry["addr"] def url(self, ip=None): if ip is None: ip = OneShotServer.find_local_ip() url = f"http://{ip}:{self.port}/{self.file}" return url def serve_once(self): self.server.handle_request() if getattr(self.server, "got_request"): # noqa: B009 _LOGGER.info("Got a request, should be downloading now.") return True else: _LOGGER.error("No request was made..") return False if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) upd = OneShotServer("/tmp/test") # nosec upd.serve_once() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/utils.py0000644000000000000000000000622614265350055013463 0ustar00import functools import inspect import warnings from datetime import datetime, timedelta from typing import Tuple def deprecated(reason): """This is a decorator which can be used to mark functions and classes as deprecated. It will result in a warning being emitted when the function is used. From https://stackoverflow.com/a/40301488 """ string_types = (bytes, str) if isinstance(reason, string_types): # The @deprecated is used with a 'reason'. # # .. code-block:: python # # @deprecated("please, use another function") # def old_function(x, y): # pass def decorator(func1): if inspect.isclass(func1): fmt1 = "Call to deprecated class {name} ({reason})." else: fmt1 = "Call to deprecated function {name} ({reason})." @functools.wraps(func1) def new_func1(*args, **kwargs): warnings.simplefilter("always", DeprecationWarning) warnings.warn( fmt1.format(name=func1.__name__, reason=reason), category=DeprecationWarning, stacklevel=2, ) warnings.simplefilter("default", DeprecationWarning) return func1(*args, **kwargs) return new_func1 return decorator elif inspect.isclass(reason) or inspect.isfunction(reason): # noqa: SIM106 # The @deprecated is used without any 'reason'. # # .. code-block:: python # # @deprecated # def old_function(x, y): # pass func2 = reason if inspect.isclass(func2): fmt2 = "Call to deprecated class {name}." else: fmt2 = "Call to deprecated function {name}." @functools.wraps(func2) def new_func2(*args, **kwargs): warnings.simplefilter("always", DeprecationWarning) warnings.warn( fmt2.format(name=func2.__name__), category=DeprecationWarning, stacklevel=2, ) warnings.simplefilter("default", DeprecationWarning) return func2(*args, **kwargs) return new_func2 else: raise TypeError(repr(type(reason))) def pretty_seconds(x: float) -> timedelta: """Return a timedelta object from seconds.""" return timedelta(seconds=x) def pretty_time(x: float) -> datetime: """Return a datetime object from unix timestamp.""" return datetime.fromtimestamp(x) def int_to_rgb(x: int) -> Tuple[int, int, int]: """Return a RGB tuple from integer.""" red = (x >> 16) & 0xFF green = (x >> 8) & 0xFF blue = x & 0xFF return red, green, blue def rgb_to_int(x: Tuple[int, int, int]) -> int: """Return an integer from RGB tuple.""" return int(x[0] << 16 | x[1] << 8 | x[2]) def int_to_brightness(x: int) -> int: """Return brightness (0-100) from integer.""" return x >> 24 def brightness_and_color_to_int(brightness: int, color: Tuple[int, int, int]) -> int: return int(brightness << 24 | color[0] << 16 | color[1] << 8 | color[2]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/vacuum.py0000644000000000000000000000046614265350055013623 0ustar00"""This file is just for compat reasons and prints out a deprecated warning when executed.""" import warnings from .integrations.vacuum.roborock.vacuum import * # noqa: F403,F401 warnings.warn( "miio.vacuum module has been renamed to miio.integrations.vacuum.roborock.vacuum", DeprecationWarning, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/walkingpad.py0000644000000000000000000002056514265350055014446 0ustar00import enum import logging from datetime import timedelta from typing import Any, Dict import click from .click_common import EnumType, command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) class WalkingpadException(DeviceException): pass class OperationMode(enum.Enum): Auto = 0 Manual = 1 Off = 2 class OperationSensitivity(enum.Enum): High = 1 Medium = 2 Low = 3 class WalkingpadStatus(DeviceStatus): """Container for status reports from Xiaomi Walkingpad A1 (ksmb.walkingpad.v3). Input data dictionary to initialise this class: {'cal': 6130, 'dist': 90, 'mode': 1, 'power': 'on', 'sensitivity': 1, 'sp': 3.0, 'start_speed': 3.0, 'step': 180, 'time': 121} """ def __init__(self, data: Dict[str, Any]) -> None: self.data = data @property def power(self) -> str: """Power state.""" return self.data["power"] @property def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property def walking_time(self) -> timedelta: """Current walking duration in seconds.""" return timedelta(seconds=int(self.data["time"])) @property def speed(self) -> float: """Current speed.""" return float(self.data["sp"]) @property def start_speed(self) -> float: """Current start speed.""" return self.data["start_speed"] @property def mode(self) -> OperationMode: """Current mode.""" return OperationMode(self.data["mode"]) @property def sensitivity(self) -> OperationSensitivity: """Current sensitivity.""" return OperationSensitivity(self.data["sensitivity"]) @property def step_count(self) -> int: """Current steps.""" return int(self.data["step"]) @property def distance(self) -> int: """Current distance in meters.""" return int(self.data["dist"]) @property def calories(self) -> int: """Current calories burnt.""" return int(self.data["cal"]) class Walkingpad(Device): """Main class representing Xiaomi Walkingpad.""" _supported_models = ["ksmb.walkingpad.v3"] @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode.name}\n" "Time: {result.walking_time}\n" "Steps: {result.step_count}\n" "Speed: {result.speed}\n" "Start Speed: {result.start_speed}\n" "Sensitivity: {result.sensitivity.name}\n" "Distance: {result.distance}\n" "Calories: {result.calories}", ) ) def status(self) -> WalkingpadStatus: """Retrieve properties.""" data = self._get_quick_status() # The quick status only retrieves a subset of the properties. The rest of them are retrieved here. properties_additional = ["power", "mode", "start_speed", "sensitivity"] values_additional = self.get_properties(properties_additional, max_properties=1) additional_props = dict(zip(properties_additional, values_additional)) data.update(additional_props) return WalkingpadStatus(data) @command( default_output=format_output( "", "Mode: {result.mode.name}\n" "Walking time: {result.walking_time}\n" "Steps: {result.step_count}\n" "Speed: {result.speed}\n" "Distance: {result.distance}\n" "Calories: {result.calories}", ) ) def quick_status(self) -> WalkingpadStatus: """Retrieve quick status. The walkingpad provides the option to retrieve a subset of properties in one call: steps, mode, speed, distance, calories and time. `status()` will do four more separate I/O requests for power, mode, start_speed, and sensitivity. If you don't need any of that, prefer this method for status updates. """ data = self._get_quick_status() return WalkingpadStatus(data) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) @command(default_output=format_output("Locking")) def lock(self): """Lock device.""" return self.send("set_lock", [1]) @command(default_output=format_output("Unlocking")) def unlock(self): """Unlock device.""" return self.send("set_lock", [0]) @command(default_output=format_output("Starting the treadmill")) def start(self): """Start the treadmill.""" # In case the treadmill is not already turned on, turn it on. if not self.status().is_on: self.on() return self.send("set_state", ["run"]) @command(default_output=format_output("Stopping the treadmill")) def stop(self): """Stop the treadmill.""" return self.send("set_state", ["stop"]) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.name}'"), ) def set_mode(self, mode: OperationMode): """Set mode (auto/manual).""" if not isinstance(mode, OperationMode): raise WalkingpadException("Invalid mode: %s" % mode) return self.send("set_mode", [mode.value]) @command( click.argument("speed", type=float), default_output=format_output("Setting speed to {speed}"), ) def set_speed(self, speed: float): """Set speed.""" # In case the treadmill is not already turned on, throw an exception. if not self.status().is_on: raise WalkingpadException("Cannot set the speed, device is turned off") if not isinstance(speed, float): raise WalkingpadException("Invalid speed: %s" % speed) if speed < 0 or speed > 6: raise WalkingpadException("Invalid speed: %s" % speed) return self.send("set_speed", [speed]) @command( click.argument("speed", type=float), default_output=format_output("Setting start speed to {speed}"), ) def set_start_speed(self, speed: float): """Set start speed.""" # In case the treadmill is not already turned on, throw an exception. if not self.status().is_on: raise WalkingpadException( "Cannot set the start speed, device is turned off" ) if not isinstance(speed, float): raise WalkingpadException("Invalid start speed: %s" % speed) if speed < 0 or speed > 6: raise WalkingpadException("Invalid start speed: %s" % speed) return self.send("set_start_speed", [speed]) @command( click.argument("sensitivity", type=EnumType(OperationSensitivity)), default_output=format_output("Setting sensitivity to {sensitivity}"), ) def set_sensitivity(self, sensitivity: OperationSensitivity): """Set sensitivity.""" if not isinstance(sensitivity, OperationSensitivity): raise WalkingpadException("Invalid mode: %s" % sensitivity) return self.send("set_sensitivity", [sensitivity.value]) def _get_quick_status(self): """Internal helper to get the quick status via the "all" property.""" # Walkingpad A1 allows you to quickly retrieve a subset of values with "all" # all other properties need to be retrieved one by one and are therefore slower # eg ['mode:1', 'time:1387', 'sp:3.0', 'dist:1150', 'cal:71710', 'step:2117'] properties = ["all"] values = self.get_properties(properties, max_properties=1) value_map = { "sp": float, "step": int, "cal": int, "time": int, "dist": int, "mode": int, } data = {} for x in values: prop, value = x.split(":") if prop not in value_map: _LOGGER.warning("Received unknown data from device: %s=%s", prop, value) data[prop] = value converted_data = {key: value_map[key](value) for key, value in data.items()} return converted_data ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/waterpurifier.py0000644000000000000000000001012614265350055015205 0ustar00import logging from typing import Any, Dict from .click_common import command, format_output from .device import Device, DeviceStatus _LOGGER = logging.getLogger(__name__) class WaterPurifierStatus(DeviceStatus): """Container for status reports from the water purifier.""" def __init__(self, data: Dict[str, Any]) -> None: self.data = data @property def power(self) -> str: return self.data["power"] @property def is_on(self) -> bool: return self.power == "on" @property def mode(self) -> str: """Current operation mode.""" return self.data["mode"] @property def tds(self) -> str: return self.data["tds"] @property def filter_life_remaining(self) -> int: """Time until the filter should be changed.""" return self.data["filter1_life"] @property def filter_state(self) -> str: return self.data["filter1_state"] @property def filter2_life_remaining(self) -> int: """Time until the filter should be changed.""" return self.data["filter_life"] @property def filter2_state(self) -> str: return self.data["filter_state"] @property def life(self) -> str: return self.data["life"] @property def state(self) -> str: return self.data["state"] @property def level(self) -> str: return self.data["level"] @property def volume(self) -> str: return self.data["volume"] @property def filter(self) -> str: return self.data["filter"] @property def usage(self) -> str: return self.data["usage"] @property def temperature(self) -> str: return self.data["temperature"] @property def uv_filter_life_remaining(self) -> int: """Time until the filter should be changed.""" return self.data["uv_life"] @property def uv_filter_state(self) -> str: return self.data["uv_state"] @property def valve(self) -> str: return self.data["elecval_state"] class WaterPurifier(Device): """Main class representing the water purifier.""" _supported_models = [ "yunmi.waterpuri.v2", # unknown if correct, based on mdns response ] @command( default_output=format_output( "", "Power: {result.power}\n" "Mode: {result.mode}\n" "TDS: {result.tds}\n" "Filter life remaining: {result.filter_life_remaining}\n" "Filter state: {result.filter_state}\n" "Filter2 life remaining: {result.filter2_life_remaining}\n" "Filter2 state: {result.filter2_state}\n" "Life remaining: {result.life_remaining}\n" "State: {result.state}\n" "Level: {result.level}\n" "Volume: {result.volume}\n" "Filter: {result.filter}\n" "Usage: {result.usage}\n" "Temperature: {result.temperature}\n" "UV filter life remaining: {result.uv_filter_life_remaining}\n" "UV filter state: {result.uv_filter_state}\n" "Valve: {result.valve}\n", ) ) def status(self) -> WaterPurifierStatus: """Retrieve properties.""" properties = [ "power", "mode", "tds", "filter1_life", "filter1_state", "filter_life", "filter_state", "life", "state", "level", "volume", "filter", "usage", "temperature", "uv_life", "uv_state", "elecval_state", ] _props_per_request = 1 values = self.get_properties(properties, max_properties=_props_per_request) return WaterPurifierStatus(dict(zip(properties, values))) @command(default_output=format_output("Powering on")) def on(self): """Power on.""" return self.send("set_power", ["on"]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" return self.send("set_power", ["off"]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/waterpurifier_yunmi.py0000644000000000000000000002605414265350055016435 0ustar00import logging from datetime import timedelta from typing import Any, Dict, List from .click_common import command, format_output from .device import Device, DeviceStatus _LOGGER = logging.getLogger(__name__) SUPPORTED_MODELS = ["yunmi.waterpuri.lx9", "yunmi.waterpuri.lx11"] ERROR_DESCRIPTION = [ { "name": "Water temperature anomaly", "advice": "Check if inlet water temperature is among 5~38℃.", }, { "name": "Inlet water flow meter damaged", "advice": "Try to purify water again after reinstalling the filter for serval times.", }, { "name": "Water flow sensor anomaly", "advice": "Check if the water pressure is too low.", }, {"name": "Filter life expired", "advice": "Replace filter."}, {"name": "WiFi communication error", "advice": "Contact the after-sales."}, {"name": "EEPROM communication error", "advice": "Contact the after-sales."}, {"name": "RFID communication error", "advice": "Contact the after-sales."}, { "name": "Faucet communication error", "advice": "Try to plug in the faucet again.", }, { "name": "Purified water flow sensor anomaly", "advice": "Check whether all filters are properly installed and water pressure is normal.", }, { "name": "Water leak", "advice": "Check if there is water leaking around the water purifier.", }, {"name": "Floater anomaly", "advice": "Contact the after-sales."}, {"name": "TDS anomaly", "advice": "Check if the RO filter is expired."}, { "name": "Water temperature too high", "advice": "Check if inlet water is warm water with temperature above 40℃.", }, { "name": "Recovery rate anomaly", "advice": "Check if the waste water pipe works abnormally and the RO filter is expired.", }, { "name": "Outlet water quality anomaly", "advice": "Check if the waste water pipe works abnormally and the RO filter is expired.", }, { "name": "Thermal protection for pumps", "advice": "The water purifier has worked for a long time, please use it after 20 minutes.", }, { "name": "Dry burning protection", "advice": "Check if the inlet water pipe works abnormally.", }, { "name": "Outlet water NTC anomaly", "advice": "Switch off the purifier and restart it again.", }, { "name": "Dry burning NTC anomaly", "advice": "Switch off the purifier and restart it again.", }, { "name": "Heater anomaly", "advice": "Switch off the purifier and restart it again.", }, ] class OperationStatus(DeviceStatus): def __init__(self, operation_status: int): """Operation status parser. Return value of operation_status: We should convert the operation_status code to binary, each bit from LSB to MSB represents one error. It's able to cover multiple errors. Example operation_status value: 9 (binary: 1001) Thus, the purifier reports 2 errors, stands bit 0 and bit 3, means "Water temperature anomaly" and "Filter life expired". """ self.err_list = [ ERROR_DESCRIPTION[i] for i in range(0, len(ERROR_DESCRIPTION)) if (1 << i) & operation_status ] @property def errors(self) -> List: return self.err_list class WaterPurifierYunmiStatus(DeviceStatus): """Container for status reports from the water purifier (Yunmi model).""" def __init__(self, data: Dict[str, Any]) -> None: """Status of a Water Purifier C1 (yummi.waterpuri.lx11): [0, 7200, 8640, 520, 379, 7200, 17280, 2110, 4544, 80, 4, 0, 31, 100, 7200, 8640, 1440, 3313] Parsed by WaterPurifierYunmi device as: {'run_status': 0, 'filter1_flow_total': 7200, 'filter1_life_total': 8640, 'filter1_flow_used': 520, 'filter1_life_used': 379, 'filter2_flow_total': 7200, 'filter2_life_total': 17280, 'filter2_flow_used': 2110, 'filter2_life_used': 4544, 'tds_in': 80, 'tds_out': 4, 'rinse': 0, 'temperature': 31, 'tds_warn_thd': 100, 'filter3_flow_total': 7200, 'filter3_life_total': 8640, 'filter3_flow_used': 1440, 'filter3_life_used': 3313} """ self.data = data @property def operation_status(self) -> OperationStatus: """Current operation status.""" return OperationStatus(self.data["run_status"]) @property def filter1_life_total(self) -> timedelta: """Filter1 total available time in hours.""" return timedelta(hours=self.data["f1_totaltime"]) @property def filter1_life_used(self) -> timedelta: """Filter1 used time in hours.""" return timedelta(hours=self.data["f1_usedtime"]) @property def filter1_life_remaining(self) -> timedelta: """Filter1 remaining time in hours.""" return self.filter1_life_total - self.filter1_life_used @property def filter1_flow_total(self) -> int: """Filter1 total available flow in Metric Liter (L).""" return self.data["f1_totalflow"] @property def filter1_flow_used(self) -> int: """Filter1 used flow in Metric Liter (L).""" return self.data["f1_usedflow"] @property def filter1_flow_remaining(self) -> int: """Filter1 remaining flow in Metric Liter (L).""" return self.filter1_flow_total - self.filter1_flow_used @property def filter2_life_total(self) -> timedelta: """Filter2 total available time in hours.""" return timedelta(hours=self.data["f2_totaltime"]) @property def filter2_life_used(self) -> timedelta: """Filter2 used time in hours.""" return timedelta(hours=self.data["f2_usedtime"]) @property def filter2_life_remaining(self) -> timedelta: """Filter2 remaining time in hours.""" return self.filter2_life_total - self.filter2_life_used @property def filter2_flow_total(self) -> int: """Filter2 total available flow in Metric Liter (L).""" return self.data["f2_totalflow"] @property def filter2_flow_used(self) -> int: """Filter2 used flow in Metric Liter (L).""" return self.data["f2_usedflow"] @property def filter2_flow_remaining(self) -> int: """Filter2 remaining flow in Metric Liter (L).""" return self.filter2_flow_total - self.filter2_flow_used @property def filter3_life_total(self) -> timedelta: """Filter3 total available time in hours.""" return timedelta(hours=self.data["f3_totaltime"]) @property def filter3_life_used(self) -> timedelta: """Filter3 used time in hours.""" return timedelta(hours=self.data["f3_usedtime"]) @property def filter3_life_remaining(self) -> timedelta: """Filter3 remaining time in hours.""" return self.filter3_life_total - self.filter3_life_used @property def filter3_flow_total(self) -> int: """Filter3 total available flow in Metric Liter (L).""" return self.data["f3_totalflow"] @property def filter3_flow_used(self) -> int: """Filter3 used flow in Metric Liter (L).""" return self.data["f3_usedflow"] @property def filter3_flow_remaining(self) -> int: """Filter1 remaining flow in Metric Liter (L).""" return self.filter3_flow_total - self.filter3_flow_used @property def tds_in(self) -> int: """TDS value of input water.""" return self.data["tds_in"] @property def tds_out(self) -> int: """TDS value of output water.""" return self.data["tds_out"] @property def rinse(self) -> bool: """True if the device is rinsing.""" return self.data["rinse"] @property def temperature(self) -> int: """Current water temperature in Celsius.""" return self.data["temperature"] @property def tds_warn_thd(self) -> int: """TDS warning threshold.""" return self.data["tds_warn_thd"] class WaterPurifierYunmi(Device): """Main class representing the water purifier (Yunmi model).""" _supported_models = SUPPORTED_MODELS @command( default_output=format_output( "", "Operaton status: {result.operation_status}\n" "Filter1 total time: {result.filter1_life_total}\n" "Filter1 used time: {result.filter1_life_used}\n" "Filter1 remaining time: {result.filter1_life_remaining}\n" "Filter1 total flow: {result.filter1_flow_total} L\n" "Filter1 used flow: {result.filter1_flow_used} L\n" "Filter1 remaining flow: {result.filter1_flow_remaining} L\n" "Filter2 total time: {result.filter2_life_total}\n" "Filter2 used time: {result.filter2_life_used}\n" "Filter2 remaining time: {result.filter2_life_remaining}\n" "Filter2 total flow: {result.filter2_flow_total} L\n" "Filter2 used flow: {result.filter2_flow_used} L\n" "Filter2 remaining flow: {result.filter2_flow_remaining} L\n" "Filter3 total time: {result.filter3_life_total}\n" "Filter3 used time: {result.filter3_life_used}\n" "Filter3 remaining time: {result.filter3_life_remaining}\n" "Filter3 total flow: {result.filter3_flow_total} L\n" "Filter3 used flow: {result.filter3_flow_used} L\n" "Filter3 remaining flow: {result.filter3_flow_remaining} L\n" "TDS in: {result.tds_in}\n" "TDS out: {result.tds_out}\n" "Rinsing: {result.rinse}\n" "Temperature: {result.temperature} ℃\n" "TDS warning threshold: {result.tds_warn_thd}\n", ) ) def status(self) -> WaterPurifierYunmiStatus: """Retrieve properties.""" properties = [ "run_status", "f1_totalflow", "f1_totaltime", "f1_usedflow", "f1_usedtime", "f2_totalflow", "f2_totaltime", "f2_usedflow", "f2_usedtime", "tds_in", "tds_out", "rinse", "temperature", "tds_warn_thd", "f3_totalflow", "f3_totaltime", "f3_usedflow", "f3_usedtime", ] """ Some models doesn't support a list of properties, while fetching them one per time usually runs into "ack timeout" error. Thus fetch them all at one time. Key "mode" (always 'purifying') and key "tds_out_avg" (always 0) are not included in return values. """ # noqa: B018 values = self.send("get_prop", ["all"]) prop_count = len(properties) val_count = len(values) if prop_count != val_count: _LOGGER.debug( "Count (%s) of requested properties does not match the " "count (%s) of received values.", prop_count, val_count, ) return WaterPurifierYunmiStatus(dict(zip(properties, values))) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/wifirepeater.py0000644000000000000000000001026414265350055015006 0ustar00import logging import click from .click_common import command, format_output from .device import Device, DeviceStatus from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) class WifiRepeaterException(DeviceException): pass class WifiRepeaterStatus(DeviceStatus): def __init__(self, data): """ Response of a xiaomi.repeater.v2: { 'sta': {'count': 2, 'access_policy': 0}, 'mat': [ {'mac': 'aa:aa:aa:aa:aa:aa', 'ip': '192.168.1.133', 'last_time': 54371873}, {'mac': 'bb:bb:bb:bb:bb:bb', 'ip': '192.168.1.156', 'last_time': 54371496} ], 'access_list': {'mac': ''} } """ self.data = data @property def access_policy(self) -> int: """Access policy of the associated stations.""" return self.data["sta"]["access_policy"] @property def associated_stations(self) -> dict: """List of associated stations.""" return self.data["mat"] def __repr__(self) -> str: s = "" % ( self.access_policy, len(self.associated_stations), ) return s class WifiRepeaterConfiguration(DeviceStatus): def __init__(self, data): """Response of a xiaomi.repeater.v2: {'ssid': 'SSID', 'pwd': 'PWD', 'hidden': 0} """ self.data = data @property def ssid(self) -> str: return self.data["ssid"] @property def password(self) -> str: return self.data["pwd"] @property def ssid_hidden(self) -> bool: return self.data["hidden"] == 1 class WifiRepeater(Device): """Device class for Xiaomi Mi WiFi Repeater 2.""" _supported_models = ["xiaomi.repeater.v2"] @command( default_output=format_output( "", "Access policy: {result.access_policy}\n" "Associated stations: {result.associated_stations}\n", ) ) def status(self) -> WifiRepeaterStatus: """Return the associated stations.""" return WifiRepeaterStatus(self.send("miIO.get_repeater_sta_info")) @command( default_output=format_output( "", "SSID: {result.ssid}\n" "Password: {result.password}\n" "SSID hidden: {result.ssid_hidden}\n", ) ) def configuration(self) -> WifiRepeaterConfiguration: """Return the configuration of the accesspoint.""" return WifiRepeaterConfiguration(self.send("miIO.get_repeater_ap_info")) @command( click.argument("wifi_roaming", type=bool), default_output=format_output( lambda led: "Turning on WiFi roaming" if led else "Turning off WiFi roaming" ), ) def set_wifi_roaming(self, wifi_roaming: bool): """Turn the WiFi roaming on/off.""" return self.send( "miIO.switch_wifi_explorer", [{"wifi_explorer": int(wifi_roaming)}] ) @command( click.argument("ssid", type=str), click.argument("password", type=str), click.argument("ssid_hidden", type=bool), default_output=format_output("Setting accesspoint configuration"), ) def set_configuration(self, ssid: str, password: str, ssid_hidden: bool = False): """Update the configuration of the accesspoint.""" return self.send( "miIO.switch_wifi_ssid", [ { "ssid": ssid, "pwd": password, "hidden": int(ssid_hidden), "wifi_explorer": 0, } ], ) @command( default_output=format_output( lambda result: "WiFi roaming is enabled" if result else "WiFi roaming is disabled" ) ) def wifi_roaming(self) -> bool: """Return the roaming setting.""" return self.info().raw["desc"]["wifi_explorer"] == 1 @command(default_output=format_output("RSSI of the accesspoint: {result}")) def rssi_accesspoint(self) -> int: """Received signal strength indicator of the accesspoint.""" return self.info().accesspoint["rssi"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/wifispeaker.py0000644000000000000000000001152214265350055014627 0ustar00import enum import logging import click from .click_common import command, format_output from .device import Device, DeviceStatus _LOGGER = logging.getLogger(__name__) class PlayState(enum.Enum): Playing = "PLAYING" Stopped = "STOPPED" Paused = "PAUSED_PLAYBACK" NoMedia = "NO_MEDIA_PRESENT" Transitioning = "TRANSITIONING" class TransportChannel(enum.Enum): Playlist = "PLAYLIST" OneTime = "ONETIME" Auxiliary = "AUX" Bluetooth = "BT" Radio = "RADIO" Air = "AIR" Qplay = "QPLAY" class WifiSpeakerStatus(DeviceStatus): """Container of a speaker state. This contains information such as the name of the device, and what is currently being played by it. """ def __init__(self, data): """Example response of a xiaomi.wifispeaker.v2: {"DeviceName": "Mi Internet Speaker", "channel_title\": "XXX", "current_state": "PLAYING", "hardware_version": "S602", "play_mode": "REPEAT_ALL", "track_artist": "XXX", "track_duration": "00:04:58", "track_title": "XXX", "transport_channel": "PLAYLIST"} """ self.data = data @property def device_name(self) -> str: """Name of the device.""" return self.data["DeviceName"] @property def channel(self) -> str: """Name of the channel.""" return self.data["channel_title"] @property def state(self) -> PlayState: """State of the device, e.g. PLAYING.""" return PlayState(self.data["current_state"]) @property def hardware_version(self) -> str: return self.data["hardware_version"] @property def play_mode(self): """Play mode such as REPEAT_ALL.""" # note: this can be enumized when all values are known return self.data["play_mode"] @property def track_artist(self) -> str: """Artist of the current track.""" return self.data["track_artist"] @property def track_title(self) -> str: """Title of the current track.""" return self.data["track_title"] @property def track_duration(self) -> str: """Total duration of the current track.""" return self.data["track_duration"] @property def transport_channel(self) -> TransportChannel: """Transport channel, e.g. PLAYLIST.""" return TransportChannel(self.data["transport_channel"]) class WifiSpeaker(Device): """Device class for Xiaomi Smart Wifi Speaker.""" _supported_models = ["xiaomi.wifispeaker.v2"] @command( default_output=format_output( "", "Device name: {result.device_name}\n" "Channel: {result.channel}\n" "State: {result.state}\n" "Play mode: {result.play_mode}\n" "Track artist: {result.track_artist}\n" "Track title: {result.track_title}\n" "Track duration: {result.track_duration}\n" "Transport channel: {result.transport_channel}\n" "Hardware version: {result.hardware_version}\n", ) ) def status(self) -> WifiSpeakerStatus: """Return device status.""" return WifiSpeakerStatus(self.send("get_prop", ["umi"])) @command(default_output=format_output("Powering on")) def power(self): """Toggle power on and off.""" # is this a toggle? return self.send("power") @command(default_output=format_output("Toggling play")) def toggle(self): """Toggle play.""" return self.send("toggle") @command( click.argument("amount", type=int), default_output=format_output("Increasing volume by {amount} percent"), ) def volume_up(self, amount: int = 5): """Set volume up.""" return self.send("vol_up", [amount]) @command( click.argument("amount", type=int), default_output=format_output("Decreasing volume by {amount} percent"), ) def volume_down(self, amount: int = 5): """Set volume down.""" return self.send("vol_down", [amount]) @command(default_output=format_output("Playing previous track")) def track_previous(self): """Move to previous track.""" return self.send("previous_track") @command(default_output=format_output("Playing next track")) def track_next(self): """Move to next track.""" return self.send("next_track") @command(default_output=format_output("Switching to the next transport channel")) def channel_next(self): """Change transport channel.""" return self.send("next_channel") @command(default_output=format_output("Track position: {result.rel_time}")) def track_position(self): """Return current track position.""" return self.send("get_prop", ["rel_time"]) def volume(self): """Speaker volume.""" return self.send("get_prop", ["volume"]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/miio/yeelight_dual_switch.py0000644000000000000000000002070314265350055016517 0ustar00import enum from typing import Any, Dict import click from .click_common import EnumType, command, format_output from .exceptions import DeviceException from .miot_device import DeviceStatus, MiotDevice, MiotMapping class YeelightDualControlModuleException(DeviceException): pass class Switch(enum.Enum): First = 0 Second = 1 _MAPPINGS: MiotMapping = { "yeelink.switch.sw1": { # http://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:switch:0000A003:yeelink-sw1:1:0000C809 # First Switch (siid=2) "switch_1_state": {"siid": 2, "piid": 1}, # bool "switch_1_default_state": {"siid": 2, "piid": 2}, # 0 - Off, 1 - On "switch_1_off_delay": { "siid": 2, "piid": 3, }, # -1 - Off, [1, 43200] - delay in sec # Second Switch (siid=3) "switch_2_state": {"siid": 3, "piid": 1}, # bool "switch_2_default_state": {"siid": 3, "piid": 2}, # 0 - Off, 1 - On "switch_2_off_delay": { "siid": 3, "piid": 3, }, # -1 - Off, [1, 43200] - delay in sec # Extensions (siid=4) "interlock": {"siid": 4, "piid": 1}, # bool "flex_mode": {"siid": 4, "piid": 2}, # 0 - Off, 1 - On "rc_list": {"siid": 4, "piid": 3}, # string "rc_list_for_del": {"siid": 4, "piid": 4}, # string "toggle": {"siid": 4, "piid": 5}, # 0 - First switch, 1 - Second switch } } class DualControlModuleStatus(DeviceStatus): def __init__(self, data: Dict[str, Any]) -> None: """ Response of Yeelight Dual Control Module { 'id': 1, 'result': [ {'did': 'switch_1_state', 'siid': 2, 'piid': 1, 'code': 0, 'value': False}, {'did': 'switch_1_default_state', 'siid': 2, 'piid': 2, 'code': 0, 'value': True}, {'did': 'switch_1_off_delay', 'siid': 2, 'piid': 3, 'code': 0, 'value': 300}, {'did': 'switch_2_state', 'siid': 3, 'piid': 1, 'code': 0, 'value': False}, {'did': 'switch_2_default_state', 'siid': 3, 'piid': 2, 'code': 0, 'value': False}, {'did': 'switch_2_off_delay', 'siid': 3, 'piid': 3, 'code': 0, 'value': 0}, {'did': 'interlock', 'siid': 4, 'piid': 1, 'code': 0, 'value': False}, {'did': 'flex_mode', 'siid': 4, 'piid': 2, 'code': 0, 'value': True}, {'did': 'rc_list', 'siid': 4, 'piid': 2, 'code': 0, 'value': '[{"mac":"9db0eb4124f8","evtid":4097,"pid":339,"beaconkey":"3691bc0679eef9596bb63abf"}]'}, ] } """ self.data = data @property def switch_1_state(self) -> bool: """First switch state.""" return bool(self.data["switch_1_state"]) @property def switch_1_default_state(self) -> bool: """First switch default state.""" return bool(self.data["switch_1_default_state"]) @property def switch_1_off_delay(self) -> int: """First switch off delay.""" return self.data["switch_1_off_delay"] @property def switch_2_state(self) -> bool: """Second switch state.""" return bool(self.data["switch_2_state"]) @property def switch_2_default_state(self) -> bool: """Second switch default state.""" return bool(self.data["switch_2_default_state"]) @property def switch_2_off_delay(self) -> int: """Second switch off delay.""" return self.data["switch_2_off_delay"] @property def interlock(self) -> bool: """Interlock.""" return bool(self.data["interlock"]) @property def flex_mode(self) -> int: """Flex mode.""" return self.data["flex_mode"] @property def rc_list(self) -> str: """List of paired remote controls.""" return self.data["rc_list"] class YeelightDualControlModule(MiotDevice): """Main class representing the Yeelight Dual Control Module (yeelink.switch.sw1) which uses MIoT protocol.""" _mappings = _MAPPINGS @command( default_output=format_output( "", "First Switch Status: {result.switch_1_state}\n" "First Switch Default State: {result.switch_1_default_state}\n" "First Switch Delay: {result.switch_1_off_delay}\n" "Second Switch Status: {result.switch_2_state}\n" "Second Switch Default State: {result.switch_2_default_state}\n" "Second Switch Delay: {result.switch_2_off_delay}\n" "Interlock: {result.interlock}\n" "Flex Mode: {result.flex_mode}\n" "RC list: {result.rc_list}\n", ) ) def status(self) -> DualControlModuleStatus: """Retrieve properties.""" p = [ "switch_1_state", "switch_1_default_state", "switch_1_off_delay", "switch_2_state", "switch_2_default_state", "switch_2_off_delay", "interlock", "flex_mode", "rc_list", ] # Filter only readable properties for status properties = [ {"did": k, **v} for k, v in filter(lambda item: item[0] in p, self._get_mapping().items()) ] values = self.get_properties(properties) return DualControlModuleStatus( dict(map(lambda v: (v["did"], v["value"]), values)) ) @command( click.argument("switch", type=EnumType(Switch)), default_output=format_output("Turn {switch} switch on"), ) def on(self, switch: Switch): """Turn switch on.""" if switch == Switch.First: return self.set_property("switch_1_state", True) elif switch == Switch.Second: return self.set_property("switch_2_state", True) @command( click.argument("switch", type=EnumType(Switch)), default_output=format_output("Turn {switch} switch off"), ) def off(self, switch: Switch): """Turn switch off.""" if switch == Switch.First: return self.set_property("switch_1_state", False) elif switch == Switch.Second: return self.set_property("switch_2_state", False) @command( click.argument("switch", type=EnumType(Switch)), default_output=format_output("Toggle {switch} switch"), ) def toggle(self, switch: Switch): """Toggle switch.""" return self.set_property("toggle", switch.value) @command( click.argument("state", type=bool), click.argument("switch", type=EnumType(Switch)), default_output=format_output("Set {switch} switch default state to: {state}"), ) def set_default_state(self, state: bool, switch: Switch): """Set switch default state.""" if switch == Switch.First: return self.set_property("switch_1_default_state", int(state)) elif switch == Switch.Second: return self.set_property("switch_2_default_state", int(state)) @command( click.argument("delay", type=int), click.argument("switch", type=EnumType(Switch)), default_output=format_output("Set {switch} switch off delay to {delay} sec."), ) def set_switch_off_delay(self, delay: int, switch: Switch): """Set switch off delay, should be between -1 to 43200 (in seconds)""" if delay < -1 or delay > 43200: raise YeelightDualControlModuleException( "Invalid switch delay: %s (should be between -1 to 43200)" % delay ) if switch == Switch.First: return self.set_property("switch_1_off_delay", delay) elif switch == Switch.Second: return self.set_property("switch_2_off_delay", delay) @command( click.argument("flex_mode", type=bool), default_output=format_output("Set flex mode to: {flex_mode}"), ) def set_flex_mode(self, flex_mode: bool): """Set flex mode.""" return self.set_property("flex_mode", int(flex_mode)) @command( click.argument("rc_mac", type=str), default_output=format_output("Delete remote control with MAC: {rc_mac}"), ) def delete_rc(self, rc_mac: str): """Delete remote control by MAC.""" return self.set_property("rc_list_for_del", rc_mac) @command( click.argument("interlock", type=bool), default_output=format_output("Set interlock to: {interlock}"), ) def set_interlock(self, interlock: bool): """Set interlock.""" return self.set_property("interlock", interlock) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179628.5386136 python-miio-0.5.12/pyproject.toml0000644000000000000000000000524314265350055013726 0ustar00[tool.poetry] name = "python-miio" version = "0.5.12" description = "Python library for interfacing with Xiaomi smart appliances" authors = ["Teemu R "] repository = "https://github.com/rytilahti/python-miio" documentation = "https://python-miio.readthedocs.io" license = "GPL-3.0-only" readme = "README.rst" packages = [ { include = "miio" } ] keywords = ["xiaomi", "miio", "miot", "smart home"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Operating System :: OS Independent", "Topic :: System :: Hardware", "Topic :: Home Automation" ] [tool.poetry.scripts] mirobo = "miio.integrations.vacuum.roborock.vacuum_cli:cli" miio-extract-tokens = "miio.extract_tokens:main" miiocli = "miio.cli:create_cli" [tool.poetry.dependencies] python = "^3.7" click = ">=8" cryptography = ">=35" construct = "^2.10.56" zeroconf = "^0" attrs = "*" pytz = "*" appdirs = "^1" tqdm = "^4" netifaces = { version = "^0", optional = true } android_backup = { version = "^0", optional = true } micloud = { version = "*", optional = true } importlib_metadata = { version = "^1", markers = "python_version <= '3.7'" } croniter = ">=1" defusedxml = "^0" sphinx = { version = ">=4.2", optional = true } sphinx_click = { version = "*", optional = true } sphinxcontrib-apidoc = { version = "^0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } PyYAML = ">=5,<7" [tool.poetry.extras] docs = ["sphinx", "sphinx_click", "sphinxcontrib-apidoc", "sphinx_rtd_theme"] [tool.poetry.dev-dependencies] pytest = ">=6.2.5" pytest-cov = "^2" pytest-mock = "^3" voluptuous = "^0" pre-commit = "^2" doc8 = "^0" restructuredtext_lint = "^1" tox = "^3" isort = "^4" cffi = "^1" docformatter = "^1" mypy = {version = "^0", markers = "platform_python_implementation == 'CPython'"} coverage = {extras = ["toml"], version = "^6"} [tool.isort] multi_line_output = 3 include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true line_length = 88 forced_separate = "miio.discover" known_first_party = "miio" known_third_party = ["appdirs", "attr", "click", "construct", "croniter", "cryptography", "netifaces", "pytest", "pytz", "setuptools", "tqdm", "zeroconf" ] [tool.coverage.run] source = ["miio"] branch = true omit = ["miio/*cli.py", "miio/extract_tokens.py", "miio/tests/*", "miio/version.py" ] [tool.coverage.report] exclude_lines = [ # ignore abstract methods "raise NotImplementedError", "def __repr__" ] [tool.check-manifest] ignore = ["devtools/*"] [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179638.3640165 python-miio-0.5.12/setup.py0000644000000000000000000003251414265350066012527 0ustar00# -*- coding: utf-8 -*- from setuptools import setup packages = \ ['miio', 'miio.gateway', 'miio.gateway.devices', 'miio.integrations', 'miio.integrations.airpurifier', 'miio.integrations.airpurifier.airdog', 'miio.integrations.airpurifier.airdog.tests', 'miio.integrations.airpurifier.dmaker', 'miio.integrations.airpurifier.dmaker.tests', 'miio.integrations.airpurifier.zhimi', 'miio.integrations.airpurifier.zhimi.tests', 'miio.integrations.fan', 'miio.integrations.fan.dmaker', 'miio.integrations.fan.leshow', 'miio.integrations.fan.leshow.tests', 'miio.integrations.fan.zhimi', 'miio.integrations.humidifier', 'miio.integrations.humidifier.deerma', 'miio.integrations.humidifier.deerma.tests', 'miio.integrations.humidifier.shuii', 'miio.integrations.humidifier.shuii.tests', 'miio.integrations.humidifier.zhimi', 'miio.integrations.humidifier.zhimi.tests', 'miio.integrations.light', 'miio.integrations.light.philips', 'miio.integrations.light.philips.tests', 'miio.integrations.light.yeelight', 'miio.integrations.light.yeelight.tests', 'miio.integrations.petwaterdispenser', 'miio.integrations.petwaterdispenser.tests', 'miio.integrations.vacuum', 'miio.integrations.vacuum.dreame', 'miio.integrations.vacuum.dreame.tests', 'miio.integrations.vacuum.mijia', 'miio.integrations.vacuum.mijia.tests', 'miio.integrations.vacuum.roborock', 'miio.integrations.vacuum.roborock.tests', 'miio.integrations.vacuum.roidmi', 'miio.integrations.vacuum.roidmi.tests', 'miio.integrations.vacuum.viomi', 'miio.interfaces', 'miio.push_server', 'miio.tests'] package_data = \ {'': ['*'], 'miio': ['data/*']} install_requires = \ ['PyYAML>=5,<7', 'appdirs>=1,<2', 'attrs', 'click>=8', 'construct>=2.10.56,<3.0.0', 'croniter>=1', 'cryptography>=35', 'defusedxml>=0,<1', 'pytz', 'tqdm>=4,<5', 'zeroconf>=0,<1'] extras_require = \ {':python_version <= "3.7"': ['importlib_metadata>=1,<2'], 'docs': ['sphinx>=4.2', 'sphinx_click', 'sphinxcontrib-apidoc>=0,<1', 'sphinx_rtd_theme>=0,<1']} entry_points = \ {'console_scripts': ['miio-extract-tokens = miio.extract_tokens:main', 'miiocli = miio.cli:create_cli', 'mirobo = ' 'miio.integrations.vacuum.roborock.vacuum_cli:cli']} setup_kwargs = { 'name': 'python-miio', 'version': '0.5.12', 'description': 'Python library for interfacing with Xiaomi smart appliances', 'long_description': 'python-miio\n===========\n\n|Chat| |PyPI version| |PyPI downloads| |Build Status| |Coverage Status| |Docs| |Black|\n\nThis library (and its accompanying cli tool) can be used to interface with devices using Xiaomi\'s `miIO `__ and MIoT protocols.\n\n\nGetting started\n---------------\n\nIf you already have a token for your device and the device type, you can directly start using `miiocli` tool.\nIf you don\'t have a token for your device, refer to `Getting started `__ section of `the manual `__ for instructions how to obtain it.\n\nThe `miiocli` is the main way to execute commands from command line.\nYou can always use `--help` to get more information about the available commands.\nFor example, executing it without any extra arguments will print out options and available commands::\n\n $ miiocli --help\n Usage: miiocli [OPTIONS] COMMAND [ARGS]...\n\n Options:\n -d, --debug\n -o, --output [default|json|json_pretty]\n --help Show this message and exit.\n\n Commands:\n airconditioningcompanion\n ..\n\nYou can get some information from any miIO/MIoT device, including its device model, using the `info` command::\n\n miiocli device --ip --token info\n\n Model: some.device.model1\n Hardware version: esp8285\n Firmware version: 1.0.1_0012\n Network: {\'localIp\': \'\', \'mask\': \'255.255.255.0\', \'gw\': \'\'}\n AP: {\'rssi\': -73, \'ssid\': \'\', \'primary\': 11, \'bssid\': \'\'}\n\nDifferent devices are supported by their corresponding modules (e.g., `roborockvacuum` or `fan`).\nYou can get the list of available commands for any given module by passing `--help` argument to it::\n\n $ miiocli roborockvacuum --help\n\n Usage: miiocli roborockvacuum [OPTIONS] COMMAND [ARGS]...\n\n Options:\n --ip TEXT [required]\n --token TEXT [required]\n --id-file FILE\n --help Show this message and exit.\n\n Commands:\n add_timer Add a timer.\n ..\n\nEach command invocation will automatically detect the device model necessary for some actions by querying the device.\nYou can avoid this by specifying the model manually::\n\n miiocli roborockvacuum --model roborock.vacuum.s5 --ip --token start\n\n\nAPI usage\n---------\nAll functionality is accessible through the `miio` module::\n\n from miio import RoborockVacuum\n\n vac = RoborockVacuum("", "")\n vac.start()\n\nEach separate device type inherits from `miio.Device`\n(and in case of MIoT devices, `miio.MiotDevice`) which provides a common API.\n\nEach command invocation will automatically detect (and cache) the device model necessary for some actions\nby querying the device.\nYou can avoid this by specifying the model manually::\n\n from miio import RoborockVacuum\n\n vac = RoborockVacuum("", "", model="roborock.vacuum.s5")\n\nPlease refer to `API documentation `__ for more information.\n\n\nTroubleshooting\n---------------\nYou can find some solutions for the most common problems can be found in `Troubleshooting `__ section.\n\nIf you have any questions, or simply want to join up for a chat, check `our Matrix room `__.\n\nContributing\n------------\n\nWe welcome all sorts of contributions from patches to add improvements or fixing bugs to improving the documentation.\nTo ease the process of setting up a development environment we have prepared `a short guide `__ for getting you started.\n\n\nSupported devices\n-----------------\n\n- Xiaomi Mi Robot Vacuum V1, S4, S4 MAX, S5, S5 MAX, S6 Pure, M1S, S7\n- Xiaomi Mi Home Air Conditioner Companion\n- Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5)\n- Xiaomi Mi Air Purifier 2, 3H, 3C, 4, Pro, Pro H, 4 Pro (zhimi.airpurifier.m2, mb3, mb4, mb5, v7, vb2, va2), 4 Lite\n- Xiaomi Mi Air (Purifier) Dog X3, X5, X7SM (airdog.airpurifier.x3, airdog.airpurifier.x5, airdog.airpurifier.x7sm)\n- Xiaomi Mi Air Humidifier\n- Smartmi Air Purifier\n- Xiaomi Aqara Camera\n- Xiaomi Aqara Gateway (basic implementation, alarm, lights)\n- Xiaomi Mijia 360 1080p\n- Xiaomi Mijia STYJ02YM (Viomi)\n- Xiaomi Mijia 1C STYTJ01ZHM (Dreame)\n- Dreame F9, D9, Z10 Pro\n- Xiaomi Mi Home (Mijia) G1 Robot Vacuum Mop MJSTG1\n- Xiaomi Roidmi Eve\n- Xiaomi Mi Smart WiFi Socket\n- Xiaomi Chuangmi Plug V1 (1 Socket, 1 USB Port)\n- Xiaomi Chuangmi Plug V3 (1 Socket, 2 USB Ports)\n- Xiaomi Smart Power Strip V1 and V2 (WiFi, 6 Ports)\n- Xiaomi Philips Eyecare Smart Lamp 2\n- Xiaomi Philips RW Read (philips.light.rwread)\n- Xiaomi Philips LED Ceiling Lamp\n- Xiaomi Philips LED Ball Lamp (philips.light.bulb)\n- Xiaomi Philips LED Ball Lamp White (philips.light.hbulb)\n- Xiaomi Philips Zhirui Smart LED Bulb E14 Candle Lamp\n- Xiaomi Philips Zhirui Bedroom Smart Lamp\n- Huayi Huizuo Lamps\n- Xiaomi Universal IR Remote Controller (Chuangmi IR)\n- Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, ZA5 1C, P5, P9, P10, P11, P33\n- Xiaomi Rosou SS4 Ventilator (leshow.fan.ss4)\n- Xiaomi Mi Air Humidifier V1, CA1, CA4, CB1, MJJSQ, JSQ, JSQ1, JSQ001\n- Xiaomi Mi Water Purifier (Basic support: Turn on & off)\n- Xiaomi Mi Water Purifier D1, C1 (Triple Setting)\n- Xiaomi PM2.5 Air Quality Monitor V1, B1, S1\n- Xiaomi Smart WiFi Speaker\n- Xiaomi Mi WiFi Repeater 2\n- Xiaomi Mi Smart Rice Cooker\n- Xiaomi Smartmi Fresh Air System VA2 (zhimi.airfresh.va2), VA4 (zhimi.airfresh.va4),\n A1 (dmaker.airfresh.a1), T2017 (dmaker.airfresh.t2017)\n- Yeelight lights (basic support, we recommend using `python-yeelight `__)\n- Xiaomi Mi Air Dehumidifier\n- Xiaomi Tinymu Smart Toilet Cover\n- Xiaomi 16 Relays Module\n- Xiaomi Xiao AI Smart Alarm Clock\n- Smartmi Radiant Heater Smart Version (ZA1 version)\n- Xiaomi Mi Smart Space Heater\n- Xiaomiyoupin Curtain Controller (Wi-Fi) (lumi.curtain.hagl05)\n- Xiaomi Xiaomi Mi Smart Space Heater S (zhimi.heater.mc2)\n- Xiaomi Xiaomi Mi Smart Space Heater 1S (zhimi.heater.za2)\n- Yeelight Dual Control Module (yeelink.switch.sw1)\n- Scishare coffee maker (scishare.coffee.s1102)\n- Qingping Air Monitor Lite (cgllc.airm.cgdn1)\n- Xiaomi Walkingpad A1 (ksmb.walkingpad.v3)\n- Xiaomi Smart Pet Water Dispenser (mmgg.pet_waterer.s1, s4)\n- Xiaomi Mi Smart Humidifer S (jsqs, jsq5)\n- Xiaomi Mi Robot Vacuum Mop 2 (Pro+, Ultra)\n\n\n*Feel free to create a pull request to add support for new devices as\nwell as additional features for supported devices.*\n\nProjects using this library\n---------------------------\n\nThis library is used by various projects to support MiIO/MiOT devices.\nIf you are using this library for your project, feel free to open a PR to get it listed here!\n\nHome Assistant (official)\n^^^^^^^^^^^^^^^^^^^^^^^^^\n\nHome Assistant uses this library to support several platforms out-of-the-box.\nThis list is incomplete as the platforms (in parentheses) may also support other devices listed above.\n\n- `Xiaomi Mi Robot Vacuum `__ (vacuum)\n- `Xiaomi Philips Light `__ (light)\n- `Xiaomi Mi Air Purifier and Air Humidifier `__ (fan)\n- `Xiaomi Smart WiFi Socket and Smart Power Strip `__ (switch)\n- `Xiaomi Universal IR Remote Controller `__ (remote)\n- `Xiaomi Mi Air Quality Monitor (PM2.5) `__ (sensor)\n- `Xiaomi Aqara Gateway Alarm `__ (alarm_control_panel)\n- `Xiaomi Mi WiFi Repeater 2 `__ (device_tracker)\n\nHome Assistant (custom)\n^^^^^^^^^^^^^^^^^^^^^^^\n\n- `Xiaomi Mi Home Air Conditioner Companion `__\n- `Xiaomi Mi Smart Pedestal Fan `__\n- `Xiaomi Mi Smart Rice Cooker `__\n- `Xiaomi Raw Sensor `__\n- `Xiaomi MIoT Devices `__\n- `Xiaomi Miot Auto `__\n\nOther related projects\n----------------------\n\nThis is a list of other projects around the Xiaomi ecosystem that you can find interesting.\nFeel free to submit more related projects.\n\n- `dustcloud `__ (reverse engineering and rooting xiaomi devices)\n- `Valetudo `__ (cloud free vacuum firmware)\n- `micloud `__ (library to access xiaomi cloud services, can be used to obtain device tokens)\n- `micloudfaker `__ (dummy cloud server, can be used to fix powerstrip status requests when without internet access)\n- `Your project here? Feel free to open a PR! `__\n\n.. |Chat| image:: https://img.shields.io/matrix/python-miio-chat:matrix.org\n :target: https://matrix.to/#/#python-miio-chat:matrix.org\n.. |PyPI version| image:: https://badge.fury.io/py/python-miio.svg\n :target: https://badge.fury.io/py/python-miio\n.. |PyPI downloads| image:: https://img.shields.io/pypi/dw/python-miio\n :target: https://pypi.org/project/python-miio/\n.. |Build Status| image:: https://github.com/rytilahti/python-miio/actions/workflows/ci.yml/badge.svg\n :target: https://github.com/rytilahti/python-miio/actions/workflows/ci.yml\n.. |Coverage Status| image:: https://codecov.io/gh/rytilahti/python-miio/branch/master/graph/badge.svg?token=lYKWubxkLU\n :target: https://codecov.io/gh/rytilahti/python-miio\n.. |Docs| image:: https://readthedocs.org/projects/python-miio/badge/?version=latest\n :alt: Documentation status\n :target: https://python-miio.readthedocs.io/en/latest/?badge=latest\n.. |Black| image:: https://img.shields.io/badge/code%20style-black-000000.svg\n :target: https://github.com/psf/black\n', 'author': 'Teemu R', 'author_email': 'tpr@iki.fi', 'maintainer': None, 'maintainer_email': None, 'url': 'https://github.com/rytilahti/python-miio', 'packages': packages, 'package_data': package_data, 'install_requires': install_requires, 'extras_require': extras_require, 'entry_points': entry_points, 'python_requires': '>=3.7,<4.0', } setup(**setup_kwargs) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1658179638.3649359 python-miio-0.5.12/PKG-INFO0000644000000000000000000003007214265350066012107 0ustar00Metadata-Version: 2.1 Name: python-miio Version: 0.5.12 Summary: Python library for interfacing with Xiaomi smart appliances Home-page: https://github.com/rytilahti/python-miio License: GPL-3.0-only Keywords: xiaomi,miio,miot,smart home Author: Teemu R Author-email: tpr@iki.fi Requires-Python: >=3.7,<4.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Topic :: Home Automation Classifier: Topic :: System :: Hardware Provides-Extra: docs Requires-Dist: PyYAML (>=5,<7) Requires-Dist: android_backup (>=0,<1) Requires-Dist: appdirs (>=1,<2) Requires-Dist: attrs Requires-Dist: click (>=8) Requires-Dist: construct (>=2.10.56,<3.0.0) Requires-Dist: croniter (>=1) Requires-Dist: cryptography (>=35) Requires-Dist: defusedxml (>=0,<1) Requires-Dist: importlib_metadata (>=1,<2); python_version <= "3.7" Requires-Dist: micloud Requires-Dist: netifaces (>=0,<1) Requires-Dist: pytz Requires-Dist: sphinx (>=4.2); extra == "docs" Requires-Dist: sphinx_click; extra == "docs" Requires-Dist: sphinx_rtd_theme (>=0,<1); extra == "docs" Requires-Dist: sphinxcontrib-apidoc (>=0,<1); extra == "docs" Requires-Dist: tqdm (>=4,<5) Requires-Dist: zeroconf (>=0,<1) Project-URL: Documentation, https://python-miio.readthedocs.io Project-URL: Repository, https://github.com/rytilahti/python-miio Description-Content-Type: text/x-rst python-miio =========== |Chat| |PyPI version| |PyPI downloads| |Build Status| |Coverage Status| |Docs| |Black| This library (and its accompanying cli tool) can be used to interface with devices using Xiaomi's `miIO `__ and MIoT protocols. Getting started --------------- If you already have a token for your device and the device type, you can directly start using `miiocli` tool. If you don't have a token for your device, refer to `Getting started `__ section of `the manual `__ for instructions how to obtain it. The `miiocli` is the main way to execute commands from command line. You can always use `--help` to get more information about the available commands. For example, executing it without any extra arguments will print out options and available commands:: $ miiocli --help Usage: miiocli [OPTIONS] COMMAND [ARGS]... Options: -d, --debug -o, --output [default|json|json_pretty] --help Show this message and exit. Commands: airconditioningcompanion .. You can get some information from any miIO/MIoT device, including its device model, using the `info` command:: miiocli device --ip --token info Model: some.device.model1 Hardware version: esp8285 Firmware version: 1.0.1_0012 Network: {'localIp': '', 'mask': '255.255.255.0', 'gw': ''} AP: {'rssi': -73, 'ssid': '', 'primary': 11, 'bssid': ''} Different devices are supported by their corresponding modules (e.g., `roborockvacuum` or `fan`). You can get the list of available commands for any given module by passing `--help` argument to it:: $ miiocli roborockvacuum --help Usage: miiocli roborockvacuum [OPTIONS] COMMAND [ARGS]... Options: --ip TEXT [required] --token TEXT [required] --id-file FILE --help Show this message and exit. Commands: add_timer Add a timer. .. Each command invocation will automatically detect the device model necessary for some actions by querying the device. You can avoid this by specifying the model manually:: miiocli roborockvacuum --model roborock.vacuum.s5 --ip --token start API usage --------- All functionality is accessible through the `miio` module:: from miio import RoborockVacuum vac = RoborockVacuum("", "") vac.start() Each separate device type inherits from `miio.Device` (and in case of MIoT devices, `miio.MiotDevice`) which provides a common API. Each command invocation will automatically detect (and cache) the device model necessary for some actions by querying the device. You can avoid this by specifying the model manually:: from miio import RoborockVacuum vac = RoborockVacuum("", "", model="roborock.vacuum.s5") Please refer to `API documentation `__ for more information. Troubleshooting --------------- You can find some solutions for the most common problems can be found in `Troubleshooting `__ section. If you have any questions, or simply want to join up for a chat, check `our Matrix room `__. Contributing ------------ We welcome all sorts of contributions from patches to add improvements or fixing bugs to improving the documentation. To ease the process of setting up a development environment we have prepared `a short guide `__ for getting you started. Supported devices ----------------- - Xiaomi Mi Robot Vacuum V1, S4, S4 MAX, S5, S5 MAX, S6 Pure, M1S, S7 - Xiaomi Mi Home Air Conditioner Companion - Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5) - Xiaomi Mi Air Purifier 2, 3H, 3C, 4, Pro, Pro H, 4 Pro (zhimi.airpurifier.m2, mb3, mb4, mb5, v7, vb2, va2), 4 Lite - Xiaomi Mi Air (Purifier) Dog X3, X5, X7SM (airdog.airpurifier.x3, airdog.airpurifier.x5, airdog.airpurifier.x7sm) - Xiaomi Mi Air Humidifier - Smartmi Air Purifier - Xiaomi Aqara Camera - Xiaomi Aqara Gateway (basic implementation, alarm, lights) - Xiaomi Mijia 360 1080p - Xiaomi Mijia STYJ02YM (Viomi) - Xiaomi Mijia 1C STYTJ01ZHM (Dreame) - Dreame F9, D9, Z10 Pro - Xiaomi Mi Home (Mijia) G1 Robot Vacuum Mop MJSTG1 - Xiaomi Roidmi Eve - Xiaomi Mi Smart WiFi Socket - Xiaomi Chuangmi Plug V1 (1 Socket, 1 USB Port) - Xiaomi Chuangmi Plug V3 (1 Socket, 2 USB Ports) - Xiaomi Smart Power Strip V1 and V2 (WiFi, 6 Ports) - Xiaomi Philips Eyecare Smart Lamp 2 - Xiaomi Philips RW Read (philips.light.rwread) - Xiaomi Philips LED Ceiling Lamp - Xiaomi Philips LED Ball Lamp (philips.light.bulb) - Xiaomi Philips LED Ball Lamp White (philips.light.hbulb) - Xiaomi Philips Zhirui Smart LED Bulb E14 Candle Lamp - Xiaomi Philips Zhirui Bedroom Smart Lamp - Huayi Huizuo Lamps - Xiaomi Universal IR Remote Controller (Chuangmi IR) - Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, ZA5 1C, P5, P9, P10, P11, P33 - Xiaomi Rosou SS4 Ventilator (leshow.fan.ss4) - Xiaomi Mi Air Humidifier V1, CA1, CA4, CB1, MJJSQ, JSQ, JSQ1, JSQ001 - Xiaomi Mi Water Purifier (Basic support: Turn on & off) - Xiaomi Mi Water Purifier D1, C1 (Triple Setting) - Xiaomi PM2.5 Air Quality Monitor V1, B1, S1 - Xiaomi Smart WiFi Speaker - Xiaomi Mi WiFi Repeater 2 - Xiaomi Mi Smart Rice Cooker - Xiaomi Smartmi Fresh Air System VA2 (zhimi.airfresh.va2), VA4 (zhimi.airfresh.va4), A1 (dmaker.airfresh.a1), T2017 (dmaker.airfresh.t2017) - Yeelight lights (basic support, we recommend using `python-yeelight `__) - Xiaomi Mi Air Dehumidifier - Xiaomi Tinymu Smart Toilet Cover - Xiaomi 16 Relays Module - Xiaomi Xiao AI Smart Alarm Clock - Smartmi Radiant Heater Smart Version (ZA1 version) - Xiaomi Mi Smart Space Heater - Xiaomiyoupin Curtain Controller (Wi-Fi) (lumi.curtain.hagl05) - Xiaomi Xiaomi Mi Smart Space Heater S (zhimi.heater.mc2) - Xiaomi Xiaomi Mi Smart Space Heater 1S (zhimi.heater.za2) - Yeelight Dual Control Module (yeelink.switch.sw1) - Scishare coffee maker (scishare.coffee.s1102) - Qingping Air Monitor Lite (cgllc.airm.cgdn1) - Xiaomi Walkingpad A1 (ksmb.walkingpad.v3) - Xiaomi Smart Pet Water Dispenser (mmgg.pet_waterer.s1, s4) - Xiaomi Mi Smart Humidifer S (jsqs, jsq5) - Xiaomi Mi Robot Vacuum Mop 2 (Pro+, Ultra) *Feel free to create a pull request to add support for new devices as well as additional features for supported devices.* Projects using this library --------------------------- This library is used by various projects to support MiIO/MiOT devices. If you are using this library for your project, feel free to open a PR to get it listed here! Home Assistant (official) ^^^^^^^^^^^^^^^^^^^^^^^^^ Home Assistant uses this library to support several platforms out-of-the-box. This list is incomplete as the platforms (in parentheses) may also support other devices listed above. - `Xiaomi Mi Robot Vacuum `__ (vacuum) - `Xiaomi Philips Light `__ (light) - `Xiaomi Mi Air Purifier and Air Humidifier `__ (fan) - `Xiaomi Smart WiFi Socket and Smart Power Strip `__ (switch) - `Xiaomi Universal IR Remote Controller `__ (remote) - `Xiaomi Mi Air Quality Monitor (PM2.5) `__ (sensor) - `Xiaomi Aqara Gateway Alarm `__ (alarm_control_panel) - `Xiaomi Mi WiFi Repeater 2 `__ (device_tracker) Home Assistant (custom) ^^^^^^^^^^^^^^^^^^^^^^^ - `Xiaomi Mi Home Air Conditioner Companion `__ - `Xiaomi Mi Smart Pedestal Fan `__ - `Xiaomi Mi Smart Rice Cooker `__ - `Xiaomi Raw Sensor `__ - `Xiaomi MIoT Devices `__ - `Xiaomi Miot Auto `__ Other related projects ---------------------- This is a list of other projects around the Xiaomi ecosystem that you can find interesting. Feel free to submit more related projects. - `dustcloud `__ (reverse engineering and rooting xiaomi devices) - `Valetudo `__ (cloud free vacuum firmware) - `micloud `__ (library to access xiaomi cloud services, can be used to obtain device tokens) - `micloudfaker `__ (dummy cloud server, can be used to fix powerstrip status requests when without internet access) - `Your project here? Feel free to open a PR! `__ .. |Chat| image:: https://img.shields.io/matrix/python-miio-chat:matrix.org :target: https://matrix.to/#/#python-miio-chat:matrix.org .. |PyPI version| image:: https://badge.fury.io/py/python-miio.svg :target: https://badge.fury.io/py/python-miio .. |PyPI downloads| image:: https://img.shields.io/pypi/dw/python-miio :target: https://pypi.org/project/python-miio/ .. |Build Status| image:: https://github.com/rytilahti/python-miio/actions/workflows/ci.yml/badge.svg :target: https://github.com/rytilahti/python-miio/actions/workflows/ci.yml .. |Coverage Status| image:: https://codecov.io/gh/rytilahti/python-miio/branch/master/graph/badge.svg?token=lYKWubxkLU :target: https://codecov.io/gh/rytilahti/python-miio .. |Docs| image:: https://readthedocs.org/projects/python-miio/badge/?version=latest :alt: Documentation status :target: https://python-miio.readthedocs.io/en/latest/?badge=latest .. |Black| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black