pyhelty 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyhelty-0.1.0/.github/workflows/ci.yml +22 -0
- pyhelty-0.1.0/.github/workflows/release.yml +24 -0
- pyhelty-0.1.0/.gitignore +13 -0
- pyhelty-0.1.0/LICENSE +21 -0
- pyhelty-0.1.0/PKG-INFO +109 -0
- pyhelty-0.1.0/README.md +78 -0
- pyhelty-0.1.0/pyproject.toml +61 -0
- pyhelty-0.1.0/src/pyhelty/__init__.py +27 -0
- pyhelty-0.1.0/src/pyhelty/client.py +186 -0
- pyhelty-0.1.0/src/pyhelty/const.py +90 -0
- pyhelty-0.1.0/src/pyhelty/exceptions.py +19 -0
- pyhelty-0.1.0/src/pyhelty/models.py +33 -0
- pyhelty-0.1.0/src/pyhelty/py.typed +0 -0
- pyhelty-0.1.0/tests/__init__.py +0 -0
- pyhelty-0.1.0/tests/conftest.py +68 -0
- pyhelty-0.1.0/tests/test_client.py +164 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[test,dev]"
|
|
20
|
+
- run: ruff check .
|
|
21
|
+
- run: mypy src
|
|
22
|
+
- run: pytest
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Release to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build-and-publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write # OIDC trusted publishing
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- run: pip install build
|
|
22
|
+
- run: python -m build
|
|
23
|
+
- name: Publish to PyPI
|
|
24
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
pyhelty-0.1.0/.gitignore
ADDED
pyhelty-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ermanno Baschiera
|
|
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.
|
pyhelty-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyhelty
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client library for Helty Flow VMC (mechanical ventilation) units with the smart Wi-Fi interface
|
|
5
|
+
Project-URL: Homepage, https://github.com/ebaschiera/pyhelty
|
|
6
|
+
Project-URL: Source, https://github.com/ebaschiera/pyhelty
|
|
7
|
+
Project-URL: Issues, https://github.com/ebaschiera/pyhelty/issues
|
|
8
|
+
Author-email: Ermanno Baschiera <ebaschiera@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: asyncio,helty,home-assistant,mvhr,ventilation,vmc
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Home Automation
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# pyhelty
|
|
33
|
+
|
|
34
|
+
Async Python client for **Helty Flow** VMC (mechanical ventilation / MVHR) units
|
|
35
|
+
equipped with the smart Wi-Fi interface, such as the *Flow Plus*.
|
|
36
|
+
|
|
37
|
+
It speaks the unit's reverse-engineered TCP protocol (default port **5001**) and
|
|
38
|
+
exposes a small, fully typed `asyncio` API. It is the device-communication layer
|
|
39
|
+
behind the Home Assistant `helty` integration; it has **no** Home Assistant
|
|
40
|
+
dependency and can be used standalone.
|
|
41
|
+
|
|
42
|
+
> The protocol has no official specification. Behaviour is reverse-engineered
|
|
43
|
+
> from a real Helty FlowPlus; your mileage may vary on other models.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pyhelty
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from pyhelty import HeltyClient, FanMode
|
|
56
|
+
|
|
57
|
+
async def main() -> None:
|
|
58
|
+
client = HeltyClient("192.168.1.50") # port defaults to 5001
|
|
59
|
+
|
|
60
|
+
data = await client.async_get_data()
|
|
61
|
+
print(data.name, data.fan_mode, data.indoor_temperature, data.indoor_humidity)
|
|
62
|
+
|
|
63
|
+
await client.async_set_fan_mode(FanMode.NIGHT)
|
|
64
|
+
await client.async_set_led(False)
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
- `HeltyClient(host, port=5001, *, timeout=10.0)`
|
|
72
|
+
- `async_get_name() -> str` — the user-assigned name (also used as a stable id)
|
|
73
|
+
- `async_get_data() -> HeltyData` — name, fan mode, LED state, indoor/outdoor
|
|
74
|
+
temperature, indoor humidity, plus the raw `VMGI`/`VMGO` integer fields for
|
|
75
|
+
fields not yet decoded
|
|
76
|
+
- `async_set_fan_mode(mode: FanMode)` — `OFF, LOW, MEDIUM, HIGH, MAX, BOOST,
|
|
77
|
+
NIGHT, FREE_COOLING`
|
|
78
|
+
- `async_set_led(on: bool)`
|
|
79
|
+
- `async_reset_filter()`
|
|
80
|
+
|
|
81
|
+
Errors derive from `HeltyError`: `HeltyConnectionError`, `HeltyResponseError`,
|
|
82
|
+
`HeltyCommandError`.
|
|
83
|
+
|
|
84
|
+
## Protocol notes
|
|
85
|
+
|
|
86
|
+
| Command | Purpose | Reply |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `VMNM?` | device name | `VMNM <name>` |
|
|
89
|
+
| `VMGI?` | sensors (15 fields, tenths) | `VMGI,<indoor_t>,<outdoor_t>,<indoor_rh>,...` |
|
|
90
|
+
| `VMGH?` | status (15 fields) | `VMGO,<fan_mode>,<led>,...` |
|
|
91
|
+
| `VMWH000000<n>` | set fan mode `n` (0-7) | `OK` |
|
|
92
|
+
| `VMWH0100010` / `VMWH0100000` | LED on / off | `OK` |
|
|
93
|
+
| `VMWH0417744` | reset filter counter | `OK` |
|
|
94
|
+
|
|
95
|
+
The unit serves one command per TCP connection and then closes it; the client
|
|
96
|
+
serialises commands with a lock.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -e ".[test,dev]"
|
|
102
|
+
pytest
|
|
103
|
+
ruff check .
|
|
104
|
+
mypy src
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
pyhelty-0.1.0/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# pyhelty
|
|
2
|
+
|
|
3
|
+
Async Python client for **Helty Flow** VMC (mechanical ventilation / MVHR) units
|
|
4
|
+
equipped with the smart Wi-Fi interface, such as the *Flow Plus*.
|
|
5
|
+
|
|
6
|
+
It speaks the unit's reverse-engineered TCP protocol (default port **5001**) and
|
|
7
|
+
exposes a small, fully typed `asyncio` API. It is the device-communication layer
|
|
8
|
+
behind the Home Assistant `helty` integration; it has **no** Home Assistant
|
|
9
|
+
dependency and can be used standalone.
|
|
10
|
+
|
|
11
|
+
> The protocol has no official specification. Behaviour is reverse-engineered
|
|
12
|
+
> from a real Helty FlowPlus; your mileage may vary on other models.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install pyhelty
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
from pyhelty import HeltyClient, FanMode
|
|
25
|
+
|
|
26
|
+
async def main() -> None:
|
|
27
|
+
client = HeltyClient("192.168.1.50") # port defaults to 5001
|
|
28
|
+
|
|
29
|
+
data = await client.async_get_data()
|
|
30
|
+
print(data.name, data.fan_mode, data.indoor_temperature, data.indoor_humidity)
|
|
31
|
+
|
|
32
|
+
await client.async_set_fan_mode(FanMode.NIGHT)
|
|
33
|
+
await client.async_set_led(False)
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
- `HeltyClient(host, port=5001, *, timeout=10.0)`
|
|
41
|
+
- `async_get_name() -> str` — the user-assigned name (also used as a stable id)
|
|
42
|
+
- `async_get_data() -> HeltyData` — name, fan mode, LED state, indoor/outdoor
|
|
43
|
+
temperature, indoor humidity, plus the raw `VMGI`/`VMGO` integer fields for
|
|
44
|
+
fields not yet decoded
|
|
45
|
+
- `async_set_fan_mode(mode: FanMode)` — `OFF, LOW, MEDIUM, HIGH, MAX, BOOST,
|
|
46
|
+
NIGHT, FREE_COOLING`
|
|
47
|
+
- `async_set_led(on: bool)`
|
|
48
|
+
- `async_reset_filter()`
|
|
49
|
+
|
|
50
|
+
Errors derive from `HeltyError`: `HeltyConnectionError`, `HeltyResponseError`,
|
|
51
|
+
`HeltyCommandError`.
|
|
52
|
+
|
|
53
|
+
## Protocol notes
|
|
54
|
+
|
|
55
|
+
| Command | Purpose | Reply |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `VMNM?` | device name | `VMNM <name>` |
|
|
58
|
+
| `VMGI?` | sensors (15 fields, tenths) | `VMGI,<indoor_t>,<outdoor_t>,<indoor_rh>,...` |
|
|
59
|
+
| `VMGH?` | status (15 fields) | `VMGO,<fan_mode>,<led>,...` |
|
|
60
|
+
| `VMWH000000<n>` | set fan mode `n` (0-7) | `OK` |
|
|
61
|
+
| `VMWH0100010` / `VMWH0100000` | LED on / off | `OK` |
|
|
62
|
+
| `VMWH0417744` | reset filter counter | `OK` |
|
|
63
|
+
|
|
64
|
+
The unit serves one command per TCP connection and then closes it; the client
|
|
65
|
+
serialises commands with a lock.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e ".[test,dev]"
|
|
71
|
+
pytest
|
|
72
|
+
ruff check .
|
|
73
|
+
mypy src
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyhelty"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async client library for Helty Flow VMC (mechanical ventilation) units with the smart Wi-Fi interface"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Ermanno Baschiera", email = "ebaschiera@gmail.com" }]
|
|
13
|
+
keywords = ["helty", "vmc", "mvhr", "ventilation", "home-assistant", "asyncio"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/ebaschiera/pyhelty"
|
|
30
|
+
Source = "https://github.com/ebaschiera/pyhelty"
|
|
31
|
+
Issues = "https://github.com/ebaschiera/pyhelty/issues"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
test = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"pytest-cov>=5.0",
|
|
38
|
+
]
|
|
39
|
+
dev = [
|
|
40
|
+
"ruff>=0.6",
|
|
41
|
+
"mypy>=1.11",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/pyhelty"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
addopts = "--cov=pyhelty --cov-report=term-missing"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
line-length = 100
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "N", "UP", "B", "ASYNC", "RUF"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
python_version = "3.11"
|
|
60
|
+
strict = true
|
|
61
|
+
warn_unreachable = true
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Async client library for Helty Flow VMC units with the smart Wi-Fi interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import HeltyClient
|
|
6
|
+
from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, FanMode
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
HeltyCommandError,
|
|
9
|
+
HeltyConnectionError,
|
|
10
|
+
HeltyError,
|
|
11
|
+
HeltyResponseError,
|
|
12
|
+
)
|
|
13
|
+
from .models import HeltyData
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_PORT",
|
|
17
|
+
"DEFAULT_TIMEOUT",
|
|
18
|
+
"FanMode",
|
|
19
|
+
"HeltyClient",
|
|
20
|
+
"HeltyCommandError",
|
|
21
|
+
"HeltyConnectionError",
|
|
22
|
+
"HeltyData",
|
|
23
|
+
"HeltyError",
|
|
24
|
+
"HeltyResponseError",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Async client for Helty Flow VMC units."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from .const import (
|
|
9
|
+
CMD_LED_OFF,
|
|
10
|
+
CMD_LED_ON,
|
|
11
|
+
CMD_NAME,
|
|
12
|
+
CMD_RESET_FILTER,
|
|
13
|
+
CMD_SENSORS,
|
|
14
|
+
CMD_STATUS,
|
|
15
|
+
DEFAULT_PORT,
|
|
16
|
+
DEFAULT_RETRIES,
|
|
17
|
+
DEFAULT_RETRY_DELAY,
|
|
18
|
+
DEFAULT_TIMEOUT,
|
|
19
|
+
LED_ON_VALUE,
|
|
20
|
+
PREFIX_NAME,
|
|
21
|
+
PREFIX_SENSORS,
|
|
22
|
+
PREFIX_STATUS,
|
|
23
|
+
RESPONSE_OK,
|
|
24
|
+
SENSOR_SCALE,
|
|
25
|
+
SIGNED_16_MODULUS,
|
|
26
|
+
SIGNED_16_THRESHOLD,
|
|
27
|
+
FanMode,
|
|
28
|
+
)
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
HeltyCommandError,
|
|
31
|
+
HeltyConnectionError,
|
|
32
|
+
HeltyResponseError,
|
|
33
|
+
)
|
|
34
|
+
from .models import HeltyData
|
|
35
|
+
|
|
36
|
+
_LOGGER = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_READ_LIMIT = 4096
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HeltyClient:
|
|
42
|
+
"""Talks to a single Helty Flow unit over its TCP interface.
|
|
43
|
+
|
|
44
|
+
The unit serves one command per TCP connection and then closes it, so each
|
|
45
|
+
call opens a fresh connection. An internal lock serialises commands because
|
|
46
|
+
the unit does not reliably handle concurrent connections.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
host: str,
|
|
52
|
+
port: int = DEFAULT_PORT,
|
|
53
|
+
*,
|
|
54
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
55
|
+
retries: int = DEFAULT_RETRIES,
|
|
56
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._host = host
|
|
59
|
+
self._port = port
|
|
60
|
+
self._timeout = timeout
|
|
61
|
+
self._retries = retries
|
|
62
|
+
self._retry_delay = retry_delay
|
|
63
|
+
self._lock = asyncio.Lock()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def host(self) -> str:
|
|
67
|
+
return self._host
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def port(self) -> int:
|
|
71
|
+
return self._port
|
|
72
|
+
|
|
73
|
+
async def _execute_once(self, command: bytes) -> str:
|
|
74
|
+
"""Open a connection, send one command and return the stripped reply."""
|
|
75
|
+
async with asyncio.timeout(self._timeout):
|
|
76
|
+
reader, writer = await asyncio.open_connection(self._host, self._port)
|
|
77
|
+
try:
|
|
78
|
+
writer.write(command)
|
|
79
|
+
await writer.drain()
|
|
80
|
+
payload = await reader.read(_READ_LIMIT)
|
|
81
|
+
finally:
|
|
82
|
+
writer.close()
|
|
83
|
+
await writer.wait_closed()
|
|
84
|
+
return payload.decode("ascii", errors="replace").strip()
|
|
85
|
+
|
|
86
|
+
async def _execute(self, command: bytes) -> str:
|
|
87
|
+
"""Send one command, retrying transient failures and empty replies.
|
|
88
|
+
|
|
89
|
+
The unit serves a single command per connection and intermittently
|
|
90
|
+
accepts a connection but returns an empty payload (especially right
|
|
91
|
+
after an idle period or on rapid reconnection), so an empty reply is
|
|
92
|
+
treated as a retryable, transient condition.
|
|
93
|
+
"""
|
|
94
|
+
last_error: Exception | None = None
|
|
95
|
+
async with self._lock:
|
|
96
|
+
for attempt in range(self._retries + 1):
|
|
97
|
+
if attempt:
|
|
98
|
+
await asyncio.sleep(self._retry_delay)
|
|
99
|
+
try:
|
|
100
|
+
reply = await self._execute_once(command)
|
|
101
|
+
except (TimeoutError, OSError) as err:
|
|
102
|
+
last_error = err
|
|
103
|
+
_LOGGER.debug(
|
|
104
|
+
"Helty %s:%s command %r failed (attempt %d/%d): %s",
|
|
105
|
+
self._host, self._port, command, attempt + 1, self._retries + 1, err,
|
|
106
|
+
)
|
|
107
|
+
continue
|
|
108
|
+
if reply:
|
|
109
|
+
return reply
|
|
110
|
+
last_error = HeltyConnectionError("empty reply")
|
|
111
|
+
_LOGGER.debug(
|
|
112
|
+
"Helty %s:%s returned empty reply to %r (attempt %d/%d)",
|
|
113
|
+
self._host, self._port, command, attempt + 1, self._retries + 1,
|
|
114
|
+
)
|
|
115
|
+
raise HeltyConnectionError(
|
|
116
|
+
f"Failed to communicate with Helty at {self._host}:{self._port}: {last_error}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _parse_fields(reply: str, prefix: str) -> tuple[int, ...]:
|
|
121
|
+
"""Validate the reply prefix and parse the trailing CSV integer fields."""
|
|
122
|
+
parts = reply.split(",")
|
|
123
|
+
if not parts or parts[0] != prefix:
|
|
124
|
+
raise HeltyResponseError(f"Expected {prefix!r} reply, got {reply!r}")
|
|
125
|
+
try:
|
|
126
|
+
return tuple(int(value) for value in parts[1:])
|
|
127
|
+
except ValueError as err:
|
|
128
|
+
raise HeltyResponseError(f"Non-integer field in {reply!r}") from err
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _to_temperature(raw: int) -> float:
|
|
132
|
+
"""Scale a raw tenths-of-degree value, treating it as signed 16-bit."""
|
|
133
|
+
if raw >= SIGNED_16_THRESHOLD:
|
|
134
|
+
raw -= SIGNED_16_MODULUS
|
|
135
|
+
return raw / SENSOR_SCALE
|
|
136
|
+
|
|
137
|
+
async def async_get_name(self) -> str:
|
|
138
|
+
"""Return the user-assigned device name (also used as a stable id)."""
|
|
139
|
+
reply = await self._execute(CMD_NAME)
|
|
140
|
+
if not reply.startswith(PREFIX_NAME):
|
|
141
|
+
raise HeltyResponseError(f"Expected {PREFIX_NAME!r} reply, got {reply!r}")
|
|
142
|
+
return reply[len(PREFIX_NAME) :].strip()
|
|
143
|
+
|
|
144
|
+
async def async_get_data(self) -> HeltyData:
|
|
145
|
+
"""Fetch name, sensors and status in a single coordinated read."""
|
|
146
|
+
name = await self.async_get_name()
|
|
147
|
+
sensors = self._parse_fields(await self._execute(CMD_SENSORS), PREFIX_SENSORS)
|
|
148
|
+
status = self._parse_fields(await self._execute(CMD_STATUS), PREFIX_STATUS)
|
|
149
|
+
|
|
150
|
+
if len(sensors) < 3:
|
|
151
|
+
raise HeltyResponseError(f"Too few sensor fields: {sensors!r}")
|
|
152
|
+
if len(status) < 2:
|
|
153
|
+
raise HeltyResponseError(f"Too few status fields: {status!r}")
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
fan_mode = FanMode(status[0])
|
|
157
|
+
except ValueError as err:
|
|
158
|
+
raise HeltyResponseError(f"Unknown fan mode {status[0]}") from err
|
|
159
|
+
|
|
160
|
+
return HeltyData(
|
|
161
|
+
name=name,
|
|
162
|
+
fan_mode=fan_mode,
|
|
163
|
+
leds_on=status[1] == LED_ON_VALUE,
|
|
164
|
+
indoor_temperature=self._to_temperature(sensors[0]),
|
|
165
|
+
outdoor_temperature=self._to_temperature(sensors[1]),
|
|
166
|
+
indoor_humidity=sensors[2] / SENSOR_SCALE,
|
|
167
|
+
raw_sensors=sensors,
|
|
168
|
+
raw_status=status,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def _write(self, command: bytes) -> None:
|
|
172
|
+
reply = await self._execute(command)
|
|
173
|
+
if reply != RESPONSE_OK:
|
|
174
|
+
raise HeltyCommandError(f"Command {command!r} not acknowledged, got {reply!r}")
|
|
175
|
+
|
|
176
|
+
async def async_set_fan_mode(self, mode: FanMode) -> None:
|
|
177
|
+
"""Select a fan speed or preset program."""
|
|
178
|
+
await self._write(mode.command)
|
|
179
|
+
|
|
180
|
+
async def async_set_led(self, on: bool) -> None:
|
|
181
|
+
"""Turn the front LED panel on or off."""
|
|
182
|
+
await self._write(CMD_LED_ON if on else CMD_LED_OFF)
|
|
183
|
+
|
|
184
|
+
async def async_reset_filter(self) -> None:
|
|
185
|
+
"""Reset the filter-life counter."""
|
|
186
|
+
await self._write(CMD_RESET_FILTER)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Protocol constants for the Helty Flow VMC TCP interface.
|
|
2
|
+
|
|
3
|
+
The protocol is reverse-engineered (no official specification). Commands are
|
|
4
|
+
plain ASCII byte strings sent over a TCP socket; the unit replies with an ASCII
|
|
5
|
+
payload and then closes the connection. One command is served per connection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import IntEnum
|
|
11
|
+
|
|
12
|
+
#: Default TCP port exposed by the Helty smart Wi-Fi interface.
|
|
13
|
+
DEFAULT_PORT = 5001
|
|
14
|
+
|
|
15
|
+
#: Default per-command timeout, in seconds.
|
|
16
|
+
DEFAULT_TIMEOUT = 10.0
|
|
17
|
+
|
|
18
|
+
#: Default number of retries on a transient failure or empty reply.
|
|
19
|
+
DEFAULT_RETRIES = 3
|
|
20
|
+
|
|
21
|
+
#: Default delay between retries, in seconds.
|
|
22
|
+
DEFAULT_RETRY_DELAY = 0.5
|
|
23
|
+
|
|
24
|
+
# --- Read commands ---------------------------------------------------------
|
|
25
|
+
#: Query the device name. Reply: ``VMNM <name>``.
|
|
26
|
+
CMD_NAME = b"VMNM?"
|
|
27
|
+
#: Query live sensor values. Reply: ``VMGI,<f1>,<f2>,...`` (15 fields).
|
|
28
|
+
CMD_SENSORS = b"VMGI?"
|
|
29
|
+
#: Query operating status/config. Reply: ``VMGO,<f1>,<f2>,...`` (15 fields).
|
|
30
|
+
CMD_STATUS = b"VMGH?"
|
|
31
|
+
|
|
32
|
+
# --- Reply prefixes --------------------------------------------------------
|
|
33
|
+
PREFIX_NAME = "VMNM"
|
|
34
|
+
PREFIX_SENSORS = "VMGI"
|
|
35
|
+
PREFIX_STATUS = "VMGO"
|
|
36
|
+
|
|
37
|
+
# --- Write commands --------------------------------------------------------
|
|
38
|
+
#: Turn the front LED panel on / off.
|
|
39
|
+
CMD_LED_ON = b"VMWH0100010"
|
|
40
|
+
CMD_LED_OFF = b"VMWH0100000"
|
|
41
|
+
#: Reset the filter-life counter.
|
|
42
|
+
CMD_RESET_FILTER = b"VMWH0417744"
|
|
43
|
+
|
|
44
|
+
#: Reply returned by the unit on a successful write command.
|
|
45
|
+
RESPONSE_OK = "OK"
|
|
46
|
+
|
|
47
|
+
#: Sensor scaling: raw integers are tenths of a unit (e.g. ``281`` -> ``28.1``).
|
|
48
|
+
SENSOR_SCALE = 10
|
|
49
|
+
|
|
50
|
+
#: Raw values at or above this are interpreted as signed 16-bit (negative temps).
|
|
51
|
+
SIGNED_16_THRESHOLD = 32768
|
|
52
|
+
SIGNED_16_MODULUS = 65536
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FanMode(IntEnum):
|
|
56
|
+
"""Operating mode reported by status field 1 and set via ``VMWH000000<n>``."""
|
|
57
|
+
|
|
58
|
+
OFF = 0
|
|
59
|
+
LOW = 1
|
|
60
|
+
MEDIUM = 2
|
|
61
|
+
HIGH = 3
|
|
62
|
+
MAX = 4
|
|
63
|
+
BOOST = 5 # AirGuard "HyperVentilation"
|
|
64
|
+
NIGHT = 6
|
|
65
|
+
FREE_COOLING = 7
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def command(self) -> bytes:
|
|
69
|
+
"""The ``VMWH`` write command that selects this mode."""
|
|
70
|
+
return f"VMWH000000{self.value}".encode("ascii")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: Modes that represent a discrete fan speed (as opposed to a preset program).
|
|
74
|
+
SPEED_MODES: tuple[FanMode, ...] = (
|
|
75
|
+
FanMode.OFF,
|
|
76
|
+
FanMode.LOW,
|
|
77
|
+
FanMode.MEDIUM,
|
|
78
|
+
FanMode.HIGH,
|
|
79
|
+
FanMode.MAX,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#: Modes that represent a preset program.
|
|
83
|
+
PRESET_MODES: tuple[FanMode, ...] = (
|
|
84
|
+
FanMode.BOOST,
|
|
85
|
+
FanMode.NIGHT,
|
|
86
|
+
FanMode.FREE_COOLING,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
#: Raw value of status field 2 (LED) meaning "on".
|
|
90
|
+
LED_ON_VALUE = 10
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions raised by :mod:`pyhelty`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HeltyError(Exception):
|
|
7
|
+
"""Base class for all pyhelty errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HeltyConnectionError(HeltyError):
|
|
11
|
+
"""Raised when the unit cannot be reached or times out."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HeltyResponseError(HeltyError):
|
|
15
|
+
"""Raised when the unit returns a malformed or unexpected response."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HeltyCommandError(HeltyError):
|
|
19
|
+
"""Raised when a write command is not acknowledged with ``OK``."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Data models for :mod:`pyhelty`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .const import FanMode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class HeltyData:
|
|
12
|
+
"""A snapshot of the unit's sensors and operating status.
|
|
13
|
+
|
|
14
|
+
``raw_sensors`` and ``raw_status`` hold the full integer field lists from
|
|
15
|
+
the ``VMGI``/``VMGO`` replies so that callers can decode additional,
|
|
16
|
+
not-yet-identified fields without a library change.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
fan_mode: FanMode
|
|
21
|
+
leds_on: bool
|
|
22
|
+
indoor_temperature: float | None
|
|
23
|
+
outdoor_temperature: float | None
|
|
24
|
+
indoor_humidity: float | None
|
|
25
|
+
raw_sensors: tuple[int, ...]
|
|
26
|
+
raw_status: tuple[int, ...]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_preset(self) -> bool:
|
|
30
|
+
"""True when the current mode is a preset program rather than a speed."""
|
|
31
|
+
from .const import PRESET_MODES
|
|
32
|
+
|
|
33
|
+
return self.fan_mode in PRESET_MODES
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Test fixtures: a fake Helty TCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
# Canonical replies captured from a real Helty FlowPlus.
|
|
11
|
+
DEFAULT_REPLIES: dict[bytes, bytes] = {
|
|
12
|
+
b"VMNM?": b"VMNM VMC_soggiorn",
|
|
13
|
+
b"VMGI?": b"VMGI,00281,00312,00422,00000,16384,04516,00265,00099,04354,00124,00000,00265,00422,00001,00001", # noqa: E501
|
|
14
|
+
b"VMGH?": b"VMGO,00001,00010,00015,00000,00000,00100,00000,01200,00200,00220,09000,00600,00000,00001,00000", # noqa: E501
|
|
15
|
+
# Any VMWH... write command is acknowledged with OK.
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeHelty:
|
|
20
|
+
"""A minimal TCP server emulating one command per connection."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.replies: dict[bytes, bytes] = dict(DEFAULT_REPLIES)
|
|
24
|
+
self.received: list[bytes] = []
|
|
25
|
+
#: Number of leading connections that should reply with an empty payload
|
|
26
|
+
#: (emulating the real unit's intermittent "busy" behaviour).
|
|
27
|
+
self.empty_replies = 0
|
|
28
|
+
self._server: asyncio.AbstractServer | None = None
|
|
29
|
+
self.host = "127.0.0.1"
|
|
30
|
+
self.port = 0
|
|
31
|
+
|
|
32
|
+
async def _handle(
|
|
33
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
34
|
+
) -> None:
|
|
35
|
+
command = (await reader.read(64)).strip()
|
|
36
|
+
self.received.append(command)
|
|
37
|
+
if self.empty_replies > 0:
|
|
38
|
+
self.empty_replies -= 1
|
|
39
|
+
writer.close()
|
|
40
|
+
return
|
|
41
|
+
if command in self.replies:
|
|
42
|
+
reply = self.replies[command]
|
|
43
|
+
elif command.startswith(b"VMWH"):
|
|
44
|
+
reply = b"OK"
|
|
45
|
+
else:
|
|
46
|
+
reply = b""
|
|
47
|
+
writer.write(reply)
|
|
48
|
+
await writer.drain()
|
|
49
|
+
writer.close()
|
|
50
|
+
|
|
51
|
+
async def start(self) -> None:
|
|
52
|
+
self._server = await asyncio.start_server(self._handle, self.host, 0)
|
|
53
|
+
self.port = self._server.sockets[0].getsockname()[1]
|
|
54
|
+
|
|
55
|
+
async def stop(self) -> None:
|
|
56
|
+
if self._server is not None:
|
|
57
|
+
self._server.close()
|
|
58
|
+
await self._server.wait_closed()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
async def fake_helty() -> AsyncIterator[FakeHelty]:
|
|
63
|
+
server = FakeHelty()
|
|
64
|
+
await server.start()
|
|
65
|
+
try:
|
|
66
|
+
yield server
|
|
67
|
+
finally:
|
|
68
|
+
await server.stop()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tests for :class:`pyhelty.HeltyClient`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from pyhelty import (
|
|
8
|
+
FanMode,
|
|
9
|
+
HeltyClient,
|
|
10
|
+
HeltyCommandError,
|
|
11
|
+
HeltyConnectionError,
|
|
12
|
+
HeltyResponseError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .conftest import FakeHelty
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client(server: FakeHelty) -> HeltyClient:
|
|
19
|
+
return HeltyClient(server.host, server.port, timeout=2.0, retries=3, retry_delay=0.0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_get_name(fake_helty: FakeHelty) -> None:
|
|
23
|
+
client = _client(fake_helty)
|
|
24
|
+
assert await client.async_get_name() == "VMC_soggiorn"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def test_get_data(fake_helty: FakeHelty) -> None:
|
|
28
|
+
client = _client(fake_helty)
|
|
29
|
+
data = await client.async_get_data()
|
|
30
|
+
|
|
31
|
+
assert data.name == "VMC_soggiorn"
|
|
32
|
+
assert data.fan_mode is FanMode.LOW
|
|
33
|
+
assert data.leds_on is True
|
|
34
|
+
assert data.indoor_temperature == pytest.approx(28.1)
|
|
35
|
+
assert data.outdoor_temperature == pytest.approx(31.2)
|
|
36
|
+
assert data.indoor_humidity == pytest.approx(42.2)
|
|
37
|
+
assert len(data.raw_sensors) == 15
|
|
38
|
+
assert len(data.raw_status) == 15
|
|
39
|
+
assert data.is_preset is False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_get_data_negative_outdoor_temperature(fake_helty: FakeHelty) -> None:
|
|
43
|
+
# -5.0 C encoded as signed 16-bit: 65536 - 50 = 65486.
|
|
44
|
+
fake_helty.replies[b"VMGI?"] = b"VMGI,00200,65486,00500"
|
|
45
|
+
client = _client(fake_helty)
|
|
46
|
+
data = await client.async_get_data()
|
|
47
|
+
assert data.outdoor_temperature == pytest.approx(-5.0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_get_data_preset_mode(fake_helty: FakeHelty) -> None:
|
|
51
|
+
fake_helty.replies[b"VMGH?"] = b"VMGO,00005,00000"
|
|
52
|
+
client = _client(fake_helty)
|
|
53
|
+
data = await client.async_get_data()
|
|
54
|
+
assert data.fan_mode is FanMode.BOOST
|
|
55
|
+
assert data.leds_on is False
|
|
56
|
+
assert data.is_preset is True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
("mode", "expected"),
|
|
61
|
+
[
|
|
62
|
+
(FanMode.OFF, b"VMWH0000000"),
|
|
63
|
+
(FanMode.LOW, b"VMWH0000001"),
|
|
64
|
+
(FanMode.MAX, b"VMWH0000004"),
|
|
65
|
+
(FanMode.BOOST, b"VMWH0000005"),
|
|
66
|
+
(FanMode.FREE_COOLING, b"VMWH0000007"),
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
async def test_set_fan_mode(fake_helty: FakeHelty, mode: FanMode, expected: bytes) -> None:
|
|
70
|
+
client = _client(fake_helty)
|
|
71
|
+
await client.async_set_fan_mode(mode)
|
|
72
|
+
assert fake_helty.received[-1] == expected
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_set_led(fake_helty: FakeHelty) -> None:
|
|
76
|
+
client = _client(fake_helty)
|
|
77
|
+
await client.async_set_led(True)
|
|
78
|
+
assert fake_helty.received[-1] == b"VMWH0100010"
|
|
79
|
+
await client.async_set_led(False)
|
|
80
|
+
assert fake_helty.received[-1] == b"VMWH0100000"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_reset_filter(fake_helty: FakeHelty) -> None:
|
|
84
|
+
client = _client(fake_helty)
|
|
85
|
+
await client.async_reset_filter()
|
|
86
|
+
assert fake_helty.received[-1] == b"VMWH0417744"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_command_not_acknowledged(fake_helty: FakeHelty) -> None:
|
|
90
|
+
fake_helty.replies[b"VMWH0100010"] = b"ERR"
|
|
91
|
+
client = _client(fake_helty)
|
|
92
|
+
with pytest.raises(HeltyCommandError):
|
|
93
|
+
await client.async_set_led(True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_malformed_sensor_reply(fake_helty: FakeHelty) -> None:
|
|
97
|
+
fake_helty.replies[b"VMGI?"] = b"GARBAGE"
|
|
98
|
+
client = _client(fake_helty)
|
|
99
|
+
with pytest.raises(HeltyResponseError):
|
|
100
|
+
await client.async_get_data()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def test_non_integer_field(fake_helty: FakeHelty) -> None:
|
|
104
|
+
fake_helty.replies[b"VMGI?"] = b"VMGI,00281,abc,00422"
|
|
105
|
+
client = _client(fake_helty)
|
|
106
|
+
with pytest.raises(HeltyResponseError):
|
|
107
|
+
await client.async_get_data()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_unknown_fan_mode(fake_helty: FakeHelty) -> None:
|
|
111
|
+
fake_helty.replies[b"VMGH?"] = b"VMGO,00099,00000"
|
|
112
|
+
client = _client(fake_helty)
|
|
113
|
+
with pytest.raises(HeltyResponseError):
|
|
114
|
+
await client.async_get_data()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_bad_name_reply(fake_helty: FakeHelty) -> None:
|
|
118
|
+
fake_helty.replies[b"VMNM?"] = b"NOPE"
|
|
119
|
+
client = _client(fake_helty)
|
|
120
|
+
with pytest.raises(HeltyResponseError):
|
|
121
|
+
await client.async_get_name()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def test_connection_error() -> None:
|
|
125
|
+
# Nothing listening on this port.
|
|
126
|
+
client = HeltyClient("127.0.0.1", 1, timeout=1.0, retries=1, retry_delay=0.0)
|
|
127
|
+
with pytest.raises(HeltyConnectionError):
|
|
128
|
+
await client.async_get_name()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def test_empty_reply_is_retried(fake_helty: FakeHelty) -> None:
|
|
132
|
+
# The first two connections reply empty; the client should retry and succeed.
|
|
133
|
+
fake_helty.empty_replies = 2
|
|
134
|
+
client = _client(fake_helty)
|
|
135
|
+
assert await client.async_get_name() == "VMC_soggiorn"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def test_empty_reply_exhausts_retries(fake_helty: FakeHelty) -> None:
|
|
139
|
+
fake_helty.empty_replies = 99
|
|
140
|
+
client = HeltyClient(
|
|
141
|
+
fake_helty.host, fake_helty.port, timeout=2.0, retries=2, retry_delay=0.0
|
|
142
|
+
)
|
|
143
|
+
with pytest.raises(HeltyConnectionError):
|
|
144
|
+
await client.async_get_name()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def test_too_few_sensor_fields(fake_helty: FakeHelty) -> None:
|
|
148
|
+
fake_helty.replies[b"VMGI?"] = b"VMGI,00281"
|
|
149
|
+
client = _client(fake_helty)
|
|
150
|
+
with pytest.raises(HeltyResponseError):
|
|
151
|
+
await client.async_get_data()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def test_too_few_status_fields(fake_helty: FakeHelty) -> None:
|
|
155
|
+
fake_helty.replies[b"VMGH?"] = b"VMGO,00001"
|
|
156
|
+
client = _client(fake_helty)
|
|
157
|
+
with pytest.raises(HeltyResponseError):
|
|
158
|
+
await client.async_get_data()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_client_properties() -> None:
|
|
162
|
+
client = HeltyClient("1.2.3.4", 5001)
|
|
163
|
+
assert client.host == "1.2.3.4"
|
|
164
|
+
assert client.port == 5001
|