pax_global_header 0000666 0000000 0000000 00000000064 14704222271 0014513 g ustar 00root root 0000000 0000000 52 comment=1af09580d0bc3f3197f6fb3b1140f4bfee493467
pyotgw-2.2.2/ 0000775 0000000 0000000 00000000000 14704222271 0013047 5 ustar 00root root 0000000 0000000 pyotgw-2.2.2/.coveragerc 0000664 0000000 0000000 00000000026 14704222271 0015166 0 ustar 00root root 0000000 0000000 [run]
source = pyotgw
pyotgw-2.2.2/.github/ 0000775 0000000 0000000 00000000000 14704222271 0014407 5 ustar 00root root 0000000 0000000 pyotgw-2.2.2/.github/workflows/ 0000775 0000000 0000000 00000000000 14704222271 0016444 5 ustar 00root root 0000000 0000000 pyotgw-2.2.2/.github/workflows/ci.yml 0000664 0000000 0000000 00000001756 14704222271 0017573 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000000120 14704222271 0015030 0 ustar 00root root 0000000 0000000 .coverage
.project
.pydevproject
pyotgw.egg-info
__pycache__
.tox
.venv
.vscode
pyotgw-2.2.2/.pre-commit-config.yaml 0000664 0000000 0000000 00000001613 14704222271 0017331 0 ustar 00root root 0000000 0000000 repos:
- 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.md 0000664 0000000 0000000 00000016205 14704222271 0014664 0 ustar 00root root 0000000 0000000 # 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/LICENSE 0000664 0000000 0000000 00000104515 14704222271 0014062 0 ustar 00root root 0000000 0000000 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.md 0000664 0000000 0000000 00000070271 14704222271 0014335 0 ustar 00root root 0000000 0000000 [](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/ 0000775 0000000 0000000 00000000000 14704222271 0014400 5 ustar 00root root 0000000 0000000 pyotgw-2.2.2/pyotgw/__init__.py 0000664 0000000 0000000 00000000160 14704222271 0016506 0 ustar 00root root 0000000 0000000 """The main pyotgw __init__ file"""
from pyotgw.pyotgw import OpenThermGateway
__all__ = ["OpenThermGateway"]
pyotgw-2.2.2/pyotgw/commandprocessor.py 0000664 0000000 0000000 00000011405 14704222271 0020331 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000020401 14704222271 0017106 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000017010 14704222271 0020335 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000037076 14704222271 0016576 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000010547 14704222271 0016622 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000110031 14704222271 0016277 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000007253 14704222271 0016304 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000025231 14704222271 0015730 0 ustar 00root root 0000000 0000000 """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.txt 0000664 0000000 0000000 00000000135 14704222271 0017371 0 ustar 00root root 0000000 0000000 bandit
black
flake8
pre-commit
pylint
pytest
pytest-asyncio
pytest-cov
pyserial-asyncio-fast
pyotgw-2.2.2/setup.py 0000664 0000000 0000000 00000002150 14704222271 0014557 0 ustar 00root root 0000000 0000000 import 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/ 0000775 0000000 0000000 00000000000 14704222271 0014211 5 ustar 00root root 0000000 0000000 pyotgw-2.2.2/tests/__init__.py 0000664 0000000 0000000 00000000023 14704222271 0016315 0 ustar 00root root 0000000 0000000 """pyotgw tests"""
pyotgw-2.2.2/tests/conftest.py 0000664 0000000 0000000 00000004062 14704222271 0016412 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000024260 14704222271 0015500 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000001270 14704222271 0016225 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000015127 14704222271 0021206 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000040265 14704222271 0017770 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000027506 14704222271 0021220 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000001165 14704222271 0017434 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000012367 14704222271 0017474 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000073024 14704222271 0017161 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000015114 14704222271 0017147 0 ustar 00root root 0000000 0000000 """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.ini 0000664 0000000 0000000 00000001617 14704222271 0014367 0 ustar 00root root 0000000 0000000 # 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