proconip 2.2.0__tar.gz → 2.2.1__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.
- {proconip-2.2.0 → proconip-2.2.1}/CHANGELOG.md +7 -0
- {proconip-2.2.0 → proconip-2.2.1}/PKG-INFO +5 -6
- {proconip-2.2.0 → proconip-2.2.1}/README.md +3 -4
- {proconip-2.2.0 → proconip-2.2.1}/pyproject.toml +3 -7
- {proconip-2.2.0 → proconip-2.2.1}/src/proconip/_version.py +2 -2
- {proconip-2.2.0 → proconip-2.2.1}/src/proconip/api.py +18 -6
- {proconip-2.2.0 → proconip-2.2.1}/src/proconip/definitions.py +29 -5
- proconip-2.2.1/tests/test_api.py +548 -0
- proconip-2.2.0/tests/test_api.py +0 -431
- {proconip-2.2.0 → proconip-2.2.1}/.gitignore +0 -0
- {proconip-2.2.0 → proconip-2.2.1}/LICENSE +0 -0
- {proconip-2.2.0 → proconip-2.2.1}/src/proconip/__init__.py +0 -0
- {proconip-2.2.0 → proconip-2.2.1}/src/proconip/py.typed +0 -0
- {proconip-2.2.0 → proconip-2.2.1}/tests/conftest.py +0 -0
- {proconip-2.2.0 → proconip-2.2.1}/tests/fixtures/get_dmx.csv +0 -0
- {proconip-2.2.0 → proconip-2.2.1}/tests/fixtures/get_state.csv +0 -0
- {proconip-2.2.0 → proconip-2.2.1}/tests/test_definitions.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.1](https://github.com/ylabonte/proconip-pypi/compare/v2.2.0...v2.2.1) (2026-10-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* support aiohttp 3.14 without deprecation warnings; test against it ([#94](https://github.com/ylabonte/proconip-pypi/issues/94), [#97](https://github.com/ylabonte/proconip-pypi/issues/97)) ([#98](https://github.com/ylabonte/proconip-pypi/issues/98)) ([87dcc3d](https://github.com/ylabonte/proconip-pypi/commit/87dcc3d01ab2d5cae7fb826638086665f2076c7c))
|
|
9
|
+
|
|
3
10
|
## [2.2.0](https://github.com/ylabonte/proconip-pypi/compare/v2.1.2...v2.2.0) (2026-06-14)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: proconip
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: Async Python library for interacting with the ProCon.IP pool controller.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ylabonte/proconip-pypi
|
|
6
6
|
Project-URL: Documentation, https://ylabonte.github.io/proconip-pypi/
|
|
@@ -176,8 +176,8 @@ async def manual_dosage_example():
|
|
|
176
176
|
client_session = aiohttp.ClientSession()
|
|
177
177
|
config = ConfigObject("http://192.168.2.3", "admin", "admin")
|
|
178
178
|
dosage_control = DosageControl(client_session, config)
|
|
179
|
-
await dosage_control.async_chlorine_dosage(3600)
|
|
180
|
-
await dosage_control.async_ph_minus_dosage(60)
|
|
179
|
+
await dosage_control.async_chlorine_dosage(3600) # start for 1 hour
|
|
180
|
+
await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
|
|
181
181
|
await client_session.close()
|
|
182
182
|
|
|
183
183
|
|
|
@@ -201,13 +201,12 @@ async def dmx_example():
|
|
|
201
201
|
print(f"{channel.name} before: {channel.value}")
|
|
202
202
|
dmx_data.set(channel.index, (channel.value + 128) % 256)
|
|
203
203
|
print(f"{channel.name} after: {dmx_data.get_value(channel.index)}")
|
|
204
|
-
|
|
204
|
+
|
|
205
205
|
await dmx_control.async_set(dmx_data)
|
|
206
206
|
await client_session.close()
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
asyncio.run(dmx_example())
|
|
210
|
-
|
|
211
210
|
```
|
|
212
211
|
|
|
213
212
|
## A brief description of the ProCon.IP pool controller
|
|
@@ -148,8 +148,8 @@ async def manual_dosage_example():
|
|
|
148
148
|
client_session = aiohttp.ClientSession()
|
|
149
149
|
config = ConfigObject("http://192.168.2.3", "admin", "admin")
|
|
150
150
|
dosage_control = DosageControl(client_session, config)
|
|
151
|
-
await dosage_control.async_chlorine_dosage(3600)
|
|
152
|
-
await dosage_control.async_ph_minus_dosage(60)
|
|
151
|
+
await dosage_control.async_chlorine_dosage(3600) # start for 1 hour
|
|
152
|
+
await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
|
|
153
153
|
await client_session.close()
|
|
154
154
|
|
|
155
155
|
|
|
@@ -173,13 +173,12 @@ async def dmx_example():
|
|
|
173
173
|
print(f"{channel.name} before: {channel.value}")
|
|
174
174
|
dmx_data.set(channel.index, (channel.value + 128) % 256)
|
|
175
175
|
print(f"{channel.name} after: {dmx_data.get_value(channel.index)}")
|
|
176
|
-
|
|
176
|
+
|
|
177
177
|
await dmx_control.async_set(dmx_data)
|
|
178
178
|
await client_session.close()
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
asyncio.run(dmx_example())
|
|
182
|
-
|
|
183
182
|
```
|
|
184
183
|
|
|
185
184
|
## A brief description of the ProCon.IP pool controller
|
|
@@ -37,15 +37,11 @@ test = [
|
|
|
37
37
|
"pytest",
|
|
38
38
|
"pytest-asyncio",
|
|
39
39
|
"pytest-cov",
|
|
40
|
-
"aioresponses",
|
|
41
|
-
# aioresponses 0.7.8 can't build mocked responses against aiohttp 3.14+
|
|
42
|
-
# (ClientResponse gained a required `stream_writer` kwarg), which breaks
|
|
43
|
-
# the whole test_api suite. Pin tests to the 3.13.x line HA Core ships.
|
|
44
|
-
# TODO: lift this cap once a compatible aioresponses release lands.
|
|
45
|
-
"aiohttp<3.14",
|
|
46
40
|
]
|
|
47
41
|
docs = [
|
|
48
|
-
|
|
42
|
+
# MkDocs 2.0 drops the plugin system and the theming that Material and
|
|
43
|
+
# mkdocstrings rely on, with no migration path — stay on 1.x.
|
|
44
|
+
"mkdocs<2",
|
|
49
45
|
"mkdocs-material",
|
|
50
46
|
"mkdocstrings[python]",
|
|
51
47
|
"mkdocs-include-markdown-plugin",
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 2,
|
|
21
|
+
__version__ = version = '2.2.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -26,11 +26,11 @@ subclasses so callers can handle them uniformly.
|
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
import asyncio
|
|
29
|
+
import base64
|
|
29
30
|
import contextlib
|
|
30
31
|
import socket
|
|
31
32
|
|
|
32
33
|
from aiohttp import (
|
|
33
|
-
BasicAuth,
|
|
34
34
|
ClientError,
|
|
35
35
|
ClientResponse,
|
|
36
36
|
ClientSession,
|
|
@@ -84,6 +84,18 @@ class TimeoutException(ProconipApiException):
|
|
|
84
84
|
"""
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def _auth_headers(config: ConfigObject) -> dict[str, str]:
|
|
88
|
+
"""Build the HTTP Basic ``Authorization`` header for the controller credentials.
|
|
89
|
+
|
|
90
|
+
Built by hand rather than via ``aiohttp.BasicAuth`` / ``auth=``, which
|
|
91
|
+
aiohttp 3.14 deprecates (removal in 4.0), while its replacement
|
|
92
|
+
``aiohttp.encode_basic_auth`` doesn't exist on older supported versions.
|
|
93
|
+
Credentials are latin-1 encoded, matching ``BasicAuth``'s default.
|
|
94
|
+
"""
|
|
95
|
+
credentials = f"{config.username}:{config.password}".encode("latin1")
|
|
96
|
+
return {"Authorization": "Basic " + base64.b64encode(credentials).decode("ascii")}
|
|
97
|
+
|
|
98
|
+
|
|
87
99
|
async def _handle_response(response: ClientResponse) -> str:
|
|
88
100
|
"""Validate the response and return its body, mapping HTTP errors to typed exceptions."""
|
|
89
101
|
if response.status in (401, 403):
|
|
@@ -130,10 +142,9 @@ async def async_get_raw_data(
|
|
|
130
142
|
ProconipApiException: For DNS failures, connection resets, and other
|
|
131
143
|
network-level errors.
|
|
132
144
|
"""
|
|
133
|
-
auth = BasicAuth(config.username, config.password)
|
|
134
145
|
try:
|
|
135
146
|
async with asyncio.timeout(timeout):
|
|
136
|
-
async with client_session.get(url,
|
|
147
|
+
async with client_session.get(url, headers=_auth_headers(config)) as response:
|
|
137
148
|
return await _handle_response(response)
|
|
138
149
|
except TimeoutError as exc:
|
|
139
150
|
raise TimeoutException("API request timed out") from exc
|
|
@@ -300,15 +311,16 @@ async def async_post_usrcfg_cgi(
|
|
|
300
311
|
ProconipApiException: For network-level errors.
|
|
301
312
|
"""
|
|
302
313
|
url = URL(config.base_url).with_path(API_PATH_USRCFG)
|
|
303
|
-
|
|
304
|
-
|
|
314
|
+
headers = {
|
|
315
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
316
|
+
**_auth_headers(config),
|
|
317
|
+
}
|
|
305
318
|
try:
|
|
306
319
|
async with asyncio.timeout(timeout):
|
|
307
320
|
async with client_session.post(
|
|
308
321
|
url=url,
|
|
309
322
|
headers=headers,
|
|
310
323
|
data=payload,
|
|
311
|
-
auth=auth,
|
|
312
324
|
) as response:
|
|
313
325
|
return await _handle_response(response)
|
|
314
326
|
except TimeoutError as exc:
|
|
@@ -585,7 +585,13 @@ class GetStateData:
|
|
|
585
585
|
@property
|
|
586
586
|
def config_other_enable(self) -> int:
|
|
587
587
|
"""Misc configuration flags. Use the `is_*_enabled` methods to query
|
|
588
|
-
individual bits (TCP/IP boost, SD card, DMX, …).
|
|
588
|
+
individual bits (TCP/IP boost, SD card, DMX, …).
|
|
589
|
+
|
|
590
|
+
Not every "Sonstiges" checkbox is mirrored here on current firmware:
|
|
591
|
+
on 1.7.6, bits 0 (TCP/IP boost), 1 (SD card) and 5 (high bus load)
|
|
592
|
+
were observed as 0 although those options were enabled, while bit 2
|
|
593
|
+
(DMX) tracked its checkbox exactly. Bits 3, 4, 6, 7 and 8 are
|
|
594
|
+
unverified on hardware."""
|
|
589
595
|
return self._config_other_enable
|
|
590
596
|
|
|
591
597
|
@property
|
|
@@ -749,15 +755,28 @@ class GetStateData:
|
|
|
749
755
|
return NTP_FAULT_STATE[0]
|
|
750
756
|
|
|
751
757
|
def is_tcpip_boost_enabled(self) -> bool:
|
|
752
|
-
"""True if TCP/IP boost
|
|
758
|
+
"""True if bit 0 (TCP/IP boost) of `config_other_enable` is set.
|
|
759
|
+
|
|
760
|
+
Unreliable on current firmware: 1.7.6 was observed reporting this bit
|
|
761
|
+
as 0 while TCP/IP boost was enabled, so ``False`` doesn't prove the
|
|
762
|
+
feature is off.
|
|
763
|
+
"""
|
|
753
764
|
return self._config_other_enable & 1 == 1
|
|
754
765
|
|
|
755
766
|
def is_sd_card_enabled(self) -> bool:
|
|
756
|
-
"""True if SD card
|
|
767
|
+
"""True if bit 1 (SD card) of `config_other_enable` is set.
|
|
768
|
+
|
|
769
|
+
Unreliable on current firmware: 1.7.6 was observed reporting this bit
|
|
770
|
+
as 0 while the SD card was enabled, so ``False`` doesn't prove the
|
|
771
|
+
feature is off.
|
|
772
|
+
"""
|
|
757
773
|
return self._config_other_enable & 2 == 2
|
|
758
774
|
|
|
759
775
|
def is_dmx_enabled(self) -> bool:
|
|
760
|
-
"""True if DMX output is enabled in the controller config (bit 2).
|
|
776
|
+
"""True if DMX output is enabled in the controller config (bit 2).
|
|
777
|
+
|
|
778
|
+
Verified on firmware 1.7.6: the bit follows the "DMX512 aktiv" checkbox.
|
|
779
|
+
"""
|
|
761
780
|
return self._config_other_enable & 4 == 4
|
|
762
781
|
|
|
763
782
|
def is_avatar_enabled(self) -> bool:
|
|
@@ -774,7 +793,12 @@ class GetStateData:
|
|
|
774
793
|
return self._config_other_enable & 16 == 16
|
|
775
794
|
|
|
776
795
|
def is_high_bus_load_enabled(self) -> bool:
|
|
777
|
-
"""True if high bus load
|
|
796
|
+
"""True if bit 5 (high bus load, 1-Wire) of `config_other_enable` is set.
|
|
797
|
+
|
|
798
|
+
Unreliable on current firmware: 1.7.6 was observed reporting this bit
|
|
799
|
+
as 0 while high bus load was enabled, so ``False`` doesn't prove the
|
|
800
|
+
feature is off.
|
|
801
|
+
"""
|
|
778
802
|
return self._config_other_enable & 32 == 32
|
|
779
803
|
|
|
780
804
|
def is_flow_sensor_enabled(self) -> bool:
|
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Tests for the API module — HTTP layer, error mapping, and class wrappers.
|
|
2
|
+
|
|
3
|
+
Requests go over a real socket to `FakeController`, an in-process aiohttp
|
|
4
|
+
server, so the suite exercises whichever aiohttp version is installed end to
|
|
5
|
+
end (response construction, auth headers, timeouts) instead of a mocked
|
|
6
|
+
client internals layer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import socket
|
|
12
|
+
from collections import defaultdict, deque
|
|
13
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
import aiohttp
|
|
18
|
+
import pytest
|
|
19
|
+
from aiohttp import web
|
|
20
|
+
from aiohttp.test_utils import RawTestServer
|
|
21
|
+
|
|
22
|
+
from proconip.api import (
|
|
23
|
+
BadCredentialsException,
|
|
24
|
+
BadStatusCodeException,
|
|
25
|
+
DigitalInputControl,
|
|
26
|
+
DmxControl,
|
|
27
|
+
DosageControl,
|
|
28
|
+
GetState,
|
|
29
|
+
ProconipApiException,
|
|
30
|
+
RelaySwitch,
|
|
31
|
+
TimeoutException,
|
|
32
|
+
async_get_dmx,
|
|
33
|
+
async_get_raw_dmx,
|
|
34
|
+
async_get_raw_state,
|
|
35
|
+
async_get_state,
|
|
36
|
+
async_set_auto_mode,
|
|
37
|
+
async_set_dmx,
|
|
38
|
+
async_start_dosage,
|
|
39
|
+
async_switch_off,
|
|
40
|
+
async_switch_on,
|
|
41
|
+
async_trigger_digital_input,
|
|
42
|
+
)
|
|
43
|
+
from proconip.definitions import ConfigObject, DosageTarget, GetDmxData, GetStateData
|
|
44
|
+
|
|
45
|
+
GET_STATE_PATH = "/GetState.csv"
|
|
46
|
+
USRCFG_PATH = "/usrcfg.cgi"
|
|
47
|
+
GET_DMX_PATH = "/GetDmx.csv"
|
|
48
|
+
|
|
49
|
+
SIMPLE_DMX_CSV = "0,10,20,30,40,50,60,70,80,90,100,110,120,130,140,150\n"
|
|
50
|
+
|
|
51
|
+
_real_sleep = asyncio.sleep
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RecordedRequest:
|
|
56
|
+
method: str
|
|
57
|
+
path_qs: str
|
|
58
|
+
body: str
|
|
59
|
+
authorization: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class _QueuedResponse:
|
|
64
|
+
body: str
|
|
65
|
+
status: int
|
|
66
|
+
delay: float
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FakeController:
|
|
70
|
+
"""Stand-in ProCon.IP controller with queued responses per method and path.
|
|
71
|
+
|
|
72
|
+
Each `add()` queues one response; requests consume them in order. An
|
|
73
|
+
unmatched request gets a 404 so a wrong URL fails loudly.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self.base_url = ""
|
|
78
|
+
self.requests: list[RecordedRequest] = []
|
|
79
|
+
self._responses: defaultdict[tuple[str, str], deque[_QueuedResponse]] = defaultdict(deque)
|
|
80
|
+
|
|
81
|
+
def add(
|
|
82
|
+
self, method: str, path_qs: str, *, body: str = "", status: int = 200, delay: float = 0.0
|
|
83
|
+
) -> None:
|
|
84
|
+
self._responses[(method, path_qs)].append(_QueuedResponse(body, status, delay))
|
|
85
|
+
|
|
86
|
+
def get(self, path_qs: str, *, body: str = "", status: int = 200, delay: float = 0.0) -> None:
|
|
87
|
+
self.add("GET", path_qs, body=body, status=status, delay=delay)
|
|
88
|
+
|
|
89
|
+
def post(self, path_qs: str, *, body: str = "", status: int = 200, delay: float = 0.0) -> None:
|
|
90
|
+
self.add("POST", path_qs, body=body, status=status, delay=delay)
|
|
91
|
+
|
|
92
|
+
def posts_to(self, path: str) -> list[RecordedRequest]:
|
|
93
|
+
return [r for r in self.requests if r.method == "POST" and r.path_qs == path]
|
|
94
|
+
|
|
95
|
+
async def handle(self, request: web.BaseRequest) -> web.Response:
|
|
96
|
+
self.requests.append(
|
|
97
|
+
RecordedRequest(
|
|
98
|
+
method=request.method,
|
|
99
|
+
path_qs=request.path_qs,
|
|
100
|
+
body=await request.text(),
|
|
101
|
+
authorization=request.headers.get("Authorization"),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
queue = self._responses.get((request.method, request.path_qs))
|
|
105
|
+
if not queue:
|
|
106
|
+
return web.Response(status=404, text=f"unexpected {request.method} {request.path_qs}")
|
|
107
|
+
response = queue.popleft()
|
|
108
|
+
if response.delay:
|
|
109
|
+
await _real_sleep(response.delay)
|
|
110
|
+
return web.Response(text=response.body, status=response.status)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.fixture
|
|
114
|
+
async def controller() -> AsyncIterator[FakeController]:
|
|
115
|
+
fake = FakeController()
|
|
116
|
+
async with RawTestServer(fake.handle) as server:
|
|
117
|
+
fake.base_url = f"http://{server.host}:{server.port}"
|
|
118
|
+
yield fake
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.fixture
|
|
122
|
+
def config(controller: FakeController) -> ConfigObject:
|
|
123
|
+
return ConfigObject(controller.base_url, "admin", "admin")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _sleep_spy(
|
|
127
|
+
on_hold: Callable[[float], Awaitable[None]],
|
|
128
|
+
) -> Callable[..., Awaitable[object]]:
|
|
129
|
+
"""Patch target for `asyncio.sleep` that only intercepts the hold delay.
|
|
130
|
+
|
|
131
|
+
`proconip.api.asyncio` is the global asyncio module, so the patch is seen
|
|
132
|
+
by aiohttp and the event loop too; every other sleep passes through.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
async def fake_sleep(delay: float, *args: object) -> object:
|
|
136
|
+
if delay == 0.6:
|
|
137
|
+
return await on_hold(delay)
|
|
138
|
+
return await _real_sleep(delay, *args)
|
|
139
|
+
|
|
140
|
+
return fake_sleep
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# async_get_raw_state
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def test_get_raw_state_happy_path(controller: FakeController, config: ConfigObject) -> None:
|
|
149
|
+
controller.get(GET_STATE_PATH, body="raw_csv_response", status=200)
|
|
150
|
+
async with aiohttp.ClientSession() as session:
|
|
151
|
+
result = await async_get_raw_state(session, config)
|
|
152
|
+
assert result == "raw_csv_response"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def test_get_raw_state_401_raises_bad_credentials(
|
|
156
|
+
controller: FakeController, config: ConfigObject
|
|
157
|
+
) -> None:
|
|
158
|
+
controller.get(GET_STATE_PATH, status=401)
|
|
159
|
+
async with aiohttp.ClientSession() as session:
|
|
160
|
+
with pytest.raises(BadCredentialsException):
|
|
161
|
+
await async_get_raw_state(session, config)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_get_raw_state_403_raises_bad_credentials(
|
|
165
|
+
controller: FakeController, config: ConfigObject
|
|
166
|
+
) -> None:
|
|
167
|
+
controller.get(GET_STATE_PATH, status=403)
|
|
168
|
+
async with aiohttp.ClientSession() as session:
|
|
169
|
+
with pytest.raises(BadCredentialsException):
|
|
170
|
+
await async_get_raw_state(session, config)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def test_get_raw_state_500_raises_bad_status_code(
|
|
174
|
+
controller: FakeController, config: ConfigObject
|
|
175
|
+
) -> None:
|
|
176
|
+
controller.get(GET_STATE_PATH, status=500)
|
|
177
|
+
async with aiohttp.ClientSession() as session:
|
|
178
|
+
with pytest.raises(BadStatusCodeException):
|
|
179
|
+
await async_get_raw_state(session, config)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def test_get_raw_state_timeout_raises_timeout_exception(
|
|
183
|
+
controller: FakeController, config: ConfigObject
|
|
184
|
+
) -> None:
|
|
185
|
+
controller.get(GET_STATE_PATH, body="too late", delay=1.0)
|
|
186
|
+
async with aiohttp.ClientSession() as session:
|
|
187
|
+
with pytest.raises(TimeoutException):
|
|
188
|
+
await async_get_raw_state(session, config, timeout=0.05)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def test_get_raw_state_connection_error_raises_api_exception() -> None:
|
|
192
|
+
# Bind and release a port so nothing is listening on it.
|
|
193
|
+
with socket.socket() as sock:
|
|
194
|
+
sock.bind(("127.0.0.1", 0))
|
|
195
|
+
port = sock.getsockname()[1]
|
|
196
|
+
unreachable = ConfigObject(f"http://127.0.0.1:{port}", "admin", "admin")
|
|
197
|
+
async with aiohttp.ClientSession() as session:
|
|
198
|
+
with pytest.raises(ProconipApiException):
|
|
199
|
+
await async_get_raw_state(session, unreachable)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def test_requests_send_basic_auth(controller: FakeController, config: ConfigObject) -> None:
|
|
203
|
+
controller.get(GET_STATE_PATH, body="ok")
|
|
204
|
+
controller.post(USRCFG_PATH, body="ok")
|
|
205
|
+
controller.post(USRCFG_PATH, body="ok")
|
|
206
|
+
async with aiohttp.ClientSession() as session:
|
|
207
|
+
await async_get_raw_state(session, config)
|
|
208
|
+
await async_trigger_digital_input(session, config, 0, hold_seconds=0)
|
|
209
|
+
expected = "Basic " + base64.b64encode(b"admin:admin").decode()
|
|
210
|
+
assert [r.authorization for r in controller.requests] == [expected] * 3
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# async_get_state
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def test_get_state_returns_parsed_data(
|
|
219
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
220
|
+
) -> None:
|
|
221
|
+
controller.get(GET_STATE_PATH, body=get_state_csv, status=200)
|
|
222
|
+
async with aiohttp.ClientSession() as session:
|
|
223
|
+
state = await async_get_state(session, config)
|
|
224
|
+
assert isinstance(state, GetStateData)
|
|
225
|
+
assert state.version == "1.7.3"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# async_get_raw_dmx / async_get_dmx
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def test_get_raw_dmx(controller: FakeController, config: ConfigObject) -> None:
|
|
234
|
+
controller.get(GET_DMX_PATH, body=SIMPLE_DMX_CSV, status=200)
|
|
235
|
+
async with aiohttp.ClientSession() as session:
|
|
236
|
+
result = await async_get_raw_dmx(session, config)
|
|
237
|
+
assert result == SIMPLE_DMX_CSV
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def test_get_dmx_returns_parsed_data(
|
|
241
|
+
controller: FakeController, config: ConfigObject
|
|
242
|
+
) -> None:
|
|
243
|
+
controller.get(GET_DMX_PATH, body=SIMPLE_DMX_CSV, status=200)
|
|
244
|
+
async with aiohttp.ClientSession() as session:
|
|
245
|
+
dmx = await async_get_dmx(session, config)
|
|
246
|
+
assert isinstance(dmx, GetDmxData)
|
|
247
|
+
assert dmx.get_value(0) == 0
|
|
248
|
+
assert dmx.get_value(15) == 150
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def test_get_dmx_401_raises_bad_credentials(
|
|
252
|
+
controller: FakeController, config: ConfigObject
|
|
253
|
+
) -> None:
|
|
254
|
+
controller.get(GET_DMX_PATH, status=401)
|
|
255
|
+
async with aiohttp.ClientSession() as session:
|
|
256
|
+
with pytest.raises(BadCredentialsException):
|
|
257
|
+
await async_get_dmx(session, config)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# async_switch_on / async_switch_off / async_set_auto_mode
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def test_switch_on_sends_post(
|
|
266
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
267
|
+
) -> None:
|
|
268
|
+
state = GetStateData(get_state_csv)
|
|
269
|
+
relay = state.get_relay(0) # not a dosage relay
|
|
270
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
271
|
+
async with aiohttp.ClientSession() as session:
|
|
272
|
+
result = await async_switch_on(session, config, state, relay)
|
|
273
|
+
assert result == "ok"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def test_switch_on_dosage_relay_raises_bad_relay(
|
|
277
|
+
config: ConfigObject, get_state_csv: str
|
|
278
|
+
) -> None:
|
|
279
|
+
from proconip.definitions import BadRelayException
|
|
280
|
+
|
|
281
|
+
state = GetStateData(get_state_csv)
|
|
282
|
+
# chlorine_dosage_relay_id = 5 from fixture SYSINFO
|
|
283
|
+
dosage_relay = state.get_relay(5)
|
|
284
|
+
async with aiohttp.ClientSession() as session:
|
|
285
|
+
with pytest.raises(BadRelayException):
|
|
286
|
+
await async_switch_on(session, config, state, dosage_relay)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def test_switch_off_sends_post(
|
|
290
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
291
|
+
) -> None:
|
|
292
|
+
state = GetStateData(get_state_csv)
|
|
293
|
+
relay = state.get_relay(0)
|
|
294
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
295
|
+
async with aiohttp.ClientSession() as session:
|
|
296
|
+
result = await async_switch_off(session, config, state, relay)
|
|
297
|
+
assert result == "ok"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def test_set_auto_mode_sends_post(
|
|
301
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
302
|
+
) -> None:
|
|
303
|
+
state = GetStateData(get_state_csv)
|
|
304
|
+
relay = state.get_relay(0)
|
|
305
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
306
|
+
async with aiohttp.ClientSession() as session:
|
|
307
|
+
result = await async_set_auto_mode(session, config, state, relay)
|
|
308
|
+
assert result == "ok"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def test_switch_on_401_raises_bad_credentials(
|
|
312
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
313
|
+
) -> None:
|
|
314
|
+
state = GetStateData(get_state_csv)
|
|
315
|
+
relay = state.get_relay(0)
|
|
316
|
+
controller.post(USRCFG_PATH, status=401)
|
|
317
|
+
async with aiohttp.ClientSession() as session:
|
|
318
|
+
with pytest.raises(BadCredentialsException):
|
|
319
|
+
await async_switch_on(session, config, state, relay)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# async_start_dosage
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def test_start_dosage_chlorine(controller: FakeController, config: ConfigObject) -> None:
|
|
328
|
+
expected_path = "/Command.htm?MAN_DOSAGE=0,60"
|
|
329
|
+
controller.get(expected_path, body="ok", status=200)
|
|
330
|
+
async with aiohttp.ClientSession() as session:
|
|
331
|
+
result = await async_start_dosage(session, config, DosageTarget.CHLORINE, 60)
|
|
332
|
+
assert result == "ok"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def test_start_dosage_ph_minus(controller: FakeController, config: ConfigObject) -> None:
|
|
336
|
+
expected_path = "/Command.htm?MAN_DOSAGE=1,120"
|
|
337
|
+
controller.get(expected_path, body="ok", status=200)
|
|
338
|
+
async with aiohttp.ClientSession() as session:
|
|
339
|
+
result = await async_start_dosage(session, config, DosageTarget.PH_MINUS, 120)
|
|
340
|
+
assert result == "ok"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
# async_set_dmx
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def test_set_dmx_sends_post(
|
|
349
|
+
controller: FakeController, config: ConfigObject, get_dmx_csv: str
|
|
350
|
+
) -> None:
|
|
351
|
+
dmx = GetDmxData(get_dmx_csv)
|
|
352
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
353
|
+
async with aiohttp.ClientSession() as session:
|
|
354
|
+
result = await async_set_dmx(session, config, dmx)
|
|
355
|
+
assert result == "ok"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# async_trigger_digital_input
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@pytest.mark.parametrize(
|
|
364
|
+
("digital_input_id", "expected_mask"),
|
|
365
|
+
[(0, 1), (1, 2), (2, 4), (3, 8)],
|
|
366
|
+
)
|
|
367
|
+
async def test_trigger_digital_input_sends_posts(
|
|
368
|
+
controller: FakeController, config: ConfigObject, digital_input_id: int, expected_mask: int
|
|
369
|
+
) -> None:
|
|
370
|
+
controller.post(USRCFG_PATH, body="press-ok", status=200)
|
|
371
|
+
controller.post(USRCFG_PATH, body="release-ok", status=200)
|
|
372
|
+
async with aiohttp.ClientSession() as session:
|
|
373
|
+
result = await async_trigger_digital_input(
|
|
374
|
+
session, config, digital_input_id, hold_seconds=0
|
|
375
|
+
)
|
|
376
|
+
posts = controller.posts_to(USRCFG_PATH)
|
|
377
|
+
assert len(posts) == 2
|
|
378
|
+
assert posts[0].body == f"IO={expected_mask}&WEBIO=1"
|
|
379
|
+
assert posts[1].body == "IO=0&WEBIO=1"
|
|
380
|
+
# The function returns the *release* response body.
|
|
381
|
+
assert result == "release-ok"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@pytest.mark.parametrize("digital_input_id", [-1, 4, 99])
|
|
385
|
+
async def test_trigger_digital_input_invalid_id_raises(
|
|
386
|
+
controller: FakeController, config: ConfigObject, digital_input_id: int
|
|
387
|
+
) -> None:
|
|
388
|
+
async with aiohttp.ClientSession() as session:
|
|
389
|
+
with pytest.raises(ValueError):
|
|
390
|
+
await async_trigger_digital_input(session, config, digital_input_id)
|
|
391
|
+
assert controller.posts_to(USRCFG_PATH) == []
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def test_trigger_digital_input_401_raises_bad_credentials(
|
|
395
|
+
controller: FakeController, config: ConfigObject
|
|
396
|
+
) -> None:
|
|
397
|
+
controller.post(USRCFG_PATH, status=401)
|
|
398
|
+
async with aiohttp.ClientSession() as session:
|
|
399
|
+
with pytest.raises(BadCredentialsException):
|
|
400
|
+
await async_trigger_digital_input(session, config, 0)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def test_trigger_digital_input_holds_high_between_posts(
|
|
404
|
+
controller: FakeController, config: ConfigObject
|
|
405
|
+
) -> None:
|
|
406
|
+
"""By default the input is held HIGH ~600ms between press and release.
|
|
407
|
+
|
|
408
|
+
Mirrors the controller's web UI, which waits before clearing the bit.
|
|
409
|
+
asyncio.sleep is patched so the test stays fast while proving the hold
|
|
410
|
+
happens *between* the two POSTs (one POST recorded when sleep fires).
|
|
411
|
+
"""
|
|
412
|
+
posts_at_sleep: list[int] = []
|
|
413
|
+
|
|
414
|
+
async def record_sleep(delay: float) -> None:
|
|
415
|
+
posts_at_sleep.append(len(controller.posts_to(USRCFG_PATH)))
|
|
416
|
+
assert delay == 0.6
|
|
417
|
+
|
|
418
|
+
controller.post(USRCFG_PATH, body="press-ok", status=200)
|
|
419
|
+
controller.post(USRCFG_PATH, body="release-ok", status=200)
|
|
420
|
+
with patch("proconip.api.asyncio.sleep", side_effect=_sleep_spy(record_sleep)):
|
|
421
|
+
async with aiohttp.ClientSession() as session:
|
|
422
|
+
result = await async_trigger_digital_input(session, config, 0)
|
|
423
|
+
|
|
424
|
+
assert result == "release-ok"
|
|
425
|
+
assert len(controller.posts_to(USRCFG_PATH)) == 2
|
|
426
|
+
# sleep fired exactly once, after the press POST and before the release.
|
|
427
|
+
assert posts_at_sleep == [1]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def test_trigger_digital_input_releases_on_cancel(
|
|
431
|
+
controller: FakeController, config: ConfigObject
|
|
432
|
+
) -> None:
|
|
433
|
+
"""A cancelled hold still attempts a best-effort release, then re-raises.
|
|
434
|
+
|
|
435
|
+
Otherwise a press would set the bit and the cancellation would leave the
|
|
436
|
+
input asserted HIGH with no release.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
async def cancel_during_hold(delay: float) -> None:
|
|
440
|
+
raise asyncio.CancelledError
|
|
441
|
+
|
|
442
|
+
controller.post(USRCFG_PATH, body="press-ok", status=200)
|
|
443
|
+
controller.post(USRCFG_PATH, body="release-ok", status=200)
|
|
444
|
+
with patch("proconip.api.asyncio.sleep", side_effect=_sleep_spy(cancel_during_hold)):
|
|
445
|
+
async with aiohttp.ClientSession() as session:
|
|
446
|
+
with pytest.raises(asyncio.CancelledError):
|
|
447
|
+
await async_trigger_digital_input(session, config, 0)
|
|
448
|
+
posts = controller.posts_to(USRCFG_PATH)
|
|
449
|
+
assert [p.body for p in posts] == ["IO=1&WEBIO=1", "IO=0&WEBIO=1"]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_digital_input_count_is_exported_from_package() -> None:
|
|
453
|
+
from proconip import DIGITAL_INPUT_COUNT
|
|
454
|
+
|
|
455
|
+
assert DIGITAL_INPUT_COUNT == 4
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# OO class wrappers
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def test_get_state_class_get_raw_state(
|
|
464
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
465
|
+
) -> None:
|
|
466
|
+
controller.get(GET_STATE_PATH, body=get_state_csv, status=200)
|
|
467
|
+
async with aiohttp.ClientSession() as session:
|
|
468
|
+
api = GetState(session, config)
|
|
469
|
+
raw = await api.async_get_raw_state()
|
|
470
|
+
assert raw == get_state_csv
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def test_get_state_class_get_state(
|
|
474
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
475
|
+
) -> None:
|
|
476
|
+
controller.get(GET_STATE_PATH, body=get_state_csv, status=200)
|
|
477
|
+
async with aiohttp.ClientSession() as session:
|
|
478
|
+
api = GetState(session, config)
|
|
479
|
+
state = await api.async_get_state()
|
|
480
|
+
assert isinstance(state, GetStateData)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
async def test_relay_switch_class(
|
|
484
|
+
controller: FakeController, config: ConfigObject, get_state_csv: str
|
|
485
|
+
) -> None:
|
|
486
|
+
state = GetStateData(get_state_csv)
|
|
487
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
488
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
489
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
490
|
+
async with aiohttp.ClientSession() as session:
|
|
491
|
+
rs = RelaySwitch(session, config)
|
|
492
|
+
await rs.async_switch_on(state, 0)
|
|
493
|
+
await rs.async_switch_off(state, 0)
|
|
494
|
+
await rs.async_set_auto_mode(state, 0)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def test_dosage_control_class(controller: FakeController, config: ConfigObject) -> None:
|
|
498
|
+
cmd_url_chlorine = "/Command.htm?MAN_DOSAGE=0,60"
|
|
499
|
+
cmd_url_ph_minus = "/Command.htm?MAN_DOSAGE=1,30"
|
|
500
|
+
cmd_url_ph_plus = "/Command.htm?MAN_DOSAGE=2,45"
|
|
501
|
+
controller.get(cmd_url_chlorine, body="ok", status=200)
|
|
502
|
+
controller.get(cmd_url_ph_minus, body="ok", status=200)
|
|
503
|
+
controller.get(cmd_url_ph_plus, body="ok", status=200)
|
|
504
|
+
async with aiohttp.ClientSession() as session:
|
|
505
|
+
dc = DosageControl(session, config)
|
|
506
|
+
await dc.async_chlorine_dosage(60)
|
|
507
|
+
await dc.async_ph_minus_dosage(30)
|
|
508
|
+
await dc.async_ph_plus_dosage(45)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def test_dmx_control_class(
|
|
512
|
+
controller: FakeController, config: ConfigObject, get_dmx_csv: str
|
|
513
|
+
) -> None:
|
|
514
|
+
dmx = GetDmxData(get_dmx_csv)
|
|
515
|
+
controller.get(GET_DMX_PATH, body=SIMPLE_DMX_CSV, status=200)
|
|
516
|
+
controller.get(GET_DMX_PATH, body=SIMPLE_DMX_CSV, status=200)
|
|
517
|
+
controller.post(USRCFG_PATH, body="ok", status=200)
|
|
518
|
+
async with aiohttp.ClientSession() as session:
|
|
519
|
+
dc = DmxControl(session, config)
|
|
520
|
+
raw = await dc.async_get_raw_dmx()
|
|
521
|
+
parsed = await dc.async_get_dmx()
|
|
522
|
+
await dc.async_set(dmx)
|
|
523
|
+
assert raw == SIMPLE_DMX_CSV
|
|
524
|
+
assert isinstance(parsed, GetDmxData)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def test_digital_input_control_class(
|
|
528
|
+
controller: FakeController, config: ConfigObject
|
|
529
|
+
) -> None:
|
|
530
|
+
controller.post(USRCFG_PATH, body="press-ok", status=200)
|
|
531
|
+
controller.post(USRCFG_PATH, body="release-ok", status=200)
|
|
532
|
+
async with aiohttp.ClientSession() as session:
|
|
533
|
+
dic = DigitalInputControl(session, config)
|
|
534
|
+
result = await dic.async_trigger(2, hold_seconds=0)
|
|
535
|
+
posts = controller.posts_to(USRCFG_PATH)
|
|
536
|
+
assert [p.body for p in posts] == ["IO=4&WEBIO=1", "IO=0&WEBIO=1"]
|
|
537
|
+
assert result == "release-ok"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
# Exception hierarchy
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def test_exception_hierarchy() -> None:
|
|
546
|
+
assert issubclass(BadCredentialsException, ProconipApiException)
|
|
547
|
+
assert issubclass(BadStatusCodeException, ProconipApiException)
|
|
548
|
+
assert issubclass(TimeoutException, ProconipApiException)
|
proconip-2.2.0/tests/test_api.py
DELETED
|
@@ -1,431 +0,0 @@
|
|
|
1
|
-
"""Tests for the API module — HTTP layer, error mapping, and class wrappers."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from unittest.mock import patch
|
|
5
|
-
|
|
6
|
-
import aiohttp
|
|
7
|
-
import pytest
|
|
8
|
-
from aioresponses import aioresponses
|
|
9
|
-
|
|
10
|
-
from proconip.api import (
|
|
11
|
-
BadCredentialsException,
|
|
12
|
-
BadStatusCodeException,
|
|
13
|
-
DigitalInputControl,
|
|
14
|
-
DmxControl,
|
|
15
|
-
DosageControl,
|
|
16
|
-
GetState,
|
|
17
|
-
ProconipApiException,
|
|
18
|
-
RelaySwitch,
|
|
19
|
-
TimeoutException,
|
|
20
|
-
async_get_dmx,
|
|
21
|
-
async_get_raw_dmx,
|
|
22
|
-
async_get_raw_state,
|
|
23
|
-
async_get_state,
|
|
24
|
-
async_set_auto_mode,
|
|
25
|
-
async_set_dmx,
|
|
26
|
-
async_start_dosage,
|
|
27
|
-
async_switch_off,
|
|
28
|
-
async_switch_on,
|
|
29
|
-
async_trigger_digital_input,
|
|
30
|
-
)
|
|
31
|
-
from proconip.definitions import ConfigObject, DosageTarget, GetDmxData, GetStateData
|
|
32
|
-
|
|
33
|
-
BASE_URL = "http://127.0.0.1"
|
|
34
|
-
GET_STATE_URL = f"{BASE_URL}/GetState.csv"
|
|
35
|
-
USRCFG_URL = f"{BASE_URL}/usrcfg.cgi"
|
|
36
|
-
GET_DMX_URL = f"{BASE_URL}/GetDmx.csv"
|
|
37
|
-
|
|
38
|
-
SIMPLE_DMX_CSV = "0,10,20,30,40,50,60,70,80,90,100,110,120,130,140,150\n"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# ---------------------------------------------------------------------------
|
|
42
|
-
# async_get_raw_state
|
|
43
|
-
# ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
async def test_get_raw_state_happy_path(config: ConfigObject) -> None:
|
|
47
|
-
with aioresponses() as m:
|
|
48
|
-
m.get(GET_STATE_URL, body="raw_csv_response", status=200)
|
|
49
|
-
async with aiohttp.ClientSession() as session:
|
|
50
|
-
result = await async_get_raw_state(session, config)
|
|
51
|
-
assert result == "raw_csv_response"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
async def test_get_raw_state_401_raises_bad_credentials(config: ConfigObject) -> None:
|
|
55
|
-
with aioresponses() as m:
|
|
56
|
-
m.get(GET_STATE_URL, status=401)
|
|
57
|
-
async with aiohttp.ClientSession() as session:
|
|
58
|
-
with pytest.raises(BadCredentialsException):
|
|
59
|
-
await async_get_raw_state(session, config)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
async def test_get_raw_state_403_raises_bad_credentials(config: ConfigObject) -> None:
|
|
63
|
-
with aioresponses() as m:
|
|
64
|
-
m.get(GET_STATE_URL, status=403)
|
|
65
|
-
async with aiohttp.ClientSession() as session:
|
|
66
|
-
with pytest.raises(BadCredentialsException):
|
|
67
|
-
await async_get_raw_state(session, config)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
async def test_get_raw_state_500_raises_bad_status_code(config: ConfigObject) -> None:
|
|
71
|
-
with aioresponses() as m:
|
|
72
|
-
m.get(GET_STATE_URL, status=500)
|
|
73
|
-
async with aiohttp.ClientSession() as session:
|
|
74
|
-
with pytest.raises(BadStatusCodeException):
|
|
75
|
-
await async_get_raw_state(session, config)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
async def test_get_raw_state_timeout_raises_timeout_exception(config: ConfigObject) -> None:
|
|
79
|
-
with aioresponses() as m:
|
|
80
|
-
m.get(GET_STATE_URL, exception=TimeoutError())
|
|
81
|
-
async with aiohttp.ClientSession() as session:
|
|
82
|
-
with pytest.raises(TimeoutException):
|
|
83
|
-
await async_get_raw_state(session, config)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
async def test_get_raw_state_connection_error_raises_api_exception(config: ConfigObject) -> None:
|
|
87
|
-
with aioresponses() as m:
|
|
88
|
-
m.get(GET_STATE_URL, exception=aiohttp.ClientConnectionError("conn failed"))
|
|
89
|
-
async with aiohttp.ClientSession() as session:
|
|
90
|
-
with pytest.raises(ProconipApiException):
|
|
91
|
-
await async_get_raw_state(session, config)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# ---------------------------------------------------------------------------
|
|
95
|
-
# async_get_state
|
|
96
|
-
# ---------------------------------------------------------------------------
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
async def test_get_state_returns_parsed_data(config: ConfigObject, get_state_csv: str) -> None:
|
|
100
|
-
with aioresponses() as m:
|
|
101
|
-
m.get(GET_STATE_URL, body=get_state_csv, status=200)
|
|
102
|
-
async with aiohttp.ClientSession() as session:
|
|
103
|
-
state = await async_get_state(session, config)
|
|
104
|
-
assert isinstance(state, GetStateData)
|
|
105
|
-
assert state.version == "1.7.3"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# ---------------------------------------------------------------------------
|
|
109
|
-
# async_get_raw_dmx / async_get_dmx
|
|
110
|
-
# ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
async def test_get_raw_dmx(config: ConfigObject) -> None:
|
|
114
|
-
with aioresponses() as m:
|
|
115
|
-
m.get(GET_DMX_URL, body=SIMPLE_DMX_CSV, status=200)
|
|
116
|
-
async with aiohttp.ClientSession() as session:
|
|
117
|
-
result = await async_get_raw_dmx(session, config)
|
|
118
|
-
assert result == SIMPLE_DMX_CSV
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
async def test_get_dmx_returns_parsed_data(config: ConfigObject) -> None:
|
|
122
|
-
with aioresponses() as m:
|
|
123
|
-
m.get(GET_DMX_URL, body=SIMPLE_DMX_CSV, status=200)
|
|
124
|
-
async with aiohttp.ClientSession() as session:
|
|
125
|
-
dmx = await async_get_dmx(session, config)
|
|
126
|
-
assert isinstance(dmx, GetDmxData)
|
|
127
|
-
assert dmx.get_value(0) == 0
|
|
128
|
-
assert dmx.get_value(15) == 150
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
async def test_get_dmx_401_raises_bad_credentials(config: ConfigObject) -> None:
|
|
132
|
-
with aioresponses() as m:
|
|
133
|
-
m.get(GET_DMX_URL, status=401)
|
|
134
|
-
async with aiohttp.ClientSession() as session:
|
|
135
|
-
with pytest.raises(BadCredentialsException):
|
|
136
|
-
await async_get_dmx(session, config)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# ---------------------------------------------------------------------------
|
|
140
|
-
# async_switch_on / async_switch_off / async_set_auto_mode
|
|
141
|
-
# ---------------------------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
async def test_switch_on_sends_post(config: ConfigObject, get_state_csv: str) -> None:
|
|
145
|
-
state = GetStateData(get_state_csv)
|
|
146
|
-
relay = state.get_relay(0) # not a dosage relay
|
|
147
|
-
with aioresponses() as m:
|
|
148
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
149
|
-
async with aiohttp.ClientSession() as session:
|
|
150
|
-
result = await async_switch_on(session, config, state, relay)
|
|
151
|
-
assert result == "ok"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
async def test_switch_on_dosage_relay_raises_bad_relay(
|
|
155
|
-
config: ConfigObject, get_state_csv: str
|
|
156
|
-
) -> None:
|
|
157
|
-
from proconip.definitions import BadRelayException
|
|
158
|
-
|
|
159
|
-
state = GetStateData(get_state_csv)
|
|
160
|
-
# chlorine_dosage_relay_id = 5 from fixture SYSINFO
|
|
161
|
-
dosage_relay = state.get_relay(5)
|
|
162
|
-
async with aiohttp.ClientSession() as session:
|
|
163
|
-
with pytest.raises(BadRelayException):
|
|
164
|
-
await async_switch_on(session, config, state, dosage_relay)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
async def test_switch_off_sends_post(config: ConfigObject, get_state_csv: str) -> None:
|
|
168
|
-
state = GetStateData(get_state_csv)
|
|
169
|
-
relay = state.get_relay(0)
|
|
170
|
-
with aioresponses() as m:
|
|
171
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
172
|
-
async with aiohttp.ClientSession() as session:
|
|
173
|
-
result = await async_switch_off(session, config, state, relay)
|
|
174
|
-
assert result == "ok"
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
async def test_set_auto_mode_sends_post(config: ConfigObject, get_state_csv: str) -> None:
|
|
178
|
-
state = GetStateData(get_state_csv)
|
|
179
|
-
relay = state.get_relay(0)
|
|
180
|
-
with aioresponses() as m:
|
|
181
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
182
|
-
async with aiohttp.ClientSession() as session:
|
|
183
|
-
result = await async_set_auto_mode(session, config, state, relay)
|
|
184
|
-
assert result == "ok"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
async def test_switch_on_401_raises_bad_credentials(
|
|
188
|
-
config: ConfigObject, get_state_csv: str
|
|
189
|
-
) -> None:
|
|
190
|
-
state = GetStateData(get_state_csv)
|
|
191
|
-
relay = state.get_relay(0)
|
|
192
|
-
with aioresponses() as m:
|
|
193
|
-
m.post(USRCFG_URL, status=401)
|
|
194
|
-
async with aiohttp.ClientSession() as session:
|
|
195
|
-
with pytest.raises(BadCredentialsException):
|
|
196
|
-
await async_switch_on(session, config, state, relay)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
# ---------------------------------------------------------------------------
|
|
200
|
-
# async_start_dosage
|
|
201
|
-
# ---------------------------------------------------------------------------
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
async def test_start_dosage_chlorine(config: ConfigObject) -> None:
|
|
205
|
-
expected_url = f"{BASE_URL}/Command.htm?MAN_DOSAGE=0,60"
|
|
206
|
-
with aioresponses() as m:
|
|
207
|
-
m.get(expected_url, body="ok", status=200)
|
|
208
|
-
async with aiohttp.ClientSession() as session:
|
|
209
|
-
result = await async_start_dosage(session, config, DosageTarget.CHLORINE, 60)
|
|
210
|
-
assert result == "ok"
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
async def test_start_dosage_ph_minus(config: ConfigObject) -> None:
|
|
214
|
-
expected_url = f"{BASE_URL}/Command.htm?MAN_DOSAGE=1,120"
|
|
215
|
-
with aioresponses() as m:
|
|
216
|
-
m.get(expected_url, body="ok", status=200)
|
|
217
|
-
async with aiohttp.ClientSession() as session:
|
|
218
|
-
result = await async_start_dosage(session, config, DosageTarget.PH_MINUS, 120)
|
|
219
|
-
assert result == "ok"
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
# ---------------------------------------------------------------------------
|
|
223
|
-
# async_set_dmx
|
|
224
|
-
# ---------------------------------------------------------------------------
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
async def test_set_dmx_sends_post(config: ConfigObject, get_dmx_csv: str) -> None:
|
|
228
|
-
dmx = GetDmxData(get_dmx_csv)
|
|
229
|
-
with aioresponses() as m:
|
|
230
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
231
|
-
async with aiohttp.ClientSession() as session:
|
|
232
|
-
result = await async_set_dmx(session, config, dmx)
|
|
233
|
-
assert result == "ok"
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
# ---------------------------------------------------------------------------
|
|
237
|
-
# async_trigger_digital_input
|
|
238
|
-
# ---------------------------------------------------------------------------
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def _usrcfg_posts(m: aioresponses) -> list:
|
|
242
|
-
"""Return the recorded POST calls to /usrcfg.cgi, in order."""
|
|
243
|
-
return [
|
|
244
|
-
call
|
|
245
|
-
for (method, url), calls in m.requests.items()
|
|
246
|
-
for call in calls
|
|
247
|
-
if method == "POST" and "usrcfg.cgi" in str(url)
|
|
248
|
-
]
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
@pytest.mark.parametrize(
|
|
252
|
-
("digital_input_id", "expected_mask"),
|
|
253
|
-
[(0, 1), (1, 2), (2, 4), (3, 8)],
|
|
254
|
-
)
|
|
255
|
-
async def test_trigger_digital_input_sends_posts(
|
|
256
|
-
config: ConfigObject, digital_input_id: int, expected_mask: int
|
|
257
|
-
) -> None:
|
|
258
|
-
with aioresponses() as m:
|
|
259
|
-
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
260
|
-
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
261
|
-
async with aiohttp.ClientSession() as session:
|
|
262
|
-
result = await async_trigger_digital_input(
|
|
263
|
-
session, config, digital_input_id, hold_seconds=0
|
|
264
|
-
)
|
|
265
|
-
posts = _usrcfg_posts(m)
|
|
266
|
-
assert len(posts) == 2
|
|
267
|
-
assert posts[0].kwargs["data"] == f"IO={expected_mask}&WEBIO=1"
|
|
268
|
-
assert posts[1].kwargs["data"] == "IO=0&WEBIO=1"
|
|
269
|
-
# The function returns the *release* response body.
|
|
270
|
-
assert result == "release-ok"
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
@pytest.mark.parametrize("digital_input_id", [-1, 4, 99])
|
|
274
|
-
async def test_trigger_digital_input_invalid_id_raises(
|
|
275
|
-
config: ConfigObject, digital_input_id: int
|
|
276
|
-
) -> None:
|
|
277
|
-
with aioresponses() as m:
|
|
278
|
-
async with aiohttp.ClientSession() as session:
|
|
279
|
-
with pytest.raises(ValueError):
|
|
280
|
-
await async_trigger_digital_input(session, config, digital_input_id)
|
|
281
|
-
assert _usrcfg_posts(m) == []
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
async def test_trigger_digital_input_401_raises_bad_credentials(config: ConfigObject) -> None:
|
|
285
|
-
with aioresponses() as m:
|
|
286
|
-
m.post(USRCFG_URL, status=401)
|
|
287
|
-
async with aiohttp.ClientSession() as session:
|
|
288
|
-
with pytest.raises(BadCredentialsException):
|
|
289
|
-
await async_trigger_digital_input(session, config, 0)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
async def test_trigger_digital_input_holds_high_between_posts(config: ConfigObject) -> None:
|
|
293
|
-
"""By default the input is held HIGH ~600ms between press and release.
|
|
294
|
-
|
|
295
|
-
Mirrors the controller's web UI, which waits before clearing the bit.
|
|
296
|
-
asyncio.sleep is patched so the test stays fast while proving the hold
|
|
297
|
-
happens *between* the two POSTs (one POST recorded when sleep fires).
|
|
298
|
-
"""
|
|
299
|
-
posts_at_sleep: list[int] = []
|
|
300
|
-
|
|
301
|
-
async def record_sleep(delay: float) -> None:
|
|
302
|
-
posts_at_sleep.append(len(_usrcfg_posts(m)))
|
|
303
|
-
assert delay == 0.6
|
|
304
|
-
|
|
305
|
-
with aioresponses() as m:
|
|
306
|
-
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
307
|
-
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
308
|
-
with patch("proconip.api.asyncio.sleep", side_effect=record_sleep):
|
|
309
|
-
async with aiohttp.ClientSession() as session:
|
|
310
|
-
result = await async_trigger_digital_input(session, config, 0)
|
|
311
|
-
|
|
312
|
-
assert result == "release-ok"
|
|
313
|
-
assert len(_usrcfg_posts(m)) == 2
|
|
314
|
-
# sleep fired exactly once, after the press POST and before the release.
|
|
315
|
-
assert posts_at_sleep == [1]
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
async def test_trigger_digital_input_releases_on_cancel(config: ConfigObject) -> None:
|
|
319
|
-
"""A cancelled hold still attempts a best-effort release, then re-raises.
|
|
320
|
-
|
|
321
|
-
Otherwise a press would set the bit and the cancellation would leave the
|
|
322
|
-
input asserted HIGH with no release.
|
|
323
|
-
"""
|
|
324
|
-
|
|
325
|
-
async def cancel_during_hold(delay: float) -> None:
|
|
326
|
-
raise asyncio.CancelledError
|
|
327
|
-
|
|
328
|
-
with aioresponses() as m:
|
|
329
|
-
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
330
|
-
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
331
|
-
with patch("proconip.api.asyncio.sleep", side_effect=cancel_during_hold):
|
|
332
|
-
async with aiohttp.ClientSession() as session:
|
|
333
|
-
with pytest.raises(asyncio.CancelledError):
|
|
334
|
-
await async_trigger_digital_input(session, config, 0)
|
|
335
|
-
posts = _usrcfg_posts(m)
|
|
336
|
-
assert [p.kwargs["data"] for p in posts] == ["IO=1&WEBIO=1", "IO=0&WEBIO=1"]
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def test_digital_input_count_is_exported_from_package() -> None:
|
|
340
|
-
from proconip import DIGITAL_INPUT_COUNT
|
|
341
|
-
|
|
342
|
-
assert DIGITAL_INPUT_COUNT == 4
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
# ---------------------------------------------------------------------------
|
|
346
|
-
# OO class wrappers
|
|
347
|
-
# ---------------------------------------------------------------------------
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
async def test_get_state_class_get_raw_state(config: ConfigObject, get_state_csv: str) -> None:
|
|
351
|
-
with aioresponses() as m:
|
|
352
|
-
m.get(GET_STATE_URL, body=get_state_csv, status=200)
|
|
353
|
-
async with aiohttp.ClientSession() as session:
|
|
354
|
-
api = GetState(session, config)
|
|
355
|
-
raw = await api.async_get_raw_state()
|
|
356
|
-
assert raw == get_state_csv
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
async def test_get_state_class_get_state(config: ConfigObject, get_state_csv: str) -> None:
|
|
360
|
-
with aioresponses() as m:
|
|
361
|
-
m.get(GET_STATE_URL, body=get_state_csv, status=200)
|
|
362
|
-
async with aiohttp.ClientSession() as session:
|
|
363
|
-
api = GetState(session, config)
|
|
364
|
-
state = await api.async_get_state()
|
|
365
|
-
assert isinstance(state, GetStateData)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
async def test_relay_switch_class(config: ConfigObject, get_state_csv: str) -> None:
|
|
369
|
-
state = GetStateData(get_state_csv)
|
|
370
|
-
with aioresponses() as m:
|
|
371
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
372
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
373
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
374
|
-
async with aiohttp.ClientSession() as session:
|
|
375
|
-
rs = RelaySwitch(session, config)
|
|
376
|
-
await rs.async_switch_on(state, 0)
|
|
377
|
-
await rs.async_switch_off(state, 0)
|
|
378
|
-
await rs.async_set_auto_mode(state, 0)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
async def test_dosage_control_class(config: ConfigObject) -> None:
|
|
382
|
-
cmd_url_chlorine = f"{BASE_URL}/Command.htm?MAN_DOSAGE=0,60"
|
|
383
|
-
cmd_url_ph_minus = f"{BASE_URL}/Command.htm?MAN_DOSAGE=1,30"
|
|
384
|
-
cmd_url_ph_plus = f"{BASE_URL}/Command.htm?MAN_DOSAGE=2,45"
|
|
385
|
-
with aioresponses() as m:
|
|
386
|
-
m.get(cmd_url_chlorine, body="ok", status=200)
|
|
387
|
-
m.get(cmd_url_ph_minus, body="ok", status=200)
|
|
388
|
-
m.get(cmd_url_ph_plus, body="ok", status=200)
|
|
389
|
-
async with aiohttp.ClientSession() as session:
|
|
390
|
-
dc = DosageControl(session, config)
|
|
391
|
-
await dc.async_chlorine_dosage(60)
|
|
392
|
-
await dc.async_ph_minus_dosage(30)
|
|
393
|
-
await dc.async_ph_plus_dosage(45)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
async def test_dmx_control_class(config: ConfigObject, get_dmx_csv: str) -> None:
|
|
397
|
-
dmx = GetDmxData(get_dmx_csv)
|
|
398
|
-
with aioresponses() as m:
|
|
399
|
-
m.get(GET_DMX_URL, body=SIMPLE_DMX_CSV, status=200)
|
|
400
|
-
m.get(GET_DMX_URL, body=SIMPLE_DMX_CSV, status=200)
|
|
401
|
-
m.post(USRCFG_URL, body="ok", status=200)
|
|
402
|
-
async with aiohttp.ClientSession() as session:
|
|
403
|
-
dc = DmxControl(session, config)
|
|
404
|
-
raw = await dc.async_get_raw_dmx()
|
|
405
|
-
parsed = await dc.async_get_dmx()
|
|
406
|
-
await dc.async_set(dmx)
|
|
407
|
-
assert raw == SIMPLE_DMX_CSV
|
|
408
|
-
assert isinstance(parsed, GetDmxData)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
async def test_digital_input_control_class(config: ConfigObject) -> None:
|
|
412
|
-
with aioresponses() as m:
|
|
413
|
-
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
414
|
-
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
415
|
-
async with aiohttp.ClientSession() as session:
|
|
416
|
-
dic = DigitalInputControl(session, config)
|
|
417
|
-
result = await dic.async_trigger(2, hold_seconds=0)
|
|
418
|
-
posts = _usrcfg_posts(m)
|
|
419
|
-
assert [p.kwargs["data"] for p in posts] == ["IO=4&WEBIO=1", "IO=0&WEBIO=1"]
|
|
420
|
-
assert result == "release-ok"
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
# ---------------------------------------------------------------------------
|
|
424
|
-
# Exception hierarchy
|
|
425
|
-
# ---------------------------------------------------------------------------
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def test_exception_hierarchy() -> None:
|
|
429
|
-
assert issubclass(BadCredentialsException, ProconipApiException)
|
|
430
|
-
assert issubclass(BadStatusCodeException, ProconipApiException)
|
|
431
|
-
assert issubclass(TimeoutException, ProconipApiException)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|