proconip 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.0](https://github.com/ylabonte/proconip-pypi/compare/v2.1.2...v2.2.0) (2026-06-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * add digital input triggering via WEBIO ([#93](https://github.com/ylabonte/proconip-pypi/issues/93)) ([8c35420](https://github.com/ylabonte/proconip-pypi/commit/8c354202ded3bbe389d6a53c0059b78d14337b97))
9
+
3
10
  ## [2.1.2](https://github.com/ylabonte/proconip-pypi/compare/v2.1.1...v2.1.2) (2026-06-14)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proconip
3
- Version: 2.1.2
3
+ Version: 2.2.0
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/
@@ -11,8 +11,10 @@ except ImportError: # pragma: no cover
11
11
  __version__ = "0.0.0.dev0"
12
12
 
13
13
  from .api import (
14
+ DIGITAL_INPUT_COUNT,
14
15
  BadCredentialsException,
15
16
  BadStatusCodeException,
17
+ DigitalInputControl,
16
18
  DmxControl,
17
19
  DosageControl,
18
20
  GetState,
@@ -30,6 +32,7 @@ from .api import (
30
32
  async_start_dosage,
31
33
  async_switch_off,
32
34
  async_switch_on,
35
+ async_trigger_digital_input,
33
36
  )
34
37
  from .definitions import (
35
38
  CATEGORY_ANALOG,
@@ -45,6 +48,7 @@ from .definitions import (
45
48
  BadRelayException,
46
49
  ConfigObject,
47
50
  DataObject,
51
+ DigitalInput,
48
52
  DmxChannelData,
49
53
  DosageTarget,
50
54
  GetDmxData,
@@ -67,12 +71,14 @@ __all__ = [
67
71
  # data classes
68
72
  "DataObject",
69
73
  "Relay",
74
+ "DigitalInput",
70
75
  "GetStateData",
71
76
  "DmxChannelData",
72
77
  "GetDmxData",
73
78
  # enums
74
79
  "DosageTarget",
75
80
  # constants
81
+ "DIGITAL_INPUT_COUNT",
76
82
  "EXTERNAL_RELAY_ID_OFFSET",
77
83
  "CATEGORY_TIME",
78
84
  "CATEGORY_ANALOG",
@@ -88,6 +94,7 @@ __all__ = [
88
94
  "RelaySwitch",
89
95
  "DosageControl",
90
96
  "DmxControl",
97
+ "DigitalInputControl",
91
98
  # free async functions
92
99
  "async_get_raw_data",
93
100
  "async_get_raw_state",
@@ -100,4 +107,5 @@ __all__ = [
100
107
  "async_get_raw_dmx",
101
108
  "async_get_dmx",
102
109
  "async_set_dmx",
110
+ "async_trigger_digital_input",
103
111
  ]
@@ -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.1.2'
22
- __version_tuple__ = version_tuple = (2, 1, 2)
21
+ __version__ = version = '2.2.0'
22
+ __version_tuple__ = version_tuple = (2, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -26,6 +26,7 @@ subclasses so callers can handle them uniformly.
26
26
  """
27
27
 
28
28
  import asyncio
29
+ import contextlib
29
30
  import socket
30
31
 
31
32
  from aiohttp import (
@@ -894,3 +895,148 @@ class DmxControl:
894
895
  dmx_states=data,
895
896
  timeout=self.timeout if timeout is None else timeout,
896
897
  )
898
+
899
+
900
+ DIGITAL_INPUT_COUNT = 4
901
+ DIGITAL_INPUT_PULSE_SECONDS = 0.6
902
+
903
+
904
+ async def async_trigger_digital_input(
905
+ client_session: ClientSession,
906
+ config: ConfigObject,
907
+ digital_input_id: int,
908
+ timeout: float = 10.0,
909
+ hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
910
+ ) -> str:
911
+ """Trigger (momentarily pulse) a digital input via the WEBIO ``IO`` field.
912
+
913
+ The controller's web UI exposes each digital input as a push button: it
914
+ sets the input's bit, waits, then clears it. This sends the same two
915
+ `/usrcfg.cgi` writes — ``IO=<mask>&WEBIO=1`` (press) then, after
916
+ ``hold_seconds``, ``IO=0&WEBIO=1`` (release) — where
917
+ ``mask = 1 << digital_input_id``. The hold mirrors the web UI (~600ms) so
918
+ the controller reliably registers the pulse.
919
+
920
+ Unlike relay switching, no `GetStateData` snapshot is needed: the ``IO``
921
+ field is written directly rather than read-modify-written.
922
+
923
+ Because the pulse is two separate writes, the release is not fully
924
+ guaranteed. If the hold is **cancelled** (e.g. the caller's task is shut
925
+ down), a best-effort release is attempted before the cancellation
926
+ propagates. If the **release POST itself fails** (timeout / network blip),
927
+ that error propagates and the input is left asserted HIGH until the next
928
+ write.
929
+
930
+ Args:
931
+ client_session: An open `aiohttp.ClientSession`.
932
+ config: Controller configuration including base URL and credentials.
933
+ digital_input_id: Zero-based digital input index (0–3).
934
+ timeout: Per-request timeout in seconds, applied to each of the two
935
+ POSTs.
936
+ hold_seconds: Seconds to hold the input HIGH between the press and
937
+ release POSTs. Defaults to the web UI's ~600ms.
938
+
939
+ Returns:
940
+ The raw response body returned by the release POST.
941
+
942
+ Raises:
943
+ ValueError: If ``digital_input_id`` is not in 0–3.
944
+ BadCredentialsException: On HTTP 401 or 403.
945
+ BadStatusCodeException: On any other 4xx or 5xx response.
946
+ TimeoutException: If an exchange exceeds ``timeout`` seconds.
947
+ ProconipApiException: For network-level errors.
948
+ """
949
+ if not 0 <= digital_input_id < DIGITAL_INPUT_COUNT:
950
+ raise ValueError(
951
+ f"digital_input_id must be in 0..{DIGITAL_INPUT_COUNT - 1}, got {digital_input_id}"
952
+ )
953
+ mask = 1 << digital_input_id
954
+ await async_post_usrcfg_cgi(
955
+ client_session=client_session,
956
+ config=config,
957
+ payload=f"IO={mask}&WEBIO=1",
958
+ timeout=timeout,
959
+ )
960
+ try:
961
+ await asyncio.sleep(hold_seconds)
962
+ except asyncio.CancelledError:
963
+ # Best-effort release so a cancelled hold doesn't leave the input
964
+ # asserted HIGH. Networking inside a cancellation is awkward, so this
965
+ # is a best effort, not a guarantee.
966
+ with contextlib.suppress(Exception):
967
+ await async_post_usrcfg_cgi(
968
+ client_session=client_session,
969
+ config=config,
970
+ payload="IO=0&WEBIO=1",
971
+ timeout=timeout,
972
+ )
973
+ raise
974
+ return await async_post_usrcfg_cgi(
975
+ client_session=client_session,
976
+ config=config,
977
+ payload="IO=0&WEBIO=1",
978
+ timeout=timeout,
979
+ )
980
+
981
+
982
+ class DigitalInputControl:
983
+ """Convenience wrapper that binds a session and config for triggering inputs.
984
+
985
+ Construct once with your `aiohttp.ClientSession` and `ConfigObject`, then
986
+ pulse digital inputs by zero-based id (0–3) without repeating those
987
+ arguments each time.
988
+
989
+ The constructor also binds a default `timeout` for every call made via this
990
+ wrapper. Each method then accepts an optional per-call `timeout` that
991
+ overrides the bound default when supplied.
992
+
993
+ Example:
994
+ ```python
995
+ dic = DigitalInputControl(session, config)
996
+ await dic.async_trigger(0) # pulse the first digital input
997
+ ```
998
+ """
999
+
1000
+ def __init__(
1001
+ self,
1002
+ client_session: ClientSession,
1003
+ config: ConfigObject,
1004
+ timeout: float = 10.0,
1005
+ ):
1006
+ """Bind the session, config, and default per-request timeout.
1007
+
1008
+ Args:
1009
+ client_session: An open `aiohttp.ClientSession`.
1010
+ config: Controller configuration.
1011
+ timeout: Default per-request timeout in seconds, used when a method
1012
+ is called without its own ``timeout`` argument.
1013
+ """
1014
+ self.client_session = client_session
1015
+ self.config = config
1016
+ self.timeout = timeout
1017
+
1018
+ async def async_trigger(
1019
+ self,
1020
+ digital_input_id: int,
1021
+ timeout: float | None = None,
1022
+ hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
1023
+ ) -> str:
1024
+ """Trigger the digital input identified by ``digital_input_id``.
1025
+
1026
+ Args:
1027
+ digital_input_id: Zero-based digital input index (0–3).
1028
+ timeout: Override for this call only. If ``None``, the timeout
1029
+ bound in `__init__` is used.
1030
+ hold_seconds: Seconds to hold the input HIGH between press and
1031
+ release. Defaults to the web UI's ~600ms.
1032
+
1033
+ See `async_trigger_digital_input` (the free function) for the full
1034
+ description of behavior and raised exceptions.
1035
+ """
1036
+ return await async_trigger_digital_input(
1037
+ client_session=self.client_session,
1038
+ config=self.config,
1039
+ digital_input_id=digital_input_id,
1040
+ timeout=self.timeout if timeout is None else timeout,
1041
+ hold_seconds=hold_seconds,
1042
+ )
@@ -409,6 +409,48 @@ class Relay(DataObject):
409
409
  return 1 << self._category_id
410
410
 
411
411
 
412
+ class DigitalInput(DataObject):
413
+ """Typed view of a single digital input (CSV columns 24–27).
414
+
415
+ Construct one by passing the `DataObject` you got from
416
+ `GetStateData.digital_input_objects`; the column, name, calibration, and
417
+ raw value are copied across so the physical value is computed exactly once.
418
+ ``GetStateData.digital_inputs()`` is a shorthand that does this for you.
419
+
420
+ The wrapper exists so digital inputs can be *triggered* (not just read):
421
+ `get_bit_mask` yields the bit used in the controller's WEBIO ``IO`` field.
422
+ """
423
+
424
+ def __init__(self, data_object: DataObject):
425
+ """Wrap an existing digital-input `DataObject`.
426
+
427
+ Args:
428
+ data_object: A `DataObject` produced by parsing a `/GetState.csv`
429
+ response. It should be in the digital_input category, but no
430
+ check is enforced — passing another object yields a
431
+ `DigitalInput` whose `get_bit_mask` is meaningless.
432
+ """
433
+ super().__init__(
434
+ data_object.column,
435
+ data_object.name,
436
+ data_object.unit,
437
+ data_object.offset,
438
+ data_object.gain,
439
+ data_object.raw_value, # pass raw value so offset+gain are applied exactly once
440
+ )
441
+
442
+ def get_bit_mask(self) -> int:
443
+ """Bit for this input in the WEBIO ``IO`` field (``1 << category_id``).
444
+
445
+ The four digital inputs occupy bits 0–3, so this returns 1, 2, 4, or 8.
446
+ To *trigger* an input, pass its ``category_id`` to
447
+ `async_trigger_digital_input` (which derives the same mask itself); use
448
+ this method when assembling an ``IO`` payload for a manual
449
+ `async_post_usrcfg_cgi` write.
450
+ """
451
+ return 1 << self._category_id
452
+
453
+
412
454
  class GetStateData:
413
455
  """Parsed representation of a single `/GetState.csv` response.
414
456
 
@@ -822,6 +864,14 @@ class GetStateData:
822
864
  """The four digital inputs (columns 24–27), in column order."""
823
865
  return self._digital_input_objects
824
866
 
867
+ def digital_inputs(self) -> list[DigitalInput]:
868
+ """The four digital inputs as `DigitalInput` instances.
869
+
870
+ Equivalent to wrapping each entry in `digital_input_objects` with
871
+ `DigitalInput(...)`. Use these to get `get_bit_mask()` for triggering.
872
+ """
873
+ return [DigitalInput(obj) for obj in self._digital_input_objects]
874
+
825
875
  @property
826
876
  def external_relay_objects(self) -> list[DataObject]:
827
877
  """The eight external relays (columns 28–35), in column order.
@@ -1,5 +1,8 @@
1
1
  """Tests for the API module — HTTP layer, error mapping, and class wrappers."""
2
2
 
3
+ import asyncio
4
+ from unittest.mock import patch
5
+
3
6
  import aiohttp
4
7
  import pytest
5
8
  from aioresponses import aioresponses
@@ -7,6 +10,7 @@ from aioresponses import aioresponses
7
10
  from proconip.api import (
8
11
  BadCredentialsException,
9
12
  BadStatusCodeException,
13
+ DigitalInputControl,
10
14
  DmxControl,
11
15
  DosageControl,
12
16
  GetState,
@@ -22,6 +26,7 @@ from proconip.api import (
22
26
  async_start_dosage,
23
27
  async_switch_off,
24
28
  async_switch_on,
29
+ async_trigger_digital_input,
25
30
  )
26
31
  from proconip.definitions import ConfigObject, DosageTarget, GetDmxData, GetStateData
27
32
 
@@ -228,6 +233,115 @@ async def test_set_dmx_sends_post(config: ConfigObject, get_dmx_csv: str) -> Non
228
233
  assert result == "ok"
229
234
 
230
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
+
231
345
  # ---------------------------------------------------------------------------
232
346
  # OO class wrappers
233
347
  # ---------------------------------------------------------------------------
@@ -294,6 +408,18 @@ async def test_dmx_control_class(config: ConfigObject, get_dmx_csv: str) -> None
294
408
  assert isinstance(parsed, GetDmxData)
295
409
 
296
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
+
297
423
  # ---------------------------------------------------------------------------
298
424
  # Exception hierarchy
299
425
  # ---------------------------------------------------------------------------
@@ -16,6 +16,7 @@ from proconip.definitions import (
16
16
  BadRelayException,
17
17
  ConfigObject,
18
18
  DataObject,
19
+ DigitalInput,
19
20
  DmxChannelData,
20
21
  GetDmxData,
21
22
  GetStateData,
@@ -199,6 +200,16 @@ def test_digital_input_objects(get_state_data: GetStateData) -> None:
199
200
  assert obj.column == idx + 24
200
201
 
201
202
 
203
+ def test_digital_inputs(get_state_data: GetStateData) -> None:
204
+ inputs = get_state_data.digital_inputs()
205
+ assert len(inputs) == 4
206
+ for idx, digital_input in enumerate(inputs):
207
+ assert isinstance(digital_input, DigitalInput)
208
+ assert digital_input.category == CATEGORY_DIGITAL_INPUT
209
+ assert digital_input.category_id == idx
210
+ assert digital_input.column == idx + 24
211
+
212
+
202
213
  def test_external_relay_objects(get_state_data: GetStateData) -> None:
203
214
  objs = get_state_data.external_relay_objects
204
215
  assert len(objs) == 8
@@ -327,6 +338,38 @@ def test_relay_str(get_state_data: GetStateData) -> None:
327
338
  assert "Terassenlicht" in str(relay)
328
339
 
329
340
 
341
+ # ---------------------------------------------------------------------------
342
+ # DigitalInput
343
+ # ---------------------------------------------------------------------------
344
+
345
+
346
+ @pytest.mark.parametrize(
347
+ ("digital_input_id", "expected_mask"),
348
+ [(0, 1), (1, 2), (2, 4), (3, 8)],
349
+ )
350
+ def test_digital_input_get_bit_mask(
351
+ get_state_data: GetStateData, digital_input_id: int, expected_mask: int
352
+ ) -> None:
353
+ digital_input = get_state_data.digital_inputs()[digital_input_id]
354
+ assert digital_input.get_bit_mask() == expected_mask
355
+
356
+
357
+ def test_digital_input_value_not_double_applied() -> None:
358
+ """DigitalInput must apply offset+gain exactly once, like Relay.
359
+
360
+ Mirrors `test_relay_value_not_double_applied`: a non-trivial offset would
361
+ expose a double-application bug. column 24 is the first digital input.
362
+ """
363
+ obj = DataObject(column=24, name="test", unit="--", offset=1.0, gain=1.0, value=1.0)
364
+ assert obj.value == pytest.approx(2.0)
365
+
366
+ digital_input = DigitalInput(obj)
367
+ assert digital_input.value == pytest.approx(2.0)
368
+ assert digital_input.raw_value == pytest.approx(1.0)
369
+ assert digital_input.category == CATEGORY_DIGITAL_INPUT
370
+ assert digital_input.category_id == 0
371
+
372
+
330
373
  # ---------------------------------------------------------------------------
331
374
  # DmxChannelData
332
375
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes