commit 488aafc97079b8858632d8efdf628a4ebab2a2c1 Author: Nils Schulte Date: Thu Jan 21 17:33:54 2021 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3752bfc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*wifi-credentials diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..53d1f3d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,675 @@ + 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 +. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5919e7c --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Heart-Light +![Front View](img/front-view.jpg) + +In this repo you can find the source for a [MicroPython](https://micropython.org/) based WS2812b-Project. +The microprozessor running it is the ESP32 (NodeMCU32S to be specific). +It also has a bmp280 termperature/pressure sensor and 2 pushbuttons for switching the animation. + + +Besides the buttons it can also be controlled via the MQTT. +It implements the [homie](https://homieiot.github.io/) convention (functionality provided by the [microhomie](https://github.com/microhomie/microhomie) library). + + +## Build and use +The case is made from cardboard and put together with loads of hot glue, which look ok, but you can 3D-Print something as well. +All LEDs are glued to the walls and the ESP32 on the back. +The light diffuser used on the picture is a acrylic sheet but i found white paper to work as well here. + +If you have problems while building this and want help, feel free to open up an issue here :) + +I use this with [OpenHAB](http://www.openhab.org/) and it works wonderfully. + +## Flashing +Download the MicroPython [binary for the ESP32](https://micropython.org/download/esp32/). + +On Linux it can be then be flashed via: +``` +esptool.py --chip esp32 erase_flash ; +esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash -z 0x1000 "$(find ~/ -name 'esp32*.bin' | head -n1)" +``` +Edit the `settings.py` to your liking and put your wifi credentials (seperated by simple newlines) into a file called `wifi-credentials` on the root of the MicroPython filesystem. +Then upload all the `.py` files and the credentials with [rshell](https://github.com/dhylands/rshell) or [mpfshell](https://github.com/wendlers/mpfshell) for example. + diff --git a/img/front-view.jpg b/img/front-view.jpg new file mode 100644 index 0000000..dce147e Binary files /dev/null and b/img/front-view.jpg differ diff --git a/src/bmp280_node.py b/src/bmp280_node.py new file mode 100644 index 0000000..6541efb --- /dev/null +++ b/src/bmp280_node.py @@ -0,0 +1,42 @@ + +from homie.constants import FLOAT +from homie.property import HomieProperty +from update_homie_node import UpdateHomieNode +# from homie.device import await_ready_state + +# import uasyncio as asyncio +# from time import ticks_ms, ticks_add, ticks_diff + + +class BMP280Node(UpdateHomieNode): + + def __init__( + self, + id, + name, + bmp280, + interval=60*5): + super().__init__(id=id, name=name, type="sensor", interval=interval) + + # BMP280 + self.bmp280 = bmp280 + self.property_temerature = HomieProperty( + id="temperature", + name="Temperatur", + datatype=FLOAT, + unit="°C", + ) + self.add_property(self.property_temerature) + self.property_pressure = HomieProperty( + id="pressure", + name="Druck", + datatype=FLOAT, + unit="Pa", + ) + self.add_property(self.property_pressure) + + def update_data(self): + self.property_pressure.value = "{:1.0f}".format( + self.bmp280.pressure * 100) # hPa = 100 Pa + self.property_temerature.value = "{:1.2f}".format( + self.bmp280.temperature) diff --git a/src/boot.py b/src/boot.py new file mode 100644 index 0000000..c66d1d9 --- /dev/null +++ b/src/boot.py @@ -0,0 +1,2 @@ +# import webrepl +# webrepl.start() diff --git a/src/led_anim.py b/src/led_anim.py new file mode 100644 index 0000000..4a4392f --- /dev/null +++ b/src/led_anim.py @@ -0,0 +1,148 @@ +from math import sin, pi + + +def hsv2rgb(h, s, v): + if s == 0.0: + v *= 255 + return (v, v, v) + i = int(h*6.) # XXX assume int() truncates! + f = (h*6.)-i + p, q, t = int(255*(v*(1.-s))), int(255*(v*(1.-s*f)) + ), int(255*(v*(1.-s*(1.-f)))) + v *= 255 + i %= 6 + v, t, p, q = int(v), int(t), int(p), int(q) + if i == 0: + return (v, t, p) + if i == 1: + return (q, v, p) + if i == 2: + return (p, v, t) + if i == 3: + return (p, q, v) + if i == 4: + return (t, p, v) + if i == 5: + return (v, p, q) + + +class AnimHeartbeat: + name = "Heartbeat" + + def __init__(self, leds): + pass + + def render(self, leds, t, brightness=1): + t = t / 550.0 + brightness *= 1.0 + 4.0 * sin(t + 1.5) * sin(t)**43.0 + leds.fill((int(brightness * 255), 0, 0)) + leds.write() + return 16 + + +class AnimRegenbogen: + name = "Regenbogen" + + def __init__(self, leds): + pass + + def render(self, leds, t, brightness=1): + t = t / 2400.0 + for i in range(leds.n): + leds[i] = hsv2rgb(i / leds.n + t, 1, brightness) + leds.write() + return 16 + + +class AnimGanzeFarben: + name = "Ganze Farben" + + def __init__(self, leds): + pass + + def render(self, leds, t, brightness=1): + t = t / 40000.0 + leds.fill(hsv2rgb(t, 1, brightness)) + leds.write() + return 16 + + +class AnimFarbverlauf: + name = "Farbverlauf" + + def __init__(self, leds): + pass + + def render(self, leds, t, brightness=1): + t = t / 1000.0 + for i in range(leds.n): + leds[i] = hsv2rgb(t/60 + 0.3921 * + sin(i / leds.n * pi + t), 1, brightness) + leds.write() + return 16 + + +class AnimHalbeHalbe: + name = "Halbe-Halbe" + + def __init__(self, leds): + pass + + def render(self, leds, t, brightness=1): + for i in range(leds.n): + offset = 0 if sin(float(i)/leds.n*pi*2-t/55234.0) > 0.0 else 0.5 + leds[i] = hsv2rgb((t/60000.0 + offset) % 1, 1, brightness) + leds.write() + return 16 + + +class AnimKnightRider: + name = "Knight Rider" + + def __init__(self, leds): + pass + + def render(self, leds, t, brightness=1): + t = t / 400.0 + leds.fill((10, 10, 10)) + leds[int(leds.n / 2 + sin(t) * leds.n / 3)] = (255, 0, 0) + leds.write() + return 90 + + +class AnimStrobo: + name = "Stroboskop" + + def __init__(self, leds): + self.on = True + pass + + def render(self, leds, t, brightness=1): + leds.fill((255, 255, 255) if self.on else (0, 0, 0)) + self.on = not self.on + leds.write() + return 10 if self.on else 40 + + +class AnimLeselicht: + name = "Leselicht" + + def __init__(self, leds): + self.on = True + pass + + def render(self, leds, t, brightness=1): + b = int(brightness * 255) + leds.fill((b, b, b)) + leds.write() + return 1000 + + +ANIMS = [AnimHeartbeat, + AnimRegenbogen, + AnimGanzeFarben, + AnimFarbverlauf, + AnimHalbeHalbe, + AnimKnightRider, + AnimStrobo, + AnimLeselicht] diff --git a/src/led_control_node.py b/src/led_control_node.py new file mode 100644 index 0000000..02aae09 --- /dev/null +++ b/src/led_control_node.py @@ -0,0 +1,201 @@ +import uasyncio as asyncio +from time import ticks_ms, ticks_add, ticks_diff + +from homie.constants import BOOLEAN, TRUE, FALSE, FLOAT, ENUM, COLOR, RGB +from homie.property import HomieProperty +from homie.node import HomieNode +from machine import Pin +from primitives.pushbutton import Pushbutton + +import gc +from led_anim import ANIMS + + +class LEDControlNode(HomieNode): + + button_names = ("up", "down") + + def __init__(self, id, name, pin_up, pin_down, leds): + super().__init__(id=id, name=name, type="colorlight") + pin_up.init(mode=Pin.IN, pull=Pin.PULL_UP) + pin_down.init(mode=Pin.IN, pull=Pin.PULL_UP) + button_up = Pushbutton(pin_up, suppress=True, sense=1) + button_down = Pushbutton(pin_down, suppress=True, sense=1) + self.buttons = (button_up, button_down) + self.on_buttons_pressed = [None, None] + self.on_buttons_released = [None, None] + self.leds = leds + + self.properties_button_pressed = [None, None] + for i, button_name in enumerate(self.button_names): + self.properties_button_pressed[i] = HomieProperty( + id="button_{0}_pressed".format(button_name), + name="Knöpgen \"{0}\" gedrückt".format(button_name), + settable=True, + default=FALSE, + on_message=self._on_button_pressed_msg, + datatype=BOOLEAN, + ) + self.add_property(self.properties_button_pressed[i]) + self.buttons[i].press_func(self._on_buttons_pressed, args=(i,)) + self.buttons[i].release_func(self._on_buttons_released, args=(i,)) + + self._power = False + self.property_power = HomieProperty( + id="power", + name="Power", + settable=True, + datatype=BOOLEAN, + default=FALSE, + on_message=self.on_power_msg, + ) + self.add_property(self.property_power) + + self.brightness = 0.7 + self.property_brightness = HomieProperty( + id="brightness", + name="Helligkeit", + settable=True, + datatype=FLOAT, + default=70, + format="0:100", + unit="%", + on_message=self.on_brightness_msg, + ) + self.add_property(self.property_brightness) + + self.anims = dict([(a.name, (a, i+1)) for i, a in enumerate(ANIMS)]) + self._animation_num = 0 + self._animation = None + self.property_animation = HomieProperty( + id="animation", + name="Animation", + settable=True, + datatype=ENUM, + format=",".join(["-"]+list(self.anims.keys())), + default="-", + on_message=self.on_change_anim_msg, + ) + self.add_property(self.property_animation) + + self.color = (0, 0, 0) + self.property_color = HomieProperty( + id="color", + name="Solide Farbe", + settable=True, + datatype=COLOR, + format=RGB, + default="0,0,0", + on_message=self.on_change_color_msg, + ) + self.add_property(self.property_color) + + self.change = False + asyncio.create_task(self._update_data_async()) + + def on_power_msg(self, topic, payload, retained): + self.set_power(payload == TRUE) + self.change = True + + def on_brightness_msg(self, topic, payload, retained): + self.brightness = (float(payload)/100.0*251.0 + 4)/255 + self.change = True + + def on_change_anim_msg(self, topic, payload, retained): + self.set_animation(payload) + + def _on_button_pressed_msg(self, topic, payload, retained): + button_topic_name = topic.split("/")[-1].split("_")[1] + i = -1 + for b, n in enumerate(self.button_names): + if n in button_topic_name: + i = b + break + if {FALSE: False, TRUE: True}[payload] and \ + not self.buttons[i].rawstate(): + self._on_buttons_pressed(i) + self._on_buttons_released(i) + + def _on_buttons_pressed(self, i): + self.properties_button_pressed[i].value = TRUE + + def _on_buttons_released(self, i): + self.properties_button_pressed[i].value = FALSE + self.set_animation_num((1 if i == 0 else -1) + self._animation_num) + + async def _update_data_async(self): + while True: + if self._power: + if self._animation: + wait_next = self._animation.render( + leds=self.leds, t=ticks_ms(), + brightness=self.brightness) + if self.color != (0, 0, 0): + self.leds.fill( + tuple([int(self.brightness * float(i)) + for i in self.color])) + self.leds.write() + wait_next = 10000 + else: + wait_next = 10000 + now = ticks_ms() + wait_till = ticks_add(now, wait_next) + while ticks_diff(wait_till, now) > 0 and not self.change: + await asyncio.sleep_ms( + min(100, ticks_diff(wait_till, now))) + now = ticks_ms() + self.change = False + + def set_power(self, p): + self._power = p + self.property_power.value = TRUE if p else FALSE + if not p: + self.leds.fill((0, 0, 0)) + self.leds.write() + + def set_animation_num(self, p): + p = p % len(ANIMS) + if p == 0: + self.set_animation("") + else: + for a in self.anims.values(): + if a[1] == p: + self.set_animation(a[0]) + break + + def on_change_color_msg(self, topic, payload, retained): + self.set_animation(tuple([int(i) for i in payload.split(",")])) + + def set_animation(self, p): + if p in ANIMS: + self._animation = p + for a in self.anims.values(): + if a[0] == self._animation: + self._animation = a[0](self.leds) + self._animation_num = a[1] + self.set_power(True) + break + if type(p) == str: + if p == "-" or p == "": + self.set_power(False) + self._animation_num = 0 + self._animation = None + else: + self.set_power(True) + a = self.anims[p] + self._animation = a[0](self.leds) + self._animation_num = a[1] + if type(p) == tuple: + if max(p) == 0: + self.set_power(False) + else: + self.set_power(True) + self._animation_num = 0 + self._animation = None + self.color = p + if self._animation: + self.color = (0, 0, 0) + self.property_color.value = str(self.color)[1:-1] + self.change = True + self.property_animation.value = self._animation.name if self._animation else "-" + gc.collect() diff --git a/src/lib/bme280.py b/src/lib/bme280.py new file mode 100644 index 0000000..d3e783a --- /dev/null +++ b/src/lib/bme280.py @@ -0,0 +1,261 @@ +# Updated 2018 and 2020 +# This module is based on the below cited resources, which are all +# based on the documentation as provided in the Bosch Data Sheet and +# the sample implementation provided therein. +# +# Final Document: BST-BME280-DS002-15 +# +# Authors: Paul Cunnane 2016, Peter Dahlebrg 2016 +# +# This module borrows from the Adafruit BME280 Python library. Original +# Copyright notices are reproduced below. +# +# Those libraries were written for the Raspberry Pi. This modification is +# intended for the MicroPython and esp8266 boards. +# +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola +# +# Based on the BMP280 driver with BME280 changes provided by +# David J Taylor, Edinburgh (www.satsignal.eu) +# +# Based on Adafruit_I2C.py created by Kevin Townsend. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +import time +from ustruct import unpack, unpack_from +from array import array + +# BME280 default address. +BME280_I2CADDR = 0x76 + +# Operating Modes +BME280_OSAMPLE_1 = 1 +BME280_OSAMPLE_2 = 2 +BME280_OSAMPLE_4 = 3 +BME280_OSAMPLE_8 = 4 +BME280_OSAMPLE_16 = 5 + +BME280_REGISTER_CONTROL_HUM = 0xF2 +BME280_REGISTER_STATUS = 0xF3 +BME280_REGISTER_CONTROL = 0xF4 + +MODE_SLEEP = const(0) +MODE_FORCED = const(1) +MODE_NORMAL = const(3) + +BME280_TIMEOUT = const(100) # about 1 second timeout + + +class BME280: + + def __init__(self, + mode=BME280_OSAMPLE_8, + address=BME280_I2CADDR, + i2c=None, + **kwargs): + # Check that mode is valid. + if mode not in [BME280_OSAMPLE_1, BME280_OSAMPLE_2, BME280_OSAMPLE_4, + BME280_OSAMPLE_8, BME280_OSAMPLE_16]: + raise ValueError( + 'Unexpected mode value {0}. Set mode to one of ' + 'BME280_OSAMPLE_1, BME280_OSAMPLE_2, BME280_OSAMPLE_4,' + 'BME280_OSAMPLE_8, BME280_OSAMPLE_16'.format(mode)) + self._mode = mode + self.address = address + if i2c is None: + raise ValueError('An I2C object is required.') + self.i2c = i2c + self.__sealevel = 101325 + + # load calibration data + dig_88_a1 = self.i2c.readfrom_mem(self.address, 0x88, 26) + dig_e1_e7 = self.i2c.readfrom_mem(self.address, 0xE1, 7) + + self.dig_T1, self.dig_T2, self.dig_T3, self.dig_P1, \ + self.dig_P2, self.dig_P3, self.dig_P4, self.dig_P5, \ + self.dig_P6, self.dig_P7, self.dig_P8, self.dig_P9, \ + _, self.dig_H1 = unpack("> 4 + raw_press = ((readout[0] << 16) | (readout[1] << 8) | readout[2]) >> 4 + # temperature(0xFA): ((msb << 16) | (lsb << 8) | xlsb) >> 4 + raw_temp = ((readout[3] << 16) | (readout[4] << 8) | readout[5]) >> 4 + # humidity(0xFD): (msb << 8) | lsb + raw_hum = (readout[6] << 8) | readout[7] + + result[0] = raw_temp + result[1] = raw_press + result[2] = raw_hum + + def read_compensated_data(self, result=None): + """ Reads the data from the sensor and returns the compensated data. + + Args: + result: array of length 3 or alike where the result will be + stored, in temperature, pressure, humidity order. You may use + this to read out the sensor without allocating heap memory + + Returns: + array with temperature, pressure, humidity. Will be the one + from the result parameter if not None + """ + self.read_raw_data(self._l3_resultarray) + raw_temp, raw_press, raw_hum = self._l3_resultarray + # temperature + var1 = (raw_temp/16384.0 - self.dig_T1/1024.0) * self.dig_T2 + var2 = raw_temp/131072.0 - self.dig_T1/8192.0 + var2 = var2 * var2 * self.dig_T3 + self.t_fine = int(var1 + var2) + temp = (var1 + var2) / 5120.0 + temp = max(-40, min(85, temp)) + + # pressure + var1 = (self.t_fine/2.0) - 64000.0 + var2 = var1 * var1 * self.dig_P6 / 32768.0 + var1 * self.dig_P5 * 2.0 + var2 = (var2 / 4.0) + (self.dig_P4 * 65536.0) + var1 = (self.dig_P3 * var1 * var1 / 524288.0 + + self.dig_P2 * var1) / 524288.0 + var1 = (1.0 + var1 / 32768.0) * self.dig_P1 + if (var1 == 0.0): + pressure = 30000 # avoid exception caused by division by zero + else: + p = ((1048576.0 - raw_press) - (var2 / 4096.0)) * 6250.0 / var1 + var1 = self.dig_P9 * p * p / 2147483648.0 + var2 = p * self.dig_P8 / 32768.0 + pressure = p + (var1 + var2 + self.dig_P7) / 16.0 + pressure = max(30000, min(110000, pressure)) + + # humidity + h = (self.t_fine - 76800.0) + h = ((raw_hum - (self.dig_H4 * 64.0 + self.dig_H5 / 16384.0 * h)) * + (self.dig_H2 / 65536.0 * (1.0 + self.dig_H6 / 67108864.0 * h * + (1.0 + self.dig_H3 / 67108864.0 * h)))) + humidity = h * (1.0 - self.dig_H1 * h / 524288.0) + # humidity = max(0, min(100, humidity)) + + if result: + result[0] = temp + result[1] = pressure + result[2] = humidity + return result + + return array("f", (temp, pressure, humidity)) + + @property + def sealevel(self): + return self.__sealevel + + @sealevel.setter + def sealevel(self, value): + if 30000 < value < 120000: # just ensure some reasonable value + self.__sealevel = value + + @property + def altitude(self): + ''' + Altitude in m. + ''' + from math import pow + try: + p = 44330 * (1.0 - pow(self.read_compensated_data()[1] / + self.__sealevel, 0.1903)) + except: + p = 0.0 + return p + + @property + def dew_point(self): + """ + Compute the dew point temperature for the current Temperature + and Humidity measured pair + """ + from math import log + t, p, h = self.read_compensated_data() + h = (log(h, 10) - 2) / 0.4343 + (17.62 * t) / (243.12 + t) + return 243.12 * h / (17.62 - h) + + @property + def values(self): + """ human readable values """ + + t, p, h = self.read_compensated_data() + + return ("{:.2f}C".format(t), "{:.2f}hPa".format(p/100), + "{:.2f}%".format(h)) + + @property + def temperature(self): + """ float in °C """ + t, _, _ = self.read_compensated_data() + return t + @property + def pressure(self): + """ float in hPa """ + _, p, _ = self.read_compensated_data() + return p diff --git a/src/lib/homie/__init__.py b/src/lib/homie/__init__.py new file mode 100644 index 0000000..0552768 --- /dev/null +++ b/src/lib/homie/__init__.py @@ -0,0 +1 @@ +__version__ = "3.0.1" diff --git a/src/lib/homie/constants.py b/src/lib/homie/constants.py new file mode 100644 index 0000000..ce8c1a3 --- /dev/null +++ b/src/lib/homie/constants.py @@ -0,0 +1,50 @@ +from micropython import const + +# Device +QOS = const(1) +MAIN_DELAY = const(1000) +STATS_DELAY = const(60000) +WDT_DELAY = const(100) + +# Device states +STATE_INIT = "init" +STATE_READY = "ready" +STATE_RECOVER = "recover" +STATE_OTA = "ota" +STATE_WEBREPL = "webrepl" + +# Property datatypes +STRING = "string" +ENUM = "enum" +BOOLEAN = "boolean" +INTEGER = "integer" +FLOAT = "float" +COLOR = "color" + +# Property formats +RGB = "rgb" +HSV = "hsv" + +# (Sub)Topics +DEVICE_STATE = "$state" +T_BC = "$broadcast" +T_MPY = "$mpy" +T_SET = "/set" + +# General +UTF8 = "utf-8" +SET = "set" +SLASH = "/" +UNDERSCORE = "_" + +ON = "on" +OFF = "off" +TRUE = "true" +FALSE = "false" +LOCKED = "locked" +UNLOCKED = "unlocked" + +# Build-in extension strings +EXT_MPY = "org.microhomie.mpy:0.1.0:[4.x]" +EXT_FW = "org.homie.legacy-firmware:0.1.1:[4.x]" +EXT_STATS = "org.homie.legacy-stats:0.1.1:[4.x]" diff --git a/src/lib/homie/device.py b/src/lib/homie/device.py new file mode 100644 index 0000000..1879edb --- /dev/null +++ b/src/lib/homie/device.py @@ -0,0 +1,335 @@ +import uasyncio as asyncio + +from gc import collect, mem_free +from sys import platform + +from homie import __version__ +from homie.network import get_local_ip, get_local_mac +from homie.constants import ( + DEVICE_STATE, + MAIN_DELAY, + QOS, + SLASH, + STATE_OTA, + STATE_INIT, + STATE_READY, + STATE_RECOVER, + STATE_WEBREPL, + T_BC, + T_MPY, + T_SET, + UNDERSCORE, + UTF8, + WDT_DELAY, + EXT_MPY, + EXT_FW, + EXT_STATS, +) +from machine import RTC, reset +from mqtt_as import LINUX, MQTTClient +from uasyncio import sleep_ms +from ubinascii import hexlify +from utime import time +from primitives import launch +from primitives.message import Message + + +def get_unique_id(): + if LINUX is False: + from machine import unique_id + return hexlify(unique_id()).decode() + else: + raise NotImplementedError( + "Linux doesn't have a unique id. Provide the DEVICE_ID option in your settings.py." + ) + + +# Decorator to block async tasks until the device is in "ready" state +_MESSAGE = Message() +def await_ready_state(func): + def new_gen(*args, **kwargs): + # fmt: off + await _MESSAGE + await func(*args, **kwargs) + # fmt: on + + return new_gen + + +class HomieDevice: + + """MicroPython implementation of the Homie MQTT convention for IoT.""" + + def __init__(self, settings): + self.debug = getattr(settings, "DEBUG", False) + + self._state = STATE_INIT + self._version = __version__ + self._fw_name = "Microhomie" + self._extensions = getattr(settings, "EXTENSIONS", []) + self._bc_enabled = getattr(settings, "BROADCAST", False) + self._wifi = getattr(settings, "WIFI_CREDENTIALS", False) + self._wifi_rescan_delay = getattr(settings, "WIFI_RESCAN_DELAY", MAIN_DELAY) + + self.first_start = True + self.stats_interval = getattr(settings, "DEVICE_STATS_INTERVAL", 60) + self.device_name = getattr(settings, "DEVICE_NAME", "") + self.callback_topics = {} + + # Registered homie nodes + self.nodes = [] + + # Generate unique id if settings has no DEVICE_ID + self.device_id = getattr(settings, "DEVICE_ID", get_unique_id()) + + # Base topic + self.btopic = getattr(settings, "MQTT_BASE_TOPIC", "homie") + # Device base topic + self.dtopic = "{}/{}".format(self.btopic, self.device_id) + + # mqtt_as client + self.mqtt = MQTTClient( + client_id=self.device_id, + server=settings.MQTT_BROKER, + port=getattr(settings, "MQTT_PORT", 1883), + user=getattr(settings, "MQTT_USERNAME", None), + password=getattr(settings, "MQTT_PASSWORD", None), + keepalive=getattr(settings, "MQTT_KEEPALIVE", 30), + ping_interval=getattr(settings, "MQTT_PING_INTERVAL", 0), + ssl=getattr(settings, "MQTT_SSL", False), + ssl_params=getattr(settings, "MQTT_SSL_PARAMS", {}), + response_time=getattr(settings, "MQTT_RESPONSE_TIME", 10), + clean_init=getattr(settings, "MQTT_CLEAN_INIT", True), + clean=getattr(settings, "MQTT_CLEAN", True), + max_repubs=getattr(settings, "MQTT_MAX_REPUBS", 4), + will=("{}/{}".format(self.dtopic, DEVICE_STATE), "lost", True, QOS), + subs_cb=self.subs_cb, + wifi_coro=None, + connect_coro=self.connection_handler, + ssid=getattr(settings, "WIFI_SSID", None), + wifi_pw=getattr(settings, "WIFI_PASSWORD", None), + ) + + def add_node(self, node): + node.device = self + node.set_topic() # set topic for node properties + _p = node.properties + for p in _p: + p.set_topic() + self.nodes.append(node) + + def all_properties(self, func, tup_args): + """ Run method on all registered property objects """ + _n = self.nodes + for n in _n: + _p = n.properties + for p in _p: + _f = getattr(p, func) + launch(_f, tup_args) + + async def subscribe(self, topic): + self.dprint("MQTT SUBSCRIBE: {}".format(topic)) + await self.mqtt.subscribe(topic, QOS) + + async def unsubscribe(self, topic): + self.dprint("MQTT UNSUBSCRIBE: {}".format(topic)) + await self.mqtt.unsubscribe(topic) + + async def connection_handler(self, client): + """subscribe to all registered device and node topics""" + if not self.first_start: + await self.publish("{}/{}".format(self.dtopic, DEVICE_STATE), STATE_RECOVER) + + # Subscribe to Homie broadcast topic + if self._bc_enabled: + await self.subscribe("{}/{}/#".format(self.btopic, T_BC)) + + # Subscribe to the Micropython extension topic + if EXT_MPY in self._extensions: + await self.subscribe("{}/{}".format(self.dtopic, T_MPY)) + + # Subscribe to node property topics + self.all_properties("subscribe", ()) + + # on first connection: + # * publish device and node properties + # * enable WDT + # * run all coros + if self.first_start is True: + await self.publish_properties() + + # Unsubscribe from retained topics that received no retained message + for t in self.callback_topics: + if not t.endswith(T_SET): + await self.unsubscribe(t) + del self.callback_topics[t] + + # Activate watchdog timer + if not LINUX and not self.debug: + asyncio.create_task(self.wdt()) + + # Start all async tasks decorated with await_ready_state + _MESSAGE.set() + await sleep_ms(MAIN_DELAY) + _MESSAGE.clear() + + # Do not run this if clause again on wifi/broker reconnect + self.first_start = False + + # Publish data from all properties on first start + self.all_properties("publish", ()) + + # Announce that the device is ready + await self.publish("{}/{}".format(self.dtopic, DEVICE_STATE), STATE_READY) + + def subs_cb(self, topic, payload, retained): + """ The main callback for all subscribed topics """ + topic = topic.decode() + payload = payload.decode() + + self.dprint( + "MQTT MESSAGE: {} --> {}, {}".format(topic, payload, retained) + ) + + # Only non-retained messages are allowed on /set topics + if retained and topic.endswith(T_SET): + return + + # broadcast topic + if T_BC in topic: + self.broadcast_callback(topic, payload, retained) + # Micropython extension + elif topic.endswith(T_MPY) and EXT_MPY in self._extensions: + if payload == "reset": + asyncio.create_task(self.reset("reset")) + elif payload == "webrepl": + asyncio.create_task(self.reset("webrepl")) + elif payload == "yaota8266" and platform == "esp8266": + asyncio.create_task(self.reset("yaotaota")) + # All other topics + else: + if topic in self.callback_topics: + self.callback_topics[topic](topic, payload, retained) + + async def publish(self, topic, payload, retain=True): + if isinstance(payload, int): + payload = str(payload).encode() + + if isinstance(payload, str): + payload = payload.encode() + + self.dprint("MQTT PUBLISH: {} --> {}".format(topic, payload)) + await self.mqtt.publish(topic, payload, retain, QOS) + + async def broadcast(self, payload, level=None): + if isinstance(payload, int): + payload = str(payload) + + topic = "{}/{}".format(self.btopic, T_BC) + if level is not None: + topic = "{}/{}".format(topic, level) + self.dprint("MQTT BROADCAST: {} --> {}".format(topic, payload)) + await self.mqtt.publish(topic, payload, retain=False, qos=QOS) + + def broadcast_callback(self, topic, payload, retained): + """ Gets called when the broadcast topic receives a message """ + pass + + async def publish_properties(self): + """ Publish device and node properties """ + _t = self.dtopic + publish = self.publish + + # device properties + await publish("{}/$homie".format(_t), "4.0.0") + await publish("{}/$name".format(_t), self.device_name) + await publish("{}/{}".format(_t, DEVICE_STATE), STATE_INIT) + await publish("{}/$implementation".format(_t), bytes(platform, UTF8)) + await publish( + "{}/$nodes".format(_t), ",".join([n.id for n in self.nodes]) + ) + + # node properties + _n = self.nodes + for n in _n: + await n.publish_properties() + + # extensions + await publish("{}/$extensions".format(_t), ",".join(self._extensions)) + if EXT_FW in self._extensions: + await publish("{}/$localip".format(_t), get_local_ip()) + await publish("{}/$mac".format(_t), get_local_mac()) + await publish("{}/$fw/name".format(_t), self._fw_name) + await publish("{}/$fw/version".format(_t), self._version) + if EXT_STATS in self._extensions: + await self.publish("{}/$stats/interval".format(_t), str(self.stats_interval)) + # Start stats coro + asyncio.create_task(self.publish_stats()) + + @await_ready_state + async def publish_stats(self): + from utime import time + + _d = self.stats_interval * 1000 # delay + _st = time() # start time + _tup = "{}/$stats/uptime".format(self.dtopic) # Uptime topic + _tfh = "{}/$stats/freeheap".format(self.dtopic) # Freeheap topic + + publish = self.publish + while True: + uptime = time() - _st + await publish(_tup, str(uptime)) + await publish(_tfh, str(mem_free())) + await sleep_ms(_d) + + async def run(self): + while True: + try: + if self._wifi: + await self.setup_wifi() + await self.mqtt.connect() + while True: + collect() + await sleep_ms(MAIN_DELAY) + except OSError: + print("ERROR: can not connect to MQTT") + await sleep_ms(5000) + + def run_forever(self): + if RTC().memory() == b"webrepl": + RTC().memory(b"") + else: + asyncio.run(self.run()) + + async def reset(self, reason): + if reason != "reset": + RTC().memory(reason) + await self.publish("{}/{}".format(self.dtopic, DEVICE_STATE), reason) + await self.mqtt.disconnect() + await sleep_ms(500) + reset() + + async def wdt(self): + from machine import WDT + + wdt = WDT() + while True: + wdt.feed() + await sleep_ms(WDT_DELAY) + + def dprint(self, *args): + if self.debug: + print(*args) + + async def setup_wifi(self): + from homie.network import get_wifi_credentials + while True: + wifi_cfg = get_wifi_credentials(self._wifi) + if wifi_cfg is None: + self.dprint("No WiFi found. Rescanning...") + await sleep_ms(self._wifi_rescan_delay) + else: + self.dprint("Connect to SSID: {}".format(wifi_cfg[0])) + self.mqtt._ssid = wifi_cfg[0] + self.mqtt._wifi_pw = wifi_cfg[1] + return diff --git a/src/lib/homie/network.py b/src/lib/homie/network.py new file mode 100644 index 0000000..256a946 --- /dev/null +++ b/src/lib/homie/network.py @@ -0,0 +1,46 @@ +from mqtt_as import LINUX + + +if LINUX is False: + from network import WLAN, AP_IF, STA_IF + from ubinascii import hexlify + + +def enable_ap(): + """Disables any Accesspoint""" + wlan = WLAN(AP_IF) + wlan.active(True) + print("NETWORK: Access Point enabled.") + + +def disable_ap(): + """Disables any Accesspoint""" + wlan = WLAN(AP_IF) + wlan.active(False) + print("NETWORK: Access Point disabled.") + + +def get_local_ip(): + try: + return bytes(WLAN(0).ifconfig()[0], "utf-8") + except NameError: + return b"127.0.0.1" + + +def get_local_mac(): + try: + return hexlify(WLAN(0).config("mac"), ":") + except NameError: + return b"00:00:00:00:00:00" + + +def get_wifi_credentials(wifi): + wlan = WLAN(STA_IF) + ssids = wlan.scan() + + for s in ssids: + ssid = s[0].decode() + if ssid in wifi: + return (ssid, wifi[ssid]) + + return None diff --git a/src/lib/homie/node.py b/src/lib/homie/node.py new file mode 100644 index 0000000..f60cc82 --- /dev/null +++ b/src/lib/homie/node.py @@ -0,0 +1,47 @@ +import uasyncio as asyncio + + +class BaseNode: + def __init__(self, id, name, type): + self.id = id + self.name = name + self.type = type + self.topic = None + self.device = None + self.properties = [] + + def set_topic(self): + self.topic = "{}/{}".format( + self.device.dtopic, + self.id, + ) + + def add_property(self, p, cb=None): + p.node = self + self.properties.append(p) + + if cb: + p.on_message = cb + + async def publish_properties(self): + """General properties of this node""" + publish = self.device.publish + + # Publish name and type + await publish("{}/$name".format(self.topic), self.name) + await publish("{}/$type".format(self.topic), self.type) + + # Publish properties registerd with the node + properties = self.properties + await publish( + "{}/$properties".format(self.topic), + ",".join([p.id for p in properties]), + ) + + # Publish registerd properties + for p in properties: + await p.publish_properties() + + +# Keep for backward compatibility +HomieNode = BaseNode diff --git a/src/lib/homie/property.py b/src/lib/homie/property.py new file mode 100644 index 0000000..7f47531 --- /dev/null +++ b/src/lib/homie/property.py @@ -0,0 +1,137 @@ +import uasyncio as asyncio + +from homie.constants import STRING, T_SET, TRUE, FALSE +from homie.validator import payload_is_valid + + +class BaseProperty: + def __init__( + self, + id, + name=None, + settable=False, + retained=True, + unit=None, + datatype=STRING, + format=None, + default=None, + restore=True, + on_message=None, + ): + self._value = default + + self.id = id + self.name = name + self.settable = settable + self.retained = retained + self.unit = unit + self.datatype = datatype + self.format = format + self.restore = restore + self.on_message = on_message + + self.topic = None + self.node = None + + # Keep for backward compatibility + @property + def data(self): + return self._value + + # Keep for backward compatibility + @data.setter + def data(self, value): + self.value = value + + @property + def value(self): + return self._value + + @value.setter + def value(self, value): + """ Set value if changed and publish to mqtt """ + if value != self._value: + self._value = value + self.publish() + + def set_topic(self): + self.topic = "{}/{}/{}".format( + self.node.device.dtopic, + self.node.id, + self.id + ) + + def publish(self): + asyncio.create_task( + self.node.device.publish( + self.topic, + self.value, + self.retained + ) + ) + + async def subscribe(self): + # Restore from topic with retained message on device start + if self.restore and self.node.device.first_start is True: + self.node.device.callback_topics[self.topic] = self.restore_handler + await self.node.device.subscribe(self.topic) + + # Subscribe to settable (/set) topics + if self.settable is True: + topic = "{}/set".format(self.topic) + self.node.device.callback_topics[topic] = self.message_handler + await self.node.device.subscribe(topic) + + def restore_handler(self, topic, payload, retained): + """ Gets called when the property should be restored from mqtt """ + # Retained messages are not allowed on /set topics + if topic.endswith(T_SET): + return + + # Unsubscribe from topic and remove the callback handler + asyncio.create_task(self.node.device.unsubscribe(topic)) + del self.node.device.callback_topics[topic] + + if payload_is_valid(self, payload): + if payload != self._value: + if self.on_message: + self.on_message(topic, payload, retained) + + self._value = payload + + def message_handler(self, topic, payload, retained): + """ Gets called when the property receive a message on /set topic """ + # No reatained messages allowed on /set topics + if retained: + return + + if payload_is_valid(self, payload): + if self.on_message: + self.on_message(topic, payload, retained) + + self.value = payload + + async def publish_properties(self): + topic = self.topic + publish = self.node.device.publish + + await publish("{}/$name".format(topic), self.name) + await publish("{}/$datatype".format(topic), self.datatype) + + if self.format is not None: + await publish("{}/$format".format(topic), self.format) + + if self.settable is True: + await publish("{}/$settable".format(topic), TRUE) + + if self.retained is False: + await publish("{}/$retained".format(topic), FALSE) + + if self.unit is not None: + await publish("{}/$unit".format(topic), self.unit) + + +HomieProperty = BaseProperty + +# Keep for backward compatibility +HomieNodeProperty = BaseProperty diff --git a/src/lib/homie/validator.py b/src/lib/homie/validator.py new file mode 100644 index 0000000..096ae1a --- /dev/null +++ b/src/lib/homie/validator.py @@ -0,0 +1,54 @@ +from homie.constants import ( + BOOLEAN, + COLOR, + ENUM, + FALSE, + FLOAT, + INTEGER, + STRING, + TRUE, + RGB, +) + + +def payload_is_valid(cls, payload): + _dt = cls.datatype + _fmt = cls.format + + if _dt == STRING: + pass + + elif _dt == INTEGER: + try: + i = int(payload) + if _fmt != None: + first, last = _fmt.split(":") + first = int(first) + last = int(last) + + if i < first or i > last: + return False + except ValueError: + return False + + elif _dt == FLOAT: + try: + float(payload) + except ValueError: + return False + + elif _dt == BOOLEAN: + if payload != TRUE and payload != FALSE: + return False + + elif _dt == ENUM: + _values = cls.format.split(",") + if payload not in _values: + return False + + elif _dt == COLOR: + if _fmt == RGB: + if len(payload.split(",")) != 3: + return False + + return True diff --git a/src/lib/mqtt_as.py b/src/lib/mqtt_as.py new file mode 100644 index 0000000..6d8192e --- /dev/null +++ b/src/lib/mqtt_as.py @@ -0,0 +1,698 @@ +# mqtt_as.py Asynchronous version of umqtt.robust +# (C) Copyright Peter Hinch 2017-2019. +# (C) Copyright Kevin Köck 2018-2019. +# Released under the MIT licence. + +# Pyboard D support added +# Various improvements contributed by Kevin Köck. + +import gc +import usocket as socket +import ustruct as struct + +gc.collect() +from ubinascii import hexlify +import uasyncio as asyncio + +gc.collect() +from utime import ticks_ms, ticks_diff +from uerrno import EINPROGRESS, ETIMEDOUT + +gc.collect() +from micropython import const + +gc.collect() +from sys import platform + +VERSION = (0, 6, 0) + +# Default short delay for good SynCom throughput (avoid sleep(0) with SynCom). +_DEFAULT_MS = const(20) +_SOCKET_POLL_DELAY = const(5) # 100ms added greatly to publish latency + +# Legitimate errors while waiting on a socket. See uasyncio __init__.py open_connection(). +if platform == 'esp32' or platform == 'esp32_LoBo': + # https://forum.micropython.org/viewtopic.php?f=16&t=3608&p=20942#p20942 + BUSY_ERRORS = [EINPROGRESS, ETIMEDOUT, 118, 119] # Add in weird ESP32 errors +else: + BUSY_ERRORS = [EINPROGRESS, ETIMEDOUT] + +ESP8266 = platform == 'esp8266' +ESP32 = platform == 'esp32' +PYBOARD = platform == 'pyboard' +LOBO = platform == 'esp32_LoBo' +LINUX = platform == "linux" + +if LINUX is False: + import network + from machine import unique_id +else: + def unique_id(): + raise NotImplementedError("Linux doesn't have a unique id. Provide the argument client_id") + + +# Default "do little" coro for optional user replacement +async def eliza(*_): # e.g. via set_wifi_handler(coro): see test program + await asyncio.sleep_ms(_DEFAULT_MS) + + +class MQTTException(Exception): + pass + + +def pid_gen(): + pid = 0 + while True: + pid = pid + 1 if pid < 65535 else 1 + yield pid + + +def qos_check(qos): + if not (qos == 0 or qos == 1): + raise ValueError('Only qos 0 and 1 are supported.') + + +# MQTT_base class. Handles MQTT protocol on the basis of a good connection. +# Exceptions from connectivity failures are handled by MQTTClient subclass. +class MQTT_base: + REPUB_COUNT = 0 # TEST + DEBUG = False + + def __init__(self, client_id, server, port, user, password, keepalive, ping_interval, + ssl, ssl_params, response_time, clean_init, clean, max_repubs, will, + subs_cb, wifi_coro, connect_coro, ssid, wifi_pw): + # MQTT config + self.ping_interval = ping_interval + self._client_id = client_id + self._user = user + self._pswd = password + self._keepalive = keepalive + if self._keepalive >= 65536: + raise ValueError('invalid keepalive time') + self._response_time = response_time * 1000 # Repub if no PUBACK received (ms). + self._max_repubs = max_repubs + self._clean_init = clean_init # clean_session state on first connection + self._clean = clean # clean_session state on reconnect + if will is None: + self._lw_topic = False + else: + self._set_last_will(*will) + # WiFi config + self._ssid = ssid # Required ESP32 / Pyboard D + self._wifi_pw = wifi_pw + self._ssl = ssl + self._ssl_params = ssl_params + # Callbacks and coros + self._cb = subs_cb + self._wifi_handler = wifi_coro + self._connect_handler = connect_coro + # Network + self.port = port + if self.port == 0: + self.port = 8883 if self._ssl else 1883 + self.server = server + if self.server is None: + raise ValueError('no server specified.') + self._sock = None + if LINUX is True: + self._sta_isconnected = True + else: + self._sta_if = network.WLAN(network.STA_IF) + self._sta_if.active(True) + + self.newpid = pid_gen() + self.rcv_pids = set() # PUBACK and SUBACK pids awaiting ACK response + self.last_rx = ticks_ms() # Time of last communication from broker + self.lock = asyncio.Lock() + + def _set_last_will(self, topic, msg, retain=False, qos=0): + qos_check(qos) + if not topic: + raise ValueError('Empty topic.') + self._lw_topic = topic + self._lw_msg = msg + self._lw_qos = qos + self._lw_retain = retain + + def dprint(self, *args): + if self.DEBUG: + print(*args) + + def _timeout(self, t): + return ticks_diff(ticks_ms(), t) > self._response_time + + async def _as_read(self, n, sock=None): # OSError caught by superclass + if sock is None: + sock = self._sock + data = b'' + t = ticks_ms() + while len(data) < n: + if self._timeout(t) or not self.isconnected(): + raise OSError(-1) + try: + msg = sock.read(n - len(data)) + except OSError as e: # ESP32 issues weird 119 errors here + msg = None + if e.args[0] not in BUSY_ERRORS: + raise + if msg == b'': # Connection closed by host + raise OSError(-1) + if msg is not None: # data received + data = b''.join((data, msg)) + t = ticks_ms() + self.last_rx = ticks_ms() + await asyncio.sleep_ms(_SOCKET_POLL_DELAY) + return data + + async def _as_write(self, bytes_wr, length=0, sock=None): + if sock is None: + sock = self._sock + if length: + bytes_wr = bytes_wr[:length] + t = ticks_ms() + while bytes_wr: + if self._timeout(t) or not self.isconnected(): + raise OSError(-1) + try: + n = sock.write(bytes_wr) + except OSError as e: # ESP32 issues weird 119 errors here + n = 0 + if e.args[0] not in BUSY_ERRORS: + raise + if n: + t = ticks_ms() + bytes_wr = bytes_wr[n:] + await asyncio.sleep_ms(_SOCKET_POLL_DELAY) + + async def _send_str(self, s): + await self._as_write(struct.pack("!H", len(s))) + await self._as_write(s) + + async def _recv_len(self): + n = 0 + sh = 0 + while 1: + res = await self._as_read(1) + b = res[0] + n |= (b & 0x7f) << sh + if not b & 0x80: + return n + sh += 7 + + async def _connect(self, clean): + self._sock = socket.socket() + self._sock.setblocking(False) + try: + self._sock.connect(self._addr) + except OSError as e: + if e.args[0] not in BUSY_ERRORS: + raise + await asyncio.sleep_ms(_DEFAULT_MS) + self.dprint('Connecting to broker.') + if self._ssl: + import ussl + self._sock = ussl.wrap_socket(self._sock, **self._ssl_params) + premsg = bytearray(b"\x10\0\0\0\0\0") + msg = bytearray(b"\x04MQTT\x04\0\0\0") # Protocol 3.1.1 + + sz = 10 + 2 + len(self._client_id) + msg[6] = clean << 1 + if self._user: + sz += 2 + len(self._user) + 2 + len(self._pswd) + msg[6] |= 0xC0 + if self._keepalive: + msg[7] |= self._keepalive >> 8 + msg[8] |= self._keepalive & 0x00FF + if self._lw_topic: + sz += 2 + len(self._lw_topic) + 2 + len(self._lw_msg) + msg[6] |= 0x4 | (self._lw_qos & 0x1) << 3 | (self._lw_qos & 0x2) << 3 + msg[6] |= self._lw_retain << 5 + + i = 1 + while sz > 0x7f: + premsg[i] = (sz & 0x7f) | 0x80 + sz >>= 7 + i += 1 + premsg[i] = sz + await self._as_write(premsg, i + 2) + await self._as_write(msg) + await self._send_str(self._client_id) + if self._lw_topic: + await self._send_str(self._lw_topic) + await self._send_str(self._lw_msg) + if self._user: + await self._send_str(self._user) + await self._send_str(self._pswd) + # Await CONNACK + # read causes ECONNABORTED if broker is out; triggers a reconnect. + resp = await self._as_read(4) + self.dprint('Connected to broker.') # Got CONNACK + if resp[3] != 0 or resp[0] != 0x20 or resp[1] != 0x02: + raise OSError(-1) # Bad CONNACK e.g. authentication fail. + + async def _ping(self): + async with self.lock: + await self._as_write(b"\xc0\0") + + # Check internet connectivity by sending DNS lookup to Google's 8.8.8.8 + async def wan_ok(self, + packet=b'$\x1a\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03www\x06google\x03com\x00\x00\x01\x00\x01'): + if not self.isconnected(): # WiFi is down + return False + length = 32 # DNS query and response packet size + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setblocking(False) + s.connect(('8.8.8.8', 53)) + await asyncio.sleep(1) + try: + await self._as_write(packet, sock=s) + await asyncio.sleep(2) + res = await self._as_read(length, s) + if len(res) == length: + return True # DNS response size OK + except OSError: # Timeout on read: no connectivity. + return False + finally: + s.close() + return False + + async def broker_up(self): # Test broker connectivity + if not self.isconnected(): + return False + tlast = self.last_rx + if ticks_diff(ticks_ms(), tlast) < 1000: + return True + try: + await self._ping() + except OSError: + return False + t = ticks_ms() + while not self._timeout(t): + await asyncio.sleep_ms(100) + if ticks_diff(self.last_rx, tlast) > 0: # Response received + return True + return False + + async def disconnect(self): + try: + async with self.lock: + self._sock.write(b"\xe0\0") + except OSError: + pass + self._has_connected = False + self.close() + + def close(self): + if self._sock is not None: + self._sock.close() + + async def _await_pid(self, pid): + t = ticks_ms() + while pid in self.rcv_pids: # local copy + if self._timeout(t) or not self.isconnected(): + break # Must repub or bail out + await asyncio.sleep_ms(100) + else: + return True # PID received. All done. + return False + + # qos == 1: coro blocks until wait_msg gets correct PID. + # If WiFi fails completely subclass re-publishes with new PID. + async def publish(self, topic, msg, retain, qos): + pid = next(self.newpid) + if qos: + self.rcv_pids.add(pid) + async with self.lock: + await self._publish(topic, msg, retain, qos, 0, pid) + if qos == 0: + return + + count = 0 + while 1: # Await PUBACK, republish on timeout + if await self._await_pid(pid): + return + # No match + if count >= self._max_repubs or not self.isconnected(): + raise OSError(-1) # Subclass to re-publish with new PID + async with self.lock: + await self._publish(topic, msg, retain, qos, dup=1, pid=pid) # Add pid + count += 1 + self.REPUB_COUNT += 1 + + async def _publish(self, topic, msg, retain, qos, dup, pid): + pkt = bytearray(b"\x30\0\0\0") + pkt[0] |= qos << 1 | retain | dup << 3 + sz = 2 + len(topic) + len(msg) + if qos > 0: + sz += 2 + if sz >= 2097152: + raise MQTTException('Strings too long.') + i = 1 + while sz > 0x7f: + pkt[i] = (sz & 0x7f) | 0x80 + sz >>= 7 + i += 1 + pkt[i] = sz + await self._as_write(pkt, i + 1) + await self._send_str(topic) + if qos > 0: + struct.pack_into("!H", pkt, 0, pid) + await self._as_write(pkt, 2) + await self._as_write(msg) + + # Can raise OSError if WiFi fails. Subclass traps + async def subscribe(self, topic, qos): + pkt = bytearray(b"\x82\0\0\0") + pid = next(self.newpid) + self.rcv_pids.add(pid) + struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic) + 1, pid) + async with self.lock: + await self._as_write(pkt) + await self._send_str(topic) + await self._as_write(qos.to_bytes(1, "little")) + + if not await self._await_pid(pid): + raise OSError(-1) + + # Can raise OSError if WiFi fails. Subclass traps + async def unsubscribe(self, topic): + pkt = bytearray(b"\xa2\0\0\0") + pid = next(self.newpid) + self.rcv_pids.add(pid) + struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic), pid) + async with self.lock: + await self._as_write(pkt) + await self._send_str(topic) + + if not await self._await_pid(pid): + raise OSError(-1) + + # Wait for a single incoming MQTT message and process it. + # Subscribed messages are delivered to a callback previously + # set by .setup() method. Other (internal) MQTT + # messages processed internally. + # Immediate return if no data available. Called from ._handle_msg(). + async def wait_msg(self): + res = self._sock.read(1) # Throws OSError on WiFi fail + if res is None: + return + if res == b'': + raise OSError(-1) + + if res == b"\xd0": # PINGRESP + await self._as_read(1) # Update .last_rx time + return + op = res[0] + + if op == 0x40: # PUBACK: save pid + sz = await self._as_read(1) + if sz != b"\x02": + raise OSError(-1) + rcv_pid = await self._as_read(2) + pid = rcv_pid[0] << 8 | rcv_pid[1] + if pid in self.rcv_pids: + self.rcv_pids.discard(pid) + else: + raise OSError(-1) + + if op == 0x90: # SUBACK + resp = await self._as_read(4) + if resp[3] == 0x80: + raise OSError(-1) + pid = resp[2] | (resp[1] << 8) + if pid in self.rcv_pids: + self.rcv_pids.discard(pid) + else: + raise OSError(-1) + + if op == 0xB0: # UNSUBACK + resp = await self._as_read(3) + pid = resp[2] | (resp[1] << 8) + if pid in self.rcv_pids: + self.rcv_pids.discard(pid) + else: + raise OSError(-1) + + if op & 0xf0 != 0x30: + return + sz = await self._recv_len() + topic_len = await self._as_read(2) + topic_len = (topic_len[0] << 8) | topic_len[1] + topic = await self._as_read(topic_len) + sz -= topic_len + 2 + if op & 6: + pid = await self._as_read(2) + pid = pid[0] << 8 | pid[1] + sz -= 2 + msg = await self._as_read(sz) + retained = op & 0x01 + self._cb(topic, msg, bool(retained)) + if op & 6 == 2: # qos 1 + pkt = bytearray(b"\x40\x02\0\0") # Send PUBACK + struct.pack_into("!H", pkt, 2, pid) + await self._as_write(pkt) + elif op & 6 == 4: # qos 2 not supported + raise OSError(-1) + + +# MQTTClient class. Handles issues relating to connectivity. + +class MQTTClient(MQTT_base): + def __init__(self, client_id=None, + server=None, + port=0, + user='', + password='', + keepalive=60, + ping_interval=0, + ssl=False, + ssl_params={}, + response_time=10, + clean_init=True, + clean=True, + max_repubs=4, + will=None, + subs_cb=lambda *_: None, + wifi_coro=None, + connect_coro=None, + ssid=None, + wifi_pw=None): + client_id = client_id or hexlify(unique_id()) + wifi_coro = wifi_coro or eliza + connect_coro = connect_coro or eliza + super().__init__(client_id, server, port, user, password, keepalive, ping_interval, + ssl, ssl_params, response_time, clean_init, clean, max_repubs, will, + subs_cb, wifi_coro, connect_coro, ssid, wifi_pw) + self._isconnected = False # Current connection state + keepalive = 1000 * self._keepalive # ms + self._ping_interval = keepalive // 4 if keepalive else 20000 + p_i = self.ping_interval * 1000 # Can specify shorter e.g. for subscribe-only + if p_i and p_i < self._ping_interval: + self._ping_interval = p_i + self._in_connect = False + self._has_connected = False # Define 'Clean Session' value to use. + if ESP8266: + import esp + esp.sleep_type(0) # Improve connection integrity at cost of power consumption. + + async def wifi_connect(self): + if LINUX is True: # no network control, assume connected as OS takes care of that + self._sta_isconnected = True + return + s = self._sta_if + if ESP8266: + if s.isconnected(): # 1st attempt, already connected. + return + s.active(True) + s.connect() # ESP8266 remembers connection. + for _ in range(60): + if s.status() != network.STAT_CONNECTING: # Break out on fail or success. Check once per sec. + break + await asyncio.sleep(1) + if s.status() == network.STAT_CONNECTING: # might hang forever awaiting dhcp lease renewal or something else + s.disconnect() + await asyncio.sleep(1) + if not s.isconnected() and self._ssid is not None and self._wifi_pw is not None: + s.connect(self._ssid, self._wifi_pw) + while s.status() == network.STAT_CONNECTING: # Break out on fail or success. Check once per sec. + await asyncio.sleep(1) + else: + s.active(True) + s.connect(self._ssid, self._wifi_pw) + if PYBOARD: # Doesn't yet have STAT_CONNECTING constant + while s.status() in (1, 2): + await asyncio.sleep(1) + elif LOBO: + i = 0 + while not s.isconnected(): + await asyncio.sleep(1) + i += 1 + if i >= 10: + break + else: + while s.status() == network.STAT_CONNECTING: # Break out on fail or success. Check once per sec. + await asyncio.sleep(1) + + if not s.isconnected(): + raise OSError + # Ensure connection stays up for a few secs. + self.dprint('Checking WiFi integrity.') + for _ in range(5): + if not s.isconnected(): + raise OSError # in 1st 5 secs + await asyncio.sleep(1) + self.dprint('Got reliable connection') + + async def connect(self): + if not self._has_connected: + await self.wifi_connect() # On 1st call, caller handles error + # Note this blocks if DNS lookup occurs. Do it once to prevent + # blocking during later internet outage: + self._addr = socket.getaddrinfo(self.server, self.port)[0][-1] + self._in_connect = True # Disable low level ._isconnected check + clean = self._clean if self._has_connected else self._clean_init + try: + await self._connect(clean) + except Exception: + self.close() + raise + self.rcv_pids.clear() + # If we get here without error broker/LAN must be up. + self._isconnected = True + self._in_connect = False # Low level code can now check connectivity. + loop = asyncio.get_event_loop() + loop.create_task(self._wifi_handler(True)) # User handler. + if not self._has_connected: + self._has_connected = True # Use normal clean flag on reconnect. + loop.create_task( + self._keep_connected()) # Runs forever unless user issues .disconnect() + + loop.create_task(self._handle_msg()) # Tasks quit on connection fail. + loop.create_task(self._keep_alive()) + if self.DEBUG: + loop.create_task(self._memory()) + loop.create_task(self._connect_handler(self)) # User handler. + + # Launched by .connect(). Runs until connectivity fails. Checks for and + # handles incoming messages. + async def _handle_msg(self): + try: + while self.isconnected(): + async with self.lock: + await self.wait_msg() # Immediate return if no message + await asyncio.sleep_ms(_DEFAULT_MS) # Let other tasks get lock + + except OSError: + pass + self._reconnect() # Broker or WiFi fail. + + # Keep broker alive MQTT spec 3.1.2.10 Keep Alive. + # Runs until ping failure or no response in keepalive period. + async def _keep_alive(self): + while self.isconnected(): + pings_due = ticks_diff(ticks_ms(), self.last_rx) // self._ping_interval + if pings_due >= 4: + self.dprint('Reconnect: broker fail.') + break + elif pings_due >= 1: + try: + await self._ping() + except OSError: + break + await asyncio.sleep(1) + self._reconnect() # Broker or WiFi fail. + + # DEBUG: show RAM messages. + async def _memory(self): + count = 0 + while self.isconnected(): # Ensure just one instance. + await asyncio.sleep(1) # Quick response to outage. + count += 1 + count %= 20 + if not count: + gc.collect() + print('RAM free {} alloc {}'.format(gc.mem_free(), gc.mem_alloc())) + + def isconnected(self): + if self._in_connect: # Disable low-level check during .connect() + return True + if LINUX is True: + if self._isconnected and self._sta_isconnected is False: + self._reconnect() + else: + if self._isconnected and not self._sta_if.isconnected(): # It's going down. + self._reconnect() + return self._isconnected + + def _reconnect(self): # Schedule a reconnection if not underway. + if self._isconnected: + self._isconnected = False + self.close() + loop = asyncio.get_event_loop() + loop.create_task(self._wifi_handler(False)) # User handler. + + # Await broker connection. + async def _connection(self): + while not self._isconnected: + await asyncio.sleep(1) + + # Scheduled on 1st successful connection. Runs forever maintaining wifi and + # broker connection. Must handle conditions at edge of WiFi range. + async def _keep_connected(self): + while self._has_connected: + if self.isconnected(): # Pause for 1 second + await asyncio.sleep(1) + gc.collect() + else: + if LINUX is True: + self._sta_isconnected = False + else: + self._sta_if.disconnect() + await asyncio.sleep(1) + try: + await self.wifi_connect() + except OSError: + continue + if not self._has_connected: # User has issued the terminal .disconnect() + self.dprint('Disconnected, exiting _keep_connected') + break + try: + await self.connect() + # Now has set ._isconnected and scheduled _connect_handler(). + self.dprint('Reconnect OK!') + except OSError as e: + self.dprint('Error in reconnect.', e) + # Can get ECONNABORTED or -1. The latter signifies no or bad CONNACK received. + self.close() # Disconnect and try again. + self._in_connect = False + self._isconnected = False + self.dprint('Disconnected, exited _keep_connected') + + async def subscribe(self, topic, qos=0): + qos_check(qos) + while 1: + await self._connection() + try: + return await super().subscribe(topic, qos) + except OSError: + pass + self._reconnect() # Broker or WiFi fail. + + async def unsubscribe(self, topic): + while 1: + await self._connection() + try: + return await super().unsubscribe(topic) + except OSError: + pass + self._reconnect() # Broker or WiFi fail. + + async def publish(self, topic, msg, retain=False, qos=0): + qos_check(qos) + while 1: + await self._connection() + try: + return await super().publish(topic, msg, retain, qos) + except OSError: + pass + self._reconnect() # Broker or WiFi fail. diff --git a/src/lib/primitives/__init__.py b/src/lib/primitives/__init__.py new file mode 100644 index 0000000..0274fc2 --- /dev/null +++ b/src/lib/primitives/__init__.py @@ -0,0 +1,31 @@ +# __init__.py Common functions for uasyncio primitives + +# Copyright (c) 2018-2020 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file + +try: + import uasyncio as asyncio +except ImportError: + import asyncio + + +async def _g(): + pass +type_coro = type(_g()) + +# If a callback is passed, run it and return. +# If a coro is passed initiate it and return. +# coros are passed by name i.e. not using function call syntax. +def launch(func, tup_args): + res = func(*tup_args) + if isinstance(res, type_coro): + res = asyncio.create_task(res) + return res + +def set_global_exception(): + def _handle_exception(loop, context): + import sys + sys.print_exception(context["exception"]) + sys.exit() + loop = asyncio.get_event_loop() + loop.set_exception_handler(_handle_exception) diff --git a/src/lib/primitives/delay_ms.py b/src/lib/primitives/delay_ms.py new file mode 100644 index 0000000..7424335 --- /dev/null +++ b/src/lib/primitives/delay_ms.py @@ -0,0 +1,69 @@ +# delay_ms.py + +# Copyright (c) 2018-2020 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file +# Rewritten for uasyncio V3. Allows stop time to be brought forwards. + +import uasyncio as asyncio +from utime import ticks_add, ticks_diff, ticks_ms +from micropython import schedule +from . import launch +# Usage: +# from primitives.delay_ms import Delay_ms + +class Delay_ms: + verbose = False # verbose and can_alloc retained to avoid breaking code. + def __init__(self, func=None, args=(), can_alloc=True, duration=1000): + self._func = func + self._args = args + self._duration = duration # Default duration + self._tstop = None # Stop time (ms). None signifies not running. + self._tsave = None # Temporary storage for stop time + self._ktask = None # timer task + self._retrn = None # Return value of launched callable + self._do_trig = self._trig # Avoid allocation in .trigger + + def stop(self): + if self._ktask is not None: + self._ktask.cancel() + + def trigger(self, duration=0): # Update end time + now = ticks_ms() + if duration <= 0: # Use default set by constructor + duration = self._duration + self._retrn = None + is_running = self() + tstop = self._tstop # Current stop time + # Retriggering normally just updates ._tstop for ._timer + self._tstop = ticks_add(now, duration) + # Identify special case where we are bringing the end time forward + can = is_running and duration < ticks_diff(tstop, now) + if not is_running or can: + schedule(self._do_trig, can) + + def _trig(self, can): + if can: + self._ktask.cancel() + self._ktask = asyncio.create_task(self._timer(can)) + + def __call__(self): # Current running status + return self._tstop is not None + + running = __call__ + + def rvalue(self): + return self._retrn + + async def _timer(self, restart): + if restart: # Restore cached end time + self._tstop = self._tsave + try: + twait = ticks_diff(self._tstop, ticks_ms()) + while twait > 0: # Must loop here: might be retriggered + await asyncio.sleep_ms(twait) + twait = ticks_diff(self._tstop, ticks_ms()) + if self._func is not None: # Timed out: execute callback + self._retrn = launch(self._func, self._args) + finally: + self._tsave = self._tstop # Save in case we restart. + self._tstop = None # timer is stopped diff --git a/src/lib/primitives/message.py b/src/lib/primitives/message.py new file mode 100644 index 0000000..fc24bb7 --- /dev/null +++ b/src/lib/primitives/message.py @@ -0,0 +1,70 @@ +# message.py + +# Copyright (c) 2018-2020 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file + +try: + import uasyncio as asyncio +except ImportError: + import asyncio +# Usage: +# from primitives.message import Message + +# A coro waiting on a message issues await message +# A coro rasing the message issues message.set(payload) +# When all waiting coros have run +# message.clear() should be issued + +# This more efficient version is commented out because Event.set is not ISR +# friendly. TODO If it gets fixed, reinstate this (tested) version and update +# tutorial for 1:n operation. +#class Message(asyncio.Event): + #def __init__(self, _=0): + #self._data = None + #super().__init__() + + #def clear(self): + #self._data = None + #super().clear() + + #def __await__(self): + #await super().wait() + + #__iter__ = __await__ + + #def set(self, data=None): + #self._data = data + #super().set() + + #def value(self): + #return self._data + +# Has an ISR-friendly .set() +class Message(): + def __init__(self, delay_ms=0): + self.delay_ms = delay_ms + self.clear() + + def clear(self): + self._flag = False + self._data = None + + async def wait(self): # CPython comptaibility + while not self._flag: + await asyncio.sleep_ms(self.delay_ms) + + def __await__(self): + while not self._flag: + await asyncio.sleep_ms(self.delay_ms) + + __iter__ = __await__ + + def is_set(self): + return self._flag + + def set(self, data=None): + self._flag = True + self._data = data + + def value(self): + return self._data diff --git a/src/lib/primitives/pushbutton.py b/src/lib/primitives/pushbutton.py new file mode 100644 index 0000000..abe438c --- /dev/null +++ b/src/lib/primitives/pushbutton.py @@ -0,0 +1,102 @@ +# pushbutton.py + +# Copyright (c) 2018-2020 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file + +import uasyncio as asyncio +import utime as time +from . import launch +from primitives.delay_ms import Delay_ms + + +# An alternative Pushbutton solution with lower RAM use is available here +# https://github.com/kevinkk525/pysmartnode/blob/dev/pysmartnode/utils/abutton.py +class Pushbutton: + debounce_ms = 50 + long_press_ms = 1000 + double_click_ms = 400 + def __init__(self, pin, suppress=False, sense=None): + self.pin = pin # Initialise for input + self._supp = suppress + self._dblpend = False # Doubleclick waiting for 2nd click + self._dblran = False # Doubleclick executed user function + self._tf = False + self._ff = False + self._df = False + self._lf = False + self._ld = False # Delay_ms instance for long press + self._dd = False # Ditto for doubleclick + self.sense = pin.value() if sense is None else sense # Convert from electrical to logical value + self.state = self.rawstate() # Initial state + asyncio.create_task(self.buttoncheck()) # Thread runs forever + + def press_func(self, func, args=()): + self._tf = func + self._ta = args + + def release_func(self, func, args=()): + self._ff = func + self._fa = args + + def double_func(self, func, args=()): + self._df = func + self._da = args + + def long_func(self, func, args=()): + self._lf = func + self._la = args + + # Current non-debounced logical button state: True == pressed + def rawstate(self): + return bool(self.pin.value() ^ self.sense) + + # Current debounced state of button (True == pressed) + def __call__(self): + return self.state + + def _ddto(self): # Doubleclick timeout: no doubleclick occurred + self._dblpend = False + if self._supp and not self.state: + if not self._ld or (self._ld and not self._ld()): + launch(self._ff, self._fa) + + async def buttoncheck(self): + if self._lf: # Instantiate timers if funcs exist + self._ld = Delay_ms(self._lf, self._la) + if self._df: + self._dd = Delay_ms(self._ddto) + while True: + state = self.rawstate() + # State has changed: act on it now. + if state != self.state: + self.state = state + if state: # Button pressed: launch pressed func + if self._tf: + launch(self._tf, self._ta) + if self._lf: # There's a long func: start long press delay + self._ld.trigger(Pushbutton.long_press_ms) + if self._df: + if self._dd(): # Second click: timer running + self._dd.stop() + self._dblpend = False + self._dblran = True # Prevent suppressed launch on release + launch(self._df, self._da) + else: + # First click: start doubleclick timer + self._dd.trigger(Pushbutton.double_click_ms) + self._dblpend = True # Prevent suppressed launch on release + else: # Button release. Is there a release func? + if self._ff: + if self._supp: + d = self._ld + # If long delay exists, is running and doubleclick status is OK + if not self._dblpend and not self._dblran: + if (d and d()) or not d: + launch(self._ff, self._fa) + else: + launch(self._ff, self._fa) + if self._ld: + self._ld.stop() # Avoid interpreting a second click as a long push + self._dblran = False + # Ignore state changes until switch has settled + await asyncio.sleep_ms(Pushbutton.debounce_ms) diff --git a/src/lib/primitives/switch.py b/src/lib/primitives/switch.py new file mode 100644 index 0000000..87ce8d5 --- /dev/null +++ b/src/lib/primitives/switch.py @@ -0,0 +1,42 @@ +# switch.py + +# Copyright (c) 2018-2020 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file + +import uasyncio as asyncio +import utime as time +from . import launch + +class Switch: + debounce_ms = 50 + def __init__(self, pin): + self.pin = pin # Should be initialised for input with pullup + self._open_func = False + self._close_func = False + self.switchstate = self.pin.value() # Get initial state + asyncio.create_task(self.switchcheck()) # Thread runs forever + + def open_func(self, func, args=()): + self._open_func = func + self._open_args = args + + def close_func(self, func, args=()): + self._close_func = func + self._close_args = args + + # Return current state of switch (0 = pressed) + def __call__(self): + return self.switchstate + + async def switchcheck(self): + while True: + state = self.pin.value() + if state != self.switchstate: + # State has changed: act on it now. + self.switchstate = state + if state == 0 and self._close_func: + launch(self._close_func, self._close_args) + elif state == 1 and self._open_func: + launch(self._open_func, self._open_args) + # Ignore further state changes until switch has settled + await asyncio.sleep_ms(Switch.debounce_ms) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..87b589d --- /dev/null +++ b/src/main.py @@ -0,0 +1,40 @@ +from homie.device import HomieDevice +from machine import Pin, I2C +import settings + +# import uasyncio as asyncio +# from time import ticks_ms, ticks_add, ticks_diff + +import bme280 +from bmp280_node import BMP280Node +import neopixel +from led_control_node import LEDControlNode + + +def main(): + i2c = I2C(scl=Pin(16), sda=Pin(17)) + + bmp280 = bme280.BME280(i2c=i2c) + + bmp280Node = BMP280Node( + id="bmp280", + name="Enviroment-Sensor", + bmp280=bmp280) + + leds = neopixel.NeoPixel(Pin(23, Pin.OUT), 28) + + controlNode = LEDControlNode( + id="leds1", name="LEDs", + pin_up=Pin(18), pin_down=Pin(5), leds=leds) + + # Homie device setup + homie = HomieDevice(settings) + homie.add_node(bmp280Node) + homie.add_node(controlNode) + + # run forever + homie.run_forever() + + +if __name__ == "__main__": + main() diff --git a/src/plant_node.py b/src/plant_node.py new file mode 100644 index 0000000..2072366 --- /dev/null +++ b/src/plant_node.py @@ -0,0 +1,147 @@ +from homie.constants import BOOLEAN, FALSE, TRUE, FLOAT +from homie.property import HomieProperty +from update_homie_node import UpdateHomieNode +# from homie.device import await_ready_state +from machine import Pin, ADC +import uasyncio as asyncio +import gc + + +class PlantNode(UpdateHomieNode): + + def __init__( + self, + id, + name, + watering_motor, + moisture_sensor, + pin_water_tank, + waterlevel_sensor, + interval=60*5, + interval_watering=0.2): + super().__init__( + id=id, name=name, type="watering", + interval=interval, + interval_short=interval_watering) + + # Update Interval + self.interval_normal = interval + self.interval_watering = interval_watering + + # WaterLevelSensor + self.waterlevel_sensor = waterlevel_sensor + self.property_waterlevel = HomieProperty( + id="waterlevel", + name="Wassertankstand", + datatype=FLOAT, + unit="L", + ) + self.add_property(self.property_waterlevel) + self.property_waterlevel_percent = HomieProperty( + id="waterlevel_percent", + name="Wassertankstand [%]", + datatype=FLOAT, + format="0.00:100.00", + unit="%", + ) + self.add_property(self.property_waterlevel_percent) + + self.property_waterlevel_volume_liter = HomieProperty( + id="waterlevel_volume_max", + name="Wassertankgröße", + settable=True, + datatype=FLOAT, + unit="L", + on_message=self._set_waterlevel_volume + ) + self.add_property(self.property_waterlevel_volume_liter) + + # Moisture + self.moisture_sensor = moisture_sensor + self.property_moisture = HomieProperty( + id="moisture", + name="Feuchte", + datatype=FLOAT, + format="0.00:100.00", + unit="%", + ) + self.add_property(self.property_moisture) + + # Watering Motor + self.watering_motor = watering_motor + self.watering_motor.add_motor_stop_callback( + self.on_watering_motor_stop) + self.watering_motor.add_motor_start_callback( + self.on_watering_motor_start) + self.property_watering_power = HomieProperty( + id="power", + name="Bewässerung", + settable=True, + datatype=BOOLEAN, + default=FALSE, + on_message=self.toggle_motor, + ) + self.add_property(self.property_watering_power) + + self.property_watering_max_duration = HomieProperty( + id="watering_duration", + name="Bewässerungszeit", + settable=True, + datatype=FLOAT, + default=3, + on_message=lambda t, p, r: + self.watering_motor.set_watering_duration(float(p)), + unit="s", + ) + self.add_property(self.property_watering_max_duration) + + def update_data(self): + self.property_moisture.value = "{:1.2f}".format( + self.moisture_sensor.value * 100) + + self.property_waterlevel_percent.value = "{:1.0f}".format( + self.waterlevel_sensor.level_percent) + self.property_waterlevel.value = "{:1.2f}".format( + self.waterlevel_sensor.level) + self.property_waterlevel_volume_liter.value = "{:1.4f}".format( + self.waterlevel_sensor.volume) + + self.property_watering_max_duration.value = \ + str(self.watering_motor.watering_duration) + + self.property_watering_power.value = \ + TRUE if self.watering_motor.is_watering() else FALSE + + if self.watering_motor.is_watering(): + if self._interval != self.interval_watering: + self.interval_normal = self._interval + self.set_interval(self.interval_watering) + else: + self.set_interval(self.interval_normal) + gc.collect() + + def toggle_motor(self, topic, payload, retained): + ONOFF = {FALSE: False, TRUE: True} + v = ONOFF[payload] + if v: + self.watering_motor.start() + else: + self.watering_motor.stop() + + def on_watering_motor_stop(self): + self.set_interval(self.interval_normal) + self.property_watering_power.value = FALSE + + def on_watering_motor_start(self): + if self._interval != self.interval_watering: + self.interval_normal = self._interval + self.set_interval(self.interval_watering) + + def _set_waterlevel_min_value(self, topic, payload, retained): + self.waterlevel_sensor.value_min = float(payload) + + def _set_waterlevel_max_value(self, topic, payload, retained): + self.waterlevel_sensor.value_max = float(payload) + + def _set_waterlevel_volume(self, topic, payload, retained): + self.waterlevel_sensor.volume = float(payload) diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..05164b3 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,84 @@ +# Debug mode disables WDT, print mqtt messages +# DEBUG = False + +### +# Wifi settings +### + + +# Multiple WiFi credentials +# If a ssid near your device matchs a wifi credentials in the dictionary, +# WIFI_SSID and WIFI_PASSWORD will be overwitten with the corresponding +# ssid,password. Set to False to disable multible wifis and use WIFI_SSID and +# WIFI_PASSWORD to access a WiFi nearby. +WIFI_CREDENTIALS = {} +with open("wifi-credentials", 'r') as f: + lines = f.readlines() + for i in range(0, len(lines), 2): + WIFI_CREDENTIALS[lines[i].replace("\n", "")] = lines[i+1].replace("\n", "") + +WIFI_SSID, WIFI_PASSWORD = list(WIFI_CREDENTIALS.items())[0] + +# The delay until wifi is rescanned to keep UI somewhat responsive +WIFI_RESCAN_DELAY = 10000 + +### +# MQTT settings +### +# + +# Broker IP or DNS Name +MQTT_BROKER = "mqtt.nils-server" + +# Broker port +MQTT_PORT = 1883 + +# Username or None for anonymous login +# MQTT_USERNAME = None + +# Password or None for anonymous login +# MQTT_PASSWORD = None + +# Defines the mqtt connection timemout in seconds +# MQTT_KEEPALIVE = 30 + +# SSL connection to the broker. Some MicroPython implementations currently +# have problems with receiving mqtt messages over ssl connections. +# MQTT_SSL = False +# MQTT_SSL_PARAMS = {} +# MQTT_SSL_PARAMS = {"do_handshake": True} + +# Base mqtt topic the device publish and subscribes to, without leading slash. +# Base topic format is bytestring. +# MQTT_BASE_TOPIC = "homie" + + +### +# Device settings +### + +# The device ID for registration at the broker. The device id is also the +# base topic of a device and must be unique and bytestring. +# from homie.utils import get_unique_id +DEVICE_ID = "herz-lampe" # get_unique_id() + +# Friendly name of the device as bytestring +DEVICE_NAME = "Inas Herz-Lampe" + +# Time in seconds the device updates device properties +DEVICE_STATS_INTERVAL = 600 + +# Subscribe to broadcast topic is enabled by default. To disable broadcast +# messages set BROADCAST to False +# BROADCAST = True + +# Enable build-in extensions +from homie.constants import EXT_MPY +EXTENSIONS = [EXT_MPY] + +# from homie.constants import EXT_MPY, EXT_FW, EXT_STATS +# EXTENSIONS = [ +# EXT_MPY, +# EXT_FW, +# EXT_STATS, +# ] diff --git a/src/update_homie_node.py b/src/update_homie_node.py new file mode 100644 index 0000000..9aa3cab --- /dev/null +++ b/src/update_homie_node.py @@ -0,0 +1,76 @@ + +from homie.constants import FLOAT +from homie.property import HomieProperty +from homie.node import HomieNode +from homie.device import await_ready_state + +import uasyncio as asyncio +from time import ticks_ms, ticks_add, ticks_diff + + +class UpdateHomieNode(HomieNode): + + def __init__( + self, + id, + name, + type, + interval=60*5, + interval_short=0.1): + super().__init__(id=id, name=name, type=type) + + self.interval_changed = False + self.interval_short = interval_short + # Update Interval + self._interval = interval + self.property_interval = HomieProperty( + id="update_interval", + name="Aktualisierungsrate", + datatype=FLOAT, # TODO ISO8601 + settable=True, + on_message=self._set_interval, + unit="s", + ) + self.add_property(self.property_interval) + + asyncio.create_task(self._update_data_async()) + + @await_ready_state + async def _update_data_async(self): + while True: + + # call child callback + self.update_data() + + # TODO ISO8601 + # self.property_interval.value = "PT{:1.3f}S".format(self.interval) + self.property_interval.value = "{:1.3f}".format(self.interval) + + # We don't simply wait the update interval, as it can change while waiting. + last_update = ticks_ms() + wait_till = ticks_add(last_update, int(self.interval * 1000.0)) + while ticks_diff(ticks_ms(), wait_till) < 0: + if self.interval_changed: + self.interval_changed = False + wait_till = ticks_add( + last_update, + int(self.interval * 1000)) + sleep_for = min(int(self.interval_short * 1000.0), + ticks_diff(wait_till, ticks_ms())) + await asyncio.sleep_ms(sleep_for) + + @property + def interval(self): + return self._interval + + @interval.setter + def setter_interval(self, i): + self.set_interval(i) + + def set_interval(self, i): + if i != self._interval: + self.interval_changed = True + self._interval = float(i) + + def _set_interval(self, topic, payload, retained): + self.set_interval(float(payload))