This commit is contained in:
darthsandmann 2022-11-29 19:10:30 +01:00
parent 9b39f1af50
commit c74c45998c
57 changed files with 3163 additions and 6 deletions

8
deps/bin/eq3cli vendored Executable file
View File

@ -0,0 +1,8 @@
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from eq3bt.eq3cli import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@ -0,0 +1,225 @@
Changelog
=========
0.2 (2022-07-13)
----------------
- Add publish to pypi workflow (#54) [Teemu R]
- Add bleak backend and make it default (#53) [Teemu R]
- Wrap backend exceptions inside BackendException (#52) [Teemu R]
- Add mac property to thermostat class (#51) [Teemu R]
- Update README, pyproject.toml (#49) [Teemu R]
- Support gattlib as an alternative btle library (#48) [Helmut Grohne]
- Use poetry, add pre-commit hooks & mass format to modern standards,
add CI (#47) [Teemu R]
0.1.12 (2021-11-13)
-------------------
- Add bt interface selection (#44) [Hummel95]
0.1.11 (2019-05-27)
-------------------
- Decoding presets in status messages (#33) [Matthias Erll]
- Adding device serial number and firmware (#31) [Matthias Erll]
- Context.invoke() -> Context.forward() (#28) [Till]
- Require python 3.4 or newer in setup.py, closes #23. [Teemu Rytilahti]
0.1.10 (2018-11-09))
------------------------
- Context.invoke() -> Context.forward() (#28) [Till]
- Require python 3.4 or newer in setup.py, closes #23. [Teemu Rytilahti]
0.1.9 (2018-02-18)
------------------------
- Update to the new construct API (#20) [Arkadiusz Bulski]
0.1.8 (2018-01-20)
------------------
- Update to work with the newest construct release, bump version. [Teemu
Rytilahti]
- Update schedule example, fixes #15. [Teemu Rytilahti]
- Do not suppress exceptions from bluepy, but log them to debug logger
and raise exceptions for users to handle. [Teemu Rytilahti]
- Install flake8 and pylint, which are required for the travis build.
[Teemu Rytilahti]
0.1.7 (2017-10-06)
------------------------
- Fixed setting schedule not working (#9) [horsitis
0.1.6 (2017-04-01)
------------------------
- Version 0.1.6. [Teemu Rytilahti]
- Use debug logging for the first round of connection error. [Teemu
Rytilahti]
- Disallow running with python versions older than 3.4. [Teemu
Rytilahti]
The library _may_ still be python2 compatible though for now,
but this is unsupported and should not be relied on.
- On/Off/Manual mode fixes (#6) [Janne Grunau]
* Handle On/Off mode correctly
* Set temperature in [EQ3BT_MIN_TEMP, EQ3BT_MAX_TEMP] for manual mode
* simplify mode setter function
- Be less noisy on connection errors. [Teemu Rytilahti]
- Require and validate mac address at the cli (#4) [Klemens Schölhorn]
- Add missing structures.py. this was already in pypi package, so no
harm done. [Teemu Rytilahti]
0.1.5 (2017-01-28)
------------------------
- Version 0.1.5. [Teemu Rytilahti]
- Fix manual on/off handling, cleanup the code for next release. [Teemu
Rytilahti]
- Make Thermostat testable. [Teemu Rytilahti]
- Use less magic constants and more structures, fix manual mode setting.
[Teemu Rytilahti]
- Fix setup.py typo. [Teemu Rytilahti]
- Eq3cli: add away command. [Teemu Rytilahti]
- Restructuring with construct for more readable code. [Teemu Rytilahti]
* add set_away(away_ends, temperature) for enabling and disabling away mode
- Add hound-ci config. [Teemu Rytilahti]
0.1.4 (2017-01-15)
------------------
- Version 0.1.4. [Teemu Rytilahti]
- Add away_end property. [Teemu Rytilahti]
- Add changelog. [Teemu Rytilahti]
0.1.3 (2017-01-15)
------------------
- Make eq3bt a module. [Teemu Rytilahti]
- Update README. [Teemu Rytilahti]
- Add scheduling and offset functionality. [Teemu Rytilahti]
- Connection: pretty print messaging in hex. [Teemu Rytilahti]
- Setup.py: fix console script location. [Teemu Rytilahti]
0.1.2 (2017-01-14)
------------------
- Fix packaging, add click dependency, bump to 0.1.2. [Teemu Rytilahti]
- Bump bluepy requirement to 1.0.5. [Teemu Rytilahti]
0.1 (2017-01-14)
----------------
- Restructure bluepy_devices to python-eq3bt. [Teemu Rytilahti]
* Complete restructure of the library. All unnecessary and problematic parts are dropped.
* General cleaning up, making flake8 and pylint happy.
* Updated and documented cli tool, named eq3cli
- Add contextmanager for connection to simplify connecting and
disconnecting. Calling writes on device will build and tear down the
connection automatically. [Teemu Rytilahti]
- Eq3btsmart: do not try to connect on init, allows adding the component
to homeassistant even if the device is not connectable at the moment.
[Teemu Rytilahti]
- Add eq3cli tool. [Teemu Rytilahti]
Included command-line tool can be used to control the device.
All current functionality is available through it, check updated README.md for usage.
- Add logger to ease debugging. [Teemu Rytilahti]
- Increase version to 0.3.0 for the enhaced eq3btsmart support. [Janne
Grunau]
- Eq3btsmart: and support for the comfort and eco temperature presets.
[Janne Grunau]
- Eq3btsmart: add a property for the low battery warning. [Janne Grunau]
- Eq3btsmart: add support for the thermostat's operating lock. [Janne
Grunau]
- Eq3btsmart: add window open mode configuration. [Janne Grunau]
- Eq3btsmart: and property to check window open state. [Janne Grunau]
- Eq3btsmart: report valve state. [Janne Grunau]
- Eq3btsmart: control the supported modes of the thermostat. [Janne
Grunau]
The away mode is not really useful yet.
- Eq3btsmart: verify that temperatures are in min/max range. [Janne
Grunau]
- Eq3btsmart: fix the minimal and maximal temperatures. [Janne Grunau]
4.5 and 30 degree celsius have special meanings and can't be set
in 'auto' mode. 4.5 means off (valve closed even if the temperature
below 4.5 degress). 30 means on (valve permanently open even if the
temperature exceeds 30 degrees).
- Eq3btsmart: the update request needs to include the full time. [Janne
Grunau]
Otherwise the thermostat can set a random time. Also fixes the format of
the set time request.
- Initial update in eq3btsmart.py. [Markus Peter]
- +travis. [Markus Peter]
- Update README.md. [Markus Peter]
- Create README.md. [Markus Peter]
- Initial Commit Version 0.2.0. [Markus Peter]
- Initial commit. [Markus Peter]

View File

@ -0,0 +1 @@
pip

View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,239 @@
Metadata-Version: 2.1
Name: aiofiles
Version: 0.8.0
Summary: File support for asyncio.
License: Apache-2.0
Author: Tin Tvrtkovic
Author-email: tinchester@gmail.com
Requires-Python: >=3.6,<4.0
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Description-Content-Type: text/x-rst
aiofiles: file support for asyncio
==================================
.. image:: https://img.shields.io/pypi/v/aiofiles.svg
:target: https://pypi.python.org/pypi/aiofiles
.. image:: https://travis-ci.org/Tinche/aiofiles.svg?branch=master
:target: https://travis-ci.org/Tinche/aiofiles
.. image:: https://codecov.io/gh/Tinche/aiofiles/branch/master/graph/badge.svg
:target: https://codecov.io/gh/Tinche/aiofiles
.. image:: https://img.shields.io/pypi/pyversions/aiofiles.svg
:target: https://github.com/Tinche/aiofiles
:alt: Supported Python versions
**aiofiles** is an Apache2 licensed library, written in Python, for handling local
disk files in asyncio applications.
Ordinary local file IO is blocking, and cannot easily and portably made
asynchronous. This means doing file IO may interfere with asyncio applications,
which shouldn't block the executing thread. aiofiles helps with this by
introducing asynchronous versions of files that support delegating operations to
a separate thread pool.
.. code-block:: python
async with aiofiles.open('filename', mode='r') as f:
contents = await f.read()
print(contents)
'My file contents'
Asynchronous iteration is also supported.
.. code-block:: python
async with aiofiles.open('filename') as f:
async for line in f:
...
Asynchronous interface to tempfile module.
.. code-block:: python
async with aiofiles.tempfile.TemporaryFile('wb') as f:
await f.write(b'Hello, World!')
Features
--------
- a file API very similar to Python's standard, blocking API
- support for buffered and unbuffered binary files, and buffered text files
- support for ``async``/``await`` (:PEP:`492`) constructs
- async interface to tempfile module
Installation
------------
To install aiofiles, simply:
.. code-block:: bash
$ pip install aiofiles
Usage
-----
Files are opened using the ``aiofiles.open()`` coroutine, which in addition to
mirroring the builtin ``open`` accepts optional ``loop`` and ``executor``
arguments. If ``loop`` is absent, the default loop will be used, as per the
set asyncio policy. If ``executor`` is not specified, the default event loop
executor will be used.
In case of success, an asynchronous file object is returned with an
API identical to an ordinary file, except the following methods are coroutines
and delegate to an executor:
* ``close``
* ``flush``
* ``isatty``
* ``read``
* ``readall``
* ``read1``
* ``readinto``
* ``readline``
* ``readlines``
* ``seek``
* ``seekable``
* ``tell``
* ``truncate``
* ``writable``
* ``write``
* ``writelines``
In case of failure, one of the usual exceptions will be raised.
The ``aiofiles.os`` module contains executor-enabled coroutine versions of
several useful ``os`` functions that deal with files:
* ``stat``
* ``sendfile``
* ``rename``
* ``replace``
* ``remove``
* ``mkdir``
* ``makedirs``
* ``rmdir``
* ``removedirs``
* ``path.exists``
* ``path.isfile``
* ``path.isdir``
* ``path.getsize``
* ``path.getatime``
* ``path.getctime``
* ``path.samefile``
* ``path.sameopenfile``
Tempfile
~~~~~~~~
**aiofiles.tempfile** implements the following interfaces:
- TemporaryFile
- NamedTemporaryFile
- SpooledTemporaryFile
- TemporaryDirectory
Results return wrapped with a context manager allowing use with async with and async for.
.. code-block:: python
async with aiofiles.tempfile.NamedTemporaryFile('wb+') as f:
await f.write(b'Line1\n Line2')
await f.seek(0)
async for line in f:
print(line)
async with aiofiles.tempfile.TemporaryDirectory() as d:
filename = os.path.join(d, "file.ext")
Writing tests for aiofiles
~~~~~~~~~~~~~~~~~~~~~~~~~~
Real file IO can be mocked by patching ``aiofiles.threadpool.sync_open``
as desired. The return type also needs to be registered with the
``aiofiles.threadpool.wrap`` dispatcher:
.. code-block:: python
aiofiles.threadpool.wrap.register(mock.MagicMock)(
lambda *args, **kwargs: threadpool.AsyncBufferedIOBase(*args, **kwargs))
async def test_stuff():
data = 'data'
mock_file = mock.MagicMock()
with mock.patch('aiofiles.threadpool.sync_open', return_value=mock_file) as mock_open:
async with aiofiles.open('filename', 'w') as f:
await f.write(data)
mock_file.write.assert_called_once_with(data)
History
~~~~~~~
0.8.0 (2021-11-27)
``````````````````
* aiofiles is now tested on Python 3.10.
* Added ``aiofiles.os.replace``.
`#107 <https://github.com/Tinche/aiofiles/pull/107>`_
* Added ``aiofiles.os.{makedirs, removedirs}``.
* Added ``aiofiles.os.path.{exists, isfile, isdir, getsize, getatime, getctime, samefile, sameopenfile}``.
`#63 <https://github.com/Tinche/aiofiles/pull/63>`_
* Added `suffix`, `prefix`, `dir` args to ``aiofiles.tempfile.TemporaryDirectory``.
`#116 <https://github.com/Tinche/aiofiles/pull/116>`_
0.7.0 (2021-05-17)
``````````````````
- Added the ``aiofiles.tempfile`` module for async temporary files.
`#56 <https://github.com/Tinche/aiofiles/pull/56>`_
- Switched to Poetry and GitHub actions.
- Dropped 3.5 support.
0.6.0 (2020-10-27)
``````````````````
- `aiofiles` is now tested on ppc64le.
- Added `name` and `mode` properties to async file objects.
`#82 <https://github.com/Tinche/aiofiles/pull/82>`_
- Fixed a DeprecationWarning internally.
`#75 <https://github.com/Tinche/aiofiles/pull/75>`_
- Python 3.9 support and tests.
0.5.0 (2020-04-12)
``````````````````
- Python 3.8 support. Code base modernization (using ``async/await`` instead of ``asyncio.coroutine``/``yield from``).
- Added ``aiofiles.os.remove``, ``aiofiles.os.rename``, ``aiofiles.os.mkdir``, ``aiofiles.os.rmdir``.
`#62 <https://github.com/Tinche/aiofiles/pull/62>`_
0.4.0 (2018-08-11)
``````````````````
- Python 3.7 support.
- Removed Python 3.3/3.4 support. If you use these versions, stick to aiofiles 0.3.x.
0.3.2 (2017-09-23)
``````````````````
- The LICENSE is now included in the sdist.
`#31 <https://github.com/Tinche/aiofiles/pull/31>`_
0.3.1 (2017-03-10)
``````````````````
- Introduced a changelog.
- ``aiofiles.os.sendfile`` will now work if the standard ``os`` module contains a ``sendfile`` function.
Contributing
~~~~~~~~~~~~
Contributions are very welcome. Tests can be run with ``tox``, please ensure
the coverage at least stays the same before you submit a pull request.

View File

@ -0,0 +1,26 @@
aiofiles-0.8.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
aiofiles-0.8.0.dist-info/LICENSE,sha256=y16Ofl9KOYjhBjwULGDcLfdWBfTEZRXnduOspt-XbhQ,11325
aiofiles-0.8.0.dist-info/METADATA,sha256=9WSIVaJhAbMi93mj4ir3932H5Je3JxAcUvH5hgyD0qU,7022
aiofiles-0.8.0.dist-info/RECORD,,
aiofiles-0.8.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
aiofiles-0.8.0.dist-info/WHEEL,sha256=FMw6u7Kp3jrdDDtJRZ3hkPV-9adLQT8pbM4iifKUGfw,85
aiofiles/__init__.py,sha256=6fPaAw6PXV8rszheTedkYcoLHC3KbbPH--eMODJ_-IE,136
aiofiles/__pycache__/__init__.cpython-310.pyc,,
aiofiles/__pycache__/base.cpython-310.pyc,,
aiofiles/__pycache__/os.cpython-310.pyc,,
aiofiles/__pycache__/ospath.cpython-310.pyc,,
aiofiles/base.py,sha256=_ntjFm4olO13y9JEQchLudmgPJ4mAFug5MRi6Efk7g0,2114
aiofiles/os.py,sha256=A8vZ0paqH14JaepvEdfSCv6BAFoV4D1LVmnKfkEG5tQ,719
aiofiles/ospath.py,sha256=cIyDMoGVHTqKoKFXfXVzuIROJQQRmeNqVuNuc9QgPY0,387
aiofiles/tempfile/__init__.py,sha256=y6siScSCxNkydsoAN0OlxSwvgqpk1XoVTvJ0gTvAmTk,7234
aiofiles/tempfile/__pycache__/__init__.cpython-310.pyc,,
aiofiles/tempfile/__pycache__/temptypes.cpython-310.pyc,,
aiofiles/tempfile/temptypes.py,sha256=ZwWLAV3eiWWW0QoRDPHN2U7XPf0DUQFYPRid7o4Sk9s,2169
aiofiles/threadpool/__init__.py,sha256=JaRiXZRaVvx32RvrUXwgZxAByrzqdmDcxi6t_P2iP6w,2273
aiofiles/threadpool/__pycache__/__init__.cpython-310.pyc,,
aiofiles/threadpool/__pycache__/binary.cpython-310.pyc,,
aiofiles/threadpool/__pycache__/text.cpython-310.pyc,,
aiofiles/threadpool/__pycache__/utils.cpython-310.pyc,,
aiofiles/threadpool/binary.py,sha256=tRdJnH6ragF5Kr13oIBPJrljgTl3hWSOaHSXfHESRBk,1167
aiofiles/threadpool/text.py,sha256=dNweKCxpwRgfqA6XCiWYDDddOTa0lbzH-Fh5o3rho-8,665
aiofiles/threadpool/utils.py,sha256=fcqvRBrcIk2qP-rOdm92zDHyCfgD6itS621V6oOLJwk,1912

View File

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: poetry 1.1.0a6
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,5 @@
"""Utilities for asyncio-friendly file handling."""
from .threadpool import open
from . import tempfile
__all__ = ["open", "tempfile"]

View File

@ -0,0 +1,91 @@
"""Various base classes."""
from types import coroutine
from collections.abc import Coroutine
class AsyncBase:
def __init__(self, file, loop, executor):
self._file = file
self._loop = loop
self._executor = executor
def __aiter__(self):
"""We are our own iterator."""
return self
def __repr__(self):
return super().__repr__() + " wrapping " + repr(self._file)
async def __anext__(self):
"""Simulate normal file iteration."""
line = await self.readline()
if line:
return line
else:
raise StopAsyncIteration
class _ContextManager(Coroutine):
__slots__ = ("_coro", "_obj")
def __init__(self, coro):
self._coro = coro
self._obj = None
def send(self, value):
return self._coro.send(value)
def throw(self, typ, val=None, tb=None):
if val is None:
return self._coro.throw(typ)
elif tb is None:
return self._coro.throw(typ, val)
else:
return self._coro.throw(typ, val, tb)
def close(self):
return self._coro.close()
@property
def gi_frame(self):
return self._coro.gi_frame
@property
def gi_running(self):
return self._coro.gi_running
@property
def gi_code(self):
return self._coro.gi_code
def __next__(self):
return self.send(None)
@coroutine
def __iter__(self):
resp = yield from self._coro
return resp
def __await__(self):
resp = yield from self._coro
return resp
async def __anext__(self):
resp = await self._coro
return resp
async def __aenter__(self):
self._obj = await self._coro
return self._obj
async def __aexit__(self, exc_type, exc, tb):
self._obj.close()
self._obj = None
class AiofilesContextManager(_ContextManager):
"""An adjusted async context manager for aiofiles."""
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._obj.close()
self._obj = None

View File

@ -0,0 +1,31 @@
"""Async executor versions of file functions from the os module."""
import asyncio
from functools import partial, wraps
import os
def wrap(func):
@wraps(func)
async def run(*args, loop=None, executor=None, **kwargs):
if loop is None:
loop = asyncio.get_event_loop()
pfunc = partial(func, *args, **kwargs)
return await loop.run_in_executor(executor, pfunc)
return run
from . import ospath as path
stat = wrap(os.stat)
rename = wrap(os.rename)
replace = wrap(os.replace)
remove = wrap(os.remove)
mkdir = wrap(os.mkdir)
makedirs = wrap(os.makedirs)
rmdir = wrap(os.rmdir)
removedirs = wrap(os.removedirs)
if hasattr(os, "sendfile"):
sendfile = wrap(os.sendfile)

View File

@ -0,0 +1,14 @@
"""Async executor versions of file functions from the os.path module."""
from .os import wrap
from os import path
exists = wrap(path.exists)
isfile = wrap(path.isfile)
isdir = wrap(path.isdir)
getsize = wrap(path.getsize)
getmtime = wrap(path.getmtime)
getatime = wrap(path.getatime)
getctime = wrap(path.getctime)
samefile = wrap(path.samefile)
sameopenfile = wrap(path.sameopenfile)

View File

@ -0,0 +1,263 @@
# Imports
import asyncio
from tempfile import (
TemporaryFile as syncTemporaryFile,
NamedTemporaryFile as syncNamedTemporaryFile,
SpooledTemporaryFile as syncSpooledTemporaryFile,
TemporaryDirectory as syncTemporaryDirectory,
_TemporaryFileWrapper as syncTemporaryFileWrapper,
)
from io import FileIO, TextIOBase, BufferedReader, BufferedWriter, BufferedRandom
from functools import partial, singledispatch
from ..base import AiofilesContextManager
from ..threadpool.text import AsyncTextIOWrapper
from ..threadpool.binary import AsyncBufferedIOBase, AsyncBufferedReader, AsyncFileIO
from .temptypes import AsyncSpooledTemporaryFile, AsyncTemporaryDirectory
__all__ = [
"NamedTemporaryFile",
"TemporaryFile",
"SpooledTemporaryFile",
"TemporaryDirectory",
]
# ================================================================
# Public methods for async open and return of temp file/directory
# objects with async interface
# ================================================================
def NamedTemporaryFile(
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
delete=True,
loop=None,
executor=None,
):
"""Async open a named temporary file"""
return AiofilesContextManager(
_temporary_file(
named=True,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
delete=delete,
loop=loop,
executor=executor,
)
)
def TemporaryFile(
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
loop=None,
executor=None,
):
"""Async open an unnamed temporary file"""
return AiofilesContextManager(
_temporary_file(
named=False,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
loop=loop,
executor=executor,
)
)
def SpooledTemporaryFile(
max_size=0,
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
loop=None,
executor=None,
):
"""Async open a spooled temporary file"""
return AiofilesContextManager(
_spooled_temporary_file(
max_size=max_size,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
loop=loop,
executor=executor,
)
)
def TemporaryDirectory(suffix=None, prefix=None, dir=None, loop=None, executor=None):
"""Async open a temporary directory"""
return AiofilesContextManagerTempDir(
_temporary_directory(
suffix=suffix, prefix=prefix, dir=dir, loop=loop, executor=executor
)
)
# =========================================================
# Internal coroutines to open new temp files/directories
# =========================================================
async def _temporary_file(
named=True,
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
delete=True,
loop=None,
executor=None,
max_size=0,
):
"""Async method to open a temporary file with async interface"""
if loop is None:
loop = asyncio.get_event_loop()
if named:
cb = partial(
syncNamedTemporaryFile,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
delete=delete,
)
else:
cb = partial(
syncTemporaryFile,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
)
f = await loop.run_in_executor(executor, cb)
# Wrap based on type of underlying IO object
if type(f) is syncTemporaryFileWrapper:
# _TemporaryFileWrapper was used (named files)
result = wrap(f.file, f, loop=loop, executor=executor)
# add delete property
result.delete = f.delete
return result
else:
# IO object was returned directly without wrapper
return wrap(f, f, loop=loop, executor=executor)
async def _spooled_temporary_file(
max_size=0,
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
loop=None,
executor=None,
):
"""Open a spooled temporary file with async interface"""
if loop is None:
loop = asyncio.get_event_loop()
cb = partial(
syncSpooledTemporaryFile,
max_size=max_size,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
)
f = await loop.run_in_executor(executor, cb)
# Single interface provided by SpooledTemporaryFile for all modes
return AsyncSpooledTemporaryFile(f, loop=loop, executor=executor)
async def _temporary_directory(
suffix=None, prefix=None, dir=None, loop=None, executor=None
):
"""Async method to open a temporary directory with async interface"""
if loop is None:
loop = asyncio.get_event_loop()
cb = partial(syncTemporaryDirectory, suffix, prefix, dir)
f = await loop.run_in_executor(executor, cb)
return AsyncTemporaryDirectory(f, loop=loop, executor=executor)
class AiofilesContextManagerTempDir(AiofilesContextManager):
"""With returns the directory location, not the object (matching sync lib)"""
async def __aenter__(self):
self._obj = await self._coro
return self._obj.name
@singledispatch
def wrap(base_io_obj, file, *, loop=None, executor=None):
"""Wrap the object with interface based on type of underlying IO"""
raise TypeError("Unsupported IO type: {}".format(base_io_obj))
@wrap.register(TextIOBase)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncTextIOWrapper(file, loop=loop, executor=executor)
@wrap.register(BufferedWriter)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncBufferedIOBase(file, loop=loop, executor=executor)
@wrap.register(BufferedReader)
@wrap.register(BufferedRandom)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncBufferedReader(file, loop=loop, executor=executor)
@wrap.register(FileIO)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncFileIO(file, loop=loop, executor=executor)

View File

@ -0,0 +1,74 @@
"""Async wrappers for spooled temp files and temp directory objects"""
# Imports
import asyncio
from types import coroutine
from ..base import AsyncBase
from ..threadpool.utils import (
delegate_to_executor,
proxy_property_directly,
cond_delegate_to_executor,
)
from functools import partial
@delegate_to_executor("fileno", "rollover")
@cond_delegate_to_executor(
"close",
"flush",
"isatty",
"newlines",
"read",
"readline",
"readlines",
"seek",
"tell",
"truncate",
)
@proxy_property_directly("closed", "encoding", "mode", "name", "softspace")
class AsyncSpooledTemporaryFile(AsyncBase):
"""Async wrapper for SpooledTemporaryFile class"""
async def _check(self):
if self._file._rolled:
return
max_size = self._file._max_size
if max_size and self._file.tell() > max_size:
await self.rollover()
async def write(self, s):
"""Implementation to anticipate rollover"""
if self._file._rolled:
cb = partial(self._file.write, s)
return await self._loop.run_in_executor(self._executor, cb)
else:
file = self._file._file # reference underlying base IO object
rv = file.write(s)
await self._check()
return rv
async def writelines(self, iterable):
"""Implementation to anticipate rollover"""
if self._file._rolled:
cb = partial(self._file.writelines, iterable)
return await self._loop.run_in_executor(self._executor, cb)
else:
file = self._file._file # reference underlying base IO object
rv = file.writelines(iterable)
await self._check()
return rv
@delegate_to_executor("cleanup")
@proxy_property_directly("name")
class AsyncTemporaryDirectory:
"""Async wrapper for TemporaryDirectory class"""
def __init__(self, file, loop, executor):
self._file = file
self._loop = loop
self._executor = executor
async def close(self):
await self.cleanup()

View File

@ -0,0 +1,108 @@
"""Handle files using a thread pool executor."""
import asyncio
from types import coroutine
from io import (
FileIO,
TextIOBase,
BufferedReader,
BufferedWriter,
BufferedRandom,
)
from functools import partial, singledispatch
from .binary import AsyncBufferedIOBase, AsyncBufferedReader, AsyncFileIO
from .text import AsyncTextIOWrapper
from ..base import AiofilesContextManager
sync_open = open
__all__ = ("open",)
def open(
file,
mode="r",
buffering=-1,
encoding=None,
errors=None,
newline=None,
closefd=True,
opener=None,
*,
loop=None,
executor=None
):
return AiofilesContextManager(
_open(
file,
mode=mode,
buffering=buffering,
encoding=encoding,
errors=errors,
newline=newline,
closefd=closefd,
opener=opener,
loop=loop,
executor=executor,
)
)
@coroutine
def _open(
file,
mode="r",
buffering=-1,
encoding=None,
errors=None,
newline=None,
closefd=True,
opener=None,
*,
loop=None,
executor=None
):
"""Open an asyncio file."""
if loop is None:
loop = asyncio.get_event_loop()
cb = partial(
sync_open,
file,
mode=mode,
buffering=buffering,
encoding=encoding,
errors=errors,
newline=newline,
closefd=closefd,
opener=opener,
)
f = yield from loop.run_in_executor(executor, cb)
return wrap(f, loop=loop, executor=executor)
@singledispatch
def wrap(file, *, loop=None, executor=None):
raise TypeError("Unsupported io type: {}.".format(file))
@wrap.register(TextIOBase)
def _(file, *, loop=None, executor=None):
return AsyncTextIOWrapper(file, loop=loop, executor=executor)
@wrap.register(BufferedWriter)
def _(file, *, loop=None, executor=None):
return AsyncBufferedIOBase(file, loop=loop, executor=executor)
@wrap.register(BufferedReader)
@wrap.register(BufferedRandom)
def _(file, *, loop=None, executor=None):
return AsyncBufferedReader(file, loop=loop, executor=executor)
@wrap.register(FileIO)
def _(file, *, loop=None, executor=None):
return AsyncFileIO(file, loop, executor)

View File

@ -0,0 +1,57 @@
from ..base import AsyncBase
from .utils import (
delegate_to_executor,
proxy_method_directly,
proxy_property_directly,
)
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"read1",
"readinto",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"writable",
"write",
"writelines",
)
@proxy_method_directly("detach", "fileno", "readable")
@proxy_property_directly("closed", "raw", "name", "mode")
class AsyncBufferedIOBase(AsyncBase):
"""The asyncio executor version of io.BufferedWriter."""
@delegate_to_executor("peek")
class AsyncBufferedReader(AsyncBufferedIOBase):
"""The asyncio executor version of io.BufferedReader and Random."""
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"readall",
"readinto",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"writable",
"write",
"writelines",
)
@proxy_method_directly("fileno", "readable")
@proxy_property_directly("closed", "name", "mode")
class AsyncFileIO(AsyncBase):
"""The asyncio executor version of io.FileIO."""

View File

@ -0,0 +1,37 @@
from ..base import AsyncBase
from .utils import (
delegate_to_executor,
proxy_method_directly,
proxy_property_directly,
)
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"readable",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"write",
"writable",
"writelines",
)
@proxy_method_directly("detach", "fileno", "readable")
@proxy_property_directly(
"buffer",
"closed",
"encoding",
"errors",
"line_buffering",
"newlines",
"name",
"mode",
)
class AsyncTextIOWrapper(AsyncBase):
"""The asyncio executor version of io.TextIOWrapper."""

View File

@ -0,0 +1,74 @@
import functools
from types import coroutine
def delegate_to_executor(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_delegate_method(attr_name))
return cls
return cls_builder
def proxy_method_directly(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_proxy_method(attr_name))
return cls
return cls_builder
def proxy_property_directly(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_proxy_property(attr_name))
return cls
return cls_builder
def cond_delegate_to_executor(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_cond_delegate_method(attr_name))
return cls
return cls_builder
def _make_delegate_method(attr_name):
@coroutine
def method(self, *args, **kwargs):
cb = functools.partial(getattr(self._file, attr_name), *args, **kwargs)
return (yield from self._loop.run_in_executor(self._executor, cb))
return method
def _make_proxy_method(attr_name):
def method(self, *args, **kwargs):
return getattr(self._file, attr_name)(*args, **kwargs)
return method
def _make_proxy_property(attr_name):
def proxy_property(self):
return getattr(self._file, attr_name)
return property(proxy_property)
def _make_cond_delegate_method(attr_name):
"""For spooled temp files, delegate only if rolled to file object"""
async def method(self, *args, **kwargs):
if self._file._rolled:
cb = functools.partial(getattr(self._file, attr_name), *args, **kwargs)
return await self._loop.run_in_executor(self._executor, cb)
else:
return getattr(self._file, attr_name)(*args, **kwargs)
return method

View File

@ -0,0 +1,7 @@
# flake8: noqa
from .eq3btsmart import Mode, TemperatureException, Thermostat
from .structures import *
class BackendException(Exception):
"""Exception to wrap backend exceptions."""

View File

@ -0,0 +1,123 @@
"""
Bleak connection backend.
This creates a new event loop that is used to integrate bleak's
asyncio functions to synchronous architecture of python-eq3bt.
"""
import asyncio
import codecs
import contextlib
import logging
from typing import Optional
from bleak import BleakClient, BleakError
from . import BackendException
DEFAULT_TIMEOUT = 1
# bleak backends are very loud on debug, this reduces the log spam when using --debug
logging.getLogger("bleak.backends").setLevel(logging.WARNING)
_LOGGER = logging.getLogger(__name__)
class BleakConnection:
"""Representation of a BTLE Connection."""
def __init__(self, mac, iface):
"""Initialize the connection."""
self._conn: Optional[BleakClient] = None
self._mac = mac
self._iface = iface
self._callbacks = {}
self._notifyevent = asyncio.Event()
self._notification_handle = None
self._loop = asyncio.new_event_loop()
def __enter__(self):
"""
Context manager __enter__ for connecting the device
:rtype: BTLEConnection
:return:
"""
_LOGGER.debug("Trying to connect to %s", self._mac)
kwargs = {}
if self._iface is not None:
kwargs["adapter"] = self._iface
self._conn = BleakClient(self._mac, **kwargs)
try:
self._loop.run_until_complete(self._conn.connect())
except BleakError as ex:
_LOGGER.debug(
"Unable to connect to the device %s, retrying: %s", self._mac, ex
)
try:
self._loop.run_until_complete(self._conn.connect())
except Exception as ex2:
_LOGGER.debug("Second connection try to %s failed: %s", self._mac, ex2)
raise BackendException(
"unable to connect to device using bleak"
) from ex2
# The notification handles are off-by-one compared to gattlib and bluepy
self._loop.run_until_complete(
self._conn.start_notify(self._notification_handle - 1, self.on_notification)
)
_LOGGER.debug("Connected to %s", self._mac)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self._conn:
self._loop.run_until_complete(self._conn.disconnect())
self._conn = None
async def on_notification(self, handle, data):
"""Handle Callback from a Bluetooth (GATT) request."""
# The notification handles are off-by-one compared to gattlib and bluepy
handle = handle + 1
_LOGGER.debug(
"Got notification from %s: %s", handle, codecs.encode(data, "hex")
)
self._notifyevent.set()
if handle in self._callbacks:
self._callbacks[handle](data)
@property
def mac(self):
"""Return the MAC address of the connected device."""
return self._mac
def set_callback(self, handle, function):
"""Set the callback for a Notification handle. It will be called with the parameter data, which is binary."""
self._notification_handle = handle
self._callbacks[handle] = function
async def wait_for_response(self, timeout):
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._notifyevent.wait(), timeout)
def make_request(self, handle, value, timeout=DEFAULT_TIMEOUT, with_response=True):
"""Write a GATT Command without callback - not utf-8."""
try:
with self:
_LOGGER.debug(
"Writing %s to %s",
codecs.encode(value, "hex"),
handle,
)
self._notifyevent.clear()
self._loop.run_until_complete(
self._conn.write_gatt_char(handle - 1, value)
)
if timeout:
_LOGGER.debug("Waiting for notifications for %s", timeout)
self._loop.run_until_complete(self.wait_for_response(timeout))
except BleakError as ex:
_LOGGER.debug("Got exception from bleak while making a request: %s", ex)
raise BackendException("Exception on write using bleak") from ex

View File

@ -0,0 +1,95 @@
"""
A simple wrapper for bluepy's btle.Connection.
Handles Connection duties (reconnecting etc.) transparently.
"""
import codecs
import logging
from bluepy import btle
from . import BackendException
DEFAULT_TIMEOUT = 1
_LOGGER = logging.getLogger(__name__)
class BTLEConnection(btle.DefaultDelegate):
"""Representation of a BTLE Connection."""
def __init__(self, mac, iface):
"""Initialize the connection."""
btle.DefaultDelegate.__init__(self)
self._conn = None
self._mac = mac
self._iface = iface
self._callbacks = {}
def __enter__(self):
"""
Context manager __enter__ for connecting the device
:rtype: btle.Peripheral
:return:
"""
self._conn = btle.Peripheral()
self._conn.withDelegate(self)
_LOGGER.debug("Trying to connect to %s", self._mac)
try:
self._conn.connect(self._mac, iface=self._iface)
except btle.BTLEException as ex:
_LOGGER.debug(
"Unable to connect to the device %s, retrying: %s", self._mac, ex
)
try:
self._conn.connect(self._mac, iface=self._iface)
except Exception as ex2:
_LOGGER.debug("Second connection try to %s failed: %s", self._mac, ex2)
raise BackendException(
"Unable to connect to device using bluepy"
) from ex2
_LOGGER.debug("Connected to %s", self._mac)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self._conn:
self._conn.disconnect()
self._conn = None
def handleNotification(self, handle, data):
"""Handle Callback from a Bluetooth (GATT) request."""
_LOGGER.debug(
"Got notification from %s: %s", handle, codecs.encode(data, "hex")
)
if handle in self._callbacks:
self._callbacks[handle](data)
@property
def mac(self):
"""Return the MAC address of the connected device."""
return self._mac
def set_callback(self, handle, function):
"""Set the callback for a Notification handle. It will be called with the parameter data, which is binary."""
self._callbacks[handle] = function
def make_request(self, handle, value, timeout=DEFAULT_TIMEOUT, with_response=True):
"""Write a GATT Command without callback - not utf-8."""
try:
with self:
_LOGGER.debug(
"Writing %s to %s with with_response=%s",
codecs.encode(value, "hex"),
handle,
with_response,
)
self._conn.writeCharacteristic(
handle, value, withResponse=with_response
)
if timeout:
_LOGGER.debug("Waiting for notifications for %s", timeout)
self._conn.waitForNotifications(timeout)
except btle.BTLEException as ex:
_LOGGER.debug("Got exception from bluepy while making a request: %s", ex)
raise BackendException("Exception on write using bluepy") from ex

View File

@ -0,0 +1,491 @@
"""
Support for eq3 Bluetooth Smart thermostats.
All temperatures in Celsius.
To get the current state, update() has to be called for powersaving reasons.
Schedule needs to be requested with query_schedule() before accessing for similar reasons.
"""
import codecs
import logging
import struct
from datetime import datetime, timedelta
from enum import IntEnum
from construct import Byte
from .structures import AwayDataAdapter, DeviceId, Schedule, Status
_LOGGER = logging.getLogger(__name__)
PROP_WRITE_HANDLE = 0x411
PROP_NTFY_HANDLE = 0x421
PROP_ID_QUERY = 0
PROP_ID_RETURN = 1
PROP_INFO_QUERY = 3
PROP_INFO_RETURN = 2
PROP_COMFORT_ECO_CONFIG = 0x11
PROP_OFFSET = 0x13
PROP_WINDOW_OPEN_CONFIG = 0x14
PROP_SCHEDULE_QUERY = 0x20
PROP_SCHEDULE_RETURN = 0x21
PROP_MODE_WRITE = 0x40
PROP_TEMPERATURE_WRITE = 0x41
PROP_COMFORT = 0x43
PROP_ECO = 0x44
PROP_BOOST = 0x45
PROP_LOCK = 0x80
EQ3BT_AWAY_TEMP = 12.0
EQ3BT_MIN_TEMP = 5.0
EQ3BT_MAX_TEMP = 29.5
EQ3BT_OFF_TEMP = 4.5
EQ3BT_ON_TEMP = 30.0
class Mode(IntEnum):
"""Thermostat modes."""
Unknown = -1
Closed = 0
Open = 1
Auto = 2
Manual = 3
Away = 4
Boost = 5
MODE_NOT_TEMP = [Mode.Unknown, Mode.Closed, Mode.Open]
class TemperatureException(Exception):
"""Temperature out of range error."""
pass
# pylint: disable=too-many-instance-attributes
class Thermostat:
"""Representation of a EQ3 Bluetooth Smart thermostat."""
def __init__(self, _mac, _iface=None, connection_cls=None):
"""Initialize the thermostat."""
self._target_temperature = Mode.Unknown
self._mode = Mode.Unknown
self._valve_state = Mode.Unknown
self._raw_mode = None
self._schedule = {}
self._window_open_temperature = None
self._window_open_time = None
self._comfort_temperature = None
self._eco_temperature = None
self._temperature_offset = None
self._away_temp = EQ3BT_AWAY_TEMP
self._away_duration = timedelta(days=30)
self._away_end = None
self._firmware_version = None
self._device_serial = None
if connection_cls is None:
from .bleakconnection import BleakConnection as connection_cls
self._conn = connection_cls(_mac, _iface)
self._conn.set_callback(PROP_NTFY_HANDLE, self.handle_notification)
def __str__(self):
away_end = "no"
if self.away_end:
away_end = "end: %s" % self._away_end
return "[{}] Target {} (mode: {}, away: {})".format(
self._conn.mac, self.target_temperature, self.mode_readable, away_end
)
def _verify_temperature(self, temp):
"""Verifies that the temperature is valid.
:raises TemperatureException: On invalid temperature.
"""
if temp < self.min_temp or temp > self.max_temp:
raise TemperatureException(
"Temperature {} out of range [{}, {}]".format(
temp, self.min_temp, self.max_temp
)
)
def parse_schedule(self, data):
"""Parses the device sent schedule."""
sched = Schedule.parse(data)
_LOGGER.debug("Got schedule data for day '%s'", sched.day)
return sched
def handle_notification(self, data):
"""Handle Callback from a Bluetooth (GATT) request."""
_LOGGER.debug("Received notification from the device..")
if data[0] == PROP_INFO_RETURN and data[1] == 1:
_LOGGER.debug("Got status: %s" % codecs.encode(data, "hex"))
status = Status.parse(data)
_LOGGER.debug("Parsed status: %s", status)
self._raw_mode = status.mode
self._valve_state = status.valve
self._target_temperature = status.target_temp
if status.mode.BOOST:
self._mode = Mode.Boost
elif status.mode.AWAY:
self._mode = Mode.Away
self._away_end = status.away
elif status.mode.MANUAL:
if status.target_temp == EQ3BT_OFF_TEMP:
self._mode = Mode.Closed
elif status.target_temp == EQ3BT_ON_TEMP:
self._mode = Mode.Open
else:
self._mode = Mode.Manual
else:
self._mode = Mode.Auto
presets = status.presets
if presets:
self._window_open_temperature = presets.window_open_temp
self._window_open_time = presets.window_open_time
self._comfort_temperature = presets.comfort_temp
self._eco_temperature = presets.eco_temp
self._temperature_offset = presets.offset
else:
self._window_open_temperature = None
self._window_open_time = None
self._comfort_temperature = None
self._eco_temperature = None
self._temperature_offset = None
_LOGGER.debug("Valve state: %s", self._valve_state)
_LOGGER.debug("Mode: %s", self.mode_readable)
_LOGGER.debug("Target temp: %s", self._target_temperature)
_LOGGER.debug("Away end: %s", self._away_end)
_LOGGER.debug("Window open temp: %s", self._window_open_temperature)
_LOGGER.debug("Window open time: %s", self._window_open_time)
_LOGGER.debug("Comfort temp: %s", self._comfort_temperature)
_LOGGER.debug("Eco temp: %s", self._eco_temperature)
_LOGGER.debug("Temp offset: %s", self._temperature_offset)
elif data[0] == PROP_SCHEDULE_RETURN:
parsed = self.parse_schedule(data)
self._schedule[parsed.day] = parsed
elif data[0] == PROP_ID_RETURN:
parsed = DeviceId.parse(data)
_LOGGER.debug("Parsed device data: %s", parsed)
self._firmware_version = parsed.version
self._device_serial = parsed.serial
else:
_LOGGER.debug(
"Unknown notification %s (%s)", data[0], codecs.encode(data, "hex")
)
def query_id(self):
"""Query device identification information, e.g. the serial number."""
_LOGGER.debug("Querying id..")
value = struct.pack("B", PROP_ID_QUERY)
self._conn.make_request(PROP_WRITE_HANDLE, value)
def update(self):
"""Update the data from the thermostat. Always sets the current time."""
_LOGGER.debug("Querying the device..")
time = datetime.now()
value = struct.pack(
"BBBBBBB",
PROP_INFO_QUERY,
time.year % 100,
time.month,
time.day,
time.hour,
time.minute,
time.second,
)
self._conn.make_request(PROP_WRITE_HANDLE, value)
def query_schedule(self, day):
_LOGGER.debug("Querying schedule..")
if day < 0 or day > 6:
_LOGGER.error("Invalid day: %s", day)
value = struct.pack("BB", PROP_SCHEDULE_QUERY, day)
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def schedule(self):
"""Returns previously fetched schedule.
:return: Schedule structure or None if not fetched.
"""
return self._schedule
def set_schedule(self, data):
"""Sets the schedule for the given day."""
value = Schedule.build(data)
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
return self._target_temperature
@target_temperature.setter
def target_temperature(self, temperature):
"""Set new target temperature."""
dev_temp = int(temperature * 2)
if temperature == EQ3BT_OFF_TEMP or temperature == EQ3BT_ON_TEMP:
dev_temp |= 0x40
value = struct.pack("BB", PROP_MODE_WRITE, dev_temp)
else:
self._verify_temperature(temperature)
value = struct.pack("BB", PROP_TEMPERATURE_WRITE, dev_temp)
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def mode(self):
"""Return the current operation mode"""
return self._mode
@mode.setter
def mode(self, mode):
"""Set the operation mode."""
_LOGGER.debug("Setting new mode: %s", mode)
if self.mode == Mode.Boost and mode != Mode.Boost:
self.boost = False
if mode == Mode.Boost:
self.boost = True
return
elif mode == Mode.Away:
end = datetime.now() + self._away_duration
return self.set_away(end, self._away_temp)
elif mode == Mode.Closed:
return self.set_mode(0x40 | int(EQ3BT_OFF_TEMP * 2))
elif mode == Mode.Open:
return self.set_mode(0x40 | int(EQ3BT_ON_TEMP * 2))
if mode == Mode.Manual:
temperature = max(
min(self._target_temperature, self.max_temp), self.min_temp
)
return self.set_mode(0x40 | int(temperature * 2))
else:
return self.set_mode(0)
@property
def away_end(self):
return self._away_end
def set_away(self, away_end=None, temperature=EQ3BT_AWAY_TEMP):
"""Sets away mode with target temperature.
When called without parameters disables away mode."""
if not away_end:
_LOGGER.debug("Disabling away, going to auto mode.")
return self.set_mode(0x00)
_LOGGER.debug("Setting away until %s, temp %s", away_end, temperature)
adapter = AwayDataAdapter(Byte[4])
packed = adapter.build(away_end)
self.set_mode(0x80 | int(temperature * 2), packed)
def set_mode(self, mode, payload=None):
value = struct.pack("BB", PROP_MODE_WRITE, mode)
if payload:
value += payload
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def mode_readable(self):
"""Return a readable representation of the mode.."""
ret = ""
mode = self._raw_mode
if mode.MANUAL:
ret = "manual"
if self.target_temperature < self.min_temp:
ret += " off"
elif self.target_temperature >= self.max_temp:
ret += " on"
else:
ret += " (%sC)" % self.target_temperature
else:
ret = "auto"
if mode.AWAY:
ret += " holiday"
if mode.BOOST:
ret += " boost"
if mode.DST:
ret += " dst"
if mode.WINDOW:
ret += " window"
if mode.LOCKED:
ret += " locked"
if mode.LOW_BATTERY:
ret += " low battery"
return ret
@property
def boost(self):
"""Returns True if the thermostat is in boost mode."""
return self.mode == Mode.Boost
@boost.setter
def boost(self, boost):
"""Sets boost mode."""
_LOGGER.debug("Setting boost mode: %s", boost)
value = struct.pack("BB", PROP_BOOST, bool(boost))
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def valve_state(self):
"""Returns the valve state. Probably reported as percent open."""
return self._valve_state
@property
def window_open(self):
"""Returns True if the thermostat reports a open window
(detected by sudden drop of temperature)"""
return self._raw_mode and self._raw_mode.WINDOW
def window_open_config(self, temperature, duration):
"""Configures the window open behavior. The duration is specified in
5 minute increments."""
_LOGGER.debug(
"Window open config, temperature: %s duration: %s", temperature, duration
)
self._verify_temperature(temperature)
if duration.seconds < 0 and duration.seconds > 3600:
raise ValueError
value = struct.pack(
"BBB",
PROP_WINDOW_OPEN_CONFIG,
int(temperature * 2),
int(duration.seconds / 300),
)
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def window_open_temperature(self):
"""The temperature to set when an open window is detected."""
return self._window_open_temperature
@property
def window_open_time(self):
"""Timeout to reset the thermostat after an open window is detected."""
return self._window_open_time
@property
def locked(self):
"""Returns True if the thermostat is locked."""
return self._raw_mode and self._raw_mode.LOCKED
@locked.setter
def locked(self, lock):
"""Locks or unlocks the thermostat."""
_LOGGER.debug("Setting the lock: %s", lock)
value = struct.pack("BB", PROP_LOCK, bool(lock))
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def low_battery(self):
"""Returns True if the thermostat reports a low battery."""
return self._raw_mode and self._raw_mode.LOW_BATTERY
def temperature_presets(self, comfort, eco):
"""Set the thermostats preset temperatures comfort (sun) and
eco (moon)."""
_LOGGER.debug("Setting temperature presets, comfort: %s eco: %s", comfort, eco)
self._verify_temperature(comfort)
self._verify_temperature(eco)
value = struct.pack(
"BBB", PROP_COMFORT_ECO_CONFIG, int(comfort * 2), int(eco * 2)
)
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def comfort_temperature(self):
"""Returns the comfort temperature preset of the thermostat."""
return self._comfort_temperature
@property
def eco_temperature(self):
"""Returns the eco temperature preset of the thermostat."""
return self._eco_temperature
@property
def temperature_offset(self):
"""Returns the thermostat's temperature offset."""
return self._temperature_offset
@temperature_offset.setter
def temperature_offset(self, offset):
"""Sets the thermostat's temperature offset."""
_LOGGER.debug("Setting offset: %s", offset)
# [-3,5 .. 0 .. 3,5 ]
# [00 .. 07 .. 0e ]
if offset < -3.5 or offset > 3.5:
raise TemperatureException("Invalid value: %s" % offset)
current = -3.5
values = {}
for i in range(15):
values[current] = i
current += 0.5
value = struct.pack("BB", PROP_OFFSET, values[offset])
self._conn.make_request(PROP_WRITE_HANDLE, value)
def activate_comfort(self):
"""Activates the comfort temperature."""
value = struct.pack("B", PROP_COMFORT)
self._conn.make_request(PROP_WRITE_HANDLE, value)
def activate_eco(self):
"""Activates the comfort temperature."""
value = struct.pack("B", PROP_ECO)
self._conn.make_request(PROP_WRITE_HANDLE, value)
@property
def min_temp(self):
"""Return the minimum temperature."""
return EQ3BT_MIN_TEMP
@property
def max_temp(self):
"""Return the maximum temperature."""
return EQ3BT_MAX_TEMP
@property
def firmware_version(self):
"""Return the firmware version."""
return self._firmware_version
@property
def device_serial(self):
"""Return the device serial number."""
return self._device_serial
@property
def mac(self):
"""Return the mac address."""
return self._conn.mac

View File

@ -0,0 +1,212 @@
""" Cli tool for testing connectivity with EQ3 smart thermostats. """
import logging
import re
import click
from eq3bt import Thermostat
pass_dev = click.make_pass_decorator(Thermostat)
def validate_mac(ctx, param, mac):
if re.match("^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", mac) is None:
raise click.BadParameter(mac + " is no valid mac address")
return mac
@click.group(invoke_without_command=True)
@click.option("--mac", envvar="EQ3_MAC", required=True, callback=validate_mac)
@click.option("--interface", default=None)
@click.option("--debug/--normal", default=False)
@click.option(
"--backend", type=click.Choice(["bleak", "bluepy", "gattlib"]), default="bleak"
)
@click.pass_context
def cli(ctx, mac, interface, debug, backend):
"""Tool to query and modify the state of EQ3 BT smart thermostat."""
if debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
if backend == "bluepy":
from .connection import BTLEConnection
connection_cls = BTLEConnection
elif backend == "gattlib":
from .gattlibconnection import BTLEConnection
connection_cls = BTLEConnection
else:
from .bleakconnection import BleakConnection
connection_cls = BleakConnection
thermostat = Thermostat(mac, interface, connection_cls)
thermostat.update()
ctx.obj = thermostat
if ctx.invoked_subcommand is None:
ctx.invoke(state)
@cli.command()
@click.option("--target", type=float, required=False)
@pass_dev
def temp(dev, target):
"""Gets or sets the target temperature."""
click.echo("Current target temp: %s" % dev.target_temperature)
if target:
click.echo("Setting target temp: %s" % target)
dev.target_temperature = target
@cli.command()
@click.option("--target", type=int, required=False)
@pass_dev
def mode(dev, target):
"""Gets or sets the active mode."""
click.echo("Current mode: %s" % dev.mode_readable)
if target:
click.echo("Setting mode: %s" % target)
dev.mode = target
@cli.command()
@click.option("--target", type=bool, required=False)
@pass_dev
def boost(dev, target):
"""Gets or sets the boost mode."""
click.echo("Boost: %s" % dev.boost)
if target is not None:
click.echo("Setting boost: %s" % target)
dev.boost = target
@cli.command()
@pass_dev
def valve_state(dev):
"""Gets the state of the valve."""
click.echo("Valve: %s" % dev.valve_state)
@cli.command()
@click.option("--target", type=bool, required=False)
@pass_dev
def locked(dev, target):
"""Gets or sets the lock."""
click.echo("Locked: %s" % dev.locked)
if target is not None:
click.echo("Setting lock: %s" % target)
dev.locked = target
@cli.command()
@pass_dev
def low_battery(dev):
"""Gets the low battery status."""
click.echo("Batter low: %s" % dev.low_battery)
@cli.command()
@click.option("--temp", type=float, required=False)
@click.option("--duration", type=float, required=False)
@pass_dev
def window_open(dev, temp, duration):
"""Gets and sets the window open settings."""
click.echo("Window open: %s" % dev.window_open)
if dev.window_open_temperature is not None:
click.echo("Window open temp: %s" % dev.window_open_temperature)
if dev.window_open_time is not None:
click.echo("Window open time: %s" % dev.window_open_time)
if temp and duration:
click.echo(f"Setting window open conf, temp: {temp} duration: {duration}")
dev.window_open_config(temp, duration)
@cli.command()
@click.option("--comfort", type=float, required=False)
@click.option("--eco", type=float, required=False)
@pass_dev
def presets(dev, comfort, eco):
"""Sets the preset temperatures for auto mode."""
if dev.comfort_temperature is not None:
click.echo("Current comfort temp: %s" % dev.comfort_temperature)
if dev.eco_temperature is not None:
click.echo("Current eco temp: %s" % dev.eco_temperature)
if comfort and eco:
click.echo(f"Setting presets: comfort {comfort}, eco {eco}")
dev.temperature_presets(comfort, eco)
@cli.command()
@pass_dev
def schedule(dev):
"""Gets the schedule from the thermostat."""
# TODO: expose setting the schedule somehow?
for d in range(7):
dev.query_schedule(d)
for day in dev.schedule.values():
click.echo(f"Day {day.day}, base temp: {day.base_temp}")
current_hour = day.next_change_at
for hour in day.hours:
if current_hour == 0:
continue
click.echo(f"\t[{current_hour}-{hour.next_change_at}] {hour.target_temp}")
current_hour = hour.next_change_at
@cli.command()
@click.argument("offset", type=float, required=False)
@pass_dev
def offset(dev, offset):
"""Sets the temperature offset [-3,5 3,5]"""
if dev.temperature_offset is not None:
click.echo("Current temp offset: %s" % dev.temperature_offset)
if offset is not None:
click.echo("Setting the offset to %s" % offset)
dev.temperature_offset = offset
@cli.command()
@click.argument("away_end", type=click.DateTime(), default=None, required=False)
@click.argument("temperature", type=float, default=None, required=False)
@pass_dev
def away(dev, away_end, temperature):
"""Enables or disables the away mode."""
if away_end:
click.echo(f"Setting away until {away_end}, temperature: {temperature}")
else:
click.echo("Disabling away mode")
dev.set_away(away_end, temperature)
@cli.command()
@pass_dev
def device(dev):
"""Displays basic device information."""
dev.query_id()
click.echo("Firmware version: %s" % dev.firmware_version)
click.echo("Device serial: %s" % dev.device_serial)
@cli.command()
@click.pass_context
def state(ctx):
"""Prints out all available information."""
dev = ctx.obj
click.echo(dev)
ctx.forward(locked)
ctx.forward(low_battery)
ctx.forward(window_open)
ctx.forward(boost)
ctx.forward(temp)
ctx.forward(presets)
ctx.forward(offset)
ctx.forward(mode)
ctx.forward(valve_state)
if __name__ == "__main__":
cli()

View File

@ -0,0 +1,99 @@
"""
A simple adapter to gattlib.
Handles Connection duties (reconnecting etc.) transparently.
"""
import codecs
import logging
import threading
import gattlib
from . import BackendException
DEFAULT_TIMEOUT = 1
_LOGGER = logging.getLogger(__name__)
class BTLEConnection:
"""Representation of a BTLE Connection."""
def __init__(self, mac, iface):
"""Initialize the connection."""
self._conn = None
self._mac = mac
self._iface = iface
self._callbacks = {}
self._notifyevent = None
def __enter__(self):
"""
Context manager __enter__ for connecting the device
:rtype: BTLEConnection
:return:
"""
_LOGGER.debug("Trying to connect to %s", self._mac)
if self._iface is None:
self._conn = gattlib.GATTRequester(self._mac, False)
else:
self._conn = gattlib.GATTRequester(self._mac, False, self._iface)
self._conn.on_notification = self.on_notification
try:
self._conn.connect()
except gattlib.BTBaseException as ex:
_LOGGER.debug(
"Unable to connect to the device %s, retrying: %s", self._mac, ex
)
try:
self._conn.connect()
except Exception as ex2:
_LOGGER.debug("Second connection try to %s failed: %s", self._mac, ex2)
raise BackendException(
"unable to connect to device using gattlib"
) from ex2
_LOGGER.debug("Connected to %s", self._mac)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self._conn:
self._conn.disconnect()
self._conn = None
def on_notification(self, handle, data):
"""Handle Callback from a Bluetooth (GATT) request."""
_LOGGER.debug(
"Got notification from %s: %s", handle, codecs.encode(data, "hex")
)
if handle in self._callbacks:
self._callbacks[handle](data[3:])
if self._notifyevent:
self._notifyevent.set()
@property
def mac(self):
"""Return the MAC address of the connected device."""
return self._mac
def set_callback(self, handle, function):
"""Set the callback for a Notification handle. It will be called with the parameter data, which is binary."""
self._callbacks[handle] = function
def make_request(self, handle, value, timeout=DEFAULT_TIMEOUT, with_response=True):
"""Write a GATT Command without callback - not utf-8."""
try:
with self:
_LOGGER.debug(
"Writing %s to %s",
codecs.encode(value, "hex"),
handle,
)
self._notifyevent = threading.Event()
self._conn.write_by_handle(handle, value)
if timeout:
_LOGGER.debug("Waiting for notifications for %s", timeout)
self._notifyevent.wait(timeout)
except gattlib.BTBaseException as ex:
_LOGGER.debug("Got exception from gattlib while making a request: %s", ex)
raise BackendException("Exception on write using gattlib") from ex

View File

@ -0,0 +1,174 @@
""" Contains construct adapters and structures. """
from datetime import datetime, time, timedelta
from construct import (
Adapter,
Bytes,
Const,
Enum,
FlagsEnum,
GreedyRange,
IfThenElse,
Int8ub,
Optional,
Struct,
)
PROP_ID_RETURN = 1
PROP_INFO_RETURN = 2
PROP_SCHEDULE_SET = 0x10
PROP_SCHEDULE_RETURN = 0x21
NAME_TO_DAY = {"sat": 0, "sun": 1, "mon": 2, "tue": 3, "wed": 4, "thu": 5, "fri": 6}
NAME_TO_CMD = {"write": PROP_SCHEDULE_SET, "response": PROP_SCHEDULE_RETURN}
HOUR_24_PLACEHOLDER = 1234
class TimeAdapter(Adapter):
"""Adapter to encode and decode schedule times."""
def _decode(self, obj, ctx, path):
h, m = divmod(obj * 10, 60)
if h == 24: # HACK, can we do better?
return HOUR_24_PLACEHOLDER
return time(hour=h, minute=m)
def _encode(self, obj, ctx, path):
# TODO: encode h == 24 hack
if obj == HOUR_24_PLACEHOLDER:
return int(24 * 60 / 10)
encoded = int((obj.hour * 60 + obj.minute) / 10)
return encoded
class TempAdapter(Adapter):
"""Adapter to encode and decode temperature."""
def _decode(self, obj, ctx, path):
return float(obj / 2.0)
def _encode(self, obj, ctx, path):
return int(obj * 2.0)
class WindowOpenTimeAdapter(Adapter):
"""Adapter to encode and decode window open times (5 min increments)."""
def _decode(self, obj, context, path):
return timedelta(minutes=float(obj * 5.0))
def _encode(self, obj, context, path):
if isinstance(obj, timedelta):
obj = obj.seconds
if 0 <= obj <= 3600.0:
return int(obj / 300.0)
raise ValueError(
"Window open time must be between 0 and 60 minutes "
"in intervals of 5 minutes."
)
class TempOffsetAdapter(Adapter):
"""Adapter to encode and decode the temperature offset."""
def _decode(self, obj, context, path):
return float((obj - 7) / 2.0)
def _encode(self, obj, context, path):
if -3.5 <= obj <= 3.5:
return int(obj * 2.0) + 7
raise ValueError(
"Temperature offset must be between -3.5 and 3.5 (in " "intervals of 0.5)."
)
ModeFlags = "ModeFlags" / FlagsEnum(
Int8ub,
AUTO=0x00, # always True, doesnt affect building
MANUAL=0x01,
AWAY=0x02,
BOOST=0x04,
DST=0x08,
WINDOW=0x10,
LOCKED=0x20,
UNKNOWN=0x40,
LOW_BATTERY=0x80,
)
class AwayDataAdapter(Adapter):
"""Adapter to encode and decode away data."""
def _decode(self, obj, ctx, path):
(day, year, hour_min, month) = obj
year += 2000
min = 0
if hour_min & 0x01:
min = 30
hour = int(hour_min / 2)
return datetime(year=year, month=month, day=day, hour=hour, minute=min)
def _encode(self, obj, ctx, path):
if obj.year < 2000 or obj.year > 2099:
raise Exception("Invalid year, possible [2000,2099]")
year = obj.year - 2000
hour = obj.hour * 2
if obj.minute: # we encode all minute values to h:30
hour |= 0x01
return (obj.day, year, hour, obj.month)
class DeviceSerialAdapter(Adapter):
"""Adapter to decode the device serial number."""
def _decode(self, obj, context, path):
return bytearray(n - 0x30 for n in obj).decode()
Status = "Status" / Struct(
"cmd" / Const(PROP_INFO_RETURN, Int8ub),
Const(0x01, Int8ub),
"mode" / ModeFlags,
"valve" / Int8ub,
Const(0x04, Int8ub),
"target_temp" / TempAdapter(Int8ub),
"away"
/ IfThenElse( # noqa: W503
lambda ctx: ctx.mode.AWAY, AwayDataAdapter(Bytes(4)), Optional(Bytes(4))
),
"presets"
/ Optional( # noqa: W503
Struct(
"window_open_temp" / TempAdapter(Int8ub),
"window_open_time" / WindowOpenTimeAdapter(Int8ub),
"comfort_temp" / TempAdapter(Int8ub),
"eco_temp" / TempAdapter(Int8ub),
"offset" / TempOffsetAdapter(Int8ub),
)
),
)
Schedule = "Schedule" / Struct(
"cmd" / Enum(Int8ub, **NAME_TO_CMD),
"day" / Enum(Int8ub, **NAME_TO_DAY),
"base_temp" / TempAdapter(Int8ub),
"next_change_at" / TimeAdapter(Int8ub),
"hours"
/ GreedyRange( # noqa: W503
Struct(
"target_temp" / TempAdapter(Int8ub),
"next_change_at" / TimeAdapter(Int8ub),
)
),
)
DeviceId = "DeviceId" / Struct(
"cmd" / Const(PROP_ID_RETURN, Int8ub),
"version" / Int8ub,
Int8ub,
Int8ub,
"serial" / DeviceSerialAdapter(Bytes(10)),
Int8ub,
)

View File

@ -0,0 +1,198 @@
import codecs
from datetime import datetime, timedelta
from unittest import TestCase
import pytest
from eq3bt import TemperatureException, Thermostat
from eq3bt.eq3btsmart import PROP_ID_QUERY, PROP_INFO_QUERY, PROP_NTFY_HANDLE, Mode
ID_RESPONSE = b"01780000807581626163606067659e"
STATUS_RESPONSES = {
"auto": b"020100000428",
"manual": b"020101000428",
"window": b"020110000428",
"away": b"0201020004231d132e03",
"boost": b"020104000428",
"low_batt": b"020180000428",
"valve_at_22": b"020100160428",
"presets": b"020100000422000000001803282207",
}
class FakeConnection:
def __init__(self, _iface, mac):
self._callbacks = {}
self._res = "auto"
def set_callback(self, handle, cb):
self._callbacks[handle] = cb
def set_status(self, key):
if key in STATUS_RESPONSES:
self._res = key
else:
raise ValueError("Invalid key for status test response.")
def make_request(self, handle, value, timeout=1, with_response=True):
"""Write a GATT Command without callback - not utf-8."""
if with_response:
cb = self._callbacks.get(PROP_NTFY_HANDLE)
if value[0] == PROP_ID_QUERY:
data = ID_RESPONSE
elif value[0] == PROP_INFO_QUERY:
data = STATUS_RESPONSES[self._res]
else:
return
cb(codecs.decode(data, "hex"))
class TestThermostat(TestCase):
def setUp(self):
self.thermostat = Thermostat(
_mac=None, _iface=None, connection_cls=FakeConnection
)
def test__verify_temperature(self):
with self.assertRaises(TemperatureException):
self.thermostat._verify_temperature(-1)
with self.assertRaises(TemperatureException):
self.thermostat._verify_temperature(35)
self.thermostat._verify_temperature(8)
self.thermostat._verify_temperature(25)
@pytest.mark.skip()
def test_parse_schedule(self):
self.fail()
@pytest.mark.skip()
def test_handle_notification(self):
self.fail()
def test_query_id(self):
self.thermostat.query_id()
self.assertEqual(self.thermostat.firmware_version, 120)
self.assertEqual(self.thermostat.device_serial, "PEQ2130075")
def test_update(self):
th = self.thermostat
th._conn.set_status("auto")
th.update()
self.assertEqual(th.valve_state, 0)
self.assertEqual(th.mode, Mode.Auto)
self.assertEqual(th.target_temperature, 20.0)
self.assertFalse(th.locked)
self.assertFalse(th.low_battery)
self.assertFalse(th.boost)
self.assertFalse(th.window_open)
th._conn.set_status("manual")
th.update()
self.assertTrue(th.mode, Mode.Manual)
th._conn.set_status("away")
th.update()
self.assertEqual(th.mode, Mode.Away)
self.assertEqual(th.target_temperature, 17.5)
self.assertEqual(th.away_end, datetime(2019, 3, 29, 23, 00))
th._conn.set_status("boost")
th.update()
self.assertTrue(th.boost)
self.assertEqual(th.mode, Mode.Boost)
def test_presets(self):
th = self.thermostat
self.thermostat._conn.set_status("presets")
self.thermostat.update()
self.assertEqual(th.window_open_temperature, 12.0)
self.assertEqual(th.window_open_time, timedelta(minutes=15.0))
self.assertEqual(th.comfort_temperature, 20.0)
self.assertEqual(th.eco_temperature, 17.0)
self.assertEqual(th.temperature_offset, 0)
@pytest.mark.skip()
def test_query_schedule(self):
self.fail()
@pytest.mark.skip()
def test_schedule(self):
self.fail()
@pytest.mark.skip()
def test_set_schedule(self):
self.fail()
@pytest.mark.skip()
def test_target_temperature(self):
self.fail()
@pytest.mark.skip()
def test_mode(self):
self.fail()
@pytest.mark.skip()
def test_mode_readable(self):
self.fail()
@pytest.mark.skip()
def test_boost(self):
self.fail()
def test_valve_state(self):
th = self.thermostat
th._conn.set_status("valve_at_22")
th.update()
self.assertEqual(th.valve_state, 22)
def test_window_open(self):
th = self.thermostat
th._conn.set_status("window")
th.update()
self.assertTrue(th.window_open)
@pytest.mark.skip()
def test_window_open_config(self):
self.fail()
@pytest.mark.skip()
def test_locked(self):
self.fail()
@pytest.mark.skip()
def test_low_battery(self):
th = self.thermostat
th._conn.set_status("low_batt")
th.update()
self.assertTrue(th.low_battery)
@pytest.mark.skip()
def test_temperature_offset(self):
self.fail()
@pytest.mark.skip()
def test_activate_comfort(self):
self.fail()
@pytest.mark.skip()
def test_activate_eco(self):
self.fail()
@pytest.mark.skip()
def test_min_temp(self):
self.fail()
@pytest.mark.skip()
def test_max_temp(self):
self.fail()
@pytest.mark.skip()
def test_away_end(self):
self.fail()
@pytest.mark.skip()
def test_decode_mode(self):
self.fail()

View File

@ -0,0 +1 @@
pip

View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Markus Peter
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,230 @@
Metadata-Version: 2.1
Name: python-eq3bt
Version: 0.2
Summary: EQ3 bluetooth thermostat support library
Home-page: https://github.com/rytilahti/python-eq3bt
License: MIT
Author: Teemu R.
Author-email: tpr@iki.fi
Requires-Python: >=3.7,<4.0
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Provides-Extra: bluepy
Provides-Extra: gattlib
Requires-Dist: bleak
Requires-Dist: bluepy (>=1.0.5); extra == "bluepy"
Requires-Dist: click
Requires-Dist: construct
Requires-Dist: gattlib; extra == "gattlib"
Project-URL: Repository, https://github.com/rytilahti/python-eq3bt
Description-Content-Type: text/markdown
# python-eq3bt
Python library and a command line tool for EQ3 Bluetooth smart thermostats, uses bleak (default), bluepy or gattlib for BTLE communication.
# Features
* Reading device status: locked, low battery, valve state, window open, target temperature, active mode
* Writing settings: target temperature, auto mode presets, temperature offset
* Setting the active mode: auto, manual, boost, away
* Reading the device serial number and firmware version
* Reading presets and temperature offset in more recent firmware versions.
## Not (yet) supported
* No easy-to-use interface for setting schedules.
# Installation
```bash
pip install python-eq3bt
```
# Command-line Usage
To test all available functionality a cli tool inside utils can be used:
```
$ eq3cli --help
Usage: eq3cli [OPTIONS] COMMAND [ARGS]...
Tool to query and modify the state of EQ3 BT smart thermostat.
Options:
--mac TEXT [required]
--interface TEXT
--debug / --normal
--backend [bleak|bluepy|gattlib]
--help Show this message and exit.
Commands:
away Enables or disables the away mode.
boost Gets or sets the boost mode.
device Displays basic device information.
locked Gets or sets the lock.
low-battery Gets the low battery status.
mode Gets or sets the active mode.
offset Sets the temperature offset [-3,5 3,5]
presets Sets the preset temperatures for auto mode.
schedule Gets the schedule from the thermostat.
state Prints out all available information.
temp Gets or sets the target temperature.
valve-state Gets the state of the valve.
window-open Gets and sets the window open settings.
```
EQ3_MAC environment variable can be used to define mac to avoid typing it:
```bash
export EQ3_MAC=XX:XX
```
Without parameters current state of the device is printed out.
```bash
$ eq3cli
[00:1A:22:XX:XX:XX] Target 17.0 (mode: auto dst, away: no)
Locked: False
Batter low: False
Window open: False
Window open temp: 12.0
Window open time: 0:15:00
Boost: False
Current target temp: 17.0
Current comfort temp: 20.0
Current eco temp: 17.0
Current mode: auto dst locked
Valve: 0
```
Getting & setting values.
```bash
$ eq3cli temp
Current target temp: 17.0
eq3cli temp --target 20
Current target temp: 17.0
Setting target temp: 20.0
```
# Pairing
If you have thermostat with firmware version 1.20+ pairing may be needed. Below simple procedure to do that.
```
Press and hold wheel on thermostat until Pair will be displayed. Remember or write it.
$ sudo bluetoothctl
[bluetooth]# power on
[bluetooth]# agent on
[bluetooth]# default-agent
[bluetooth]# scan on
[bluetooth]# scan off
[bluetooth]# pair 00:1A:22:06:A7:83
[agent] Enter passkey (number in 0-999999): <enter pin>
[bluetooth]# trust 00:1A:22:06:A7:83
[bluetooth]# disconnect 00:1A:22:06:A7:83
[bluetooth]# exit
Optional steps:
[bluetooth]# devices - to list all bluetooth devices
[bluetooth]# info 00:1A:22:06:A7:83
Device 00:1A:22:06:A7:83 (public)
Name: CC-RT-BLE
Alias: CC-RT-BLE
Paired: yes
Trusted: yes
Blocked: no
Connected: no
LegacyPairing: no
UUID: Generic Access Profile (00001800-0000-1000-8000-00805f9b34fb)
UUID: Generic Attribute Profile (00001801-0000-1000-8000-00805f9b34fb)
UUID: Device Information (0000180a-0000-1000-8000-00805f9b34fb)
UUID: Vendor specific (3e135142-654f-9090-134a-a6ff5bb77046)
UUID: Vendor specific (9e5d1e47-5c13-43a0-8635-82ad38a1386f)
ManufacturerData Key: 0x0000
ManufacturerData Value:
00 00 00 00 00 00 00 00 00 .........
```
Be aware that sometimes if you pair your device then mobile application (calor BT) can't connect with thermostat and vice versa.
# Library Usage
```
from eq3bt import Thermostat
thermostat = Thermostat('AB:CD:EF:12:23:45')
thermostat.update() # fetches data from the thermostat
print(thermostat)
```
<aside class="notice">
Notice: The device in question has to be disconnected from bluetoothd, since BTLE devices can only hold one connection at a time.
The library will try to connect to the device second time in case it wasn't successful in the first time,
which can happen if you are running other applications connecting to the same thermostat.
</aside>
## Fetching schedule
The schedule is queried per day basis and the cached information can be
accessed through `schedule` property..
```
from eq3bt import Thermostat
thermostat = Thermostat('AB:CD:EF:12:34:45')
thermostat.query_schedule(0)
print(thermostat.schedule)
```
## Setting schedule
The 'base_temp' and 'next_change_at' paramater define the first period for that 'day' (the period from midnight up till next_change_at).
The schedule can be set on a per day basis like follows:
```
from datetime import time
from eq3bt import Thermostat
from eq3bt import HOUR_24_PLACEHOLDER as END_OF_DAY
thermostat = Thermostat('12:34:56:78:9A:BC')
thermostat.set_schedule(
dict(
cmd="write",
day="sun",
base_temp=18,
next_change_at=time(8, 0),
hours=[
dict(target_temp=23, next_change_at=time(20, 0)),
dict(target_temp=18, next_change_at=END_OF_DAY),
dict(target_temp=23, next_change_at=END_OF_DAY),
dict(target_temp=23, next_change_at=END_OF_DAY),
dict(target_temp=23, next_change_at=END_OF_DAY),
dict(target_temp=23, next_change_at=END_OF_DAY)
]
)
)
```
# Contributing
Feel free to open pull requests to improve the library!
This project uses github actions to enforce code formatting using tools like black, isort, flake8, and mypy.
You can run these checks locally either by executing `pre-commit run -a` or using `tox` which also runs the test suite.
# History
This library is a simplified version of bluepy_devices from Markus Peter (https://github.com/bimbar/bluepy_devices.git) with support for more features and robuster device handling.

View File

@ -0,0 +1,25 @@
../../../bin/eq3cli,sha256=TFR4GGzCskn7h5eKh4qUtn2HO-VX7rl4O4VKAFMH92Y,215
CHANGELOG,sha256=FVxHhLou6OnfINd-OStO2tzYAjsalbowKDL61Fr9Z-Y,6002
eq3bt/__init__.py,sha256=fUKZ9G7dFcM5wEOIJdOUAUcyJBeDODsdeVCk2J-w3q0,189
eq3bt/__pycache__/__init__.cpython-310.pyc,,
eq3bt/__pycache__/bleakconnection.cpython-310.pyc,,
eq3bt/__pycache__/connection.cpython-310.pyc,,
eq3bt/__pycache__/eq3btsmart.cpython-310.pyc,,
eq3bt/__pycache__/eq3cli.cpython-310.pyc,,
eq3bt/__pycache__/gattlibconnection.cpython-310.pyc,,
eq3bt/__pycache__/structures.cpython-310.pyc,,
eq3bt/bleakconnection.py,sha256=E5QWp0fYUDR150zBB8xtY68cRThADXpEUei5F6hTCQo,4269
eq3bt/connection.py,sha256=j2E2Nugo3BeteHFY3uozfb_8YZxWB5Y1itNa_SGQtlk,3217
eq3bt/eq3btsmart.py,sha256=st-NMiIeuXOR6EYBESkKWyRnMfrFpzZq6b4P-sOy7Sk,15716
eq3bt/eq3cli.py,sha256=_YFvGT0XsHE3WdGW0jp1NaH_yrRnYDpvmwBJHPOm7io,6219
eq3bt/gattlibconnection.py,sha256=25s9pY0azzwEUfJsTRXcFs0SPeoOKwb2Tx4QUsL5w_g,3285
eq3bt/structures.py,sha256=rSFQ6eTYR-18A9WEcNPcBb2P8S-G-u9gx5jVhDIjgl0,4635
eq3bt/tests/__pycache__/test_thermostat.cpython-310.pyc,,
eq3bt/tests/test_thermostat.py,sha256=lDLCNvCNgVOJoa6JUEXvAtvfhkLZR2UJZh7_XvXGQbI,5381
python_eq3bt-0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
python_eq3bt-0.2.dist-info/LICENSE,sha256=og5_OL0-NkYbkZwhvFpb4hR3EfM-0IXaore85BAkhLw,1079
python_eq3bt-0.2.dist-info/METADATA,sha256=gjGWD8CeJ_uZqr_JTfQd4wCfRErXhUxoLuJs5l8F3KA,6842
python_eq3bt-0.2.dist-info/RECORD,,
python_eq3bt-0.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
python_eq3bt-0.2.dist-info/WHEEL,sha256=DA86_h4QwwzGeRoz62o1svYt5kGEXpoUTuTtwzoTb30,83
python_eq3bt-0.2.dist-info/entry_points.txt,sha256=XClUgvSU6CSK_T1HCDmeMObxQ6wKPpxMjWlZe5TiELk,43

View File

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: poetry 1.0.8
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,3 @@
[console_scripts]
eq3cli=eq3bt.eq3cli:cli

View File

@ -1,6 +1,6 @@
notify:
- platform: html5
name: browser
vapid_pub_key: !secret google_api_pub
vapid_prv_key: !secret google_api_priv
vapid_email: !secret google_api_email
#notify:
# - platform: html5
# name: browser
# vapid_pub_key: !secret google_api_pub
# vapid_prv_key: !secret google_api_priv
# vapid_email: !secret google_api_email

4
themes/minimalist-desktop/minimalist-desktop.yaml Executable file → Normal file
View File

@ -10,6 +10,10 @@ minimalist-desktop:
info-color: "var(--google-blue)"
divider-color: "rgba(var(--color-theme),.12)"
accent-color: "var(--google-yellow)"
ha-dialog-border-radius: "10px"
# fix added border-lines in 2022.11
ha-card-border-width: "0px"
card-mod-theme: "minimalist-desktop"
card-mod-view-yaml: |
"*:first-child$": |

View File

@ -12,6 +12,10 @@ minimalist-ios-tapbar:
info-color: "var(--google-blue)"
divider-color: "rgba(var(--color-theme),.12)"
accent-color: "var(--google-yellow)"
ha-dialog-border-radius: "10px"
# fix added border-lines in 2022.11
ha-card-border-width: "0px"
card-mod-theme: "minimalist-ios-tapbar"
card-mod-root: |
app-toolbar {

View File

@ -14,6 +14,9 @@ minimalist-mobile-tapbar:
header-height: "calc(var(--header-base-height) + env(safe-area-inset-bottom))"
header-base-height: "70px"
app-header-selection-bar-color: "transparent"
ha-dialog-border-radius: "10px"
# fix added border-lines in 2022.11
ha-card-border-width: "0px"
card-mod-view-yaml: |
"*:first-child$": |

4
themes/minimalist-mobile/minimalist-mobile.yaml Executable file → Normal file
View File

@ -10,6 +10,10 @@ minimalist-mobile:
info-color: "var(--google-blue)"
divider-color: "rgba(var(--color-theme),.12)"
accent-color: "var(--google-yellow)"
ha-dialog-border-radius: "10px"
# fix added border-lines in 2022.11
ha-card-border-width: "0px"
card-mod-theme: "minimalist-mobile"
card-mod-root: |
app-toolbar {