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.
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: proconip
3
- Version: 2.2.0
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) # start for 1 hour
180
- await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
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) # start for 1 hour
152
- await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
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
- "mkdocs",
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.0'
22
- __version_tuple__ = version_tuple = (2, 2, 0)
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, auth=auth) as response:
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
- auth = BasicAuth(config.username, config.password)
304
- headers = {"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
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 is enabled in the controller config (bit 0)."""
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 logging is enabled in the controller config (bit 1)."""
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 mode is enabled in the controller config (bit 5)."""
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)
@@ -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