proconip 2.1.2__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,19 @@
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
+
10
+ ## [2.2.0](https://github.com/ylabonte/proconip-pypi/compare/v2.1.2...v2.2.0) (2026-06-14)
11
+
12
+
13
+ ### Features
14
+
15
+ * add digital input triggering via WEBIO ([#93](https://github.com/ylabonte/proconip-pypi/issues/93)) ([8c35420](https://github.com/ylabonte/proconip-pypi/commit/8c354202ded3bbe389d6a53c0059b78d14337b97))
16
+
3
17
  ## [2.1.2](https://github.com/ylabonte/proconip-pypi/compare/v2.1.1...v2.1.2) (2026-06-14)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: proconip
3
- Version: 2.1.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) # 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",
@@ -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.1'
22
+ __version_tuple__ = version_tuple = (2, 2, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -26,10 +26,11 @@ subclasses so callers can handle them uniformly.
26
26
  """
27
27
 
28
28
  import asyncio
29
+ import base64
30
+ import contextlib
29
31
  import socket
30
32
 
31
33
  from aiohttp import (
32
- BasicAuth,
33
34
  ClientError,
34
35
  ClientResponse,
35
36
  ClientSession,
@@ -83,6 +84,18 @@ class TimeoutException(ProconipApiException):
83
84
  """
84
85
 
85
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
+
86
99
  async def _handle_response(response: ClientResponse) -> str:
87
100
  """Validate the response and return its body, mapping HTTP errors to typed exceptions."""
88
101
  if response.status in (401, 403):
@@ -129,10 +142,9 @@ async def async_get_raw_data(
129
142
  ProconipApiException: For DNS failures, connection resets, and other
130
143
  network-level errors.
131
144
  """
132
- auth = BasicAuth(config.username, config.password)
133
145
  try:
134
146
  async with asyncio.timeout(timeout):
135
- async with client_session.get(url, auth=auth) as response:
147
+ async with client_session.get(url, headers=_auth_headers(config)) as response:
136
148
  return await _handle_response(response)
137
149
  except TimeoutError as exc:
138
150
  raise TimeoutException("API request timed out") from exc
@@ -299,15 +311,16 @@ async def async_post_usrcfg_cgi(
299
311
  ProconipApiException: For network-level errors.
300
312
  """
301
313
  url = URL(config.base_url).with_path(API_PATH_USRCFG)
302
- auth = BasicAuth(config.username, config.password)
303
- 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
+ }
304
318
  try:
305
319
  async with asyncio.timeout(timeout):
306
320
  async with client_session.post(
307
321
  url=url,
308
322
  headers=headers,
309
323
  data=payload,
310
- auth=auth,
311
324
  ) as response:
312
325
  return await _handle_response(response)
313
326
  except TimeoutError as exc:
@@ -894,3 +907,148 @@ class DmxControl:
894
907
  dmx_states=data,
895
908
  timeout=self.timeout if timeout is None else timeout,
896
909
  )
910
+
911
+
912
+ DIGITAL_INPUT_COUNT = 4
913
+ DIGITAL_INPUT_PULSE_SECONDS = 0.6
914
+
915
+
916
+ async def async_trigger_digital_input(
917
+ client_session: ClientSession,
918
+ config: ConfigObject,
919
+ digital_input_id: int,
920
+ timeout: float = 10.0,
921
+ hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
922
+ ) -> str:
923
+ """Trigger (momentarily pulse) a digital input via the WEBIO ``IO`` field.
924
+
925
+ The controller's web UI exposes each digital input as a push button: it
926
+ sets the input's bit, waits, then clears it. This sends the same two
927
+ `/usrcfg.cgi` writes — ``IO=<mask>&WEBIO=1`` (press) then, after
928
+ ``hold_seconds``, ``IO=0&WEBIO=1`` (release) — where
929
+ ``mask = 1 << digital_input_id``. The hold mirrors the web UI (~600ms) so
930
+ the controller reliably registers the pulse.
931
+
932
+ Unlike relay switching, no `GetStateData` snapshot is needed: the ``IO``
933
+ field is written directly rather than read-modify-written.
934
+
935
+ Because the pulse is two separate writes, the release is not fully
936
+ guaranteed. If the hold is **cancelled** (e.g. the caller's task is shut
937
+ down), a best-effort release is attempted before the cancellation
938
+ propagates. If the **release POST itself fails** (timeout / network blip),
939
+ that error propagates and the input is left asserted HIGH until the next
940
+ write.
941
+
942
+ Args:
943
+ client_session: An open `aiohttp.ClientSession`.
944
+ config: Controller configuration including base URL and credentials.
945
+ digital_input_id: Zero-based digital input index (0–3).
946
+ timeout: Per-request timeout in seconds, applied to each of the two
947
+ POSTs.
948
+ hold_seconds: Seconds to hold the input HIGH between the press and
949
+ release POSTs. Defaults to the web UI's ~600ms.
950
+
951
+ Returns:
952
+ The raw response body returned by the release POST.
953
+
954
+ Raises:
955
+ ValueError: If ``digital_input_id`` is not in 0–3.
956
+ BadCredentialsException: On HTTP 401 or 403.
957
+ BadStatusCodeException: On any other 4xx or 5xx response.
958
+ TimeoutException: If an exchange exceeds ``timeout`` seconds.
959
+ ProconipApiException: For network-level errors.
960
+ """
961
+ if not 0 <= digital_input_id < DIGITAL_INPUT_COUNT:
962
+ raise ValueError(
963
+ f"digital_input_id must be in 0..{DIGITAL_INPUT_COUNT - 1}, got {digital_input_id}"
964
+ )
965
+ mask = 1 << digital_input_id
966
+ await async_post_usrcfg_cgi(
967
+ client_session=client_session,
968
+ config=config,
969
+ payload=f"IO={mask}&WEBIO=1",
970
+ timeout=timeout,
971
+ )
972
+ try:
973
+ await asyncio.sleep(hold_seconds)
974
+ except asyncio.CancelledError:
975
+ # Best-effort release so a cancelled hold doesn't leave the input
976
+ # asserted HIGH. Networking inside a cancellation is awkward, so this
977
+ # is a best effort, not a guarantee.
978
+ with contextlib.suppress(Exception):
979
+ await async_post_usrcfg_cgi(
980
+ client_session=client_session,
981
+ config=config,
982
+ payload="IO=0&WEBIO=1",
983
+ timeout=timeout,
984
+ )
985
+ raise
986
+ return await async_post_usrcfg_cgi(
987
+ client_session=client_session,
988
+ config=config,
989
+ payload="IO=0&WEBIO=1",
990
+ timeout=timeout,
991
+ )
992
+
993
+
994
+ class DigitalInputControl:
995
+ """Convenience wrapper that binds a session and config for triggering inputs.
996
+
997
+ Construct once with your `aiohttp.ClientSession` and `ConfigObject`, then
998
+ pulse digital inputs by zero-based id (0–3) without repeating those
999
+ arguments each time.
1000
+
1001
+ The constructor also binds a default `timeout` for every call made via this
1002
+ wrapper. Each method then accepts an optional per-call `timeout` that
1003
+ overrides the bound default when supplied.
1004
+
1005
+ Example:
1006
+ ```python
1007
+ dic = DigitalInputControl(session, config)
1008
+ await dic.async_trigger(0) # pulse the first digital input
1009
+ ```
1010
+ """
1011
+
1012
+ def __init__(
1013
+ self,
1014
+ client_session: ClientSession,
1015
+ config: ConfigObject,
1016
+ timeout: float = 10.0,
1017
+ ):
1018
+ """Bind the session, config, and default per-request timeout.
1019
+
1020
+ Args:
1021
+ client_session: An open `aiohttp.ClientSession`.
1022
+ config: Controller configuration.
1023
+ timeout: Default per-request timeout in seconds, used when a method
1024
+ is called without its own ``timeout`` argument.
1025
+ """
1026
+ self.client_session = client_session
1027
+ self.config = config
1028
+ self.timeout = timeout
1029
+
1030
+ async def async_trigger(
1031
+ self,
1032
+ digital_input_id: int,
1033
+ timeout: float | None = None,
1034
+ hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
1035
+ ) -> str:
1036
+ """Trigger the digital input identified by ``digital_input_id``.
1037
+
1038
+ Args:
1039
+ digital_input_id: Zero-based digital input index (0–3).
1040
+ timeout: Override for this call only. If ``None``, the timeout
1041
+ bound in `__init__` is used.
1042
+ hold_seconds: Seconds to hold the input HIGH between press and
1043
+ release. Defaults to the web UI's ~600ms.
1044
+
1045
+ See `async_trigger_digital_input` (the free function) for the full
1046
+ description of behavior and raised exceptions.
1047
+ """
1048
+ return await async_trigger_digital_input(
1049
+ client_session=self.client_session,
1050
+ config=self.config,
1051
+ digital_input_id=digital_input_id,
1052
+ timeout=self.timeout if timeout is None else timeout,
1053
+ hold_seconds=hold_seconds,
1054
+ )
@@ -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
 
@@ -543,7 +585,13 @@ class GetStateData:
543
585
  @property
544
586
  def config_other_enable(self) -> int:
545
587
  """Misc configuration flags. Use the `is_*_enabled` methods to query
546
- 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."""
547
595
  return self._config_other_enable
548
596
 
549
597
  @property
@@ -707,15 +755,28 @@ class GetStateData:
707
755
  return NTP_FAULT_STATE[0]
708
756
 
709
757
  def is_tcpip_boost_enabled(self) -> bool:
710
- """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
+ """
711
764
  return self._config_other_enable & 1 == 1
712
765
 
713
766
  def is_sd_card_enabled(self) -> bool:
714
- """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
+ """
715
773
  return self._config_other_enable & 2 == 2
716
774
 
717
775
  def is_dmx_enabled(self) -> bool:
718
- """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
+ """
719
780
  return self._config_other_enable & 4 == 4
720
781
 
721
782
  def is_avatar_enabled(self) -> bool:
@@ -732,7 +793,12 @@ class GetStateData:
732
793
  return self._config_other_enable & 16 == 16
733
794
 
734
795
  def is_high_bus_load_enabled(self) -> bool:
735
- """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
+ """
736
802
  return self._config_other_enable & 32 == 32
737
803
 
738
804
  def is_flow_sensor_enabled(self) -> bool:
@@ -822,6 +888,14 @@ class GetStateData:
822
888
  """The four digital inputs (columns 24–27), in column order."""
823
889
  return self._digital_input_objects
824
890
 
891
+ def digital_inputs(self) -> list[DigitalInput]:
892
+ """The four digital inputs as `DigitalInput` instances.
893
+
894
+ Equivalent to wrapping each entry in `digital_input_objects` with
895
+ `DigitalInput(...)`. Use these to get `get_bit_mask()` for triggering.
896
+ """
897
+ return [DigitalInput(obj) for obj in self._digital_input_objects]
898
+
825
899
  @property
826
900
  def external_relay_objects(self) -> list[DataObject]:
827
901
  """The eight external relays (columns 28–35), in column order.