├── eq3bt ├── __init__.py ├── connection.py ├── gattlibconnection.py ├── bleakconnection.py ├── structures.py ├── tests │ └── test_thermostat.py ├── eq3cli.py └── eq3btsmart.py ├── tox.ini ├── .github └── workflows │ ├── publish.yml │ └── ci.yml ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── CHANGELOG ├── README.md └── poetry.lock /eq3bt/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .eq3btsmart import Mode, TemperatureException, Thermostat 3 | from .structures import * 4 | 5 | 6 | class BackendException(Exception): 7 | """Exception to wrap backend exceptions.""" 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,tests 3 | skip_missing_interpreters = True 4 | isolated_build = True 5 | 6 | [flake8] 7 | ignore = E501 8 | 9 | [testenv:lint] 10 | deps= 11 | pre-commit 12 | basepython = python3 13 | ignore_errors = True 14 | commands = 15 | pre-commit run -a 16 | 17 | [testenv:tests] 18 | deps= 19 | pytest 20 | construct 21 | commands = 22 | pytest eq3bt 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish packages 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | build-n-publish: 8 | name: Build release packages 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@master 13 | - name: Setup python 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: 3.9 17 | 18 | - name: Install pypa/build 19 | run: >- 20 | python -m 21 | pip install 22 | build 23 | --user 24 | - name: Build a binary wheel and a source tarball 25 | run: >- 26 | python -m 27 | build 28 | --sdist 29 | --wheel 30 | --outdir dist/ 31 | . 32 | - name: Publish release on pypi 33 | uses: pypa/gh-action-pypi-publish@master 34 | with: 35 | password: ${{ secrets.PYPI_API_TOKEN }} 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-docstring-first 8 | - id: check-yaml 9 | - id: debug-statements 10 | - id: check-ast 11 | 12 | - repo: https://github.com/asottile/pyupgrade 13 | rev: v2.35.0 14 | hooks: 15 | - id: pyupgrade 16 | args: ['--py37-plus'] 17 | 18 | - repo: https://github.com/python/black 19 | rev: 22.6.0 20 | hooks: 21 | - id: black 22 | 23 | - repo: https://github.com/pycqa/flake8 24 | rev: 3.9.2 25 | hooks: 26 | - id: flake8 27 | 28 | - repo: https://github.com/pre-commit/mirrors-isort 29 | rev: v5.10.1 30 | hooks: 31 | - id: isort 32 | additional_dependencies: [toml] 33 | 34 | - repo: https://github.com/pre-commit/mirrors-mypy 35 | rev: v0.961 36 | hooks: 37 | - id: mypy 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Markus Peter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "python-eq3bt" 3 | version = "0.2" 4 | description = "EQ3 bluetooth thermostat support library" 5 | license = "MIT" 6 | authors = ["Teemu R. ", "Markus Peter "] 7 | repository = "https://github.com/rytilahti/python-eq3bt" 8 | readme = "README.md" 9 | packages = [ 10 | { include = "eq3bt" } 11 | ] 12 | include = ["CHANGELOG"] 13 | 14 | [tool.poetry.scripts] 15 | eq3cli = "eq3bt.eq3cli:cli" 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.7" 19 | click = "*" 20 | construct = "*" 21 | bleak = "*" 22 | gattlib = { version = "*", optional = true } 23 | bluepy = { version = ">=1.0.5", optional = true } 24 | 25 | [tool.poetry.extras] 26 | gattlib = ["gattlib"] 27 | bluepy = ["bluepy"] 28 | 29 | [tool.poetry.dev-dependencies] 30 | pytest = "*" 31 | pre-commit = "*" 32 | toml = "*" 33 | tox = "*" 34 | codecov = "*" 35 | pytest-cov = "*" 36 | 37 | 38 | [tool.isort] 39 | multi_line_output = 3 40 | include_trailing_comma = true 41 | force_grid_wrap = 0 42 | use_parentheses = true 43 | line_length = 88 44 | known_first_party = "eq3bt" 45 | known_third_party = ["click", "pytest"] 46 | 47 | [build-system] 48 | requires = ["poetry-core>=1.0.0"] 49 | build-backend = "poetry.core.masonry.api" 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | workflow_dispatch: # to allow manual re-runs 9 | 10 | 11 | jobs: 12 | linting: 13 | name: "Perform linting checks" 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | python-version: ["3.10"] 19 | 20 | steps: 21 | - uses: "actions/checkout@v2" 22 | - uses: "actions/setup-python@v2" 23 | with: 24 | python-version: "${{ matrix.python-version }}" 25 | - name: "Install dependencies" 26 | run: | 27 | python -m pip install --upgrade pip poetry tox 28 | poetry install 29 | - name: "Run pre-commit -a" 30 | run: | 31 | poetry run pre-commit run -a 32 | 33 | tests: 34 | name: "Python ${{ matrix.python-version}} on ${{ matrix.os }}" 35 | needs: linting 36 | runs-on: ${{ matrix.os }} 37 | 38 | strategy: 39 | matrix: 40 | python-version: ["3.7", "3.8", "3.9", "3.10", "pypy-3.7"] 41 | os: [ubuntu-latest] 42 | 43 | steps: 44 | - uses: "actions/checkout@v2" 45 | - uses: "actions/setup-python@v2" 46 | with: 47 | python-version: "${{ matrix.python-version }}" 48 | - name: "Install dependencies" 49 | run: | 50 | python -m pip install --upgrade pip poetry 51 | poetry install 52 | - name: "Run tests" 53 | run: | 54 | poetry run pytest --cov eq3bt --cov-report xml 55 | -------------------------------------------------------------------------------- /eq3bt/connection.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple wrapper for bluepy's btle.Connection. 3 | Handles Connection duties (reconnecting etc.) transparently. 4 | """ 5 | import codecs 6 | import logging 7 | 8 | from bluepy import btle 9 | 10 | from . import BackendException 11 | 12 | DEFAULT_TIMEOUT = 1 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class BTLEConnection(btle.DefaultDelegate): 18 | """Representation of a BTLE Connection.""" 19 | 20 | def __init__(self, mac, iface): 21 | """Initialize the connection.""" 22 | btle.DefaultDelegate.__init__(self) 23 | 24 | self._conn = None 25 | self._mac = mac 26 | self._iface = iface 27 | self._callbacks = {} 28 | 29 | def __enter__(self): 30 | """ 31 | Context manager __enter__ for connecting the device 32 | :rtype: btle.Peripheral 33 | :return: 34 | """ 35 | self._conn = btle.Peripheral() 36 | self._conn.withDelegate(self) 37 | _LOGGER.debug("Trying to connect to %s", self._mac) 38 | try: 39 | self._conn.connect(self._mac, iface=self._iface) 40 | except btle.BTLEException as ex: 41 | _LOGGER.debug( 42 | "Unable to connect to the device %s, retrying: %s", self._mac, ex 43 | ) 44 | try: 45 | self._conn.connect(self._mac, iface=self._iface) 46 | except Exception as ex2: 47 | _LOGGER.debug("Second connection try to %s failed: %s", self._mac, ex2) 48 | raise BackendException( 49 | "Unable to connect to device using bluepy" 50 | ) from ex2 51 | 52 | _LOGGER.debug("Connected to %s", self._mac) 53 | return self 54 | 55 | def __exit__(self, exc_type, exc_val, exc_tb): 56 | if self._conn: 57 | self._conn.disconnect() 58 | self._conn = None 59 | 60 | def handleNotification(self, handle, data): 61 | """Handle Callback from a Bluetooth (GATT) request.""" 62 | _LOGGER.debug( 63 | "Got notification from %s: %s", handle, codecs.encode(data, "hex") 64 | ) 65 | if handle in self._callbacks: 66 | self._callbacks[handle](data) 67 | 68 | @property 69 | def mac(self): 70 | """Return the MAC address of the connected device.""" 71 | return self._mac 72 | 73 | def set_callback(self, handle, function): 74 | """Set the callback for a Notification handle. It will be called with the parameter data, which is binary.""" 75 | self._callbacks[handle] = function 76 | 77 | def make_request(self, handle, value, timeout=DEFAULT_TIMEOUT, with_response=True): 78 | """Write a GATT Command without callback - not utf-8.""" 79 | try: 80 | with self: 81 | _LOGGER.debug( 82 | "Writing %s to %s with with_response=%s", 83 | codecs.encode(value, "hex"), 84 | handle, 85 | with_response, 86 | ) 87 | self._conn.writeCharacteristic( 88 | handle, value, withResponse=with_response 89 | ) 90 | if timeout: 91 | _LOGGER.debug("Waiting for notifications for %s", timeout) 92 | self._conn.waitForNotifications(timeout) 93 | except btle.BTLEException as ex: 94 | _LOGGER.debug("Got exception from bluepy while making a request: %s", ex) 95 | raise BackendException("Exception on write using bluepy") from ex 96 | -------------------------------------------------------------------------------- /eq3bt/gattlibconnection.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple adapter to gattlib. 3 | Handles Connection duties (reconnecting etc.) transparently. 4 | """ 5 | import codecs 6 | import logging 7 | import threading 8 | 9 | import gattlib 10 | 11 | from . import BackendException 12 | 13 | DEFAULT_TIMEOUT = 1 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class BTLEConnection: 19 | """Representation of a BTLE Connection.""" 20 | 21 | def __init__(self, mac, iface): 22 | """Initialize the connection.""" 23 | 24 | self._conn = None 25 | self._mac = mac 26 | self._iface = iface 27 | self._callbacks = {} 28 | self._notifyevent = None 29 | 30 | def __enter__(self): 31 | """ 32 | Context manager __enter__ for connecting the device 33 | :rtype: BTLEConnection 34 | :return: 35 | """ 36 | _LOGGER.debug("Trying to connect to %s", self._mac) 37 | if self._iface is None: 38 | self._conn = gattlib.GATTRequester(self._mac, False) 39 | else: 40 | self._conn = gattlib.GATTRequester(self._mac, False, self._iface) 41 | self._conn.on_notification = self.on_notification 42 | try: 43 | self._conn.connect() 44 | except gattlib.BTBaseException as ex: 45 | _LOGGER.debug( 46 | "Unable to connect to the device %s, retrying: %s", self._mac, ex 47 | ) 48 | try: 49 | self._conn.connect() 50 | except Exception as ex2: 51 | _LOGGER.debug("Second connection try to %s failed: %s", self._mac, ex2) 52 | raise BackendException( 53 | "unable to connect to device using gattlib" 54 | ) from ex2 55 | 56 | _LOGGER.debug("Connected to %s", self._mac) 57 | return self 58 | 59 | def __exit__(self, exc_type, exc_val, exc_tb): 60 | if self._conn: 61 | self._conn.disconnect() 62 | self._conn = None 63 | 64 | def on_notification(self, handle, data): 65 | """Handle Callback from a Bluetooth (GATT) request.""" 66 | _LOGGER.debug( 67 | "Got notification from %s: %s", handle, codecs.encode(data, "hex") 68 | ) 69 | if handle in self._callbacks: 70 | self._callbacks[handle](data[3:]) 71 | if self._notifyevent: 72 | self._notifyevent.set() 73 | 74 | @property 75 | def mac(self): 76 | """Return the MAC address of the connected device.""" 77 | return self._mac 78 | 79 | def set_callback(self, handle, function): 80 | """Set the callback for a Notification handle. It will be called with the parameter data, which is binary.""" 81 | self._callbacks[handle] = function 82 | 83 | def make_request(self, handle, value, timeout=DEFAULT_TIMEOUT, with_response=True): 84 | """Write a GATT Command without callback - not utf-8.""" 85 | try: 86 | with self: 87 | _LOGGER.debug( 88 | "Writing %s to %s", 89 | codecs.encode(value, "hex"), 90 | handle, 91 | ) 92 | self._notifyevent = threading.Event() 93 | self._conn.write_by_handle(handle, value) 94 | if timeout: 95 | _LOGGER.debug("Waiting for notifications for %s", timeout) 96 | self._notifyevent.wait(timeout) 97 | except gattlib.BTBaseException as ex: 98 | _LOGGER.debug("Got exception from gattlib while making a request: %s", ex) 99 | raise BackendException("Exception on write using gattlib") from ex 100 | -------------------------------------------------------------------------------- /eq3bt/bleakconnection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bleak connection backend. 3 | This creates a new event loop that is used to integrate bleak's 4 | asyncio functions to synchronous architecture of python-eq3bt. 5 | """ 6 | import asyncio 7 | import codecs 8 | import contextlib 9 | import logging 10 | from typing import Optional 11 | 12 | from bleak import BleakClient, BleakError 13 | 14 | from . import BackendException 15 | 16 | DEFAULT_TIMEOUT = 1 17 | 18 | # bleak backends are very loud on debug, this reduces the log spam when using --debug 19 | logging.getLogger("bleak.backends").setLevel(logging.WARNING) 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class BleakConnection: 25 | """Representation of a BTLE Connection.""" 26 | 27 | def __init__(self, mac, iface): 28 | """Initialize the connection.""" 29 | 30 | self._conn: Optional[BleakClient] = None 31 | self._mac = mac 32 | self._iface = iface 33 | self._callbacks = {} 34 | self._notification_handle = None 35 | 36 | try: 37 | self._loop = asyncio.get_running_loop() 38 | _LOGGER.debug("Using existing asyncio loop") 39 | except RuntimeError: 40 | self._loop = asyncio.new_event_loop() 41 | _LOGGER.debug("Creating new asyncio loop") 42 | asyncio.set_event_loop(self._loop) 43 | 44 | self._notifyevent = asyncio.Event() 45 | 46 | def __enter__(self): 47 | """ 48 | Context manager __enter__ for connecting the device 49 | :rtype: BTLEConnection 50 | :return: 51 | """ 52 | _LOGGER.debug("Trying to connect to %s", self._mac) 53 | 54 | kwargs = {} 55 | if self._iface is not None: 56 | kwargs["adapter"] = self._iface 57 | self._conn = BleakClient(self._mac, **kwargs) 58 | try: 59 | self._loop.run_until_complete(self._conn.connect()) 60 | except BleakError as ex: 61 | _LOGGER.debug( 62 | "Unable to connect to the device %s, retrying: %s", self._mac, ex 63 | ) 64 | try: 65 | self._loop.run_until_complete(self._conn.connect()) 66 | except Exception as ex2: 67 | _LOGGER.debug("Second connection try to %s failed: %s", self._mac, ex2) 68 | raise BackendException( 69 | "unable to connect to device using bleak" 70 | ) from ex2 71 | 72 | # The notification handles are off-by-one compared to gattlib and bluepy 73 | self._loop.run_until_complete( 74 | self._conn.start_notify(self._notification_handle - 1, self.on_notification) 75 | ) 76 | _LOGGER.debug("Connected to %s", self._mac) 77 | 78 | return self 79 | 80 | def __exit__(self, exc_type, exc_val, exc_tb): 81 | if self._conn: 82 | self._loop.run_until_complete(self._conn.disconnect()) 83 | self._conn = None 84 | 85 | async def on_notification(self, characteristic, data): 86 | """Handle Callback from a Bluetooth (GATT) request.""" 87 | # The notification handles are off-by-one compared to gattlib and bluepy 88 | try: 89 | handle = characteristic.handle + 1 90 | except: # noqa: E722 # fallback to old-style, int-based handle 91 | handle = characteristic + 1 92 | 93 | _LOGGER.debug( 94 | "Got notification from %s: %s", handle, codecs.encode(data, "hex") 95 | ) 96 | self._notifyevent.set() 97 | 98 | if handle in self._callbacks: 99 | self._callbacks[handle](data) 100 | 101 | @property 102 | def mac(self): 103 | """Return the MAC address of the connected device.""" 104 | return self._mac 105 | 106 | def set_callback(self, handle, function): 107 | """Set the callback for a Notification handle. It will be called with the parameter data, which is binary.""" 108 | self._notification_handle = handle 109 | self._callbacks[handle] = function 110 | 111 | async def wait_for_response(self, timeout): 112 | with contextlib.suppress(asyncio.TimeoutError): 113 | await asyncio.wait_for(self._notifyevent.wait(), timeout) 114 | 115 | def make_request(self, handle, value, timeout=DEFAULT_TIMEOUT, with_response=True): 116 | """Write a GATT Command without callback - not utf-8.""" 117 | try: 118 | with self: 119 | _LOGGER.debug( 120 | "Writing %s to %s", 121 | codecs.encode(value, "hex"), 122 | handle, 123 | ) 124 | self._notifyevent.clear() 125 | 126 | self._loop.run_until_complete( 127 | self._conn.write_gatt_char(handle - 1, value) 128 | ) 129 | if timeout: 130 | _LOGGER.debug("Waiting for notifications for %s", timeout) 131 | self._loop.run_until_complete(self.wait_for_response(timeout)) 132 | 133 | except BleakError as ex: 134 | _LOGGER.debug("Got exception from bleak while making a request: %s", ex) 135 | raise BackendException("Exception on write using bleak") from ex 136 | -------------------------------------------------------------------------------- /eq3bt/structures.py: -------------------------------------------------------------------------------- 1 | """ Contains construct adapters and structures. """ 2 | from datetime import datetime, time, timedelta 3 | 4 | from construct import ( 5 | Adapter, 6 | Bytes, 7 | Const, 8 | Enum, 9 | FlagsEnum, 10 | GreedyRange, 11 | IfThenElse, 12 | Int8ub, 13 | Optional, 14 | Struct, 15 | ) 16 | 17 | PROP_ID_RETURN = 1 18 | PROP_INFO_RETURN = 2 19 | PROP_SCHEDULE_SET = 0x10 20 | PROP_SCHEDULE_RETURN = 0x21 21 | 22 | NAME_TO_DAY = {"sat": 0, "sun": 1, "mon": 2, "tue": 3, "wed": 4, "thu": 5, "fri": 6} 23 | NAME_TO_CMD = {"write": PROP_SCHEDULE_SET, "response": PROP_SCHEDULE_RETURN} 24 | HOUR_24_PLACEHOLDER = 1234 25 | 26 | 27 | class TimeAdapter(Adapter): 28 | """Adapter to encode and decode schedule times.""" 29 | 30 | def _decode(self, obj, ctx, path): 31 | h, m = divmod(obj * 10, 60) 32 | if h == 24: # HACK, can we do better? 33 | return HOUR_24_PLACEHOLDER 34 | return time(hour=h, minute=m) 35 | 36 | def _encode(self, obj, ctx, path): 37 | # TODO: encode h == 24 hack 38 | if obj == HOUR_24_PLACEHOLDER: 39 | return int(24 * 60 / 10) 40 | encoded = int((obj.hour * 60 + obj.minute) / 10) 41 | return encoded 42 | 43 | 44 | class TempAdapter(Adapter): 45 | """Adapter to encode and decode temperature.""" 46 | 47 | def _decode(self, obj, ctx, path): 48 | return float(obj / 2.0) 49 | 50 | def _encode(self, obj, ctx, path): 51 | return int(obj * 2.0) 52 | 53 | 54 | class WindowOpenTimeAdapter(Adapter): 55 | """Adapter to encode and decode window open times (5 min increments).""" 56 | 57 | def _decode(self, obj, context, path): 58 | return timedelta(minutes=float(obj * 5.0)) 59 | 60 | def _encode(self, obj, context, path): 61 | if isinstance(obj, timedelta): 62 | obj = obj.seconds 63 | if 0 <= obj <= 3600.0: 64 | return int(obj / 300.0) 65 | raise ValueError( 66 | "Window open time must be between 0 and 60 minutes " 67 | "in intervals of 5 minutes." 68 | ) 69 | 70 | 71 | class TempOffsetAdapter(Adapter): 72 | """Adapter to encode and decode the temperature offset.""" 73 | 74 | def _decode(self, obj, context, path): 75 | return float((obj - 7) / 2.0) 76 | 77 | def _encode(self, obj, context, path): 78 | if -3.5 <= obj <= 3.5: 79 | return int(obj * 2.0) + 7 80 | raise ValueError( 81 | "Temperature offset must be between -3.5 and 3.5 (in " "intervals of 0.5)." 82 | ) 83 | 84 | 85 | ModeFlags = "ModeFlags" / FlagsEnum( 86 | Int8ub, 87 | AUTO=0x00, # always True, doesnt affect building 88 | MANUAL=0x01, 89 | AWAY=0x02, 90 | BOOST=0x04, 91 | DST=0x08, 92 | WINDOW=0x10, 93 | LOCKED=0x20, 94 | UNKNOWN=0x40, 95 | LOW_BATTERY=0x80, 96 | ) 97 | 98 | 99 | class AwayDataAdapter(Adapter): 100 | """Adapter to encode and decode away data.""" 101 | 102 | def _decode(self, obj, ctx, path): 103 | (day, year, hour_min, month) = obj 104 | year += 2000 105 | 106 | min = 0 107 | if hour_min & 0x01: 108 | min = 30 109 | hour = int(hour_min / 2) 110 | 111 | return datetime(year=year, month=month, day=day, hour=hour, minute=min) 112 | 113 | def _encode(self, obj, ctx, path): 114 | if obj.year < 2000 or obj.year > 2099: 115 | raise Exception("Invalid year, possible [2000,2099]") 116 | year = obj.year - 2000 117 | hour = obj.hour * 2 118 | if obj.minute: # we encode all minute values to h:30 119 | hour |= 0x01 120 | return (obj.day, year, hour, obj.month) 121 | 122 | 123 | class DeviceSerialAdapter(Adapter): 124 | """Adapter to decode the device serial number.""" 125 | 126 | def _decode(self, obj, context, path): 127 | return bytearray(n - 0x30 for n in obj).decode() 128 | 129 | 130 | Status = "Status" / Struct( 131 | "cmd" / Const(PROP_INFO_RETURN, Int8ub), 132 | Const(0x01, Int8ub), 133 | "mode" / ModeFlags, 134 | "valve" / Int8ub, 135 | Const(0x04, Int8ub), 136 | "target_temp" / TempAdapter(Int8ub), 137 | "away" 138 | / IfThenElse( # noqa: W503 139 | lambda ctx: ctx.mode.AWAY, AwayDataAdapter(Bytes(4)), Optional(Bytes(4)) 140 | ), 141 | "presets" 142 | / Optional( # noqa: W503 143 | Struct( 144 | "window_open_temp" / TempAdapter(Int8ub), 145 | "window_open_time" / WindowOpenTimeAdapter(Int8ub), 146 | "comfort_temp" / TempAdapter(Int8ub), 147 | "eco_temp" / TempAdapter(Int8ub), 148 | "offset" / TempOffsetAdapter(Int8ub), 149 | ) 150 | ), 151 | ) 152 | 153 | Schedule = "Schedule" / Struct( 154 | "cmd" / Enum(Int8ub, **NAME_TO_CMD), 155 | "day" / Enum(Int8ub, **NAME_TO_DAY), 156 | "base_temp" / TempAdapter(Int8ub), 157 | "next_change_at" / TimeAdapter(Int8ub), 158 | "hours" 159 | / GreedyRange( # noqa: W503 160 | Struct( 161 | "target_temp" / TempAdapter(Int8ub), 162 | "next_change_at" / TimeAdapter(Int8ub), 163 | ) 164 | ), 165 | ) 166 | 167 | DeviceId = "DeviceId" / Struct( 168 | "cmd" / Const(PROP_ID_RETURN, Int8ub), 169 | "version" / Int8ub, 170 | Int8ub, 171 | Int8ub, 172 | "serial" / DeviceSerialAdapter(Bytes(10)), 173 | Int8ub, 174 | ) 175 | -------------------------------------------------------------------------------- /eq3bt/tests/test_thermostat.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | from datetime import datetime, timedelta 3 | from unittest import TestCase 4 | 5 | import pytest 6 | 7 | from eq3bt import TemperatureException, Thermostat 8 | from eq3bt.eq3btsmart import PROP_ID_QUERY, PROP_INFO_QUERY, PROP_NTFY_HANDLE, Mode 9 | 10 | ID_RESPONSE = b"01780000807581626163606067659e" 11 | STATUS_RESPONSES = { 12 | "auto": b"020100000428", 13 | "manual": b"020101000428", 14 | "window": b"020110000428", 15 | "away": b"0201020004231d132e03", 16 | "boost": b"020104000428", 17 | "low_batt": b"020180000428", 18 | "valve_at_22": b"020100160428", 19 | "presets": b"020100000422000000001803282207", 20 | } 21 | 22 | 23 | class FakeConnection: 24 | def __init__(self, _iface, mac): 25 | self._callbacks = {} 26 | self._res = "auto" 27 | 28 | def set_callback(self, handle, cb): 29 | self._callbacks[handle] = cb 30 | 31 | def set_status(self, key): 32 | if key in STATUS_RESPONSES: 33 | self._res = key 34 | else: 35 | raise ValueError("Invalid key for status test response.") 36 | 37 | def make_request(self, handle, value, timeout=1, with_response=True): 38 | """Write a GATT Command without callback - not utf-8.""" 39 | if with_response: 40 | cb = self._callbacks.get(PROP_NTFY_HANDLE) 41 | 42 | if value[0] == PROP_ID_QUERY: 43 | data = ID_RESPONSE 44 | elif value[0] == PROP_INFO_QUERY: 45 | data = STATUS_RESPONSES[self._res] 46 | else: 47 | return 48 | cb(codecs.decode(data, "hex")) 49 | 50 | 51 | class TestThermostat(TestCase): 52 | def setUp(self): 53 | self.thermostat = Thermostat( 54 | _mac=None, _iface=None, connection_cls=FakeConnection 55 | ) 56 | 57 | def test__verify_temperature(self): 58 | with self.assertRaises(TemperatureException): 59 | self.thermostat._verify_temperature(-1) 60 | with self.assertRaises(TemperatureException): 61 | self.thermostat._verify_temperature(35) 62 | 63 | self.thermostat._verify_temperature(8) 64 | self.thermostat._verify_temperature(25) 65 | 66 | @pytest.mark.skip() 67 | def test_parse_schedule(self): 68 | self.fail() 69 | 70 | @pytest.mark.skip() 71 | def test_handle_notification(self): 72 | self.fail() 73 | 74 | def test_query_id(self): 75 | self.thermostat.query_id() 76 | self.assertEqual(self.thermostat.firmware_version, 120) 77 | self.assertEqual(self.thermostat.device_serial, "PEQ2130075") 78 | 79 | def test_update(self): 80 | th = self.thermostat 81 | 82 | th._conn.set_status("auto") 83 | th.update() 84 | self.assertEqual(th.valve_state, 0) 85 | self.assertEqual(th.mode, Mode.Auto) 86 | self.assertEqual(th.target_temperature, 20.0) 87 | self.assertFalse(th.locked) 88 | self.assertFalse(th.low_battery) 89 | self.assertFalse(th.boost) 90 | self.assertFalse(th.window_open) 91 | 92 | th._conn.set_status("manual") 93 | th.update() 94 | self.assertTrue(th.mode, Mode.Manual) 95 | 96 | th._conn.set_status("away") 97 | th.update() 98 | self.assertEqual(th.mode, Mode.Away) 99 | self.assertEqual(th.target_temperature, 17.5) 100 | self.assertEqual(th.away_end, datetime(2019, 3, 29, 23, 00)) 101 | 102 | th._conn.set_status("boost") 103 | th.update() 104 | self.assertTrue(th.boost) 105 | self.assertEqual(th.mode, Mode.Boost) 106 | 107 | def test_presets(self): 108 | th = self.thermostat 109 | self.thermostat._conn.set_status("presets") 110 | self.thermostat.update() 111 | self.assertEqual(th.window_open_temperature, 12.0) 112 | self.assertEqual(th.window_open_time, timedelta(minutes=15.0)) 113 | self.assertEqual(th.comfort_temperature, 20.0) 114 | self.assertEqual(th.eco_temperature, 17.0) 115 | self.assertEqual(th.temperature_offset, 0) 116 | 117 | @pytest.mark.skip() 118 | def test_query_schedule(self): 119 | self.fail() 120 | 121 | @pytest.mark.skip() 122 | def test_schedule(self): 123 | self.fail() 124 | 125 | @pytest.mark.skip() 126 | def test_set_schedule(self): 127 | self.fail() 128 | 129 | @pytest.mark.skip() 130 | def test_target_temperature(self): 131 | self.fail() 132 | 133 | @pytest.mark.skip() 134 | def test_mode(self): 135 | self.fail() 136 | 137 | @pytest.mark.skip() 138 | def test_mode_readable(self): 139 | self.fail() 140 | 141 | @pytest.mark.skip() 142 | def test_boost(self): 143 | self.fail() 144 | 145 | def test_valve_state(self): 146 | th = self.thermostat 147 | th._conn.set_status("valve_at_22") 148 | th.update() 149 | self.assertEqual(th.valve_state, 22) 150 | 151 | def test_window_open(self): 152 | th = self.thermostat 153 | th._conn.set_status("window") 154 | th.update() 155 | self.assertTrue(th.window_open) 156 | 157 | @pytest.mark.skip() 158 | def test_window_open_config(self): 159 | self.fail() 160 | 161 | @pytest.mark.skip() 162 | def test_locked(self): 163 | self.fail() 164 | 165 | @pytest.mark.skip() 166 | def test_low_battery(self): 167 | th = self.thermostat 168 | th._conn.set_status("low_batt") 169 | th.update() 170 | self.assertTrue(th.low_battery) 171 | 172 | @pytest.mark.skip() 173 | def test_temperature_offset(self): 174 | self.fail() 175 | 176 | @pytest.mark.skip() 177 | def test_activate_comfort(self): 178 | self.fail() 179 | 180 | @pytest.mark.skip() 181 | def test_activate_eco(self): 182 | self.fail() 183 | 184 | @pytest.mark.skip() 185 | def test_min_temp(self): 186 | self.fail() 187 | 188 | @pytest.mark.skip() 189 | def test_max_temp(self): 190 | self.fail() 191 | 192 | @pytest.mark.skip() 193 | def test_away_end(self): 194 | self.fail() 195 | 196 | @pytest.mark.skip() 197 | def test_decode_mode(self): 198 | self.fail() 199 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.2 (2022-07-13) 5 | ---------------- 6 | 7 | - Add publish to pypi workflow (#54) [Teemu R] 8 | 9 | - Add bleak backend and make it default (#53) [Teemu R] 10 | 11 | - Wrap backend exceptions inside BackendException (#52) [Teemu R] 12 | 13 | - Add mac property to thermostat class (#51) [Teemu R] 14 | 15 | - Update README, pyproject.toml (#49) [Teemu R] 16 | 17 | - Support gattlib as an alternative btle library (#48) [Helmut Grohne] 18 | 19 | - Use poetry, add pre-commit hooks & mass format to modern standards, 20 | add CI (#47) [Teemu R] 21 | 22 | 23 | 0.1.12 (2021-11-13) 24 | ------------------- 25 | 26 | - Add bt interface selection (#44) [Hummel95] 27 | 28 | 0.1.11 (2019-05-27) 29 | ------------------- 30 | 31 | - Decoding presets in status messages (#33) [Matthias Erll] 32 | 33 | - Adding device serial number and firmware (#31) [Matthias Erll] 34 | 35 | - Context.invoke() -> Context.forward() (#28) [Till] 36 | 37 | - Require python 3.4 or newer in setup.py, closes #23. [Teemu Rytilahti] 38 | 39 | 40 | 0.1.10 (2018-11-09)) 41 | ------------------------ 42 | - Context.invoke() -> Context.forward() (#28) [Till] 43 | - Require python 3.4 or newer in setup.py, closes #23. [Teemu Rytilahti] 44 | 45 | 46 | 0.1.9 (2018-02-18) 47 | ------------------------ 48 | 49 | - Update to the new construct API (#20) [Arkadiusz Bulski] 50 | 51 | 52 | 0.1.8 (2018-01-20) 53 | ------------------ 54 | 55 | - Update to work with the newest construct release, bump version. [Teemu 56 | Rytilahti] 57 | 58 | - Update schedule example, fixes #15. [Teemu Rytilahti] 59 | 60 | - Do not suppress exceptions from bluepy, but log them to debug logger 61 | and raise exceptions for users to handle. [Teemu Rytilahti] 62 | 63 | - Install flake8 and pylint, which are required for the travis build. 64 | [Teemu Rytilahti] 65 | 66 | 0.1.7 (2017-10-06) 67 | ------------------------ 68 | - Fixed setting schedule not working (#9) [horsitis 69 | 70 | 0.1.6 (2017-04-01) 71 | ------------------------ 72 | 73 | - Version 0.1.6. [Teemu Rytilahti] 74 | 75 | - Use debug logging for the first round of connection error. [Teemu 76 | Rytilahti] 77 | 78 | - Disallow running with python versions older than 3.4. [Teemu 79 | Rytilahti] 80 | 81 | The library _may_ still be python2 compatible though for now, 82 | but this is unsupported and should not be relied on. 83 | 84 | - On/Off/Manual mode fixes (#6) [Janne Grunau] 85 | 86 | * Handle On/Off mode correctly 87 | 88 | * Set temperature in [EQ3BT_MIN_TEMP, EQ3BT_MAX_TEMP] for manual mode 89 | 90 | * simplify mode setter function 91 | 92 | - Be less noisy on connection errors. [Teemu Rytilahti] 93 | 94 | - Require and validate mac address at the cli (#4) [Klemens Schölhorn] 95 | 96 | - Add missing structures.py. this was already in pypi package, so no 97 | harm done. [Teemu Rytilahti] 98 | 99 | 100 | 0.1.5 (2017-01-28) 101 | ------------------------ 102 | 103 | - Version 0.1.5. [Teemu Rytilahti] 104 | 105 | - Fix manual on/off handling, cleanup the code for next release. [Teemu 106 | Rytilahti] 107 | 108 | - Make Thermostat testable. [Teemu Rytilahti] 109 | 110 | - Use less magic constants and more structures, fix manual mode setting. 111 | [Teemu Rytilahti] 112 | 113 | - Fix setup.py typo. [Teemu Rytilahti] 114 | 115 | - Eq3cli: add away command. [Teemu Rytilahti] 116 | 117 | - Restructuring with construct for more readable code. [Teemu Rytilahti] 118 | 119 | * add set_away(away_ends, temperature) for enabling and disabling away mode 120 | 121 | - Add hound-ci config. [Teemu Rytilahti] 122 | 123 | 124 | 0.1.4 (2017-01-15) 125 | ------------------ 126 | 127 | - Version 0.1.4. [Teemu Rytilahti] 128 | 129 | - Add away_end property. [Teemu Rytilahti] 130 | 131 | - Add changelog. [Teemu Rytilahti] 132 | 133 | 0.1.3 (2017-01-15) 134 | ------------------ 135 | 136 | - Make eq3bt a module. [Teemu Rytilahti] 137 | 138 | - Update README. [Teemu Rytilahti] 139 | 140 | - Add scheduling and offset functionality. [Teemu Rytilahti] 141 | 142 | - Connection: pretty print messaging in hex. [Teemu Rytilahti] 143 | 144 | - Setup.py: fix console script location. [Teemu Rytilahti] 145 | 146 | 0.1.2 (2017-01-14) 147 | ------------------ 148 | 149 | - Fix packaging, add click dependency, bump to 0.1.2. [Teemu Rytilahti] 150 | 151 | - Bump bluepy requirement to 1.0.5. [Teemu Rytilahti] 152 | 153 | 0.1 (2017-01-14) 154 | ---------------- 155 | 156 | - Restructure bluepy_devices to python-eq3bt. [Teemu Rytilahti] 157 | 158 | * Complete restructure of the library. All unnecessary and problematic parts are dropped. 159 | * General cleaning up, making flake8 and pylint happy. 160 | * Updated and documented cli tool, named eq3cli 161 | 162 | - Add contextmanager for connection to simplify connecting and 163 | disconnecting. Calling writes on device will build and tear down the 164 | connection automatically. [Teemu Rytilahti] 165 | 166 | - Eq3btsmart: do not try to connect on init, allows adding the component 167 | to homeassistant even if the device is not connectable at the moment. 168 | [Teemu Rytilahti] 169 | 170 | - Add eq3cli tool. [Teemu Rytilahti] 171 | 172 | Included command-line tool can be used to control the device. 173 | All current functionality is available through it, check updated README.md for usage. 174 | 175 | - Add logger to ease debugging. [Teemu Rytilahti] 176 | 177 | - Increase version to 0.3.0 for the enhaced eq3btsmart support. [Janne 178 | Grunau] 179 | 180 | - Eq3btsmart: and support for the comfort and eco temperature presets. 181 | [Janne Grunau] 182 | 183 | - Eq3btsmart: add a property for the low battery warning. [Janne Grunau] 184 | 185 | - Eq3btsmart: add support for the thermostat's operating lock. [Janne 186 | Grunau] 187 | 188 | - Eq3btsmart: add window open mode configuration. [Janne Grunau] 189 | 190 | - Eq3btsmart: and property to check window open state. [Janne Grunau] 191 | 192 | - Eq3btsmart: report valve state. [Janne Grunau] 193 | 194 | - Eq3btsmart: control the supported modes of the thermostat. [Janne 195 | Grunau] 196 | 197 | The away mode is not really useful yet. 198 | 199 | - Eq3btsmart: verify that temperatures are in min/max range. [Janne 200 | Grunau] 201 | 202 | - Eq3btsmart: fix the minimal and maximal temperatures. [Janne Grunau] 203 | 204 | 4.5 and 30 degree celsius have special meanings and can't be set 205 | in 'auto' mode. 4.5 means off (valve closed even if the temperature 206 | below 4.5 degress). 30 means on (valve permanently open even if the 207 | temperature exceeds 30 degrees). 208 | 209 | - Eq3btsmart: the update request needs to include the full time. [Janne 210 | Grunau] 211 | 212 | Otherwise the thermostat can set a random time. Also fixes the format of 213 | the set time request. 214 | 215 | - Initial update in eq3btsmart.py. [Markus Peter] 216 | 217 | - +travis. [Markus Peter] 218 | 219 | - Update README.md. [Markus Peter] 220 | 221 | - Create README.md. [Markus Peter] 222 | 223 | - Initial Commit Version 0.2.0. [Markus Peter] 224 | 225 | - Initial commit. [Markus Peter] 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-eq3bt 2 | 3 | Python library and a command line tool for EQ3 Bluetooth smart thermostats, uses bleak (default), bluepy or gattlib for BTLE communication. 4 | 5 | # Attention Home Assistant users 6 | 7 | This library (and therefore the built-in integration) is **not compatible** anymore since 2022.7.0. 8 | Use [this custom component](https://github.com/dbuezas/eq3btsmart) instead. 9 | 10 | # Features 11 | 12 | * Reading device status: locked, low battery, valve state, window open, target temperature, active mode 13 | * Writing settings: target temperature, auto mode presets, temperature offset 14 | * Setting the active mode: auto, manual, boost, away 15 | * Reading the device serial number and firmware version 16 | * Reading presets and temperature offset in more recent firmware versions. 17 | 18 | ## Not (yet) supported 19 | 20 | * No easy-to-use interface for setting schedules. 21 | 22 | # Installation 23 | 24 | ```bash 25 | pip install python-eq3bt 26 | ``` 27 | 28 | # Command-line Usage 29 | 30 | To test all available functionality a cli tool inside utils can be used: 31 | ``` 32 | $ eq3cli --help 33 | Usage: eq3cli [OPTIONS] COMMAND [ARGS]... 34 | 35 | Tool to query and modify the state of EQ3 BT smart thermostat. 36 | 37 | Options: 38 | --mac TEXT [required] 39 | --interface TEXT 40 | --debug / --normal 41 | --backend [bleak|bluepy|gattlib] 42 | --help Show this message and exit. 43 | 44 | Commands: 45 | away Enables or disables the away mode. 46 | boost Gets or sets the boost mode. 47 | device Displays basic device information. 48 | locked Gets or sets the lock. 49 | low-battery Gets the low battery status. 50 | mode Gets or sets the active mode. 51 | offset Sets the temperature offset [-3,5 3,5] 52 | presets Sets the preset temperatures for auto mode. 53 | schedule Gets the schedule from the thermostat. 54 | state Prints out all available information. 55 | temp Gets or sets the target temperature. 56 | valve-state Gets the state of the valve. 57 | window-open Gets and sets the window open settings. 58 | ``` 59 | 60 | EQ3_MAC environment variable can be used to define mac to avoid typing it: 61 | ```bash 62 | export EQ3_MAC=XX:XX 63 | ``` 64 | 65 | Without parameters current state of the device is printed out. 66 | ```bash 67 | $ eq3cli 68 | 69 | [00:1A:22:XX:XX:XX] Target 17.0 (mode: auto dst, away: no) 70 | Locked: False 71 | Batter low: False 72 | Window open: False 73 | Window open temp: 12.0 74 | Window open time: 0:15:00 75 | Boost: False 76 | Current target temp: 17.0 77 | Current comfort temp: 20.0 78 | Current eco temp: 17.0 79 | Current mode: auto dst locked 80 | Valve: 0 81 | ``` 82 | 83 | Getting & setting values. 84 | ```bash 85 | $ eq3cli temp 86 | 87 | Current target temp: 17.0 88 | 89 | eq3cli temp --target 20 90 | 91 | Current target temp: 17.0 92 | Setting target temp: 20.0 93 | ``` 94 | 95 | # Pairing 96 | 97 | If you have thermostat with firmware version 1.20+ pairing may be needed. Below simple procedure to do that. 98 | 99 | ``` 100 | Press and hold wheel on thermostat until Pair will be displayed. Remember or write it. 101 | 102 | $ sudo bluetoothctl 103 | [bluetooth]# power on 104 | [bluetooth]# agent on 105 | [bluetooth]# default-agent 106 | [bluetooth]# scan on 107 | [bluetooth]# scan off 108 | [bluetooth]# pair 00:1A:22:06:A7:83 109 | [agent] Enter passkey (number in 0-999999): 110 | [bluetooth]# trust 00:1A:22:06:A7:83 111 | [bluetooth]# disconnect 00:1A:22:06:A7:83 112 | [bluetooth]# exit 113 | 114 | Optional steps: 115 | [bluetooth]# devices - to list all bluetooth devices 116 | [bluetooth]# info 00:1A:22:06:A7:83 117 | Device 00:1A:22:06:A7:83 (public) 118 | Name: CC-RT-BLE 119 | Alias: CC-RT-BLE 120 | Paired: yes 121 | Trusted: yes 122 | Blocked: no 123 | Connected: no 124 | LegacyPairing: no 125 | UUID: Generic Access Profile (00001800-0000-1000-8000-00805f9b34fb) 126 | UUID: Generic Attribute Profile (00001801-0000-1000-8000-00805f9b34fb) 127 | UUID: Device Information (0000180a-0000-1000-8000-00805f9b34fb) 128 | UUID: Vendor specific (3e135142-654f-9090-134a-a6ff5bb77046) 129 | UUID: Vendor specific (9e5d1e47-5c13-43a0-8635-82ad38a1386f) 130 | ManufacturerData Key: 0x0000 131 | ManufacturerData Value: 132 | 00 00 00 00 00 00 00 00 00 ......... 133 | ``` 134 | 135 | Be aware that sometimes if you pair your device then mobile application (calor BT) can't connect with thermostat and vice versa. 136 | 137 | 138 | # Library Usage 139 | 140 | ``` 141 | from eq3bt import Thermostat 142 | 143 | thermostat = Thermostat('AB:CD:EF:12:23:45') 144 | thermostat.update() # fetches data from the thermostat 145 | 146 | print(thermostat) 147 | ``` 148 | 149 | 155 | 156 | ## Fetching schedule 157 | 158 | The schedule is queried per day basis and the cached information can be 159 | accessed through `schedule` property.. 160 | 161 | ``` 162 | from eq3bt import Thermostat 163 | 164 | thermostat = Thermostat('AB:CD:EF:12:34:45') 165 | thermostat.query_schedule(0) 166 | print(thermostat.schedule) 167 | ``` 168 | 169 | ## Setting schedule 170 | 171 | The 'base_temp' and 'next_change_at' paramater define the first period for that 'day' (the period from midnight up till next_change_at). 172 | 173 | The schedule can be set on a per day basis like follows: 174 | 175 | ``` 176 | from datetime import time 177 | from eq3bt import Thermostat 178 | from eq3bt import HOUR_24_PLACEHOLDER as END_OF_DAY 179 | 180 | thermostat = Thermostat('12:34:56:78:9A:BC') 181 | thermostat.set_schedule( 182 | dict( 183 | cmd="write", 184 | day="sun", 185 | base_temp=18, 186 | next_change_at=time(8, 0), 187 | hours=[ 188 | dict(target_temp=23, next_change_at=time(20, 0)), 189 | dict(target_temp=18, next_change_at=END_OF_DAY), 190 | dict(target_temp=23, next_change_at=END_OF_DAY), 191 | dict(target_temp=23, next_change_at=END_OF_DAY), 192 | dict(target_temp=23, next_change_at=END_OF_DAY), 193 | dict(target_temp=23, next_change_at=END_OF_DAY) 194 | ] 195 | ) 196 | ) 197 | ``` 198 | 199 | # Contributing 200 | 201 | Feel free to open pull requests to improve the library! 202 | 203 | This project uses github actions to enforce code formatting using tools like black, isort, flake8, and mypy. 204 | You can run these checks locally either by executing `pre-commit run -a` or using `tox` which also runs the test suite. 205 | 206 | 207 | # History 208 | 209 | 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. 210 | -------------------------------------------------------------------------------- /eq3bt/eq3cli.py: -------------------------------------------------------------------------------- 1 | """ Cli tool for testing connectivity with EQ3 smart thermostats. """ 2 | import logging 3 | import re 4 | 5 | import click 6 | 7 | from eq3bt import Thermostat 8 | 9 | pass_dev = click.make_pass_decorator(Thermostat) 10 | 11 | 12 | def validate_mac(ctx, param, mac): 13 | if re.match("^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", mac) is None: 14 | raise click.BadParameter(mac + " is no valid mac address") 15 | return mac 16 | 17 | 18 | @click.group(invoke_without_command=True) 19 | @click.option("--mac", envvar="EQ3_MAC", required=True, callback=validate_mac) 20 | @click.option("--interface", default=None) 21 | @click.option("--debug/--normal", default=False) 22 | @click.option( 23 | "--backend", type=click.Choice(["bleak", "bluepy", "gattlib"]), default="bleak" 24 | ) 25 | @click.pass_context 26 | def cli(ctx, mac, interface, debug, backend): 27 | """Tool to query and modify the state of EQ3 BT smart thermostat.""" 28 | if debug: 29 | logging.basicConfig(level=logging.DEBUG) 30 | else: 31 | logging.basicConfig(level=logging.INFO) 32 | 33 | if backend == "bluepy": 34 | from .connection import BTLEConnection 35 | 36 | connection_cls = BTLEConnection 37 | elif backend == "gattlib": 38 | from .gattlibconnection import BTLEConnection 39 | 40 | connection_cls = BTLEConnection 41 | else: 42 | from .bleakconnection import BleakConnection 43 | 44 | connection_cls = BleakConnection 45 | 46 | thermostat = Thermostat(mac, interface, connection_cls) 47 | thermostat.update() 48 | ctx.obj = thermostat 49 | 50 | if ctx.invoked_subcommand is None: 51 | ctx.invoke(state) 52 | 53 | 54 | @cli.command() 55 | @click.option("--target", type=float, required=False) 56 | @pass_dev 57 | def temp(dev, target): 58 | """Gets or sets the target temperature.""" 59 | click.echo("Current target temp: %s" % dev.target_temperature) 60 | if target: 61 | click.echo("Setting target temp: %s" % target) 62 | dev.target_temperature = target 63 | 64 | 65 | @cli.command() 66 | @click.option("--target", type=int, required=False) 67 | @pass_dev 68 | def mode(dev, target): 69 | """Gets or sets the active mode.""" 70 | click.echo("Current mode: %s" % dev.mode_readable) 71 | if target: 72 | click.echo("Setting mode: %s" % target) 73 | dev.mode = target 74 | 75 | 76 | @cli.command() 77 | @click.option("--target", type=bool, required=False) 78 | @pass_dev 79 | def boost(dev, target): 80 | """Gets or sets the boost mode.""" 81 | click.echo("Boost: %s" % dev.boost) 82 | if target is not None: 83 | click.echo("Setting boost: %s" % target) 84 | dev.boost = target 85 | 86 | 87 | @cli.command() 88 | @pass_dev 89 | def valve_state(dev): 90 | """Gets the state of the valve.""" 91 | click.echo("Valve: %s" % dev.valve_state) 92 | 93 | 94 | @cli.command() 95 | @click.option("--target", type=bool, required=False) 96 | @pass_dev 97 | def locked(dev, target): 98 | """Gets or sets the lock.""" 99 | click.echo("Locked: %s" % dev.locked) 100 | if target is not None: 101 | click.echo("Setting lock: %s" % target) 102 | dev.locked = target 103 | 104 | 105 | @cli.command() 106 | @pass_dev 107 | def low_battery(dev): 108 | """Gets the low battery status.""" 109 | click.echo("Batter low: %s" % dev.low_battery) 110 | 111 | 112 | @cli.command() 113 | @click.option("--temp", type=float, required=False) 114 | @click.option("--duration", type=float, required=False) 115 | @pass_dev 116 | def window_open(dev, temp, duration): 117 | """Gets and sets the window open settings.""" 118 | click.echo("Window open: %s" % dev.window_open) 119 | if dev.window_open_temperature is not None: 120 | click.echo("Window open temp: %s" % dev.window_open_temperature) 121 | if dev.window_open_time is not None: 122 | click.echo("Window open time: %s" % dev.window_open_time) 123 | if temp and duration: 124 | click.echo(f"Setting window open conf, temp: {temp} duration: {duration}") 125 | dev.window_open_config(temp, duration) 126 | 127 | 128 | @cli.command() 129 | @click.option("--comfort", type=float, required=False) 130 | @click.option("--eco", type=float, required=False) 131 | @pass_dev 132 | def presets(dev, comfort, eco): 133 | """Sets the preset temperatures for auto mode.""" 134 | if dev.comfort_temperature is not None: 135 | click.echo("Current comfort temp: %s" % dev.comfort_temperature) 136 | if dev.eco_temperature is not None: 137 | click.echo("Current eco temp: %s" % dev.eco_temperature) 138 | if comfort and eco: 139 | click.echo(f"Setting presets: comfort {comfort}, eco {eco}") 140 | dev.temperature_presets(comfort, eco) 141 | 142 | 143 | @cli.command() 144 | @pass_dev 145 | def schedule(dev): 146 | """Gets the schedule from the thermostat.""" 147 | # TODO: expose setting the schedule somehow? 148 | for d in range(7): 149 | dev.query_schedule(d) 150 | for day in dev.schedule.values(): 151 | click.echo(f"Day {day.day}, base temp: {day.base_temp}") 152 | current_hour = day.next_change_at 153 | for hour in day.hours: 154 | if current_hour == 0: 155 | continue 156 | click.echo(f"\t[{current_hour}-{hour.next_change_at}] {hour.target_temp}") 157 | current_hour = hour.next_change_at 158 | 159 | 160 | @cli.command() 161 | @click.argument("offset", type=float, required=False) 162 | @pass_dev 163 | def offset(dev, offset): 164 | """Sets the temperature offset [-3,5 3,5]""" 165 | if dev.temperature_offset is not None: 166 | click.echo("Current temp offset: %s" % dev.temperature_offset) 167 | if offset is not None: 168 | click.echo("Setting the offset to %s" % offset) 169 | dev.temperature_offset = offset 170 | 171 | 172 | @cli.command() 173 | @click.argument("away_end", type=click.DateTime(), default=None, required=False) 174 | @click.argument("temperature", type=float, default=None, required=False) 175 | @pass_dev 176 | def away(dev, away_end, temperature): 177 | """Enables or disables the away mode.""" 178 | if away_end: 179 | click.echo(f"Setting away until {away_end}, temperature: {temperature}") 180 | else: 181 | click.echo("Disabling away mode") 182 | dev.set_away(away_end, temperature) 183 | 184 | 185 | @cli.command() 186 | @pass_dev 187 | def device(dev): 188 | """Displays basic device information.""" 189 | dev.query_id() 190 | click.echo("Firmware version: %s" % dev.firmware_version) 191 | click.echo("Device serial: %s" % dev.device_serial) 192 | 193 | 194 | @cli.command() 195 | @click.pass_context 196 | def state(ctx): 197 | """Prints out all available information.""" 198 | dev = ctx.obj 199 | click.echo(dev) 200 | ctx.forward(locked) 201 | ctx.forward(low_battery) 202 | ctx.forward(window_open) 203 | ctx.forward(boost) 204 | ctx.forward(temp) 205 | ctx.forward(presets) 206 | ctx.forward(offset) 207 | ctx.forward(mode) 208 | ctx.forward(valve_state) 209 | 210 | 211 | if __name__ == "__main__": 212 | cli() 213 | -------------------------------------------------------------------------------- /eq3bt/eq3btsmart.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for eq3 Bluetooth Smart thermostats. 3 | 4 | All temperatures in Celsius. 5 | 6 | To get the current state, update() has to be called for powersaving reasons. 7 | Schedule needs to be requested with query_schedule() before accessing for similar reasons. 8 | """ 9 | 10 | import codecs 11 | import logging 12 | import struct 13 | from datetime import datetime, timedelta 14 | from enum import IntEnum 15 | 16 | from construct import Byte 17 | 18 | from .structures import AwayDataAdapter, DeviceId, Schedule, Status 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | PROP_WRITE_HANDLE = 0x411 23 | PROP_NTFY_HANDLE = 0x421 24 | 25 | PROP_ID_QUERY = 0 26 | PROP_ID_RETURN = 1 27 | PROP_INFO_QUERY = 3 28 | PROP_INFO_RETURN = 2 29 | PROP_COMFORT_ECO_CONFIG = 0x11 30 | PROP_OFFSET = 0x13 31 | PROP_WINDOW_OPEN_CONFIG = 0x14 32 | PROP_SCHEDULE_QUERY = 0x20 33 | PROP_SCHEDULE_RETURN = 0x21 34 | 35 | PROP_MODE_WRITE = 0x40 36 | PROP_TEMPERATURE_WRITE = 0x41 37 | PROP_COMFORT = 0x43 38 | PROP_ECO = 0x44 39 | PROP_BOOST = 0x45 40 | PROP_LOCK = 0x80 41 | 42 | EQ3BT_AWAY_TEMP = 12.0 43 | EQ3BT_MIN_TEMP = 5.0 44 | EQ3BT_MAX_TEMP = 29.5 45 | EQ3BT_OFF_TEMP = 4.5 46 | EQ3BT_ON_TEMP = 30.0 47 | 48 | 49 | class Mode(IntEnum): 50 | """Thermostat modes.""" 51 | 52 | Unknown = -1 53 | Closed = 0 54 | Open = 1 55 | Auto = 2 56 | Manual = 3 57 | Away = 4 58 | Boost = 5 59 | 60 | 61 | MODE_NOT_TEMP = [Mode.Unknown, Mode.Closed, Mode.Open] 62 | 63 | 64 | class TemperatureException(Exception): 65 | """Temperature out of range error.""" 66 | 67 | pass 68 | 69 | 70 | # pylint: disable=too-many-instance-attributes 71 | class Thermostat: 72 | """Representation of a EQ3 Bluetooth Smart thermostat.""" 73 | 74 | def __init__(self, _mac, _iface=None, connection_cls=None): 75 | """Initialize the thermostat.""" 76 | 77 | self._target_temperature = Mode.Unknown 78 | self._mode = Mode.Unknown 79 | self._valve_state = Mode.Unknown 80 | self._raw_mode = None 81 | 82 | self._schedule = {} 83 | 84 | self._window_open_temperature = None 85 | self._window_open_time = None 86 | self._comfort_temperature = None 87 | self._eco_temperature = None 88 | self._temperature_offset = None 89 | 90 | self._away_temp = EQ3BT_AWAY_TEMP 91 | self._away_duration = timedelta(days=30) 92 | self._away_end = None 93 | 94 | self._firmware_version = None 95 | self._device_serial = None 96 | 97 | if connection_cls is None: 98 | from .bleakconnection import BleakConnection as connection_cls 99 | self._conn = connection_cls(_mac, _iface) 100 | self._conn.set_callback(PROP_NTFY_HANDLE, self.handle_notification) 101 | 102 | def __str__(self): 103 | away_end = "no" 104 | if self.away_end: 105 | away_end = "end: %s" % self._away_end 106 | 107 | return "[{}] Target {} (mode: {}, away: {})".format( 108 | self._conn.mac, self.target_temperature, self.mode_readable, away_end 109 | ) 110 | 111 | def _verify_temperature(self, temp): 112 | """Verifies that the temperature is valid. 113 | :raises TemperatureException: On invalid temperature. 114 | """ 115 | if temp < self.min_temp or temp > self.max_temp: 116 | raise TemperatureException( 117 | "Temperature {} out of range [{}, {}]".format( 118 | temp, self.min_temp, self.max_temp 119 | ) 120 | ) 121 | 122 | def parse_schedule(self, data): 123 | """Parses the device sent schedule.""" 124 | sched = Schedule.parse(data) 125 | _LOGGER.debug("Got schedule data for day '%s'", sched.day) 126 | 127 | return sched 128 | 129 | def handle_notification(self, data): 130 | """Handle Callback from a Bluetooth (GATT) request.""" 131 | _LOGGER.debug("Received notification from the device..") 132 | 133 | if data[0] == PROP_INFO_RETURN and data[1] == 1: 134 | _LOGGER.debug("Got status: %s" % codecs.encode(data, "hex")) 135 | status = Status.parse(data) 136 | _LOGGER.debug("Parsed status: %s", status) 137 | 138 | self._raw_mode = status.mode 139 | self._valve_state = status.valve 140 | self._target_temperature = status.target_temp 141 | 142 | if status.mode.BOOST: 143 | self._mode = Mode.Boost 144 | elif status.mode.AWAY: 145 | self._mode = Mode.Away 146 | self._away_end = status.away 147 | elif status.mode.MANUAL: 148 | if status.target_temp == EQ3BT_OFF_TEMP: 149 | self._mode = Mode.Closed 150 | elif status.target_temp == EQ3BT_ON_TEMP: 151 | self._mode = Mode.Open 152 | else: 153 | self._mode = Mode.Manual 154 | else: 155 | self._mode = Mode.Auto 156 | 157 | presets = status.presets 158 | if presets: 159 | self._window_open_temperature = presets.window_open_temp 160 | self._window_open_time = presets.window_open_time 161 | self._comfort_temperature = presets.comfort_temp 162 | self._eco_temperature = presets.eco_temp 163 | self._temperature_offset = presets.offset 164 | else: 165 | self._window_open_temperature = None 166 | self._window_open_time = None 167 | self._comfort_temperature = None 168 | self._eco_temperature = None 169 | self._temperature_offset = None 170 | 171 | _LOGGER.debug("Valve state: %s", self._valve_state) 172 | _LOGGER.debug("Mode: %s", self.mode_readable) 173 | _LOGGER.debug("Target temp: %s", self._target_temperature) 174 | _LOGGER.debug("Away end: %s", self._away_end) 175 | _LOGGER.debug("Window open temp: %s", self._window_open_temperature) 176 | _LOGGER.debug("Window open time: %s", self._window_open_time) 177 | _LOGGER.debug("Comfort temp: %s", self._comfort_temperature) 178 | _LOGGER.debug("Eco temp: %s", self._eco_temperature) 179 | _LOGGER.debug("Temp offset: %s", self._temperature_offset) 180 | 181 | elif data[0] == PROP_SCHEDULE_RETURN: 182 | parsed = self.parse_schedule(data) 183 | self._schedule[parsed.day] = parsed 184 | 185 | elif data[0] == PROP_ID_RETURN: 186 | parsed = DeviceId.parse(data) 187 | _LOGGER.debug("Parsed device data: %s", parsed) 188 | self._firmware_version = parsed.version 189 | self._device_serial = parsed.serial 190 | 191 | else: 192 | _LOGGER.debug( 193 | "Unknown notification %s (%s)", data[0], codecs.encode(data, "hex") 194 | ) 195 | 196 | def query_id(self): 197 | """Query device identification information, e.g. the serial number.""" 198 | _LOGGER.debug("Querying id..") 199 | value = struct.pack("B", PROP_ID_QUERY) 200 | self._conn.make_request(PROP_WRITE_HANDLE, value) 201 | 202 | def update(self): 203 | """Update the data from the thermostat. Always sets the current time.""" 204 | _LOGGER.debug("Querying the device..") 205 | time = datetime.now() 206 | value = struct.pack( 207 | "BBBBBBB", 208 | PROP_INFO_QUERY, 209 | time.year % 100, 210 | time.month, 211 | time.day, 212 | time.hour, 213 | time.minute, 214 | time.second, 215 | ) 216 | 217 | self._conn.make_request(PROP_WRITE_HANDLE, value) 218 | 219 | def query_schedule(self, day): 220 | _LOGGER.debug("Querying schedule..") 221 | 222 | if day < 0 or day > 6: 223 | _LOGGER.error("Invalid day: %s", day) 224 | 225 | value = struct.pack("BB", PROP_SCHEDULE_QUERY, day) 226 | 227 | self._conn.make_request(PROP_WRITE_HANDLE, value) 228 | 229 | @property 230 | def schedule(self): 231 | """Returns previously fetched schedule. 232 | :return: Schedule structure or None if not fetched. 233 | """ 234 | return self._schedule 235 | 236 | def set_schedule(self, data): 237 | """Sets the schedule for the given day.""" 238 | value = Schedule.build(data) 239 | self._conn.make_request(PROP_WRITE_HANDLE, value) 240 | 241 | @property 242 | def target_temperature(self): 243 | """Return the temperature we try to reach.""" 244 | return self._target_temperature 245 | 246 | @target_temperature.setter 247 | def target_temperature(self, temperature): 248 | """Set new target temperature.""" 249 | dev_temp = int(temperature * 2) 250 | if temperature == EQ3BT_OFF_TEMP or temperature == EQ3BT_ON_TEMP: 251 | dev_temp |= 0x40 252 | value = struct.pack("BB", PROP_MODE_WRITE, dev_temp) 253 | else: 254 | self._verify_temperature(temperature) 255 | value = struct.pack("BB", PROP_TEMPERATURE_WRITE, dev_temp) 256 | 257 | self._conn.make_request(PROP_WRITE_HANDLE, value) 258 | 259 | @property 260 | def mode(self): 261 | """Return the current operation mode""" 262 | return self._mode 263 | 264 | @mode.setter 265 | def mode(self, mode): 266 | """Set the operation mode.""" 267 | _LOGGER.debug("Setting new mode: %s", mode) 268 | 269 | if self.mode == Mode.Boost and mode != Mode.Boost: 270 | self.boost = False 271 | 272 | if mode == Mode.Boost: 273 | self.boost = True 274 | return 275 | elif mode == Mode.Away: 276 | end = datetime.now() + self._away_duration 277 | return self.set_away(end, self._away_temp) 278 | elif mode == Mode.Closed: 279 | return self.set_mode(0x40 | int(EQ3BT_OFF_TEMP * 2)) 280 | elif mode == Mode.Open: 281 | return self.set_mode(0x40 | int(EQ3BT_ON_TEMP * 2)) 282 | 283 | if mode == Mode.Manual: 284 | temperature = max( 285 | min(self._target_temperature, self.max_temp), self.min_temp 286 | ) 287 | return self.set_mode(0x40 | int(temperature * 2)) 288 | else: 289 | return self.set_mode(0) 290 | 291 | @property 292 | def away_end(self): 293 | return self._away_end 294 | 295 | def set_away(self, away_end=None, temperature=EQ3BT_AWAY_TEMP): 296 | """Sets away mode with target temperature. 297 | When called without parameters disables away mode.""" 298 | if not away_end: 299 | _LOGGER.debug("Disabling away, going to auto mode.") 300 | return self.set_mode(0x00) 301 | 302 | _LOGGER.debug("Setting away until %s, temp %s", away_end, temperature) 303 | adapter = AwayDataAdapter(Byte[4]) 304 | packed = adapter.build(away_end) 305 | 306 | self.set_mode(0x80 | int(temperature * 2), packed) 307 | 308 | def set_mode(self, mode, payload=None): 309 | value = struct.pack("BB", PROP_MODE_WRITE, mode) 310 | if payload: 311 | value += payload 312 | self._conn.make_request(PROP_WRITE_HANDLE, value) 313 | 314 | @property 315 | def mode_readable(self): 316 | """Return a readable representation of the mode..""" 317 | ret = "" 318 | mode = self._raw_mode 319 | 320 | if mode.MANUAL: 321 | ret = "manual" 322 | if self.target_temperature < self.min_temp: 323 | ret += " off" 324 | elif self.target_temperature >= self.max_temp: 325 | ret += " on" 326 | else: 327 | ret += " (%sC)" % self.target_temperature 328 | else: 329 | ret = "auto" 330 | 331 | if mode.AWAY: 332 | ret += " holiday" 333 | if mode.BOOST: 334 | ret += " boost" 335 | if mode.DST: 336 | ret += " dst" 337 | if mode.WINDOW: 338 | ret += " window" 339 | if mode.LOCKED: 340 | ret += " locked" 341 | if mode.LOW_BATTERY: 342 | ret += " low battery" 343 | 344 | return ret 345 | 346 | @property 347 | def boost(self): 348 | """Returns True if the thermostat is in boost mode.""" 349 | return self.mode == Mode.Boost 350 | 351 | @boost.setter 352 | def boost(self, boost): 353 | """Sets boost mode.""" 354 | _LOGGER.debug("Setting boost mode: %s", boost) 355 | value = struct.pack("BB", PROP_BOOST, bool(boost)) 356 | self._conn.make_request(PROP_WRITE_HANDLE, value) 357 | 358 | @property 359 | def valve_state(self): 360 | """Returns the valve state. Probably reported as percent open.""" 361 | return self._valve_state 362 | 363 | @property 364 | def window_open(self): 365 | """Returns True if the thermostat reports a open window 366 | (detected by sudden drop of temperature)""" 367 | return self._raw_mode and self._raw_mode.WINDOW 368 | 369 | def window_open_config(self, temperature, duration): 370 | """Configures the window open behavior. The duration is specified in 371 | 5 minute increments.""" 372 | _LOGGER.debug( 373 | "Window open config, temperature: %s duration: %s", temperature, duration 374 | ) 375 | self._verify_temperature(temperature) 376 | if duration.seconds < 0 and duration.seconds > 3600: 377 | raise ValueError 378 | 379 | value = struct.pack( 380 | "BBB", 381 | PROP_WINDOW_OPEN_CONFIG, 382 | int(temperature * 2), 383 | int(duration.seconds / 300), 384 | ) 385 | self._conn.make_request(PROP_WRITE_HANDLE, value) 386 | 387 | @property 388 | def window_open_temperature(self): 389 | """The temperature to set when an open window is detected.""" 390 | return self._window_open_temperature 391 | 392 | @property 393 | def window_open_time(self): 394 | """Timeout to reset the thermostat after an open window is detected.""" 395 | return self._window_open_time 396 | 397 | @property 398 | def locked(self): 399 | """Returns True if the thermostat is locked.""" 400 | return self._raw_mode and self._raw_mode.LOCKED 401 | 402 | @locked.setter 403 | def locked(self, lock): 404 | """Locks or unlocks the thermostat.""" 405 | _LOGGER.debug("Setting the lock: %s", lock) 406 | value = struct.pack("BB", PROP_LOCK, bool(lock)) 407 | self._conn.make_request(PROP_WRITE_HANDLE, value) 408 | 409 | @property 410 | def low_battery(self): 411 | """Returns True if the thermostat reports a low battery.""" 412 | return self._raw_mode and self._raw_mode.LOW_BATTERY 413 | 414 | def temperature_presets(self, comfort, eco): 415 | """Set the thermostats preset temperatures comfort (sun) and 416 | eco (moon).""" 417 | _LOGGER.debug("Setting temperature presets, comfort: %s eco: %s", comfort, eco) 418 | self._verify_temperature(comfort) 419 | self._verify_temperature(eco) 420 | value = struct.pack( 421 | "BBB", PROP_COMFORT_ECO_CONFIG, int(comfort * 2), int(eco * 2) 422 | ) 423 | self._conn.make_request(PROP_WRITE_HANDLE, value) 424 | 425 | @property 426 | def comfort_temperature(self): 427 | """Returns the comfort temperature preset of the thermostat.""" 428 | return self._comfort_temperature 429 | 430 | @property 431 | def eco_temperature(self): 432 | """Returns the eco temperature preset of the thermostat.""" 433 | return self._eco_temperature 434 | 435 | @property 436 | def temperature_offset(self): 437 | """Returns the thermostat's temperature offset.""" 438 | return self._temperature_offset 439 | 440 | @temperature_offset.setter 441 | def temperature_offset(self, offset): 442 | """Sets the thermostat's temperature offset.""" 443 | _LOGGER.debug("Setting offset: %s", offset) 444 | # [-3,5 .. 0 .. 3,5 ] 445 | # [00 .. 07 .. 0e ] 446 | if offset < -3.5 or offset > 3.5: 447 | raise TemperatureException("Invalid value: %s" % offset) 448 | 449 | current = -3.5 450 | values = {} 451 | for i in range(15): 452 | values[current] = i 453 | current += 0.5 454 | 455 | value = struct.pack("BB", PROP_OFFSET, values[offset]) 456 | self._conn.make_request(PROP_WRITE_HANDLE, value) 457 | 458 | def activate_comfort(self): 459 | """Activates the comfort temperature.""" 460 | value = struct.pack("B", PROP_COMFORT) 461 | self._conn.make_request(PROP_WRITE_HANDLE, value) 462 | 463 | def activate_eco(self): 464 | """Activates the comfort temperature.""" 465 | value = struct.pack("B", PROP_ECO) 466 | self._conn.make_request(PROP_WRITE_HANDLE, value) 467 | 468 | @property 469 | def min_temp(self): 470 | """Return the minimum temperature.""" 471 | return EQ3BT_MIN_TEMP 472 | 473 | @property 474 | def max_temp(self): 475 | """Return the maximum temperature.""" 476 | return EQ3BT_MAX_TEMP 477 | 478 | @property 479 | def firmware_version(self): 480 | """Return the firmware version.""" 481 | return self._firmware_version 482 | 483 | @property 484 | def device_serial(self): 485 | """Return the device serial number.""" 486 | return self._device_serial 487 | 488 | @property 489 | def mac(self): 490 | """Return the mac address.""" 491 | return self._conn.mac 492 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.1" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.4.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 22 | 23 | [[package]] 24 | name = "bleak" 25 | version = "0.14.3" 26 | description = "Bluetooth Low Energy platform Agnostic Klient" 27 | category = "main" 28 | optional = false 29 | python-versions = "*" 30 | 31 | [package.dependencies] 32 | bleak-winrt = {version = ">=1.1.1", markers = "platform_system == \"Windows\""} 33 | dbus-next = {version = "*", markers = "platform_system == \"Linux\""} 34 | pyobjc-core = {version = "*", markers = "platform_system == \"Darwin\""} 35 | pyobjc-framework-CoreBluetooth = {version = "*", markers = "platform_system == \"Darwin\""} 36 | pyobjc-framework-libdispatch = {version = "*", markers = "platform_system == \"Darwin\""} 37 | 38 | [[package]] 39 | name = "bleak-winrt" 40 | version = "1.1.1" 41 | description = "Python WinRT bindings for Bleak" 42 | category = "main" 43 | optional = false 44 | python-versions = "*" 45 | 46 | [[package]] 47 | name = "bluepy" 48 | version = "1.3.0" 49 | description = "Python module for interfacing with BLE devices through Bluez" 50 | category = "main" 51 | optional = true 52 | python-versions = "*" 53 | 54 | [[package]] 55 | name = "certifi" 56 | version = "2022.6.15" 57 | description = "Python package for providing Mozilla's CA Bundle." 58 | category = "dev" 59 | optional = false 60 | python-versions = ">=3.6" 61 | 62 | [[package]] 63 | name = "cfgv" 64 | version = "3.3.1" 65 | description = "Validate configuration and produce human readable error messages." 66 | category = "dev" 67 | optional = false 68 | python-versions = ">=3.6.1" 69 | 70 | [[package]] 71 | name = "charset-normalizer" 72 | version = "2.1.0" 73 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 74 | category = "dev" 75 | optional = false 76 | python-versions = ">=3.6.0" 77 | 78 | [package.extras] 79 | unicode_backport = ["unicodedata2"] 80 | 81 | [[package]] 82 | name = "click" 83 | version = "8.1.3" 84 | description = "Composable command line interface toolkit" 85 | category = "main" 86 | optional = false 87 | python-versions = ">=3.7" 88 | 89 | [package.dependencies] 90 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 91 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 92 | 93 | [[package]] 94 | name = "codecov" 95 | version = "2.1.12" 96 | description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" 97 | category = "dev" 98 | optional = false 99 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 100 | 101 | [package.dependencies] 102 | coverage = "*" 103 | requests = ">=2.7.9" 104 | 105 | [[package]] 106 | name = "colorama" 107 | version = "0.4.5" 108 | description = "Cross-platform colored terminal text." 109 | category = "main" 110 | optional = false 111 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 112 | 113 | [[package]] 114 | name = "construct" 115 | version = "2.10.68" 116 | description = "A powerful declarative symmetric parser/builder for binary data" 117 | category = "main" 118 | optional = false 119 | python-versions = ">=3.6" 120 | 121 | [package.extras] 122 | extras = ["arrow", "cloudpickle", "enum34", "lz4", "numpy", "ruamel.yaml"] 123 | 124 | [[package]] 125 | name = "coverage" 126 | version = "6.4.1" 127 | description = "Code coverage measurement for Python" 128 | category = "dev" 129 | optional = false 130 | python-versions = ">=3.7" 131 | 132 | [package.dependencies] 133 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 134 | 135 | [package.extras] 136 | toml = ["tomli"] 137 | 138 | [[package]] 139 | name = "dbus-next" 140 | version = "0.2.3" 141 | description = "A zero-dependency DBus library for Python with asyncio support" 142 | category = "main" 143 | optional = false 144 | python-versions = ">=3.6.0" 145 | 146 | [[package]] 147 | name = "distlib" 148 | version = "0.3.4" 149 | description = "Distribution utilities" 150 | category = "dev" 151 | optional = false 152 | python-versions = "*" 153 | 154 | [[package]] 155 | name = "filelock" 156 | version = "3.7.1" 157 | description = "A platform independent file lock." 158 | category = "dev" 159 | optional = false 160 | python-versions = ">=3.7" 161 | 162 | [package.extras] 163 | docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] 164 | testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] 165 | 166 | [[package]] 167 | name = "gattlib" 168 | version = "0.20201113" 169 | description = "Library to access Bluetooth LE devices" 170 | category = "main" 171 | optional = true 172 | python-versions = "*" 173 | 174 | [[package]] 175 | name = "identify" 176 | version = "2.5.1" 177 | description = "File identification library for Python" 178 | category = "dev" 179 | optional = false 180 | python-versions = ">=3.7" 181 | 182 | [package.extras] 183 | license = ["ukkonen"] 184 | 185 | [[package]] 186 | name = "idna" 187 | version = "3.3" 188 | description = "Internationalized Domain Names in Applications (IDNA)" 189 | category = "dev" 190 | optional = false 191 | python-versions = ">=3.5" 192 | 193 | [[package]] 194 | name = "importlib-metadata" 195 | version = "4.12.0" 196 | description = "Read metadata from Python packages" 197 | category = "main" 198 | optional = false 199 | python-versions = ">=3.7" 200 | 201 | [package.dependencies] 202 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 203 | zipp = ">=0.5" 204 | 205 | [package.extras] 206 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 207 | perf = ["ipython"] 208 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 209 | 210 | [[package]] 211 | name = "iniconfig" 212 | version = "1.1.1" 213 | description = "iniconfig: brain-dead simple config-ini parsing" 214 | category = "dev" 215 | optional = false 216 | python-versions = "*" 217 | 218 | [[package]] 219 | name = "nodeenv" 220 | version = "1.7.0" 221 | description = "Node.js virtual environment builder" 222 | category = "dev" 223 | optional = false 224 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 225 | 226 | [[package]] 227 | name = "packaging" 228 | version = "21.3" 229 | description = "Core utilities for Python packages" 230 | category = "dev" 231 | optional = false 232 | python-versions = ">=3.6" 233 | 234 | [package.dependencies] 235 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 236 | 237 | [[package]] 238 | name = "platformdirs" 239 | version = "2.5.2" 240 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 241 | category = "dev" 242 | optional = false 243 | python-versions = ">=3.7" 244 | 245 | [package.extras] 246 | docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] 247 | test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] 248 | 249 | [[package]] 250 | name = "pluggy" 251 | version = "1.0.0" 252 | description = "plugin and hook calling mechanisms for python" 253 | category = "dev" 254 | optional = false 255 | python-versions = ">=3.6" 256 | 257 | [package.dependencies] 258 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 259 | 260 | [package.extras] 261 | dev = ["pre-commit", "tox"] 262 | testing = ["pytest", "pytest-benchmark"] 263 | 264 | [[package]] 265 | name = "pre-commit" 266 | version = "2.19.0" 267 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 268 | category = "dev" 269 | optional = false 270 | python-versions = ">=3.7" 271 | 272 | [package.dependencies] 273 | cfgv = ">=2.0.0" 274 | identify = ">=1.0.0" 275 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 276 | nodeenv = ">=0.11.1" 277 | pyyaml = ">=5.1" 278 | toml = "*" 279 | virtualenv = ">=20.0.8" 280 | 281 | [[package]] 282 | name = "py" 283 | version = "1.11.0" 284 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 285 | category = "dev" 286 | optional = false 287 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 288 | 289 | [[package]] 290 | name = "pyobjc-core" 291 | version = "8.5" 292 | description = "Python<->ObjC Interoperability Module" 293 | category = "main" 294 | optional = false 295 | python-versions = ">=3.6" 296 | 297 | [[package]] 298 | name = "pyobjc-framework-cocoa" 299 | version = "8.5" 300 | description = "Wrappers for the Cocoa frameworks on macOS" 301 | category = "main" 302 | optional = false 303 | python-versions = ">=3.6" 304 | 305 | [package.dependencies] 306 | pyobjc-core = ">=8.5" 307 | 308 | [[package]] 309 | name = "pyobjc-framework-corebluetooth" 310 | version = "8.5" 311 | description = "Wrappers for the framework CoreBluetooth on macOS" 312 | category = "main" 313 | optional = false 314 | python-versions = ">=3.6" 315 | 316 | [package.dependencies] 317 | pyobjc-core = ">=8.5" 318 | pyobjc-framework-Cocoa = ">=8.5" 319 | 320 | [[package]] 321 | name = "pyobjc-framework-libdispatch" 322 | version = "8.5" 323 | description = "Wrappers for libdispatch on macOS" 324 | category = "main" 325 | optional = false 326 | python-versions = ">=3.6" 327 | 328 | [package.dependencies] 329 | pyobjc-core = ">=8.5" 330 | 331 | [[package]] 332 | name = "pyparsing" 333 | version = "3.0.9" 334 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 335 | category = "dev" 336 | optional = false 337 | python-versions = ">=3.6.8" 338 | 339 | [package.extras] 340 | diagrams = ["railroad-diagrams", "jinja2"] 341 | 342 | [[package]] 343 | name = "pytest" 344 | version = "7.1.2" 345 | description = "pytest: simple powerful testing with Python" 346 | category = "dev" 347 | optional = false 348 | python-versions = ">=3.7" 349 | 350 | [package.dependencies] 351 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 352 | attrs = ">=19.2.0" 353 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 354 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 355 | iniconfig = "*" 356 | packaging = "*" 357 | pluggy = ">=0.12,<2.0" 358 | py = ">=1.8.2" 359 | tomli = ">=1.0.0" 360 | 361 | [package.extras] 362 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 363 | 364 | [[package]] 365 | name = "pytest-cov" 366 | version = "3.0.0" 367 | description = "Pytest plugin for measuring coverage." 368 | category = "dev" 369 | optional = false 370 | python-versions = ">=3.6" 371 | 372 | [package.dependencies] 373 | coverage = {version = ">=5.2.1", extras = ["toml"]} 374 | pytest = ">=4.6" 375 | 376 | [package.extras] 377 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 378 | 379 | [[package]] 380 | name = "pyyaml" 381 | version = "6.0" 382 | description = "YAML parser and emitter for Python" 383 | category = "dev" 384 | optional = false 385 | python-versions = ">=3.6" 386 | 387 | [[package]] 388 | name = "requests" 389 | version = "2.28.1" 390 | description = "Python HTTP for Humans." 391 | category = "dev" 392 | optional = false 393 | python-versions = ">=3.7, <4" 394 | 395 | [package.dependencies] 396 | certifi = ">=2017.4.17" 397 | charset-normalizer = ">=2,<3" 398 | idna = ">=2.5,<4" 399 | urllib3 = ">=1.21.1,<1.27" 400 | 401 | [package.extras] 402 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 403 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 404 | 405 | [[package]] 406 | name = "six" 407 | version = "1.16.0" 408 | description = "Python 2 and 3 compatibility utilities" 409 | category = "dev" 410 | optional = false 411 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 412 | 413 | [[package]] 414 | name = "toml" 415 | version = "0.10.2" 416 | description = "Python Library for Tom's Obvious, Minimal Language" 417 | category = "dev" 418 | optional = false 419 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 420 | 421 | [[package]] 422 | name = "tomli" 423 | version = "2.0.1" 424 | description = "A lil' TOML parser" 425 | category = "dev" 426 | optional = false 427 | python-versions = ">=3.7" 428 | 429 | [[package]] 430 | name = "tox" 431 | version = "3.25.1" 432 | description = "tox is a generic virtualenv management and test command line tool" 433 | category = "dev" 434 | optional = false 435 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 436 | 437 | [package.dependencies] 438 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 439 | filelock = ">=3.0.0" 440 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 441 | packaging = ">=14" 442 | pluggy = ">=0.12.0" 443 | py = ">=1.4.17" 444 | six = ">=1.14.0" 445 | toml = ">=0.9.4" 446 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 447 | 448 | [package.extras] 449 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 450 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "psutil (>=5.6.1)", "pathlib2 (>=2.3.3)"] 451 | 452 | [[package]] 453 | name = "typing-extensions" 454 | version = "4.3.0" 455 | description = "Backported and Experimental Type Hints for Python 3.7+" 456 | category = "main" 457 | optional = false 458 | python-versions = ">=3.7" 459 | 460 | [[package]] 461 | name = "urllib3" 462 | version = "1.26.10" 463 | description = "HTTP library with thread-safe connection pooling, file post, and more." 464 | category = "dev" 465 | optional = false 466 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" 467 | 468 | [package.extras] 469 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 470 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 471 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 472 | 473 | [[package]] 474 | name = "virtualenv" 475 | version = "20.15.1" 476 | description = "Virtual Python Environment builder" 477 | category = "dev" 478 | optional = false 479 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 480 | 481 | [package.dependencies] 482 | distlib = ">=0.3.1,<1" 483 | filelock = ">=3.2,<4" 484 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 485 | platformdirs = ">=2,<3" 486 | six = ">=1.9.0,<2" 487 | 488 | [package.extras] 489 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] 490 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] 491 | 492 | [[package]] 493 | name = "zipp" 494 | version = "3.8.0" 495 | description = "Backport of pathlib-compatible object wrapper for zip files" 496 | category = "main" 497 | optional = false 498 | python-versions = ">=3.7" 499 | 500 | [package.extras] 501 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 502 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 503 | 504 | [extras] 505 | bluepy = ["bluepy"] 506 | gattlib = ["gattlib"] 507 | 508 | [metadata] 509 | lock-version = "1.1" 510 | python-versions = "^3.7" 511 | content-hash = "21ec4141f11f1c8ae25544a03f436e718b35884212e0f916887e2685907ee555" 512 | 513 | [metadata.files] 514 | atomicwrites = [] 515 | attrs = [ 516 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 517 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 518 | ] 519 | bleak = [] 520 | bleak-winrt = [] 521 | bluepy = [ 522 | {file = "bluepy-1.3.0.tar.gz", hash = "sha256:2a71edafe103565fb990256ff3624c1653036a837dfc90e1e32b839f83971cec"}, 523 | ] 524 | certifi = [] 525 | cfgv = [ 526 | {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, 527 | {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, 528 | ] 529 | charset-normalizer = [] 530 | click = [] 531 | codecov = [ 532 | {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, 533 | {file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"}, 534 | {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, 535 | ] 536 | colorama = [] 537 | construct = [ 538 | {file = "construct-2.10.68.tar.gz", hash = "sha256:7b2a3fd8e5f597a5aa1d614c3bd516fa065db01704c72a1efaaeec6ef23d8b45"}, 539 | ] 540 | coverage = [] 541 | dbus-next = [] 542 | distlib = [ 543 | {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, 544 | {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, 545 | ] 546 | filelock = [] 547 | gattlib = [ 548 | {file = "gattlib-0.20201113.tar.gz", hash = "sha256:1e3d92d07bdaad7574aabc3fd36aea5ef1fae4339e521a162fe2341a9a33fcb5"}, 549 | ] 550 | identify = [] 551 | idna = [ 552 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 553 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 554 | ] 555 | importlib-metadata = [] 556 | iniconfig = [ 557 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 558 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 559 | ] 560 | nodeenv = [] 561 | packaging = [ 562 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 563 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 564 | ] 565 | platformdirs = [] 566 | pluggy = [ 567 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 568 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 569 | ] 570 | pre-commit = [] 571 | py = [ 572 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 573 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 574 | ] 575 | pyobjc-core = [] 576 | pyobjc-framework-cocoa = [] 577 | pyobjc-framework-corebluetooth = [] 578 | pyobjc-framework-libdispatch = [] 579 | pyparsing = [] 580 | pytest = [] 581 | pytest-cov = [ 582 | {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, 583 | {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, 584 | ] 585 | pyyaml = [ 586 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 587 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 588 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 589 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 590 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 591 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 592 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 593 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 594 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 595 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 596 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 597 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 598 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 599 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 600 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 601 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 602 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 603 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 604 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 605 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 606 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 607 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 608 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 609 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 610 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 611 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 612 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 613 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 614 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 615 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 616 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 617 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 618 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 619 | ] 620 | requests = [] 621 | six = [ 622 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 623 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 624 | ] 625 | toml = [ 626 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 627 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 628 | ] 629 | tomli = [ 630 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 631 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 632 | ] 633 | tox = [] 634 | typing-extensions = [] 635 | urllib3 = [] 636 | virtualenv = [] 637 | zipp = [ 638 | {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, 639 | {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, 640 | ] 641 | --------------------------------------------------------------------------------