pax_global_header00006660000000000000000000000064147042222710014513gustar00rootroot0000000000000052 comment=1af09580d0bc3f3197f6fb3b1140f4bfee493467 pyotgw-2.2.2/000077500000000000000000000000001470422227100130475ustar00rootroot00000000000000pyotgw-2.2.2/.coveragerc000066400000000000000000000000261470422227100151660ustar00rootroot00000000000000[run] source = pyotgw pyotgw-2.2.2/.github/000077500000000000000000000000001470422227100144075ustar00rootroot00000000000000pyotgw-2.2.2/.github/workflows/000077500000000000000000000000001470422227100164445ustar00rootroot00000000000000pyotgw-2.2.2/.github/workflows/ci.yml000066400000000000000000000017561470422227100175730ustar00rootroot00000000000000name: CI on: pull_request: branches: - master jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements_test.txt ]; then pip install -r requirements_test.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. flake8 . --count --exit-zero --max-line-length=88 --statistics - name: Test with pytest run: | pytest --cov --cov-report=term-missing pyotgw-2.2.2/.gitignore000066400000000000000000000001201470422227100150300ustar00rootroot00000000000000.coverage .project .pydevproject pyotgw.egg-info __pycache__ .tox .venv .vscode pyotgw-2.2.2/.pre-commit-config.yaml000066400000000000000000000016131470422227100173310ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-docstring-first - id: check-yaml - id: debug-statements - id: check-ast - repo: https://github.com/psf/black rev: 22.3.0 hooks: - id: black - repo: https://github.com/pycqa/flake8 rev: 7.0.0 hooks: - id: flake8 args: - --max-line-length=88 - repo: https://github.com/asottile/pyupgrade rev: v2.10.1 hooks: - id: pyupgrade args: ['--py38-plus'] - repo: https://github.com/pre-commit/mirrors-isort rev: v5.7.0 hooks: - id: isort args: - --multi-line=3 - --trailing-comma - --force-grid-wrap=0 - --use-parentheses - --line-width=88 - repo: https://github.com/PyCQA/bandit rev: 1.7.0 hooks: - id: bandit args: - --quiet - --recursive files: ^pyotgw/.+\.py pyotgw-2.2.2/CHANGELOG.md000066400000000000000000000162051470422227100146640ustar00rootroot00000000000000# pyotgw Changelog ### 2.2.2 - Fix exception order in _attempt_connect() - Update CI actions ### 2.2.1 - Catch OSError when trying to establish a connection ### 2.2.0 - Split status line processing into functions (#65) - Various small fixes (#64) - Split get_status dict into relevant values for each side (#63) - Handle SyntaxError on connect (#62) - Switch to pyserial-asyncio-fast (#61) - Add tests and additional docstring for OpenThermGateway.send_transparent_command() (#60) - Allow retrying the first init command. (#55) - Add transparent command (#59) - Delete .travis.yml (#56) - Add new python versions to tox, remove older versions. (#54) ### 2.1.3 - Fix TRSET quirk problem (#50) ### 2.1.2 - Fix handle TRSET on thermostat side (#48) - Fix pre-commit flake8 repo (#47) ### 2.1.1 - Fix boiler side room_setpoint not updating ### 2.1.0 - Add skip_init feature to OpenThermGateway.connect() - Add test case for skip_init feature ### 2.0.3 - Fix watchdog reconnect logic - Use deepcopy when submitting status updates to the queue - Fix tests for watchdog logic ### 2.0.2 - Only log unexpected disconnects ### 2.0.1 - Fix bug in watchdog reconnect logic - Add test case for watchdog reconnect logic - Add documentation for OpenThermGateway.set_connection_options() to README.md - Update usage example in README.md ### 2.0.0 - Add CHANGELOG.md - Make protocol.disconnect synchronous - Update pylint config in tox.ini and add pylint to travis - Remove unimplemented methods from OpenThermGateway class - Update pre-commit, CI and Travis config - Drop support for python 3.7 - Rename pyotgw class to OpenThermGateway - Remove loop argument from OpenThermGateway.connect() - Remove loop parameters from all classes - Add CI workflow - Refactor status management into a StatusManager class (pyotgw/status.py) - Refactor connection management into a ConnectionManager class (pyotgw/connection.py) - Refactor connection watchdog into a ConnectionWatchdog class (pyotgw/connection.py) - Refactor protocol message processing into MessageProcessor (pyotgw/messageprocessor.py) - Refactor command processing into CommandProcessor (pyotgw/commandprocessor.py) - Further improve message handling - Remove licence headers - Add test suite - Update pre-commit hooks - Address pre-commit issues - Prepare pylint integration - Support python 3.8-3.10 in pre-commit hooks - Refactor protocol._process_msg() message parsing - Refactor protocol.active into a function - Convert protocol.setup_watchdog() and protocol.set_update_cb() to synchronous functions - Don't use loop.create_task() for task.cancel() calls - Change hex values logging to uppercase - Fix get_reports() firmware versions for commands - Poll GPIO states only when either GPIO has mode 0 (input) - Fix attempt_connect() return value - Handle non-responsive gateway serial connection (fixes #30) - Increase retry_timeout with every unsuccessful connection attempt up to MAX_RETRY_TIMEOUT - Remove loop arguments that were deprecated in python 3.8, removed in python 3.10 (fixes #29) - Small fixes and optimizations ### 1.1b1 - Add features and their documentation for firmware v5.0 - Add support for firmware 5.0 (#27) - SerialTransport.write() does not return amount of bytes written ### 1.0b2 - Fix for OpenTherm Gateway 4.2.8.1 - Change log level and message for E* messages from the gateway. ### 1.0b1 - Copy DEFAULT_STATUS instead of editing it - Update README.md ### 1.0b0 - Avoid sending updates twice for related changes to status (#22) - Separate thermostat, boiler and otgw status (#20) ### 0.6b1 - Improve connection routine - Cleanup after unfinished connection attempt in disconnect() - Make SerialTransport.write() non-blocking - Add debug output to write operations - Add Python version to setup.py (#15) - Add Travis to the repository (#14) ### 0.6b0 - Send empty report on connection loss - Fix debug output for python <3.8 - Add debug logging to watchdog - Fix commands while not connected. - Add pre-commit and use several linters to ensure a consistent code style (#12) ### 0.5b1 - Fix iSense quirk handling bug - Improve disconnect handling, add more debug logging. ### 0.5b0 - Add pyotgw.disconnect() method. ### 0.4b4 - Fix bug during disconnect handling (#7) - Remove unused import - Improve log messages - Add more debug logging - Flake8 fixes - Only set status[DATA_ROOM_SETPOINT_OVRD] immediately if no iSense thermostat is detected - Put copies of status dict in update queue ### 0.4b3 - Work around iSense quirk with MSG_TROVRD (ID 9) - Make _process_msg async. - Improve queue emptying - don't try-except QueueEmpty - Move special message processing from _dissect_msg to _process_msg - Update expect regex for command processing. - Fix false clearing of room setpoint override if boiler supports MSG_TROVRD. - Deal with DecodeErrors on received data. ### 0.4b2 - Update setup.py with new filename for README.md - Improve connection establishing routine. - Use a while loop instead of recursion on connect(). - Fix broken reconnect logic (#5) - Updated documentation - Renamed pyotgw.send_report() to pyotgw._send_report as it is only used internally. - Renamed arguments to not-implemented functions. - Rename README to README.md ### 0.4b1 - Fix 100% CPU issue when able to connect but not receiving data - Add Lock to _inform_watchdog to prevent losing track of watchdog tasks due to concurrent calls - Improve handling of PR commands ### 0.4b0 - Improved connection error handling - Fixed leaked Tasks during reconnect - Add and remove some debug logging - Fix callback listeners after reconnect. - Move reporting system from protocol to pyotgw. - Handle disconnects and reconnect automatically - Retry commands up to 3 times - Change ensure_future to create_task where appropriate - Some match changes (is to ==) ### 0.3b1. - Fix a bug where manual action after remote override would not be detected properly by the library. ### 0.3b0 - Ignore 'A' messages as they cause trouble and are there just to keep the thermostat happy. - Fix bug in set_control_setpoint, now cast correctly. - Keep the status dict for ourselves, provide copies to clients. - Improved error handling and messages. - Streamline status updates - Fix bug when clearing some variables - Fix flake findings - Remove date from status updates - Fix configuring GPIOs and add polling for their state (no push available). - Fix reset command. - Use logging instead of print(). - Fix calling methods/properties before connect(). - Improve command error handling. - Update import syntax and order. - Update README - Various bugfixes. ### 0.2b1 - Move handling of status values updates to dedicated functions - Rename CH_BURNER vars to BURNER to reflect their actual meaning - Fix data types for vars (cast to type where needed) ### 0.2b0 - General code cleanup and some rewrites. - Syntax corrections according flake8. - Update README - Fixed a small bug with room setpoint override - Promoted to beta release - Updated setup.py with github url - Renamed license file - Added docstrings to functions and classes - Fixed a bug with the Remote Override functionality - Some fixes and additions - Implemented more commands, improved loop handling ### 0.1a0 - Initial commit, monitoring support and some commands implemented pyotgw-2.2.2/LICENSE000066400000000000000000001045151470422227100140620ustar00rootroot00000000000000 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 . pyotgw-2.2.2/README.md000066400000000000000000000702711470422227100143350ustar00rootroot00000000000000[![Build Status](https://travis-ci.org/mvn23/pyotgw.svg?branch=master)](https://travis-ci.org/mvn23/pyotgw) # pyotgw A python library to interface with the OpenTherm Gateway See http://otgw.tclcode.com for the hardware. This library is written primarily for use with Home Assistant (https://www.home-assistant.io) but can be used for other purposes as well. Parts of the code have not been thoroughly tested since my thermostat and boiler do not support all OpenTherm features. Feel free to test and contribute where you see fit. #### Contents - [Library Reference](#library-reference) - [General](#general) - [Getting Data](#getting-data) - [Methods](#methods) - [Usage Example](#usage-example) - [Development](#development) - [Status Dict Structure](#status-dict-structure) ### Library Reference #### General pyotgw exposes its OpenThermGateway class which uses [pyserial-asyncio](https://pyserial-asyncio.readthedocs.io/en/latest/) to connect to the OpenTherm Gateway. After initialization of the object, `OpenThermGateway.connect()` should be used to establish a connection. The object will maintain the connection in the background, using it to send commands and continuously receive updates. The received information will be cached on the object for instant availability. The OpenThermGateway object implements a watchdog to monitor the connection for inactivity. During `OpenThermGateway.connect()`, an inactivity timeout can be set for this purpose. Normally, the OpenTherm Gateway will send a message on its serial interface approximately every second. If no messages are received for the duration of the timeout, the watchdog will trigger a reconnection attempt. #### Getting Data There are multiple ways to get information from pyotgw. Calling `OpenThermGateway.connect()` will request some initial information from the Gateway and return it in a dict. After this, the OpenThermGateway object exposes quite a few methods which return values that are cached on the object. There is also the option to register a callback with `OpenThermGateway.subscribe()` which will be called when any value changes. #### Methods --- ##### OpenThermGateway() The OpenThermGateway constructor takes no arguments and returns an empty OpenThermGateway object. --- ##### OpenThermGateway.add_alternative(_self_, alt, timeout=OTGW_DEFAULT_TIMEOUT) Add the specified data-ID to the list of alternative commands to send to the boiler instead of a data-ID that is known to be unsupported by the boiler. Alternative data-IDs will always be sent to the boiler in a Read-Data request message with the data-value set to zero. The table of alternative data-IDs is stored in non-volatile memory so it will persist even if the gateway has been powered off. This method supports the following arguments: - __alt__ The alternative data-ID to add. Values from 1 to 255 are allowed. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the ID that was added to the list, or None on failure. This method is a coroutine. --- ##### OpenThermGateway.add_unknown_id(_self_, unknown_id, timeout=OTGW_DEFAULT_TIMEOUT) Inform the gateway that the boiler doesn't support the specified data-ID, even if the boiler doesn't indicate that by returning an `unknown-dataID` response. Using this command allows the gateway to send an alternative data-ID to the boiler instead. This method supports the following arguments: - __unknown_id__ The data-ID to mark as unsupported. Values from 1 to 255 are allowed. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the added ID, or None on failure. This method is a coroutine. --- ##### OpenThermGateway.connect(_self_, port, timeout=5, skip_init=None) Connect to an OpenTherm Gateway and initializes the parameters obtained from the `PS` and `PR` commands. If called while connected, reconnect to the gateway. All optional serial-related arguments default to the OpenTherm Gateway default settings. This method supports the following arguments: - __port__ The port/url on which the OpenTherm Gateway can be reached as supported by [pyserial](https://pythonhosted.org/pyserial/url_handlers.html). - __timeout__ The inactivity timeout in seconds after which the watchdog will trigger a reconnect. Defaults to 5. - __skip_init__ If set to True, the PS= and PR= commands are skipped and only PS=0 is sent upon the current and future connection attempts. Defaults to None, which keeps the last known setting. Returns a status dict with all known values. This method is a coroutine. --- ##### OpenThermGateway.disconnect(_self_) Disconnect from the OpenTherm Gateway and clean up the object. This method is a coroutine. --- ##### OpenThermGateway.del_alternative(_self_, alt, timeout=OTGW_DEFAULT_TIMEOUT) Remove the specified data-ID from the list of alternative commands. Only one occurrence is deleted. If the data-ID appears multiple times in the list of alternative commands, this command must be repeated to delete all occurrences. The table of alternative data-IDs is stored in non-volatile memory so it will persist even if the gateway has been powered off. This method supports the following arguments: - __alt__ The alternative data-ID to remove. Values from 1 to 255 are allowed. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the ID that was removed from the list, or None on failure. This method is a coroutine. --- ##### OpenThermGateway.del_unknown_id(_self_, unknown_id, timeout=OTGW_DEFAULT_TIMEOUT) Start forwarding the specified Data-ID to the boiler again. This command resets the counter used to determine if the specified data-ID is supported by the boiler. This method supports the following arguments: - __unknown_id__ The data-ID to mark as supported. Values from 1 to 255 are allowed. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Return the ID that was marked as supported, or None on failure. This method is a coroutine. --- ##### OpenThermGateway.get_reports(_self_) Update the OpenThermGateway object with the information from all of the `PR` commands. This method is also called from `OpenThermGateway.connect()` to populate the status dict with initial values. Returns the full updated status dict. This method is a coroutine. --- ##### OpenThermGateway.get_status(_self_) Update the OpenThermGateway object with the information from the `PS` command. This method is also called from `OpenThermGateway.connect()` to populate the status dict with initial values. Returns the full updated status dict. This method is a coroutine. --- ##### OpenThermGateway.set_ch_enable_bit(_self_, ch_bit, timeout=OTGW_DEFAULT_TIMEOUT) Set or unset the `Central Heating Enable` bit. Control the CH enable status bit when overriding the control setpoint. By default the CH enable bit is set after a call to `OpenThermGateway.set_control_setpoint()` with a value other than 0. With this method, the bit can be manipulated. This method supports the following arguments: - __ch_bit__ The new value for the `Central Heating Enable` bit. Can be either `0` or `1`. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Return the newly accepted value (`0` or `1`), or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_ch2_enable_bit(_self_, ch_bit, timeout=OTGW_DEFAULT_TIMEOUT) Set or unset the `Central Heating Enable` bit for heating circuit 2. Control the CH enable status bit when overriding the control setpoint. By default the CH enable bit is set after a call to `OpenThermGateway.set_control_setpoint()` with a value other than 0. With this method, the bit can be manipulated. This method supports the following arguments: - __ch_bit__ The new value for the `Central Heating Enable` bit. Can be either `0` or `1`. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Return the newly accepted value (`0` or `1`), or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_clock(_self_, date=datetime.now(), timeout=OTGW_DEFAULT_TIMEOUT) Set the clock on the thermostat. Change the time and day of the week of the thermostat. The gateway will send the specified time and day of the week in response to the next time and date message from the thermostat. This method supports the following arguments: - __date__ A datetime object containing the time and day of the week to be sent to the thermostat. Defaults to `datetime.now()`. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the accepted response from the gateway with format `HH:MM/DOW`, where DOW is a single digit: 1=Monday, 7=Sunday, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_connection_options(_self_, **kwargs) Set the serial connection parameters before calling connect(). Valid kwargs are 'baudrate', 'bytesize', 'parity' and 'stopbits'. Returns True on success, False on fail or if already connected. For more information on the kwargs see the pyserial documentation. --- ##### OpenThermGateway.set_control_setpoint(_self_, setpoint, timeout=OTGW_DEFAULT_TIMEOUT) Set the control setpoint. The control setpoint is the target temperature for the water in the central heating system. This method will cause the OpenTherm Gateway to manipulate the control setpoint which is sent to the boiler. Set the control setpoint to `0` to pass along the value specified by the thermostat. This method supports the following arguments: - __setpoint__ The new control setpoint. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the newly accepted value, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_control_setpoint_2(_self_, setpoint, timeout=OTGW_DEFAULT_TIMEOUT) Set the control setpoint for central heating circuit 2. The control setpoint is the target temperature for the water in the central heating system. This method will cause the OpenTherm Gateway to manipulate the control setpoint which is sent to the boiler. Set the control setpoint to `0` to pass along the value specified by the thermostat. This method supports the following arguments: - __setpoint__ The new control setpoint. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the newly accepted value, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_dhw_setpoint(_self_, temperature, timeout=OTGW_DEFAULT_TIMEOUT) Set the domestic hot water setpoint. The domestic hot water setpoint is the target temperature for the hot water system. Not all boilers support this command. This method supports the following arguments: - __temperature__ The new domestic hot water setpoint. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the newly accepted setpoint, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_gpio_mode(_self_, gpio_id, mode, timeout=OTGW_DEFAULT_TIMEOUT) Configure the functions of the two GPIO pins of the gateway. Possible modes are: - __0.__ No function, default for both ports on a freshly flashed chip. - __1.__ Ground - A permanently low output (0V). Could be used for a power LED. - __2.__ Vcc - A permanently high output (5V). Can be used as a short-proof power supply for some external circuitry used by the other GPIO port. - __3.__ LED E - An additional LED if you want to present more than 4 LED functions. - __4.__ LED F - An additional LED if you want to present more than 5 LED functions. - __5.__ Home - Set thermostat to setback temperature when pulled low. - __6.__ Away - Set thermostat to setback temperature when pulled high. - __7.__ DS1820 (GPIO port B only) - Data line for a DS18S20 or DS18B20 temperature sensor used to measure the outside temperature. A 4k7 resistor should be connected between GPIO port B and Vcc. This method supports the following arguments: - __gpio_id__ The GPIO pin on which the mode is set. Either `A` or `B`. - __mode__ The requested mode for the GPIO pin. Values from `0` to `7` are supported (`7` only for GPIO `B`). - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the new mode for the specified gpio, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_hot_water_ovrd(_self_, state, timeout=OTGW_DEFAULT_TIMEOUT) Control the domestic hot water enable option. If the boiler has been configured to let the room unit control when to keep a small amount of water preheated, this option can influence that. A state of `0` or `1` will override the domestic hot water option `off` or `on` respectively. Any other single character disables the override and resumes normal operation. This method supports the following arguments: - __state__ The requested state for the domestic hot water option. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the accepted value, `A` if the override is disabled or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_led_mode(_self_, led_id, mode, timeout=OTGW_DEFAULT_TIMEOUT) Set the mode of one of the LEDs. Configure the functions of the six LEDs (A-F) that can optionally be connected to pins RB3/RB4/RB6/RB7 and the GPIO pins of the PIC. Possible modes are: - __R__ Receiving an Opentherm message from the thermostat or boiler - __X__ Transmitting an Opentherm message to the thermostat or boiler - __T__ Transmitting or receiving a message on the master interface - __B__ Transmitting or receiving a message on the slave interface - __O__ Remote setpoint override is active - __F__ Flame is on - __H__ Central heating is on - __W__ Hot water is on - __C__ Comfort mode (Domestic Hot Water Enable) is on - __E__ Transmission error has been detected - __M__ Boiler requires maintenance - __P__ Raised power mode active on thermostat interface. This method supports the following arguments: - __led_id__ The LED for which the mode is set. Must be a character in the range `A-F`. - __mode__ The requested state for the LED. Must be one of `R`, `X`, `T`, `B`, `O`, `F`, `H`, `W`, `C`, `E`, `M` or `P`. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the new mode for the specified LED, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_max_ch_setpoint(_self_, temperature, timeout=OTGW_DEFAULT_TIMEOUT) Set the maximum central heating water setpoint. Not all boilers support this option. This method supports the following arguments: - __temperature__ The new maximum central heating water setpoint. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the newly accepted setpoint, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_max_relative_mod(_self_, max_mod, timeout=OTGW_DEFAULT_TIMEOUT) Set the maximum relative modulation level. Override the maximum relative modulation from the thermostat. Valid values are 0 through 100. Clear the setting by specifying a non-numeric value. This method supports the following arguments: - __temperature__ The new maximum central heating water setpoint. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the newly accepted value, `-` if a previous value was cleared, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_mode(_self_, mode, timeout=OTGW_DEFAULT_TIMEOUT) Set the operating mode of the gateway. The operating mode can be either `gateway` or `monitor` mode. This method can also be used to reset the OpenTherm Gateway. This method supports the following arguments: - __mode__ The mode to be set on the gateway. Can be `0` or `OTGW_MODE_MONITOR` for `monitor` mode, `1` or `OTGW_MODE_GATEWAY` for `gateway mode, or `OTGW_MODE_RESET` to reset the gateway. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Return the newly activated mode, or the full renewed status dict after a reset. This method is a coroutine. --- ##### OpenThermGateway.set_outside_temp(_self_, temp, timeout=OTGW_DEFAULT_TIMEOUT) Set the outside temperature. Configure the outside temperature to send to the thermostat. Allowed values are between -40.0 and +64.0, although thermostats may not display the full range. Specify a value above 64 (suggestion: 99) to clear a previously configured value. This method supports the following arguments: - __temp__ The outside temperature to provide to the gateway. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the accepted value on success, `-` if a previously configured value has been cleared or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_setback_temp(_self_, sb_temp, timeout=OTGW_DEFAULT_TIMEOUT) Set the setback temperature. Configure the setback temperature to use in combination with the GPIO functions `home`(5) and `away`(6). This method supports the following arguments: - __sb_temp__ The new setback temperature. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the new setback temperature, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_target_temp(_self_, temp, temporary=True, timeout=OTGW_DEFAULT_TIMEOUT) Set the room setpoint. Configure the thermostat setpoint and specify whether or not it may be overridden by a programmed change. This method supports the following arguments: - __temp__ The new room setpoint. Will be formatted to 1 decimal. - __temporary__ Whether or not the thermostat program may override the room setpoint. Either `True` or `False`. Defaults to `True`. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the newly accepted room setpoint, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_temp_sensor_function(_self_, func, timeout=v.OTGW_DEFAULT_TIMEOUT): Set the function of the temperature sensor that can be attached to the gateway. This method supports the following arguments: - __func__ The new temperature sensor function. Either `O` for `Outside Air Temperature` or `R` for `Return Water Temperature`. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Returns the newly accepted temperature sensor function or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.set_ventilation(_self_, pct, timeout=OTGW_DEFAULT_TIMEOUT) Set the ventilation setpoint. Configure a ventilation setpoint override value (0-100%). This method supports the following arguments: - __pct__ The new ventilation setpoint. Must be between `0` and `100`. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). Return the newly accepted value, or `None` on failure. This method is a coroutine. --- ##### OpenThermGateway.send_transparent_command(_self_, cmd, state, timeout=OTGW_DEFAULT_TIMEOUT) Send a transparent command. Sends custom commands through a transparent interface. Check https://otgw.tclcode.com/firmware.html for supported commands. This method supports the following arguments: - __cmd__ The supported command e.g. `SC` (set time/day). - __state__ The command argument e.g. `23:59/4` (the current time/day) Returns the gateway response, which should be equal __state__. This method is a coroutine. --- ##### OpenThermGateway.subscribe(_self_, coro) Subscribe to status updates from the Opentherm Gateway. The subscribed coroutine must have the following signature: ``` async def coro(status) ``` Where `status` will be the full status dict containing the last known information from the OpenTherm Gateway. This method supports the following arguments: - __coro__ A coroutine which will be called whenever a status change occurs. Returns `True` on success, `False` if the coroutine is already subscribed. --- ##### OpenThermGateway.unsubscribe(_self_, coro) Unsubscribe from status updates from the Opentherm Gateway. The supplied coroutine must have been subscribed with `OpenThermGateway.subscribe()` before. This method supports the following arguments: - __coro__ The coroutine which will be unsubscribed. Returns `True` on success, `False` if the coroutine was not subscribed before. --- ### Usage Example ```python import asyncio from pyotgw import OpenThermGateway PORT = '/dev/ttyUSB0' async def print_status(status): """Receive and print status.""" print("Received a status update:\n{}".format(status)) async def connect_and_subscribe(): """Connect to the OpenTherm Gateway and subscribe to status updates.""" # Create the object gw = OpenThermGateway() # Connect to OpenTherm Gateway on PORT status = await gw.connect(PORT) print("Initial status after connecting:\n{}".format(status)) # Subscribe to updates from the gateway if not gw.subscribe(print_status): print("Could not subscribe to status updates.") # Keep the event loop alive... while True: await asyncio.sleep(1) # Run the connect_and_subscribe coroutine. try: asyncio.run(connect_and_subscribe()) except KeyboardInterrupt: print("Exiting") ``` ### Development We use pre-commit to ensure a consistent code style, so `pip install pre_commit` and run ``` pre-commit install ``` in the repository. ### Status Dict Structure The full possible status dict with some example values looks like below. Note that not all keys will always be present and that the presence of a key does not guarantee that it contains useful information. ```python { vars.BOILER: { vars.DATA_CH_PUMP_HOURS: 15010, vars.DATA_CH_PUMP_STARTS: 43832, vars.DATA_CH_WATER_PRESS: 0.0, vars.DATA_CH_WATER_TEMP: 47.2, vars.DATA_CH_WATER_TEMP_2: 0.0, vars.DATA_CONTROL_SETPOINT: 44.0, vars.DATA_CONTROL_SETPOINT_2: 0.0, vars.DATA_COOLING_CONTROL: 0, vars.DATA_DHW_BURNER_HOURS: 411, vars.DATA_DHW_BURNER_STARTS: 34296, vars.DATA_DHW_FLOW_RATE: 0.0, vars.DATA_DHW_PUMP_HOURS: 250, vars.DATA_DHW_PUMP_STARTS: 9424, vars.DATA_DHW_SETPOINT: 0.0, vars.DATA_DHW_TEMP: 0.0, vars.DATA_DHW_TEMP_2: 0.0, vars.DATA_EXHAUST_TEMP: 0, vars.DATA_MASTER_CH2_ENABLED: 0, vars.DATA_MASTER_CH_ENABLED: 1, vars.DATA_MASTER_COOLING_ENABLED: 0, vars.DATA_MASTER_DHW_ENABLED: 1, vars.DATA_MASTER_MEMBERID: 0, vars.DATA_MASTER_OTC_ENABLED: 0, vars.DATA_MASTER_OT_VERSION: 0.0, vars.DATA_MASTER_PRODUCT_TYPE: 0, vars.DATA_MASTER_PRODUCT_VERSION: 0, vars.DATA_MAX_CH_SETPOINT: 75.0, vars.DATA_OEM_DIAG: 0, vars.DATA_OUTSIDE_TEMP: 0.0, vars.DATA_REL_MOD_LEVEL: 0.0, vars.DATA_REMOTE_RW_DHW: 1, vars.DATA_REMOTE_RW_MAX_CH: 1, vars.DATA_REMOTE_TRANSFER_DHW: 1, vars.DATA_REMOTE_TRANSFER_MAX_CH: 1, vars.DATA_RETURN_WATER_TEMP: 0.0, vars.DATA_ROOM_SETPOINT: 20.0, vars.DATA_ROOM_SETPOINT_2: 0.0, vars.DATA_ROOM_SETPOINT_OVRD: 20.0, vars.DATA_ROOM_TEMP: 19.62, vars.DATA_ROVRD_AUTO_PRIO: 0, vars.DATA_ROVRD_MAN_PRIO: 1, vars.DATA_SLAVE_AIR_PRESS_FAULT: 0, vars.DATA_SLAVE_CH2_ACTIVE: 0, vars.DATA_SLAVE_CH2_PRESENT: 0, vars.DATA_SLAVE_CH_ACTIVE: 1, vars.DATA_SLAVE_CH_MAX_SETP: 75, vars.DATA_SLAVE_CH_MIN_SETP: 20, vars.DATA_SLAVE_CONTROL_TYPE: 1, vars.DATA_SLAVE_COOLING_ACTIVE: 0, vars.DATA_SLAVE_COOLING_SUPPORTED: 0, vars.DATA_SLAVE_DHW_ACTIVE: 0, vars.DATA_SLAVE_DHW_CONFIG: 0, vars.DATA_SLAVE_DHW_MAX_SETP: 60, vars.DATA_SLAVE_DHW_MIN_SETP: 40, vars.DATA_SLAVE_DHW_PRESENT: 1, vars.DATA_SLAVE_DIAG_IND: 0, vars.DATA_SLAVE_FAULT_IND: 0, vars.DATA_SLAVE_FLAME_ON: 1, vars.DATA_SLAVE_GAS_FAULT: 0, vars.DATA_SLAVE_LOW_WATER_PRESS: 0, vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: 0, vars.DATA_SLAVE_MAX_CAPACITY: 0, vars.DATA_SLAVE_MAX_RELATIVE_MOD: 100.0, vars.DATA_SLAVE_MEMBERID: 0, vars.DATA_SLAVE_MIN_MOD_LEVEL: 0, vars.DATA_SLAVE_OEM_FAULT: 0, vars.DATA_SLAVE_OT_VERSION: 0.0, vars.DATA_SLAVE_PRODUCT_TYPE: 0, vars.DATA_SLAVE_PRODUCT_VERSION: 0, vars.DATA_SLAVE_REMOTE_RESET: 0, vars.DATA_SLAVE_SERVICE_REQ: 0, vars.DATA_SLAVE_WATER_OVERTEMP: 0, vars.DATA_SOLAR_COLL_TEMP: 0.0, vars.DATA_SOLAR_STORAGE_TEMP: 0.0, vars.DATA_TOTAL_BURNER_HOURS: 0, vars.DATA_TOTAL_BURNER_STARTS: 0, }, vars.OTGW: { vars.OTGW_ABOUT: 'OpenTherm Gateway 4.2.5', vars.OTGW_BUILD: '17:59 20-10-2015', vars.OTGW_CLOCKMHZ: '4 MHz', vars.OTGW_DHW_OVRD: '1', vars.OTGW_GPIO_A: 0, vars.OTGW_GPIO_A_STATE: 0, vars.OTGW_GPIO_B: 0, vars.OTGW_GPIO_B_STATE: 0, vars.OTGW_IGNORE_TRANSITIONS: 1, vars.OTGW_LED_A: 'F', vars.OTGW_LED_B: 'X', vars.OTGW_LED_C: 'O', vars.OTGW_LED_D: 'M', vars.OTGW_LED_E: 'P', vars.OTGW_LED_F: 'C', vars.OTGW_MODE: 'G', vars.OTGW_OVRD_HB: 1, vars.OTGW_SB_TEMP: 16.0, vars.OTGW_SETP_OVRD_MODE: 'T', vars.OTGW_SMART_PWR: 'Low power', vars.OTGW_THRM_DETECT: 'D', vars.OTGW_VREF: 3, }, vars.THERMOSTAT: { vars.DATA_CH_PUMP_HOURS: 15010, vars.DATA_CH_PUMP_STARTS: 43832, vars.DATA_CH_WATER_PRESS: 0.0, vars.DATA_CH_WATER_TEMP: 47.2, vars.DATA_CH_WATER_TEMP_2: 0.0, vars.DATA_CONTROL_SETPOINT: 44.0, vars.DATA_CONTROL_SETPOINT_2: 0.0, vars.DATA_COOLING_CONTROL: 0, vars.DATA_DHW_BURNER_HOURS: 411, vars.DATA_DHW_BURNER_STARTS: 34296, vars.DATA_DHW_FLOW_RATE: 0.0, vars.DATA_DHW_PUMP_HOURS: 250, vars.DATA_DHW_PUMP_STARTS: 9424, vars.DATA_DHW_SETPOINT: 0.0, vars.DATA_DHW_TEMP: 0.0, vars.DATA_DHW_TEMP_2: 0.0, vars.DATA_EXHAUST_TEMP: 0, vars.DATA_MASTER_CH2_ENABLED: 0, vars.DATA_MASTER_CH_ENABLED: 1, vars.DATA_MASTER_COOLING_ENABLED: 0, vars.DATA_MASTER_DHW_ENABLED: 1, vars.DATA_MASTER_MEMBERID: 0, vars.DATA_MASTER_OTC_ENABLED: 0, vars.DATA_MASTER_OT_VERSION: 0.0, vars.DATA_MASTER_PRODUCT_TYPE: 0, vars.DATA_MASTER_PRODUCT_VERSION: 0, vars.DATA_MAX_CH_SETPOINT: 75.0, vars.DATA_OEM_DIAG: 0, vars.DATA_OUTSIDE_TEMP: 0.0, vars.DATA_REL_MOD_LEVEL: 0.0, vars.DATA_REMOTE_RW_DHW: 1, vars.DATA_REMOTE_RW_MAX_CH: 1, vars.DATA_REMOTE_TRANSFER_DHW: 1, vars.DATA_REMOTE_TRANSFER_MAX_CH: 1, vars.DATA_RETURN_WATER_TEMP: 0.0, vars.DATA_ROOM_SETPOINT: 20.0, vars.DATA_ROOM_SETPOINT_2: 0.0, vars.DATA_ROOM_SETPOINT_OVRD: 20.0, vars.DATA_ROOM_TEMP: 19.62, vars.DATA_ROVRD_AUTO_PRIO: 0, vars.DATA_ROVRD_MAN_PRIO: 1, vars.DATA_SLAVE_AIR_PRESS_FAULT: 0, vars.DATA_SLAVE_CH2_ACTIVE: 0, vars.DATA_SLAVE_CH2_PRESENT: 0, vars.DATA_SLAVE_CH_ACTIVE: 1, vars.DATA_SLAVE_CH_MAX_SETP: 75, vars.DATA_SLAVE_CH_MIN_SETP: 20, vars.DATA_SLAVE_CONTROL_TYPE: 1, vars.DATA_SLAVE_COOLING_ACTIVE: 0, vars.DATA_SLAVE_COOLING_SUPPORTED: 0, vars.DATA_SLAVE_DHW_ACTIVE: 0, vars.DATA_SLAVE_DHW_CONFIG: 0, vars.DATA_SLAVE_DHW_MAX_SETP: 60, vars.DATA_SLAVE_DHW_MIN_SETP: 40, vars.DATA_SLAVE_DHW_PRESENT: 1, vars.DATA_SLAVE_DIAG_IND: 0, vars.DATA_SLAVE_FAULT_IND: 0, vars.DATA_SLAVE_FLAME_ON: 1, vars.DATA_SLAVE_GAS_FAULT: 0, vars.DATA_SLAVE_LOW_WATER_PRESS: 0, vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: 0, vars.DATA_SLAVE_MAX_CAPACITY: 0, vars.DATA_SLAVE_MAX_RELATIVE_MOD: 100.0, vars.DATA_SLAVE_MEMBERID: 0, vars.DATA_SLAVE_MIN_MOD_LEVEL: 0, vars.DATA_SLAVE_OEM_FAULT: 0, vars.DATA_SLAVE_OT_VERSION: 0.0, vars.DATA_SLAVE_PRODUCT_TYPE: 0, vars.DATA_SLAVE_PRODUCT_VERSION: 0, vars.DATA_SLAVE_REMOTE_RESET: 0, vars.DATA_SLAVE_SERVICE_REQ: 0, vars.DATA_SLAVE_WATER_OVERTEMP: 0, vars.DATA_SOLAR_COLL_TEMP: 0.0, vars.DATA_SOLAR_STORAGE_TEMP: 0.0, vars.DATA_TOTAL_BURNER_HOURS: 0, vars.DATA_TOTAL_BURNER_STARTS: 0, } } ``` pyotgw-2.2.2/pyotgw/000077500000000000000000000000001470422227100144005ustar00rootroot00000000000000pyotgw-2.2.2/pyotgw/__init__.py000066400000000000000000000001601470422227100165060ustar00rootroot00000000000000"""The main pyotgw __init__ file""" from pyotgw.pyotgw import OpenThermGateway __all__ = ["OpenThermGateway"] pyotgw-2.2.2/pyotgw/commandprocessor.py000066400000000000000000000114051470422227100203310ustar00rootroot00000000000000"""OpenTherm Gateway command handler.""" import asyncio import logging import re from asyncio.queues import QueueFull from pyotgw import vars as v _LOGGER = logging.getLogger(__name__) class CommandProcessor: """OpenTherm Gateway command handler.""" def __init__( self, protocol, status_manager, ): """Initialise the CommandProcessor object.""" self.protocol = protocol self._lock = asyncio.Lock() self._cmdq = asyncio.Queue() self.status_manager = status_manager async def issue_cmd(self, cmd, value, retry=3): """ Issue a command, then await and return the return value. This method is a coroutine """ async with self._lock: if not self.protocol.connected: _LOGGER.debug("Serial transport closed, not sending command %s", cmd) return self.clear_queue() if isinstance(value, float): value = f"{value:.2f}" _LOGGER.debug("Sending command: %s with value %s", cmd, value) self.protocol.transport.write(f"{cmd}={value}\r\n".encode("ascii")) expect = self._get_expected_response(cmd, value) async def send_again(err): """Resend the command.""" nonlocal retry _LOGGER.warning("Command %s failed with %s, retrying...", cmd, err) retry -= 1 self.protocol.transport.write(f"{cmd}={value}\r\n".encode("ascii")) async def process(msg): """Process a possible response.""" _LOGGER.debug("Got possible response for command %s: %s", cmd, msg) if msg in v.OTGW_ERRS: # Some errors appear by themselves on one line. if retry == 0: raise v.OTGW_ERRS[msg] await send_again(msg) return if cmd == v.OTGW_CMD_MODE and value == "R": # Device was reset, msg contains build info while not re.match(r"OpenTherm Gateway \d+(\.\d+)*", msg): msg = await self._cmdq.get() return True match = re.match(expect, msg) if match: if match.group(1) in v.OTGW_ERRS: # Some errors are considered a response. if retry == 0: raise v.OTGW_ERRS[match.group(1)] await send_again(msg) return ret = match.group(1) if cmd == v.OTGW_CMD_SUMMARY and ret == "1": # Expects a second line part2 = await self._cmdq.get() ret = [ret, part2] return ret if re.match(r"Error 0[1-4]", msg): _LOGGER.warning( "Received %s. If this happens during a " "reset of the gateway it can be safely " "ignored.", msg, ) else: _LOGGER.warning("Unknown message in command queue: %s", msg) await send_again(msg) while True: msg = await self._cmdq.get() ret = await process(msg) if ret is not None: return ret def clear_queue(self): """Clear leftover messages from the command queue""" while not self._cmdq.empty(): _LOGGER.debug( "Clearing leftover message from command queue: %s", self._cmdq.get_nowait(), ) def submit_response(self, response): """Add a possible response to the command queue""" try: self._cmdq.put_nowait(response) _LOGGER.debug("Response submitted. Queue size: %d", self._cmdq.qsize()) except QueueFull: _LOGGER.error("Queue full, discarded message: %s", response) @staticmethod def _get_expected_response(cmd, value): """Return the expected response pattern""" if cmd == v.OTGW_CMD_REPORT: return rf"^{cmd}:\s*([A-Z]{{2}}|{value}=[^$]+)$" # OTGW_CMD_CONTROL_HEATING_2 and OTGW_CMD_CONTROL_SETPOINT_2 do not adhere # to the standard response format (: ) at the moment, but report # only the value. This will likely be fixed in the future, so we support # both formats. if cmd in ( v.OTGW_CMD_CONTROL_HEATING_2, v.OTGW_CMD_CONTROL_SETPOINT_2, ): return rf"^(?:{cmd}:\s*)?(0|1|[0-9]+\.[0-9]{{2}}|[A-Z]{{2}})$" return rf"^{cmd}:\s*([^$]+)$" pyotgw-2.2.2/pyotgw/connection.py000066400000000000000000000204011470422227100171060ustar00rootroot00000000000000""" Connection Manager for pyotgw. Everything related to making, maintaining and monitoring the connection to the gateway goes here. """ import asyncio import logging from functools import partial import serial import serial_asyncio_fast from pyotgw.protocol import OpenThermProtocol CONNECTION_TIMEOUT = 5 MAX_RETRY_TIMEOUT = 60 MIN_RETRY_TIMEOUT = 5 WATCHDOG_TIMEOUT = 3 _LOGGER = logging.getLogger(__name__) class ConnectionManager: # pylint: disable=too-many-instance-attributes """Functionality for setting up and tearing down a connection""" def __init__(self, otgw): """Initialise the connection manager""" self._error = None self._port = None self._retry_timeout = MIN_RETRY_TIMEOUT self._connecting_task = None self._config = { "baudrate": 9600, "bytesize": serial.EIGHTBITS, "parity": serial.PARITY_NONE, "stopbits": serial.STOPBITS_ONE, } self._otgw = otgw self._timeout = 10 self.watchdog = ConnectionWatchdog() self._transport = None self.protocol = None async def connect(self, port, timeout=None): """Start connection attempts. Return True on success or False on failure.""" if self.connected or self._connecting_task: # We are actually reconnecting, cleanup first. _LOGGER.debug("Reconnecting to serial device on %s", port) await self.disconnect() loop = asyncio.get_running_loop() self._port = port self._timeout = timeout if timeout else self._timeout self._connecting_task = loop.create_task(self._attempt_connect()) try: transport, protocol = await self._connecting_task except asyncio.CancelledError: return False finally: self._connecting_task = None self._error = None _LOGGER.debug("Connected to serial device on %s", port) self._transport = transport self.protocol = protocol self.watchdog.start(self.reconnect, timeout=timeout or WATCHDOG_TIMEOUT) return True async def disconnect(self): """Disconnect from the OpenTherm Gateway.""" await self._cleanup() if self.connected: self.protocol.disconnect() async def reconnect(self): """Reconnect to the OpenTherm Gateway.""" if not self._port: _LOGGER.error("Reconnect called before connect!") return _LOGGER.debug("Scheduling reconnect...") await self.disconnect() await self._otgw.connect(self._port) @property def connected(self): """Return the connection status""" return self.protocol and self.protocol.connected def set_connection_config(self, **kwargs): """ Set the serial connection parameters before calling connect() Valid kwargs are 'baudrate', 'bytesize', 'parity' and 'stopbits'. Returns True on success, False on fail or if already connected. For more information see the pyserial documentation. """ if self.connected: return False for arg in kwargs: if arg not in self._config: _LOGGER.error("Invalid connection parameter: %s", arg) return False self._config.update(kwargs) return True async def _attempt_connect(self): """Try to connect to the OpenTherm Gateway.""" loop = asyncio.get_running_loop() transport = None protocol = None self._retry_timeout = MIN_RETRY_TIMEOUT while transport is None: try: transport, protocol = await ( serial_asyncio_fast.create_serial_connection( loop, partial( OpenThermProtocol, self._otgw.status, self.watchdog.inform, ), self._port, write_timeout=0, **self._config, ) ) await asyncio.wait_for( protocol.init_and_wait_for_activity(), CONNECTION_TIMEOUT, ) return transport, protocol except asyncio.TimeoutError as err: if not isinstance(err, type(self._error)): _LOGGER.error( "The serial device on %s is not responding. " "Will keep trying.", self._port, ) self._error = err if protocol: await protocol.cleanup() except (serial.SerialException, OSError) as err: if not isinstance(err, type(self._error)): _LOGGER.error( "Could not connect to serial device on %s. " "Will keep trying. Reported error was: %s", self._port, err, ) self._error = err if protocol: await protocol.cleanup() except SyntaxError as err: # The gateway may throw a SyntaxError on the first initialization # attempt. if protocol: await protocol.cleanup() if isinstance(err, type(self._error)): raise self._error = err transport = None await asyncio.sleep(self._get_retry_timeout()) async def _cleanup(self): """Cleanup possible leftovers from old connections""" await self.watchdog.stop() if self.protocol: await self.protocol.cleanup() if self._connecting_task is not None: self._connecting_task.cancel() try: await self._connecting_task except asyncio.CancelledError: self._connecting_task = None def _get_retry_timeout(self): """Increase if needed and return the retry timeout.""" if self._retry_timeout == MAX_RETRY_TIMEOUT: return self._retry_timeout timeout = self._retry_timeout self._retry_timeout = min([self._retry_timeout * 1.5, MAX_RETRY_TIMEOUT]) return timeout class ConnectionWatchdog: """Connection watchdog""" def __init__(self): """Initialise the object""" self._callback = None self.timeout = WATCHDOG_TIMEOUT self._wd_task = None self._lock = asyncio.Lock() self.loop = asyncio.get_event_loop() @property def is_active(self): """Return watchdog status""" return self._wd_task is not None async def inform(self): """Reset the watchdog timer.""" async with self._lock: if not self.is_active: # Check within the Lock to deal with external stop() # calls with queued inform() tasks. return self._wd_task.cancel() try: await self._wd_task except asyncio.CancelledError: self._wd_task = self.loop.create_task(self._watchdog(self.timeout)) _LOGGER.debug("Watchdog timer reset!") def start(self, callback, timeout): """Start the watchdog, return boolean indicating success""" if self.is_active: return False self._callback = callback self.timeout = timeout self._wd_task = self.loop.create_task(self._watchdog(timeout)) return self.is_active async def stop(self): """Stop the watchdog""" async with self._lock: if not self.is_active: return _LOGGER.debug("Canceling Watchdog task.") self._wd_task.cancel() try: await self._wd_task except asyncio.CancelledError: self._wd_task = None async def _watchdog(self, timeout): """Trigger and cancel the watchdog after timeout. Schedule callback.""" await asyncio.sleep(timeout) _LOGGER.debug("Watchdog triggered!") self.loop.create_task(self._callback()) await self.stop() pyotgw-2.2.2/pyotgw/messageprocessor.py000066400000000000000000000170101470422227100203350ustar00rootroot00000000000000"""OpenTherm Protocol message handler""" import asyncio import logging import re import struct import pyotgw.messages as m import pyotgw.vars as v _LOGGER = logging.getLogger(__name__) class MessageProcessor: """ Process protocol messages and submit status updates. """ def __init__( self, command_processor, status_manager, ): """Initialise the protocol object.""" self._msgq = asyncio.Queue() self._task = asyncio.get_running_loop().create_task(self._process_msgs()) self.command_processor = command_processor self.status_manager = status_manager async def cleanup(self): """Empty the message queue and clean up running task.""" self.connection_lost() if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: self._task = None def connection_lost(self): """ Gets called when the connection to the gateway is lost. Tear down and clean up the object. """ while not self._msgq.empty(): self._msgq.get_nowait() def submit_matched_message(self, match): """Add a matched message to the processing queue.""" src, mtype, mid, msb, lsb = self._dissect_msg(match) if lsb is not None: self._msgq.put_nowait((src, mtype, mid, msb, lsb)) _LOGGER.debug( "Added line to message queue. Queue size: %d", self._msgq.qsize(), ) def _dissect_msg(self, match): """ Split messages into bytes and return a tuple of bytes. """ recvfrom = match.group(1) frame = bytes.fromhex(match.group(2)) if recvfrom == "E": _LOGGER.info( "The OpenTherm Gateway received an erroneous message." " This is not a bug in pyotgw. Ignoring: %s", frame.hex().upper(), ) return None, None, None, None, None msgtype = self._get_msgtype(frame[0]) if msgtype in (v.READ_ACK, v.WRITE_ACK, v.READ_DATA, v.WRITE_DATA): # Some info is best read from the READ/WRITE_DATA messages # as the boiler may not support the data ID. # Slice syntax is used to prevent implicit cast to int. data_id = frame[1:2] data_msb = frame[2:3] data_lsb = frame[3:4] return recvfrom, msgtype, data_id, data_msb, data_lsb return None, None, None, None, None @staticmethod def _get_msgtype(byte): """ Return the message type of Opentherm messages according to byte. """ return (byte >> 4) & 0x7 async def _process_msgs(self): """ Get messages from the queue and pass them to _process_msg(). Make sure we process one message at a time to keep them in sequence. """ while True: args = await self._msgq.get() _LOGGER.debug( "Processing: %s %02x %s %s %s", args[0], args[1], *[args[i].hex().upper() for i in range(2, 5)], ) await self._process_msg(args) async def _process_msg(self, message): """ Process message and update status variables where necessary. Add status to queue if it was changed in the process. """ ( src, mtype, msgid, msb, # pylint: disable=possibly-unused-variable lsb, # pylint: disable=possibly-unused-variable ) = message if msgid not in m.REGISTRY: return if src in "TA": part = v.THERMOSTAT else: # src in "BR" part = v.BOILER update = {} for action in m.REGISTRY[msgid][m.MSG_TYPE[mtype]]: update.update(await self._get_dict_update_for_action(action, locals())) if not update: return self.status_manager.submit_partial_update(part, update) async def _get_dict_update_for_action(self, action, env): """Return a partial dict update for message""" func = getattr(self, action[m.FUNC]) loc = locals() loc.update(env) args = (loc[arg] for arg in action[m.ARGS]) if asyncio.iscoroutinefunction(func): ret = await func(*args) else: ret = func(*args) ret = ret if isinstance(ret, list) else [ret] update = {} for var, val in zip(action[m.RETURNS], ret): if var is False: return {} if var is None: continue update.update({var: val}) return update async def _quirk_trovrd(self, part, src, msb, lsb): """Handle MSG_TROVRD with iSense quirk""" update = {} ovrd_value = self._get_f8_8(msb, lsb) if ovrd_value > 0: # iSense quirk: the gateway keeps sending override value # even if the thermostat has cancelled the override. if ( self.status_manager.status[v.OTGW].get(v.OTGW_THRM_DETECT) == "I" and src == "A" ): ovrd = await self.command_processor.issue_cmd( v.OTGW_CMD_REPORT, v.OTGW_REPORT_SETPOINT_OVRD ) match = re.match(r"^O=(N|[CT]([0-9]+.[0-9]+))$", ovrd, re.IGNORECASE) if not match: return if match.group(1) in "Nn": self.status_manager.delete_value(part, v.DATA_ROOM_SETPOINT_OVRD) return update[v.DATA_ROOM_SETPOINT_OVRD] = float(match.group(2)) else: update[v.DATA_ROOM_SETPOINT_OVRD] = ovrd_value self.status_manager.submit_partial_update(part, update) else: self.status_manager.delete_value(part, v.DATA_ROOM_SETPOINT_OVRD) async def _quirk_trset_s2m(self, part, msb, lsb): """Handle MSG_TRSET with gateway quirk""" # Ignore s2m messages on thermostat side as they are ALWAYS WriteAcks # but may contain invalid data. if part == v.THERMOSTAT: return self.status_manager.submit_partial_update( part, {v.DATA_ROOM_SETPOINT: self._get_f8_8(msb, lsb)} ) @staticmethod def _get_flag8(byte): """ Split a byte into a list of 8 bits (1/0). """ ret = [0, 0, 0, 0, 0, 0, 0, 0] byte = byte[0] for i in range(0, 8): ret[i] = byte & 1 byte = byte >> 1 return ret @staticmethod def _get_u8(byte): """ Convert a byte into an unsigned int. """ return struct.unpack(">B", byte)[0] @staticmethod def _get_s8(byte): """ Convert a byte into a signed int. """ return struct.unpack(">b", byte)[0] def _get_f8_8(self, msb, lsb): """ Convert 2 bytes into an OpenTherm f8_8 (float) value. """ return float(self._get_s16(msb, lsb) / 256) def _get_u16(self, msb, lsb): """ Convert 2 bytes into an unsigned int. """ buf = struct.pack(">BB", self._get_u8(msb), self._get_u8(lsb)) return int(struct.unpack(">H", buf)[0]) def _get_s16(self, msb, lsb): """ Convert 2 bytes into a signed int. """ buf = struct.pack(">bB", self._get_s8(msb), self._get_u8(lsb)) return int(struct.unpack(">h", buf)[0]) pyotgw-2.2.2/pyotgw/messages.py000066400000000000000000000370761470422227100165760ustar00rootroot00000000000000"""Data related to message processing""" from pyotgw import vars as v _GET_FLAG8 = "_get_flag8" _GET_FLOAT = "_get_f8_8" _GET_S16 = "_get_s16" _GET_S8 = "_get_s8" _GET_U16 = "_get_u16" _GET_U8 = "_get_u8" _LSB = "lsb" _MSB = "msb" ARGS = "args" FUNC = "function" M2S = "m2s" RETURNS = "returns" S2M = "s2m" MSG_TYPE = { v.READ_DATA: M2S, v.WRITE_DATA: M2S, v.READ_ACK: S2M, v.WRITE_ACK: S2M, } REGISTRY = { # MSG_ID = { # msg_type: [ # { # FUNC: "func_name", # ARGS: (f_args,), # RETURNS: (val_1, val_2, ..., val_n), # }, # ], # } v.MSG_STATUS: { M2S: [ { FUNC: _GET_FLAG8, ARGS: (_MSB,), RETURNS: ( v.DATA_MASTER_CH_ENABLED, v.DATA_MASTER_DHW_ENABLED, v.DATA_MASTER_COOLING_ENABLED, v.DATA_MASTER_OTC_ENABLED, v.DATA_MASTER_CH2_ENABLED, ), }, ], S2M: [ { FUNC: _GET_FLAG8, ARGS: (_LSB,), RETURNS: ( v.DATA_SLAVE_FAULT_IND, v.DATA_SLAVE_CH_ACTIVE, v.DATA_SLAVE_DHW_ACTIVE, v.DATA_SLAVE_FLAME_ON, v.DATA_SLAVE_COOLING_ACTIVE, v.DATA_SLAVE_CH2_ACTIVE, v.DATA_SLAVE_DIAG_IND, ), }, ], }, v.MSG_TSET: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_CONTROL_SETPOINT,), }, ], }, v.MSG_MCONFIG: { M2S: [{FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_MASTER_MEMBERID,)}], S2M: [], }, v.MSG_SCONFIG: { M2S: [], S2M: [ { FUNC: _GET_FLAG8, ARGS: (_MSB,), RETURNS: ( v.DATA_SLAVE_DHW_PRESENT, v.DATA_SLAVE_CONTROL_TYPE, v.DATA_SLAVE_COOLING_SUPPORTED, v.DATA_SLAVE_DHW_CONFIG, v.DATA_SLAVE_MASTER_LOW_OFF_PUMP, v.DATA_SLAVE_CH2_PRESENT, ), }, {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_MEMBERID,)}, ], }, v.MSG_COMMAND: {M2S: [], S2M: []}, v.MSG_ASFFLAGS: { M2S: [], S2M: [ { FUNC: _GET_FLAG8, ARGS: (_MSB,), RETURNS: ( v.DATA_SLAVE_SERVICE_REQ, v.DATA_SLAVE_REMOTE_RESET, v.DATA_SLAVE_LOW_WATER_PRESS, v.DATA_SLAVE_GAS_FAULT, v.DATA_SLAVE_AIR_PRESS_FAULT, v.DATA_SLAVE_WATER_OVERTEMP, ), }, {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_OEM_FAULT,)}, ], }, v.MSG_RBPFLAGS: { M2S: [], S2M: [ { FUNC: _GET_FLAG8, ARGS: (_MSB,), RETURNS: ( v.DATA_REMOTE_TRANSFER_DHW, v.DATA_REMOTE_TRANSFER_MAX_CH, ), }, { FUNC: _GET_FLAG8, ARGS: (_LSB,), RETURNS: ( v.DATA_REMOTE_RW_DHW, v.DATA_REMOTE_RW_MAX_CH, ), }, ], }, v.MSG_COOLING: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_COOLING_CONTROL,), }, ], }, v.MSG_TSETC2: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_CONTROL_SETPOINT_2,), }, ], }, v.MSG_TROVRD: { M2S: [], S2M: [ { FUNC: "_quirk_trovrd", ARGS: ( "part", "src", _MSB, _LSB, ), RETURNS: (False,), }, ], }, v.MSG_TSP: {M2S: [], S2M: []}, v.MSG_TSPIDX: {M2S: [], S2M: []}, v.MSG_FHBSIZE: {M2S: [], S2M: []}, v.MSG_FHBIDX: {M2S: [], S2M: []}, v.MSG_MAXRMOD: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_SLAVE_MAX_RELATIVE_MOD,), }, ], }, v.MSG_MAXCAPMINMOD: { M2S: [], S2M: [ {FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_MAX_CAPACITY,)}, {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_MIN_MOD_LEVEL,)}, ], }, v.MSG_TRSET: { M2S: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_ROOM_SETPOINT,), }, ], S2M: [ { FUNC: "_quirk_trset_s2m", ARGS: ( "part", _MSB, _LSB, ), RETURNS: (False,), }, ], }, v.MSG_RELMOD: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_REL_MOD_LEVEL,), }, ], }, v.MSG_CHPRESS: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_CH_WATER_PRESS,), }, ], }, v.MSG_DHWFLOW: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_FLOW_RATE,), }, ], }, v.MSG_TIME: {M2S: [], S2M: []}, v.MSG_DATE: {M2S: [], S2M: []}, v.MSG_YEAR: {M2S: [], S2M: []}, v.MSG_TRSET2: { M2S: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_ROOM_SETPOINT_2,), }, ], S2M: [], }, v.MSG_TROOM: { M2S: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_ROOM_TEMP,), } ], S2M: [], }, v.MSG_TBOILER: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_CH_WATER_TEMP,), }, ], }, v.MSG_TDHW: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_TEMP,), } ], }, v.MSG_TOUTSIDE: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_OUTSIDE_TEMP,), }, ], }, v.MSG_TRET: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_RETURN_WATER_TEMP,), }, ], }, v.MSG_TSTOR: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_SOLAR_STORAGE_TEMP,), }, ], }, v.MSG_TCOLL: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_SOLAR_COLL_TEMP,), }, ], }, v.MSG_TFLOWCH2: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_CH_WATER_TEMP_2,), }, ], }, v.MSG_TDHW2: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_TEMP_2,), } ], }, v.MSG_TEXHAUST: { M2S: [], S2M: [ { FUNC: _GET_S16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_EXHAUST_TEMP,), } ], }, v.MSG_TDHWSETUL: { M2S: [], S2M: [ {FUNC: _GET_S8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_DHW_MAX_SETP,)}, {FUNC: _GET_S8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_DHW_MIN_SETP,)}, ], }, v.MSG_TCHSETUL: { M2S: [], S2M: [ {FUNC: _GET_S8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_CH_MAX_SETP,)}, {FUNC: _GET_S8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_CH_MIN_SETP,)}, ], }, v.MSG_OTCCURVEUL: {M2S: [], S2M: []}, v.MSG_TDHWSET: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_SETPOINT,), }, ], }, v.MSG_MAXTSET: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_MAX_CH_SETPOINT,), }, ], }, v.MSG_OTCCURVE: {M2S: [], S2M: []}, v.MSG_STATUSVH: { M2S: [ { FUNC: _GET_FLAG8, ARGS: (_MSB,), RETURNS: ( v.DATA_VH_MASTER_VENT_ENABLED, v.DATA_VH_MASTER_BYPASS_POS, v.DATA_VH_MASTER_BYPASS_MODE, v.DATA_VH_MASTER_FREE_VENT_MODE, ), }, ], S2M: [ { FUNC: _GET_FLAG8, ARGS: (_LSB,), RETURNS: ( v.DATA_VH_SLAVE_FAULT_INDICATE, v.DATA_VH_SLAVE_VENT_MODE, v.DATA_VH_SLAVE_BYPASS_STATUS, v.DATA_VH_SLAVE_BYPASS_AUTO_STATUS, v.DATA_VH_SLAVE_FREE_VENT_STATUS, None, v.DATA_VH_SLAVE_DIAG_INDICATE, ), }, ], }, v.MSG_RELVENTPOS: { M2S: [{FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_VH_CONTROL_SETPOINT,)}], S2M: [], }, v.MSG_RELVENT: { M2S: [], S2M: [{FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_VH_RELATIVE_VENT,)}], }, v.MSG_ROVRD: { M2S: [], S2M: [ { FUNC: _GET_FLAG8, ARGS: (_LSB,), RETURNS: ( v.DATA_ROVRD_MAN_PRIO, v.DATA_ROVRD_AUTO_PRIO, ), }, ], }, v.MSG_OEMDIAG: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_OEM_DIAG,), } ], }, v.MSG_BURNSTARTS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_TOTAL_BURNER_STARTS,), }, ], }, v.MSG_CHPUMPSTARTS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_CH_PUMP_STARTS,), }, ], }, v.MSG_DHWPUMPSTARTS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_PUMP_STARTS,), }, ], }, v.MSG_DHWBURNSTARTS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_BURNER_STARTS,), }, ], }, v.MSG_BURNHRS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_TOTAL_BURNER_HOURS,), }, ], }, v.MSG_CHPUMPHRS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_CH_PUMP_HOURS,), } ], }, v.MSG_DHWPUMPHRS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_PUMP_HOURS,), }, ], }, v.MSG_DHWBURNHRS: { M2S: [], S2M: [ { FUNC: _GET_U16, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_DHW_BURNER_HOURS,), }, ], }, v.MSG_OTVERM: { M2S: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_MASTER_OT_VERSION,), }, ], S2M: [], }, v.MSG_OTVERS: { M2S: [], S2M: [ { FUNC: _GET_FLOAT, ARGS: ( _MSB, _LSB, ), RETURNS: (v.DATA_SLAVE_OT_VERSION,), }, ], }, v.MSG_MVER: { M2S: [ {FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_MASTER_PRODUCT_TYPE,)}, {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_MASTER_PRODUCT_VERSION,)}, ], S2M: [], }, v.MSG_SVER: { M2S: [], S2M: [ {FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_PRODUCT_TYPE,)}, {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_PRODUCT_VERSION,)}, ], }, } pyotgw-2.2.2/pyotgw/protocol.py000066400000000000000000000105471470422227100166220ustar00rootroot00000000000000"""Asyncio protocol implementation for pyotgw""" import asyncio import logging import re from pyotgw import vars as v from pyotgw.commandprocessor import CommandProcessor from pyotgw.messageprocessor import MessageProcessor _LOGGER = logging.getLogger(__name__) class OpenThermProtocol( asyncio.Protocol ): # pylint: disable=too-many-instance-attributes """ Implementation of the Opentherm Gateway protocol to be used with asyncio connections. """ def __init__( self, status_manager, activity_callback, ): """Initialise the protocol object.""" self.transport = None self._readbuf = b"" self._received_lines = 0 self.activity_callback = activity_callback self.command_processor = CommandProcessor( self, status_manager, ) self._connected = False self.message_processor = MessageProcessor( self.command_processor, status_manager, ) self.status_manager = status_manager def connection_made(self, transport): """Gets called when a connection to the gateway is established.""" self.transport = transport self._received_lines = 0 self._connected = True def connection_lost(self, exc): """ Gets called when the connection to the gateway is lost. Tear down and clean up the protocol object. """ if self.active and self.connected: _LOGGER.error("Disconnected: %s", exc) self._received_lines = 0 self._connected = False self.command_processor.clear_queue() self.message_processor.connection_lost() self.status_manager.reset() @property def connected(self): """Return the connection status""" return self._connected async def cleanup(self): """Clean up""" self.disconnect() await self.message_processor.cleanup() def disconnect(self): """Disconnect gracefully.""" if self.transport.is_closing() or not self.connected: return self._connected = False self.transport.close() def data_received(self, data): """ Gets called when new data is received on the serial interface. Perform line buffering and call line_received() with complete lines. """ # DIY line buffering... newline = b"\r\n" eot = b"\x04" self._readbuf += data while newline in self._readbuf: line, _, self._readbuf = self._readbuf.partition(newline) if line: if eot in line: # Discard everything before EOT _, _, line = line.partition(eot) try: decoded = line.decode("ascii") except UnicodeDecodeError: _LOGGER.debug("Invalid data received, ignoring...") return self.line_received(decoded) def line_received(self, line): """ Gets called by data_received() when a complete line is received. Inspect the received line and process or queue accordingly. """ self._received_lines += 1 _LOGGER.debug("Received line %d: %s", self._received_lines, line) if self.activity_callback: asyncio.create_task(self.activity_callback()) pattern = r"^(T|B|R|A|E)([0-9A-F]{8})$" msg = re.match(pattern, line) if msg: self.message_processor.submit_matched_message(msg) elif re.match(r"^[0-9A-F]{1,8}$", line) and self._received_lines == 1: # Partial message on fresh connection. Ignore. self._received_lines = 0 _LOGGER.debug("Ignoring line: %s", line) else: _LOGGER.debug( "Submitting line %d to CommandProcessor", self._received_lines, ) self.command_processor.submit_response(line) @property def active(self): """Indicate that we have seen activity on the serial line.""" return self._received_lines > 0 async def init_and_wait_for_activity(self): """Wait for activity on the serial connection.""" await self.command_processor.issue_cmd(v.OTGW_CMD_SUMMARY, 0, retry=1) while not self.active: await asyncio.sleep(0) pyotgw-2.2.2/pyotgw/pyotgw.py000066400000000000000000001100311470422227100162770ustar00rootroot00000000000000"""pyotgw is a library to interface with the OpenTherm Gateway.""" import asyncio import logging from datetime import datetime from pyotgw import vars as v from pyotgw.connection import ConnectionManager from pyotgw.status import StatusManager _LOGGER = logging.getLogger(__name__) class OpenThermGateway: # pylint: disable=too-many-public-methods """Main OpenThermGateway object abstraction""" def __init__(self): """Create an OpenThermGateway object.""" self._transport = None self._protocol = None self._gpio_task = None self._skip_init = False self.status = StatusManager() self.connection = ConnectionManager(self) async def cleanup(self): """Clean up tasks.""" await self.connection.disconnect() await self.status.cleanup() if self._gpio_task: self._gpio_task.cancel() await self._gpio_task async def connect( self, port, timeout=None, skip_init=None, ): """ Connect to Opentherm Gateway at @port. Initialize the parameters obtained from the PS= and PR= commands and returns the status dict with the obtained values or False if cancelled. If @skip_init is True, the PS= and PR= commands are skipped and only PS=0 is sent upon this and future connection attempts. If called while connected, reconnect to the gateway. This method is a coroutine """ if skip_init is not None: self._skip_init = skip_init if not await self.connection.connect(port, timeout): return False self._protocol = self.connection.protocol if not self._skip_init: await self.get_reports() await self.get_status() await self._poll_gpio() return self.status.status async def disconnect(self): """Disconnect from the OpenTherm Gateway.""" await self.cleanup() return await self.connection.disconnect() def set_connection_options(self, **kwargs): """Set connection parameters.""" return self.connection.set_connection_config(**kwargs) async def set_target_temp( self, temp, temporary=True, timeout=v.OTGW_DEFAULT_TIMEOUT ): """ Set the thermostat setpoint and return the newly accepted value. kwarg @temporary specifies whether or not the thermostat program may override this temperature. This method is a coroutine """ cmd = v.OTGW_CMD_TARGET_TEMP if temporary else v.OTGW_CMD_TARGET_TEMP_CONST value = f"{temp:2.1f}" ret = await self._wait_for_cmd(cmd, value, timeout) if ret is None: return None ret = float(ret) if 0 <= ret <= 30: if ret == 0: status_update = { v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_DISABLED}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: None}, } else: if temporary: ovrd_mode = v.OTGW_SETP_OVRD_TEMPORARY else: ovrd_mode = v.OTGW_SETP_OVRD_PERMANENT status_update = { v.OTGW: {v.OTGW_SETP_OVRD_MODE: ovrd_mode}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: ret}, } self.status.submit_full_update(status_update) return ret async def set_temp_sensor_function(self, func, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Set the temperature sensor function. The following functions are available: O: Outside temperature R: Return water temperature This method is a coroutine """ cmd = v.OTGW_CMD_TEMP_SENSOR if func not in "OR": return None ret = await self._wait_for_cmd(cmd, func, timeout) if ret is None: return None status_otgw = {v.OTGW_TEMP_SENSOR: ret} self.status.submit_partial_update(v.OTGW, status_otgw) return ret async def set_outside_temp(self, temp, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Configure the outside temperature to send to the thermostat. Allowed values are between -40.0 and +64.0, although thermostats may not display the full range. Specify a value above 64 (suggestion: 99) to clear a previously configured value. Return the accepted value on success, '-' if a previously configured value has been cleared or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_OUTSIDE_TEMP status_thermostat = {} if temp < -40: return None value = f"{temp:2.1f}" ret = await self._wait_for_cmd(cmd, value, timeout) if ret is None: return if ret == "-": status_thermostat[v.DATA_OUTSIDE_TEMP] = 0.0 else: ret = float(ret) status_thermostat[v.DATA_OUTSIDE_TEMP] = ret self.status.submit_partial_update(v.THERMOSTAT, status_thermostat) return ret async def set_clock(self, date=datetime.now(), timeout=v.OTGW_DEFAULT_TIMEOUT): """ Change the time and day of the week of the thermostat. The gateway will send the specified time and day of the week in response to the next time and date message from the thermostat. @date is a :datetime: object which defaults to now() Return the response from the gateway with format HH:MM/DOW, where DOW is a single digit: 1=Monday, 7=Sunday. This method is a coroutine """ cmd = v.OTGW_CMD_SET_CLOCK value = f"{date.strftime('%H:%M')}/{date.isoweekday()}" return await self._wait_for_cmd(cmd, value, timeout) async def get_reports(self): """ Update the OpenThermGateway object with the information from all of the PR commands and return the updated status dict. This method is a coroutine """ cmd = v.OTGW_CMD_REPORT reports = {} # Get version info first ret = await self._wait_for_cmd(cmd, v.OTGW_REPORT_ABOUT) reports[v.OTGW_REPORT_ABOUT] = ret[2:] if ret else None for value in v.OTGW_REPORTS: ver = reports.get(v.OTGW_REPORT_ABOUT) if ver and int(ver[18]) < 5 and value == "D": # Added in v5 continue if value == v.OTGW_REPORT_ABOUT: continue ret = await self._wait_for_cmd(cmd, value) if ret is None: reports[value] = None continue reports[value] = ret[2:] status_otgw = { v.OTGW_ABOUT: reports.get(v.OTGW_REPORT_ABOUT), v.OTGW_BUILD: reports.get(v.OTGW_REPORT_BUILDDATE), v.OTGW_CLOCKMHZ: reports.get(v.OTGW_REPORT_CLOCKMHZ), v.OTGW_DHW_OVRD: reports.get(v.OTGW_REPORT_DHW_SETTING), v.OTGW_MODE: reports.get(v.OTGW_REPORT_GW_MODE), v.OTGW_RST_CAUSE: reports.get(v.OTGW_REPORT_RST_CAUSE), v.OTGW_SMART_PWR: reports.get(v.OTGW_REPORT_SMART_PWR), v.OTGW_TEMP_SENSOR: reports.get(v.OTGW_REPORT_TEMP_SENSOR), v.OTGW_THRM_DETECT: reports.get(v.OTGW_REPORT_THERMOSTAT_DETECT), } status_thermostat = {} ovrd_mode = reports.get(v.OTGW_REPORT_SETPOINT_OVRD) if ovrd_mode is not None: ovrd_mode = str.upper(ovrd_mode[0]) status_otgw.update({v.OTGW_SETP_OVRD_MODE: ovrd_mode}) gpio_funcs = reports.get(v.OTGW_REPORT_GPIO_FUNCS) if gpio_funcs is not None: status_otgw.update( {v.OTGW_GPIO_A: int(gpio_funcs[0]), v.OTGW_GPIO_B: int(gpio_funcs[1])} ) led_funcs = reports.get(v.OTGW_REPORT_LED_FUNCS) if led_funcs is not None: status_otgw.update( { v.OTGW_LED_A: led_funcs[0], v.OTGW_LED_B: led_funcs[1], v.OTGW_LED_C: led_funcs[2], v.OTGW_LED_D: led_funcs[3], v.OTGW_LED_E: led_funcs[4], v.OTGW_LED_F: led_funcs[5], } ) tweaks = reports.get(v.OTGW_REPORT_TWEAKS) if tweaks is not None: status_otgw.update( { v.OTGW_IGNORE_TRANSITIONS: int(tweaks[0]), v.OTGW_OVRD_HB: int(tweaks[1]), } ) sb_temp = reports.get(v.OTGW_REPORT_SETBACK_TEMP) if sb_temp is not None: status_otgw.update({v.OTGW_SB_TEMP: float(sb_temp)}) vref = reports.get(v.OTGW_REPORT_VREF) if vref is not None: status_otgw.update({v.OTGW_VREF: int(vref)}) if ovrd_mode is not None and ovrd_mode != v.OTGW_SETP_OVRD_DISABLED: status_thermostat = { v.DATA_ROOM_SETPOINT_OVRD: float( reports[v.OTGW_REPORT_SETPOINT_OVRD][1:] ) } self.status.submit_full_update( {v.THERMOSTAT: status_thermostat, v.OTGW: status_otgw} ) return self.status.status async def get_status(self): """ Update the OpenThermGateway object with the information from the PS command and return the updated status dict. This method is a coroutine """ cmd = v.OTGW_CMD_SUMMARY ret = await self._wait_for_cmd(cmd, 1) # Return to 'reporting' mode if ret is None: return asyncio.get_running_loop().create_task(self._wait_for_cmd(cmd, 0)) fields = ret[1].split(",") if len(fields) == 34: # OpenTherm Gateway 5.0 boiler_status, thermostat_status = process_statusfields_v5(fields) else: boiler_status, thermostat_status = process_statusfields_v4(fields) self.status.submit_full_update({ v.BOILER: boiler_status, v.THERMOSTAT: thermostat_status }) return self.status.status async def set_hot_water_ovrd(self, state, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Control the domestic hot water enable option. If the boiler has been configured to let the room unit control when to keep a small amount of water preheated, this command can influence that. @state should be 0 or 1 to enable the override in off or on state, or any other single character to disable the override. Return the accepted value, 'A' if the override is disabled or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_HOT_WATER status_otgw = {} ret = await self._wait_for_cmd(cmd, state, timeout) if ret is None: return None if ret == "A": status_otgw[v.OTGW_DHW_OVRD] = None elif ret in ["0", "1"]: ret = int(ret) status_otgw[v.OTGW_DHW_OVRD] = ret self.status.submit_partial_update(v.OTGW, status_otgw) return ret async def set_mode(self, mode, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Set the operating mode to either "Gateway" mode (:mode: = OTGW_MODE_GATEWAY or 1) or "Monitor" mode (:mode: = OTGW_MODE_MONITOR or 0), or use this method to reset the device (:mode: = OTGW_MODE_RESET). Return the newly activated mode, or the full renewed status dict after a reset. This method is a coroutine """ cmd = v.OTGW_CMD_MODE status_otgw = {} ret = await self._wait_for_cmd(cmd, mode, timeout) if ret is None: return None if mode is v.OTGW_MODE_RESET: self.status.reset() await self.get_reports() await self.get_status() return self.status.status status_otgw[v.OTGW_MODE] = ret self.status.submit_partial_update(v.OTGW, status_otgw) return ret async def set_led_mode(self, led_id, mode, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Configure the functions of the six LEDs (A-F) that can optionally be connected to pins RB3/RB4/RB6/RB7 and the GPIO pins of the PIC. The following functions are currently available: R Receiving an Opentherm message from the thermostat or boiler X Transmitting an Opentherm message to the thermostat or boiler T Transmitting or receiving a message on the master interface B Transmitting or receiving a message on the slave interface O Remote setpoint override is active F Flame is on H Central heating is on W Hot water is on C Comfort mode (Domestic Hot Water Enable) is on E Transmission error has been detected M Boiler requires maintenance P Raised power mode active on thermostat interface. Return the new mode for the specified led, or None on failure. This method is a coroutine """ if led_id in "ABCDEF" and mode in "RXTBOFHWCEMP": cmd = getattr(v, f"OTGW_CMD_LED_{led_id}") status_otgw = {} ret = await self._wait_for_cmd(cmd, mode, timeout) if ret is None: return None var = getattr(v, f"OTGW_LED_{led_id}") status_otgw[var] = ret self.status.submit_partial_update(v.OTGW, status_otgw) return ret async def set_gpio_mode(self, gpio_id, mode, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Configure the functions of the two GPIO pins of the gateway. The following functions are available: 0 No function, default for both ports on a freshly flashed chip 1 Ground - A permanently low output (0V). Could be used for a power LED 2 Vcc - A permanently high output (5V). Can be used as a short-proof power supply for some external circuitry used by the other GPIO port 3 LED E - An additional LED if you want to present more than 4 LED functions 4 LED F - An additional LED if you want to present more than 5 LED functions 5 Home - Set thermostat to setback temperature when pulled low 6 Away - Set thermostat to setback temperature when pulled high 7 DS1820 (GPIO port B only) - Data line for a DS18S20 or DS18B20 temperature sensor used to measure the outside temperature. A 4k7 resistor should be connected between GPIO port B and Vcc Return the new mode for the specified gpio, or None on failure. This method is a coroutine """ if gpio_id in "AB" and mode in range(8): if mode == 7 and gpio_id != "B": return None cmd = getattr(v, f"OTGW_CMD_GPIO_{gpio_id}") status_otgw = {} ret = await self._wait_for_cmd(cmd, mode, timeout) if ret is None: return ret = int(ret) var = getattr(v, f"OTGW_GPIO_{gpio_id}") status_otgw[var] = ret self.status.submit_partial_update(v.OTGW, status_otgw) asyncio.ensure_future(self._poll_gpio()) return ret async def set_setback_temp(self, sb_temp, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Configure the setback temperature to use in combination with GPIO functions HOME (5) and AWAY (6). Return the new setback temperature, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_SETBACK status_otgw = {} ret = await self._wait_for_cmd(cmd, sb_temp, timeout) if ret is None: return ret = float(ret) status_otgw[v.OTGW_SB_TEMP] = ret self.status.submit_partial_update(v.OTGW, status_otgw) return ret async def add_alternative(self, alt, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Add the specified Data-ID to the list of alternative commands to send to the boiler instead of a Data-ID that is known to be unsupported by the boiler. Alternative Data-IDs will always be sent to the boiler in a Read-Data request message with the data-value set to zero. The table of alternative Data-IDs is stored in non-volatile memory so it will persist even if the gateway has been powered off. Data-ID values from 1 to 255 are allowed. Return the ID that was added to the list, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_ADD_ALT alt = int(alt) if alt < 1 or alt > 255: return None ret = await self._wait_for_cmd(cmd, alt, timeout) if ret is not None: return int(ret) async def del_alternative(self, alt, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Remove the specified Data-ID from the list of alternative commands. Only one occurrence is deleted. If the Data-ID appears multiple times in the list of alternative commands, this command must be repeated to delete all occurrences. The table of alternative Data-IDs is stored in non-volatile memory so it will persist even if the gateway has been powered off. Data-ID values from 1 to 255 are allowed. Return the ID that was removed from the list, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_DEL_ALT alt = int(alt) if alt < 1 or alt > 255: return None ret = await self._wait_for_cmd(cmd, alt, timeout) if ret is not None: return int(ret) async def add_unknown_id(self, unknown_id, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Inform the gateway that the boiler doesn't support the specified Data-ID, even if the boiler doesn't indicate that by returning an Unknown-DataId response. Using this command allows the gateway to send an alternative Data-ID to the boiler instead. Return the added ID, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_UNKNOWN_ID unknown_id = int(unknown_id) if unknown_id < 1 or unknown_id > 255: return None ret = await self._wait_for_cmd(cmd, unknown_id, timeout) if ret is not None: return int(ret) async def del_unknown_id(self, unknown_id, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Start forwarding the specified Data-ID to the boiler again. This command resets the counter used to determine if the specified Data-ID is supported by the boiler. Return the ID that was marked as supported, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_KNOWN_ID unknown_id = int(unknown_id) if unknown_id < 1 or unknown_id > 255: return None ret = await self._wait_for_cmd(cmd, unknown_id, timeout) if ret is not None: return int(ret) async def set_max_ch_setpoint(self, temperature, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Set the maximum central heating setpoint. This command is only available with boilers that support this function. Return the newly accepted setpoint, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_SET_MAX status_boiler = {} ret = await self._wait_for_cmd(cmd, temperature, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_MAX_CH_SETPOINT] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def set_dhw_setpoint(self, temperature, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Set the domestic hot water setpoint. This command is only available with boilers that support this function. Return the newly accepted setpoint, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_SET_WATER status_boiler = {} ret = await self._wait_for_cmd(cmd, temperature, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_DHW_SETPOINT] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def set_max_relative_mod(self, max_mod, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Override the maximum relative modulation from the thermostat. Valid values are 0 through 100. Clear the setting by specifying a non-numeric value. Return the newly accepted value, '-' if a previous value was cleared, or None on failure. This method is a coroutine """ if isinstance(max_mod, int) and not 0 <= max_mod <= 100: return None cmd = v.OTGW_CMD_MAX_MOD status_boiler = {} ret = await self._wait_for_cmd(cmd, max_mod, timeout) if ret is None: return if ret == "-": status_boiler[v.DATA_SLAVE_MAX_RELATIVE_MOD] = None else: ret = int(ret) status_boiler[v.DATA_SLAVE_MAX_RELATIVE_MOD] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def set_control_setpoint(self, setpoint, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Manipulate the control setpoint being sent to the boiler. Set to 0 to pass along the value specified by the thermostat. Return the newly accepted value, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_CONTROL_SETPOINT status_boiler = {} ret = await self._wait_for_cmd(cmd, setpoint, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_CONTROL_SETPOINT] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def set_control_setpoint_2(self, setpoint, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Manipulate the control setpoint being sent to the boiler for the second heating circuit. Set to 0 to pass along the value specified by the thermostat. Return the newly accepted value, or None on failure. This method is a coroutine """ cmd = v.OTGW_CMD_CONTROL_SETPOINT_2 status_boiler = {} ret = await self._wait_for_cmd(cmd, setpoint, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_CONTROL_SETPOINT_2] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def set_ch_enable_bit(self, ch_bit, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Control the CH enable status bit when overriding the control setpoint. By default the CH enable bit is set after a call to set_control_setpoint with a value other than 0. With this method, the bit can be manipulated. @ch_bit can be either 0 or 1. Return the newly accepted value (0 or 1), or None on failure. This method is a coroutine """ if ch_bit not in [0, 1]: return None cmd = v.OTGW_CMD_CONTROL_HEATING status_boiler = {} ret = await self._wait_for_cmd(cmd, ch_bit, timeout) if ret is None: return ret = int(ret) status_boiler[v.DATA_MASTER_CH_ENABLED] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def set_ch2_enable_bit(self, ch_bit, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Control the CH enable status bit when overriding the control setpoint. By default the CH enable bit is set after a call to set_control_setpoint with a value other than 0. With this method, the bit can be manipulated. @ch_bit can be either 0 or 1. Return the newly accepted value (0 or 1), or None on failure. This method is a coroutine """ if ch_bit not in [0, 1]: return None cmd = v.OTGW_CMD_CONTROL_HEATING_2 status_boiler = {} ret = await self._wait_for_cmd(cmd, ch_bit, timeout) if ret is None: return ret = int(ret) status_boiler[v.DATA_MASTER_CH2_ENABLED] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def set_ventilation(self, pct, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Configure a ventilation setpoint override value (0-100%). Return the newly accepted value, or None on failure. @pct :int: Must be between 0 and 100. This method is a coroutine """ if not 0 <= pct <= 100: return None cmd = v.OTGW_CMD_VENT status_boiler = {} ret = await self._wait_for_cmd(cmd, pct, timeout) if ret is None: return ret = int(ret) status_boiler[v.DATA_COOLING_CONTROL] = ret self.status.submit_partial_update(v.BOILER, status_boiler) return ret async def send_transparent_command( self, cmd, state, timeout=v.OTGW_DEFAULT_TIMEOUT ): """ Sends custom otgw commands throug a transparent interface. Check https://otgw.tclcode.com/firmware.html for supported commands. @cmd the supported command e.g. 'SC' (set time/day) @state the command argument e.g. '23:59/4' (the current time/day) Returns the gateway response, which should be equal to @state. Note that unlike the set_* methods, this does not update the status object. This method is a coroutine """ ret = await self._wait_for_cmd(cmd, state, timeout) return ret def subscribe(self, coro): """ Subscribe to status updates from the Opentherm Gateway. Can only be used after connect() @coro is a coroutine which will be called with a single argument (status) when a status change occurs. Return True on success, False if not connected or already subscribed. """ return self.status.subscribe(coro) def unsubscribe(self, coro): """ Unsubscribe from status updates from the Opentherm Gateway. Can only be used after connect() @coro is a coroutine which has been subscribed with subscribe() earlier. Return True on success, false if not connected or subscribed. """ return self.status.unsubscribe(coro) async def _wait_for_cmd(self, cmd, value, timeout=v.OTGW_DEFAULT_TIMEOUT): """ Wrap @cmd in applicable asyncio call. This method is a coroutine. """ if not self.connection.connected: return None try: return await asyncio.wait_for( self._protocol.command_processor.issue_cmd(cmd, value), timeout, ) except asyncio.TimeoutError: _LOGGER.error("Timed out waiting for command: %s, value: %s.", cmd, value) return except (RuntimeError, SyntaxError, ValueError) as exc: _LOGGER.error( "Command %s with value %s raised exception: %s", cmd, value, exc ) async def _poll_gpio(self, interval=10): """ Start or stop polling GPIO states. GPIO states aren't being pushed by the gateway, we need to poll if we want updates. """ poll = 0 in ( self.status.status[v.OTGW].get(v.OTGW_GPIO_A), self.status.status[v.OTGW].get(v.OTGW_GPIO_B), ) if poll and self._gpio_task is None: async def polling_routine(): """Poll GPIO state every @interval seconds.""" try: while True: ret = await self._wait_for_cmd( v.OTGW_CMD_REPORT, v.OTGW_REPORT_GPIO_STATES ) if ret: pios = ret[2:] status_otgw = { v.OTGW_GPIO_A_STATE: int(pios[0]), v.OTGW_GPIO_B_STATE: int(pios[1]), } self.status.submit_partial_update(v.OTGW, status_otgw) await asyncio.sleep(interval) except asyncio.CancelledError: status_otgw = { v.OTGW_GPIO_A_STATE: 0, v.OTGW_GPIO_B_STATE: 0, } self.status.submit_partial_update(v.OTGW, status_otgw) self._gpio_task = None _LOGGER.debug("GPIO polling routine stopped") _LOGGER.debug("Starting GPIO polling routine") self._gpio_task = asyncio.get_running_loop().create_task(polling_routine()) elif not poll and self._gpio_task is not None: _LOGGER.debug("Stopping GPIO polling routine") self._gpio_task.cancel() def process_statusfields_v4(status_fields): """ Process the fields of a split status line for OpenTherm Gateway firmware version <5.0. Return a tuple of (boiler_status, thermostat_status). """ device_status = status_fields[0].split("/") master_status = device_status[0] slave_status = device_status[1] remote_params = status_fields[2].split("/") capmodlimits = status_fields[4].split("/") dhw_setp_bounds = status_fields[13].split("/") ch_setp_bounds = status_fields[14].split("/") thermostat_status = { v.DATA_MASTER_CH_ENABLED: int(master_status[7]), v.DATA_MASTER_DHW_ENABLED: int(master_status[6]), v.DATA_MASTER_COOLING_ENABLED: int(master_status[5]), v.DATA_MASTER_OTC_ENABLED: int(master_status[4]), v.DATA_MASTER_CH2_ENABLED: int(master_status[3]), v.DATA_CONTROL_SETPOINT: float(status_fields[1]), v.DATA_ROOM_SETPOINT: float(status_fields[5]), v.DATA_ROOM_TEMP: float(status_fields[8]), } boiler_status = { v.DATA_SLAVE_FAULT_IND: int(slave_status[7]), v.DATA_SLAVE_CH_ACTIVE: int(slave_status[6]), v.DATA_SLAVE_DHW_ACTIVE: int(slave_status[5]), v.DATA_SLAVE_FLAME_ON: int(slave_status[4]), v.DATA_SLAVE_COOLING_ACTIVE: int(slave_status[3]), v.DATA_SLAVE_CH2_ACTIVE: int(slave_status[2]), v.DATA_SLAVE_DIAG_IND: int(slave_status[1]), v.DATA_REMOTE_TRANSFER_DHW: int(remote_params[0][7]), v.DATA_REMOTE_TRANSFER_MAX_CH: int(remote_params[0][6]), v.DATA_REMOTE_RW_DHW: int(remote_params[1][7]), v.DATA_REMOTE_RW_MAX_CH: int(remote_params[1][6]), v.DATA_SLAVE_MAX_RELATIVE_MOD: float(status_fields[3]), v.DATA_SLAVE_MAX_CAPACITY: int(capmodlimits[0]), v.DATA_SLAVE_MIN_MOD_LEVEL: int(capmodlimits[1]), v.DATA_REL_MOD_LEVEL: float(status_fields[6]), v.DATA_CH_WATER_PRESS: float(status_fields[7]), v.DATA_CH_WATER_TEMP: float(status_fields[9]), v.DATA_DHW_TEMP: float(status_fields[10]), v.DATA_OUTSIDE_TEMP: float(status_fields[11]), v.DATA_RETURN_WATER_TEMP: float(status_fields[12]), v.DATA_SLAVE_DHW_MAX_SETP: int(dhw_setp_bounds[0]), v.DATA_SLAVE_DHW_MIN_SETP: int(dhw_setp_bounds[1]), v.DATA_SLAVE_CH_MAX_SETP: int(ch_setp_bounds[0]), v.DATA_SLAVE_CH_MIN_SETP: int(ch_setp_bounds[1]), v.DATA_DHW_SETPOINT: float(status_fields[15]), v.DATA_MAX_CH_SETPOINT: float(status_fields[16]), v.DATA_TOTAL_BURNER_STARTS: int(status_fields[17]), v.DATA_CH_PUMP_STARTS: int(status_fields[18]), v.DATA_DHW_PUMP_STARTS: int(status_fields[19]), v.DATA_DHW_BURNER_STARTS: int(status_fields[20]), v.DATA_TOTAL_BURNER_HOURS: int(status_fields[21]), v.DATA_CH_PUMP_HOURS: int(status_fields[22]), v.DATA_DHW_PUMP_HOURS: int(status_fields[23]), v.DATA_DHW_BURNER_HOURS: int(status_fields[24]), } return (boiler_status, thermostat_status) def process_statusfields_v5(status_fields): """ Process the fields of a split status line for OpenTherm Gateway firmware version >=5.0. Return a tuple of (boiler_status, thermostat_status). """ device_status = status_fields[0].split("/") master_status = device_status[0] slave_status = device_status[1] remote_params = status_fields[2].split("/") capmodlimits = status_fields[6].split("/") dhw_setp_bounds = status_fields[19].split("/") ch_setp_bounds = status_fields[20].split("/") vh_device_status = status_fields[23].split("/") vh_master_status = vh_device_status[0] vh_slave_status = vh_device_status[1] thermostat_status = { v.DATA_MASTER_CH_ENABLED: int(master_status[7]), v.DATA_MASTER_DHW_ENABLED: int(master_status[6]), v.DATA_MASTER_COOLING_ENABLED: int(master_status[5]), v.DATA_MASTER_OTC_ENABLED: int(master_status[4]), v.DATA_MASTER_CH2_ENABLED: int(master_status[3]), v.DATA_CONTROL_SETPOINT: float(status_fields[1]), v.DATA_ROOM_SETPOINT: float(status_fields[7]), v.DATA_COOLING_CONTROL: float(status_fields[3]), v.DATA_CONTROL_SETPOINT_2: float(status_fields[4]), v.DATA_ROOM_SETPOINT_2: float(status_fields[11]), v.DATA_ROOM_TEMP: float(status_fields[12]), v.DATA_VH_MASTER_VENT_ENABLED: int(vh_master_status[7]), v.DATA_VH_MASTER_BYPASS_POS: int(vh_master_status[6]), v.DATA_VH_MASTER_BYPASS_MODE: int(vh_master_status[5]), v.DATA_VH_MASTER_FREE_VENT_MODE: int(vh_master_status[4]), v.DATA_VH_CONTROL_SETPOINT: int(status_fields[24]), } boiler_status = { v.DATA_SLAVE_FAULT_IND: int(slave_status[7]), v.DATA_SLAVE_CH_ACTIVE: int(slave_status[6]), v.DATA_SLAVE_DHW_ACTIVE: int(slave_status[5]), v.DATA_SLAVE_FLAME_ON: int(slave_status[4]), v.DATA_SLAVE_COOLING_ACTIVE: int(slave_status[3]), v.DATA_SLAVE_CH2_ACTIVE: int(slave_status[2]), v.DATA_SLAVE_DIAG_IND: int(slave_status[1]), v.DATA_REMOTE_TRANSFER_DHW: int(remote_params[0][7]), v.DATA_REMOTE_TRANSFER_MAX_CH: int(remote_params[0][6]), v.DATA_REMOTE_RW_DHW: int(remote_params[1][7]), v.DATA_REMOTE_RW_MAX_CH: int(remote_params[1][6]), v.DATA_SLAVE_MAX_RELATIVE_MOD: float(status_fields[5]), v.DATA_SLAVE_MAX_CAPACITY: int(capmodlimits[0]), v.DATA_SLAVE_MIN_MOD_LEVEL: int(capmodlimits[1]), v.DATA_REL_MOD_LEVEL: float(status_fields[8]), v.DATA_CH_WATER_PRESS: float(status_fields[9]), v.DATA_DHW_FLOW_RATE: float(status_fields[10]), v.DATA_CH_WATER_TEMP: float(status_fields[13]), v.DATA_DHW_TEMP: float(status_fields[14]), v.DATA_OUTSIDE_TEMP: float(status_fields[15]), v.DATA_RETURN_WATER_TEMP: float(status_fields[16]), v.DATA_CH_WATER_TEMP_2: float(status_fields[17]), v.DATA_EXHAUST_TEMP: int(status_fields[18]), v.DATA_SLAVE_DHW_MAX_SETP: int(dhw_setp_bounds[0]), v.DATA_SLAVE_DHW_MIN_SETP: int(dhw_setp_bounds[1]), v.DATA_SLAVE_CH_MAX_SETP: int(ch_setp_bounds[0]), v.DATA_SLAVE_CH_MIN_SETP: int(ch_setp_bounds[1]), v.DATA_DHW_SETPOINT: float(status_fields[21]), v.DATA_MAX_CH_SETPOINT: float(status_fields[22]), v.DATA_VH_SLAVE_FAULT_INDICATE: int(vh_slave_status[7]), v.DATA_VH_SLAVE_VENT_MODE: int(vh_slave_status[6]), v.DATA_VH_SLAVE_BYPASS_STATUS: int(vh_slave_status[5]), v.DATA_VH_SLAVE_BYPASS_AUTO_STATUS: int(vh_slave_status[4]), v.DATA_VH_SLAVE_FREE_VENT_STATUS: int(vh_slave_status[3]), v.DATA_VH_SLAVE_DIAG_INDICATE: int(vh_slave_status[1]), v.DATA_TOTAL_BURNER_STARTS: int(status_fields[26]), v.DATA_CH_PUMP_STARTS: int(status_fields[27]), v.DATA_DHW_PUMP_STARTS: int(status_fields[28]), v.DATA_DHW_BURNER_STARTS: int(status_fields[29]), v.DATA_TOTAL_BURNER_HOURS: int(status_fields[30]), v.DATA_CH_PUMP_HOURS: int(status_fields[31]), v.DATA_DHW_PUMP_HOURS: int(status_fields[32]), v.DATA_DHW_BURNER_HOURS: int(status_fields[33]), v.DATA_VH_RELATIVE_VENT: int(status_fields[25]), } return (boiler_status, thermostat_status) pyotgw-2.2.2/pyotgw/status.py000066400000000000000000000072531470422227100163040ustar00rootroot00000000000000"""All status related code""" import asyncio import logging from copy import deepcopy from pyotgw import vars as v _LOGGER = logging.getLogger(__name__) class StatusManager: """Manage status tracking and updates""" def __init__(self): """Initialise the status manager""" self.loop = asyncio.get_event_loop() self._updateq = asyncio.Queue() self._status = deepcopy(v.DEFAULT_STATUS) self._notify = [] self._update_task = self.loop.create_task(self._process_updates()) def reset(self): """Clear the queue and reset the status dict""" while not self._updateq.empty(): self._updateq.get_nowait() self._status = deepcopy(v.DEFAULT_STATUS) @property def status(self): """Return the full status dict""" return deepcopy(self._status) def delete_value(self, part, key): """Delete key from status part.""" try: del self._status[part][key] except (AttributeError, KeyError): return False self._updateq.put_nowait(self.status) return True def submit_partial_update(self, part, update): """ Submit an update for part of the status dict to the queue. Return a boolean indicating success. """ if part not in self.status: _LOGGER.error("Invalid status part for update: %s", part) return False if not isinstance(update, dict): _LOGGER.error("Update for %s is not a dict: %s", part, update) return False self._status[part].update(update) self._updateq.put_nowait(self.status) return True def submit_full_update(self, update): """ Submit an update for multiple parts of the status dict to the queue. Return a boolean indicating success. """ for part, values in update.items(): # First we verify all data if part not in self.status: _LOGGER.error("Invalid status part for update: %s", part) return False if not isinstance(values, dict): _LOGGER.error("Update for %s is not a dict: %s", part, values) return False for part, values in update.items(): # Then we actually update self._status[part].update(values) self._updateq.put_nowait(self.status) return True def subscribe(self, callback): """ Subscribe callback for future status updates. Return boolean indicating success. """ if callback in self._notify: return False self._notify.append(callback) return True def unsubscribe(self, callback): """ Unsubscribe callback from future status updates. Return boolean indicating success. """ if callback not in self._notify: return False self._notify.remove(callback) return True async def cleanup(self): """Clean up task""" if self._update_task: self._update_task.cancel() try: await self._update_task except asyncio.CancelledError: self._update_task = None async def _process_updates(self): """Process updates from the queue.""" _LOGGER.debug("Starting reporting routine") while True: oldstatus = deepcopy(self.status) stat = await self._updateq.get() if oldstatus != stat and self._notify: for coro in self._notify: # Each client gets its own copy of the dict. self.loop.create_task(coro(deepcopy(stat))) pyotgw-2.2.2/pyotgw/vars.py000066400000000000000000000252311470422227100157300ustar00rootroot00000000000000"""Global pyotgw values""" MSG_STATUS = b"\x00" MSG_TSET = b"\x01" MSG_MCONFIG = b"\x02" MSG_SCONFIG = b"\x03" MSG_COMMAND = b"\x04" MSG_ASFFLAGS = b"\x05" MSG_RBPFLAGS = b"\x06" MSG_COOLING = b"\x07" MSG_TSETC2 = b"\x08" MSG_TROVRD = b"\x09" MSG_TSP = b"\x0A" MSG_TSPIDX = b"\x0B" MSG_FHBSIZE = b"\x0C" MSG_FHBIDX = b"\x0D" MSG_MAXRMOD = b"\x0E" MSG_MAXCAPMINMOD = b"\x0F" MSG_TRSET = b"\x10" MSG_RELMOD = b"\x11" MSG_CHPRESS = b"\x12" MSG_DHWFLOW = b"\x13" MSG_TIME = b"\x14" MSG_DATE = b"\x15" MSG_YEAR = b"\x16" MSG_TRSET2 = b"\x17" MSG_TROOM = b"\x18" MSG_TBOILER = b"\x19" MSG_TDHW = b"\x1A" MSG_TOUTSIDE = b"\x1B" MSG_TRET = b"\x1C" MSG_TSTOR = b"\x1D" MSG_TCOLL = b"\x1E" MSG_TFLOWCH2 = b"\x1F" MSG_TDHW2 = b"\x20" MSG_TEXHAUST = b"\x21" MSG_TDHWSETUL = b"\x30" MSG_TCHSETUL = b"\x31" MSG_OTCCURVEUL = b"\x32" MSG_TDHWSET = b"\x38" MSG_MAXTSET = b"\x39" MSG_OTCCURVE = b"\x3A" MSG_STATUSVH = b"\x46" MSG_RELVENTPOS = b"\x47" MSG_RELVENT = b"\x4D" MSG_ROVRD = b"\x64" MSG_OEMDIAG = b"\x73" MSG_BURNSTARTS = b"\x74" MSG_CHPUMPSTARTS = b"\x75" MSG_DHWPUMPSTARTS = b"\x76" MSG_DHWBURNSTARTS = b"\x77" MSG_BURNHRS = b"\x78" MSG_CHPUMPHRS = b"\x79" MSG_DHWPUMPHRS = b"\x7A" MSG_DHWBURNHRS = b"\x7B" MSG_OTVERM = b"\x7C" MSG_OTVERS = b"\x7D" MSG_MVER = b"\x7E" MSG_SVER = b"\x7F" BOILER = "boiler" OTGW = "gateway" THERMOSTAT = "thermostat" DEFAULT_STATUS = {BOILER: {}, OTGW: {}, THERMOSTAT: {}} # MSG_STATUS DATA_MASTER_CH_ENABLED = "master_ch_enabled" DATA_MASTER_DHW_ENABLED = "master_dhw_enabled" DATA_MASTER_COOLING_ENABLED = "master_cooling_enabled" DATA_MASTER_OTC_ENABLED = "master_otc_enabled" DATA_MASTER_CH2_ENABLED = "master_ch2_enabled" DATA_SLAVE_FAULT_IND = "slave_fault_indication" DATA_SLAVE_CH_ACTIVE = "slave_ch_active" DATA_SLAVE_DHW_ACTIVE = "slave_dhw_active" DATA_SLAVE_FLAME_ON = "slave_flame_on" DATA_SLAVE_COOLING_ACTIVE = "slave_cooling_active" DATA_SLAVE_CH2_ACTIVE = "slave_ch2_active" DATA_SLAVE_DIAG_IND = "slave_diagnostic_indication" # MSG_TSET DATA_CONTROL_SETPOINT = "control_setpoint" # MSG_MCONFIG DATA_MASTER_MEMBERID = "master_memberid" # MSG_SCONFIG DATA_SLAVE_DHW_PRESENT = "slave_dhw_present" DATA_SLAVE_CONTROL_TYPE = "slave_control_type" DATA_SLAVE_COOLING_SUPPORTED = "slave_cooling_supported" DATA_SLAVE_DHW_CONFIG = "slave_dhw_config" DATA_SLAVE_MASTER_LOW_OFF_PUMP = "slave_master_low_off_pump" DATA_SLAVE_CH2_PRESENT = "slave_ch2_present" DATA_SLAVE_MEMBERID = "slave_memberid" # MSG_COMMAND # MSG_ASFFLAGS DATA_SLAVE_SERVICE_REQ = "slave_service_required" DATA_SLAVE_REMOTE_RESET = "slave_remote_reset" DATA_SLAVE_LOW_WATER_PRESS = "slave_low_water_pressure" DATA_SLAVE_GAS_FAULT = "slave_gas_fault" DATA_SLAVE_AIR_PRESS_FAULT = "slave_air_pressure_fault" DATA_SLAVE_WATER_OVERTEMP = "slave_water_overtemp" DATA_SLAVE_OEM_FAULT = "slave_oem_fault" # MSG_RBPFLAGS DATA_REMOTE_TRANSFER_DHW = "remote_transfer_dhw" DATA_REMOTE_TRANSFER_MAX_CH = "remote_transfer_max_ch" DATA_REMOTE_RW_DHW = "remote_rw_dhw" DATA_REMOTE_RW_MAX_CH = "remote_rw_max_ch" # MSG_COOLING DATA_COOLING_CONTROL = "cooling_control" # MSG_TSETC2 DATA_CONTROL_SETPOINT_2 = "control_setpoint_2" # MSG_TROVRD DATA_ROOM_SETPOINT_OVRD = "room_setpoint_ovrd" # MSG_TSP # MSG_TSPIDX # MSG_FHBSIZE # MSG_FHBIDX # MSG_MAXRMOD DATA_SLAVE_MAX_RELATIVE_MOD = "slave_max_relative_modulation" # MSG_MAXCAPMINMOD DATA_SLAVE_MAX_CAPACITY = "slave_max_capacity" DATA_SLAVE_MIN_MOD_LEVEL = "slave_min_mod_level" # MSG_TRSET DATA_ROOM_SETPOINT = "room_setpoint" # MSG_RELMOD DATA_REL_MOD_LEVEL = "relative_mod_level" # MSG_CHPRESS DATA_CH_WATER_PRESS = "ch_water_pressure" # MSG_DHWFLOW DATA_DHW_FLOW_RATE = "dhw_flow_rate" # MSG_TIME # MSG_DATE # MSG_YEAR # MSG_TRSET2 DATA_ROOM_SETPOINT_2 = "room_setpoint_2" # MSG_TROOM DATA_ROOM_TEMP = "room_temp" # MSG_TBOILER DATA_CH_WATER_TEMP = "ch_water_temp" # MSG_TDHW DATA_DHW_TEMP = "dhw_temp" # MSG_TOUTSIDE DATA_OUTSIDE_TEMP = "outside_temp" # MSG_TRET DATA_RETURN_WATER_TEMP = "return_water_temp" # MSG_TSTOR DATA_SOLAR_STORAGE_TEMP = "solar_storage_temp" # MSG_TCOLL DATA_SOLAR_COLL_TEMP = "solar_coll_temp" # MSG_TFLOWCH2 DATA_CH_WATER_TEMP_2 = "ch_water_temp_2" # MSG_TDHW2 DATA_DHW_TEMP_2 = "dhw_temp_2" # MSG_TEXHAUST DATA_EXHAUST_TEMP = "exhaust_temp" # MSG_TDHWSETUL DATA_SLAVE_DHW_MAX_SETP = "slave_dhw_max_setp" DATA_SLAVE_DHW_MIN_SETP = "slave_dhw_min_setp" # MSG_TCHSETUL DATA_SLAVE_CH_MAX_SETP = "slave_ch_max_setp" DATA_SLAVE_CH_MIN_SETP = "slave_ch_min_setp" # MSG_OTCCURVEUL # MSG_TDHWSET DATA_DHW_SETPOINT = "dhw_setpoint" # MSG_MAXTSET DATA_MAX_CH_SETPOINT = "max_ch_setpoint" # MSG_OTCCURVE # MSG_STATUSVH DATA_VH_MASTER_VENT_ENABLED = "vh_master_vent_enabled" DATA_VH_MASTER_BYPASS_POS = "vh_master_bypass_pos" DATA_VH_MASTER_BYPASS_MODE = "vh_master_bypass_mode" DATA_VH_MASTER_FREE_VENT_MODE = "vh_master_free_vent_mode" DATA_VH_SLAVE_FAULT_INDICATE = "vh_slave_fault_indicate" DATA_VH_SLAVE_VENT_MODE = "vh_slave_vent_mode" DATA_VH_SLAVE_BYPASS_STATUS = "vh_slave_bypass_status" DATA_VH_SLAVE_BYPASS_AUTO_STATUS = "vh_slave_bypass_auto_status" DATA_VH_SLAVE_FREE_VENT_STATUS = "vh_slave_free_vent_status" DATA_VH_SLAVE_DIAG_INDICATE = "vh_slave_diag_indicate" # MSG_RELVENTPOS DATA_VH_CONTROL_SETPOINT = "vh_control_setpoint" # MSG_RELVENT DATA_VH_RELATIVE_VENT = "vh_relative_vent" # MSG_ROVRD DATA_ROVRD_MAN_PRIO = "rovrd_man_prio" DATA_ROVRD_AUTO_PRIO = "rovrd_auto_prio" # MSG_OEMDIAG DATA_OEM_DIAG = "oem_diag" # MSG_BURNSTARTS DATA_TOTAL_BURNER_STARTS = "burner_starts" # MSG_CHPUMPSTARTS DATA_CH_PUMP_STARTS = "ch_pump_starts" # MSG_DHWPUMPSTARTS DATA_DHW_PUMP_STARTS = "dhw_pump_starts" # MSG_DHWBURNSTARTS DATA_DHW_BURNER_STARTS = "dhw_burner_starts" # MSG_BURNHRS DATA_TOTAL_BURNER_HOURS = "burner_hours" # MSG_CHPUMPHRS DATA_CH_PUMP_HOURS = "ch_pump_hours" # MSG_DHWPUMPHRS DATA_DHW_PUMP_HOURS = "dhw_pump_hours" # MSG_DHWBURNHRS DATA_DHW_BURNER_HOURS = "dhw_burner_hours" # MSG_OTVERM DATA_MASTER_OT_VERSION = "master_ot_version" # MSG_OTVERS DATA_SLAVE_OT_VERSION = "slave_ot_version" # MSG_MVER DATA_MASTER_PRODUCT_TYPE = "master_product_type" DATA_MASTER_PRODUCT_VERSION = "master_product_version" # MSG_SVER DATA_SLAVE_PRODUCT_TYPE = "slave_product_type" DATA_SLAVE_PRODUCT_VERSION = "slave_product_version" READ_DATA = 0x0 WRITE_DATA = 0x1 INVALID_DATA = 0x2 RESERVED = 0x3 READ_ACK = 0x4 WRITE_ACK = 0x5 DATA_INVALID = 0x6 UNKNOWN_DATAID = 0x7 OTGW_DEFAULT_TIMEOUT = 3 OTGW_CMD_TARGET_TEMP = "TT" OTGW_CMD_TARGET_TEMP_CONST = "TC" OTGW_CMD_OUTSIDE_TEMP = "OT" OTGW_CMD_SET_CLOCK = "SC" OTGW_CMD_HOT_WATER = "HW" OTGW_CMD_REPORT = "PR" OTGW_CMD_SUMMARY = "PS" OTGW_CMD_MODE = "GW" OTGW_CMD_LED_A = "LA" OTGW_CMD_LED_B = "LB" OTGW_CMD_LED_C = "LC" OTGW_CMD_LED_D = "LD" OTGW_CMD_LED_E = "LE" OTGW_CMD_LED_F = "LF" OTGW_CMD_GPIO_A = "GA" OTGW_CMD_GPIO_B = "GB" OTGW_CMD_SETBACK = "SB" OTGW_CMD_TEMP_SENSOR = "TS" OTGW_CMD_ADD_ALT = "AA" OTGW_CMD_DEL_ALT = "DA" OTGW_CMD_UNKNOWN_ID = "UI" OTGW_CMD_KNOWN_ID = "KI" OTGW_CMD_PRIO_MSG = "PM" OTGW_CMD_SET_RESP = "SR" OTGW_CMD_CLR_RESP = "CR" OTGW_CMD_SET_MAX = "SH" OTGW_CMD_SET_WATER = "SW" OTGW_CMD_MAX_MOD = "MM" OTGW_CMD_CONTROL_SETPOINT = "CS" OTGW_CMD_CONTROL_SETPOINT_2 = "C2" OTGW_CMD_CONTROL_HEATING = "CH" OTGW_CMD_CONTROL_HEATING_2 = "H2" OTGW_CMD_VENT = "VS" OTGW_CMD_RST_CNT = "RS" OTGW_CMD_IGNORE_TRANS = "IT" OTGW_CMD_OVRD_HIGH = "OH" OTGW_CMD_OVRD_THRMST = "FT" OTGW_CMD_VREF = "VR" OTGW_MODE = "otgw_mode" OTGW_DHW_OVRD = "otgw_dhw_ovrd" OTGW_ABOUT = "otgw_about" OTGW_BUILD = "otgw_build" OTGW_CLOCKMHZ = "otgw_clockmhz" OTGW_LED_A = "otgw_led_a" OTGW_LED_B = "otgw_led_b" OTGW_LED_C = "otgw_led_c" OTGW_LED_D = "otgw_led_d" OTGW_LED_E = "otgw_led_e" OTGW_LED_F = "otgw_led_f" OTGW_GPIO_A = "otgw_gpio_a" OTGW_GPIO_B = "otgw_gpio_b" OTGW_GPIO_A_STATE = "otgw_gpio_a_state" OTGW_GPIO_B_STATE = "otgw_gpio_b_state" OTGW_RST_CAUSE = "otgw_reset_cause" OTGW_SB_TEMP = "otgw_setback_temp" OTGW_SETP_OVRD_MODE = "otgw_setpoint_ovrd_mode" OTGW_SMART_PWR = "otgw_smart_pwr" OTGW_TEMP_SENSOR = "otgw_temp_sensor" OTGW_THRM_DETECT = "otgw_thermostat_detect" OTGW_IGNORE_TRANSITIONS = "otgw_ignore_transitions" OTGW_OVRD_HB = "otgw_ovrd_high_byte" OTGW_VREF = "otgw_vref" OTGW_SETP_OVRD_TEMPORARY = "T" OTGW_SETP_OVRD_PERMANENT = "C" OTGW_SETP_OVRD_DISABLED = "N" OTGW_MODE_MONITOR = "M" OTGW_MODE_GATEWAY = "G" OTGW_MODE_RESET = "R" OTGW_REPORT_ABOUT = "A" OTGW_REPORT_BUILDDATE = "B" OTGW_REPORT_CLOCKMHZ = "C" OTGW_REPORT_TEMP_SENSOR = "D" OTGW_REPORT_GPIO_FUNCS = "G" OTGW_REPORT_GPIO_STATES = "I" OTGW_REPORT_LED_FUNCS = "L" OTGW_REPORT_GW_MODE = "M" OTGW_REPORT_SETPOINT_OVRD = "O" OTGW_REPORT_SMART_PWR = "P" OTGW_REPORT_RST_CAUSE = "Q" OTGW_REPORT_THERMOSTAT_DETECT = "R" OTGW_REPORT_SETBACK_TEMP = "S" OTGW_REPORT_TWEAKS = "T" OTGW_REPORT_VREF = "V" OTGW_REPORT_DHW_SETTING = "W" OTGW_REPORTS = { OTGW_REPORT_ABOUT: OTGW_ABOUT, OTGW_REPORT_BUILDDATE: OTGW_BUILD, OTGW_REPORT_CLOCKMHZ: OTGW_CLOCKMHZ, OTGW_REPORT_DHW_SETTING: OTGW_DHW_OVRD, OTGW_REPORT_GPIO_FUNCS: [OTGW_GPIO_A, OTGW_GPIO_B], OTGW_REPORT_GPIO_STATES: [OTGW_GPIO_A_STATE, OTGW_GPIO_B_STATE], OTGW_REPORT_LED_FUNCS: [ OTGW_LED_A, OTGW_LED_B, OTGW_LED_C, OTGW_LED_D, OTGW_LED_E, OTGW_LED_F, ], OTGW_REPORT_GW_MODE: OTGW_MODE, OTGW_REPORT_RST_CAUSE: OTGW_RST_CAUSE, OTGW_REPORT_SETBACK_TEMP: OTGW_SB_TEMP, OTGW_REPORT_SETPOINT_OVRD: OTGW_SETP_OVRD_MODE, OTGW_REPORT_SMART_PWR: OTGW_SMART_PWR, OTGW_REPORT_TEMP_SENSOR: OTGW_TEMP_SENSOR, OTGW_REPORT_THERMOSTAT_DETECT: OTGW_THRM_DETECT, OTGW_REPORT_TWEAKS: [OTGW_IGNORE_TRANSITIONS, OTGW_OVRD_HB], OTGW_REPORT_VREF: OTGW_VREF, } NO_GOOD = "NG" SYNTAX_ERR = "SE" BAD_VALUE = "BV" OUT_OF_RANGE = "OR" NO_SPACE = "NS" NOT_FOUND = "NF" OVERRUN_ERR = "OE" MPC_ERR = "MPC" OTGW_ERRS = { NO_GOOD: RuntimeError( "No Good: The command code is unknown or unsupported on this " "version of the OpenTherm Gateway." ), SYNTAX_ERR: SyntaxError( "Syntax Error: The command contained an " "unexpected character or was incomplete." ), BAD_VALUE: ValueError( "Bad Value: The command contained a data value that is not allowed or not " "supported on this version of the OpenTherm Gateway." ), OUT_OF_RANGE: RuntimeError( "Out of Range: A number was specified outside of the allowed range." ), NO_SPACE: RuntimeError( "No Space: The alternative Data-ID could not be " "added because the table is full." ), NOT_FOUND: RuntimeError( "Not Found: The specified alternative Data-ID " "could not be removed because it does not exist " "in the table." ), OVERRUN_ERR: RuntimeError( "Overrun Error: The processor was busy and " "failed to process all received characters." ), MPC_ERR: RuntimeError("MPC Error"), } pyotgw-2.2.2/requirements_test.txt000066400000000000000000000001351470422227100173710ustar00rootroot00000000000000bandit black flake8 pre-commit pylint pytest pytest-asyncio pytest-cov pyserial-asyncio-fast pyotgw-2.2.2/setup.py000066400000000000000000000021501470422227100145570ustar00rootroot00000000000000import os from setuptools import setup # Utility function to read the README file. # Used for the long_description. It's nice, because now 1) we have a top level # README file and 2) it's easier to type in the README file than to put a raw # string in below ... def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() setup( name="pyotgw", version="2.2.2", author="Milan van Nugteren", author_email="milan@network23.nl", description=( "A library to interface with the opentherm gateway through " "serial or network connection." ), license="GPLv3+", keywords="opentherm gateway otgw", url="https://github.com/mvn23/pyotgw", packages=["pyotgw"], python_requires=">=3.8", long_description=read("README.md"), long_description_content_type="text/markdown", install_requires=["pyserial-asyncio-fast"], classifiers=[ "Development Status :: 5 - Production/Stable", "Topic :: Software Development :: Libraries", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", ], ) pyotgw-2.2.2/tests/000077500000000000000000000000001470422227100142115ustar00rootroot00000000000000pyotgw-2.2.2/tests/__init__.py000066400000000000000000000000231470422227100163150ustar00rootroot00000000000000"""pyotgw tests""" pyotgw-2.2.2/tests/conftest.py000066400000000000000000000040621470422227100164120ustar00rootroot00000000000000"""Config and fixtures for pyotgw tests""" import asyncio from unittest.mock import MagicMock, patch import pytest import pytest_asyncio import pyotgw from pyotgw.connection import ConnectionManager, ConnectionWatchdog from pyotgw.status import StatusManager @pytest_asyncio.fixture async def pygw(): """Return a basic pyotgw object""" gw = pyotgw.OpenThermGateway() await gw.connection.watchdog.stop() yield gw await gw.cleanup() @pytest_asyncio.fixture async def pygw_proto(pygw): """Return a "connected" protocol object""" async def empty_coroutine(): return trans = MagicMock(loop=asyncio.get_running_loop()) activity_callback = MagicMock(side_effect=empty_coroutine) proto = pyotgw.protocol.OpenThermProtocol( pygw.status, activity_callback, ) proto.activity_callback = activity_callback pygw._transport = trans pygw._protocol = proto with patch( "pyotgw.messageprocessor.MessageProcessor._process_msgs", return_value=None ): proto.connection_made(trans) yield proto await proto.cleanup() @pytest_asyncio.fixture async def pygw_status(): """Return a StatusManager object""" status_manager = StatusManager() yield status_manager await status_manager.cleanup() @pytest.fixture def pygw_message_processor(pygw_proto): """Return a MessageProcessor object""" return pygw_proto.message_processor @pytest_asyncio.fixture async def pygw_conn(pygw): """Return a ConnectionManager object""" connection_manager = ConnectionManager(pygw) yield connection_manager await connection_manager.watchdog.stop() @pytest_asyncio.fixture async def pygw_watchdog(): """Return a ConnectionWatchdog object""" watchdog = ConnectionWatchdog() yield watchdog await watchdog.stop() @pytest_asyncio.fixture(autouse=True) async def check_task_cleanup(): loop = asyncio.get_running_loop() task_count = len(asyncio.all_tasks(loop)) yield assert len(asyncio.all_tasks(loop)) == task_count, "Test is leaving tasks behind!" pyotgw-2.2.2/tests/data.py000066400000000000000000000242601470422227100155000ustar00rootroot00000000000000"""Test data for pyotgw tests""" from types import SimpleNamespace import pyotgw.vars as v _report_responses_51 = { v.OTGW_REPORT_ABOUT: "A=OpenTherm Gateway 5.1", v.OTGW_REPORT_BUILDDATE: "B=17:44 11-02-2021", v.OTGW_REPORT_CLOCKMHZ: "C=4 MHz", v.OTGW_REPORT_DHW_SETTING: "W=A", v.OTGW_REPORT_GPIO_FUNCS: "G=10", v.OTGW_REPORT_GPIO_STATES: "I=00", v.OTGW_REPORT_LED_FUNCS: "L=FXOMPC", v.OTGW_REPORT_GW_MODE: "M=G", v.OTGW_REPORT_RST_CAUSE: "Q=C", v.OTGW_REPORT_SETBACK_TEMP: "S=16.50", v.OTGW_REPORT_SETPOINT_OVRD: "O=T20.50", v.OTGW_REPORT_SMART_PWR: "P=Low power", v.OTGW_REPORT_TEMP_SENSOR: "D=0", v.OTGW_REPORT_THERMOSTAT_DETECT: "R=D", v.OTGW_REPORT_TWEAKS: "T=11", v.OTGW_REPORT_VREF: "V=3", } _report_responses_42 = { v.OTGW_REPORT_ABOUT: "A=OpenTherm Gateway 4.2.5", v.OTGW_REPORT_BUILDDATE: "B=17:59 20-10-2015", v.OTGW_REPORT_CLOCKMHZ: None, v.OTGW_REPORT_DHW_SETTING: "W=A", v.OTGW_REPORT_GPIO_FUNCS: "G=10", v.OTGW_REPORT_GPIO_STATES: "I=00", v.OTGW_REPORT_LED_FUNCS: "L=FXOMPC", v.OTGW_REPORT_GW_MODE: "M=G", v.OTGW_REPORT_RST_CAUSE: "Q=C", v.OTGW_REPORT_SETBACK_TEMP: "S=16.50", v.OTGW_REPORT_SETPOINT_OVRD: "O=T20.50", v.OTGW_REPORT_SMART_PWR: "P=Low power", v.OTGW_REPORT_THERMOSTAT_DETECT: "R=D", v.OTGW_REPORT_TWEAKS: "T=11", v.OTGW_REPORT_VREF: "V=3", } _report_expect_51 = { v.BOILER: {}, v.OTGW: { v.OTGW_ABOUT: "OpenTherm Gateway 5.1", v.OTGW_BUILD: "17:44 11-02-2021", v.OTGW_CLOCKMHZ: "4 MHz", v.OTGW_DHW_OVRD: "A", v.OTGW_MODE: "G", v.OTGW_RST_CAUSE: "C", v.OTGW_SMART_PWR: "Low power", v.OTGW_TEMP_SENSOR: "0", v.OTGW_THRM_DETECT: "D", v.OTGW_SETP_OVRD_MODE: "T", v.OTGW_GPIO_A: 1, v.OTGW_GPIO_B: 0, v.OTGW_LED_A: "F", v.OTGW_LED_B: "X", v.OTGW_LED_C: "O", v.OTGW_LED_D: "M", v.OTGW_LED_E: "P", v.OTGW_LED_F: "C", v.OTGW_IGNORE_TRANSITIONS: 1, v.OTGW_OVRD_HB: 1, v.OTGW_SB_TEMP: 16.5, v.OTGW_VREF: 3, }, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, } _report_expect_42 = { v.BOILER: {}, v.OTGW: { v.OTGW_ABOUT: "OpenTherm Gateway 4.2.5", v.OTGW_BUILD: "17:59 20-10-2015", v.OTGW_CLOCKMHZ: None, v.OTGW_DHW_OVRD: "A", v.OTGW_MODE: "G", v.OTGW_RST_CAUSE: "C", v.OTGW_SMART_PWR: "Low power", v.OTGW_TEMP_SENSOR: None, v.OTGW_THRM_DETECT: "D", v.OTGW_SETP_OVRD_MODE: "T", v.OTGW_GPIO_A: 1, v.OTGW_GPIO_B: 0, v.OTGW_LED_A: "F", v.OTGW_LED_B: "X", v.OTGW_LED_C: "O", v.OTGW_LED_D: "M", v.OTGW_LED_E: "P", v.OTGW_LED_F: "C", v.OTGW_IGNORE_TRANSITIONS: 1, v.OTGW_OVRD_HB: 1, v.OTGW_SB_TEMP: 16.5, v.OTGW_VREF: 3, }, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, } pygw_reports = SimpleNamespace( expect_42=_report_expect_42, expect_51=_report_expect_51, report_responses_42=_report_responses_42, report_responses_51=_report_responses_51, ) _status_5 = ( "10101010/01010101,1.23,01010101/10101010,2.34,3.45,4.56,1/0,5.67,6.78,7.89,8.90," "9.09,0.98,9.87,8.76,7.65,6.54,5.43,2,34/56,78/90,4.32,3.21,10101010/01010101,9,8," "7654,6543,54321,43210,32101,21012,10123,99" ) _status_expect_5 = { v.BOILER: { v.DATA_SLAVE_FAULT_IND: 1, v.DATA_SLAVE_CH_ACTIVE: 0, v.DATA_SLAVE_DHW_ACTIVE: 1, v.DATA_SLAVE_FLAME_ON: 0, v.DATA_SLAVE_COOLING_ACTIVE: 1, v.DATA_SLAVE_CH2_ACTIVE: 0, v.DATA_SLAVE_DIAG_IND: 1, v.DATA_REMOTE_TRANSFER_DHW: 1, v.DATA_REMOTE_TRANSFER_MAX_CH: 0, v.DATA_REMOTE_RW_DHW: 0, v.DATA_REMOTE_RW_MAX_CH: 1, v.DATA_SLAVE_MAX_RELATIVE_MOD: 4.56, v.DATA_SLAVE_MAX_CAPACITY: 1, v.DATA_SLAVE_MIN_MOD_LEVEL: 0, v.DATA_REL_MOD_LEVEL: 6.78, v.DATA_CH_WATER_PRESS: 7.89, v.DATA_DHW_FLOW_RATE: 8.90, v.DATA_CH_WATER_TEMP: 9.87, v.DATA_DHW_TEMP: 8.76, v.DATA_OUTSIDE_TEMP: 7.65, v.DATA_RETURN_WATER_TEMP: 6.54, v.DATA_CH_WATER_TEMP_2: 5.43, v.DATA_EXHAUST_TEMP: 2, v.DATA_SLAVE_DHW_MAX_SETP: 34, v.DATA_SLAVE_DHW_MIN_SETP: 56, v.DATA_SLAVE_CH_MAX_SETP: 78, v.DATA_SLAVE_CH_MIN_SETP: 90, v.DATA_DHW_SETPOINT: 4.32, v.DATA_MAX_CH_SETPOINT: 3.21, v.DATA_VH_SLAVE_FAULT_INDICATE: 1, v.DATA_VH_SLAVE_VENT_MODE: 0, v.DATA_VH_SLAVE_BYPASS_STATUS: 1, v.DATA_VH_SLAVE_BYPASS_AUTO_STATUS: 0, v.DATA_VH_SLAVE_FREE_VENT_STATUS: 1, v.DATA_VH_SLAVE_DIAG_INDICATE: 1, v.DATA_VH_RELATIVE_VENT: 8, v.DATA_TOTAL_BURNER_STARTS: 7654, v.DATA_CH_PUMP_STARTS: 6543, v.DATA_DHW_PUMP_STARTS: 54321, v.DATA_DHW_BURNER_STARTS: 43210, v.DATA_TOTAL_BURNER_HOURS: 32101, v.DATA_CH_PUMP_HOURS: 21012, v.DATA_DHW_PUMP_HOURS: 10123, v.DATA_DHW_BURNER_HOURS: 99, }, v.OTGW: {}, v.THERMOSTAT: { v.DATA_MASTER_CH_ENABLED: 0, v.DATA_MASTER_DHW_ENABLED: 1, v.DATA_MASTER_COOLING_ENABLED: 0, v.DATA_MASTER_OTC_ENABLED: 1, v.DATA_MASTER_CH2_ENABLED: 0, v.DATA_CONTROL_SETPOINT: 1.23, v.DATA_ROOM_SETPOINT: 5.67, v.DATA_COOLING_CONTROL: 2.34, v.DATA_CONTROL_SETPOINT_2: 3.45, v.DATA_ROOM_SETPOINT_2: 9.09, v.DATA_ROOM_TEMP: 0.98, v.DATA_VH_MASTER_VENT_ENABLED: 0, v.DATA_VH_MASTER_BYPASS_POS: 1, v.DATA_VH_MASTER_BYPASS_MODE: 0, v.DATA_VH_MASTER_FREE_VENT_MODE: 1, v.DATA_VH_CONTROL_SETPOINT: 9, }, } _status_4 = ( "10101010/01010101,1.23,01010101/10101010,2.34,0/1,3.45,4.56,5.67,6.78,7.89,8.90," "9.09,0.98,12/34,56/78,9.87,8.76,1234,2345,3456,4567,5678,6789,7890,8909" ) _status_expect_4 = { v.BOILER: { v.DATA_SLAVE_FAULT_IND: 1, v.DATA_SLAVE_CH_ACTIVE: 0, v.DATA_SLAVE_DHW_ACTIVE: 1, v.DATA_SLAVE_FLAME_ON: 0, v.DATA_SLAVE_COOLING_ACTIVE: 1, v.DATA_SLAVE_CH2_ACTIVE: 0, v.DATA_SLAVE_DIAG_IND: 1, v.DATA_REMOTE_TRANSFER_DHW: 1, v.DATA_REMOTE_TRANSFER_MAX_CH: 0, v.DATA_REMOTE_RW_DHW: 0, v.DATA_REMOTE_RW_MAX_CH: 1, v.DATA_SLAVE_MAX_RELATIVE_MOD: 2.34, v.DATA_SLAVE_MAX_CAPACITY: 0, v.DATA_SLAVE_MIN_MOD_LEVEL: 1, v.DATA_REL_MOD_LEVEL: 4.56, v.DATA_CH_WATER_PRESS: 5.67, v.DATA_CH_WATER_TEMP: 7.89, v.DATA_DHW_TEMP: 8.90, v.DATA_OUTSIDE_TEMP: 9.09, v.DATA_RETURN_WATER_TEMP: 0.98, v.DATA_SLAVE_DHW_MAX_SETP: 12, v.DATA_SLAVE_DHW_MIN_SETP: 34, v.DATA_SLAVE_CH_MAX_SETP: 56, v.DATA_SLAVE_CH_MIN_SETP: 78, v.DATA_DHW_SETPOINT: 9.87, v.DATA_MAX_CH_SETPOINT: 8.76, v.DATA_TOTAL_BURNER_STARTS: 1234, v.DATA_CH_PUMP_STARTS: 2345, v.DATA_DHW_PUMP_STARTS: 3456, v.DATA_DHW_BURNER_STARTS: 4567, v.DATA_TOTAL_BURNER_HOURS: 5678, v.DATA_CH_PUMP_HOURS: 6789, v.DATA_DHW_PUMP_HOURS: 7890, v.DATA_DHW_BURNER_HOURS: 8909, }, v.OTGW: {}, v.THERMOSTAT: { v.DATA_MASTER_CH_ENABLED: 0, v.DATA_MASTER_DHW_ENABLED: 1, v.DATA_MASTER_COOLING_ENABLED: 0, v.DATA_MASTER_OTC_ENABLED: 1, v.DATA_MASTER_CH2_ENABLED: 0, v.DATA_CONTROL_SETPOINT: 1.23, v.DATA_ROOM_SETPOINT: 3.45, v.DATA_ROOM_TEMP: 6.78, }, } pygw_status = SimpleNamespace( expect_4=_status_expect_4, expect_5=_status_expect_5, status_4=_status_4, status_5=_status_5, ) pygw_proto_messages = ( # Invalid message ID ( ("A", 114, None, None, None), None, ), # _get_flag8 ( ("T", v.READ_DATA, v.MSG_STATUS, b"\x43", b"\x00"), { v.BOILER: {}, v.OTGW: {}, v.THERMOSTAT: { v.DATA_MASTER_CH_ENABLED: 1, v.DATA_MASTER_DHW_ENABLED: 1, v.DATA_MASTER_COOLING_ENABLED: 0, v.DATA_MASTER_OTC_ENABLED: 0, v.DATA_MASTER_CH2_ENABLED: 0, }, }, ), # _get_f8_8 ( ("B", v.WRITE_ACK, v.MSG_TDHWSET, b"\x14", b"\x80"), {v.BOILER: {v.DATA_DHW_SETPOINT: 20.5}, v.OTGW: {}, v.THERMOSTAT: {}}, ), # _get_flag8 with skipped bits ( ( "R", v.READ_ACK, v.MSG_STATUSVH, b"\00", int("01010101", 2).to_bytes(1, "big"), ), { v.BOILER: { v.DATA_VH_SLAVE_FAULT_INDICATE: 1, v.DATA_VH_SLAVE_VENT_MODE: 0, v.DATA_VH_SLAVE_BYPASS_STATUS: 1, v.DATA_VH_SLAVE_BYPASS_AUTO_STATUS: 0, v.DATA_VH_SLAVE_FREE_VENT_STATUS: 1, v.DATA_VH_SLAVE_DIAG_INDICATE: 1, }, v.OTGW: {}, v.THERMOSTAT: {}, }, ), # Combined _get_flag8 and _get_u8 ( ( "R", v.WRITE_ACK, v.MSG_SCONFIG, int("10101010", 2).to_bytes(1, "big"), b"\xFF", ), { v.BOILER: { v.DATA_SLAVE_DHW_PRESENT: 0, v.DATA_SLAVE_CONTROL_TYPE: 1, v.DATA_SLAVE_COOLING_SUPPORTED: 0, v.DATA_SLAVE_DHW_CONFIG: 1, v.DATA_SLAVE_MASTER_LOW_OFF_PUMP: 0, v.DATA_SLAVE_CH2_PRESENT: 1, v.DATA_SLAVE_MEMBERID: 255, }, v.OTGW: {}, v.THERMOSTAT: {}, }, ), # _get_u16 ( ("A", v.READ_ACK, v.MSG_BURNSTARTS, b"\x12", b"\xAA"), {v.BOILER: {}, v.OTGW: {}, v.THERMOSTAT: {v.DATA_TOTAL_BURNER_STARTS: 4778}}, ), # _get_s8 ( ("R", v.WRITE_ACK, v.MSG_TCHSETUL, b"\x50", b"\x1E"), { v.BOILER: {v.DATA_SLAVE_CH_MAX_SETP: 80, v.DATA_SLAVE_CH_MIN_SETP: 30}, v.OTGW: {}, v.THERMOSTAT: {}, }, ), # _get_s16 ( ("B", v.READ_ACK, v.MSG_TEXHAUST, b"\xFF", b"\x83"), {v.BOILER: {v.DATA_EXHAUST_TEMP: -125}, v.OTGW: {}, v.THERMOSTAT: {}}, ), ) pyotgw-2.2.2/tests/helpers.py000066400000000000000000000012701470422227100162250ustar00rootroot00000000000000"""Helper functions for tests""" import asyncio async def called_x_times(mocked, x, timeout=10): """Wait for x or more calls on mocked object or timeout""" async def _wait(): while mocked.call_count < x: await asyncio.sleep(0) await asyncio.wait_for(_wait(), timeout) async def called_once(mocked, timeout=10): """Wait for at least 1 call on mocked object or timeout""" await called_x_times(mocked, 1, timeout) async def let_queue_drain(queue, timeout=10): """Wait for queue to become empty or timeout""" async def _wait(): while not queue.empty(): await asyncio.sleep(0) await asyncio.wait_for(_wait(), timeout) pyotgw-2.2.2/tests/test_commandprocessor.py000066400000000000000000000151271470422227100212060ustar00rootroot00000000000000"""Tests for pyotgw/commandprocessor.py""" import asyncio import logging from unittest.mock import MagicMock, call, patch import pytest import pyotgw.vars as v from tests.helpers import called_once, let_queue_drain @pytest.mark.asyncio async def test_submit_response_queuefull(caplog, pygw_proto): """Test queuefull on submit_response()""" test_lines = ("BCDEF", "A1A2B3C4D", "MustBeCommand", "AlsoCommand") with patch.object( pygw_proto.command_processor._cmdq, "put_nowait", side_effect=asyncio.QueueFull ) as put_nowait, caplog.at_level(logging.ERROR): pygw_proto.line_received(test_lines[3]) pygw_proto.activity_callback.assert_called_once() put_nowait.assert_called_once_with(test_lines[3]) assert pygw_proto.command_processor._cmdq.qsize() == 0 assert caplog.record_tuples == [ ( "pyotgw.commandprocessor", logging.ERROR, f"Queue full, discarded message: {test_lines[3]}", ), ] @pytest.mark.asyncio async def test_issue_cmd(caplog, pygw_proto): """Test OpenThermProtocol.issue_cmd()""" pygw_proto._connected = False with caplog.at_level(logging.DEBUG): assert await pygw_proto.command_processor.issue_cmd("PS", 1, 0) is None assert caplog.record_tuples == [ ( "pyotgw.commandprocessor", logging.DEBUG, "Serial transport closed, not sending command PS", ), ] caplog.clear() loop = asyncio.get_running_loop() pygw_proto._connected = True pygw_proto.command_processor._cmdq.put_nowait("thisshouldbecleared") pygw_proto.transport.write = MagicMock() with caplog.at_level(logging.DEBUG): task = loop.create_task( pygw_proto.command_processor.issue_cmd( v.OTGW_CMD_REPORT, "I", 1, ) ) await let_queue_drain(pygw_proto.command_processor._cmdq) pygw_proto.transport.write.assert_called_once_with(b"PR=I\r\n") assert caplog.record_tuples == [ ( "pyotgw.commandprocessor", logging.DEBUG, "Clearing leftover message from command queue: thisshouldbecleared", ), ( "pyotgw.commandprocessor", logging.DEBUG, "Sending command: PR with value I", ), ] caplog.clear() pygw_proto.command_processor.submit_response("SE") pygw_proto.command_processor.submit_response("SE") with pytest.raises(SyntaxError): await task assert pygw_proto.transport.write.call_args_list == [ call(b"PR=I\r\n"), call(b"PR=I\r\n"), ] assert caplog.record_tuples == [ ( "pyotgw.commandprocessor", logging.DEBUG, "Response submitted. Queue size: 1", ), ( "pyotgw.commandprocessor", logging.DEBUG, "Response submitted. Queue size: 2", ), ( "pyotgw.commandprocessor", logging.DEBUG, "Got possible response for command PR: SE", ), ( "pyotgw.commandprocessor", logging.WARNING, "Command PR failed with SE, retrying...", ), ( "pyotgw.commandprocessor", logging.DEBUG, "Got possible response for command PR: SE", ), ] caplog.clear() pygw_proto.transport.write = MagicMock() with caplog.at_level(logging.WARNING): task = loop.create_task( pygw_proto.command_processor.issue_cmd( v.OTGW_CMD_CONTROL_SETPOINT_2, 20.501, 1, ) ) await called_once(pygw_proto.transport.write) pygw_proto.transport.write.assert_called_once_with(b"C2=20.50\r\n") pygw_proto.command_processor.submit_response("InvalidCommand") pygw_proto.command_processor.submit_response("C2: 20.50") assert await task == "20.50" assert pygw_proto.transport.write.call_args_list == [ call(b"C2=20.50\r\n"), call(b"C2=20.50\r\n"), ] assert caplog.record_tuples == [ ( "pyotgw.commandprocessor", logging.WARNING, "Unknown message in command queue: InvalidCommand", ), ( "pyotgw.commandprocessor", logging.WARNING, "Command C2 failed with InvalidCommand, retrying...", ), ] caplog.clear() pygw_proto.transport.write = MagicMock() with caplog.at_level(logging.WARNING): task = loop.create_task( pygw_proto.command_processor.issue_cmd( v.OTGW_CMD_CONTROL_HEATING_2, -1, 2, ) ) await called_once(pygw_proto.transport.write) pygw_proto.transport.write.assert_called_once_with(b"H2=-1\r\n") pygw_proto.command_processor.submit_response("Error 03") pygw_proto.command_processor.submit_response("H2: BV") pygw_proto.command_processor.submit_response("H2: BV") with pytest.raises(ValueError): await task assert caplog.record_tuples == [ ( "pyotgw.commandprocessor", logging.WARNING, "Received Error 03. " "If this happens during a reset of the gateway it can be safely ignored.", ), ( "pyotgw.commandprocessor", logging.WARNING, "Command H2 failed with Error 03, retrying...", ), ( "pyotgw.commandprocessor", logging.WARNING, "Command H2 failed with H2: BV, retrying...", ), ] pygw_proto.transport.write = MagicMock() task = loop.create_task( pygw_proto.command_processor.issue_cmd( v.OTGW_CMD_MODE, "R", 0, ) ) await called_once(pygw_proto.transport.write) pygw_proto.command_processor.submit_response("ThisGetsIgnored") pygw_proto.command_processor.submit_response("OpenTherm Gateway 4.3.5") assert await task is True pygw_proto.transport.write = MagicMock() task = loop.create_task( pygw_proto.command_processor.issue_cmd( v.OTGW_CMD_SUMMARY, 1, 0, ) ) await called_once(pygw_proto.transport.write) pygw_proto.command_processor.submit_response("PS: 1") pygw_proto.command_processor.submit_response( "part_2_will_normally_be_parsed_by_get_status", ) assert await task == ["1", "part_2_will_normally_be_parsed_by_get_status"] pyotgw-2.2.2/tests/test_connection.py000066400000000000000000000402651470422227100177700ustar00rootroot00000000000000"""Tests for pyotgw/connection.py""" import asyncio import functools import logging from unittest.mock import DEFAULT, MagicMock, patch import pytest import serial from pyotgw.connection import MAX_RETRY_TIMEOUT from pyotgw.protocol import OpenThermProtocol from tests.helpers import called_once, called_x_times @pytest.mark.asyncio async def test_connect_success_and_reconnect(caplog, pygw_conn, pygw_proto): """Test ConnectionManager.connect()""" pygw_conn._error = asyncio.CancelledError() with patch.object( pygw_conn, "_attempt_connect", return_value=(pygw_proto.transport, pygw_proto), ) as attempt_connect, caplog.at_level(logging.DEBUG): assert await pygw_conn.connect("loop://") assert pygw_conn._port == "loop://" attempt_connect.assert_called_once() assert pygw_conn._connecting_task is None assert pygw_conn._error is None assert caplog.record_tuples == [ ( "pyotgw.connection", logging.DEBUG, "Connected to serial device on loop://", ), ] assert pygw_conn._transport == pygw_proto.transport assert pygw_conn.protocol == pygw_proto assert pygw_conn.watchdog.is_active assert pygw_conn.connected await pygw_conn.watchdog.stop() caplog.clear() with patch.object( pygw_conn, "_attempt_connect", return_value=(pygw_proto.transport, pygw_proto), ) as attempt_connect, caplog.at_level(logging.DEBUG): assert await pygw_conn.connect("loop://new") assert pygw_conn._port == "loop://new" attempt_connect.assert_called_once() assert pygw_conn._connecting_task is None assert pygw_conn._error is None assert caplog.record_tuples == [ ( "pyotgw.connection", logging.DEBUG, "Reconnecting to serial device on loop://new", ), ( "pyotgw.connection", logging.DEBUG, "Connected to serial device on loop://new", ), ] assert pygw_conn._transport == pygw_proto.transport assert pygw_conn.protocol == pygw_proto assert pygw_conn.watchdog.is_active assert pygw_conn.connected @pytest.mark.asyncio async def test_connect_cancel(pygw_conn): """Test ConnectionManager.connect() cancellation""" with patch.object( pygw_conn, "_attempt_connect", side_effect=asyncio.CancelledError, ) as create_serial_connection: assert not await pygw_conn.connect("loop://") create_serial_connection.assert_called_once() @pytest.mark.asyncio async def test_disconnect(pygw_conn, pygw_proto): """Test ConnectionManager.disconnect()""" with patch.object( pygw_conn, "_attempt_connect", return_value=(pygw_proto.transport, pygw_proto), ): assert await pygw_conn.connect("loop://") with patch.object( pygw_proto, "disconnect", ) as disconnect: await pygw_conn.disconnect() assert disconnect.called @pytest.mark.asyncio async def test_disconnect_while_connecting(pygw_conn): """Test ConnectionManager.disconnect() during an ongoing connection attempt""" loop = asyncio.get_running_loop() async def wait_forever(): while True: await asyncio.sleep(1) with patch.object( pygw_conn, "_attempt_connect", side_effect=wait_forever, ) as attempt_connect: task = loop.create_task(pygw_conn.connect("loop://")) await called_once(attempt_connect) await pygw_conn.disconnect() assert await task is False assert not pygw_conn.connected @pytest.mark.asyncio async def test_reconnect(caplog, pygw_conn): """Test ConnectionManager.reconnect()""" with patch.object(pygw_conn, "disconnect") as disconnect, patch.object( pygw_conn, "connect", ) as connect, caplog.at_level(logging.ERROR): await pygw_conn.reconnect() disconnect.assert_not_called() connect.assert_not_called() assert caplog.record_tuples == [ ("pyotgw.connection", logging.ERROR, "Reconnect called before connect!") ] caplog.clear() pygw_conn._port = "loop://" with patch.object(pygw_conn, "disconnect") as disconnect, patch.object( pygw_conn._otgw, "connect", ) as connect, caplog.at_level(logging.DEBUG): await pygw_conn.reconnect() assert caplog.record_tuples == [ ("pyotgw.connection", logging.DEBUG, "Scheduling reconnect...") ] disconnect.assert_called_once() connect.assert_called_once_with("loop://") @pytest.mark.asyncio async def test_reconnect_after_connection_loss(caplog, pygw_conn, pygw_proto): """Test ConnectionManager.reconnect() after connection loss""" pygw_conn._error = asyncio.CancelledError() with patch.object( pygw_conn._otgw, "connect", side_effect=pygw_conn.connect, ), patch.object( pygw_conn, "_attempt_connect", return_value=(pygw_proto.transport, pygw_proto), ) as attempt_conn, patch.object( pygw_conn.watchdog, "start", side_effect=pygw_conn.watchdog.start, ) as wd_start, caplog.at_level( logging.DEBUG ): assert await pygw_conn.connect("loop://", timeout=0.001) caplog.clear() attempt_conn.assert_called_once() attempt_conn.reset_mock() await called_x_times(wd_start, 2, timeout=3) assert pygw_conn.protocol.connected attempt_conn.assert_called_once() assert caplog.record_tuples == [ ( "pyotgw.connection", logging.DEBUG, "Watchdog triggered!", ), ( "pyotgw.connection", logging.DEBUG, "Canceling Watchdog task.", ), ( "pyotgw.connection", logging.DEBUG, "Scheduling reconnect...", ), ( "pyotgw.connection", logging.DEBUG, "Reconnecting to serial device on loop://", ), ( "pyotgw.connection", logging.DEBUG, "Connected to serial device on loop://", ), ] def test_connected(pygw_conn, pygw_proto): """Test ConnectionManager.connected()""" pygw_conn.protocol = pygw_proto pygw_conn.protocol._connected = False assert not pygw_conn.connected pygw_conn.protocol._connected = True assert pygw_conn.connected def test_set_connection_config(pygw_conn): """Test ConnectionManager.set_connection_config()""" assert pygw_conn.set_connection_config(baudrate=19200, parity=serial.PARITY_NONE) assert pygw_conn._config == { "baudrate": 19200, "bytesize": serial.EIGHTBITS, "parity": serial.PARITY_NONE, "stopbits": serial.STOPBITS_ONE, } assert not pygw_conn.set_connection_config(baudrate=9600, invalid="value") assert pygw_conn._config == { "baudrate": 19200, "bytesize": serial.EIGHTBITS, "parity": serial.PARITY_NONE, "stopbits": serial.STOPBITS_ONE, } with patch("pyotgw.connection.ConnectionManager.connected", return_value=True): assert not pygw_conn.set_connection_config() @pytest.mark.asyncio async def test_attempt_connect_success(pygw_conn, pygw_proto): """Test ConnectionManager._attempt_connect()""" pygw_conn._port = "loop://" # We cannot compare the functools.partial object which is created in-line # so we have to manually save args and kwargs and compare them one by one. saved_args_list = [] def save_args(*used_args, **used_kwargs): """Store args and kwargs each time we're called""" saved_args_list.append({"args": used_args, "kwargs": used_kwargs}) return DEFAULT with patch( "pyotgw.protocol.OpenThermProtocol.init_and_wait_for_activity", ) as init_and_wait, patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), side_effect=save_args, ) as create_serial_connection: assert await pygw_conn._attempt_connect() == (pygw_proto.transport, pygw_proto) create_serial_connection.assert_called_once() assert len(saved_args_list) == 1 args = saved_args_list[0]["args"] assert len(args) == 3 assert args[0] == asyncio.get_running_loop() assert isinstance(args[1], functools.partial) assert args[1].func == OpenThermProtocol assert args[1].args == ( pygw_conn._otgw.status, pygw_conn.watchdog.inform, ) assert args[2] == pygw_conn._port kwargs = saved_args_list[0]["kwargs"] assert kwargs == { "write_timeout": 0, "baudrate": 9600, "bytesize": serial.EIGHTBITS, "parity": serial.PARITY_NONE, "stopbits": serial.STOPBITS_ONE, } init_and_wait.assert_called_once() @pytest.mark.asyncio async def test_attempt_connect_oserror(caplog, pygw_conn): """Test ConnectionManager._attempt_connect() with OSError""" loop = asyncio.get_running_loop() pygw_conn._port = "loop://" with patch( "serial_asyncio_fast.create_serial_connection", side_effect=OSError, ) as create_serial_connection, patch.object( pygw_conn, "_get_retry_timeout", return_value=0, ) as retry_timeout, caplog.at_level( logging.ERROR ): task = loop.create_task(pygw_conn._attempt_connect()) await called_x_times(retry_timeout, 2) assert create_serial_connection.call_count >= 2 assert caplog.record_tuples == [ ( "pyotgw.connection", logging.ERROR, "Could not connect to serial device on loop://. " "Will keep trying. Reported error was: ", ) ] task.cancel() with pytest.raises(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_attempt_connect_serialexception(caplog, pygw_conn): """Test ConnectionManager._attempt_connect() with SerialException""" loop = asyncio.get_running_loop() pygw_conn._port = "loop://" with patch( "serial_asyncio_fast.create_serial_connection", side_effect=serial.SerialException, ) as create_serial_connection, patch.object( pygw_conn, "_get_retry_timeout", return_value=0, ) as retry_timeout, caplog.at_level( logging.ERROR ): task = loop.create_task(pygw_conn._attempt_connect()) await called_x_times(retry_timeout, 2) assert create_serial_connection.call_count >= 2 assert caplog.record_tuples == [ ( "pyotgw.connection", logging.ERROR, "Could not connect to serial device on loop://. " "Will keep trying. Reported error was: ", ) ] task.cancel() with pytest.raises(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_attempt_connect_timeouterror(caplog, pygw_conn, pygw_proto): """Test ConnectionManager._attempt_connect() with SerialException""" loop = asyncio.get_running_loop() pygw_conn._port = "loop://" pygw_proto.init_and_wait_for_activity = MagicMock(side_effect=asyncio.TimeoutError) pygw_proto.disconnect = MagicMock() with patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), ) as create_serial_connection, patch.object( pygw_conn, "_get_retry_timeout", return_value=0, ) as retry_timeout, caplog.at_level( logging.ERROR ): task = loop.create_task(pygw_conn._attempt_connect()) await called_x_times(retry_timeout, 2) assert create_serial_connection.call_count >= 2 assert pygw_proto.disconnect.call_count >= 2 assert caplog.record_tuples == [ ( "pyotgw.connection", logging.ERROR, "The serial device on loop:// is not responding. " "Will keep trying.", ) ] task.cancel() with pytest.raises(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_attempt_connect_syntaxerror(caplog, pygw_conn, pygw_proto): """Test ConnectionManager._attempt_connect() with SyntaxError""" loop = asyncio.get_running_loop() pygw_conn._port = "loop://" pygw_proto.init_and_wait_for_activity = MagicMock(side_effect=SyntaxError) pygw_proto.disconnect = MagicMock() with patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), ) as create_serial_connection, patch.object( pygw_conn, "_get_retry_timeout", return_value=0, ) as retry_timeout, caplog.at_level( logging.ERROR ): task = loop.create_task(pygw_conn._attempt_connect()) with pytest.raises(SyntaxError): await task assert retry_timeout.call_count == 1 assert create_serial_connection.call_count >= 2 assert pygw_proto.disconnect.call_count >= 2 assert isinstance(pygw_conn._error, SyntaxError) @pytest.mark.asyncio async def test_cleanup(pygw_conn): """Test ConnectionManager._cleanup()""" pass # with patch.object() def test_get_retry_timeout(pygw): """Test pyotgw._get_retry_timeout()""" pygw.connection._retry_timeout = MAX_RETRY_TIMEOUT / 2 assert pygw.connection._get_retry_timeout() == MAX_RETRY_TIMEOUT / 2 assert pygw.connection._get_retry_timeout() == (MAX_RETRY_TIMEOUT / 2) * 1.5 assert pygw.connection._get_retry_timeout() == MAX_RETRY_TIMEOUT def test_is_active(pygw_watchdog): """Test ConnectionWatchdog.is_active()""" assert not pygw_watchdog.is_active pygw_watchdog.start(None, 10) assert pygw_watchdog.is_active @pytest.mark.asyncio async def test_inform_watchdog(caplog, pygw_watchdog): """Test ConnectionWatchdog.inform()""" await pygw_watchdog.inform() async def empty_coroutine(): return pygw_watchdog.start(empty_coroutine, 10) with patch.object( pygw_watchdog._wd_task, "cancel", side_effect=pygw_watchdog._wd_task.cancel, ) as task_cancel, patch.object( pygw_watchdog, "_watchdog", ) as watchdog, caplog.at_level( logging.DEBUG ): await pygw_watchdog.inform() task_cancel.assert_called_once() watchdog.assert_called_once_with(pygw_watchdog.timeout) assert caplog.record_tuples == [ ("pyotgw.connection", logging.DEBUG, "Watchdog timer reset!") ] def test_start(pygw_watchdog): """Test ConnectionWatchdog.start()""" def callback(): return with patch.object( pygw_watchdog, "_watchdog", ) as watchdog: assert pygw_watchdog.start(callback, 10) assert pygw_watchdog.timeout == 10 assert pygw_watchdog._callback == callback watchdog.assert_called_once_with(10) assert not pygw_watchdog.start(callback, 10) @pytest.mark.asyncio async def test_stop(caplog, pygw_watchdog): """Test ConnectionWatchdog.stop()""" with caplog.at_level(logging.DEBUG): await pygw_watchdog.stop() assert caplog.records == [] async def empty_coroutine(): return pygw_watchdog.start(empty_coroutine, 10) with caplog.at_level(logging.DEBUG): await pygw_watchdog.stop() assert not pygw_watchdog.is_active assert caplog.record_tuples == [ ( "pyotgw.connection", logging.DEBUG, "Canceling Watchdog task.", ), ] @pytest.mark.asyncio async def test_watchdog(caplog, pygw_watchdog): """Test ConnectionWatchdog._watchdog()""" async def empty_callback(): return watchdog_callback = MagicMock(side_effect=empty_callback) pygw_watchdog.start(watchdog_callback, 0) with caplog.at_level(logging.DEBUG): await called_once(watchdog_callback) assert caplog.record_tuples == [ ( "pyotgw.connection", logging.DEBUG, "Watchdog triggered!", ), ( "pyotgw.connection", logging.DEBUG, "Canceling Watchdog task.", ), ] pyotgw-2.2.2/tests/test_messageprocessor.py000066400000000000000000000275061470422227100212200ustar00rootroot00000000000000"""Test for pyotgw/messageprocessor.py""" import asyncio import logging import re from unittest.mock import MagicMock, patch import pytest from pyotgw import vars as v from tests.data import pygw_proto_messages from tests.helpers import called_once MATCH_PATTERN = r"^(T|B|R|A|E)([0-9A-F]{8})$" @pytest.mark.asyncio async def test_cleanup(pygw_message_processor): """Test MessageProcessor.cleanup()""" assert pygw_message_processor._task await pygw_message_processor.cleanup() assert not pygw_message_processor._task def test_connection_lost(pygw_message_processor): """Test MessageProcessor.connection_lost()""" message = re.match(MATCH_PATTERN, "A01020304") pygw_message_processor.submit_matched_message(message) pygw_message_processor.submit_matched_message(message) pygw_message_processor.submit_matched_message(message) pygw_message_processor.connection_lost() assert pygw_message_processor._msgq.empty() def test_submit_matched_message(caplog, pygw_message_processor): """Tests MessageProcessor.submit_matched_message()""" bad_match = re.match(MATCH_PATTERN, "E01020304") good_match = re.match(MATCH_PATTERN, "A01020304") pygw_message_processor.submit_matched_message(bad_match) assert pygw_message_processor._msgq.empty() with caplog.at_level(logging.DEBUG): pygw_message_processor.submit_matched_message(good_match) assert caplog.record_tuples == [ ( "pyotgw.messageprocessor", logging.DEBUG, "Added line to message queue. Queue size: 1", ), ] assert pygw_message_processor._msgq.get_nowait() == ( "A", v.READ_DATA, b"\x02", b"\x03", b"\x04", ) def test_dissect_msg(caplog, pygw_message_processor): """Test MessageProcessor._dissect_msg""" pat = r"^(T|B|R|A|E)([0-9A-F]{8})$" test_matches = ( re.match(pat, "A10203040"), re.match(pat, "EEEEEEEEE"), re.match(pat, "AEEEEEEEE"), ) none_tuple = (None, None, None, None, None) assert pygw_message_processor._dissect_msg(test_matches[0]) == ( "A", v.WRITE_DATA, b"\x20", b"\x30", b"\x40", ) with caplog.at_level(logging.INFO): assert pygw_message_processor._dissect_msg(test_matches[1]) == none_tuple assert caplog.record_tuples == [ ( "pyotgw.messageprocessor", logging.INFO, "The OpenTherm Gateway received an erroneous message." " This is not a bug in pyotgw. Ignoring: EEEEEEEE", ) ] assert pygw_message_processor._dissect_msg(test_matches[2]) == none_tuple def test_get_msgtype(pygw_message_processor): """Test MessageProcessor._get_msgtype()""" assert pygw_message_processor._get_msgtype(int("11011111", 2)) == int("0101", 2) assert pygw_message_processor._get_msgtype(int("01000001", 2)) == int("0100", 2) @pytest.mark.asyncio async def test_process_msgs(caplog, pygw_message_processor): """Test MessageProcessor._process_msgs()""" test_case = ( "B", v.READ_ACK, b"\x23", b"\x0A", b"\x01", ) with patch.object( pygw_message_processor, "_process_msg" ) as process_msg, caplog.at_level(logging.DEBUG): task = asyncio.create_task(pygw_message_processor._process_msgs()) pygw_message_processor._msgq.put_nowait(test_case) await called_once(process_msg) task.cancel() with pytest.raises(asyncio.CancelledError): await task process_msg.assert_called_once_with(test_case) assert caplog.record_tuples == [ ("pyotgw.messageprocessor", logging.DEBUG, "Processing: B 04 23 0A 01") ] @pytest.mark.asyncio async def test_process_msg(pygw_message_processor): """Test MessageProcessor._process_msg()""" # Test quirks test_case = ( "B", v.READ_ACK, v.MSG_TROVRD, b"\x10", b"\x80", ) with patch.object( pygw_message_processor, "_quirk_trovrd", return_value=None ) as quirk_trovrd: await pygw_message_processor._process_msg(test_case) quirk_trovrd.assert_called_once_with( v.BOILER, "B", b"\x10", b"\x80", ) async def empty_coroutine(status): return status_callback = MagicMock(side_effect=empty_coroutine) pygw_message_processor.status_manager.subscribe(status_callback) for test_case, expected_result in pygw_proto_messages: pygw_message_processor.status_manager.reset() await pygw_message_processor._process_msg(test_case) if expected_result is not None: await called_once(status_callback) status_callback.assert_called_once_with(expected_result) status_callback.reset_mock() @pytest.mark.asyncio async def test_get_dict_update_for_action(): """Test MessageProcessor._get_dict_update_for_action""" assert True # Fully tested in test_process_msg() @pytest.mark.asyncio async def test_quirk_trovrd(pygw_message_processor): """Test MessageProcessor._quirk_trovrd()""" async def empty_coroutine(stat): return status_callback = MagicMock(side_effect=empty_coroutine) pygw_message_processor.status_manager.subscribe(status_callback) pygw_message_processor.status_manager.submit_partial_update( v.OTGW, {v.OTGW_THRM_DETECT: "I"}, ) await called_once(status_callback) status_callback.reset_mock() with patch.object( pygw_message_processor.command_processor, "issue_cmd", return_value="O=c19.5", ): await pygw_message_processor._quirk_trovrd( v.THERMOSTAT, "A", b"\x15", b"\x40", ) await called_once(status_callback) status_callback.assert_called_once_with( { v.BOILER: {}, v.OTGW: {v.OTGW_THRM_DETECT: "I"}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 19.5}, } ) with patch.object( pygw_message_processor.command_processor, "issue_cmd", return_value="O=q---", ), patch.object( pygw_message_processor.status_manager, "submit_partial_update", ) as partial_update, patch.object( pygw_message_processor.status_manager, "delete_value", ) as delete_value: await pygw_message_processor._quirk_trovrd( v.THERMOSTAT, "A", b"\x15", b"\x40", ) partial_update.assert_not_called() delete_value.assert_not_called() assert ( v.DATA_ROOM_SETPOINT_OVRD in pygw_message_processor.status_manager.status[v.THERMOSTAT] ) status_callback.reset_mock() await pygw_message_processor._quirk_trovrd( v.THERMOSTAT, "A", b"\x00", b"\x00", ) await called_once(status_callback) status_callback.assert_called_once_with( { v.BOILER: {}, v.OTGW: {v.OTGW_THRM_DETECT: "I"}, v.THERMOSTAT: {}, } ) status_callback.reset_mock() pygw_message_processor.status_manager.submit_partial_update( v.OTGW, {v.OTGW_THRM_DETECT: "D"} ) await called_once(status_callback) status_callback.reset_mock() await pygw_message_processor._quirk_trovrd( v.THERMOSTAT, "A", b"\x15", b"\x40", ) await called_once(status_callback) status_callback.assert_called_once_with( { v.BOILER: {}, v.OTGW: {v.OTGW_THRM_DETECT: "D"}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 21.25}, } ) status_callback.reset_mock() pygw_message_processor.status_manager.submit_partial_update( v.OTGW, {v.OTGW_THRM_DETECT: "I"} ) await called_once(status_callback) status_callback.reset_mock() with patch.object( pygw_message_processor.command_processor, "issue_cmd", return_value="O=N", ): await pygw_message_processor._quirk_trovrd( v.THERMOSTAT, "A", b"\x15", b"\x40", ) await called_once(status_callback) status_callback.assert_called_once_with( { v.BOILER: {}, v.OTGW: {v.OTGW_THRM_DETECT: "I"}, v.THERMOSTAT: {}, } ) @pytest.mark.asyncio async def test_quirk_trset_s2m(pygw_message_processor): """Test MessageProcessor._quirk_trset_s2m()""" async def empty_coroutine(stat): return with patch.object( pygw_message_processor.status_manager, "submit_partial_update" ) as partial_update: await pygw_message_processor._quirk_trset_s2m( v.THERMOSTAT, b"\x01", b"\x02", ) await pygw_message_processor._quirk_trset_s2m( v.BOILER, b"\x14", b"\x80" ) partial_update.assert_called_once_with( v.BOILER, {v.DATA_ROOM_SETPOINT: 20.5} ) def test_get_flag8(pygw_message_processor): """Test pygw._get_flag8()""" test_cases = ( ( int("00000001", 2).to_bytes(1, "big"), [1, 0, 0, 0, 0, 0, 0, 0], ), ( int("00000010", 2).to_bytes(1, "big"), [0, 1, 0, 0, 0, 0, 0, 0], ), ( int("00000100", 2).to_bytes(1, "big"), [0, 0, 1, 0, 0, 0, 0, 0], ), ( int("00001000", 2).to_bytes(1, "big"), [0, 0, 0, 1, 0, 0, 0, 0], ), ( int("00010000", 2).to_bytes(1, "big"), [0, 0, 0, 0, 1, 0, 0, 0], ), ( int("00100000", 2).to_bytes(1, "big"), [0, 0, 0, 0, 0, 1, 0, 0], ), ( int("01000000", 2).to_bytes(1, "big"), [0, 0, 0, 0, 0, 0, 1, 0], ), ( int("10000000", 2).to_bytes(1, "big"), [0, 0, 0, 0, 0, 0, 0, 1], ), ) for case, res in test_cases: assert pygw_message_processor._get_flag8(case) == res def test_get_u8(pygw_message_processor): """Test pygw._get_u8()""" test_cases = ( ( b"\x00", 0, ), ( b"\xFF", 255, ), ) for case, res in test_cases: assert pygw_message_processor._get_u8(case) == res def test_get_s8(pygw_message_processor): """Test pygw._get_s8()""" test_cases = ( ( b"\x00", 0, ), ( b"\xFF", -1, ), ) for case, res in test_cases: assert pygw_message_processor._get_s8(case) == res def test_get_f8_8(pygw_message_processor): """Test pygw._get_f8_8()""" test_cases = ( ( ( b"\x00", b"\x00", ), 0.0, ), ( ( b"\xFF", b"\x80", ), -0.5, ), ) for case, res in test_cases: assert pygw_message_processor._get_f8_8(*case) == res def test_get_u16(pygw_message_processor): """Test pygw._get_u16()""" test_cases = ( ( ( b"\x00", b"\x00", ), 0, ), ( ( b"\xFF", b"\xFF", ), 65535, ), ) for case, res in test_cases: assert pygw_message_processor._get_u16(*case) == res def test_get_s16(pygw_message_processor): """Test pygw._get_s16()""" test_cases = ( ( ( b"\x00", b"\x00", ), 0, ), ( ( b"\xFF", b"\xFF", ), -1, ), ) for case, res in test_cases: assert pygw_message_processor._get_s16(*case) == res pyotgw-2.2.2/tests/test_messages.py000066400000000000000000000011651470422227100174340ustar00rootroot00000000000000"""Tests for pyotgw/messages.py""" import pyotgw.messages as m from pyotgw.messageprocessor import MessageProcessor def test_message_registry(): """Test message registry values.""" for msgid, processing in m.REGISTRY.items(): assert 0 <= int.from_bytes(msgid, "big") < 128 assert isinstance(processing[m.M2S], list) assert isinstance(processing[m.S2M], list) for action in [*processing[m.M2S], *processing[m.S2M]]: assert hasattr(MessageProcessor, action[m.FUNC]) assert isinstance(action[m.ARGS], tuple) assert isinstance(action[m.RETURNS], tuple) pyotgw-2.2.2/tests/test_protocol.py000066400000000000000000000123671470422227100174740ustar00rootroot00000000000000"""Tests for pyotgw/protocol.py""" import asyncio import logging from unittest.mock import patch import pytest import pyotgw.vars as v from tests.helpers import called_x_times def test_connection_made(pygw_proto): """Test OpenThermProtocol.connection_made()""" # pygw_proto already calls connection_made() assert pygw_proto.connected def test_connection_lost(caplog, pygw_proto): """Test OpenThermProtocol.connection_lost()""" pygw_proto._received_lines = 1 pygw_proto.command_processor.submit_response("test cmdq") assert not pygw_proto.command_processor._cmdq.empty() with caplog.at_level(logging.ERROR): pygw_proto.connection_lost(None) assert not pygw_proto.active assert pygw_proto.command_processor._cmdq.empty() assert pygw_proto.status_manager.status == v.DEFAULT_STATUS assert caplog.record_tuples == [ ( "pyotgw.protocol", logging.ERROR, "Disconnected: None", ), ] def test_connected(pygw_proto): """Test OpenThermProtocol.connected()""" assert pygw_proto.connected is True pygw_proto._connected = False assert pygw_proto.connected is False @pytest.mark.asyncio async def test_cleanup(pygw_proto): """Test OpenThermProtocol.cleanup()""" with patch.object( pygw_proto.message_processor, "cleanup", ) as message_processor_cleanup: await pygw_proto.cleanup() message_processor_cleanup.assert_called_once() def test_disconnect(pygw_proto): """Test OpenThermProtocol.disconnect()""" pygw_proto.disconnect() pygw_proto._connected = True pygw_proto.transport.is_closing.return_value = False pygw_proto.disconnect() assert not pygw_proto.connected pygw_proto.transport.close.assert_called_once() def test_data_received(caplog, pygw_proto): """Test OpenThermProtocol.data_received()""" test_input = ( b"ignorethis\x04A123", b"45678\r\n", b"\x80\r\n", ) with patch.object(pygw_proto, "line_received") as line_received: pygw_proto.data_received(test_input[0]) line_received.assert_not_called() pygw_proto.data_received(test_input[1]) line_received.assert_called_once_with("A12345678") with caplog.at_level(logging.DEBUG): pygw_proto.data_received(test_input[2]) assert pygw_proto._readbuf == b"" assert caplog.record_tuples == [ ( "pyotgw.protocol", logging.DEBUG, "Invalid data received, ignoring...", ), ] @pytest.mark.asyncio async def test_line_received(caplog, pygw_proto): """Test OpenThermProtocol.line_received()""" test_lines = ("BCDEF", "A1A2B3C4D", "MustBeCommand", "AlsoCommand") with caplog.at_level(logging.DEBUG): pygw_proto.line_received(test_lines[0]) pygw_proto.activity_callback.assert_called_once() assert not pygw_proto.active assert caplog.record_tuples == [ ( "pyotgw.protocol", logging.DEBUG, f"Received line 1: {test_lines[0]}", ), ( "pyotgw.protocol", logging.DEBUG, f"Ignoring line: {test_lines[0]}", ), ] pygw_proto.activity_callback.reset_mock() caplog.clear() with patch.object( pygw_proto.message_processor, "submit_matched_message", ) as submit_message, caplog.at_level(logging.DEBUG): pygw_proto.line_received(test_lines[1]) assert pygw_proto.active pygw_proto.activity_callback.assert_called_once() submit_message.assert_called_once() assert caplog.record_tuples == [ ( "pyotgw.protocol", logging.DEBUG, f"Received line 1: {test_lines[1]}", ), ] pygw_proto.activity_callback.reset_mock() caplog.clear() with caplog.at_level(logging.DEBUG): pygw_proto.line_received(test_lines[2]) assert pygw_proto.active pygw_proto.activity_callback.assert_called_once() assert pygw_proto.command_processor._cmdq.qsize() == 1 assert pygw_proto.command_processor._cmdq.get_nowait() == test_lines[2] assert caplog.record_tuples == [ ( "pyotgw.protocol", logging.DEBUG, f"Received line 2: {test_lines[2]}", ), ( "pyotgw.protocol", logging.DEBUG, "Submitting line 2 to CommandProcessor", ), ( "pyotgw.commandprocessor", logging.DEBUG, "Response submitted. Queue size: 1", ), ] pygw_proto.activity_callback.reset_mock() caplog.clear() def test_active(pygw_proto): """Test OpenThermProtocol.active()""" pygw_proto._received_lines = 0 assert pygw_proto.active is False pygw_proto._received_lines = 1 assert pygw_proto.active is True @pytest.mark.asyncio async def test_init_and_wait_for_activity(pygw_proto): """Test OpenThermProtocol.init_and_wait_for_activity()""" loop = asyncio.get_running_loop() with patch.object(pygw_proto.command_processor, "issue_cmd") as issue_cmd: task = loop.create_task(pygw_proto.init_and_wait_for_activity()) await called_x_times(issue_cmd, 1) pygw_proto._received_lines = 1 await task pyotgw-2.2.2/tests/test_pyotgw.py000066400000000000000000000730241470422227100171610ustar00rootroot00000000000000"""Tests for pyotgw/pyotgw.py""" import asyncio import logging from datetime import datetime from unittest.mock import MagicMock, call, patch import pytest import serial import pyotgw.vars as v from tests.data import pygw_reports, pygw_status from tests.helpers import called_once, called_x_times @pytest.mark.asyncio async def test_cleanup(pygw): """Test pyotgw.cleanup()""" pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_A: 0}) pygw.loop = asyncio.get_running_loop() with patch.object(pygw, "_wait_for_cmd"): await pygw._poll_gpio() assert pygw._gpio_task await pygw.cleanup() assert not pygw._gpio_task @pytest.mark.asyncio async def test_connect_success_and_reconnect_with_gpio(caplog, pygw, pygw_proto): """Test pyotgw.connect()""" with patch.object(pygw, "get_reports", return_value={}), patch.object( pygw, "get_status", return_value={}, ), patch.object(pygw, "_poll_gpio") as poll_gpio, patch.object( pygw_proto, "init_and_wait_for_activity", ) as init_and_wait, patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), ), caplog.at_level( logging.DEBUG ): status = await pygw.connect("loop://") assert status == v.DEFAULT_STATUS init_and_wait.assert_called_once() poll_gpio.assert_called_once() await pygw.connection.watchdog.stop() await pygw.connection.watchdog._callback() assert ( "pyotgw.connection", logging.DEBUG, "Scheduling reconnect...", ) in caplog.record_tuples await pygw.disconnect() @pytest.mark.asyncio async def test_connect_skip_init(caplog, pygw, pygw_proto): """Test pyotgw.connect() with skip_init""" with patch.object( pygw, "get_reports", return_value={} ) as get_reports, patch.object( pygw, "get_status", return_value={}, ) as get_status, patch.object( pygw, "_poll_gpio" ) as poll_gpio, patch.object( pygw_proto, "init_and_wait_for_activity", ) as init_and_wait, patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), ), caplog.at_level( logging.DEBUG ): status = await pygw.connect("loop://", skip_init=True) assert status == v.DEFAULT_STATUS init_and_wait.assert_called_once() poll_gpio.assert_called_once() get_reports.assert_not_awaited() get_status.assert_not_awaited() await pygw.connection.watchdog.stop() await pygw.connection.watchdog._callback() assert ( "pyotgw.connection", logging.DEBUG, "Scheduling reconnect...", ) in caplog.record_tuples await pygw.disconnect() @pytest.mark.asyncio async def test_connect_serialexception(caplog, pygw): """Test pyotgw.connect() with SerialException""" loop = asyncio.get_running_loop() with patch( "serial_asyncio_fast.create_serial_connection", side_effect=serial.serialutil.SerialException, ) as create_serial_connection, patch.object( pygw.connection, "_get_retry_timeout", return_value=0, ) as loops_done: task = loop.create_task(pygw.connect("loop://")) await called_x_times(loops_done, 2) assert isinstance(pygw.connection._connecting_task, asyncio.Task) assert len(caplog.records) == 1 assert caplog.record_tuples == [ ( "pyotgw.connection", logging.ERROR, "Could not connect to serial device on loop://. " "Will keep trying. Reported error was: ", ), ] assert create_serial_connection.call_count > 1 task.cancel() assert await task is False @pytest.mark.asyncio async def test_connect_cancel(pygw): """Test pyotgw.connect() with CancelledError""" with patch( "serial_asyncio_fast.create_serial_connection", side_effect=asyncio.CancelledError, ) as create_serial_connection: status = await pygw.connect("loop://") assert status is False create_serial_connection.assert_called_once() @pytest.mark.asyncio async def test_connect_timeouterror(caplog, pygw, pygw_proto): """Test pyotgw.connect() with TimeoutError""" loop = asyncio.get_running_loop() # Mock these before the 'with' context manager to ensure the mocks get # included in the patched response for create_serial_connection(). pygw_proto.init_and_wait_for_activity = MagicMock(side_effect=asyncio.TimeoutError) pygw_proto.disconnect = MagicMock() with patch.object( pygw.connection, "_get_retry_timeout", return_value=0, ) as loops_done, patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), ), caplog.at_level( logging.DEBUG ): task = loop.create_task(pygw.connect("loop://")) await called_x_times(loops_done, 2) assert isinstance(pygw.connection._connecting_task, asyncio.Task) assert len(caplog.records) == 1 assert caplog.record_tuples == [ ( "pyotgw.connection", logging.ERROR, "The serial device on loop:// is not responding. " "Will keep trying.", ), ] assert pygw_proto.init_and_wait_for_activity.call_count > 1 assert pygw_proto.disconnect.call_count > 1 task.cancel() assert await task is False @pytest.mark.asyncio async def test_disconnect_while_connecting(pygw): """Test pyotgw.disconnect()""" loop = asyncio.get_running_loop() with patch( "serial_asyncio_fast.create_serial_connection", side_effect=serial.SerialException, ): task = loop.create_task(pygw.connect("loop://")) with patch( "pyotgw.protocol.OpenThermProtocol.disconnect" ) as protocol_disconnect: await pygw.disconnect() assert await task is False assert not protocol_disconnect.called def test_set_connection_options(pygw): """Test pyotgw.set_connection_options""" # Just test forwarding, we test the actual functionality in # ConnectionManagerData.set_connection_options() with patch.object(pygw.connection, "set_connection_config") as set_config: pygw.set_connection_options(just="some", random="kwargs") set_config.assert_called_once_with(just="some", random="kwargs") @pytest.mark.asyncio async def test_set_target_temp(pygw): """Test pyotgw.set_target_temp()""" with pytest.raises(TypeError): await pygw.set_target_temp(None) with patch.object(pygw, "_wait_for_cmd", return_value=None) as wait_for_cmd: assert await pygw.set_target_temp(12.3) is None wait_for_cmd.assert_called_once_with( v.OTGW_CMD_TARGET_TEMP, "12.3", v.OTGW_DEFAULT_TIMEOUT, ) with patch.object( pygw, "_wait_for_cmd", return_value="0.00", ) as wait_for_cmd, patch.object( pygw.status, "submit_full_update", ) as update_full_status: temp = await pygw.set_target_temp(0, timeout=5) assert isinstance(temp, float) assert temp == 0 wait_for_cmd.assert_called_once_with( v.OTGW_CMD_TARGET_TEMP, "0.0", 5, ) update_full_status.assert_called_once_with( { v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_DISABLED}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: None}, } ) with patch.object( pygw, "_wait_for_cmd", return_value="15.50", ) as wait_for_cmd, patch.object( pygw.status, "submit_full_update", ) as update_full_status: temp = await pygw.set_target_temp(15.5) assert temp == 15.5 wait_for_cmd.assert_called_once_with(v.OTGW_CMD_TARGET_TEMP, "15.5", 3) update_full_status.assert_called_once_with( { v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_TEMPORARY}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 15.5}, } ) with patch.object( pygw, "_wait_for_cmd", return_value="20.50", ) as wait_for_cmd, patch.object( pygw.status, "submit_full_update", ) as update_full_status: temp = await pygw.set_target_temp(20.5, temporary=False) assert temp == 20.5 wait_for_cmd.assert_called_once_with(v.OTGW_CMD_TARGET_TEMP_CONST, "20.5", 3) update_full_status.assert_called_once_with( { v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_PERMANENT}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, } ) @pytest.mark.asyncio async def test_set_temp_sensor_function(pygw): """Test pyotgw.set_temp_sensor_function()""" assert await pygw.set_temp_sensor_function("P") is None with patch.object(pygw, "_wait_for_cmd", return_value=None) as wait_for_cmd: assert await pygw.set_temp_sensor_function("O") is None wait_for_cmd.assert_called_once_with( v.OTGW_CMD_TEMP_SENSOR, "O", v.OTGW_DEFAULT_TIMEOUT, ) with patch.object( pygw, "_wait_for_cmd", return_value="R", ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update" ) as update_status: assert await pygw.set_temp_sensor_function("R", timeout=5) == "R" wait_for_cmd.assert_called_once_with(v.OTGW_CMD_TEMP_SENSOR, "R", 5) update_status.assert_called_once_with(v.OTGW, {v.OTGW_TEMP_SENSOR: "R"}) @pytest.mark.asyncio async def test_set_outside_temp(pygw): """Test pyotgw.set_outside_temp()""" assert await pygw.set_outside_temp(-40.1) is None with pytest.raises(TypeError): await pygw.set_outside_temp(None) with patch.object(pygw, "_wait_for_cmd", return_value=None) as wait_for_cmd: assert await pygw.set_outside_temp(0, timeout=5) is None wait_for_cmd.assert_called_once_with(v.OTGW_CMD_OUTSIDE_TEMP, "0.0", 5) with patch.object( pygw, "_wait_for_cmd", return_value="23.5", ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update" ) as update_status: assert await pygw.set_outside_temp(23.5) == 23.5 wait_for_cmd.assert_called_once_with( v.OTGW_CMD_OUTSIDE_TEMP, "23.5", v.OTGW_DEFAULT_TIMEOUT ) update_status.assert_called_once_with(v.THERMOSTAT, {v.DATA_OUTSIDE_TEMP: 23.5}) with patch.object( pygw, "_wait_for_cmd", return_value="-", ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_outside_temp(99) == "-" wait_for_cmd.assert_called_once_with( v.OTGW_CMD_OUTSIDE_TEMP, "99.0", v.OTGW_DEFAULT_TIMEOUT ) update_status.assert_called_once_with(v.THERMOSTAT, {v.DATA_OUTSIDE_TEMP: 0.0}) @pytest.mark.asyncio async def test_set_clock(pygw): """Test pyotgw.set_clock()""" dt = datetime(year=2021, month=3, day=12, hour=12, minute=34) with patch.object(pygw, "_wait_for_cmd", return_value="12:34/5") as wait_for_cmd: assert await pygw.set_clock(dt) == "12:34/5" wait_for_cmd.assert_called_once_with( v.OTGW_CMD_SET_CLOCK, "12:34/5", v.OTGW_DEFAULT_TIMEOUT ) with patch.object(pygw, "_wait_for_cmd", return_value="12:34/5") as wait_for_cmd: assert await pygw.set_clock(dt, timeout=5) == "12:34/5" wait_for_cmd.assert_called_once_with(v.OTGW_CMD_SET_CLOCK, "12:34/5", 5) @pytest.mark.asyncio async def test_get_reports(pygw): """Test pyotgw.get_reports()""" def get_response_42(cmd, val): """Get response from dict or raise ValueError""" try: return pygw_reports.report_responses_42[val] except KeyError: raise ValueError with patch.object( pygw, "_wait_for_cmd", side_effect=lambda _, v: pygw_reports.report_responses_51[v], ): assert await pygw.get_reports() == pygw_reports.expect_51 pygw.status.reset() with patch.object( pygw, "_wait_for_cmd", side_effect=get_response_42, ): assert await pygw.get_reports() == pygw_reports.expect_42 with patch.object( pygw, "_wait_for_cmd", side_effect=["OpenTherm Gateway 5.1", ValueError], ), pytest.raises(ValueError): await pygw.get_reports() @pytest.mark.asyncio async def test_get_status(pygw): """Test pyotgw.get_status()""" pygw.loop = asyncio.get_running_loop() with patch.object( pygw, "_wait_for_cmd", side_effect=[None, (None, pygw_status.status_5), (None, pygw_status.status_4)], ): assert await pygw.get_status() is None assert await pygw.get_status() == pygw_status.expect_5 pygw.status.reset() assert await pygw.get_status() == pygw_status.expect_4 @pytest.mark.asyncio async def test_set_hot_water_ovrd(pygw): """Test pyotgw.set_hot_water_ovrd()""" with patch.object( pygw, "_wait_for_cmd", side_effect=[None, "A", "1"], ) as wait_for_cmd: assert await pygw.set_hot_water_ovrd(0) is None assert await pygw.set_hot_water_ovrd("A", 5) == "A" assert await pygw.set_hot_water_ovrd(1) == 1 assert wait_for_cmd.call_count == 3 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_HOT_WATER, 0, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_HOT_WATER, "A", 5), call(v.OTGW_CMD_HOT_WATER, 1, v.OTGW_DEFAULT_TIMEOUT), ], any_order=False, ) @pytest.mark.asyncio async def test_set_mode(pygw): """Test pyotgw.set_mode()""" with patch.object(pygw, "_wait_for_cmd", side_effect=[None, v.OTGW_MODE_MONITOR]): assert await pygw.set_mode(v.OTGW_MODE_GATEWAY) is None assert await pygw.set_mode(v.OTGW_MODE_MONITOR) == v.OTGW_MODE_MONITOR with patch.object( pygw, "_wait_for_cmd", return_value=v.OTGW_MODE_RESET, ), patch.object(pygw, "get_reports") as get_reports, patch.object( pygw, "get_status", ) as get_status: assert await pygw.set_mode(v.OTGW_MODE_RESET) == v.DEFAULT_STATUS get_reports.assert_called_once() get_status.assert_called_once() @pytest.mark.asyncio async def test_set_led_mode(pygw): """Test pyotgw.set_led_mode()""" assert await pygw.set_led_mode("G", "A") is None with patch.object( pygw, "_wait_for_cmd", side_effect=[None, "X"], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update" ) as update_status: assert await pygw.set_led_mode("B", "H") is None assert await pygw.set_led_mode("A", "X", timeout=5) == "X" assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_LED_B, "H", v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_LED_A, "X", 5), ], any_order=False, ) update_status.assert_called_once_with(v.OTGW, {v.OTGW_LED_A: "X"}) @pytest.mark.asyncio async def test_set_gpio_mode(pygw): """Test pyotgw.set_gpio_mode()""" assert await pygw.set_gpio_mode("A", 9) is None with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 3], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update" ) as update_status: assert await pygw.set_gpio_mode("A", 7) is None assert await pygw.set_gpio_mode("A", 6) is None assert await pygw.set_gpio_mode("B", 3, timeout=5) == 3 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_GPIO_A, 6, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_GPIO_B, 3, 5), ], any_order=False, ) update_status.assert_called_once_with(v.OTGW, {v.OTGW_GPIO_B: 3}) @pytest.mark.asyncio async def test_set_setback_temp(pygw): """Test pyotgw.set_setback_temp()""" with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 16.5], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update" ) as update_status: assert await pygw.set_setback_temp(17.5) is None assert await pygw.set_setback_temp(16.5, 5) == 16.5 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_SETBACK, 17.5, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_SETBACK, 16.5, 5), ], any_order=False, ) update_status.assert_called_once_with(v.OTGW, {v.OTGW_SB_TEMP: 16.5}) @pytest.mark.asyncio async def test_add_alternative(pygw): """Test pyotgw.add_alternative()""" assert await pygw.add_alternative(0) is None with patch.object(pygw, "_wait_for_cmd", side_effect=[None, 23]) as wait_for_cmd: assert await pygw.add_alternative(20) is None assert await pygw.add_alternative(23, 5) == 23 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_ADD_ALT, 20, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_ADD_ALT, 23, 5), ], any_order=False, ) @pytest.mark.asyncio async def test_del_alternative(pygw): """Test pyotgw.del_alternative()""" assert await pygw.del_alternative(0) is None with patch.object(pygw, "_wait_for_cmd", side_effect=[None, 23]) as wait_for_cmd: assert await pygw.del_alternative(20) is None assert await pygw.del_alternative(23, 5) == 23 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_DEL_ALT, 20, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_DEL_ALT, 23, 5), ], any_order=False, ) @pytest.mark.asyncio async def test_add_unknown_id(pygw): """Test pyotgw.add_unknown_id()""" assert await pygw.add_unknown_id(0) is None with patch.object(pygw, "_wait_for_cmd", side_effect=[None, 23]) as wait_for_cmd: assert await pygw.add_unknown_id(20) is None assert await pygw.add_unknown_id(23, 5) == 23 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_UNKNOWN_ID, 20, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_UNKNOWN_ID, 23, 5), ], any_order=False, ) @pytest.mark.asyncio async def test_del_unknown_id(pygw): """Test pyotgw.del_unknown_id()""" assert await pygw.del_unknown_id(0) is None with patch.object(pygw, "_wait_for_cmd", side_effect=[None, 23]) as wait_for_cmd: assert await pygw.del_unknown_id(20) is None assert await pygw.del_unknown_id(23, 5) == 23 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_KNOWN_ID, 20, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_KNOWN_ID, 23, 5), ], any_order=False, ) @pytest.mark.asyncio async def test_set_max_ch_setpoint(pygw): """Test pyotgw.set_max_ch_setpoint()""" with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 74.5], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_max_ch_setpoint(75.5) is None assert await pygw.set_max_ch_setpoint(74.5, 5) == 74.5 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_SET_MAX, 75.5, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_SET_MAX, 74.5, 5), ], any_order=False, ) update_status.assert_called_once_with(v.BOILER, {v.DATA_MAX_CH_SETPOINT: 74.5}) @pytest.mark.asyncio async def test_set_dhw_setpoint(pygw): """Test pyotgw.set_dhw_setpoint()""" with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 54.5], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_dhw_setpoint(55.5) is None assert await pygw.set_dhw_setpoint(54.5, 5) == 54.5 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_SET_WATER, 55.5, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_SET_WATER, 54.5, 5), ], any_order=False, ) update_status.assert_called_once_with(v.BOILER, {v.DATA_DHW_SETPOINT: 54.5}) @pytest.mark.asyncio async def test_set_max_relative_mod(pygw): """Test pyotgw.set_max_relative_mod()""" assert await pygw.set_max_relative_mod(-1) is None with patch.object( pygw, "_wait_for_cmd", side_effect=[None, "-", 55], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_max_relative_mod(56) is None assert await pygw.set_max_relative_mod(54, 5) == "-" assert await pygw.set_max_relative_mod(55) == 55 assert wait_for_cmd.call_count == 3 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_MAX_MOD, 56, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_MAX_MOD, 54, 5), call(v.OTGW_CMD_MAX_MOD, 55, v.OTGW_DEFAULT_TIMEOUT), ], any_order=False, ) assert update_status.call_count == 2 update_status.assert_has_calls( [ call(v.BOILER, {v.DATA_SLAVE_MAX_RELATIVE_MOD: None}), call(v.BOILER, {v.DATA_SLAVE_MAX_RELATIVE_MOD: 55}), ], any_order=False, ) @pytest.mark.asyncio async def test_set_control_setpoint(pygw): """Test pyotgw.set_control_setpoint()""" with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 19.5], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_control_setpoint(21.5) is None assert await pygw.set_control_setpoint(19.5, 5) == 19.5 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_CONTROL_SETPOINT, 21.5, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_CONTROL_SETPOINT, 19.5, 5), ], any_order=False, ) update_status.assert_called_once_with(v.BOILER, {v.DATA_CONTROL_SETPOINT: 19.5}) @pytest.mark.asyncio async def test_set_control_setpoint_2(pygw): """Test pyotgw.set_control_setpoint_2()""" with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 19.5], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_control_setpoint_2(21.5) is None assert await pygw.set_control_setpoint_2(19.5, 5) == 19.5 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_CONTROL_SETPOINT_2, 21.5, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_CONTROL_SETPOINT_2, 19.5, 5), ], any_order=False, ) update_status.assert_called_once_with(v.BOILER, {v.DATA_CONTROL_SETPOINT_2: 19.5}) @pytest.mark.asyncio async def test_set_ch_enable_bit(pygw): """Test pyotgw.set_ch_enable_bit()""" assert await pygw.set_ch_enable_bit(None) is None with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 1], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update" ) as update_status: assert await pygw.set_ch_enable_bit(0) is None assert await pygw.set_ch_enable_bit(1, 5) == 1 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_CONTROL_HEATING, 0, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_CONTROL_HEATING, 1, 5), ], any_order=False, ) update_status.assert_called_once_with(v.BOILER, {v.DATA_MASTER_CH_ENABLED: 1}) @pytest.mark.asyncio async def test_set_ch2_enable_bit(pygw): """Test pyotgw.set_ch2_enable_bit()""" assert await pygw.set_ch2_enable_bit(None) is None with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 1], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_ch2_enable_bit(0) is None assert await pygw.set_ch2_enable_bit(1, 5) == 1 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_CONTROL_HEATING_2, 0, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_CONTROL_HEATING_2, 1, 5), ], any_order=False, ) update_status.assert_called_once_with(v.BOILER, {v.DATA_MASTER_CH2_ENABLED: 1}) @pytest.mark.asyncio async def test_set_ventilation(pygw): """Test pyotgw.set_ventilation()""" assert await pygw.set_ventilation(-1) is None with patch.object( pygw, "_wait_for_cmd", side_effect=[None, 75], ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status: assert await pygw.set_ventilation(25) is None assert await pygw.set_ventilation(75, 5) == 75 assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ call(v.OTGW_CMD_VENT, 25, v.OTGW_DEFAULT_TIMEOUT), call(v.OTGW_CMD_VENT, 75, 5), ], any_order=False, ) update_status.assert_called_once_with(v.BOILER, {v.DATA_COOLING_CONTROL: 75}) @pytest.mark.asyncio async def test_send_transparent_command(pygw): """Test pyotgw.send_transparent_command()""" with patch.object( pygw, "_wait_for_cmd", side_effect=["CD"], ) as wait_for_cmd: assert await pygw.send_transparent_command("AB", "CD") == "CD" assert wait_for_cmd.call_count == 1 wait_for_cmd.assert_has_awaits( [ call("AB", "CD", v.OTGW_DEFAULT_TIMEOUT), ], ) def test_subscribe_and_unsubscribe(pygw): """Test pyotgw.subscribe() and pyotgw.unsubscribe()""" async def empty_coroutine(status): return async def empty_coroutine_2(status): return assert pygw.subscribe(empty_coroutine) assert pygw.subscribe(empty_coroutine_2) assert not pygw.subscribe(empty_coroutine_2) assert pygw.status._notify == [empty_coroutine, empty_coroutine_2] assert pygw.unsubscribe(empty_coroutine) assert not pygw.unsubscribe(empty_coroutine) assert pygw.status._notify == [empty_coroutine_2] @pytest.mark.asyncio async def test_wait_for_cmd(caplog, pygw, pygw_proto): """Test pyotgw.wait_for_cmd()""" assert await pygw._wait_for_cmd(None, None) is None with patch( "pyotgw.connection.ConnectionManager.connected", return_value=True, ), patch.object( pygw_proto.command_processor, "issue_cmd", side_effect=[None, "0", asyncio.TimeoutError, ValueError], ) as issue_cmd, caplog.at_level( logging.ERROR ): assert await pygw._wait_for_cmd(v.OTGW_CMD_MODE, "G") is None assert await pygw._wait_for_cmd(v.OTGW_CMD_SUMMARY, 0) == "0" assert await pygw._wait_for_cmd(v.OTGW_CMD_REPORT, "I", 1) is None assert await pygw._wait_for_cmd(v.OTGW_CMD_MAX_MOD, -1) is None assert issue_cmd.await_count == 4 issue_cmd.assert_has_awaits( [ call(v.OTGW_CMD_MODE, "G"), call(v.OTGW_CMD_SUMMARY, 0), call(v.OTGW_CMD_REPORT, "I"), call(v.OTGW_CMD_MAX_MOD, -1), ], any_order=False, ) assert caplog.record_tuples == [ ( "pyotgw.pyotgw", logging.ERROR, f"Timed out waiting for command: {v.OTGW_CMD_REPORT}, value: I.", ), ( "pyotgw.pyotgw", logging.ERROR, f"Command {v.OTGW_CMD_MAX_MOD} with value -1 raised exception: ", ), ] @pytest.mark.asyncio async def test_poll_gpio(caplog, pygw): """Test pyotgw._poll_gpio()""" pygw._gpio_task = None pygw.loop = asyncio.get_running_loop() pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_A: 4, v.OTGW_GPIO_B: 1}) with caplog.at_level(logging.DEBUG): await pygw._poll_gpio() assert len(caplog.records) == 0 pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_B: 0}) with patch.object( pygw, "_wait_for_cmd", return_value="I=10", ) as wait_for_cmd, patch.object( pygw.status, "submit_partial_update", ) as update_status, caplog.at_level( logging.DEBUG ): await pygw._poll_gpio() await called_once(update_status) assert isinstance(pygw._gpio_task, asyncio.Task) wait_for_cmd.assert_awaited_once_with(v.OTGW_CMD_REPORT, v.OTGW_REPORT_GPIO_STATES) update_status.assert_called_once_with( v.OTGW, {v.OTGW_GPIO_A_STATE: 1, v.OTGW_GPIO_B_STATE: 0}, ) assert caplog.record_tuples == [ ("pyotgw.pyotgw", logging.DEBUG, "Starting GPIO polling routine"), ] caplog.clear() pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_B: 1}) with patch.object( pygw.status, "submit_partial_update", ) as update_status, caplog.at_level(logging.DEBUG): await pygw._poll_gpio() await called_once(update_status) assert pygw._gpio_task is None update_status.assert_called_once_with( v.OTGW, {v.OTGW_GPIO_A_STATE: 0, v.OTGW_GPIO_B_STATE: 0}, ) assert caplog.record_tuples == [ ("pyotgw.pyotgw", logging.DEBUG, "Stopping GPIO polling routine"), ("pyotgw.pyotgw", logging.DEBUG, "GPIO polling routine stopped"), ] pyotgw-2.2.2/tests/test_status.py000066400000000000000000000151141470422227100171470ustar00rootroot00000000000000"""Tests for pyotgw/status.py""" import asyncio import logging from unittest.mock import MagicMock import pytest import pyotgw.vars as v from tests.helpers import called_once def test_reset(pygw_status): """Test StatusManager.reset()""" assert pygw_status.status == v.DEFAULT_STATUS pygw_status.submit_partial_update(v.OTGW, {"Test": "value"}) assert pygw_status.status != v.DEFAULT_STATUS assert not pygw_status._updateq.empty() pygw_status.reset() assert pygw_status.status == v.DEFAULT_STATUS assert pygw_status._updateq.empty() def test_status(pygw_status): """Test StatusManager.status()""" assert pygw_status.status == pygw_status._status assert pygw_status.status is not pygw_status._status def test_delete_value(pygw_status): """Test StatusManager.delete_value()""" assert not pygw_status.delete_value("Invalid", v.OTGW_MODE) assert not pygw_status.delete_value(v.OTGW, v.OTGW_MODE) pygw_status.submit_partial_update(v.THERMOSTAT, {v.DATA_ROOM_SETPOINT: 20.5}) pygw_status._updateq.get_nowait() assert pygw_status.delete_value(v.THERMOSTAT, v.DATA_ROOM_SETPOINT) assert pygw_status._updateq.get_nowait() == v.DEFAULT_STATUS def test_submit_partial_update(caplog, pygw_status): """Test StatusManager.submit_partial_update()""" with caplog.at_level(logging.ERROR): assert not pygw_status.submit_partial_update("Invalid", {}) assert pygw_status._updateq.empty() assert caplog.record_tuples == [ ( "pyotgw.status", logging.ERROR, "Invalid status part for update: Invalid", ), ] caplog.clear() with caplog.at_level(logging.ERROR): assert not pygw_status.submit_partial_update(v.OTGW, "Invalid") assert pygw_status._updateq.empty() assert caplog.record_tuples == [ ( "pyotgw.status", logging.ERROR, f"Update for {v.OTGW} is not a dict: Invalid", ), ] caplog.clear() pygw_status.submit_partial_update(v.BOILER, {v.DATA_CONTROL_SETPOINT: 1.5}) pygw_status.submit_partial_update(v.OTGW, {v.OTGW_ABOUT: "test value"}) pygw_status.submit_partial_update(v.THERMOSTAT, {v.DATA_ROOM_SETPOINT: 20}) assert pygw_status.status == { v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, v.OTGW: {v.OTGW_ABOUT: "test value"}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } assert pygw_status._updateq.qsize() == 3 assert pygw_status._updateq.get_nowait() == { v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, v.OTGW: {}, v.THERMOSTAT: {}, } assert pygw_status._updateq.get_nowait() == { v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, v.OTGW: {v.OTGW_ABOUT: "test value"}, v.THERMOSTAT: {}, } assert pygw_status._updateq.get_nowait() == { v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, v.OTGW: {v.OTGW_ABOUT: "test value"}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } def test_submit_full_update(caplog, pygw_status): """Test StatusManager.submit_full_update()""" assert pygw_status.submit_full_update({}) assert pygw_status._updateq.qsize() == 1 assert pygw_status._updateq.get_nowait() == v.DEFAULT_STATUS with caplog.at_level(logging.ERROR): pygw_status.submit_full_update({"Invalid": {}}) assert pygw_status._updateq.empty() assert caplog.record_tuples == [ ( "pyotgw.status", logging.ERROR, "Invalid status part for update: Invalid", ), ] caplog.clear() with caplog.at_level(logging.ERROR): pygw_status.submit_full_update({v.OTGW: "Invalid"}) assert pygw_status._updateq.empty() assert caplog.record_tuples == [ ( "pyotgw.status", logging.ERROR, f"Update for {v.OTGW} is not a dict: Invalid", ), ] caplog.clear() pygw_status.submit_full_update( { v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, v.OTGW: {v.OTGW_ABOUT: "test value"}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } ) assert pygw_status.status == { v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, v.OTGW: {v.OTGW_ABOUT: "test value"}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } assert pygw_status._updateq.qsize() == 1 assert pygw_status._updateq.get_nowait() == { v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, v.OTGW: {v.OTGW_ABOUT: "test value"}, v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } def test_subscribe(pygw_status): """Test StatusManager.subscribe()""" def empty_callback(): return assert pygw_status.subscribe(empty_callback) assert empty_callback in pygw_status._notify assert not pygw_status.subscribe(empty_callback) def test_unsubscribe(pygw_status): """Test StatusManager.unsubscribe()""" def empty_callback(): return assert not pygw_status.unsubscribe(empty_callback) pygw_status.subscribe(empty_callback) assert pygw_status.unsubscribe(empty_callback) assert empty_callback not in pygw_status._notify @pytest.mark.asyncio async def test_stop_reporting(pygw_status): """Test StatusManager.stop_reporting()""" assert isinstance(pygw_status._update_task, asyncio.Task) await pygw_status.cleanup() assert pygw_status._update_task is None @pytest.mark.asyncio async def test_process_updates(caplog, pygw_status): """Test StatusManager._process_updates()""" await pygw_status.cleanup() pygw_status.__init__() with caplog.at_level(logging.DEBUG): # Let the reporting routine start await asyncio.sleep(0) assert isinstance(pygw_status._update_task, asyncio.Task) assert caplog.record_tuples == [ ( "pyotgw.status", logging.DEBUG, "Starting reporting routine", ), ] caplog.clear() async def empty_callback_1(status): return async def empty_callback_2(status): return mock_callback_1 = MagicMock(side_effect=empty_callback_1) mock_callback_2 = MagicMock(side_effect=empty_callback_2) pygw_status.subscribe(mock_callback_1) pygw_status.subscribe(mock_callback_2) pygw_status.submit_partial_update(v.OTGW, {v.OTGW_ABOUT: "Test Value"}) await asyncio.gather(called_once(mock_callback_1), called_once(mock_callback_2)) for mock in (mock_callback_1, mock_callback_2): mock.assert_called_once_with( { v.BOILER: {}, v.OTGW: {v.OTGW_ABOUT: "Test Value"}, v.THERMOSTAT: {}, } ) pyotgw-2.2.2/tox.ini000066400000000000000000000016171470422227100143670ustar00rootroot00000000000000# tox (https://tox.readthedocs.io/) is a tool for running tests # in multiple virtualenvs. This configuration file will run the # test suite on all supported python versions. To use it, "pip install tox" # and then run "tox" from this directory. [tox] envlist = clean, pylint, py310, py311, py312 skip_missing_interpreters = True [testenv] commands = pytest --cov --cov-append --cov-report=term-missing {posargs} deps = -rrequirements_test.txt [testenv:clean] deps = coverage skip_install = True commands = coverage erase [testenv:pylint] deps = -rrequirements_test.txt commands = pylint {env:PYLINT_ARGS:} {posargs} --fail-under=9.9 pyotgw [testenv:precommit] deps = -rrequirements_test.txt commands = pre-commit run {posargs: --all-files} [flake8] ignore = F403,F405,W503 max-line-length = 88 exclude = .git, .tox, .pytest_cache, __pycache__, build, dist