diff --git a/.github/images/opentherm-mqtt.png b/.github/images/opentherm-mqtt.png new file mode 100644 index 00000000..e5d59577 Binary files /dev/null and b/.github/images/opentherm-mqtt.png differ diff --git a/.github/images/overshoot_protection.png b/.github/images/overshoot_protection.png new file mode 100644 index 00000000..10daace7 Binary files /dev/null and b/.github/images/overshoot_protection.png differ diff --git a/.github/images/setup.png b/.github/images/setup.png new file mode 100644 index 00000000..d8b4ab2c Binary files /dev/null and b/.github/images/setup.png differ diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..46d04261 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,29 @@ +name: Run PyTest Unit Tests + +# yamllint disable-line rule:truthy +on: + push: + pull_request: + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.10" ] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + if [ -f requirements_test.txt ]; then pip install -r requirements_test.txt; fi + - name: Test with pytest + run: | + pytest \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + 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 index cc589a9c..38d2e042 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,47 @@ -# Smart Autotune Thermostat (SAT) -The Smart Autotune Thermostat (SAT) is a custom component for Home Assistant that works with an [OpenTherm Gateway (OTGW)](https://otgw.tclcode.com/) in order to provide advanced temperature control functionality based on Outside Temperature compensation and Proportional-Integral-Derivative (PID) algorithm. Unlike other thermostat components, SAT supports automatic gain tuning and heating curve coefficient, which means it can determine the optimal setpoint for your boiler without any manual intervention. +# Smart Autotune Thermostat + +[![hacs][hacs-badge]][hacs-url] +[![release][release-badge]][release-url] +![build][build-badge] +[![discord][discord-badge]][discord-url] + + + +![opentherm-mqtt.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/opentherm-mqtt.png) +![overshoot_protection.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/overshoot_protection.png) + + +## What is the Smart Autotune Thermostat? + +The Smart Autotune Thermostat, or SAT for short, is a custom component for Home Assistant that works with an [OpenTherm Gateway (OTGW)](https://otgw.tclcode.com/) (MQTT or Serial). It can also function as a PID ON/OFF thermostat, providing advanced temperature control based on Outside Temperature compensation and the Proportional-Integral-Derivative (PID) algorithm. Unlike other thermostat components, SAT supports automatic gain tuning and heating curve coefficients. This capability allows it to determine the optimal setpoint for your boiler without any manual intervention. ## Features +OpenTherm ( MQTT / Serial ): - Multi-room temperature control with support for temperature synchronization for main climates +- Overshoot protection value automatic calculation mechanism - Adjustable heating curve coefficients to fine-tune your heating system - Target temperature step for adjusting the temperature in smaller increments - Presets for different modes such as Away, Sleep, Home, Comfort - Automatic gains for PID control -- PWM and Automatic duty cycle -- Overshoot protection to prevent the boiler from overshooting the setpoint +- PWM and Automatic-duty cycle +- Overshoot protection to prevent the boiler from overshooting the setpoint ( Low-Load Control ) - Climate valve offset to adjust the temperature reading for your climate valve - Sample time for PID control to fine-tune your system's response time +- Open Window detection - Control DHW setpoint +PID ON/OFF thermostat: + +- Multi-room temperature control with support for temperature synchronization for main climates +- Adjustable heating curve coefficients to fine-tune your heating system +- Target temperature step for adjusting the temperature in smaller increments +- Presets for different modes such as Away, Sleep, Home, Comfort +- Automatic gains for PID control +- PWM and Automatic-duty cycle +- Climate valve offset to adjust the temperature reading for your climate valve +- Sample time for PID control to fine-tune your system's response time +- Open Window detection + ## Installation ### Manual 1. Download the latest release of the SAT custom component from the GitHub repository. @@ -23,65 +52,102 @@ The Smart Autotune Thermostat (SAT) is a custom component for Home Assistant tha ### HACS 1. Install HACS if you haven't already. 2. Open the HACS web interface in Home Assistant and navigate to the Integrations section. -3. Click the three dots in the top-right corner and select "Custom repositories". -4. Enter the URL of the SAT custom component GitHub repository (https://github.com/Alexwijn/SAT) and select "Integration" as the category. Click "Add". +3. Click the three dots in the top-right corner and select "Custom repositories." +4. Enter the URL of the SAT custom component GitHub repository (https://github.com/Alexwijn/SAT) and select "Integration" as the category. Click "Add." 5. Once the SAT custom component appears in the list of available integrations, click "Install" to install it. 6. Restart Home Assistant to load the SAT custom component. 7. After installing the SAT custom component, you can configure it via the Home Assistant Config Flow interface. -## Configuration -SAT is configured using a config flow. After installation, go to the Integrations page in Home Assistant, click on the Add Integration button, and search for SAT. Follow the prompts to configure the integration. +# Configuration +SAT is configured using a config flow. After installation, go to the Integrations page in Home Assistant, click on the Add Integration button, and search for SAT if the autodiscovery feature fails. -## Multi-room setup -In multi-room mode, SAT monitors the climates in other rooms to determine the error and calculates how much heat is needed. It selects the highest error value as the error value for the current room, instead of using the average temperature across all rooms. This ensures that the temperature in each room is maintained at its desired level. +## OpenTherm -Note that SAT assumes that the climate control systems in the additional rooms are smart and won't exceed their target temperatures, as this can cause inefficiencies in the overall system. Once every climate control system in all rooms is around the target temperature, SAT can operate at its most efficient level. +1. OpenTherm Connection + - MQTT + - Name of the thermostat + - Top Topic ( *MQTT Top Topic* found in OTGW-firmware Settings ) + - Device + + - Serial: + - Name of the thermostat + - URL + +2. Configure sensors: + - Inside Sensor Entity ( Your Room Temperature sensor ) + - Outside temperature sensor ( Your Outside Temperature sensor ) + +3. Heating System: Selecting the correct heating system type is important for SAT to accurately control the temperature and optimize performance. Choose the option that matches your setup to ensure proper temperature regulation throughout your home. + +4. Calibrate System: Optimize your heating system by automatically determining the optimal PID values for your setup. When selecting Automatic Gains, please note that the system will go through a calibration process that may take approximately 20 minutes to complete. + +If you already know this value, then use the "Manually enter the overshoot protection value" option and fill the value. + +Automatic Gains are recommended for most users as it simplifies the setup process and ensures optimal performance. However, if you're familiar with PID control and prefer to manually set the values, you can choose to skip Automatic Gains. + +Please note that choosing to skip Automatic Gains requires a good understanding of PID control and may require additional manual adjustments to achieve optimal performance. + +## PID ON/OFF -## Heating Curve Coefficient +To be completed + +# Configure + +## General tab: +*Maximum Setpoint*: +You can choose the max water setpoint for your system. +For radiator installations, it is recommended to choose a value between 55-75 °C. +For underfloor installations, the recommended max water setpoint is 50 °C. + +Note for Radiators: Higher Max water setpoint values will cause a more aggressive warm-up. + +*Heating Curve Coefficient*: The heating curve coefficient is a configurable parameter in SAT that allows you to adjust the relationship between the outdoor temperature and the heating system output. This is useful for optimizing the heating system's performance in different weather conditions, as it allows you to adjust how much heat the system delivers as the outdoor temperature changes. By tweaking this parameter, you can achieve a more efficient and comfortable heating system. -## Automatic gains -SAT supports automatic PID gain tuning. When this feature is enabled, SAT will continuously adjust the PID gains to optimize the temperature control performance based on the current conditions instead of manually filling in the PID-values. +## Areas tab: +*Multi-room setup*: +In multi-room mode, SAT monitors the climates in other rooms to determine the error and calculates how much heat is needed. It selects the highest error value as the error value for the current room, instead of using the average temperature across all rooms. This ensures that the temperature in each room is maintained at its desired level. -## Overshoot protection +Note that SAT assumes that the climate control systems in the additional rooms are smart and won't exceed their target temperatures, as this can cause inefficiencies in the overall system. Once every climate control system in all rooms is around the target temperature, SAT can operate at its most efficient level. -With overshoot protection enabled, SAT will automatically calculate the maximum allowed modulation value for the boiler based on the setpoint and the calculation overshoot -protection value. +*Contact Sensor*: You can add contact sensors to avoid wasting energy when a door/window is open. When the door/window is closed again, SAT restores heating. -## Tuning +## Presets tab: +Predefined temperature settings for different scenarios or activities. -*Heating Curve Coefficient*: By adjusting the heating curve coefficient, you can balance the heating loss of your home with the energy generated from your boiler at a given -setpoint based on the outside temperature. When this value is properly tuned then the room temperature should float around the setpoint. +# Terminology +*Heating Curve Coefficient*: By adjusting the heating curve coefficient, you can balance the heating loss of your home with the energy generated from your boiler at a given setpoint based on the outside temperature. When this value is properly tuned, the room temperature should hover around the setpoint. *Gains*: SAT offers two ways of tuning the PID gains - manual and automatic. -- Manual tuning: You can fill the Proportional, Integral and Derivative fields in the General tab with your own values. -- Automatic Gains ( Recommended ): You can enable this option in the Advanced Tab. Automatic gains dynamically change the kP, kI and kD values based on the heating curve - value. So, based on the outside temperature the gains are changing from mild to aggressive without intervention. +- Manual tuning: You can fill the Proportional, Integral, and Derivative fields in the General tab with your values. +- Automatic Gains ( Recommended ): This option is enabled by default when the Overshoot protection value is present (During initial configuration). Automatic gains dynamically change the kP, kI, and kD values based on the heating curve value. So, based on the outside temperature, the gains change from mild to aggressive without intervention. -*Overshoot Protection* (Experimental): This feature should be enabled when the minimum boiler capacity is greater than the control setpoint calculated by SAT. If the boiler -overshoots the control setpoint, it may cycle, which can shorten the life of the burner. The solution is to adjust the boiler's on/off times to maintain the temperature at the -setpoint while minimizing cycling. +*Overshoot Protection*: This feature should be enabled when the minimum boiler capacity is greater than the control setpoint calculated by SAT. If the boiler overshoots the control setpoint, it may cycle, shortening the life of the burner. The solution is to adjust the boiler's on/off times to maintain the temperature at the setpoint while minimizing cycling. -Overshoot Protection Value (OPV) Calculation: The OPV is a crucial value that determines the boiler's on/off times when the Overshoot Protection feature is enabled. SAT -provides two ways to calculate it. +Overshoot Protection Value (OPV) Calculation: +The OPV is a crucial value that determines the boiler's on/off times when the Overshoot Protection feature is present (During initial configuration). -*Manual Calculation*: If you know the maximum flow water temperature of the boiler at 0% modulation, you can use the service `Overshoot Protection Value` to set the value. +*Manual Calculation*: If you know the maximum flow water temperature of the boiler at 0% modulation, you can fill in this value during the initial configuration. -*Automatic Calculation*: To calculate the OPV automatically, call the service `Overshoot Protection Calculation`. SAT will then send the MM=0 and CS=75 commands and attempt to -find the highest flow water temperature the boiler can produce while running at 0% modulation. This process takes at least 20 minutes. Once the OPV calculation is complete, -SAT will resume normal operation and send a completion notification. The calculated value will be stored as an attribute in the SAT climate entity and used to determine the -boiler's on/off times in the low load control algorithm. +*Automatic Calculation*: To calculate the OPV automatically, choose the "Calibrate and determine your overshoot protection value (approx. 20 min)" option during the initial configuration. SAT will then send the MM=0 and CS=75 commands, attempting to find the highest flow water temperature the boiler can produce while running at 0% modulation. This process takes at least 20 minutes. Once the OPV calculation is complete, SAT will resume normal operation and send a completion notification. The calculated value will be stored as an attribute in the SAT climate entity and used to determine the boiler's on/off times in the low-load control algorithm. If SAT detects that the boiler doesn't respect the 0% Max Modulation command, it will automatically change the calibration algorithm to a more sophisticated one to perform the calibration of the system. -Note: If you have any TRVs, open all of them (set them to a high setpoint) to ensure accurate calculation of the OPV. Once the calculation is complete, you can lower the -setpoint back to your desired temperature. +Note: If you have any TRVs, open all of them (set them to a high setpoint) to ensure accurate calculation of the OPV. Once the calculation is complete, you can lower the setpoint back to your desired temperature. -*Automatic Duty Cycle* ( Experimental ): When this option is enabled, SAT calculates the ON and OFF times of the boiler, in 15 minutes intervals, given that the kW needed to -heat the home are less than the minimum boiler capacity. Moreover using this feature SAT is able to regulate efficiently the room temperature even in mild weather by -automatically adjusting the duty cycle. +*Automatic Duty Cycle*: When this option is enabled, SAT calculates the ON and OFF times of the boiler in 15-minute intervals, given that the kW needed to heat the home is less than the minimum boiler capacity. Moreover, using this feature, SAT can efficiently regulate the room temperature even in mild weather by automatically adjusting the duty cycle. -## Support + -If you want to support this project, you can [**buy me a coffee here**](https://www.buymeacoffee.com/alexwijn). +[hacs-url]: https://github.com/hacs/integration +[hacs-badge]: https://img.shields.io/badge/hacs-default-orange.svg?style=for-the-badge +[release-badge]: https://img.shields.io/github/v/tag/Alexwijn/SAT?style=for-the-badge +[downloads-badge]: https://img.shields.io/github/downloads/Alexwijn/SAT/total?style=for-the-badge +[build-badge]: https://img.shields.io/github/actions/workflow/status/Alexwijn/SAT/pytest.yml?branch=develop&style=for-the-badge +[discord-badge]: https://img.shields.io/discord/1184879273991995515?label=Discord&logo=discord&logoColor=white&style=for-the-badge - + + +[hacs]: https://hacs.xyz +[home-assistant]: https://www.home-assistant.io/ +[release-url]: https://github.com/Alexwijn/SAT/releases +[discord-url]: https://discord.gg/jnVXpzqGEq \ No newline at end of file diff --git a/compose.yml b/compose.yml index cfd80767..43ce7a2f 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,6 @@ version: "3.9" services: homeassistant: - container_name: homeassistant image: "ghcr.io/home-assistant/home-assistant:stable" volumes: - homeassistant:/config diff --git a/configuration.yaml b/configuration.yaml index b9e41242..1709f622 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -10,7 +10,31 @@ homeassistant: climate.heater: sensor_temperature_id: "sensor.heater_temperature" +climate: + - platform: generic_thermostat + name: Fake Thermostat + heater: input_boolean.fake_thermostat + target_sensor: sensor.current_temperature + +switch: + - platform: template + switches: + heater: + value_template: "{{ is_state('input_boolean.heater', 'on') }}" + turn_on: + service: input_boolean.turn_on + target: + entity_id: input_boolean.heater + turn_off: + service: input_boolean.turn_off + target: + entity_id: input_boolean.heater + template: + binary_sensor: + name: "Window" + device_class: "window" + state: "{{ is_state('input_boolean.window_sensor', 'on') }}" sensor: - unit_of_measurement: °C name: Heater Temperature @@ -24,6 +48,10 @@ template: name: Outside Temperature device_class: 'temperature' state: "{{ states('input_number.outside_temperature_raw') }}" + - unit_of_measurement: "%" + name: Current Humidity + device_class: 'humidity' + state: "{{ states('input_number.humidity_raw') }}" input_number: heater_temperature_raw: @@ -44,15 +72,17 @@ input_number: min: 0 max: 35 step: 0.01 + humidity_raw: + name: Humidity + initial: 50 + min: 0 + max: 100 + step: 0.1 input_boolean: heater: name: Heater - icon: mdi:heater - -climate: - - platform: generic_thermostat - name: Heater - unique_id: heater - heater: input_boolean.heater - target_sensor: sensor.heater_temperature \ No newline at end of file + window_sensor: + name: Window Sensor + fake_thermostat: + name: Fake Thermostat diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index a5877b8e..d9004115 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -1,62 +1,65 @@ import asyncio import logging -from typing import Optional, Any +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Config, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from pyotgw import OpenThermGateway -from serial import SerialException +from . import mqtt, serial, switch from .const import * +from .coordinator import SatDataUpdateCoordinatorFactory _LOGGER: logging.Logger = logging.getLogger(__name__) -async def async_setup(_hass: HomeAssistant, __config: Config): - """Set up this integration using YAML is not supported.""" - return True - - async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - """Set up this integration using UI.""" - if _hass.data.get(DOMAIN) is None: - _hass.data.setdefault(DOMAIN, {}) + """ + Set up this integration using the UI. - try: - client = OpenThermGateway() - await client.connect(port=_entry.data.get(CONF_DEVICE), timeout=5) - except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: - raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {ex}") from ex + This function is called by Home Assistant when the integration is set up with the UI. + """ + # Make sure we have our default domain property + _hass.data.setdefault(DOMAIN, {}) - _hass.data[DOMAIN][_entry.entry_id] = { - COORDINATOR: SatDataUpdateCoordinator(_hass, client=client), - } + # Create a new dictionary for this entry + _hass.data[DOMAIN][_entry.entry_id] = {} - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, SENSOR)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, NUMBER)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, BINARY_SENSOR)) + # Resolve the coordinator by using the factory according to the mode + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = await SatDataUpdateCoordinatorFactory().resolve( + hass=_hass, config_entry=_entry, mode=_entry.data.get(CONF_MODE), device=_entry.data.get(CONF_DEVICE) + ) + # Forward entry setup for climate and other platforms + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE_DOMAIN)) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN])) + + # Add an update listener for this entry _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) return True async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: - """Handle removal of an entry.""" + """ + Handle removal of an entry. + + This function is called by Home Assistant when the integration is being removed. + """ + + climate = _hass.data[DOMAIN][_entry.entry_id][CLIMATE] + await _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].async_will_remove_from_hass(climate) + unloaded = all( await asyncio.gather( - _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), - _hass.config_entries.async_forward_entry_unload(_entry, SENSOR), - _hass.config_entries.async_forward_entry_unload(_entry, NUMBER), - _hass.config_entries.async_forward_entry_unload(_entry, BINARY_SENSOR), - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].cleanup() + _hass.config_entries.async_unload_platforms(_entry, [CLIMATE_DOMAIN, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]), ) ) + # Remove the entry from the data dictionary if all components are unloaded successfully if unloaded: _hass.data[DOMAIN].pop(_entry.entry_id) @@ -64,66 +67,82 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None: - """Reload config entry.""" + """ + Reload config entry. + + This function is called by Home Assistant when the integration configuration is updated. + """ + # Unload the entry and its dependent components await async_unload_entry(_hass, _entry) + + # Set up the entry again await async_setup_entry(_hass, _entry) -class SatDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the OTGW Gateway.""" +async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: + """Migrate old entry.""" + from custom_components.sat.config_flow import SatFlowHandler + _LOGGER.debug("Migrating from version %s", _entry.version) - def __init__(self, hass: HomeAssistant, client: OpenThermGateway) -> None: - """Initialize.""" - self.api = client - self.api.subscribe(self._async_coroutine) + if _entry.version < SatFlowHandler.VERSION: + new_data = {**_entry.data} + new_options = {**_entry.options} - super().__init__(hass, _LOGGER, name=DOMAIN) + if _entry.version < 2: + if not _entry.data.get(CONF_MINIMUM_SETPOINT): + # Legacy Store + store = Store(_hass, 1, DOMAIN) + new_data[CONF_MINIMUM_SETPOINT] = MINIMUM_SETPOINT - async def _async_update_data(self): - """Update data via library.""" - try: - return await self.api.get_status() - except Exception as exception: - raise UpdateFailed() from exception + if (data := await store.async_load()) and (overshoot_protection_value := data.get("overshoot_protection_value")): + new_data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value - async def _async_coroutine(self, data): - self.async_set_updated_data(data) + if _entry.options.get("heating_system") == "underfloor": + new_data[CONF_HEATING_SYSTEM] = HEATING_SYSTEM_UNDERFLOOR + else: + new_data[CONF_HEATING_SYSTEM] = HEATING_SYSTEM_RADIATORS - async def cleanup(self): - """Cleanup and disconnect.""" - self.api.unsubscribe(self._async_coroutine) + if not _entry.data.get(CONF_MAXIMUM_SETPOINT): + new_data[CONF_MAXIMUM_SETPOINT] = 55 - await self.api.set_control_setpoint(0) - await self.api.set_max_relative_mod("-") - await self.api.disconnect() + if _entry.options.get("heating_system") == "underfloor": + new_data[CONF_MAXIMUM_SETPOINT] = 50 - def get(self, key: str) -> Optional[Any]: - """Get the value for the given `key` from the boiler data. + if _entry.options.get("heating_system") == "radiator_low_temperatures": + new_data[CONF_MAXIMUM_SETPOINT] = 55 - :param key: Key of the value to retrieve from the boiler data. - :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. - """ - return self.data[gw_vars.BOILER].get(key) if self.data[gw_vars.BOILER] else None + if _entry.options.get("heating_system") == "radiator_medium_temperatures": + new_data[CONF_MAXIMUM_SETPOINT] = 65 + if _entry.options.get("heating_system") == "radiator_high_temperatures": + new_data[CONF_MAXIMUM_SETPOINT] = 75 -class SatConfigStore: - _STORAGE_VERSION = 1 - _STORAGE_KEY = DOMAIN + if _entry.version < 3: + if main_climates := _entry.options.get("main_climates"): + new_data[CONF_MAIN_CLIMATES] = main_climates + new_options.pop("main_climates") - def __init__(self, hass): - self._hass = hass - self._data = None - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + if secondary_climates := _entry.options.get("climates"): + new_data[CONF_SECONDARY_CLIMATES] = secondary_climates + new_options.pop("climates") - async def async_initialize(self): - if (data := await self._store.async_load()) is None: - data = {STORAGE_OVERSHOOT_PROTECTION_VALUE: None} + if sync_with_thermostat := _entry.options.get("sync_with_thermostat"): + new_data[CONF_SYNC_WITH_THERMOSTAT] = sync_with_thermostat + new_options.pop("sync_with_thermostat") - self._data = data + if _entry.version < 4: + if _entry.data.get("window_sensor") is not None: + new_data[CONF_WINDOW_SENSORS] = [_entry.data.get("window_sensor")] + del new_options["window_sensor"] - def retrieve_overshoot_protection_value(self): - return self._data[STORAGE_OVERSHOOT_PROTECTION_VALUE] + if _entry.version < 5: + if _entry.options.get("overshoot_protection") is not None: + new_data[CONF_OVERSHOOT_PROTECTION] = _entry.options.get("overshoot_protection") + del new_options["overshoot_protection"] - def store_overshoot_protection_value(self, value: float): - self._data[STORAGE_OVERSHOOT_PROTECTION_VALUE] = value - self._store.async_delay_save(lambda: self._data, 1.0) + _entry.version = SatFlowHandler.VERSION + _hass.config_entries.async_update_entry(_entry, data=new_data, options=new_options) + + _LOGGER.info("Migration to version %s successful", _entry.version) + + return True diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index f8aa4418..e17010cf 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -1,119 +1,80 @@ -"""Binary Sensor platform for SAT.""" +from __future__ import annotations + import logging -import pyotgw.vars as gw_vars from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT from homeassistant.components.climate import HVACAction +from homeassistant.components.group.binary_sensor import BinarySensorGroup from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SatDataUpdateCoordinator from .climate import SatClimate -from .const import DOMAIN, COORDINATOR, CLIMATE, TRANSLATE_SOURCE, CONF_NAME, BINARY_SENSOR_INFO -from .entity import SatEntity - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): - """Setup sensor platform.""" - climate = hass.data[DOMAIN][config_entry.entry_id][CLIMATE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" - - # Create list of devices to be added - sensors = [ - SatControlSetpointSynchroSensor(coordinator, climate, config_entry), - SatCentralHeatingSynchroSensor(coordinator, climate, config_entry), - ] - - # Iterate through sensor information - for key, info in BINARY_SENSOR_INFO.items(): - device_class = info[0] - status_sources = info[2] - friendly_name_format = info[1] - - # Check if the sensor should be added based on its availability and thermostat presence - for source in status_sources: - if source == gw_vars.THERMOSTAT and has_thermostat is False: - continue - - if coordinator.data[source].get(key) is not None: - sensors.append(SatBinarySensor(coordinator, config_entry, key, source, device_class, friendly_name_format)) - - # Add all devices - async_add_entities(sensors) - - -class SatBinarySensor(SatEntity, BinarySensorEntity): - _attr_should_poll = False - - def __init__( - self, - coordinator: SatDataUpdateCoordinator, - config_entry: ConfigEntry, - key: str, - source: str, - device_class: str, - friendly_name_format: str - ): - super().__init__(coordinator, config_entry) - - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass - ) +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, CONF_WINDOW_SENSORS +from .entity import SatClimateEntity +from .serial import binary_sensor as serial_binary_sensor + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + """ + Add binary sensors for the serial protocol if the integration is set to use it. + """ + climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] + coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] - self._key = key - self._source = source - self._coordinator = coordinator - self._device_class = device_class - self._config_entry = config_entry + # Check if integration is set to use the serial protocol + if _config_entry.data.get(CONF_MODE) == MODE_SERIAL: + await serial_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) - if TRANSLATE_SOURCE[source] is not None: - friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" + if coordinator.supports_setpoint_management: + _async_add_entities([SatControlSetpointSynchroSensor(coordinator, climate, _config_entry)]) - self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) + if coordinator.supports_relative_modulation_management: + _async_add_entities([SatRelativeModulationSynchroSensor(coordinator, climate, _config_entry)]) + + if len(_config_entry.options.get(CONF_WINDOW_SENSORS, [])) > 0: + _async_add_entities([SatWindowSensor(coordinator, climate, _config_entry)]) + + _async_add_entities([SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry)]) + + +class SatControlSetpointSynchroSensor(SatClimateEntity, BinarySensorEntity): @property def name(self): """Return the friendly name of the sensor.""" - return self._friendly_name + return "Control Setpoint Synchro" @property def device_class(self): """Return the device class.""" - return self._device_class + return BinarySensorDeviceClass.PROBLEM @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + return self._climate.setpoint is not None and self._coordinator.setpoint is not None @property def is_on(self): - """Return the state of the device.""" - return self._coordinator.data[self._source].get(self._key) + """Return the state of the sensor.""" + return round(self._climate.setpoint, 1) != round(self._coordinator.setpoint, 1) @property def unique_id(self): """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME.lower())}-{self._source}-{self._key}" - - -class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): + return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" - def __init__(self, coordinator: SatDataUpdateCoordinator, climate: SatClimate, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - self._coordinator = coordinator - self._climate = climate +class SatRelativeModulationSynchroSensor(SatClimateEntity, BinarySensorEntity): @property def name(self): """Return the friendly name of the sensor.""" - return "Control Setpoint Synchro" + return "Relative Modulation Synchro" @property def device_class(self): @@ -123,38 +84,20 @@ def device_class(self): @property def available(self): """Return availability of the sensor.""" - if self._climate is None: - return False - - if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: - return False - - return True + return self._climate.relative_modulation_value is not None and self._coordinator.maximum_relative_modulation_value is not None @property def is_on(self): """Return the state of the sensor.""" - boiler_setpoint = float(self._coordinator.data[gw_vars.BOILER].get(gw_vars.DATA_CONTROL_SETPOINT) or 0) - climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or boiler_setpoint) - - return not ( - self._climate.state_attributes.get("hvac_action") != HVACAction.HEATING or - round(climate_setpoint, 1) == round(boiler_setpoint, 1) - ) + return int(self._climate.relative_modulation_value) != int(self._coordinator.maximum_relative_modulation_value) @property def unique_id(self): """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" - + return f"{self._config_entry.data.get(CONF_NAME).lower()}-relative-modulation-synchro" -class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator: SatDataUpdateCoordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: - """Initialize the Central Heating Synchro sensor.""" - super().__init__(coordinator, config_entry) - self._coordinator = coordinator - self._climate = climate +class SatCentralHeatingSynchroSensor(SatClimateEntity, BinarySensorEntity): @property def name(self) -> str: @@ -169,28 +112,45 @@ def device_class(self) -> str: @property def available(self) -> bool: """Return availability of the sensor.""" - if self._climate is None: - return False - - if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: - return False - - return True + return self._climate is not None @property def is_on(self) -> bool: """Return the state of the sensor.""" - boiler = self._coordinator.data[gw_vars.BOILER] - boiler_central_heating = bool(boiler.get(gw_vars.DATA_MASTER_CH_ENABLED)) + device_active = self._coordinator.device_active climate_hvac_action = self._climate.state_attributes.get("hvac_action") return not ( - (climate_hvac_action == HVACAction.OFF and not boiler_central_heating) or - (climate_hvac_action == HVACAction.IDLE and not boiler_central_heating) or - (climate_hvac_action == HVACAction.HEATING and boiler_central_heating) + (climate_hvac_action == HVACAction.OFF and not device_active) or + (climate_hvac_action == HVACAction.IDLE and not device_active) or + (climate_hvac_action == HVACAction.HEATING and device_active) ) @property def unique_id(self) -> str: """Return a unique ID to use for this entity.""" return f"{self._config_entry.data.get(CONF_NAME).lower()}-central-heating-synchro" + + +class SatWindowSensor(SatClimateEntity, BinarySensorGroup): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): + super().__init__(coordinator, climate, config_entry) + + self.mode = any + self._entity_ids = self._config_entry.options.get(CONF_WINDOW_SENSORS) + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._entity_ids} + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return "Smart Autotune Thermostat Window Sensor" + + @property + def device_class(self) -> str: + """Return the device class.""" + return BinarySensorDeviceClass.WINDOW + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-window-sensor" diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 69d7000f..ef02453b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -1,16 +1,21 @@ """Climate platform for SAT.""" +from __future__ import annotations + +import asyncio import logging from collections import deque from datetime import timedelta from statistics import mean -from time import monotonic +from time import monotonic, time from typing import List +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, HVACAction, HVACMode, + PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_NONE, @@ -26,122 +31,79 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID, STATE_ON, STATE_OFF from homeassistant.core import HomeAssistant, ServiceCall, Event +from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt -from . import SatDataUpdateCoordinator, SatConfigStore from .const import * +from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity -from .heating_curve import HeatingCurve -from .overshoot_protection import OvershootProtection -from .pid import PID -from .pwm import PWM, PWMState +from .minimum_setpoint import MinimumSetpoint +from .pwm import PWMState +from .relative_modulation import RelativeModulation, RelativeModulationState +from .summer_simmer import SummerSimmer +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, \ + calculate_derivative_per_hour ATTR_ROOMS = "rooms" +ATTR_WARMING_UP = "warming_up_data" +ATTR_OPTIMAL_COEFFICIENT = "optimal_coefficient" +ATTR_COEFFICIENT_DERIVATIVE = "coefficient_derivative" +ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" +ATTR_PRE_CUSTOM_TEMPERATURE = "pre_custom_temperature" +ATTR_PRE_ACTIVITY_TEMPERATURE = "pre_activity_temperature" +ATTR_ADJUSTED_MINIMUM_SETPOINTS = "adjusted_minimum_setpoints" + SENSOR_TEMPERATURE_ID = "sensor_temperature_id" _LOGGER = logging.getLogger(__name__) -def convert_time_str_to_seconds(time_str: str) -> float: - """Convert a time string in the format 'HH:MM:SS' to seconds. - - Args: - time_str: A string representing a time in the format 'HH:MM:SS'. - - Returns: - float: The time in seconds. - """ - date_time = dt.parse_time(time_str) - # Calculate the number of seconds by multiplying the hours, minutes and seconds - return (date_time.hour * 3600) + (date_time.minute * 60) + date_time.second - - -def create_pid_controller(options) -> PID: - """Create and return a PID controller instance with the given configuration options.""" - # Extract the configuration options - kp = float(options.get(CONF_PROPORTIONAL)) - ki = float(options.get(CONF_INTEGRAL)) - kd = float(options.get(CONF_DERIVATIVE)) - heating_system = options.get(CONF_HEATING_SYSTEM) - automatic_gains = bool(options.get(CONF_AUTOMATIC_GAINS)) - sample_time_limit = convert_time_str_to_seconds(options.get(CONF_SAMPLE_TIME)) - - # Return a new PID controller instance with the given configuration options - return PID(kp=kp, ki=ki, kd=kd, heating_system=heating_system, automatic_gains=automatic_gains, sample_time_limit=sample_time_limit) - - -def create_heating_curve_controller(options) -> HeatingCurve: - """Create and return a PID controller instance with the given configuration options.""" - # Extract the configuration options - heating_system = options.get(CONF_HEATING_SYSTEM) - coefficient = float(options.get(CONF_HEATING_CURVE_COEFFICIENT)) - - # Return a new heating Curve controller instance with the given configuration options - return HeatingCurve(heating_system=heating_system, coefficient=coefficient) - - -def create_pwm_controller(heating_curve: HeatingCurve, store: SatConfigStore, options) -> PWM | None: - """Create and return a PWM controller instance with the given configuration options.""" - # Extract the configuration options - automatic_duty_cycle = bool(options.get(CONF_AUTOMATIC_DUTY_CYCLE)) - max_cycle_time = int(convert_time_str_to_seconds(options.get(CONF_DUTY_CYCLE))) - - # Return a new PWM controller instance with the given configuration options - return PWM(store=store, heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle) +async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_devices: AddEntitiesCallback): + """Set up the SatClimate device.""" + coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] + climate = SatClimate(coordinator, _config_entry, _hass.config.units.temperature_unit) + _async_add_devices([climate]) + _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] = climate -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_devices): - """Set up the SatClimate device.""" - store = SatConfigStore(hass) - await store.async_initialize() - climate = SatClimate( - hass.data[DOMAIN][config_entry.entry_id][COORDINATOR], - store, - config_entry, - hass.config.units.temperature_unit - ) +class SatWarmingUp: + def __init__(self, error: float, boiler_temperature: float = None, started: int = None): + self.error = error + self.boiler_temperature = boiler_temperature + self.started = started if started is not None else int(time()) - async_add_devices([climate]) - hass.data[DOMAIN][config_entry.entry_id][CLIMATE] = climate + @property + def elapsed(self): + return int(time()) - self.started class SatClimate(SatEntity, ClimateEntity, RestoreEntity): - def __init__(self, coordinator: SatDataUpdateCoordinator, store: SatConfigStore, config_entry: ConfigEntry, unit: str): + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, unit: str): super().__init__(coordinator, config_entry) - self._store = store - self._coordinator = coordinator - self._config_entry = config_entry - - # Get configuration options and update with default values - options = OPTIONS_DEFAULTS.copy() - options.update(config_entry.options) - - # Create dictionary mapping preset keys to temperature options - conf_presets = {p: f"{p}_temperature" for p in (PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} - - # Create dictionary mapping preset keys to temperature values - self._presets = {key: options[value] for key, value in conf_presets.items() if value in options} - - # Create PID controller with given configuration options - self._pid = create_pid_controller(options) - - # Get inside sensor entity ID + # Get some sensor entity IDs self.inside_sensor_entity_id = config_entry.data.get(CONF_INSIDE_SENSOR_ENTITY_ID) + self.humidity_sensor_entity_id = config_entry.data.get(CONF_HUMIDITY_SENSOR_ENTITY_ID) - # Get inside sensor entity state + # Get some sensor entity states inside_sensor_entity = coordinator.hass.states.get(self.inside_sensor_entity_id) + humidity_sensor_entity = coordinator.hass.states.get(self.humidity_sensor_entity_id) if self.humidity_sensor_entity_id is not None else None # Get current temperature self._current_temperature = None if inside_sensor_entity is not None and inside_sensor_entity.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: self._current_temperature = float(inside_sensor_entity.state) + # Get current temperature + self._current_humidity = None + if humidity_sensor_entity is not None and humidity_sensor_entity.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + self._current_humidity = float(humidity_sensor_entity.state) + # Get outside sensor entity IDs self.outside_sensor_entities = config_entry.data.get(CONF_OUTSIDE_SENSOR_ENTITY_ID) @@ -149,39 +111,29 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, store: SatConfigStore, if isinstance(self.outside_sensor_entities, str): self.outside_sensor_entities = [self.outside_sensor_entities] - # Create Heating Curve controller with given configuration options - self._heating_curve = create_heating_curve_controller(options) + # Create config options dictionary with defaults + config_options = OPTIONS_DEFAULTS.copy() + config_options.update(config_entry.options) - # Create PWM controller with given configuration options - self._pwm = create_pwm_controller(self._heating_curve, self._store, options) + # Create dictionary mapping preset keys to temperature options + conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} + + # Create dictionary mapping preset keys to temperature values + self._presets = {key: config_options[value] for key, value in conf_presets.items() if key in conf_presets} self._sensors = [] self._rooms = None self._setpoint = None - self._warming_up = False - self._max_relative_mod = None - self._is_device_active = False self._outputs = deque(maxlen=50) + self._warming_up_data = None + self._warming_up_derivative = None + self._hvac_mode = None self._target_temperature = None - self._saved_target_temperature = None - self._overshoot_protection_calculate = False - - self._climates = options.get(CONF_CLIMATES) - self._main_climates = options.get(CONF_MAIN_CLIMATES) - - self._simulation = bool(options.get(CONF_SIMULATION)) - self._heating_system = str(options.get(CONF_HEATING_SYSTEM)) - self._overshoot_protection = bool(options.get(CONF_OVERSHOOT_PROTECTION)) - self._climate_valve_offset = float(options.get(CONF_CLIMATE_VALVE_OFFSET)) - self._target_temperature_step = float(options.get(CONF_TARGET_TEMPERATURE_STEP)) - self._sync_climates_with_preset = bool(options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) - self._force_pulse_width_modulation = bool(options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - self._sensor_max_value_age = convert_time_str_to_seconds(options.get(CONF_SENSOR_MAX_VALUE_AGE)) - - self._attr_name = str(config_entry.data.get(CONF_NAME)) - self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() + self._window_sensor_handle = None + self._pre_custom_temperature = None + self._pre_activity_temperature = None self._attr_temperature_unit = unit self._attr_hvac_mode = HVACMode.OFF @@ -190,6 +142,45 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, store: SatConfigStore, self._attr_preset_modes = [PRESET_NONE] + list(self._presets.keys()) self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + # System Configuration + self._attr_name = str(config_entry.data.get(CONF_NAME)) + self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() + + self._climates = config_entry.data.get(CONF_SECONDARY_CLIMATES) or [] + self._main_climates = config_entry.data.get(CONF_MAIN_CLIMATES) or [] + self._window_sensors = config_entry.data.get(CONF_WINDOW_SENSORS) or [] + + self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) + self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM)) + self._sync_with_thermostat = bool(config_entry.data.get(CONF_SYNC_WITH_THERMOSTAT)) + self._overshoot_protection = bool(config_entry.data.get(CONF_OVERSHOOT_PROTECTION)) + + # User Configuration + self._thermal_comfort = bool(config_options.get(CONF_THERMAL_COMFORT)) + self._climate_valve_offset = float(config_options.get(CONF_CLIMATE_VALVE_OFFSET)) + self._target_temperature_step = float(config_options.get(CONF_TARGET_TEMPERATURE_STEP)) + self._dynamic_minimum_setpoint = bool(config_options.get(CONF_DYNAMIC_MINIMUM_SETPOINT)) + self._sync_climates_with_preset = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) + self._maximum_relative_modulation = int(config_options.get(CONF_MAXIMUM_RELATIVE_MODULATION)) + self._force_pulse_width_modulation = bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + self._sensor_max_value_age = convert_time_str_to_seconds(config_options.get(CONF_SENSOR_MAX_VALUE_AGE)) + self._window_minimum_open_time = convert_time_str_to_seconds(config_options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) + + # Create PID controller with given configuration options + self.pid = create_pid_controller(config_options) + + # Create Heating Curve controller with given configuration options + self.heating_curve = create_heating_curve_controller(config_entry.data, config_options) + + # Create PWM controller with given configuration options + self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) + + # Create the Minimum Setpoint controller + self._minimum_setpoint = MinimumSetpoint(coordinator) + + # Create Relative Modulation controller + self._relative_modulation = RelativeModulation(coordinator, self._heating_system) + if self._simulation: _LOGGER.warning("Simulation mode!") @@ -197,6 +188,36 @@ async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() + # Register event listeners + await self._register_event_listeners() + + # Restore previous state if available, or set default values + await self._restore_previous_state_or_set_defaults() + + # Update a heating curve if outside temperature is available + if self.current_outside_temperature is not None: + self.heating_curve.update(self.target_temperature, self.current_outside_temperature) + + # Start control loop + await self.async_control_heating_loop() + + # Register services + await self._register_services() + + # Initialize minimum setpoint system + await self._minimum_setpoint.async_initialize(self.hass) + + # Let the coordinator know we are ready + await self._coordinator.async_added_to_hass(self) + + async def _register_event_listeners(self): + """Register event listeners.""" + self.async_on_remove( + async_track_time_interval( + self.hass, self.async_control_heating_loop, timedelta(seconds=30) + ) + ) + self.async_on_remove( async_track_state_change_event( self.hass, [self.inside_sensor_entity_id], self._async_inside_sensor_changed @@ -204,47 +225,57 @@ async def async_added_to_hass(self) -> None: ) self.async_on_remove( - async_track_time_interval( - self.hass, self._async_control_heating, timedelta(seconds=30) + async_track_state_change_event( + self.hass, self.outside_sensor_entities, self._async_outside_entity_changed ) ) - for entity_id in self.outside_sensor_entities: + if self.humidity_sensor_entity_id is not None: self.async_on_remove( async_track_state_change_event( - self.hass, [entity_id], self._async_outside_entity_changed + self.hass, [self.humidity_sensor_entity_id], self._async_humidity_sensor_changed ) ) - for climate_id in self._main_climates: + self.async_on_remove( + async_track_state_change_event( + self.hass, self._main_climates, self._async_main_climate_changed + ) + ) + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._climates, self._async_climate_changed + ) + ) + + if len(self._window_sensors) > 0: + entities = entity_registry.async_get(self.hass) + unique_id = f"{self._config_entry.data.get(CONF_NAME).lower()}-window-sensor" + self.async_on_remove( async_track_state_change_event( - self.hass, [climate_id], self._async_main_climate_changed + self.hass, [entities.async_get_entity_id(BINARY_SENSOR_DOMAIN, DOMAIN, unique_id)], self._async_window_sensor_changed ) ) for climate_id in self._climates: state = self.hass.states.get(climate_id) if state is not None and (sensor_temperature_id := state.attributes.get(SENSOR_TEMPERATURE_ID)): - await self.track_sensor_temperature(sensor_temperature_id) + await self.async_track_sensor_temperature(sensor_temperature_id) - self.async_on_remove( - async_track_state_change_event( - self.hass, [climate_id], self._async_climate_changed - ) - ) + async def _restore_previous_state_or_set_defaults(self): + """Restore previous state if available, or set default values.""" + old_state = await self.async_get_last_state() - # Check If we have an old state - if (old_state := await self.async_get_last_state()) is not None: - # If we have no initial temperature, restore + if old_state is not None: if self._target_temperature is None: - # If we have a previously saved temperature if old_state.attributes.get(ATTR_TEMPERATURE) is None: - self._pid.setpoint = self.min_temp + self.pid.setpoint = self.min_temp self._target_temperature = self.min_temp _LOGGER.warning("Undefined target temperature, falling back to %s", self._target_temperature, ) else: - self._pid.restore(old_state) + self.pid.restore(old_state) self._target_temperature = float(old_state.attributes[ATTR_TEMPERATURE]) if old_state.state: @@ -253,196 +284,110 @@ async def async_added_to_hass(self) -> None: if old_state.attributes.get(ATTR_PRESET_MODE): self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + if warming_up := old_state.attributes.get(ATTR_WARMING_UP): + self._warming_up_data = SatWarmingUp(warming_up["error"], warming_up["boiler_temperature"], warming_up["started"]) + + if old_state.attributes.get(ATTR_WARMING_UP_DERIVATIVE): + self._warming_up_derivative = old_state.attributes.get(ATTR_WARMING_UP_DERIVATIVE) + + if old_state.attributes.get(ATTR_PRE_ACTIVITY_TEMPERATURE): + self._pre_activity_temperature = old_state.attributes.get(ATTR_PRE_ACTIVITY_TEMPERATURE) + + if old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE): + self._pre_custom_temperature = old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE) + + if old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT): + self.heating_curve.restore_autotune( + old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT), + old_state.attributes.get(ATTR_COEFFICIENT_DERIVATIVE) + ) + if old_state.attributes.get(ATTR_ROOMS): self._rooms = old_state.attributes.get(ATTR_ROOMS) else: - await self._update_room_with_target_temperature() + await self._async_update_rooms_from_climates() else: - # No previous state, try and restore defaults if self._rooms is None: - await self._update_room_with_target_temperature() + await self._async_update_rooms_from_climates() if self._target_temperature is None: - self._pid.setpoint = self.min_temp + self.pid.setpoint = self.min_temp self._target_temperature = self.min_temp _LOGGER.warning("No previously saved temperature, setting to %s", self._target_temperature) - # Set default state to off if not self._hvac_mode: self._hvac_mode = HVACMode.OFF - if self.current_outside_temperature is not None: - self._heating_curve.update( - target_temperature=self.target_temperature, - outside_temperature=self.current_outside_temperature - ) - self.async_write_ha_state() - await self._async_control_heating() - await self._async_control_max_setpoint() - - if self._overshoot_protection and self._store.retrieve_overshoot_protection_value() is None: - self._overshoot_protection = False - - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Smart Autotune Thermostat", - "message": "Disabled overshoot protection because no overshoot value has been found." - }) - - if self._force_pulse_width_modulation and self._store.retrieve_overshoot_protection_value() is None: - self._force_pulse_width_modulation = False - - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Smart Autotune Thermostat", - "message": "Disabled forced pulse width modulation because no overshoot value has been found." - }) - - async def start_overshoot_protection_calculation(_call: ServiceCall): - """Service to start the overshoot protection calculation process. - - This process will activate overshoot protection by turning on the heater and setting the control setpoint to - a fixed value. Then, it will collect return water temperature data and calculate the mean of the last 3 data - points. If the difference between the current return water temperature and the mean is small, it will - deactivate overshoot protection and store the calculated value. - """ - if self._overshoot_protection_calculate: - _LOGGER.warning("[Overshoot Protection] Calculation already in progress.") - return - - self._is_device_active = True - self._overshoot_protection_calculate = True - - saved_hvac_mode = self._hvac_mode - saved_target_temperature = self._target_temperature - - saved_target_temperatures = {} - for entity_id in self._climates: - if state := self.hass.states.get(entity_id): - saved_target_temperatures[entity_id] = float(state.attributes.get("temperature")) - - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 30} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - - self._hvac_mode = HVACMode.HEAT - await self._async_set_setpoint(30) - - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": "Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." - }) - - overshoot_protection_value = await OvershootProtection(self._coordinator).calculate(_call.data.get("solution")) - self._overshoot_protection_calculate = False - - await self.async_set_hvac_mode(saved_hvac_mode) - - await self._async_control_max_setpoint() - await self._async_set_setpoint(saved_target_temperature) - - for entity_id in self._climates: - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: saved_target_temperatures[entity_id]} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - - if overshoot_protection_value is None: - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": f"Timed out waiting for stable temperature" - }) - else: - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": f"Finished calculating. Result: {round(overshoot_protection_value, 1)}" - }) - - # Turn the overshoot protection settings back on - self._overshoot_protection = bool(self._config_entry.options.get(CONF_OVERSHOOT_PROTECTION)) - self._force_pulse_width_modulation = bool(self._config_entry.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - - # Store the new value - self._store.store_overshoot_protection_value(overshoot_protection_value) - - self.hass.services.async_register(DOMAIN, "start_overshoot_protection_calculation", start_overshoot_protection_calculation) - - async def set_overshoot_protection_value(_call: ServiceCall): - """Service to set the overshoot protection value.""" - self._store.store_overshoot_protection_value(_call.data.get("value")) - - self.hass.services.async_register(DOMAIN, "overshoot_protection_value", set_overshoot_protection_value) + async def _register_services(self): async def reset_integral(_call: ServiceCall): """Service to reset the integral part of the PID controller.""" - self._pid.reset() - - self.hass.services.async_register(DOMAIN, "reset_integral", reset_integral) + self.pid.reset() - async def track_sensor_temperature(self, entity_id): - """ - Track the temperature of the sensor specified by the given entity_id. - - Parameters: - entity_id (str): The entity id of the sensor to track. - - If the sensor is already being tracked, the method will return without doing anything. - Otherwise, it will register a callback for state changes on the specified sensor and start tracking its temperature. - """ - if entity_id in self._sensors: - return - - self.async_on_remove( - async_track_state_change_event( - self.hass, [entity_id], self._async_temperature_change - ) - ) - - self._sensors.append(entity_id) + self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) @property def name(self): """Return the friendly name of the sensor.""" return self._attr_name + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return self._attr_id + @property def extra_state_attributes(self): """Return device state attributes.""" return { - "error": self._pid.last_error, - "integral": self._pid.integral, - "derivative": self._pid.derivative, - "proportional": self._pid.proportional, - "history_size": self._pid.history_size, - "collected_errors": self._pid.num_errors, - "integral_enabled": self._pid.integral_enabled, + "error": self.pid.last_error, + "integral": self.pid.integral, + "derivative": self.pid.derivative, + "proportional": self.pid.proportional, + "history_size": self.pid.history_size, + "collected_errors": self.pid.num_errors, + "integral_enabled": self.pid.integral_enabled, + + "pre_custom_temperature": self._pre_custom_temperature, + "pre_activity_temperature": self._pre_activity_temperature, - "derivative_enabled": self._pid.derivative_enabled, - "derivative_raw": self._pid.raw_derivative, + "derivative_enabled": self.pid.derivative_enabled, + "derivative_raw": self.pid.raw_derivative, - "current_kp": self._pid.kp, - "current_ki": self._pid.ki, - "current_kd": self._pid.kd, + "current_kp": self.pid.kp, + "current_ki": self.pid.ki, + "current_kd": self.pid.kd, "rooms": self._rooms, "setpoint": self._setpoint, - "warming_up": self._warming_up, + "current_humidity": self._current_humidity, + "summer_simmer_index": SummerSimmer.index(self._current_temperature, self._current_humidity), + "summer_simmer_perception": SummerSimmer.perception(self._current_temperature, self._current_humidity), + "warming_up_data": vars(self._warming_up_data) if self._warming_up_data is not None else None, + "warming_up_derivative": self._warming_up_derivative, "valves_open": self.valves_open, - "max_relative_mod": self._max_relative_mod, - "heating_curve": self._heating_curve.value, + "heating_curve": self.heating_curve.value, + "minimum_setpoint": self.minimum_setpoint, + "requested_setpoint": self.requested_setpoint, + "adjusted_minimum_setpoint": self.adjusted_minimum_setpoint, "outside_temperature": self.current_outside_temperature, - "optimal_coefficient": self._heating_curve.optimal_coefficient, - "pulse_width_modulation_enabled": self._pulse_width_modulation_enabled, - "pulse_width_modulation_state": self._pwm.state, - "pulse_width_modulation_duty_cycle": self._pwm.duty_cycle, - "overshoot_protection_calculating": self._overshoot_protection_calculate, - "overshoot_protection_value": self._store.retrieve_overshoot_protection_value(), + "optimal_coefficient": self.heating_curve.optimal_coefficient, + "coefficient_derivative": self.heating_curve.coefficient_derivative, + "relative_modulation_value": self.relative_modulation_value, + "relative_modulation_state": self.relative_modulation_state, + "relative_modulation_enabled": self._relative_modulation.enabled, + "pulse_width_modulation_enabled": self.pulse_width_modulation_enabled, + "pulse_width_modulation_state": self.pwm.state, + "pulse_width_modulation_duty_cycle": self.pwm.duty_cycle, } - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return self._attr_id - @property def current_temperature(self): """Return the sensor temperature.""" + if self._thermal_comfort and self._current_humidity is not None: + return SummerSimmer.index(self._current_temperature, self._current_humidity) + return self._current_temperature @property @@ -450,13 +395,18 @@ def target_temperature(self): """Return the temperature we try to reach.""" return self._target_temperature + @property + def current_humidity(self): + """Return the sensor humidity.""" + return self._current_humidity + @property def error(self): """Return the error value.""" - if self._target_temperature is None or self._current_temperature is None: + if self.target_temperature is None or self.current_temperature is None: return 0 - return round(self._target_temperature - self._current_temperature, 2) + return round(self.target_temperature - self.current_temperature, 2) @property def current_outside_temperature(self): @@ -494,7 +444,7 @@ def hvac_action(self): if self._hvac_mode == HVACMode.OFF: return HVACAction.OFF - if not self._is_device_active: + if self._coordinator.device_state == DeviceState.OFF: return HVACAction.IDLE return HVACAction.HEATING @@ -503,6 +453,18 @@ def hvac_action(self): def max_error(self) -> float: return max([self.error] + self.climate_errors) + @property + def setpoint(self) -> float | None: + return self._setpoint + + @property + def requested_setpoint(self) -> float: + """Get the requested setpoint based on the heating curve and PID output.""" + if self.heating_curve.value is None: + return MINIMUM_SETPOINT + + return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1) + @property def climate_errors(self) -> List[float]: """Calculate the temperature difference between the current temperature and target temperature for all connected climates.""" @@ -517,13 +479,17 @@ def climate_errors(self) -> List[float]: target_temperature = float(state.attributes.get("temperature")) current_temperature = float(state.attributes.get("current_temperature") or target_temperature) - # Retrieve the overriden sensor temperature if set + # Retrieve the overridden sensor temperature if set if sensor_temperature_id := state.attributes.get(SENSOR_TEMPERATURE_ID): sensor_state = self.hass.states.get(sensor_temperature_id) if sensor_state is not None and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]: current_temperature = float(sensor_state.state) - errors.append(round(target_temperature - current_temperature, 2)) + # Calculate the error value + error = round(target_temperature - current_temperature, 2) + + # Add to the list, so we calculate the max. later + errors.append(error) return errors @@ -568,77 +534,51 @@ def valves_open(self) -> bool: return False @property - def _pulse_width_modulation_enabled(self) -> bool: - """Return True if pulse width modulation is enabled, False otherwise. + def pulse_width_modulation_enabled(self) -> bool: + """Return True if pulse width modulation is enabled, False otherwise.""" + if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: + return True - If an overshoot protection value is not set, pulse width modulation is disabled. - If pulse width modulation is forced on, it is enabled. - If overshoot protection is enabled, and we are below the overshoot protection value. - """ - if (overshoot_protection_value := self._store.retrieve_overshoot_protection_value()) is None: - return False + return self._overshoot_protection and self._calculate_control_setpoint() < self.minimum_setpoint - if self._force_pulse_width_modulation: - return True + @property + def relative_modulation_value(self) -> int: + return self._maximum_relative_modulation if self._relative_modulation.enabled else MINIMUM_RELATIVE_MOD - if self._overshoot_protection: - if self.max_error <= 0.1: - return True + @property + def relative_modulation_state(self) -> RelativeModulationState: + return self._relative_modulation.state - if self._warming_up and self._setpoint < (overshoot_protection_value - 2): - return True + @property + def warming_up(self): + """Return True if we are warming up, False otherwise.""" + return self._warming_up_data is not None and self._warming_up_data.elapsed < HEATER_STARTUP_TIMEFRAME - return False + @property + def minimum_setpoint(self) -> float: + if not self._dynamic_minimum_setpoint: + return self._coordinator.minimum_setpoint + + return self.adjusted_minimum_setpoint + + @property + def adjusted_minimum_setpoint(self) -> float: + return self._minimum_setpoint.current([self.error] + self.climate_errors) def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" - if self._heating_curve.value is None: + if self.heating_curve.value is None: return MINIMUM_SETPOINT # Combine the heating curve value and the calculated output from the pid controller - requested_setpoint = self._get_requested_setpoint() + requested_setpoint = self.requested_setpoint - # Make sure we are above the base setpoint when we are below target temperature + # Make sure we are above the base setpoint when we are below the target temperature if self.max_error > 0: - requested_setpoint = max(requested_setpoint, self._heating_curve.value) + requested_setpoint = max(requested_setpoint, self.heating_curve.value) # Ensure setpoint is limited to our max - setpoint = min(requested_setpoint, self._get_maximum_setpoint()) - - # Ensure setpoint is at least 10 - return round(max(setpoint, MINIMUM_SETPOINT), 1) - - def _get_requested_setpoint(self): - return self._heating_curve.value + self._pid.output - - def _get_maximum_setpoint(self) -> float: - if self._heating_system == HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES: - return 75.0 - - if self._heating_system == HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES: - return 65.0 - - if self._heating_system == HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES: - return 55.0 - - if self._heating_system == HEATING_SYSTEM_UNDERFLOOR: - return 50.0 - - def _calculate_max_relative_mod(self) -> int: - if bool(self._coordinator.get(gw_vars.DATA_SLAVE_DHW_ACTIVE)): - return MAXIMUM_RELATIVE_MOD - - if self._setpoint <= MINIMUM_SETPOINT: - return MAXIMUM_RELATIVE_MOD - - if self._overshoot_protection and not self._force_pulse_width_modulation: - if self._setpoint is None or (overshoot_protection_value := self._store.retrieve_overshoot_protection_value()) is None: - return MAXIMUM_RELATIVE_MOD - - if abs(self.max_error) > 0.1 and self._setpoint >= (overshoot_protection_value - 2): - return MAXIMUM_RELATIVE_MOD - - return MINIMUM_RELATIVE_MOD + return min(requested_setpoint, self._coordinator.maximum_setpoint) async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" @@ -651,14 +591,31 @@ async def _async_inside_sensor_changed(self, event: Event) -> None: self.async_write_ha_state() await self._async_control_pid() - await self._async_control_heating() + await self.async_control_heating_loop() async def _async_outside_entity_changed(self, event: Event) -> None: """Handle changes to the outside entity.""" if event.data.get("new_state") is None: return - await self._async_control_heating() + _LOGGER.debug("Outside Sensor Changed.") + self.async_write_ha_state() + + await self._async_control_pid() + await self.async_control_heating_loop() + + async def _async_humidity_sensor_changed(self, event: Event) -> None: + """Handle changes to the inside temperature sensor.""" + new_state = event.data.get("new_state") + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + _LOGGER.debug("Humidity Sensor Changed.") + self._current_humidity = float(new_state.state) + self.async_write_ha_state() + + await self._async_control_pid() + await self.async_control_heating_loop() async def _async_main_climate_changed(self, event: Event) -> None: """Handle changes to the main climate entity.""" @@ -669,7 +626,7 @@ async def _async_main_climate_changed(self, event: Event) -> None: if old_state is None or new_state.state != old_state.state: _LOGGER.debug(f"Main Climate State Changed ({new_state.entity_id}).") - await self._async_control_heating() + await self.async_control_heating_loop() async def _async_climate_changed(self, event: Event) -> None: """Handle changes to the climate entity. @@ -696,7 +653,7 @@ async def _async_climate_changed(self, event: Event) -> None: # Check if the last state is None, so we can track the attached sensor if needed if old_state is None and (sensor_temperature_id := new_attrs.get(SENSOR_TEMPERATURE_ID)): - await self.track_sensor_temperature(sensor_temperature_id) + await self.async_track_sensor_temperature(sensor_temperature_id) # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: @@ -706,8 +663,9 @@ async def _async_climate_changed(self, event: Event) -> None: elif new_attrs.get("temperature") != old_attrs.get("temperature"): await self._async_control_pid(True) - # If current temperature has changed, update the PID controller - elif not hasattr(new_state.attributes, SENSOR_TEMPERATURE_ID) and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): + # If the current temperature has changed, update the PID controller + elif not hasattr(new_state.attributes, SENSOR_TEMPERATURE_ID) and new_attrs.get("current_temperature") != old_attrs.get( + "current_temperature"): await self._async_control_pid(False) if (self._rooms is not None and new_state.entity_id not in self._rooms) or self.preset_mode in [PRESET_HOME, PRESET_COMFORT]: @@ -715,7 +673,7 @@ async def _async_climate_changed(self, event: Event) -> None: self._rooms[new_state.entity_id] = float(target_temperature) # Update the heating control - await self._async_control_heating() + await self.async_control_heating_loop() async def _async_temperature_change(self, event: Event) -> None: """Handle changes to the climate sensor entity. @@ -728,71 +686,57 @@ async def _async_temperature_change(self, event: Event) -> None: _LOGGER.debug(f"Climate Sensor Changed ({new_state.entity_id}).") await self._async_control_pid(False) - await self._async_control_heating() - - async def _async_control_heating(self, _time=None) -> None: - """Control the heating based on current temperature, target temperature, and outside temperature.""" - # If overshoot protection is active, we are not doing anything since we already have task running in async - if self._overshoot_protection_calculate: - return + await self.async_control_heating_loop() - # If the current, target or outside temperature is not available, do nothing - if self.current_temperature is None or self.target_temperature is None or self.current_outside_temperature is None: + async def _async_window_sensor_changed(self, event: Event) -> None: + """Handle changes to the contact sensor entity.""" + new_state = event.data.get("new_state") + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return - # Make sure the boiler is off when the climate is off, and do nothing else - if self.hvac_mode == HVACMode.OFF and bool(self._coordinator.get(gw_vars.DATA_MASTER_CH_ENABLED)): - await self._async_control_heater(False) - - # Pulse Width Modulation - if self._overshoot_protection or self._force_pulse_width_modulation: - await self._pwm.update(self._get_requested_setpoint()) + _LOGGER.debug(f"Window Sensor Changed to {new_state.state}.") - # Set the control setpoint to make sure we always stay in control - await self._async_control_setpoint(self._pwm.state) + if new_state.state == STATE_ON: + if self.preset_mode == PRESET_ACTIVITY: + return - # Set the max relative mod - await self._async_control_max_relative_mod() + try: + self._window_sensor_handle = asyncio.create_task(asyncio.sleep(self._window_minimum_open_time)) + self._pre_activity_temperature = self.target_temperature - # Control the integral (if exceeded the time limit) - self._pid.update_integral(self.max_error, self._heating_curve.value) + await self._window_sensor_handle + await self.async_set_preset_mode(PRESET_ACTIVITY) + except asyncio.CancelledError: + _LOGGER.debug("Window closed before minimum open time.") - if self._is_device_active: - # If the setpoint is too low or the valves are closed or HVAC is off, turn off the heater - if self._setpoint <= MINIMUM_SETPOINT or not self.valves_open or self.hvac_mode == HVACMode.OFF: - await self._async_control_heater(False) - else: - await self._async_control_heater(True) - else: - # If the setpoint is high and the valves are open and the HVAC is not off, turn on the heater - if self._setpoint > MINIMUM_SETPOINT and self.valves_open and self.hvac_mode != HVACMode.OFF: - await self._async_control_heater(True) - else: - await self._async_control_heater(False) + return - self.async_write_ha_state() + if new_state.state == STATE_OFF: + if self._window_sensor_handle is not None: + self._window_sensor_handle.cancel() + self._window_sensor_handle = None - async def _async_control_max_setpoint(self) -> None: - _LOGGER.info(f"Set max setpoint to {self._get_maximum_setpoint()}") + if self.preset_mode == PRESET_ACTIVITY: + _LOGGER.debug(f"Restoring original target temperature.") + await self.async_set_temperature(temperature=self._pre_activity_temperature) - if not self._simulation: - await self._coordinator.api.set_max_ch_setpoint(self._get_maximum_setpoint()) + return - async def _async_control_pid(self, reset: bool = False): + async def _async_control_pid(self, reset: bool = False) -> None: """Control the PID controller.""" # We can't continue if we don't have a valid outside temperature if self.current_outside_temperature is None: return # Reset the PID controller if the sensor data is too old - if self._sensor_max_value_age != 0 and monotonic() - self._pid.last_updated > self._sensor_max_value_age: - self._pid.reset() + if self._sensor_max_value_age != 0 and monotonic() - self.pid.last_updated > self._sensor_max_value_age: + self.pid.reset() # Calculate the maximum error between the current temperature and the target temperature of all climates max_error = self.max_error # Make sure we use the latest heating curve value - self._heating_curve.update( + self.heating_curve.update( target_temperature=self.target_temperature, outside_temperature=self.current_outside_temperature, ) @@ -801,97 +745,185 @@ async def _async_control_pid(self, reset: bool = False): if not reset: _LOGGER.info(f"Updating error value to {max_error} (Reset: False)") - # Calculate optimal heating curve when we are in the deadband - if -0.1 <= max_error <= 0.1: - self._heating_curve.autotune( - setpoint=self._get_requested_setpoint(), + # Calculate an optimal heating curve when we are in the deadband + if -DEADBAND <= max_error <= DEADBAND: + self.heating_curve.autotune( + setpoint=self.requested_setpoint, target_temperature=self.target_temperature, outside_temperature=self.current_outside_temperature ) - # Since we are in the deadband we can safely assume we are not warming up anymore - if self._warming_up and max_error <= 0.1: - self._warming_up = False + # Since we are in the deadband, we can safely assume we are not warming up anymore + if self._warming_up_data and max_error <= DEADBAND: + # Calculate the derivative per hour + self._warming_up_derivative = calculate_derivative_per_hour( + self._warming_up_data.error, + self._warming_up_data.elapsed + ) + + # Notify that we are not warming anymore _LOGGER.info("Reached deadband, turning off warming up.") + self._warming_up_data = None - # Update the pid controller - self._pid.update(error=max_error, heating_curve_value=self._heating_curve.value) - elif max_error != self._pid.last_error: + self.pid.update( + error=max_error, + heating_curve_value=self.heating_curve.value, + boiler_temperature=self._coordinator.filtered_boiler_temperature + ) + elif max_error != self.pid.last_error: _LOGGER.info(f"Updating error value to {max_error} (Reset: True)") - self._pid.update_reset(error=max_error, heating_curve_value=self._heating_curve.value) + self.pid.update_reset(error=max_error, heating_curve_value=self.heating_curve.value) self._outputs.clear() - self._pwm.reset() + self.pwm.reset() # Determine if we are warming up - if self.max_error > 0.1: - self._warming_up = True + if self.max_error > DEADBAND: + self._warming_up_data = SatWarmingUp(self.max_error, self._coordinator.boiler_temperature) _LOGGER.info("Outside of deadband, we are warming up") self.async_write_ha_state() - async def _async_control_heater(self, enabled: bool) -> None: - """Control the state of the central heating.""" - if enabled: - await self._async_control_pid(True) - - if not self._simulation: - await self._coordinator.api.set_ch_enable_bit(int(enabled)) - - self._is_device_active = enabled - - _LOGGER.info("Set central heating to %d", enabled) - - async def _async_control_setpoint(self, pwm_state: PWMState): + async def _async_control_setpoint(self, pwm_state: PWMState) -> None: """Control the setpoint of the heating system.""" if self.hvac_mode == HVACMode.HEAT: - if self._pulse_width_modulation_enabled and pwm_state != pwm_state.IDLE: - self._setpoint = self._store.retrieve_overshoot_protection_value() if pwm_state == pwm_state.ON else MINIMUM_SETPOINT - _LOGGER.info(f"Running pulse width modulation cycle: {pwm_state}") + self._outputs.append(self._calculate_control_setpoint()) + + if not self.pulse_width_modulation_enabled or pwm_state == pwm_state.IDLE: + _LOGGER.info("Running Normal cycle") + setpoint = round(mean(list(self._outputs)[-5:]), 1) + self._setpoint = max(self.minimum_setpoint, setpoint) else: - self._outputs.append(self._calculate_control_setpoint()) - self._setpoint = mean(list(self._outputs)[-5:]) - _LOGGER.info("Running normal cycle") + _LOGGER.info(f"Running PWM cycle: {pwm_state}") + self._setpoint = self.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT else: self._outputs.clear() self._setpoint = MINIMUM_SETPOINT - if not self._simulation: - await self._coordinator.api.set_control_setpoint(self._setpoint) + await self._coordinator.async_set_control_setpoint(self._setpoint) - _LOGGER.info("Set control setpoint to %d", self._setpoint) + async def _async_control_relative_modulation(self) -> None: + """Control the relative modulation value based on the conditions""" + if self._coordinator.supports_relative_modulation_management: + await self._relative_modulation.update(self.warming_up, self.pwm.state) + await self._coordinator.async_set_control_max_relative_modulation(self.relative_modulation_value) - async def _async_control_max_relative_mod(self): - """Control the max relative mod of the heating system.""" - if self.hvac_mode == HVACMode.HEAT: - self._max_relative_mod = self._calculate_max_relative_mod() - else: - self._max_relative_mod = 100 + async def _async_update_rooms_from_climates(self) -> None: + """Update the temperature setpoint for each room based on their associated climate entity.""" + self._rooms = {} + + # Iterate through each climate entity + for entity_id in self._climates: + state = self.hass.states.get(entity_id) + + # Skip any entities that are unavailable or have an unknown state + if not state or state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + continue + + # Retrieve the target temperature from the climate entity's attributes + target_temperature = state.attributes.get("temperature") + + # If the target temperature exists, store it in the _rooms dictionary with the climate entity as the key + if target_temperature is not None: + self._rooms[entity_id] = float(target_temperature) + + async def async_track_sensor_temperature(self, entity_id): + """ + Track the temperature of the sensor specified by the given entity_id. + + Parameters: + entity_id (str): The entity id of the sensor to track. + + If the sensor is already being tracked, the method will return without doing anything. + Otherwise, it will register a callback for state changes on the specified sensor and start tracking its temperature. + """ + if entity_id in self._sensors: + return + + self.async_on_remove( + async_track_state_change_event( + self.hass, [entity_id], self._async_temperature_change + ) + ) - if float(self._coordinator.get(gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD)) == self._max_relative_mod: + self._sensors.append(entity_id) + + async def async_control_heating_loop(self, _time=None) -> None: + """Control the heating based on current temperature, target temperature, and outside temperature.""" + # If the current, target or outside temperature is not available, do nothing + if self.current_temperature is None or self.target_temperature is None or self.current_outside_temperature is None: return - if not self._simulation: - await self._coordinator.api.set_max_relative_mod(self._max_relative_mod) + # Control the heating through the coordinator + await self._coordinator.async_control_heating_loop(self) + + # Pulse Width Modulation + if self.pulse_width_modulation_enabled: + await self.pwm.update(self._calculate_control_setpoint(), self._coordinator.boiler_temperature) + + # Set the control setpoint to make sure we always stay in control + await self._async_control_setpoint(self.pwm.state) + + # Set the relative modulation value, if supported + await self._async_control_relative_modulation() + + # Control the integral (if exceeded the time limit) + self.pid.update_integral(self.max_error, self.heating_curve.value) + + # Calculate the minimum setpoint + self._minimum_setpoint.calculate(self._coordinator.setpoint, [self.error] + self.climate_errors) + + # If the setpoint is high and the HVAC is not off, turn on the heater + if self._setpoint > MINIMUM_SETPOINT and self.hvac_mode != HVACMode.OFF: + await self.async_set_heater_state(DeviceState.ON) + else: + await self.async_set_heater_state(DeviceState.OFF) + + self.async_write_ha_state() + + async def async_set_heater_state(self, state: DeviceState): + if state == DeviceState.ON and not self.valves_open: + _LOGGER.warning('No valves are open at the moment.') + return await self._coordinator.async_set_heater_state(DeviceState.OFF) - _LOGGER.info("Set max relative mod to %d", self._max_relative_mod) + return await self._coordinator.async_set_heater_state(state) async def async_set_temperature(self, **kwargs) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return - # Ignore the request when we are in calculation mode - if self._overshoot_protection_calculate: - return - # Automatically select the preset for preset in self._presets: if float(self._presets[preset]) == float(temperature): return await self.async_set_preset_mode(preset) self._attr_preset_mode = PRESET_NONE - await self._async_set_setpoint(temperature) + await self.async_set_target_temperature(temperature) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the heating/cooling mode for the devices and update the state.""" + # Only allow the hvac mode to be set to heat or off + if hvac_mode == HVACMode.HEAT: + self._hvac_mode = HVACMode.HEAT + elif hvac_mode == HVACMode.OFF: + self._hvac_mode = HVACMode.OFF + else: + # If an unsupported mode is passed, log an error message + _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) + return + + # Reset the PID controller + await self._async_control_pid(True) + + # Set the hvac mode for all climate devices + for entity_id in (self._climates + self._main_climates): + data = {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode} + await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, blocking=True) + + # Update the state and control the heating + self.async_write_ha_state() + await self.async_control_heating_loop() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the thermostat.""" @@ -899,10 +931,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: if preset_mode not in self.preset_modes: raise ValueError(f"Got unsupported preset_mode {preset_mode}. Must be one of {self.preset_modes}") - # Ignore the request when we are in calculation mode - if self._overshoot_protection_calculate: - return - # Return if the given preset mode is already set if preset_mode == self._attr_preset_mode: return @@ -910,7 +938,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Reset the preset mode if `PRESET_NONE` is given if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE - await self._async_set_setpoint(self._saved_target_temperature) + await self.async_set_target_temperature(self._pre_custom_temperature) else: # Set the HVAC mode to `HEAT` if it is currently `OFF` if self.hvac_mode == HVACMode.OFF: @@ -918,11 +946,11 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Save the current target temperature if the preset mode is being set for the first time if self._attr_preset_mode == PRESET_NONE: - self._saved_target_temperature = self._target_temperature + self._pre_custom_temperature = self._target_temperature # Set the preset mode and target temperature self._attr_preset_mode = preset_mode - await self._async_set_setpoint(self._presets[preset_mode]) + await self.async_set_target_temperature(self._presets[preset_mode]) # Set the temperature for each room, when enabled if self._sync_climates_with_preset: @@ -932,13 +960,13 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: continue target_temperature = self._presets[preset_mode] - if preset_mode in [PRESET_HOME, PRESET_COMFORT]: + if preset_mode == PRESET_HOME or preset_mode == PRESET_COMFORT: target_temperature = self._rooms[entity_id] data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: target_temperature} await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - async def _async_set_setpoint(self, temperature: float): + async def async_set_target_temperature(self, temperature: float) -> None: """Set the temperature setpoint for all main climates.""" if self._target_temperature == temperature: return @@ -949,51 +977,22 @@ async def _async_set_setpoint(self, temperature: float): # Reset the PID controller await self._async_control_pid(True) - # Set the temperature for each main climate + # Set the target temperature for each main climate for entity_id in self._main_climates: data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: temperature} await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + if self._sync_with_thermostat: + # Set the target temperature for the connected boiler + await self._coordinator.async_set_control_thermostat_setpoint(temperature) + # Write the state to Home Assistant self.async_write_ha_state() # Control the heating based on the new temperature setpoint - await self._async_control_heating() - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the heating/cooling mode for the devices and update the state.""" - # Ignore the request when we are in calculation mode - if self._overshoot_protection_calculate: - return - - # Only allow the hvac mode to be set to heat or off - if hvac_mode == HVACMode.HEAT: - self._hvac_mode = HVACMode.HEAT - elif hvac_mode == HVACMode.OFF: - self._hvac_mode = HVACMode.OFF - else: - # If an unsupported mode is passed, log an error message - _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) - return - - # Reset the PID controller - await self._async_control_pid(True) - - # Set the hvac mode for all climate devices - for entity_id in (self._climates + self._main_climates): - data = {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, blocking=True) - - # Update the state and control the heating - self.async_write_ha_state() - await self._async_control_heating() - - async def _update_room_with_target_temperature(self): - self._rooms = {} - for climate in self._climates: - state = self.hass.states.get(climate) - if state is None or state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: - continue + await self.async_control_heating_loop() - if target_temperature := state.attributes.get("temperature"): - self._rooms[climate] = float(target_temperature) + async def async_send_notification(self, title: str, message: str, service: str = SERVICE_PERSISTENT_NOTIFICATION): + """Send a notification to the user.""" + data = {"title": title, "message": message} + await self.hass.services.async_call(NOTIFY_DOMAIN, service, data) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index ffa1ff1d..dfc9f1b3 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -1,101 +1,400 @@ """Adds config flow for SAT.""" +import asyncio import logging import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import dhcp +from homeassistant.components import mqtt +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, SOURCE_USER +from homeassistant.const import MAJOR_VERSION, MINOR_VERSION from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import selector +from homeassistant.helpers import selector, device_registry, entity_registry +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from pyotgw import OpenThermGateway +from . import SatDataUpdateCoordinatorFactory from .const import * +from .coordinator import SatDataUpdateCoordinator +from .overshoot_protection import OvershootProtection +from .util import calculate_default_maximum_setpoint, snake_case + +DEFAULT_NAME = "Living Room" _LOGGER = logging.getLogger(__name__) class SatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for SAT.""" - VERSION = 1 + VERSION = 5 + calibration = None + overshoot_protection_value = None def __init__(self): """Initialize.""" self._data = {} self._errors = {} - async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry): + return SatOptionsFlowHandler(config_entry) + + @callback + def async_remove(self) -> None: + if self.calibration is not None: + self.calibration.cancel() + + async def async_step_user(self, _user_input=None) -> FlowResult: + """Handle user flow.""" + menu_options = [] + + # Since we rely on the availability logic in 2023.5, we do not support below it. + if MAJOR_VERSION >= 2023 and MINOR_VERSION >= 5: + menu_options.append("mosquitto") + + menu_options.append("serial") + menu_options.append("switch") + + if self.show_advanced_options: + menu_options.append("simulator") + + return self.async_show_menu(step_id="user", menu_options=menu_options) + + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - _LOGGER.debug("Discovered OTGW at [%s]", discovery_info.ip) + _LOGGER.debug("Discovered OTGW at [socket://%s]", discovery_info.hostname) + self._data[CONF_DEVICE] = f"socket://{discovery_info.hostname}:25238" # abort if we already have exactly this gateway id/host # reload the integration if the host got updated - await self.async_set_unique_id(discovery_info.ip) - self._abort_if_unique_id_configured(updates={CONF_DEVICE: discovery_info.ip}, reload_on_update=True) + await self.async_set_unique_id(discovery_info.hostname) + self._abort_if_unique_id_configured(updates=self._data, reload_on_update=True) - return await self.async_step_user() + return await self.async_step_serial() - async def async_step_user(self, _user_input=None) -> FlowResult: + async def async_step_mqtt(self, discovery_info: MqttServiceInfo): + """Handle dhcp discovery.""" + device = device_registry.async_get(self.hass).async_get_device( + {(MQTT_DOMAIN, discovery_info.topic[11:])} + ) + + _LOGGER.debug("Discovered OTGW at [mqtt://%s]", discovery_info.topic) + self._data[CONF_DEVICE] = device.id + + # abort if we already have exactly this gateway id/host + # reload the integration if the host got updated + await self.async_set_unique_id(device.id) + self._abort_if_unique_id_configured(updates=self._data, reload_on_update=True) + + return await self.async_step_mosquitto() + + async def async_step_mosquitto(self, _user_input=None): self._errors = {} if _user_input is not None: self._data.update(_user_input) + self._data[CONF_MODE] = MODE_MQTT + + if not await mqtt.async_wait_for_mqtt_client(self.hass): + self._errors["base"] = "mqtt_component" + return await self.async_step_mosquitto() - if not await self._test_gateway_connection(): - self._errors["base"] = "auth" - return await self.async_step_gateway_setup() + return await self.async_step_sensors() - await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) - self._abort_if_unique_id_configured() + return self.async_show_form( + step_id="mosquitto", + last_step=False, + errors=self._errors, + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_MQTT_TOPIC, default=OPTIONS_DEFAULTS[CONF_MQTT_TOPIC]): str, + vol.Required(CONF_DEVICE, default=self._data.get(CONF_DEVICE)): selector.DeviceSelector( + selector.DeviceSelectorConfig(model="otgw-nodo") + ), + }), + ) - return await self.async_step_sensors_setup() + async def async_step_serial(self, _user_input=None): + self._errors = {} - return await self.async_step_gateway_setup() + if _user_input is not None: + self._data.update(_user_input) + self._data[CONF_MODE] = MODE_SERIAL + + gateway = OpenThermGateway() + if not await gateway.connect(port=self._data[CONF_DEVICE], skip_init=True, timeout=5): + await gateway.disconnect() + self._errors["base"] = "connection" + return await self.async_step_serial() + + return await self.async_step_sensors() - async def async_step_gateway_setup(self): return self.async_show_form( - step_id="user", + step_id="serial", last_step=False, errors=self._errors, data_schema=vol.Schema({ - vol.Required(CONF_NAME, default="Living Room"): str, - vol.Required(CONF_DEVICE, default="socket://otgw.local:25238"): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_DEVICE, default=self._data.get(CONF_DEVICE, "socket://otgw.local:25238")): str, + }), + ) + + async def async_step_switch(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + self._data[CONF_MODE] = MODE_SWITCH + + return await self.async_step_sensors() + + return self.async_show_form( + step_id="switch", + last_step=False, + errors=self._errors, + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_DEVICE): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]) + ), + vol.Required(CONF_MINIMUM_SETPOINT, default=50): selector.NumberSelector( + selector.NumberSelectorConfig(min=10, max=100, step=1) + ) + }), + ) + + async def async_step_simulator(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + self._data[CONF_MODE] = MODE_SIMULATOR + self._data[CONF_DEVICE] = f"%s_%s".format(MODE_SIMULATOR, snake_case(_user_input.get(CONF_NAME))) + + return await self.async_step_sensors() + + return self.async_show_form( + step_id="simulator", + last_step=False, + errors=self._errors, + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_SIMULATED_HEATING, default=OPTIONS_DEFAULTS[CONF_SIMULATED_HEATING]): selector.NumberSelector( + selector.NumberSelectorConfig(min=1, max=100, step=1) + ), + vol.Required(CONF_SIMULATED_COOLING, default=OPTIONS_DEFAULTS[CONF_SIMULATED_COOLING]): selector.NumberSelector( + selector.NumberSelectorConfig(min=1, max=100, step=1) + ), + vol.Required(CONF_MINIMUM_SETPOINT, default=OPTIONS_DEFAULTS[CONF_MINIMUM_SETPOINT]): selector.NumberSelector( + selector.NumberSelectorConfig(min=10, max=100, step=1) + ), + vol.Required(CONF_SIMULATED_WARMING_UP, default=OPTIONS_DEFAULTS[CONF_SIMULATED_WARMING_UP]): selector.TimeSelector() }), ) async def async_step_sensors(self, _user_input=None): - self._errors = {} + await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) + self._abort_if_unique_id_configured() if _user_input is not None: self._data.update(_user_input) - return self.async_create_entry(title=self._data[CONF_NAME], data=self._data) - return await self.async_step_sensors_setup() + if self._data[CONF_MODE] in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: + return await self.async_step_heating_system() + + return await self.async_step_areas() - async def async_step_sensors_setup(self): return self.async_show_form( + last_step=False, step_id="sensors", data_schema=vol.Schema({ vol.Required(CONF_INSIDE_SENSOR_ENTITY_ID): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN]) + selector.EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=[SensorDeviceClass.TEMPERATURE] + ) ), vol.Required(CONF_OUTSIDE_SENSOR_ENTITY_ID): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, WEATHER_DOMAIN], multiple=True) + selector.EntitySelectorConfig( + multiple=True, + domain=[SENSOR_DOMAIN, WEATHER_DOMAIN] + ) ), + vol.Optional(CONF_HUMIDITY_SENSOR_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=[SensorDeviceClass.HUMIDITY] + ) + ) }), ) - async def _test_gateway_connection(self): - """Return true if credentials is valid.""" - return await OpenThermGateway().connect(port=self._data[CONF_DEVICE], skip_init=True, timeout=5) + async def async_step_heating_system(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry): - return SatOptionsFlowHandler(config_entry) + return await self.async_step_areas() + + return self.async_show_form( + last_step=False, + step_id="heating_system", + data_schema=vol.Schema({ + vol.Required(CONF_HEATING_SYSTEM, default=OPTIONS_DEFAULTS[CONF_HEATING_SYSTEM]): selector.SelectSelector( + selector.SelectSelectorConfig(options=[ + {"value": HEATING_SYSTEM_RADIATORS, "label": "Radiators"}, + {"value": HEATING_SYSTEM_HEAT_PUMP, "label": "Heat Pump"}, + {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"}, + ]) + ) + }) + ) + + async def async_step_areas(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + + if (await self._create_coordinator()).supports_setpoint_management: + return await self.async_step_calibrate_system() + + return await self.async_step_automatic_gains() + + climate_selector = selector.EntitySelector(selector.EntitySelectorConfig( + domain=CLIMATE_DOMAIN, multiple=True + )) + + return self.async_show_form( + step_id="areas", + data_schema=vol.Schema({ + vol.Optional(CONF_MAIN_CLIMATES): climate_selector, + vol.Optional(CONF_SECONDARY_CLIMATES): climate_selector, + }) + ) + + async def async_step_automatic_gains(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + + if not self._data[CONF_AUTOMATIC_GAINS]: + return await self.async_step_pid_controller() + + return await self.async_step_finish() + + return self.async_show_form( + last_step=False, + step_id="automatic_gains", + data_schema=vol.Schema({vol.Required(CONF_AUTOMATIC_GAINS, default=True): bool}) + ) + + async def async_step_calibrate_system(self, _user_input=None): + return self.async_show_menu( + step_id="calibrate_system", + menu_options=["calibrate", "overshoot_protection", "pid_controller"] + ) + + async def async_step_calibrate(self, _user_input=None): + coordinator = await self._create_coordinator() + + async def start_calibration(): + try: + overshoot_protection = OvershootProtection(coordinator) + self.overshoot_protection_value = await overshoot_protection.calculate() + except asyncio.TimeoutError: + _LOGGER.warning("Calibration time-out.") + return False + except asyncio.CancelledError: + _LOGGER.warning("Cancelled calibration.") + return False + + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + return True + + if not self.calibration: + self.calibration = self.hass.async_create_task( + start_calibration() + ) + + return self.async_show_progress( + step_id="calibrate", + progress_action="calibration", + ) + + if self.overshoot_protection_value is None: + return self.async_abort(reason="unable_to_calibrate") + + await self._enable_overshoot_protection( + self.overshoot_protection_value + ) + + self.calibration = None + self.overshoot_protection_value = None + + return self.async_show_progress_done(next_step_id="calibrated") + + async def async_step_calibrated(self, _user_input=None): + return self.async_show_menu( + step_id="calibrated", + description_placeholders=self._data, + menu_options=["calibrate", "finish"], + ) + + async def async_step_overshoot_protection(self, _user_input=None): + if _user_input is not None: + await self._enable_overshoot_protection( + _user_input[CONF_MINIMUM_SETPOINT] + ) + + return await self.async_step_finish() + + return self.async_show_form( + step_id="overshoot_protection", + data_schema=vol.Schema({ + vol.Required(CONF_MINIMUM_SETPOINT, default=OPTIONS_DEFAULTS[CONF_MINIMUM_SETPOINT]): selector.NumberSelector( + selector.NumberSelectorConfig(min=MINIMUM_SETPOINT, max=OVERSHOOT_PROTECTION_SETPOINT, step=1, unit_of_measurement="°C") + ), + }) + ) + + async def async_step_pid_controller(self, _user_input=None): + self._data[CONF_AUTOMATIC_GAINS] = False + + if _user_input is not None: + self._data.update(_user_input) + return await self.async_step_finish() + + return self.async_show_form( + step_id="pid_controller", + data_schema=vol.Schema({ + vol.Required(CONF_PROPORTIONAL, default=OPTIONS_DEFAULTS[CONF_PROPORTIONAL]): str, + vol.Required(CONF_INTEGRAL, default=OPTIONS_DEFAULTS[CONF_INTEGRAL]): str, + vol.Required(CONF_DERIVATIVE, default=OPTIONS_DEFAULTS[CONF_DERIVATIVE]): str + }) + ) + + async def async_step_finish(self, _user_input=None): + return self.async_create_entry(title=self._data[CONF_NAME], data=self._data) + + async def _create_coordinator(self) -> SatDataUpdateCoordinator: + # Create a new config to use + config = ConfigEntry( + version=self.VERSION, domain=DOMAIN, title=self._data[CONF_NAME], data=self._data, source=SOURCE_USER + ) + + # Resolve the coordinator by using the factory according to the mode + return await SatDataUpdateCoordinatorFactory().resolve( + hass=self.hass, config_entry=config, mode=self._data[CONF_MODE], device=self._data[CONF_DEVICE] + ) + + async def _enable_overshoot_protection(self, overshoot_protection_value: float): + self._data[CONF_OVERSHOOT_PROTECTION] = True + self._data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value class SatOptionsFlowHandler(config_entries.OptionsFlow): @@ -106,16 +405,13 @@ def __init__(self, config_entry: ConfigEntry): self._options = dict(config_entry.options) async def async_step_init(self, _user_input=None): - return await self.async_step_user(_user_input) - - async def async_step_user(self, _user_input=None) -> FlowResult: - menu_options = ["general", "presets", "climates"] + menu_options = ["general", "presets", "system_configuration"] if self.show_advanced_options: menu_options.append("advanced") return self.async_show_menu( - step_id="user", + step_id="init", menu_options=menu_options ) @@ -123,32 +419,44 @@ async def async_step_general(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) - defaults = await self.get_options() + schema = {} + options = await self.get_options() - schema = { - vol.Required(CONF_HEATING_CURVE_COEFFICIENT, default=defaults[CONF_HEATING_CURVE_COEFFICIENT]): selector.NumberSelector( - selector.NumberSelectorConfig(min=0.1, max=12, step=0.1) - ), - vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=defaults[CONF_TARGET_TEMPERATURE_STEP]): selector.NumberSelector( - selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) - ), - vol.Required(CONF_HEATING_SYSTEM, default=defaults[CONF_HEATING_SYSTEM]): selector.SelectSelector( - selector.SelectSelectorConfig(options=[ - {"value": HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES, "label": "Radiators ( High Temperatures )"}, - {"value": HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES, "label": "Radiators ( Medium Temperatures )"}, - {"value": HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES, "label": "Radiators ( Low Temperatures )"}, - {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"} - ]) - ) - } + default_maximum_setpoint = calculate_default_maximum_setpoint(self._config_entry.data.get(CONF_HEATING_SYSTEM)) + maximum_setpoint = float(options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) - if not defaults.get(CONF_AUTOMATIC_GAINS): - schema[vol.Required(CONF_PROPORTIONAL, default=defaults.get(CONF_PROPORTIONAL))] = str - schema[vol.Required(CONF_INTEGRAL, default=defaults.get(CONF_INTEGRAL))] = str - schema[vol.Required(CONF_DERIVATIVE, default=defaults.get(CONF_DERIVATIVE))] = str + schema[vol.Required(CONF_MAXIMUM_SETPOINT, default=maximum_setpoint)] = selector.NumberSelector( + selector.NumberSelectorConfig(min=10, max=100, step=1, unit_of_measurement="°C") + ) - if not defaults.get(CONF_AUTOMATIC_DUTY_CYCLE): - schema[vol.Required(CONF_DUTY_CYCLE, default=defaults.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() + if not options[CONF_AUTOMATIC_GAINS]: + schema[vol.Required(CONF_PROPORTIONAL, default=options[CONF_PROPORTIONAL])] = str + schema[vol.Required(CONF_INTEGRAL, default=options[CONF_INTEGRAL])] = str + schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str + + schema[vol.Required(CONF_HEATING_CURVE_COEFFICIENT, default=options[CONF_HEATING_CURVE_COEFFICIENT])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0.1, max=12, step=0.1) + ) + + schema[vol.Required(CONF_AUTOMATIC_GAINS_VALUE, default=options[CONF_AUTOMATIC_GAINS_VALUE])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=1, max=5, step=1) + ) + + if not options[CONF_AUTOMATIC_DUTY_CYCLE]: + schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector() + + entities = entity_registry.async_get(self.hass) + device_name = self._config_entry.data.get(CONF_NAME) + window_id = entities.async_get_entity_id(BINARY_SENSOR_DOMAIN, DOMAIN, f"{device_name.lower()}-window-sensor") + + schema[vol.Optional(CONF_WINDOW_SENSORS, default=options[CONF_WINDOW_SENSORS])] = selector.EntitySelector( + selector.EntitySelectorConfig( + multiple=True, + domain=BINARY_SENSOR_DOMAIN, + exclude_entities=[window_id] if window_id else [], + device_class=[BinarySensorDeviceClass.DOOR, BinarySensorDeviceClass.WINDOW, BinarySensorDeviceClass.GARAGE_DOOR] + ) + ) return self.async_show_form(step_id="general", data_schema=vol.Schema(schema)) @@ -156,46 +464,41 @@ async def async_step_presets(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) - defaults = await self.get_options() + options = await self.get_options() return self.async_show_form( step_id="presets", data_schema=vol.Schema({ - vol.Required(CONF_AWAY_TEMPERATURE, default=defaults[CONF_AWAY_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + vol.Required(CONF_ACTIVITY_TEMPERATURE, default=options[CONF_ACTIVITY_TEMPERATURE]): selector.NumberSelector( + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") + ), + vol.Required(CONF_AWAY_TEMPERATURE, default=options[CONF_AWAY_TEMPERATURE]): selector.NumberSelector( + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_SLEEP_TEMPERATURE, default=defaults[CONF_SLEEP_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + vol.Required(CONF_SLEEP_TEMPERATURE, default=options[CONF_SLEEP_TEMPERATURE]): selector.NumberSelector( + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_HOME_TEMPERATURE, default=defaults[CONF_HOME_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + vol.Required(CONF_HOME_TEMPERATURE, default=options[CONF_HOME_TEMPERATURE]): selector.NumberSelector( + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_COMFORT_TEMPERATURE, default=defaults[CONF_COMFORT_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + vol.Required(CONF_COMFORT_TEMPERATURE, default=options[CONF_COMFORT_TEMPERATURE]): selector.NumberSelector( + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_SYNC_CLIMATES_WITH_PRESET, default=defaults[CONF_SYNC_CLIMATES_WITH_PRESET]): bool, + vol.Required(CONF_SYNC_CLIMATES_WITH_PRESET, default=options[CONF_SYNC_CLIMATES_WITH_PRESET]): bool, }) ) - async def async_step_climates(self, _user_input=None) -> FlowResult: + async def async_step_system_configuration(self, _user_input=None) -> FlowResult: if _user_input is not None: - if _user_input.get(CONF_MAIN_CLIMATES) is None: - self._options[CONF_MAIN_CLIMATES] = [] - - if _user_input.get(CONF_CLIMATES) is None: - self._options[CONF_CLIMATES] = [] - return await self.update_options(_user_input) - defaults = await self.get_options() + options = await self.get_options() + return self.async_show_form( - step_id="climates", + step_id="system_configuration", data_schema=vol.Schema({ - vol.Optional(CONF_MAIN_CLIMATES, default=defaults[CONF_MAIN_CLIMATES]): selector.EntitySelector( - selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True) - ), - vol.Optional(CONF_CLIMATES, default=defaults[CONF_CLIMATES]): selector.EntitySelector( - selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True) - ), + vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options[CONF_AUTOMATIC_DUTY_CYCLE]): bool, + vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options[CONF_SENSOR_MAX_VALUE_AGE]): selector.TimeSelector(), + vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options[CONF_WINDOW_MINIMUM_OPEN_TIME]): selector.TimeSelector(), }) ) @@ -203,21 +506,42 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) - defaults = await self.get_options() + options = await self.get_options() + + schema = { + vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION]): bool, + vol.Required(CONF_THERMAL_COMFORT, default=options[CONF_THERMAL_COMFORT]): bool, + vol.Required(CONF_DYNAMIC_MINIMUM_SETPOINT, default=options[CONF_DYNAMIC_MINIMUM_SETPOINT]): bool + } + + if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: + schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool + + schema[vol.Required(CONF_MINIMUM_CONSUMPTION, default=options[CONF_MINIMUM_CONSUMPTION])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0, max=8, step=0.1) + ) + + schema[vol.Required(CONF_MAXIMUM_CONSUMPTION, default=options[CONF_MAXIMUM_CONSUMPTION])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0, max=8, step=0.1) + ) + + schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=options[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=-1, max=1, step=0.1) + ) + + schema[vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=options[CONF_TARGET_TEMPERATURE_STEP])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) + ) + + schema[vol.Required(CONF_MAXIMUM_RELATIVE_MODULATION, default=options[CONF_MAXIMUM_RELATIVE_MODULATION])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0, max=100, step=1) + ) + + schema[vol.Required(CONF_SAMPLE_TIME, default=options[CONF_SAMPLE_TIME])] = selector.TimeSelector() + return self.async_show_form( step_id="advanced", - data_schema=vol.Schema({ - vol.Required(CONF_SIMULATION, default=defaults[CONF_SIMULATION]): bool, - vol.Required(CONF_AUTOMATIC_GAINS, default=defaults.get(CONF_AUTOMATIC_GAINS)): bool, - vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=defaults.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, - vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=defaults[CONF_FORCE_PULSE_WIDTH_MODULATION]): bool, - vol.Required(CONF_OVERSHOOT_PROTECTION, default=defaults[CONF_OVERSHOOT_PROTECTION]): bool, - vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=defaults[CONF_CLIMATE_VALVE_OFFSET]): selector.NumberSelector( - selector.NumberSelectorConfig(min=-1, max=1, step=0.1) - ), - vol.Required(CONF_SAMPLE_TIME, default=defaults.get(CONF_SAMPLE_TIME)): selector.TimeSelector(), - vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=defaults.get(CONF_SENSOR_MAX_VALUE_AGE)): selector.TimeSelector(), - }) + data_schema=vol.Schema(schema) ) async def update_options(self, _user_input) -> FlowResult: @@ -225,7 +549,7 @@ async def update_options(self, _user_input) -> FlowResult: return self.async_create_entry(title=self._config_entry.data[CONF_NAME], data=self._options) async def get_options(self): - defaults = OPTIONS_DEFAULTS.copy() - defaults.update(self._options) + options = OPTIONS_DEFAULTS.copy() + options.update(self._options) - return defaults + return options diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 28aee71c..46fc2d07 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -1,52 +1,45 @@ -import pyotgw.vars as gw_vars -from homeassistant.backports.enum import StrEnum -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import ( - UnitOfTemperature, - UnitOfPressure, - UnitOfVolume, - UnitOfPower, - TIME_MINUTES, - PERCENTAGE -) - # Base component constants NAME = "Smart Autotune Thermostat" DOMAIN = "sat" -VERSION = "0.0.1" +VERSION = "3.0.x" +CLIMATE = "climate" COORDINATOR = "coordinator" +CONFIG_STORE = "config_store" -UNIT_KW = "kW" -UNIT_L_MIN = f"L/{TIME_MINUTES}" +MODE_FAKE = "fake" +MODE_MQTT = "mqtt" +MODE_SWITCH = "switch" +MODE_SERIAL = "serial" +MODE_SIMULATOR = "simulator" + +DEADBAND = 0.1 +HEATER_STARTUP_TIMEFRAME = 180 -HOT_TOLERANCE = 0.3 -COLD_TOLERANCE = 0.1 MINIMUM_SETPOINT = 10 MINIMUM_RELATIVE_MOD = 0 MAXIMUM_RELATIVE_MOD = 100 +MAX_BOILER_TEMPERATURE_AGE = 60 OVERSHOOT_PROTECTION_SETPOINT = 75 -OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0 OVERSHOOT_PROTECTION_REQUIRED_DATASET = 40 -# Icons -ICON = "mdi:format-quote-close" - -# Device classes -BINARY_SENSOR_DEVICE_CLASS = "connectivity" - -# Platforms -SENSOR = "sensor" -NUMBER = "number" -CLIMATE = "climate" -BINARY_SENSOR = "binary_sensor" - # Configuration and options +CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" -CONF_CLIMATES = "climates" +CONF_SIMULATED_HEATING = "simulated_heating" +CONF_SIMULATED_COOLING = "simulated_cooling" +CONF_SIMULATED_WARMING_UP = "simulated_warming_up" +CONF_MINIMUM_SETPOINT = "minimum_setpoint" +CONF_MAXIMUM_SETPOINT = "maximum_setpoint" +CONF_MAXIMUM_RELATIVE_MODULATION = "maximum_relative_modulation" +CONF_SECONDARY_CLIMATES = "secondary_climates" +CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" +CONF_WINDOW_SENSORS = "window_sensors" +CONF_SYNC_WITH_THERMOSTAT = "sync_with_thermostat" +CONF_WINDOW_MINIMUM_OPEN_TIME = "window_minimum_open_time" +CONF_THERMAL_COMFORT = "thermal_comfort" CONF_SIMULATION = "simulation" CONF_INTEGRAL = "integral" CONF_DERIVATIVE = "derivative" @@ -55,6 +48,7 @@ CONF_SAMPLE_TIME = "sample_time" CONF_AUTOMATIC_GAINS = "automatic_gains" CONF_AUTOMATIC_DUTY_CYCLE = "automatic_duty_cycle" +CONF_AUTOMATIC_GAINS_VALUE = "automatic_gains_value" CONF_CLIMATE_VALVE_OFFSET = "climate_valve_offset" CONF_SENSOR_MAX_VALUE_AGE = "sensor_max_value_age" CONF_OVERSHOOT_PROTECTION = "overshoot_protection" @@ -63,581 +57,84 @@ CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" CONF_INSIDE_SENSOR_ENTITY_ID = "inside_sensor_entity_id" CONF_OUTSIDE_SENSOR_ENTITY_ID = "outside_sensor_entity_id" +CONF_HUMIDITY_SENSOR_ENTITY_ID = "humidity_sensor_entity_id" +CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" CONF_HEATING_SYSTEM = "heating_system" CONF_HEATING_CURVE_COEFFICIENT = "heating_curve_coefficient" +CONF_MINIMUM_CONSUMPTION = "minimum_consumption" +CONF_MAXIMUM_CONSUMPTION = "maximum_consumption" + CONF_AWAY_TEMPERATURE = "away_temperature" CONF_HOME_TEMPERATURE = "home_temperature" CONF_SLEEP_TEMPERATURE = "sleep_temperature" CONF_COMFORT_TEMPERATURE = "comfort_temperature" +CONF_ACTIVITY_TEMPERATURE = "activity_temperature" +HEATING_SYSTEM_UNKNOWN = "unknown" +HEATING_SYSTEM_HEAT_PUMP = "heat_pump" +HEATING_SYSTEM_RADIATORS = "radiators" HEATING_SYSTEM_UNDERFLOOR = "underfloor" -HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES = "radiator_low_temperatures" -HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES = "radiator_medium_temperatures" -HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES = "radiator_high_temperatures" OPTIONS_DEFAULTS = { + CONF_MODE: MODE_SERIAL, CONF_PROPORTIONAL: "45", CONF_INTEGRAL: "0", CONF_DERIVATIVE: "6000", - CONF_CLIMATES: [], + CONF_AUTOMATIC_GAINS: True, + CONF_AUTOMATIC_DUTY_CYCLE: True, + CONF_AUTOMATIC_GAINS_VALUE: 5.0, + CONF_OVERSHOOT_PROTECTION: False, + CONF_DYNAMIC_MINIMUM_SETPOINT: False, + + CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, - CONF_AUTOMATIC_GAINS: False, - CONF_AUTOMATIC_DUTY_CYCLE: False, + CONF_WINDOW_SENSORS: [], + CONF_THERMAL_COMFORT: False, + CONF_HUMIDITY_SENSOR_ENTITY_ID: None, + CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, - CONF_OVERSHOOT_PROTECTION: False, + CONF_SIMULATED_HEATING: 20, + CONF_SIMULATED_COOLING: 5, + + CONF_MINIMUM_SETPOINT: 10, + CONF_MAXIMUM_RELATIVE_MODULATION: 100, CONF_FORCE_PULSE_WIDTH_MODULATION: False, + CONF_MINIMUM_CONSUMPTION: 0, + CONF_MAXIMUM_CONSUMPTION: 0, + + CONF_MQTT_TOPIC: "OTGW", CONF_DUTY_CYCLE: "00:13:00", CONF_SAMPLE_TIME: "00:01:00", CONF_CLIMATE_VALVE_OFFSET: 0, CONF_TARGET_TEMPERATURE_STEP: 0.5, CONF_SENSOR_MAX_VALUE_AGE: "06:00:00", + CONF_SIMULATED_WARMING_UP: "00:00:15", + CONF_WINDOW_MINIMUM_OPEN_TIME: "00:00:15", + CONF_ACTIVITY_TEMPERATURE: 10, CONF_AWAY_TEMPERATURE: 10, CONF_HOME_TEMPERATURE: 18, CONF_SLEEP_TEMPERATURE: 15, CONF_COMFORT_TEMPERATURE: 20, CONF_HEATING_CURVE_COEFFICIENT: 1.0, - CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES, + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, } # Storage STORAGE_OVERSHOOT_PROTECTION_VALUE = "overshoot_protection_value" +# Services +SERVICE_RESET_INTEGRAL = "reset_integral" +SERVICE_SET_OVERSHOOT_PROTECTION_VALUE = "set_overshoot_protection_value" +SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION = "start_overshoot_protection_calculation" + # Config steps STEP_SETUP_GATEWAY = "gateway" STEP_SETUP_SENSORS = "sensors" - -# Defaults -DEFAULT_NAME = DOMAIN - -# Sensors -TRANSLATE_SOURCE = { - gw_vars.OTGW: None, - gw_vars.BOILER: "Boiler", - gw_vars.THERMOSTAT: "Thermostat", -} - - -# Time units -class UnitOfTime(StrEnum): - """Time units.""" - - MICROSECONDS = "μs" - MILLISECONDS = "ms" - SECONDS = "s" - MINUTES = "min" - HOURS = "h" - DAYS = "d" - WEEKS = "w" - MONTHS = "m" - YEARS = "y" - - -BINARY_SENSOR_INFO: dict[str, list] = { - # [device_class, friendly_name format, [status source, ...]] - gw_vars.DATA_MASTER_CH_ENABLED: [ - None, - "Thermostat Central Heating {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_DHW_ENABLED: [ - None, - "Thermostat Hot Water {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_COOLING_ENABLED: [ - None, - "Thermostat Cooling {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_OTC_ENABLED: [ - None, - "Thermostat Outside Temperature Correction {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_CH2_ENABLED: [ - None, - "Thermostat Central Heating 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_FAULT_IND: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Fault {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH_ACTIVE: [ - BinarySensorDeviceClass.HEAT, - "Boiler Central Heating {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_ACTIVE: [ - BinarySensorDeviceClass.HEAT, - "Boiler Hot Water {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_FLAME_ON: [ - BinarySensorDeviceClass.HEAT, - "Boiler Flame {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_COOLING_ACTIVE: [ - BinarySensorDeviceClass.COLD, - "Boiler Cooling {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH2_ACTIVE: [ - BinarySensorDeviceClass.HEAT, - "Boiler Central Heating 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DIAG_IND: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Diagnostics {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_PRESENT: [ - None, - "Boiler Hot Water Present {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CONTROL_TYPE: [ - None, - "Boiler Control Type {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_COOLING_SUPPORTED: [ - None, - "Boiler Cooling Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_CONFIG: [ - None, - "Boiler Hot Water Configuration {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: [ - None, - "Boiler Pump Commands Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH2_PRESENT: [ - None, - "Boiler Central Heating 2 Present {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_SERVICE_REQ: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Service Required {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_REMOTE_RESET: [ - None, - "Boiler Remote Reset Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_LOW_WATER_PRESS: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Low Water Pressure {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_GAS_FAULT: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Gas Fault {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_AIR_PRESS_FAULT: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Air Pressure Fault {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_WATER_OVERTEMP: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Water Over-temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_TRANSFER_DHW: [ - None, - "Remote Hot Water Setpoint Transfer Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_TRANSFER_MAX_CH: [ - None, - "Remote Maximum Central Heating Setpoint Write Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_RW_DHW: [ - None, - "Remote Hot Water Setpoint Write Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_RW_MAX_CH: [ - None, - "Remote Central Heating Setpoint Write Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROVRD_MAN_PRIO: [ - None, - "Remote Override Manual Change Priority {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROVRD_AUTO_PRIO: [ - None, - "Remote Override Program Change Priority {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.OTGW_GPIO_A_STATE: [ - None, - "Gateway GPIO A {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_GPIO_B_STATE: [ - None, - "Gateway GPIO B {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_IGNORE_TRANSITIONS: [ - None, - "Gateway Ignore Transitions {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_OVRD_HB: [ - None, - "Gateway Override High Byte {}", - [gw_vars.OTGW] - ], -} - -SENSOR_INFO: dict[str, list] = { - # [device_class, unit, friendly_name, [status source, ...]] - gw_vars.DATA_CONTROL_SETPOINT: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Control Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_MEMBERID: [ - None, - None, - "Thermostat Member ID {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MEMBERID: [ - None, - None, - "Boiler Member ID {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_OEM_FAULT: [ - None, - None, - "Boiler OEM Fault Code {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_COOLING_CONTROL: [ - None, - PERCENTAGE, - "Cooling Control Signal {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CONTROL_SETPOINT_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Control Setpoint 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_SETPOINT_OVRD: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Setpoint Override {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [ - None, - PERCENTAGE, - "Boiler Maximum Relative Modulation {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MAX_CAPACITY: [ - SensorDeviceClass.POWER, - UnitOfPower.KILO_WATT, - "Boiler Maximum Capacity {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [ - None, - PERCENTAGE, - "Boiler Minimum Modulation Level {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_SETPOINT: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REL_MOD_LEVEL: [ - None, - PERCENTAGE, - "Relative Modulation Level {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_WATER_PRESS: [ - SensorDeviceClass.PRESSURE, - UnitOfPressure.BAR, - "Central Heating Water Pressure {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_FLOW_RATE: [ - None, - f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", - "Hot Water Flow Rate {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_SETPOINT_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Setpoint 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_WATER_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Central Heating Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_OUTSIDE_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Outside Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_RETURN_WATER_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Return Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SOLAR_STORAGE_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Solar Storage Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SOLAR_COLL_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Solar Collector Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_WATER_TEMP_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Central Heating 2 Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_TEMP_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water 2 Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_EXHAUST_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Exhaust Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_MAX_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water Maximum Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_MIN_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water Minimum Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH_MAX_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Boiler Maximum Central Heating Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH_MIN_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Boiler Minimum Central Heating Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MAX_CH_SETPOINT: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Maximum Central Heating Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_OEM_DIAG: [ - None, - None, - "OEM Diagnostic Code {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_TOTAL_BURNER_STARTS: [ - None, - None, - "Total Burner Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_PUMP_STARTS: [ - None, - None, - "Central Heating Pump Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_PUMP_STARTS: [ - None, - None, - "Hot Water Pump Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_BURNER_STARTS: [ - None, - None, - "Hot Water Burner Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_TOTAL_BURNER_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Total Burner Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_PUMP_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Central Heating Pump Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_PUMP_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Hot Water Pump Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_BURNER_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Hot Water Burner Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_OT_VERSION: [ - None, - None, - "Thermostat OpenTherm Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_OT_VERSION: [ - None, - None, - "Boiler OpenTherm Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_PRODUCT_TYPE: [ - None, - None, - "Thermostat Product Type {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_PRODUCT_VERSION: [ - None, - None, - "Thermostat Product Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_PRODUCT_TYPE: [ - None, - None, - "Boiler Product Type {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_PRODUCT_VERSION: [ - None, - None, - "Boiler Product Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.OTGW_MODE: [ - None, - None, - "Gateway/Monitor Mode {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_DHW_OVRD: [ - None, - None, - "Gateway Hot Water Override Mode {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_ABOUT: [ - None, - None, - "Gateway Firmware Version {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_BUILD: [ - None, - None, - "Gateway Firmware Build {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_SB_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Gateway Setback Temperature {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_SETP_OVRD_MODE: [ - None, - None, - "Gateway Room Setpoint Override Mode {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_SMART_PWR: [ - None, - None, - "Gateway Smart Power Mode {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_THRM_DETECT: [ - None, - None, - "Gateway Thermostat Detection {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_VREF: [ - None, - None, - "Gateway Reference Voltage Setting {}", - [gw_vars.OTGW], - ], -} diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py new file mode 100644 index 00000000..be9c56bd --- /dev/null +++ b/custom_components/sat/coordinator.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import logging +import typing +from abc import abstractmethod +from datetime import datetime, timedelta +from enum import Enum + +from homeassistant.components.climate import HVACMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import * +from .util import calculate_default_maximum_setpoint + +if typing.TYPE_CHECKING: + from .climate import SatClimate + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class DeviceState(str, Enum): + ON = "on" + OFF = "off" + + +class SatDataUpdateCoordinatorFactory: + @staticmethod + async def resolve(hass: HomeAssistant, config_entry: ConfigEntry, mode: str, device: str) -> SatDataUpdateCoordinator: + if mode == MODE_FAKE: + from .fake import SatFakeCoordinator + return SatFakeCoordinator(hass, config_entry) + + if mode == MODE_SIMULATOR: + from .simulator import SatSimulatorCoordinator + return SatSimulatorCoordinator(hass, config_entry) + + if mode == MODE_MQTT: + from .mqtt import SatMqttCoordinator + return SatMqttCoordinator(hass, config_entry, device) + + if mode == MODE_SERIAL: + from .serial import SatSerialCoordinator + return await SatSerialCoordinator(hass, config_entry, device).async_connect() + + if mode == MODE_SWITCH: + from .switch import SatSwitchCoordinator + return SatSwitchCoordinator(hass, config_entry, device) + + raise Exception(f'Invalid mode[{mode}]') + + +class SatDataUpdateCoordinator(DataUpdateCoordinator): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize.""" + self.boiler_temperatures = [] + self._config_entry = config_entry + self._device_state = DeviceState.OFF + self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) + self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) + + super().__init__(hass, _LOGGER, name=DOMAIN) + + @property + def device_state(self): + """Return the current state of the device.""" + return self._device_state + + @property + @abstractmethod + def setpoint(self) -> float | None: + pass + + @property + @abstractmethod + def device_active(self) -> bool: + pass + + @property + def flame_active(self) -> bool: + return self.device_active + + @property + def hot_water_active(self) -> bool: + return False + + @property + def hot_water_setpoint(self) -> float | None: + return None + + @property + def boiler_temperature(self) -> float | None: + return None + + @property + def filtered_boiler_temperature(self) -> float | None: + # Not able to use if we do not have at least two values + if len(self.boiler_temperatures) < 2: + return None + + # Some noise filtering on the boiler temperature + difference_boiler_temperature_sum = sum( + abs(j[1] - i[1]) for i, j in zip(self.boiler_temperatures, self.boiler_temperatures[1:]) + ) + + # Average it and return it + return round(difference_boiler_temperature_sum / (len(self.boiler_temperatures) - 1), 2) + + @property + def minimum_hot_water_setpoint(self) -> float: + return 30 + + @property + def maximum_hot_water_setpoint(self) -> float: + return 60 + + @property + def relative_modulation_value(self) -> float | None: + return None + + @property + def boiler_capacity(self) -> float | None: + return None + + @property + def minimum_boiler_capacity(self) -> float | None: + if (minimum_relative_modulation_value := self.minimum_relative_modulation_value) is None: + return None + + if (boiler_capacity := self.boiler_capacity) is None: + return None + + if boiler_capacity == 0: + return 0 + + return boiler_capacity * (minimum_relative_modulation_value / 100) + + @property + def boiler_power(self) -> float | None: + if (boiler_capacity := self.boiler_capacity) is None: + return None + + if (minimum_boiler_capacity := self.minimum_boiler_capacity) is None: + return None + + if (relative_modulation_value := self.relative_modulation_value) is None: + return None + + if self.flame_active is False: + return 0 + + return minimum_boiler_capacity + ((boiler_capacity - minimum_boiler_capacity) * (relative_modulation_value / 100)) + + @property + def minimum_relative_modulation_value(self) -> float | None: + return None + + @property + def maximum_relative_modulation_value(self) -> float | None: + return None + + @property + def maximum_setpoint(self) -> float: + """Return the maximum setpoint temperature that the device can support.""" + default_maximum_setpoint = calculate_default_maximum_setpoint(self._heating_system) + return float(self._config_entry.options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) + + @property + def minimum_setpoint(self) -> float: + """Return the minimum setpoint temperature before the device starts to overshoot.""" + return float(self._config_entry.data.get(CONF_MINIMUM_SETPOINT)) + + @property + def supports_setpoint_management(self): + """Returns whether the device supports setting a boiler setpoint. + + This property is used to determine whether the coordinator can send a setpoint to the device. + If a device doesn't support setpoint management, the coordinator won't be able to control the temperature. + """ + return False + + @property + def supports_hot_water_setpoint_management(self): + """Returns whether the device supports setting a hot water setpoint. + + This property is used to determine whether the coordinator can send a setpoint to the device. + If a device doesn't support setpoint management, the coordinator won't be able to control the temperature. + """ + return False + + @property + def supports_relative_modulation_management(self): + """Returns whether the device supports setting a relative modulation value. + + This property is used to determine whether the coordinator can send a relative modulation value to the device. + If a device doesn't support relative modulation management, the coordinator won't be able to control the value. + """ + return False + + @property + def supports_maximum_setpoint_management(self): + """Returns whether the device supports setting a maximum setpoint. + + This property is used to determine whether the coordinator can send a maximum setpoint to the device. + If a device doesn't support maximum setpoint management, the coordinator won't be able to control the value. + """ + return False + + async def async_added_to_hass(self, climate: SatClimate) -> None: + """Perform setup when the integration is added to Home Assistant.""" + await self.async_set_control_max_setpoint(self.maximum_setpoint) + + async def async_will_remove_from_hass(self, climate: SatClimate) -> None: + """Run when an entity is removed from hass.""" + pass + + async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: + """Control the heating loop for the device.""" + if climate is not None and climate.hvac_mode == HVACMode.OFF and self.device_active: + # Send out a new command to turn off the device + await self.async_set_heater_state(DeviceState.OFF) + + current_time = datetime.now() + + # Make sure we have valid value + if self.boiler_temperature is not None: + self.boiler_temperatures.append((current_time, self.boiler_temperature)) + + # Clear up any values that are older than the specified age + while self.boiler_temperatures and current_time - self.boiler_temperatures[0][0] > timedelta(seconds=MAX_BOILER_TEMPERATURE_AGE): + self.boiler_temperatures.pop() + + async def async_set_heater_state(self, state: DeviceState) -> None: + """Set the state of the device heater.""" + self._device_state = state + self.logger.info("Set central heater state %s", state) + + async def async_set_control_setpoint(self, value: float) -> None: + """Control the boiler setpoint temperature for the device.""" + if self.supports_setpoint_management: + self.logger.info("Set control boiler setpoint to %d", value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + """Control the DHW setpoint temperature for the device.""" + if self.supports_hot_water_setpoint_management: + self.logger.info("Set control hot water setpoint to %d", value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + """Control the maximum setpoint temperature for the device.""" + if self.supports_maximum_setpoint_management: + self.logger.info("Set maximum setpoint to %d", value) + + async def async_set_control_max_relative_modulation(self, value: int) -> None: + """Control the maximum relative modulation for the device.""" + if self.supports_relative_modulation_management: + self.logger.info("Set maximum relative modulation to %d", value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + """Control the setpoint temperature for the thermostat.""" + pass diff --git a/custom_components/sat/entity.py b/custom_components/sat/entity.py index b6ed072f..b4cea2f1 100644 --- a/custom_components/sat/entity.py +++ b/custom_components/sat/entity.py @@ -1,17 +1,25 @@ -"""SatEntity class""" +from __future__ import annotations + import logging +import typing +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, NAME, VERSION, CONF_NAME _LOGGER: logging.Logger = logging.getLogger(__name__) +if typing.TYPE_CHECKING: + from .climate import SatClimate + from .coordinator import SatDataUpdateCoordinator + class SatEntity(CoordinatorEntity): - def __init__(self, coordinator, config_entry): + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): super().__init__(coordinator) + self._coordinator = coordinator self._config_entry = config_entry @property @@ -22,3 +30,10 @@ def device_info(self): "manufacturer": NAME, "identifiers": {(DOMAIN, self._config_entry.data.get(CONF_NAME))}, } + + +class SatClimateEntity(SatEntity): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._climate = climate diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py new file mode 100644 index 00000000..64ccc0dc --- /dev/null +++ b/custom_components/sat/fake/__init__.py @@ -0,0 +1,102 @@ +from __future__ import annotations, annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class SatFakeConfig: + def __init__( + self, + supports_setpoint_management: bool = False, + supports_maximum_setpoint_management: bool = False, + supports_hot_water_setpoint_management: bool = False, + supports_relative_modulation_management: bool = False + ): + self.supports_setpoint_management = supports_setpoint_management + self.supports_maximum_setpoint_management = supports_maximum_setpoint_management + self.supports_hot_water_setpoint_management = supports_hot_water_setpoint_management + self.supports_relative_modulation_management = supports_relative_modulation_management + + +class SatFakeCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using mqtt.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + self.data = {} + self.config = SatFakeConfig(True) + + self._setpoint = None + self._maximum_setpoint = None + self._hot_water_setpoint = None + self._boiler_temperature = None + self._relative_modulation_value = 100 + + super().__init__(hass, config_entry) + + @property + def setpoint(self) -> float | None: + return self._setpoint + + @property + def boiler_temperature(self) -> float | None: + return self._boiler_temperature + + @property + def device_active(self) -> bool: + return self._device_state == DeviceState.ON + + @property + def supports_setpoint_management(self): + if self.config is None: + return super().supports_setpoint_management + + return self.config.supports_setpoint_management + + @property + def supports_hot_water_setpoint_management(self): + if self.config is None: + return super().supports_hot_water_setpoint_management + + return self.config.supports_hot_water_setpoint_management + + def supports_maximum_setpoint_management(self): + if self.config is None: + return super().supports_maximum_setpoint_management + + return self.config.supports_maximum_setpoint_management + + @property + def supports_relative_modulation_management(self): + if self.config is None: + return super().supports_relative_modulation_management + + return self.config.supports_relative_modulation_management + + async def async_set_boiler_temperature(self, value: float) -> None: + self._boiler_temperature = value + + async def async_set_control_setpoint(self, value: float) -> None: + self._setpoint = value + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + self._hot_water_setpoint = value + + await super().async_set_control_hot_water_setpoint(value) + + async def async_set_control_max_relative_modulation(self, value: int) -> None: + self._relative_modulation_value = value + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + self._maximum_setpoint = value + + await super().async_set_control_max_setpoint(value) \ No newline at end of file diff --git a/custom_components/sat/heating_curve.py b/custom_components/sat/heating_curve.py index 3761aa4d..d65abc99 100644 --- a/custom_components/sat/heating_curve.py +++ b/custom_components/sat/heating_curve.py @@ -1,7 +1,7 @@ from collections import deque from statistics import mean -from custom_components.sat.const import * +from .const import * class HeatingCurve: @@ -16,59 +16,73 @@ def __init__(self, heating_system: str, coefficient: float): def reset(self): self._optimal_coefficient = None + self._coefficient_derivative = None self._last_heating_curve_value = None self._optimal_coefficients = deque(maxlen=5) def update(self, target_temperature: float, outside_temperature: float) -> None: """Calculate the heating curve based on the outside temperature.""" - heating_curve_value = self._get_heating_curve_value( - target_temperature=target_temperature, - outside_temperature=outside_temperature - ) - + heating_curve_value = self._get_heating_curve_value(target_temperature, outside_temperature) self._last_heating_curve_value = round(self.base_offset + ((self._coefficient / 4) * heating_curve_value), 1) def calculate_coefficient(self, setpoint: float, target_temperature: float, outside_temperature: float) -> float: """Convert a setpoint to a coefficient value""" - heating_curve_value = self._get_heating_curve_value( - target_temperature=target_temperature, - outside_temperature=outside_temperature - ) - + heating_curve_value = self._get_heating_curve_value(target_temperature, outside_temperature) return round(4 * (setpoint - self.base_offset) / heating_curve_value, 1) def autotune(self, setpoint: float, target_temperature: float, outside_temperature: float): + """Calculate an optimal coefficient value.""" if setpoint <= MINIMUM_SETPOINT: return - coefficient = self.calculate_coefficient( - setpoint=setpoint, - target_temperature=target_temperature, - outside_temperature=outside_temperature - ) - - if coefficient != self._optimal_coefficient: - self._optimal_coefficients.append(coefficient) - + coefficient = self.calculate_coefficient(setpoint, target_temperature, outside_temperature) + self._coefficient_derivative = round(coefficient - self._optimal_coefficient, 1) if self._optimal_coefficient else coefficient + + # Fuzzy logic for when the derivative is positive + if self._coefficient_derivative > 1: + coefficient -= 0.3 + elif self._coefficient_derivative < 0.5: + coefficient -= 0.1 + elif self._coefficient_derivative < 1: + coefficient -= 0.2 + + # Fuzzy logic for when the derivative is negative + if self._coefficient_derivative < -1: + coefficient += 0.3 + elif self._coefficient_derivative > -0.5: + coefficient += 0.1 + elif self._coefficient_derivative > -1: + coefficient += 0.2 + + # Store the results + self._optimal_coefficients.append(coefficient) + self._optimal_coefficient = round(mean(self._optimal_coefficients), 1) + + def restore_autotune(self, coefficient: float, derivative: float): + """Restore a previous optimal coefficient value.""" self._optimal_coefficient = coefficient + self._coefficient_derivative = derivative + + self._optimal_coefficients = deque([coefficient] * 5, maxlen=5) @staticmethod def _get_heating_curve_value(target_temperature: float, outside_temperature: float) -> float: """Calculate the heating curve value based on the current outside temperature""" return target_temperature - (0.01 * outside_temperature ** 2) - (0.8 * outside_temperature) - @property - def optimal_coefficient(self): - if len(self._optimal_coefficients) == 0: - return None - - return round(mean(self._optimal_coefficients), 1) - @property def base_offset(self) -> float: """Determine the base offset for the heating system.""" return 20 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 27.2 + @property + def optimal_coefficient(self): + return self._optimal_coefficient + + @property + def coefficient_derivative(self): + return self._coefficient_derivative + @property def value(self): return self._last_heating_curve_value diff --git a/custom_components/sat/manifest.json b/custom_components/sat/manifest.json index a049c84b..6135a9c8 100644 --- a/custom_components/sat/manifest.json +++ b/custom_components/sat/manifest.json @@ -1,20 +1,26 @@ { - "domain": "sat", - "name": "Smart Autotune Thermostat", - "codeowners": [ - "@Alexwijn" - ], - "config_flow": true, - "dhcp": [ - { - "hostname": "otgw" - } - ], - "documentation": "https://github.com/Alexwijn/SAT", - "iot_class": "local_polling", - "issue_tracker": "https://github.com/Alexwijn/SAT/issues", - "requirements": [ - "pyotgw==2.1.3" - ], - "version": "1.0.0" + "domain": "sat", + "name": "Smart Autotune Thermostat", + "codeowners": [ + "@Alexwijn" + ], + "config_flow": true, + "dependencies": [ + "mqtt" + ], + "dhcp": [ + { + "hostname": "otgw" + } + ], + "documentation": "https://github.com/Alexwijn/SAT", + "iot_class": "local_push", + "issue_tracker": "https://github.com/Alexwijn/SAT/issues", + "mqtt": [ + "OTGW/value/+" + ], + "requirements": [ + "pyotgw==2.1.3" + ], + "version": "2.1.0" } diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py new file mode 100644 index 00000000..388989a0 --- /dev/null +++ b/custom_components/sat/minimum_setpoint.py @@ -0,0 +1,118 @@ +import hashlib +import logging +import time +from typing import List + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from custom_components.sat.coordinator import SatDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def _is_valid(data): + if not isinstance(data, dict): + return False + + if not 'value' in data or not isinstance(data['value'], float): + return False + + if not 'timestamp' in data or not isinstance(data['timestamp'], int): + return False + + return True + + +class MinimumSetpoint: + _STORAGE_VERSION = 1 + _STORAGE_KEY = "minimum_setpoint" + + def __init__(self, coordinator: SatDataUpdateCoordinator): + self._alpha = 0.2 + self._store = None + self._adjusted_setpoints = {} + self._coordinator = coordinator + self._previous_adjusted_setpoint = None + + @staticmethod + def _get_cache_key(errors: List[float]) -> str: + errors_str = ','.join(map(str, errors)) + cache_hash = hashlib.sha256(errors_str.encode('utf-8')) + return cache_hash.hexdigest() + + async def async_initialize(self, hass: HomeAssistant) -> None: + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + + if (adjusted_setpoints := await self._store.async_load()) is None: + adjusted_setpoints = {} + + self._adjusted_setpoints = adjusted_setpoints + + def calculate(self, setpoint: float, errors: List[float], adjustment_percentage=10): + # Check for a valid setpoint + if setpoint is None: + return + + # Calculate a cache key for adjusted setpoints + hash_key = self._get_cache_key(errors) + + # Extract relevant values from the coordinator for clarity + boiler_temperature = self._coordinator.boiler_temperature + target_setpoint_temperature = self._coordinator.setpoint + is_flame_active = self._coordinator.flame_active + + # Check for None values + if boiler_temperature is None or target_setpoint_temperature is None: + return + + # Check for flame activity and if we are stable + if not is_flame_active or abs(target_setpoint_temperature - boiler_temperature) <= 1: + return + + # Check if we are above configured minimum setpoint, does not make sense if we are below it + if boiler_temperature <= self._coordinator.minimum_setpoint: + return + + # Dynamically adjust the minimum setpoint + adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) + raw_adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) + + adjusted_setpoint = raw_adjusted_setpoint + if hash_key in self._adjusted_setpoints: + # Determine some defaults + previous_adjusted_setpoint = self._previous_adjusted_setpoint + if setpoint in self._adjusted_setpoints[hash_key]: + previous_adjusted_setpoint = self._adjusted_setpoints[hash_key][setpoint]['value'] + + # Use the moving average to adjust the calculated setpoint + if previous_adjusted_setpoint is not None: + adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * previous_adjusted_setpoint + else: + self._adjusted_setpoints[hash_key] = {} + + # Keep track of the adjusted setpoint and update the timestamp + self._adjusted_setpoints[hash_key][setpoint] = { + 'errors': errors, + 'timestamp': int(time.time()), + 'value': round(adjusted_setpoint, 1) + } + + # Store previous value, so we have a moving value + self._previous_adjusted_setpoint = round(adjusted_setpoint, 1) + + # Store the change calibration + if self._store is not None: + self._store.async_delay_save(lambda: self._adjusted_setpoints) + + def current(self, errors: List[float]) -> float: + cache_key = self._get_cache_key(errors) + + if (data := self._adjusted_setpoints.get(cache_key)) is None: + return self._coordinator.minimum_setpoint + 2 + + return min(data.values(), key=lambda x: x['value'])['value'] + + @property + def cache(self) -> dict[str, float]: + return self._adjusted_setpoints diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py new file mode 100644 index 00000000..fd225514 --- /dev/null +++ b/custom_components/sat/mqtt/__init__.py @@ -0,0 +1,229 @@ +from __future__ import annotations, annotations + +import logging +import typing + +from homeassistant.components import mqtt +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, Event +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.event import async_track_state_change_event + +from ..const import * +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +DATA_FLAME_ACTIVE = "flame" +DATA_DHW_SETPOINT = "TdhwSet" +DATA_CONTROL_SETPOINT = "TSet" +DATA_REL_MOD_LEVEL = "RelModLevel" +DATA_BOILER_TEMPERATURE = "Tboiler" +DATA_DHW_ENABLE = "domestichotwater" +DATA_CENTRAL_HEATING = "centralheating" +DATA_BOILER_CAPACITY = "MaxCapacityMinModLevel_hb_u8" +DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevel_lb_u8" +DATA_REL_MIN_MOD_LEVELL = "MaxCapacityMinModLevell_lb_u8" +DATA_MAX_REL_MOD_LEVEL_SETTING = "MaxRelModLevelSetting" +DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" +DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" + +if typing.TYPE_CHECKING: + from ..climate import SatClimate + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class SatMqttCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using mqtt.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, device_id: str) -> None: + super().__init__(hass, config_entry) + + self.data = {} + + self._device = device_registry.async_get(hass).async_get(device_id) + self._node_id = list(self._device.identifiers)[0][1] + self._topic = config_entry.data.get(CONF_MQTT_TOPIC) + + self._entity_registry = entity_registry.async_get(hass) + self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) + + @property + def supports_setpoint_management(self): + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + def supports_maximum_setpoint_management(self): + return True + + @property + def supports_relative_modulation_management(self): + return True + + @property + def device_active(self) -> bool: + return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING) == DeviceState.ON + + @property + def flame_active(self) -> bool: + return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE) == DeviceState.ON + + @property + def hot_water_active(self) -> bool: + return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE) == DeviceState.ON + + @property + def setpoint(self) -> float | None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return super().hot_water_setpoint + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def relative_modulation_value(self) -> float | None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return super().relative_modulation_value + + @property + def boiler_capacity(self) -> float | None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL)) is not None: + return float(value) + + # Legacy + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL)) is not None: + return float(value) + + return super().minimum_relative_modulation_value + + @property + def maximum_relative_modulation_value(self) -> float | None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_MAX_REL_MOD_LEVEL_SETTING)) is not None: + return float(value) + + return super().maximum_relative_modulation_value + + async def async_added_to_hass(self, climate: SatClimate) -> None: + await mqtt.async_wait_for_mqtt_client(self.hass) + + # Create a list of entities that we track + entities = list(filter(lambda entity: entity is not None, [ + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE), + + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL), + self._get_entity_id(SENSOR_DOMAIN, DATA_MAX_REL_MOD_LEVEL_SETTING), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), + ])) + + # Track those entities so the coordinator can be updated when something changes + async_track_state_change_event(self.hass, entities, self.async_state_change_event) + + await self._send_command("PM=48") + await super().async_added_to_hass(climate) + + async def async_state_change_event(self, event: Event): + if self._listeners: + self._schedule_refresh() + + self.async_update_listeners() + + async def async_set_control_setpoint(self, value: float) -> None: + await self._send_command(f"CS={value}") + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + await self._send_command(f"SW={value}") + + await super().async_set_control_hot_water_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + await self._send_command(f"TC={value}") + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + await self._send_command(f"CH={1 if state == DeviceState.ON else 0}") + + await super().async_set_heater_state(state) + + async def async_set_control_max_relative_modulation(self, value: int) -> None: + await self._send_command(f"MM={value}") + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + await self._send_command(f"SH={value}") + + await super().async_set_control_max_setpoint(value) + + def _get_entity_state(self, domain: str, key: str): + entity_id = self._get_entity_id(domain, key) + if entity_id is None: + return None + + state = self.hass.states.get(self._get_entity_id(domain, key)) + if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return None + + return state.state + + def _get_entity_id(self, domain: str, key: str): + return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") + + async def _send_command(self, payload: str): + if not self._simulation: + await mqtt.async_publish(self.hass, f"{self._topic}/set/{self._node_id}/command", payload) + + _LOGGER.debug(f"Publishing '{payload}' to MQTT.") diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index c0cf4867..00bf5518 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -1,26 +1,27 @@ -import pyotgw.vars as gw_vars from homeassistant.components.number import NumberEntity, NumberDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from custom_components.sat import SatDataUpdateCoordinator, CONF_NAME, COORDINATOR, DOMAIN -from custom_components.sat.entity import SatEntity +from .const import * +from .coordinator import SatDataUpdateCoordinator +from .entity import SatEntity async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - async_add_entities([SatHotWaterSetpointEntity(coordinator, config_entry)]) + + if coordinator.supports_hot_water_setpoint_management: + async_add_entities([SatHotWaterSetpointEntity(coordinator, config_entry)]) class SatHotWaterSetpointEntity(SatEntity, NumberEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) - - self._coordinator = coordinator + self._name = self._config_entry.data.get(CONF_NAME) @property def name(self) -> str | None: - return f"Hot Water Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" + return f"Hot Water Setpoint {self._name} (Boiler)" @property def device_class(self): @@ -30,7 +31,7 @@ def device_class(self): @property def unique_id(self) -> str: """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-dhw-setpoint" + return f"{self._name.lower()}-boiler-dhw-setpoint" @property def icon(self) -> str | None: @@ -39,7 +40,7 @@ def icon(self) -> str | None: @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None + return self._coordinator.hot_water_setpoint is not None @property def native_unit_of_measurement(self): @@ -49,18 +50,18 @@ def native_unit_of_measurement(self): @property def native_value(self): """Return the state of the device in native units.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_DHW_SETPOINT] + return self._coordinator.hot_water_setpoint @property def native_min_value(self) -> float: """Return the minimum accepted temperature.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MIN_SETP] + return self._coordinator.minimum_hot_water_setpoint @property def native_max_value(self) -> float: """Return the maximum accepted temperature.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MAX_SETP] + return self._coordinator.maximum_hot_water_setpoint async def async_set_native_value(self, value: float) -> None: """Update the setpoint.""" - await self._coordinator.api.set_dhw_setpoint(value) + await self._coordinator.async_set_control_hot_water_setpoint(value) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index e5cc35b4..684fcefd 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -1,75 +1,63 @@ import asyncio import logging -from collections import deque -from custom_components.sat import SatDataUpdateCoordinator -from .const import * - -SOLUTION_AUTOMATIC = "auto" -SOLUTION_WITH_MODULATION = "with_modulation" -SOLUTION_WITH_ZERO_MODULATION = "with_zero_modulation" +from custom_components.sat.const import * +from custom_components.sat.coordinator import DeviceState, SatDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -OVERSHOOT_PROTECTION_SETPOINT = 75 -OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0.00 -OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 -OVERSHOOT_PROTECTION_TIMEOUT = 7200 # 2 hours in seconds -OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # 2 minutes in seconds +OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds +OVERSHOOT_PROTECTION_INITIAL_WAIT = 180 # Three minutes in seconds class OvershootProtection: def __init__(self, coordinator: SatDataUpdateCoordinator): + self._alpha = 0.2 self._coordinator = coordinator - async def calculate(self, solution: str) -> float | None: + async def calculate(self) -> float | None: _LOGGER.info("Starting calculation") - await self._coordinator.api.set_ch_enable_bit(1) - await self._coordinator.api.set_max_ch_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - await self._coordinator.api.set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + + await self._coordinator.async_set_heater_state(DeviceState.ON) try: # First wait for a flame - await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_TIMEOUT) - - if solution == SOLUTION_AUTOMATIC: - # First run start_with_zero_modulation for at least 2 minutes - start_with_zero_modulation_task = asyncio.create_task(self._calculate_with_zero_modulation()) - await asyncio.sleep(OVERSHOOT_PROTECTION_INITIAL_WAIT) - - # Check if relative modulation is still zero - if float(self._coordinator.get(gw_vars.DATA_REL_MOD_LEVEL)) == OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: - return await start_with_zero_modulation_task - else: - start_with_zero_modulation_task.cancel() - _LOGGER.info("Relative modulation is not zero, switching to with modulation") - return await self._calculate_with_modulation() - elif solution == SOLUTION_WITH_MODULATION: - return await self._calculate_with_modulation() - elif solution == SOLUTION_WITH_ZERO_MODULATION: - return await self._calculate_with_zero_modulation() + await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) + + # Since the coordinator doesn't support modulation management, so we need to fall back to find it with modulation + if not self._coordinator.supports_relative_modulation_management: + return await self._calculate_with_no_modulation_management() + + # Run with maximum power of the boiler, zero modulation. + return await self._calculate_with_zero_modulation() except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") return None + except asyncio.CancelledError as exception: + await self._coordinator.async_set_heater_state(DeviceState.OFF) + await self._coordinator.async_set_control_setpoint(MINIMUM_SETPOINT) + await self._coordinator.async_set_control_max_relative_modulation(MAXIMUM_RELATIVE_MOD) + + raise exception async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") - await self._coordinator.api.set_max_relative_mod(OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD) + await self._coordinator.async_set_control_max_relative_modulation(MINIMUM_RELATIVE_MOD) try: return await asyncio.wait_for( - self._wait_for_stable_temperature(OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD), + self._wait_for_stable_temperature(0), timeout=OVERSHOOT_PROTECTION_TIMEOUT, ) except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") - async def _calculate_with_modulation(self) -> float: - _LOGGER.info("Running calculation with modulation") + async def _calculate_with_no_modulation_management(self) -> float: + _LOGGER.info("Running calculation with no modulation management") try: return await asyncio.wait_for( - self._wait_for_stable_temperature(OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD), + self._wait_for_stable_temperature(100), timeout=OVERSHOOT_PROTECTION_TIMEOUT, ) except asyncio.TimeoutError: @@ -77,30 +65,33 @@ async def _calculate_with_modulation(self) -> float: async def _wait_for_flame(self): while True: - if bool(self._coordinator.get(gw_vars.DATA_SLAVE_FLAME_ON)): + if bool(self._coordinator.flame_active): _LOGGER.info("Heating system has started to run") break _LOGGER.warning("Heating system is not running yet") + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + await asyncio.sleep(5) + await self._coordinator.async_control_heating_loop() async def _wait_for_stable_temperature(self, max_modulation: float) -> float: - temps = deque(maxlen=50) - previous_average_temp = None + previous_average_temperature = float(self._coordinator.boiler_temperature) while True: - actual_temp = float(self._coordinator.get(gw_vars.DATA_CH_WATER_TEMP)) + actual_temperature = float(self._coordinator.boiler_temperature) + average_temperature = self._alpha * actual_temperature + (1 - self._alpha) * previous_average_temperature - temps.append(actual_temp) - average_temp = sum(temps) / 50 + if previous_average_temperature is not None and abs(actual_temperature - previous_average_temperature) <= DEADBAND: + _LOGGER.info("Stable temperature reached: %s", actual_temperature) + return actual_temperature - if previous_average_temp is not None: - if abs(actual_temp - previous_average_temp) <= 0.1: - _LOGGER.info("Stable temperature reached: %s", actual_temp) - return actual_temp + previous_average_temperature = average_temperature - if max_modulation != OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: - await self._coordinator.api.set_control_setpoint(actual_temp) + if max_modulation > 0: + await self._coordinator.async_set_control_setpoint(actual_temperature) + else: + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - previous_average_temp = average_temp - await asyncio.sleep(3) + await asyncio.sleep(5) + await self._coordinator.async_control_heating_loop() diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 6a133627..a0d30264 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -1,3 +1,4 @@ +import logging from collections import deque from time import monotonic from typing import Optional @@ -6,20 +7,27 @@ from .const import * +_LOGGER = logging.getLogger(__name__) + +MAX_BOILER_TEMPERATURE_AGE = 300 + class PID: """A proportional-integral-derivative (PID) controller.""" - def __init__(self, kp: float, ki: float, kd: float, + def __init__(self, + heating_system: str, automatic_gain_value: float, + kp: float, ki: float, kd: float, max_history: int = 2, - deadband: float = 0.1, + deadband: float = DEADBAND, automatic_gains: bool = False, integral_time_limit: float = 300, - sample_time_limit: Optional[float] = 10, - heating_system: str = HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES): + sample_time_limit: Optional[float] = 10): """ Initialize the PID controller. + :param heating_system: The type of heating system, either "underfloor" or "radiator" + :param automatic_gain_value: The value to finetune the aggression value. :param kp: The proportional gain of the PID controller. :param ki: The integral gain of the PID controller. :param kd: The derivative gain of the PID controller. @@ -27,7 +35,6 @@ def __init__(self, kp: float, ki: float, kd: float, :param deadband: The deadband of the PID controller. The range of error values where the controller will not make adjustments. :param integral_time_limit: The minimum time interval between integral updates to the PID controller, in seconds. :param sample_time_limit: The minimum time interval between updates to the PID controller, in seconds. - :param heating_system: The heating system type that we are controlling. """ self._kp = kp self._ki = ki @@ -36,6 +43,7 @@ def __init__(self, kp: float, ki: float, kd: float, self._history_size = max_history self._heating_system = heating_system self._automatic_gains = automatic_gains + self._automatic_gains_value = automatic_gain_value self._last_interval_updated = monotonic() self._sample_time_limit = max(sample_time_limit, 1) self._integral_time_limit = max(integral_time_limit, 1) @@ -47,6 +55,7 @@ def reset(self) -> None: self._time_elapsed = 0 self._last_updated = monotonic() self._last_heating_curve_value = 0 + self._last_boiler_temperature = None # Reset the integral and derivative self._integral = 0.0 @@ -56,11 +65,12 @@ def reset(self) -> None: self._times = deque(maxlen=self._history_size) self._errors = deque(maxlen=self._history_size) - def update(self, error: float, heating_curve_value: float) -> None: + def update(self, error: float, heating_curve_value: float, boiler_temperature: float) -> None: """Update the PID controller with the current error, inside temperature, outside temperature, and heating curve value. :param error: The max error between all the target temperatures and the current temperatures. :param heating_curve_value: The current heating curve value. + :param boiler_temperature: The current boiler temperature. """ current_time = monotonic() time_elapsed = current_time - self._last_updated @@ -79,6 +89,7 @@ def update(self, error: float, heating_curve_value: float) -> None: self._time_elapsed = time_elapsed self._last_error = error + self._last_boiler_temperature = boiler_temperature self._last_heating_curve_value = heating_curve_value def update_reset(self, error: float, heating_curve_value: Optional[float]) -> None: @@ -176,7 +187,7 @@ def update_derivative(self, error: float, alpha1: float = 0.8, alpha2: float = 0 def update_history_size(self, alpha: float = 0.8): """ - Update the size of the history of errors and times. + Update the history of errors and times. The size of the history is updated based on the frequency of updates to the sensor value. If the frequency of updates is high, the history size is increased, and if the frequency of updates is low, @@ -202,7 +213,7 @@ def update_history_size(self, alpha: float = 0.8): history_size = max(2, history_size) history_size = min(history_size, 100) - # Calculate a weighted average of the rate of updates and the previous history size + # Calculate an average weighted rate of updates and the previous history size self._history_size = alpha * history_size + (1 - alpha) * self._history_size # Update our lists with the new size @@ -248,9 +259,10 @@ def last_updated(self) -> float: def kp(self) -> float | None: """Return the value of kp based on the current configuration.""" if self._automatic_gains: - return round(self._last_heating_curve_value * 1.65, 6) + automatic_gain_value = 0.243 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 0.33 + return round(self._automatic_gains_value * automatic_gain_value * self._last_heating_curve_value, 6) - return self._kp + return float(self._kp) @property def ki(self) -> float | None: @@ -261,7 +273,7 @@ def ki(self) -> float | None: return round(self._last_heating_curve_value / 73900, 6) - return self._ki + return float(self._ki) @property def kd(self) -> float | None: @@ -273,12 +285,10 @@ def kd(self) -> float | None: if self._last_heating_curve_value is None: return 0 - if self._heating_system == HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES: - return round(self._last_heating_curve_value * 1650, 6) + aggression_value = 438.2 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 596 + return round(self._automatic_gains_value * aggression_value * self._last_heating_curve_value, 6) - return round(self._last_heating_curve_value * 2720, 6) - - return self._kd + return float(self._kd) @property def proportional(self) -> float: @@ -293,7 +303,17 @@ def integral(self) -> float: @property def derivative(self) -> float: """Return the derivative value.""" - return round(self.kd * self._raw_derivative, 3) + derivative = self.kd * self._raw_derivative + output = self._last_heating_curve_value + self.proportional + self.integral + + if self._last_boiler_temperature is not None: + if abs(self._last_error) > 0.1 and abs(self._last_boiler_temperature - output) < 3: + return 0 + + if abs(self._last_error) <= 0.1 and abs(self._last_boiler_temperature - output) < 7: + return 0 + + return round(derivative, 3) @property def raw_derivative(self) -> float: diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 3530a763..e867a94b 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -3,8 +3,8 @@ from time import monotonic from typing import Optional, Tuple -from custom_components.sat import SatConfigStore -from custom_components.sat.heating_curve import HeatingCurve +from .const import HEATER_STARTUP_TIMEFRAME +from .heating_curve import HeatingCurve _LOGGER = logging.getLogger(__name__) @@ -17,7 +17,7 @@ ON_TIME_80_PERCENT = 900 -class PWMState(Enum): +class PWMState(str, Enum): ON = "on" OFF = "off" IDLE = "idle" @@ -26,9 +26,13 @@ class PWMState(Enum): class PWM: """A class for implementing Pulse Width Modulation (PWM) control.""" - def __init__(self, store: SatConfigStore, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool): + def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" - self._store = store + self._alpha = 0.2 + self._force = force + self._last_boiler_temperature = None + self._last_duty_cycle_percentage = None + self._heating_curve = heating_curve self._max_cycle_time = max_cycle_time self._automatic_duty_cycle = automatic_duty_cycle @@ -41,81 +45,93 @@ def reset(self) -> None: self._state = PWMState.IDLE self._last_update = monotonic() - async def update(self, setpoint: float) -> None: + async def update(self, requested_setpoint: float, boiler_temperature: float) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value: self._state = PWMState.IDLE self._last_update = monotonic() - _LOGGER.warning("Invalid heating curve value") + _LOGGER.warning("Turned off PWM due since we do not have a valid heating curve value.") return - if setpoint is None or setpoint > self._store.retrieve_overshoot_protection_value(): + if requested_setpoint is None: self._state = PWMState.IDLE self._last_update = monotonic() - _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value") + self._last_boiler_temperature = boiler_temperature + _LOGGER.debug("Turned off PWM due since we do not have a valid requested setpoint.") return - elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(setpoint) + if boiler_temperature is not None and self._last_boiler_temperature is None: + self._last_boiler_temperature = boiler_temperature - if self._duty_cycle is None: - self._state = PWMState.IDLE - self._last_update = monotonic() - _LOGGER.debug("Turned off PWM because we are above maximum duty cycle") - return + elapsed = monotonic() - self._last_update + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint) _LOGGER.debug("Calculated duty cycle %.0f seconds ON", self._duty_cycle[0]) _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) - if self._state != PWMState.ON and self._duty_cycle[0] >= 180 and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): + if self._state == PWMState.ON and boiler_temperature is not None: + if elapsed <= HEATER_STARTUP_TIMEFRAME: + self._last_boiler_temperature = self._alpha * boiler_temperature + (1 - self._alpha) * self._last_boiler_temperature + else: + self._last_boiler_temperature = boiler_temperature + + if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): self._state = PWMState.ON self._last_update = monotonic() + self._last_boiler_temperature = boiler_temperature or 0 _LOGGER.debug("Starting duty cycle.") return - if self._state != PWMState.OFF and (self._duty_cycle[0] < 180 or elapsed >= self._duty_cycle[0] or self._state == PWMState.IDLE): + if self._state != PWMState.OFF and (self._duty_cycle[0] < HEATER_STARTUP_TIMEFRAME or elapsed >= self._duty_cycle[0] or self._state == PWMState.IDLE): self._state = PWMState.OFF self._last_update = monotonic() _LOGGER.debug("Finished duty cycle.") return - _LOGGER.debug("Cycle time elapsed %.0f seconds", elapsed) + _LOGGER.debug("Cycle time elapsed %.0f seconds in %s", elapsed, self._state) - def _calculate_duty_cycle(self, setpoint: float) -> Optional[Tuple[int, int]]: + def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" + boiler_temperature = self._last_boiler_temperature or requested_setpoint base_offset = self._heating_curve.base_offset - overshoot_protection = self._store.retrieve_overshoot_protection_value() - duty_cycle_percentage = (setpoint - base_offset) / (overshoot_protection - base_offset) - _LOGGER.debug("Requested setpoint %.1f", setpoint) - _LOGGER.debug("Calculated duty cycle %.0f%%", duty_cycle_percentage * 100) + if boiler_temperature < base_offset: + boiler_temperature = base_offset + 1 + + self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (boiler_temperature - base_offset) + self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) + self._last_duty_cycle_percentage = max(self._last_duty_cycle_percentage, 0) + + _LOGGER.debug("Requested setpoint %.1f", requested_setpoint) + _LOGGER.debug("Boiler Temperature %.1f", boiler_temperature) + _LOGGER.debug("Calculated duty cycle %.2f%%", self._last_duty_cycle_percentage * 100) - if not self._automatic_duty_cycle and duty_cycle_percentage >= 0: - return int(duty_cycle_percentage * self._max_cycle_time), int((1 - duty_cycle_percentage) * self._max_cycle_time) + if not self._automatic_duty_cycle: + return int(self._last_duty_cycle_percentage * self._max_cycle_time), int((1 - self._last_duty_cycle_percentage) * self._max_cycle_time) - if duty_cycle_percentage < MIN_DUTY_CYCLE_PERCENTAGE: - return 0, 0 + if self._last_duty_cycle_percentage < MIN_DUTY_CYCLE_PERCENTAGE: + return 0, 1800 - if duty_cycle_percentage <= DUTY_CYCLE_20_PERCENT: + if self._last_duty_cycle_percentage <= DUTY_CYCLE_20_PERCENT: on_time = ON_TIME_20_PERCENT - off_time = (ON_TIME_20_PERCENT / duty_cycle_percentage) - ON_TIME_20_PERCENT + off_time = (ON_TIME_20_PERCENT / self._last_duty_cycle_percentage) - ON_TIME_20_PERCENT return int(on_time), int(off_time) - if duty_cycle_percentage <= DUTY_CYCLE_80_PERCENT: - on_time = ON_TIME_80_PERCENT * duty_cycle_percentage - off_time = ON_TIME_80_PERCENT * (1 - duty_cycle_percentage) + if self._last_duty_cycle_percentage <= DUTY_CYCLE_80_PERCENT: + on_time = ON_TIME_80_PERCENT * self._last_duty_cycle_percentage + off_time = ON_TIME_80_PERCENT * (1 - self._last_duty_cycle_percentage) return int(on_time), int(off_time) - if duty_cycle_percentage <= MAX_DUTY_CYCLE_PERCENTAGE: - on_time = ON_TIME_20_PERCENT / (1 - duty_cycle_percentage) - ON_TIME_20_PERCENT + if self._last_duty_cycle_percentage <= MAX_DUTY_CYCLE_PERCENTAGE: + on_time = ON_TIME_20_PERCENT / (1 - self._last_duty_cycle_percentage) - ON_TIME_20_PERCENT off_time = ON_TIME_20_PERCENT return int(on_time), int(off_time) - if duty_cycle_percentage > MAX_DUTY_CYCLE_PERCENTAGE: - return None + if self._last_duty_cycle_percentage > MAX_DUTY_CYCLE_PERCENTAGE: + return 1800, 0 @property def state(self) -> PWMState: @@ -131,3 +147,7 @@ def duty_cycle(self) -> None | tuple[int, int]: Otherwise, a tuple is returned with the on and off times of the duty cycle in seconds. """ return self._duty_cycle + + @property + def last_duty_cycle_percentage(self): + return round(self._last_duty_cycle_percentage * 100, 2) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py new file mode 100644 index 00000000..e35062ff --- /dev/null +++ b/custom_components/sat/relative_modulation.py @@ -0,0 +1,56 @@ +from enum import Enum + +from custom_components.sat import MINIMUM_SETPOINT, HEATING_SYSTEM_HEAT_PUMP +from custom_components.sat.coordinator import SatDataUpdateCoordinator +from custom_components.sat.pwm import PWMState + + +# Enum to represent different states of relative modulation +class RelativeModulationState(str, Enum): + OFF = "off" + COLD = "cold" + HOT_WATER = "hot_water" + WARMING_UP = "warming_up" + PULSE_WIDTH_MODULATION_OFF = "pulse_width_modulation_off" + + +class RelativeModulation: + def __init__(self, coordinator: SatDataUpdateCoordinator, heating_system: str): + """Initialize instance variables""" + self._heating_system = heating_system # The heating system that is being controlled + self._pwm_state = None # Tracks the current state of the PWM (Pulse Width Modulation) system + self._warming_up = False # Stores data related to the warming up state of the heating system + self._coordinator = coordinator # Reference to the data coordinator responsible for system-wide information + + async def update(self, warming_up: bool, state: PWMState) -> None: + """Update internal state with new data received from the coordinator""" + self._pwm_state = state + self._warming_up = warming_up + + @property + def state(self) -> RelativeModulationState: + """Determine the current state of relative modulation based on coordinator and internal data""" + # If setpoint is not available or below the minimum threshold, it's considered COLD + if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: + return RelativeModulationState.COLD + + # If hot water is actively being used, it's considered HOT_WATER + if self._coordinator.hot_water_active: + return RelativeModulationState.HOT_WATER + + # If the heating system is currently in the process of warming up, it's considered WARMING_UP + if self._warming_up and self._heating_system != HEATING_SYSTEM_HEAT_PUMP: + return RelativeModulationState.WARMING_UP + + # If the PWM state is in the ON state, it's considered PULSE_WIDTH_MODULATION_OFF + if self._pwm_state != PWMState.ON: + return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF + + # Default case, when none of the above conditions are met, it's considered OFF + return RelativeModulationState.OFF + + @property + def enabled(self) -> bool: + """Check if the relative modulation is enabled based on its current state""" + # Relative modulation is considered enabled if it's not in the OFF state or in the WARMING_UP state + return self.state != RelativeModulationState.OFF and self.state != RelativeModulationState.WARMING_UP diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index a1f8b7c5..cc0536c1 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -1,166 +1,235 @@ -"""Sensor platform for SAT.""" +from __future__ import annotations + import logging +import typing -import pyotgw.vars as gw_vars -from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT, SensorDeviceClass +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.const import UnitOfPower, UnitOfTemperature, UnitOfVolume +from homeassistant.core import HomeAssistant, Event +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event -from . import SatDataUpdateCoordinator -from .const import SENSOR_INFO, DOMAIN, COORDINATOR, TRANSLATE_SOURCE, CONF_NAME +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, MODE_SIMULATOR, CONF_MINIMUM_CONSUMPTION, CONF_MAXIMUM_CONSUMPTION +from .coordinator import SatDataUpdateCoordinator from .entity import SatEntity +from .serial import sensor as serial_sensor +from .simulator import sensor as simulator_sensor -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" - - # Create list of devices to be added - devices = [ - SatCurrentPowerSensor(coordinator, config_entry), - ] - - # Iterate through sensor information - for key, info in SENSOR_INFO.items(): - unit = info[1] - device_class = info[0] - status_sources = info[3] - friendly_name_format = info[2] - - # Check if the sensor should be added based on its availability and thermostat presence - for source in status_sources: - if source == gw_vars.THERMOSTAT and has_thermostat is False: - continue - - if coordinator.data[source].get(key) is not None: - devices.append(SatSensor(coordinator, config_entry, key, source, device_class, unit, friendly_name_format)) - - # Add all devices - async_add_entities(devices) - - -class SatSensor(SatEntity, SensorEntity): - def __init__( - self, - coordinator: SatDataUpdateCoordinator, - config_entry: ConfigEntry, - key: str, - source: str, - device_class: str, - unit: str, - friendly_name_format: str - ): - super().__init__(coordinator, config_entry) +if typing.TYPE_CHECKING: + from .climate import SatClimate - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass - ) +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + """ + Add sensors for the serial protocol if the integration is set to use it. + """ + climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] + coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] + + # Check if integration is set to use the serial protocol + if _config_entry.data.get(CONF_MODE) == MODE_SERIAL: + await serial_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) - self._key = key - self._unit = unit - self._source = source - self._coordinator = coordinator - self._device_class = device_class - self._config_entry = config_entry + # Check if integration is set to use the simulator + if _config_entry.data.get(CONF_MODE) == MODE_SIMULATOR: + await simulator_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) - if TRANSLATE_SOURCE[source] is not None: - friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" + _async_add_entities([ + SatErrorValueSensor(coordinator, _config_entry, climate), + SatHeatingCurveSensor(coordinator, _config_entry, climate), + ]) - self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) + if coordinator.supports_relative_modulation_management: + _async_add_entities([SatCurrentPowerSensor(coordinator, _config_entry)]) + + if float(_config_entry.options.get(CONF_MINIMUM_CONSUMPTION) or 0) > 0 and float(_config_entry.options.get(CONF_MAXIMUM_CONSUMPTION) or 0) > 0: + _async_add_entities([SatCurrentConsumptionSensor(coordinator, _config_entry)]) + + +class SatCurrentPowerSensor(SatEntity, SensorEntity): @property - def name(self): - """Return the friendly name of the sensor.""" - return self._friendly_name + def name(self) -> str: + return f"Boiler Current Power {self._config_entry.data.get(CONF_NAME)} (Boiler)" @property def device_class(self): """Return the device class.""" - return self._device_class + return SensorDeviceClass.POWER @property def native_unit_of_measurement(self): """Return the unit of measurement.""" - return self._unit + return UnitOfPower.KILO_WATT @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + return self._coordinator.boiler_power is not None @property - def native_value(self): - """Return the state of the device.""" - value = self._coordinator.data[self._source].get(self._key) - if isinstance(value, float): - value = f"{value:2.1f}" + def native_value(self) -> float: + """Return the state of the device in native units. - return value + In this case, the state represents the current power of the boiler in kW. + """ + return self._coordinator.boiler_power @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._source}-{self._key}" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" -class SatCurrentPowerSensor(SatEntity, SensorEntity): +class SatCurrentConsumptionSensor(SatEntity, SensorEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) - self._coordinator = coordinator + self._minimum_consumption = self._config_entry.options.get(CONF_MINIMUM_CONSUMPTION) + self._maximum_consumption = self._config_entry.options.get(CONF_MAXIMUM_CONSUMPTION) @property - def name(self) -> str | None: - return f"Boiler Current Power {self._config_entry.data.get(CONF_NAME)} (Boiler)" + def name(self) -> str: + return f"Boiler Current Consumption {self._config_entry.data.get(CONF_NAME)} (Boiler)" @property def device_class(self): """Return the device class.""" - return SensorDeviceClass.POWER + return SensorDeviceClass.GAS @property def native_unit_of_measurement(self): """Return the unit of measurement.""" - return UnitOfPower.KILO_WATT + return UnitOfVolume.CUBIC_METERS @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None + return self._coordinator.relative_modulation_value is not None @property def native_value(self) -> float: """Return the state of the device in native units. - In this case, the state represents the current capacity of the boiler in kW. + In this case, the state represents the current consumption of the boiler in m³/h. """ - # Get the data of the boiler from the coordinator - boiler = self._coordinator.data[gw_vars.BOILER] - # If the flame is off, return 0 kW - if bool(boiler.get(gw_vars.DATA_SLAVE_FLAME_ON)) is False: + if self._coordinator.device_active is False: return 0 - # Get the relative modulation level from the data - relative_modulation = float(boiler.get(gw_vars.DATA_REL_MOD_LEVEL) or 0) - - # Get the maximum capacity from the data - if (maximum_capacity := float(boiler.get(gw_vars.DATA_SLAVE_MAX_CAPACITY) or 0)) == 0: + if self._coordinator.flame_active is False: return 0 - # Get and calculate the minimum capacity from the data - minimum_capacity = maximum_capacity / (100 / float(boiler.get(gw_vars.DATA_SLAVE_MIN_MOD_LEVEL))) + differential_gas_consumption = self._maximum_consumption - self._minimum_consumption + relative_modulation_value = self._coordinator.relative_modulation_value - # Calculate and return the current capacity in kW - return minimum_capacity + (((maximum_capacity - minimum_capacity) / 100) * relative_modulation) + return round(self._minimum_consumption + ((relative_modulation_value / 100) * differential_gas_consumption), 3) @property def unique_id(self) -> str: """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-consumption" + + +class SatHeatingCurveSensor(SatEntity, SensorEntity): + + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, climate: SatClimate): + super().__init__(coordinator, config_entry) + + self._climate = climate + + async def async_added_to_hass(self) -> None: + async def on_state_change(_event: Event): + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._climate.entity_id], on_state_change + ) + ) + + @property + def name(self) -> str: + return f"Heating Curve {self._config_entry.data.get(CONF_NAME)}" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return UnitOfTemperature.CELSIUS + + @property + def available(self): + """Return availability of the sensor.""" + return self._climate.extra_state_attributes.get("heating_curve") is not None + + @property + def native_value(self) -> float: + """Return the state of the device in native units. + + In this case, the state represents the current heating curve value. + """ + return self._climate.extra_state_attributes.get("heating_curve") + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-heating-curve" + + +class SatErrorValueSensor(SatEntity, SensorEntity): + + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, climate: SatClimate): + super().__init__(coordinator, config_entry) + + self._climate = climate + + async def async_added_to_hass(self) -> None: + async def on_state_change(_event: Event): + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._climate.entity_id], on_state_change + ) + ) + + @property + def name(self) -> str: + return f"Error Value {self._config_entry.data.get(CONF_NAME)}" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return UnitOfTemperature.CELSIUS + + @property + def available(self): + """Return availability of the sensor.""" + return self._climate.extra_state_attributes.get("error") is not None + + @property + def native_value(self) -> float: + """Return the state of the device in native units. + + In this case, the state represents the current error value. + """ + return self._climate.extra_state_attributes.get("error") + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-error-value" diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py new file mode 100644 index 00000000..5939b6a0 --- /dev/null +++ b/custom_components/sat/serial/__init__.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import asyncio +import logging +import typing +from typing import Optional, Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from pyotgw import vars as gw_vars, OpenThermGateway +from pyotgw.vars import * +from serial import SerialException + +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +if typing.TYPE_CHECKING: + from ..climate import SatClimate + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +# Sensors +TRANSLATE_SOURCE = { + gw_vars.OTGW: None, + gw_vars.BOILER: "Boiler", + gw_vars.THERMOSTAT: "Thermostat", +} + + +class SatSerialCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using pyotgw.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, port: str) -> None: + """Initialize.""" + super().__init__(hass, config_entry) + + self.data = DEFAULT_STATUS + + async def async_coroutine(data): + self.async_set_updated_data(data) + + self._port = port + self._api = OpenThermGateway() + self._api.subscribe(async_coroutine) + + @property + def device_active(self) -> bool: + return bool(self.get(DATA_MASTER_CH_ENABLED) or False) + + @property + def hot_water_active(self) -> bool: + return bool(self.get(DATA_SLAVE_DHW_ACTIVE) or False) + + @property + def supports_setpoint_management(self) -> bool: + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + @property + def supports_maximum_setpoint_management(self) -> bool: + return True + + @property + def supports_relative_modulation_management(self) -> bool: + return True + + @property + def setpoint(self) -> float | None: + if (setpoint := self.get(DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return super().hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self.get(DATA_CH_WATER_TEMP)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self.get(DATA_SLAVE_DHW_MIN_SETP)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_SLAVE_DHW_MAX_SETP)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return super().relative_modulation_value + + @property + def boiler_capacity(self) -> float | None: + if (value := self.get(DATA_SLAVE_MAX_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_SLAVE_MIN_MOD_LEVEL)) is not None: + return float(value) + + return super().minimum_relative_modulation_value + + @property + def maximum_relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_SLAVE_MAX_RELATIVE_MOD)) is not None: + return float(value) + + return super().maximum_relative_modulation_value + + @property + def flame_active(self) -> bool: + return bool(self.get(DATA_SLAVE_FLAME_ON)) + + def get(self, key: str) -> Optional[Any]: + """Get the value for the given `key` from the boiler data. + + :param key: Key of the value to retrieve from the boiler data. + :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. + """ + return self.data[BOILER].get(key) + + async def async_connect(self) -> SatSerialCoordinator: + try: + await self._api.connect(port=self._port, timeout=5) + except (asyncio.TimeoutError, ConnectionError, SerialException) as exception: + raise ConfigEntryNotReady(f"Could not connect to gateway at {self._port}: {exception}") from exception + + return self + + async def async_will_remove_from_hass(self, climate: SatClimate) -> None: + self._api.unsubscribe(self.async_set_updated_data) + + await self._api.set_control_setpoint(0) + await self._api.set_max_relative_mod("-") + await self._api.disconnect() + + async def async_set_control_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_control_setpoint(value) + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_dhw_setpoint(value) + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_target_temp(value) + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + if not self._simulation: + await self._api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) + + await super().async_set_heater_state(state) + + async def async_set_control_max_relative_modulation(self, value: int) -> None: + if not self._simulation: + await self._api.set_max_relative_mod(value) + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_max_ch_setpoint(value) + + await super().async_set_control_max_setpoint(value) diff --git a/custom_components/sat/serial/binary_sensor.py b/custom_components/sat/serial/binary_sensor.py new file mode 100644 index 00000000..6a7f54ae --- /dev/null +++ b/custom_components/sat/serial/binary_sensor.py @@ -0,0 +1,133 @@ +"""Binary Sensor platform for SAT.""" +from __future__ import annotations + +import logging +import typing + +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass, ENTITY_ID_FORMAT +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import async_generate_entity_id +from pyotgw.vars import * + +from . import TRANSLATE_SOURCE, SatSerialCoordinator +from ..const import * +from ..entity import SatEntity + +_LOGGER = logging.getLogger(__name__) + + +class SatBinarySensorInfo: + def __init__(self, device_class: typing.Optional[str], friendly_name_format: str, status_sources: typing.List[str]): + self.device_class = device_class + self.status_sources = status_sources + self.friendly_name_format = friendly_name_format + + +BINARY_SENSOR_INFO: dict[str, SatBinarySensorInfo] = { + DATA_MASTER_CH_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating {}", [BOILER, THERMOSTAT]), + DATA_MASTER_DHW_ENABLED: SatBinarySensorInfo(None, "Thermostat Hot Water {}", [BOILER, THERMOSTAT]), + DATA_MASTER_COOLING_ENABLED: SatBinarySensorInfo(None, "Thermostat Cooling {}", [BOILER, THERMOSTAT]), + DATA_MASTER_OTC_ENABLED: SatBinarySensorInfo(None, "Thermostat Outside Temperature Correction {}", [BOILER, THERMOSTAT]), + DATA_MASTER_CH2_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating 2 {}", [BOILER, THERMOSTAT]), + + DATA_SLAVE_FAULT_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Hot Water {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_FLAME_ON: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Flame {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_COOLING_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.COLD, "Boiler Cooling {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH2_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating 2 {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DIAG_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Diagnostics {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_PRESENT: SatBinarySensorInfo(None, "Boiler Hot Water Present {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CONTROL_TYPE: SatBinarySensorInfo(None, "Boiler Control Type {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_COOLING_SUPPORTED: SatBinarySensorInfo(None, "Boiler Cooling Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_CONFIG: SatBinarySensorInfo(None, "Boiler Hot Water Configuration {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MASTER_LOW_OFF_PUMP: SatBinarySensorInfo(None, "Boiler Pump Commands Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH2_PRESENT: SatBinarySensorInfo(None, "Boiler Central Heating 2 Present {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_SERVICE_REQ: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Service Required {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_REMOTE_RESET: SatBinarySensorInfo(None, "Boiler Remote Reset Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_LOW_WATER_PRESS: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Low Water Pressure {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_GAS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Gas Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_AIR_PRESS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Air Pressure Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_WATER_OVERTEMP: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Water Over-temperature {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_TRANSFER_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Transfer Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_TRANSFER_MAX_CH: SatBinarySensorInfo(None, "Remote Maximum Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_RW_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_RW_MAX_CH: SatBinarySensorInfo(None, "Remote Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_ROVRD_MAN_PRIO: SatBinarySensorInfo(None, "Remote Override Manual Change Priority {}", [BOILER, THERMOSTAT]), + DATA_ROVRD_AUTO_PRIO: SatBinarySensorInfo(None, "Remote Override Program Change Priority {}", [BOILER, THERMOSTAT]), + + OTGW_GPIO_A_STATE: SatBinarySensorInfo(None, "Gateway GPIO A {}", [OTGW]), + OTGW_GPIO_B_STATE: SatBinarySensorInfo(None, "Gateway GPIO B {}", [OTGW]), + OTGW_IGNORE_TRANSITIONS: SatBinarySensorInfo(None, "Gateway Ignore Transitions {}", [OTGW]), + OTGW_OVRD_HB: SatBinarySensorInfo(None, "Gateway Override High Byte {}", [OTGW]), +} + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" + + # Create a list of entities to be added + entities = [] + + # Iterate through sensor information + for key, info in BINARY_SENSOR_INFO.items(): + # Check if the sensor should be added based on its availability and thermostat presence + for source in info.status_sources: + if source == THERMOSTAT and has_thermostat is False: + continue + + if coordinator.data[source].get(key) is not None: + entities.append(SatBinarySensor(coordinator, config_entry, info, key, source)) + + # Add all devices + async_add_entities(entities) + + +class SatBinarySensor(SatEntity, BinarySensorEntity): + _attr_should_poll = False + + def __init__(self, coordinator: SatSerialCoordinator, config_entry: ConfigEntry, info: SatBinarySensorInfo, key: str, source: str): + super().__init__(coordinator, config_entry) + + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass + ) + + self._key = key + self._source = source + self._config_entry = config_entry + self._device_class = info.device_class + + friendly_name_format = info.friendly_name_format + if TRANSLATE_SOURCE[source] is not None: + friendly_name_format = f"{info.friendly_name_format} ({TRANSLATE_SOURCE[source]})" + + self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self._friendly_name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.data[self._source].get(self._key) is not None + + @property + def is_on(self): + """Return the state of the device.""" + return self._coordinator.data[self._source].get(self._key) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME.lower())}-{self._source}-{self._key}" diff --git a/custom_components/sat/serial/sensor.py b/custom_components/sat/serial/sensor.py new file mode 100644 index 00000000..43d45443 --- /dev/null +++ b/custom_components/sat/serial/sensor.py @@ -0,0 +1,160 @@ +"""Sensor platform for SAT.""" +import logging +from typing import Optional, List + +from homeassistant.components.sensor import SensorEntity, DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower, UnitOfTemperature, PERCENTAGE, UnitOfPressure, UnitOfVolume, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import async_generate_entity_id +from pyotgw.vars import * + +from . import TRANSLATE_SOURCE, SatSerialCoordinator +from ..const import * +from ..entity import SatEntity + +_LOGGER = logging.getLogger(__name__) + + +class SatSensorInfo: + def __init__(self, device_class: Optional[str], unit: Optional[str], friendly_name_format: str, status_sources: List[str]): + self.unit = unit + self.device_class = device_class + self.status_sources = status_sources + self.friendly_name_format = friendly_name_format + + +SENSOR_INFO: dict[str, SatSensorInfo] = { + DATA_CONTROL_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint {}", [BOILER, THERMOSTAT]), + DATA_MASTER_MEMBERID: SatSensorInfo(None, None, "Thermostat Member ID {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MEMBERID: SatSensorInfo(None, None, "Boiler Member ID {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_OEM_FAULT: SatSensorInfo(None, None, "Boiler OEM Fault Code {}", [BOILER, THERMOSTAT]), + DATA_COOLING_CONTROL: SatSensorInfo(None, PERCENTAGE, "Cooling Control Signal {}", [BOILER, THERMOSTAT]), + DATA_CONTROL_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint 2 {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT_OVRD: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint Override {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MAX_RELATIVE_MOD: SatSensorInfo(None, PERCENTAGE, "Boiler Maximum Relative Modulation {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MAX_CAPACITY: SatSensorInfo(SensorDeviceClass.POWER, UnitOfPower.KILO_WATT, "Boiler Maximum Capacity {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MIN_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Boiler Minimum Modulation Level {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint {}", [BOILER, THERMOSTAT]), + DATA_REL_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Relative Modulation Level {}", [BOILER, THERMOSTAT], ), + DATA_CH_WATER_PRESS: SatSensorInfo(SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, "Central Heating Water Pressure {}", [BOILER, THERMOSTAT]), + DATA_DHW_FLOW_RATE: SatSensorInfo(None, f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", "Hot Water Flow Rate {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint 2 {}", [BOILER, THERMOSTAT]), + DATA_ROOM_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Temperature {}", [BOILER, THERMOSTAT]), + DATA_CH_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_DHW_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_OUTSIDE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Outside Temperature {}", [BOILER, THERMOSTAT]), + DATA_RETURN_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Return Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_SOLAR_STORAGE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Storage Temperature {}", [BOILER, THERMOSTAT]), + DATA_SOLAR_COLL_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Collector Temperature {}", [BOILER, THERMOSTAT]), + DATA_CH_WATER_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating 2 Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_DHW_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water 2 Temperature {}", [BOILER, THERMOSTAT]), + DATA_EXHAUST_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Exhaust Temperature {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Maximum Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Minimum Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Minimum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_MAX_CH_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_OEM_DIAG: SatSensorInfo(None, None, "OEM Diagnostic Code {}", [BOILER, THERMOSTAT]), + DATA_TOTAL_BURNER_STARTS: SatSensorInfo(None, None, "Total Burner Starts {}", [BOILER, THERMOSTAT]), + DATA_CH_PUMP_STARTS: SatSensorInfo(None, None, "Central Heating Pump Starts {}", [BOILER, THERMOSTAT]), + DATA_DHW_PUMP_STARTS: SatSensorInfo(None, None, "Hot Water Pump Starts {}", [BOILER, THERMOSTAT]), + DATA_DHW_BURNER_STARTS: SatSensorInfo(None, None, "Hot Water Burner Starts {}", [BOILER, THERMOSTAT]), + DATA_TOTAL_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Total Burner Hours {}", [BOILER, THERMOSTAT]), + DATA_CH_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Central Heating Pump Hours {}", [BOILER, THERMOSTAT]), + DATA_DHW_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Pump Hours {}", [BOILER, THERMOSTAT]), + DATA_DHW_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Burner Hours {}", [BOILER, THERMOSTAT]), + DATA_MASTER_OT_VERSION: SatSensorInfo(None, None, "Thermostat OpenTherm Version {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_OT_VERSION: SatSensorInfo(None, None, "Boiler OpenTherm Version {}", [BOILER, THERMOSTAT]), + DATA_MASTER_PRODUCT_TYPE: SatSensorInfo(None, None, "Thermostat Product Type {}", [BOILER, THERMOSTAT]), + DATA_MASTER_PRODUCT_VERSION: SatSensorInfo(None, None, "Thermostat Product Version {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_PRODUCT_TYPE: SatSensorInfo(None, None, "Boiler Product Type {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_PRODUCT_VERSION: SatSensorInfo(None, None, "Boiler Product Version {}", [BOILER, THERMOSTAT]), + + OTGW_MODE: SatSensorInfo(None, None, "Gateway/Monitor Mode {}", [OTGW]), + OTGW_DHW_OVRD: SatSensorInfo(None, None, "Gateway Hot Water Override Mode {}", [OTGW]), + OTGW_ABOUT: SatSensorInfo(None, None, "Gateway Firmware Version {}", [OTGW]), + OTGW_BUILD: SatSensorInfo(None, None, "Gateway Firmware Build {}", [OTGW]), + OTGW_SB_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Gateway Setback Temperature {}", [OTGW]), + OTGW_SETP_OVRD_MODE: SatSensorInfo(None, None, "Gateway Room Setpoint Override Mode {}", [OTGW]), + OTGW_SMART_PWR: SatSensorInfo(None, None, "Gateway Smart Power Mode {}", [OTGW]), + OTGW_THRM_DETECT: SatSensorInfo(None, None, "Gateway Thermostat Detection {}", [OTGW]), + OTGW_VREF: SatSensorInfo(None, None, "Gateway Reference Voltage Setting {}", [OTGW]), +} + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" + + # Create a list of entities to be added + entities = [] + + # Iterate through sensor information + for key, info in SENSOR_INFO.items(): + # Check if the sensor should be added based on its availability and thermostat presence + for source in info.status_sources: + if source == THERMOSTAT and has_thermostat is False: + continue + + if coordinator.data[source].get(key) is not None: + entities.append(SatSensor(coordinator, config_entry, info, key, source)) + + # Add all devices + async_add_entities(entities) + + +class SatSensor(SatEntity, SensorEntity): + def __init__(self, coordinator: SatSerialCoordinator, config_entry: ConfigEntry, info: SatSensorInfo, key: str, source: str): + super().__init__(coordinator, config_entry) + + self.entity_id = async_generate_entity_id( + SENSOR_DOMAIN + ".{}", f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass + ) + + self._key = key + self._unit = info.unit + self._source = source + self._device_class = info.device_class + self._config_entry = config_entry + + friendly_name_format = info.friendly_name_format + if TRANSLATE_SOURCE[source] is not None: + friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" + + self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self._friendly_name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.data[self._source].get(self._key) is not None + + @property + def native_value(self): + """Return the state of the device.""" + value = self._coordinator.data[self._source].get(self._key) + if isinstance(value, float): + value = f"{value:2.1f}" + + return value + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._source}-{self._key}" diff --git a/custom_components/sat/services.yaml b/custom_components/sat/services.yaml index 54e4af74..be968678 100644 --- a/custom_components/sat/services.yaml +++ b/custom_components/sat/services.yaml @@ -1,36 +1,3 @@ -start_overshoot_protection_calculation: - name: Overshoot Protection Calculation - description: "This service calculates the value that will be used to determine the setpoint and prevent overshooting." - fields: - solution: - name: Solution - required: true - default: auto - description: Select the solution for calculation - selector: - select: - options: - - label: "Automatic" - value: "auto" - - label: "With Modulation" - value: "with_modulation" - - label: "With Zero Modulation" - value: "with_zero_modulation" - -overshoot_protection_value: - name: Overshoot Protection Value - description: "Override the stored overshoot protection value without doing a calculation." - fields: - value: - name: Value - default: 60 - required: true - description: The value to set - selector: - number: - min: 10 - max: 100 - clear_integral: name: Clear Integral description: "This service clears the integrating part of the PID controller for the specified climate entity. This may be useful if the integral value has become too large or if the PID controller's performance has degraded." \ No newline at end of file diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py new file mode 100644 index 00000000..15348d58 --- /dev/null +++ b/custom_components/sat/simulator/__init__.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import typing +from time import monotonic + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .. import CONF_SIMULATED_HEATING, CONF_SIMULATED_COOLING, MINIMUM_SETPOINT, CONF_SIMULATED_WARMING_UP, CONF_MAXIMUM_SETPOINT +from ..coordinator import DeviceState, SatDataUpdateCoordinator +from ..util import convert_time_str_to_seconds + +if typing.TYPE_CHECKING: + from ..climate import SatClimate + + +class SatSimulatorCoordinator(SatDataUpdateCoordinator): + """Class to manage the Switch.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize.""" + super().__init__(hass, config_entry) + + self._started_on = None + self._setpoint = MINIMUM_SETPOINT + self._boiler_temperature = MINIMUM_SETPOINT + + self._heating = config_entry.data.get(CONF_SIMULATED_HEATING) + self._cooling = config_entry.data.get(CONF_SIMULATED_COOLING) + self._maximum_setpoint = config_entry.data.get(CONF_MAXIMUM_SETPOINT) + self._warming_up = convert_time_str_to_seconds(config_entry.data.get(CONF_SIMULATED_WARMING_UP)) + + @property + def supports_setpoint_management(self) -> bool: + return True + + @property + def supports_maximum_setpoint_management(self): + return True + + @property + def supports_relative_modulation_management(self) -> float | None: + return True + + @property + def setpoint(self) -> float: + return self._setpoint + + @property + def boiler_temperature(self) -> float | None: + return self._boiler_temperature + + @property + def device_active(self) -> bool: + return self._device_state == DeviceState.ON + + @property + def flame_active(self) -> bool: + return self.device_active and self.target > self._boiler_temperature + + @property + def relative_modulation_value(self) -> float | None: + return 100 if self.flame_active else 0 + + async def async_set_heater_state(self, state: DeviceState) -> None: + self._started_on = monotonic() if state == DeviceState.ON else None + + await super().async_set_heater_state(state) + + async def async_set_control_setpoint(self, value: float) -> None: + self._setpoint = value + await super().async_set_control_setpoint(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + self._maximum_setpoint = value + await super().async_set_control_max_setpoint(value) + + async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: + # Calculate the difference, so we know when to slowdown + difference = abs(self._boiler_temperature - self.target) + self.logger.debug(f"Target: {self.target}, Current: {self._boiler_temperature}, Difference: {difference}") + + # Heating + if self.target >= self._boiler_temperature: + if self._heating >= difference: + self._boiler_temperature = self.target + self.logger.debug(f"Reached boiler temperature") + else: + self._boiler_temperature += self._heating + self.logger.debug(f"Increasing boiler temperature with {self._heating}") + + # Cooling + elif self._boiler_temperature >= self.target: + if self._cooling >= difference: + self._boiler_temperature = self.target + self.logger.debug(f"Reached boiler temperature") + else: + self._boiler_temperature -= self._cooling + self.logger.debug(f"Decreasing boiler temperature with {self._cooling}") + + self.async_set_updated_data({}) + + @property + def target(self): + # Overshoot + if self.minimum_setpoint >= self.setpoint: + return self.minimum_setpoint + + # State check + if not self._started_on or (monotonic() - self._started_on) < self._warming_up: + return MINIMUM_SETPOINT + + return self.setpoint diff --git a/custom_components/sat/simulator/sensor.py b/custom_components/sat/simulator/sensor.py new file mode 100644 index 00000000..a9b0ad8b --- /dev/null +++ b/custom_components/sat/simulator/sensor.py @@ -0,0 +1,99 @@ +from homeassistant.components.sensor import SensorEntity, DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import async_generate_entity_id + +from ..const import * +from ..entity import SatEntity +from ..simulator import SatSimulatorCoordinator + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + + # Add all devices + async_add_entities([ + SatSetpointSensor(coordinator, config_entry), + SatBoilerTemperatureSensor(coordinator, config_entry), + ]) + + +class SatSetpointSensor(SatEntity, SensorEntity): + def __init__(self, coordinator: SatSimulatorCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._config_entry = config_entry + self.entity_id = async_generate_entity_id( + SENSOR_DOMAIN + ".{}", f"{config_entry.data.get(CONF_NAME).lower()}_setpoint", hass=coordinator.hass + ) + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return f"Current Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement in native units.""" + return "°C" + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.setpoint is not None + + @property + def native_value(self): + """Return the state of the device.""" + return self._coordinator.setpoint + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-setpoint" + + +class SatBoilerTemperatureSensor(SatEntity, SensorEntity): + def __init__(self, coordinator: SatSimulatorCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._config_entry = config_entry + self.entity_id = async_generate_entity_id( + SENSOR_DOMAIN + ".{}", f"{config_entry.data.get(CONF_NAME).lower()}_boiler_temperature", hass=coordinator.hass + ) + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return f"Current Temperature {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement in native units.""" + return "°C" + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.boiler_temperature is not None + + @property + def native_value(self): + """Return the state of the device.""" + return self._coordinator.boiler_temperature + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler_temperature" diff --git a/custom_components/sat/summer_simmer.py b/custom_components/sat/summer_simmer.py new file mode 100644 index 00000000..156890aa --- /dev/null +++ b/custom_components/sat/summer_simmer.py @@ -0,0 +1,61 @@ +from homeassistant.const import UnitOfTemperature +from homeassistant.util.unit_conversion import TemperatureConverter + + +class SummerSimmer: + @staticmethod + def index(temperature: float, humidity: float) -> float | None: + """ + Calculate the Summer Simmer Index. + + The Summer Simmer Index is a measure of heat and humidity. + + Formula: 1.98 * (F - (0.55 - 0.0055 * H) * (F - 58.0)) - 56.83 + If F < 58, the index is F. + + Returns: + float: Summer Simmer Index in Celsius. + """ + # Make sure we have a valid values + if temperature is None or humidity is None: + return None + + # Convert temperature to Fahrenheit + fahrenheit = TemperatureConverter.convert( + temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + ) + + # Calculate Summer Simmer Index + index = 1.98 * (fahrenheit - (0.55 - 0.0055 * humidity) * (fahrenheit - 58.0)) - 56.83 + + # If the temperature is below 58°F, use the temperature as the index + if fahrenheit < 58: + index = fahrenheit + + # Convert the result back to Celsius + return round(TemperatureConverter.convert(index, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS), 1) + + @staticmethod + def perception(temperature: float, humidity: float) -> str: + index = SummerSimmer.index(temperature, humidity) + + if index is None: + return "Unknown" + elif index < 21.1: + return "Cool" + elif index < 25.0: + return "Slightly Cool" + elif index < 28.3: + return "Comfortable" + elif index < 32.8: + return "Slightly Warm" + elif index < 37.8: + return "Increasing Discomfort" + elif index < 44.4: + return "Extremely Warm" + elif index < 51.7: + return "Danger Of Heatstroke" + elif index < 65.6: + return "Extreme Danger Of Heatstroke" + else: + return "Circulatory Collapse Imminent" diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py new file mode 100644 index 00000000..0f0be47f --- /dev/null +++ b/custom_components/sat/switch/__init__.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry + +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +DOMAIN_SERVICE = { + SWITCH_DOMAIN: SWITCH_DOMAIN, + INPUT_BOOLEAN_DOMAIN: INPUT_BOOLEAN_DOMAIN +} + + +class SatSwitchCoordinator(SatDataUpdateCoordinator): + """Class to manage the Switch.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, entity_id: str) -> None: + """Initialize.""" + super().__init__(hass, config_entry) + + self._entity = entity_registry.async_get(hass).async_get(entity_id) + + @property + def setpoint(self) -> float: + return self.minimum_setpoint + + @property + def maximum_setpoint(self) -> float: + return self.minimum_setpoint + + @property + def device_active(self) -> bool: + if (state := self.hass.states.get(self._entity.id)) is None: + return False + + return state.state == STATE_ON + + async def async_set_heater_state(self, state: DeviceState) -> None: + if not self._simulation: + domain_service = DOMAIN_SERVICE.get(self._entity.domain) + state_service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF + + if domain_service: + await self.hass.services.async_call(domain_service, state_service, {ATTR_ENTITY_ID: self._entity.entity_id}, blocking=True) + + await super().async_set_heater_state(state) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index e73b061f..fb15b0e7 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -3,63 +3,191 @@ "step": { "user": { "title": "Smart Autotune Thermostat (SAT)", + "description": "SAT is a smart thermostat that is capable of auto-tuning itself to optimize temperature control. Select the appropriate mode that matches your heating system.", + "menu_options": { + "mosquitto": "OpenTherm Gateway ( MQTT )", + "serial": "OpenTherm Gateway ( SERIAL )", + "switch": "PID Thermostat with PWM ( ON/OFF )", + "simulator": "Simulated Gateway ( ADVANCED )" + } + }, + "mosquitto": { + "title": "OpenTherm Gateway ( MQTT )", + "description": "Please provide the following details to set up the OpenTherm Gateway. In the Name field, enter a name for the gateway that will help you identify it within your system.\n\nSpecify the Climate entity to use for the OpenTherm Gateway. This entity is provided by the OpenTherm Gateway and represents your heating system.\n\nAdditionally, enter the Top Topic that will be used for publishing and subscribing to MQTT messages related to the OpenTherm Gateway.\n\nThese settings are essential for establishing communication and integration with your OpenTherm Gateway through MQTT. They allow for seamless data exchange and control of your heating system. Ensure that the provided details are accurate to ensure proper functionality.", + "data": { + "name": "Name", + "device": "Device", + "mqtt_topic": "Top Topic" + } + }, + "serial": { + "title": "OpenTherm Gateway ( SERIAL )", + "description": "To establish a connection with the OpenTherm Gateway using a socket connection, please provide the following details. In the Name field, enter a name for the gateway that will help you identify it within your system.\n\nSpecify the network address of the OpenTherm Gateway in the Device field. This could be in the format of \"socket://otgw.local:25238\", where \"otgw.local\" is the hostname or IP address of the gateway and \"25238\" is the port number.\n\nThese settings are essential for establishing communication and integration with your OpenTherm Gateway through the socket connection. Ensure that the provided details are accurate to ensure proper functionality.", + "data": { + "name": "Name", + "device": "URL" + } + }, + "switch": { + "title": "PID Thermostat with PWM ( ON/OFF )", + "description": "Please fill in the following details to set up the switch. Enter a name for the switch in the Name field, which will help you identify it within your system. Choose the appropriate entity to use for your switch from the provided options.\n\nIn the Temperature Setting field, specify the desired target temperature for your heating system. If you are using a hot water boiler, fill in the Boiler Temperature Setting with the appropriate value. For electric heating systems, enter the value 100.\n\nThese settings are essential for precise temperature control and ensuring optimal performance of your heating system. Providing the correct Temperature Setting allows for accurate regulation and helps achieve a comfortable and energy-efficient environment in your home.", "data": { "name": "Name", - "device": "Path or URL" + "device": "Entity", + "minimum_setpoint": "Temperature Setting" + } + }, + "simulator": { + "title": "Simulated Gateway ( ADVANCED )", + "description": "This gateway allows you to simulate a boiler for testing and demonstration purposes. Please provide the following information to configure the simulator.\n\nNote: The Simulator Gateway is intended for testing and demonstration purposes only and should not be used in production environments.", + "data": { + "name": "Name", + "minimum_setpoint": "Minimum Setpoint", + "maximum_setpoint": "Maximum Setpoint", + "simulated_heating": "Simulated Heating", + "simulated_cooling": "Simulated Cooling", + "simulated_warming_up": "Simulated Warming Up" } }, "sensors": { "title": "Configure sensors", + "description": "Please select the sensors that will be used to track the temperature.", "data": { "inside_sensor_entity_id": "Inside Sensor Entity", - "outside_sensor_entity_id": "Outside Sensor Entity" + "outside_sensor_entity_id": "Outside Sensor Entity", + "humidity_sensor_entity_id": "Humidity Sensor Entity" + } + }, + "heating_system": { + "title": "Heating System", + "description": "Selecting the correct heating system type is important for SAT to accurately control the temperature and optimize performance. Choose the option that matches your setup to ensure proper temperature regulation throughout your home.", + "data": { + "heating_system": "System" + } + }, + "areas": { + "title": "Areas", + "description": "Settings related to climates, multi-room and temperature control.", + "data": { + "main_climates": "Climates", + "secondary_climates": "Rooms" + } + }, + "automatic_gains": { + "title": "Automatic Gains", + "description": "This feature adjusts the control parameters of your heating system dynamically, optimizing temperature control for better comfort and energy efficiency. Enabling this option allows SAT to continuously adapt and fine-tune the heating settings based on the environmental conditions. This helps maintain a stable and comfortable environment without manual intervention.\n\nNote: If you choose not to enable automatic gains, you will need to manually enter the PID values for precise temperature control. Please ensure that you have accurate PID values for your specific heating system to achieve optimal performance.", + "data": { + "automatic_gains": "Automatic Gains (recommended)" + } + }, + "calibrate_system": { + "title": "Calibrate System", + "description": "Optimize your heating system by automatically determining the optimal PID values for your setup. When selecting Automatic Gains, please note that the system will go through a calibration process that may take approximately 20 minutes to complete.\n\nAutomatic Gains is recommended for most users as it simplifies the setup process and ensures optimal performance. However, if you're familiar with PID control and prefer to manually set the values, you can choose to skip Automatic Gains.\n\nPlease note that choosing to skip Automatic Gains requires a good understanding of PID control and may require additional manual adjustments to achieve optimal performance.", + "menu_options": { + "calibrate": "Calibrate and determine your overshoot protection value (approx. 20 min).", + "overshoot_protection": "Manually enter the overshoot protection value.", + "pid_controller": "Manually enter PID values (not recommended)." + } + }, + "overshoot_protection": { + "title": "Overshoot Protection", + "description": "By providing the overshoot protection value, SAT will adjust the control parameters accordingly to maintain a stable and comfortable heating environment. This manual configuration allows you to fine-tune the system based on your specific setup.\n\nNote: If you are unsure about the overshoot protection value or have not performed the calibration process, it is recommended to cancel the configuration and go through the calibration process to let SAT automatically determine the value for optimal performance.", + "data": { + "minimum_setpoint": "Value" + } + }, + "pid_controller": { + "title": "Configure the PID controller manually.", + "description": "Configure the proportional, integral, and derivative gains manually to fine-tune your heating system. Use this option if you prefer to have full control over the PID controller parameters. Adjust the gains based on your specific heating system characteristics and preferences.", + "data": { + "integral": "Integral (kI)", + "derivative": "Derivative (kD)", + "proportional": "Proportional (kP)" + } + }, + "calibrated": { + "title": "Calibration Completed", + "description": "The calibration process has completed successfully.\n\nCongratulations! Your Smart Autotune Thermostat (SAT) has been calibrated to optimize the heating performance of your system. During the calibration process, SAT carefully analyzed the heating characteristics and determined the appropriate overshoot protection value to ensure precise temperature control.\n\nOvershoot Protection Value: {minimum_setpoint} °C\n\nThis value represents the maximum amount of overshoot allowed during the heating process. SAT will actively monitor and adjust the heating to prevent excessive overshooting, maintaining a comfortable and efficient heating experience in your home.\n\nPlease note that the overshoot protection value may vary depending on the specific characteristics of your heating system and environmental factors. It has been fine-tuned to provide optimal performance based on the calibration results.", + "menu_options": { + "calibrate": "Retry calibration", + "finish": "Continue with current calibration" } } }, "error": { - "auth": "Unable to connect." + "connection": "Unable to connect to the gateway.", + "mqtt_component": "The MQTT component is unavailable.", + "unable_to_calibrate": "The calibration process has encountered an issue and could not be completed successfully. Please ensure that your heating system is functioning properly and that all required sensors are connected and working correctly.\n\nIf you continue to experience issues with calibration, consider contacting us for further assistance. We apologize for any inconvenience caused." }, "abort": { "already_configured": "Gateway is already configured." + }, + "progress": { + "calibration": "Calibrating and finding the overshoot protection value...\n\nPlease wait while we optimize your heating system. This process may take approximately 20 minutes." } }, "options": { "step": { - "user": { + "init": { "menu_options": { "general": "General", "presets": "Presets", - "climates": "Climates", - "advanced": "Advanced" + "advanced": "Advanced Options", + "system_configuration": "System Configuration" } }, "general": { "title": "General", + "description": "General settings and configurations.", "data": { - "integral": "Integral", - "derivative": "Derivative", - "proportional": "Proportional", - "duty_cycle": "Duty Cycle", - "heating_system": "Heating System", - "target_temperature_step": "Target Temperature Step", - "heating_curve_coefficient": "Heating Curve Coefficient" + "integral": "Integral (kI)", + "derivative": "Derivative (kD)", + "proportional": "Proportional (kP)", + "maximum_setpoint": "Maximum Setpoint", + "window_sensors": "Contact Sensors", + "automatic_gains_value": "Automatic Gains Value", + "heating_curve_coefficient": "Heating Curve Coefficient", + "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", + "sync_with_thermostat": "Synchronize setpoint with thermostat" + }, + "data_description": { + "integral": "The integral term (kI) in the PID controller, responsible for reducing steady-state error.", + "derivative": "The derivative term (kD) in the PID controller, responsible for mitigating overshooting.", + "proportional": "The proportional term (kP) in the PID controller, responsible for the immediate response to errors.", + "maximum_setpoint": "The optimal temperature for efficient boiler operation.", + "window_sensors": "Contact Sensors that trigger the system to react when a window or door is open for a period of time.", + "automatic_gains_value": "The value used for automatic gains in the PID controller.", + "heating_curve_coefficient": "The coefficient used to adjust the heating curve.", + "duty_cycle": "The maximum duty cycle for Pulse Width Modulation (PWM), controlling the boiler's on-off cycles.", + "sync_with_thermostat": "Synchronize setpoint with thermostat to ensure coordinated temperature control." } }, "presets": { "title": "Presets", + "description": "Predefined temperature settings for different scenarios or activities.", "data": { "away_temperature": "Away Temperature", "home_temperature": "Home Temperature", "sleep_temperature": "Sleep Temperature", "comfort_temperature": "Comfort Temperature", - "sync_climates_with_preset": "Synchronize climates with preset (sleep / away)" + "activity_temperature": "Activity Temperature", + "sync_climates_with_preset": "Synchronize climates with preset (sleep / away / activity)" } }, - "climates": { - "title": "Climates (multi-room)", + "system_configuration": { + "title": "System Configuration", + "description": "For fine-tuning and customization.", "data": { - "climates": "Climates (rooms)", - "main_climates": "Climates (main)" + "automatic_duty_cycle": "Automatic duty cycle", + "overshoot_protection": "Overshoot Protection (with PWM)", + "window_minimum_open_time": "Minimum time for window to be open", + "sensor_max_value_age": "Temperature Sensor maximum value age" + }, + "data_description": { + "automatic_duty_cycle": "Enable or disable automatic duty cycle for Pulse Width Modulation (PWM).", + "overshoot_protection": "Enable overshoot protection with Pulse Width Modulation (PWM) to prevent boiler temperature overshooting.", + "window_minimum_open_time": "The minimum time a window must be open before the system reacts.", + "sensor_max_value_age": "The maximum age of the temperature sensor value before considering it as a stall." } }, "advanced": { @@ -67,13 +195,23 @@ "data": { "simulation": "Simulation", "sample_time": "Sample Time", + "thermal_comfort": "Thermal Comfort", + "minimum_consumption": "Minimum Consumption", + "maximum_consumption": "Maximum Consumption", "climate_valve_offset": "Climate valve offset", - "sensor_max_value_age": "Sensor max. value age", - "automatic_gains": "Automatic gains", - "overshoot_protection": "Overshoot Protection (with PWM)", - "automatic_duty_cycle": "Automatic duty cycle (experimental)", + "target_temperature_step": "Target Temperature Step", + "maximum_relative_modulation": "Maximum Relative Modulation", "force_pulse_width_modulation": "Force Pulse Width Modulation", - "min_num_updates": "Minimum number of updates required for auto-tuning" + "dynamic_minimum_setpoint": "Dynamic Minimum Setpoint (Experimental)" + }, + "data_description": { + "thermal_comfort": "Enable the use of the Simmer Index for thermal comfort adjustment.", + "minimum_consumption": "The minimum gas consumption when the boiler is active.", + "maximum_consumption": "The maximum gas consumption when the boiler is active.", + "climate_valve_offset": "Offset to adjust the opening degree of the climate valve.", + "target_temperature_step": "Adjust the target temperature step for fine-tuning comfort levels.", + "sample_time": "The minimum time interval between updates to the PID controller.", + "maximum_relative_modulation": "Representing the highest modulation level for an efficient heating system." } } } diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py new file mode 100644 index 00000000..77f3576f --- /dev/null +++ b/custom_components/sat/util.py @@ -0,0 +1,89 @@ +from re import sub + +from homeassistant.util import dt + +from .const import * +from .heating_curve import HeatingCurve +from .pid import PID +from .pwm import PWM + + +def convert_time_str_to_seconds(time_str: str) -> float: + """Convert a time string in the format 'HH:MM:SS' to seconds. + + Args: + time_str: A string representing a time in the format 'HH:MM:SS'. + + Returns: + float: The time in seconds. + """ + date_time = dt.parse_time(time_str) + # Calculate the number of seconds by multiplying the hours, minutes and seconds + return (date_time.hour * 3600) + (date_time.minute * 60) + date_time.second + + +def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: float): + """ + Calculates the derivative per hour based on the temperature error and time taken.""" + # Convert time taken from seconds to hours + time_taken_hours = time_taken_seconds / 3600 + # Calculate the derivative per hour by dividing temperature error by time taken + return round(temperature_error / time_taken_hours, 2) + + +def calculate_default_maximum_setpoint(heating_system: str) -> int: + if heating_system == HEATING_SYSTEM_UNDERFLOOR: + return 50 + + return 55 + + +def create_pid_controller(config_options) -> PID: + """Create and return a PID controller instance with the given configuration options.""" + # Extract the configuration options + kp = float(config_options.get(CONF_PROPORTIONAL)) + ki = float(config_options.get(CONF_INTEGRAL)) + kd = float(config_options.get(CONF_DERIVATIVE)) + + heating_system = config_options.get(CONF_HEATING_SYSTEM) + automatic_gains = bool(config_options.get(CONF_AUTOMATIC_GAINS)) + automatic_gains_value = float(config_options.get(CONF_AUTOMATIC_GAINS_VALUE)) + sample_time_limit = convert_time_str_to_seconds(config_options.get(CONF_SAMPLE_TIME)) + + # Return a new PID controller instance with the given configuration options + return PID( + heating_system=heating_system, + automatic_gain_value=automatic_gains_value, + + kp=kp, ki=ki, kd=kd, + automatic_gains=automatic_gains, + sample_time_limit=sample_time_limit + ) + + +def create_heating_curve_controller(config_data, config_options) -> HeatingCurve: + """Create and return a PID controller instance with the given configuration options.""" + # Extract the configuration options + heating_system = config_data.get(CONF_HEATING_SYSTEM) + coefficient = float(config_options.get(CONF_HEATING_CURVE_COEFFICIENT)) + + # Return a new heating Curve controller instance with the given configuration options + return HeatingCurve(heating_system=heating_system, coefficient=coefficient) + + +def create_pwm_controller(heating_curve: HeatingCurve, config_data, config_options) -> PWM | None: + """Create and return a PWM controller instance with the given configuration options.""" + # Extract the configuration options + automatic_duty_cycle = bool(config_options.get(CONF_AUTOMATIC_DUTY_CYCLE)) + max_cycle_time = int(convert_time_str_to_seconds(config_options.get(CONF_DUTY_CYCLE))) + force = bool(config_data.get(CONF_MODE) == MODE_SWITCH) or bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + + # Return a new PWM controller instance with the given configuration options + return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) + + +def snake_case(s): + return '_'.join( + sub('([A-Z][a-z]+)', r' \1', + sub('([A-Z]+)', r' \1', + s.replace('-', ' '))).split()).lower() diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 00000000..61b52bad --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,11 @@ +pytest +pytest-cov +pytest-asyncio +pytest-homeassistant-custom-component +homeassistant==2023.5.3 +aiohttp_cors +aiodiscover +freezegun +pyotgw +scapy +janus \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..c3b29162 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[tool:pytest] +testpaths = tests +asyncio_mode = auto \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/bandit.yaml b/tests/bandit.yaml new file mode 100644 index 00000000..ebd284ea --- /dev/null +++ b/tests/bandit.yaml @@ -0,0 +1,17 @@ +# https://bandit.readthedocs.io/en/latest/config.html + +tests: + - B108 + - B306 + - B307 + - B313 + - B314 + - B315 + - B316 + - B317 + - B318 + - B319 + - B320 + - B325 + - B602 + - B604 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..2db936f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +"""Fixtures for testing.""" +import pytest +from _pytest.logging import LogCaptureFixture +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import assert_setup_component, MockConfigEntry + +from custom_components.sat import DOMAIN, CLIMATE, COORDINATOR +from custom_components.sat.climate import SatClimate +from custom_components.sat.fake import SatFakeCoordinator +from tests.const import DEFAULT_USER_DATA + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield + + +@pytest.fixture +async def entry(hass: HomeAssistant, domains: list, data: dict, options: dict, config: dict, caplog: LogCaptureFixture) -> MockConfigEntry: + """Setup any given integration.""" + for domain, count in domains: + with assert_setup_component(count, domain): + assert await async_setup_component(hass, domain, config) + + await hass.async_block_till_done() + + await hass.async_start() + await hass.async_block_till_done() + + user_data = DEFAULT_USER_DATA.copy() + user_data.update(data) + + config_entry = MockConfigEntry(domain=DOMAIN, data=user_data, options=options) + await hass.config_entries.async_add(config_entry) + + return config_entry + + +@pytest.fixture +async def climate(hass, entry: MockConfigEntry) -> SatClimate: + return hass.data[DOMAIN][entry.entry_id][CLIMATE] + + +@pytest.fixture +async def coordinator(hass, entry: MockConfigEntry) -> SatFakeCoordinator: + return hass.data[DOMAIN][entry.entry_id][COORDINATOR] diff --git a/tests/const.py b/tests/const.py new file mode 100644 index 00000000..891884de --- /dev/null +++ b/tests/const.py @@ -0,0 +1,13 @@ +from custom_components.sat import CONF_NAME, CONF_DEVICE, CONF_INSIDE_SENSOR_ENTITY_ID, CONF_OUTSIDE_SENSOR_ENTITY_ID, CONF_MODE, MODE_FAKE, CONF_AUTOMATIC_GAINS, \ + CONF_AUTOMATIC_DUTY_CYCLE, CONF_OVERSHOOT_PROTECTION + +DEFAULT_USER_DATA = { + CONF_MODE: MODE_FAKE, + CONF_NAME: "Test", + CONF_DEVICE: None, + CONF_AUTOMATIC_GAINS: True, + CONF_AUTOMATIC_DUTY_CYCLE: True, + CONF_OVERSHOOT_PROTECTION: True, + CONF_INSIDE_SENSOR_ENTITY_ID: "sensor.test_inside_sensor", + CONF_OUTSIDE_SENSOR_ENTITY_ID: "sensor.test_outside_sensor", +} diff --git a/tests/test_climate.py b/tests/test_climate.py new file mode 100644 index 00000000..6efc2080 --- /dev/null +++ b/tests/test_climate.py @@ -0,0 +1,143 @@ +"""The tests for the climate component.""" + +import pytest +from homeassistant.components.climate import HVACMode +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.template import DOMAIN as TEMPLATE_DOMAIN +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.sat.climate import SatClimate +from custom_components.sat.const import * +from custom_components.sat.fake import SatFakeCoordinator + + +@pytest.mark.parametrize(*[ + "domains, data, options, config", + [( + [(TEMPLATE_DOMAIN, 1)], + { + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, + CONF_MINIMUM_SETPOINT: 57, + CONF_MAXIMUM_SETPOINT: 75, + }, + { + CONF_HEATING_CURVE_COEFFICIENT: 1.8, + }, + { + TEMPLATE_DOMAIN: [ + { + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 20.9 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ 9.9 | float }}", + } + ] + }, + ], + }, + )], +]) +async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await coordinator.async_set_boiler_temperature(57) + await climate.async_set_target_temperature(21.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + assert climate.setpoint == 57 + assert climate.heating_curve.value == 32.6 + + assert climate.pulse_width_modulation_enabled + assert climate.pwm.last_duty_cycle_percentage == 36.24 + assert climate.pwm.duty_cycle == (326, 573) + + +@pytest.mark.parametrize(*[ + "domains, data, options, config", + [( + [(TEMPLATE_DOMAIN, 1)], + { + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, + CONF_MINIMUM_SETPOINT: 58, + CONF_MAXIMUM_SETPOINT: 75 + }, + { + CONF_HEATING_CURVE_COEFFICIENT: 1.3 + }, + { + TEMPLATE_DOMAIN: [ + { + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 18.99 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ 11.1 | float }}", + } + ] + }, + ], + }, + )], +]) +async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await coordinator.async_set_boiler_temperature(58) + await climate.async_set_target_temperature(19.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + assert climate.setpoint == 58 + assert climate.heating_curve.value == 30.1 + assert climate.requested_setpoint == 30.6 + + assert climate.pulse_width_modulation_enabled + assert climate.pwm.last_duty_cycle_percentage == 11.04 + assert climate.pwm.duty_cycle == (180, 1450) + + +@pytest.mark.parametrize(*[ + "domains, data, options, config", + [( + [(TEMPLATE_DOMAIN, 1)], + { + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, + CONF_MINIMUM_SETPOINT: 41, + CONF_MAXIMUM_SETPOINT: 75, + }, + { + CONF_HEATING_CURVE_COEFFICIENT: 0.9 + }, + { + TEMPLATE_DOMAIN: [ + { + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 19.9 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ -2.2 | float }}", + } + ] + }, + ], + }, + )], +]) +async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await coordinator.async_set_boiler_temperature(41) + await climate.async_set_target_temperature(20.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + assert climate.setpoint == 41 + assert climate.heating_curve.value == 32.1 + assert climate.requested_setpoint == 37.4 + + assert climate.pulse_width_modulation_enabled + assert climate.pwm.last_duty_cycle_percentage == 73.91 + assert climate.pwm.duty_cycle == (665, 234) diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 00000000..ebf5ec36 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,26 @@ +"""Test setup process.""" + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.sat import async_reload_entry +from custom_components.sat.const import DOMAIN +from tests.const import DEFAULT_USER_DATA + + +async def test_setup_update_unload_entry(hass): + """Test entry setup and unload.""" + # Create the switch entry + switch_entry = MockConfigEntry(domain=SWITCH_DOMAIN, entry_id="switch.test") + await hass.config_entries.async_add(switch_entry) + + # Create our entity + sat_entry = MockConfigEntry(domain=DOMAIN, data=DEFAULT_USER_DATA) + await hass.config_entries.async_add(sat_entry) + + # Wait till there are no tasks and see if we have been configured + await hass.async_block_till_done() + assert DOMAIN in hass.data and sat_entry.entry_id in hass.data[DOMAIN] + + # Reload the entry without errors + assert await async_reload_entry(hass, sat_entry) is None