././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/LICENSE 0000644 0000000 0000000 00000104513 14265350055 012017 0 ustar 00 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
.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/LICENSE.md 0000644 0000000 0000000 00000104145 14265350055 012417 0 ustar 00 # 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).
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/README.rst 0000644 0000000 0000000 00000024406 14265350055 012503 0 ustar 00 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/__init__.py 0000644 0000000 0000000 00000006017 14265350055 014060 0 ustar 00 # 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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/airconditioner_miot.py 0000644 0000000 0000000 00000036521 14265350055 016365 0 ustar 00 import 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)))
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/airconditioningcompanion.py 0000644 0000000 0000000 00000034151 14265350055 017405 0 ustar 00 import 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])
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/airconditioningcompanionMCN.py 0000644 0000000 0000000 00000010623 14265350055 017741 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/airdehumidifier.py 0000644 0000000 0000000 00000020004 14265350055 015443 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/airqualitymonitor.py 0000644 0000000 0000000 00000021013 14265350055 016106 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/airqualitymonitor_miot.py 0000644 0000000 0000000 00000016676 14265350055 017161 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/alarmclock.py 0000644 0000000 0000000 00000021216 14265350055 014427 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/aqaracamera.py 0000644 0000000 0000000 00000021726 14265350055 014563 0 ustar 00 """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"])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/chuangmi_camera.py 0000644 0000000 0000000 00000026321 14265350055 015424 0 ustar 00 """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,
},
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/chuangmi_ir.py 0000644 0000000 0000000 00000016571 14265350055 014614 0 ustar 00 import 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),
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/chuangmi_plug.py 0000644 0000000 0000000 00000013121 14265350055 015135 0 ustar 00 import 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"])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/cli.py 0000644 0000000 0000000 00000003313 14265350055 013064 0 ustar 00 import 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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/click_common.py 0000644 0000000 0000000 00000024524 14265350055 014761 0 ustar 00 """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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5226135
python-miio-0.5.12/miio/cloud.py 0000644 0000000 0000000 00000013440 14265350055 013425 0 ustar 00 import 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}")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/cooker.py 0000644 0000000 0000000 00000054635 14265350055 013614 0 ustar 00 import 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,
]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/curtain_youpin.py 0000644 0000000 0000000 00000016061 14265350055 015371 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/data/cooker_profiles.json 0000644 0000000 0000000 00000031607 14265350055 016743 0 ustar 00 {
"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"
}
]
}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/device.py 0000644 0000000 0000000 00000030056 14265350055 013560 0 ustar 00 import 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})>"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/deviceinfo.py 0000644 0000000 0000000 00000005065 14265350055 014436 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/discovery.py 0000644 0000000 0000000 00000025457 14265350055 014341 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/exceptions.py 0000644 0000000 0000000 00000002045 14265350055 014477 0 ustar 00 class 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."""
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/extract_tokens.py 0000644 0000000 0000000 00000016655 14265350055 015367 0 ustar 00 import 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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/fan_common.py 0000644 0000000 0000000 00000000504 14265350055 014430 0 ustar 00 import 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"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/__init__.py 0000644 0000000 0000000 00000000146 14265350055 015516 0 ustar 00 """Xiaomi Gateway implementation using Miio protecol."""
# flake8: noqa
from .gateway import Gateway
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/alarm.py 0000644 0000000 0000000 00000006727 14265350055 015066 0 ustar 00 """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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/devices/__init__.py 0000644 0000000 0000000 00000000320 14265350055 017132 0 ustar 00 """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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/devices/light.py 0000644 0000000 0000000 00000001513 14265350055 016507 0 ustar 00 """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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/devices/sensor.py 0000644 0000000 0000000 00000000740 14265350055 016712 0 ustar 00 """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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/devices/subdevice.py 0000644 0000000 0000000 00000026033 14265350055 017355 0 ustar 00 """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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/devices/subdevices.yaml 0000644 0000000 0000000 00000061314 14265350055 020053 0 ustar 00 # 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/devices/switch.py 0000644 0000000 0000000 00000002176 14265350055 016707 0 ustar 00 """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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/gateway.py 0000644 0000000 0000000 00000035220 14265350055 015421 0 ustar 00 """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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/gatewaydevice.py 0000644 0000000 0000000 00000001333 14265350055 016577 0 ustar 00 """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] = []
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/light.py 0000644 0000000 0000000 00000013050 14265350055 015064 0 ustar 00 """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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/radio.py 0000644 0000000 0000000 00000006342 14265350055 015061 0 ustar 00 """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"]}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/gateway/zigbee.py 0000644 0000000 0000000 00000003247 14265350055 015231 0 ustar 00 """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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/heater.py 0000644 0000000 0000000 00000016635 14265350055 013600 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/heater_miot.py 0000644 0000000 0000000 00000020411 14265350055 014613 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/huizuo.py 0000644 0000000 0000000 00000046476 14265350055 013661 0 ustar 00 """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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/__init__.py 0000644 0000000 0000000 00000000000 14265350055 016550 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/__init__.py 0000644 0000000 0000000 00000000120 14265350055 021074 0 ustar 00 # flake8: noqa
from .airdog import *
from .dmaker import *
from .zhimi import *
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/airdog/__init__.py 0000644 0000000 0000000 00000000070 14265350055 022345 0 ustar 00 # flake8: noqa
from .airpurifier_airdog import AirDogX3
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/airdog/airpurifier_airdog.py 0000644 0000000 0000000 00000011662 14265350055 024465 0 ustar 00 import 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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/airdog/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023500 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/airdog/tests/test_airpurifier_airdog.py 0000644 0000000 0000000 00000013307 14265350055 026664 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/dmaker/__init__.py 0000644 0000000 0000000 00000000105 14265350055 022342 0 ustar 00 # flake8: noqa
from .airfresh_t2017 import AirFreshA1, AirFreshT2017
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/dmaker/airfresh_t2017.py 0000644 0000000 0000000 00000030340 14265350055 023247 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/dmaker/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023476 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/dmaker/tests/test_airfresh_t2017.py 0000644 0000000 0000000 00000031563 14265350055 025460 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/__init__.py 0000644 0000000 0000000 00000000201 14265350055 022214 0 ustar 00 # flake8: noqa
from .airfresh import AirFresh
from .airpurifier import AirPurifier
from .airpurifier_miot import AirPurifierMiot
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airfilter_util.py 0000644 0000000 0000000 00000002545 14265350055 023510 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airfresh.py 0000644 0000000 0000000 00000023120 14265350055 022265 0 ustar 00 import 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"])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airpurifier.py 0000644 0000000 0000000 00000041661 14265350055 023015 0 ustar 00 import 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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/airpurifier_miot.py 0000644 0000000 0000000 00000062476 14265350055 024054 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023353 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airfilter_util.py 0000644 0000000 0000000 00000003001 14265350055 025675 0 ustar 00 from 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
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airfresh.py 0000644 0000000 0000000 00000024474 14265350055 024503 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airpurifier.py 0000644 0000000 0000000 00000031527 14265350055 025216 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/airpurifier/zhimi/tests/test_airpurifier_miot.py 0000644 0000000 0000000 00000030302 14265350055 026234 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/fan/__init__.py 0000644 0000000 0000000 00000000120 14265350055 017317 0 ustar 00 # flake8: noqa
from .dmaker import *
from .leshow import *
from .zhimi import *
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5266137
python-miio-0.5.12/miio/integrations/fan/dmaker/__init__.py 0000644 0000000 0000000 00000000113 14265350055 020564 0 ustar 00 # flake8: noqa
from .fan import FanP5
from .fan_miot import Fan1C, FanMiot
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/dmaker/fan.py 0000644 0000000 0000000 00000015544 14265350055 017607 0 ustar 00 from 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/dmaker/fan_miot.py 0000644 0000000 0000000 00000041702 14265350055 020632 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/dmaker/test_fan.py 0000644 0000000 0000000 00000013064 14265350055 020641 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/dmaker/test_fan_miot.py 0000644 0000000 0000000 00000021775 14265350055 021701 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/leshow/__init__.py 0000644 0000000 0000000 00000000061 14265350055 020624 0 ustar 00 # flake8: noqa
from .fan_leshow import FanLeshow
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/leshow/fan_leshow.py 0000644 0000000 0000000 00000011661 14265350055 021222 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/leshow/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 021757 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/leshow/tests/test_fan_leshow.py 0000644 0000000 0000000 00000007454 14265350055 023430 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/zhimi/__init__.py 0000644 0000000 0000000 00000000103 14265350055 020440 0 ustar 00 # flake8: noqa
from .fan import Fan
from .zhimi_miot import FanZA5
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/zhimi/fan.py 0000644 0000000 0000000 00000027527 14265350055 017470 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/zhimi/test_fan.py 0000644 0000000 0000000 00000057451 14265350055 020526 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/zhimi/test_zhimi_miot.py 0000644 0000000 0000000 00000010353 14265350055 022120 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/fan/zhimi/zhimi_miot.py 0000644 0000000 0000000 00000025651 14265350055 021070 0 ustar 00 import 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())
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/__init__.py 0000644 0000000 0000000 00000000117 14265350055 020706 0 ustar 00 # flake8: noqa
from .deerma import *
from .shuii import *
from .zhimi import *
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/deerma/__init__.py 0000644 0000000 0000000 00000000165 14265350055 022146 0 ustar 00 # flake8: noqa
from .airhumidifier_jsqs import AirHumidifierJsqs
from .airhumidifier_mjjsq import AirHumidifierMjjsq
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/deerma/airhumidifier_jsqs.py 0000644 0000000 0000000 00000016755 14265350055 024304 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/deerma/airhumidifier_mjjsq.py 0000644 0000000 0000000 00000014563 14265350055 024443 0 ustar 00 import 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)])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/deerma/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023274 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/deerma/tests/test_airhumidifier_jsqs.py 0000644 0000000 0000000 00000007202 14265350055 026470 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/deerma/tests/test_airhumidifier_mjjsq.py 0000644 0000000 0000000 00000011471 14265350055 026637 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/shuii/__init__.py 0000644 0000000 0000000 00000000077 14265350055 022034 0 ustar 00 # flake8: noqa
from .airhumidifier_jsq import AirHumidifierJsq
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/shuii/airhumidifier_jsq.py 0000644 0000000 0000000 00000017435 14265350055 024001 0 ustar 00 import 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))])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/shuii/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023160 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/shuii/tests/test_airhumidifier_jsq.py 0000644 0000000 0000000 00000023256 14265350055 026200 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/zhimi/__init__.py 0000644 0000000 0000000 00000000152 14265350055 022025 0 ustar 00 # flake8: noqa
from .airhumidifier import AirHumidifier
from .airhumidifier_miot import AirHumidifierMiot
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/zhimi/airhumidifier.py 0000644 0000000 0000000 00000030166 14265350055 023117 0 ustar 00 import 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"])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/zhimi/airhumidifier_miot.py 0000644 0000000 0000000 00000030724 14265350055 024147 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/zhimi/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023157 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/zhimi/tests/test_airhumidifier.py 0000644 0000000 0000000 00000022247 14265350055 025321 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/humidifier/zhimi/tests/test_airhumidifier_miot.py 0000644 0000000 0000000 00000013163 14265350055 026346 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/__init__.py 0000644 0000000 0000000 00000000076 14265350055 017674 0 ustar 00 # flake8: noqa
from .philips import *
from .yeelight import *
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/__init__.py 0000644 0000000 0000000 00000000344 14265350055 021342 0 ustar 00 # 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/ceil.py 0000644 0000000 0000000 00000013776 14265350055 020534 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/philips_bulb.py 0000644 0000000 0000000 00000013011 14265350055 022252 0 ustar 00 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 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/philips_eyecare.py 0000644 0000000 0000000 00000015250 14265350055 022752 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/philips_moonlight.py 0000644 0000000 0000000 00000015766 14265350055 023351 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/philips_rwread.py 0000644 0000000 0000000 00000013100 14265350055 022611 0 ustar 00 import 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)])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 022471 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/tests/test_ceil.py 0000644 0000000 0000000 00000014273 14265350055 022726 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_bulb.py 0000644 0000000 0000000 00000020465 14265350055 024466 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_eyecare.py 0000644 0000000 0000000 00000014002 14265350055 025145 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_moonlight.py 0000644 0000000 0000000 00000020434 14265350055 025536 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/philips/tests/test_philips_rwread.py 0000644 0000000 0000000 00000012371 14265350055 025023 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/yeelight/__init__.py 0000644 0000000 0000000 00000000137 14265350055 021504 0 ustar 00 # flake8: noqa
from .yeelight import Yeelight, YeelightException, YeelightMode, YeelightStatus
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/yeelight/spec_helper.py 0000644 0000000 0000000 00000004612 14265350055 022240 0 ustar 00 import 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]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/yeelight/specs.yaml 0000644 0000000 0000000 00000010127 14265350055 021374 0 ustar 00 yeelink.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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/yeelight/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 022633 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/yeelight/tests/test_yeelight.py 0000644 0000000 0000000 00000040737 14265350055 023772 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/yeelight/tests/test_yeelight_spec_helper.py 0000644 0000000 0000000 00000002024 14265350055 026326 0 ustar 00 from ..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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/light/yeelight/yeelight.py 0000644 0000000 0000000 00000042026 14265350055 021562 0 ustar 00 from 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/petwaterdispenser/__init__.py 0000644 0000000 0000000 00000000065 14265350055 022333 0 ustar 00 # flake8: noqa
from .device import PetWaterDispenser
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/petwaterdispenser/device.py 0000644 0000000 0000000 00000012577 14265350055 022046 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5306137
python-miio-0.5.12/miio/integrations/petwaterdispenser/status.py 0000644 0000000 0000000 00000006502 14265350055 022121 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/petwaterdispenser/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023462 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/petwaterdispenser/tests/test_status.py 0000644 0000000 0000000 00000002043 14265350055 024316 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/__init__.py 0000644 0000000 0000000 00000000175 14265350055 020065 0 ustar 00 # flake8: noqa
from .dreame import *
from .mijia import *
from .roborock import *
from .roidmi import *
from .viomi import *
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/dreame/__init__.py 0000644 0000000 0000000 00000000073 14265350055 021317 0 ustar 00 # flake8: noqa
from .dreamevacuum_miot import DreameVacuum
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/dreame/dreamevacuum_miot.py 0000644 0000000 0000000 00000052410 14265350055 023270 0 ustar 00 """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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/dreame/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 022447 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/dreame/tests/test_dreamevacuum_miot.py 0000644 0000000 0000000 00000023455 14265350055 025500 0 ustar 00 from 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 == {}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/mijia/__init__.py 0000644 0000000 0000000 00000000056 14265350055 021154 0 ustar 00 # flake8: noqa
from .g1vacuum import G1Vacuum
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/mijia/g1vacuum.py 0000644 0000000 0000000 00000030041 14265350055 021142 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/mijia/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 022303 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/__init__.py 0000644 0000000 0000000 00000000121 14265350055 021674 0 ustar 00 # flake8: noqa
from .vacuum import RoborockVacuum, VacuumException, VacuumStatus
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 023032 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/tests/test_mirobo.py 0000644 0000000 0000000 00000000653 14265350055 023637 0 ustar 00 from 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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/tests/test_vacuum.py 0000644 0000000 0000000 00000027442 14265350055 023655 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuum.py 0000644 0000000 0000000 00000076342 14265350055 021457 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuum_cli.py 0000644 0000000 0000000 00000046773 14265350055 022313 0 ustar 00 import 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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuum_tui.py 0000644 0000000 0000000 00000005553 14265350055 022334 0 ustar 00 try:
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"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roborock/vacuumcontainers.py 0000644 0000000 0000000 00000044263 14265350055 023542 0 ustar 00 from 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"]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roidmi/__init__.py 0000644 0000000 0000000 00000000077 14265350055 021351 0 ustar 00 # flake8: noqa
from .roidmivacuum_miot import RoidmiVacuumMiot
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roidmi/roidmivacuum_miot.py 0000644 0000000 0000000 00000064033 14265350055 023350 0 ustar 00 """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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roidmi/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 022475 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/roidmi/tests/test_roidmivacuum_miot.py 0000644 0000000 0000000 00000017743 14265350055 025557 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/viomi/__init__.py 0000644 0000000 0000000 00000000064 14265350055 021205 0 ustar 00 # flake8: noqa
from .viomivacuum import ViomiVacuum
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/integrations/vacuum/viomi/viomivacuum.py 0000644 0000000 0000000 00000073453 14265350055 022026 0 ustar 00 """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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/interfaces/__init__.py 0000644 0000000 0000000 00000000207 14265350055 016176 0 ustar 00 """Interfaces API."""
from .vacuuminterface import FanspeedPresets, VacuumInterface
__all__ = ["FanspeedPresets", "VacuumInterface"]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/interfaces/vacuuminterface.py 0000644 0000000 0000000 00000002340 14265350055 017620 0 ustar 00 """`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
"""
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/miioprotocol.py 0000644 0000000 0000000 00000023067 14265350055 015044 0 ustar 00 """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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/miot_device.py 0000644 0000000 0000000 00000013142 14265350055 014605 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/powerstrip.py 0000644 0000000 0000000 00000016070 14265350055 014537 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/protocol.py 0000644 0000000 0000000 00000017046 14265350055 014166 0 ustar 00 """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),
),
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/push_server/__init__.py 0000644 0000000 0000000 00000000317 14265350055 016422 0 ustar 00 """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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/push_server/eventinfo.py 0000644 0000000 0000000 00000002661 14265350055 016664 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/push_server/server.py 0000644 0000000 0000000 00000023700 14265350055 016172 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/push_server/serverprotocol.py 0000644 0000000 0000000 00000010104 14265350055 017746 0 ustar 00 import 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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/pwzn_relay.py 0000644 0000000 0000000 00000010034 14265350055 014505 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/py.typed 0000644 0000000 0000000 00000000000 14265350055 013430 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/scishare_coffeemaker.py 0000644 0000000 0000000 00000007771 14265350055 016461 0 ustar 00 import 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"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/__init__.py 0000644 0000000 0000000 00000000000 14265350055 015204 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/dummies.py 0000644 0000000 0000000 00000006317 14265350055 015131 0 ustar 00 class 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/test_airconditioner_miot.py 0000644 0000000 0000000 00000020534 14265350055 020563 0 ustar 00 from 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"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/test_airconditioningcompanion.json 0000644 0000000 0000000 00000013173 14265350055 022130 0 ustar 00 {
"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"
}
]
}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/test_airconditioningcompanion.py 0000644 0000000 0000000 00000026770 14265350055 021616 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/test_airdehumidifier.py 0000644 0000000 0000000 00000014537 14265350055 017662 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/test_airqualitymonitor.py 0000644 0000000 0000000 00000013516 14265350055 020320 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5346136
python-miio-0.5.12/miio/tests/test_airqualitymonitor_miot.py 0000644 0000000 0000000 00000011322 14265350055 021341 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_chuangmi_ir.json 0000644 0000000 0000000 00000012506 14265350055 017330 0 ustar 00 {
"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
]
}
]
}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_chuangmi_ir.py 0000644 0000000 0000000 00000010431 14265350055 017002 0 ustar 00 import 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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_chuangmi_plug.py 0000644 0000000 0000000 00000015273 14265350055 017350 0 ustar 00 from 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"]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_click_common.py 0000644 0000000 0000000 00000000336 14265350055 017155 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_device.py 0000644 0000000 0000000 00000011544 14265350055 015762 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_deviceinfo.py 0000644 0000000 0000000 00000003534 14265350055 016636 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_devicestatus.py 0000644 0000000 0000000 00000002610 14265350055 017220 0 ustar 00 from 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()) == ""
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_heater.py 0000644 0000000 0000000 00000011451 14265350055 015770 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_heater_miot.py 0000644 0000000 0000000 00000007265 14265350055 017030 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_huizuo.py 0000644 0000000 0000000 00000015537 14265350055 016054 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_miotdevice.py 0000644 0000000 0000000 00000010000 14265350055 016635 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_powerstrip.py 0000644 0000000 0000000 00000016702 14265350055 016742 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_protocol.py 0000644 0000000 0000000 00000010736 14265350055 016366 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_toiletlid.py 0000644 0000000 0000000 00000011566 14265350055 016520 0 ustar 00 """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 == {}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_vacuums.py 0000644 0000000 0000000 00000004456 14265350055 016212 0 ustar 00 """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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_walkingpad.py 0000644 0000000 0000000 00000014244 14265350055 016644 0 ustar 00 from 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")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_waterpurifier.py 0000644 0000000 0000000 00000003523 14265350055 017411 0 ustar 00 from 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_wifirepeater.py 0000644 0000000 0000000 00000013532 14265350055 017210 0 ustar 00 from 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"]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/tests/test_yeelight_dual_switch.py 0000644 0000000 0000000 00000006324 14265350055 020723 0 ustar 00 from 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/toiletlid.py 0000644 0000000 0000000 00000011370 14265350055 014310 0 ustar 00 import 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"])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/updater.py 0000644 0000000 0000000 00000006353 14265350055 013770 0 ustar 00 import 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()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/utils.py 0000644 0000000 0000000 00000006226 14265350055 013463 0 ustar 00 import 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])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/vacuum.py 0000644 0000000 0000000 00000000466 14265350055 013623 0 ustar 00 """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,
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/walkingpad.py 0000644 0000000 0000000 00000020565 14265350055 014446 0 ustar 00 import 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
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/waterpurifier.py 0000644 0000000 0000000 00000010126 14265350055 015205 0 ustar 00 import 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"])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/waterpurifier_yunmi.py 0000644 0000000 0000000 00000026054 14265350055 016435 0 ustar 00 import 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)))
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/wifirepeater.py 0000644 0000000 0000000 00000010264 14265350055 015006 0 ustar 00 import 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"]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/wifispeaker.py 0000644 0000000 0000000 00000011522 14265350055 014627 0 ustar 00 import 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"])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/miio/yeelight_dual_switch.py 0000644 0000000 0000000 00000020703 14265350055 016517 0 ustar 00 import 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179628.5386136
python-miio-0.5.12/pyproject.toml 0000644 0000000 0000000 00000005243 14265350055 013726 0 ustar 00 [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"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179638.3640165
python-miio-0.5.12/setup.py 0000644 0000000 0000000 00000032514 14265350066 012527 0 ustar 00 # -*- 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)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1658179638.3649359
python-miio-0.5.12/PKG-INFO 0000644 0000000 0000000 00000030072 14265350066 012107 0 ustar 00 Metadata-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