proconip 2.2.0__tar.gz → 2.3.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.0](https://github.com/ylabonte/proconip-pypi/compare/v2.2.1...v2.3.0) (2026-10-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * flag the chlorine canister columns as cell data under electrolysis ([#100](https://github.com/ylabonte/proconip-pypi/issues/100)) ([27ca22a](https://github.com/ylabonte/proconip-pypi/commit/27ca22acb2e42e47647318713670ddd9f3938daf))
9
+
10
+ ## [2.2.1](https://github.com/ylabonte/proconip-pypi/compare/v2.2.0...v2.2.1) (2026-10-08)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * 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))
16
+
3
17
  ## [2.2.0](https://github.com/ylabonte/proconip-pypi/compare/v2.1.2...v2.2.0) (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.2.0
3
+ Version: 2.3.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/
@@ -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.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
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:
@@ -187,7 +187,10 @@ class DataObject:
187
187
  temperature, ``16–23`` → relay, ``24–27`` → digital input,
188
188
  ``28–35`` → external relay, ``36–38`` → canister, ``39–41`` →
189
189
  consumption. Anything outside this range falls into a sentinel
190
- "uncategorized" bucket.
190
+ "uncategorized" bucket. The category is purely positional: with
191
+ a salt-electrolysis cell, columns 36 and 39 still land in the
192
+ canister/consumption categories although they describe the
193
+ cell (see `GetStateData.is_electrolysis_object`).
191
194
  name: Sensor name as reported by the controller (e.g. ``"Redox"``).
192
195
  unit: Unit string (``"mV"``, ``"°C"``, ``"%"``, ``"--"``, …).
193
196
  offset: Calibration offset applied to ``value``.
@@ -585,7 +588,13 @@ class GetStateData:
585
588
  @property
586
589
  def config_other_enable(self) -> int:
587
590
  """Misc configuration flags. Use the `is_*_enabled` methods to query
588
- individual bits (TCP/IP boost, SD card, DMX, …)."""
591
+ individual bits (TCP/IP boost, SD card, DMX, …).
592
+
593
+ Not every "Sonstiges" checkbox is mirrored here on current firmware:
594
+ on 1.7.6, bits 0 (TCP/IP boost), 1 (SD card) and 5 (high bus load)
595
+ were observed as 0 although those options were enabled, while bit 2
596
+ (DMX) tracked its checkbox exactly. Bits 3, 4, 6, 7 and 8 are
597
+ unverified on hardware."""
589
598
  return self._config_other_enable
590
599
 
591
600
  @property
@@ -614,7 +623,24 @@ class GetStateData:
614
623
  return self._dosage_control & 1 == 1
615
624
 
616
625
  def is_electrolysis_enabled(self) -> bool:
617
- """True if electrolysis (saltwater) chlorination is enabled (bit 4)."""
626
+ """True if the chlorine supply is a salt-electrolysis cell (bit 4).
627
+
628
+ When set, the chlorine channel is not fed from a canister: the
629
+ chlorine "canister" reading (column 36) is the cell's remaining
630
+ lifetime / maintenance interval in percent, and the chlorine
631
+ "consumption" reading (column 39) is today's *calculated* chlorine
632
+ production. Use `is_electrolysis_object` to tell those two objects
633
+ apart from real canister readings.
634
+
635
+ Polarity evidence: the TRM labels bit 4 "Cl liquid || Elektrolysis",
636
+ naming both options without saying which value is which. A live
637
+ firmware 1.7.6 controller configured for liquid chlorine
638
+ (``rdxcntrl.ini`` ``DTYPE=0``, chlorine dosing on) reports
639
+ DOSAGE_CNTRL 257 (bits 0 and 8, bit 4 clear), as does the real capture
640
+ in the test fixtures. So bit 4 is the chlorine device-type selector,
641
+ and set = electrolysis is inferred, not observed; a real electrolysis
642
+ capture is still pending.
643
+ """
618
644
  return self._dosage_control & 16 == 16
619
645
 
620
646
  def is_ph_minus_dosage_enabled(self) -> bool:
@@ -645,6 +671,33 @@ class GetStateData:
645
671
  return self.is_ph_plus_dosage_enabled()
646
672
  return False
647
673
 
674
+ def is_electrolysis_object(self, data_entity: DataObject) -> bool:
675
+ """Does this canister/consumption object describe a salt-electrolysis cell?
676
+
677
+ The controller reuses the chlorine canister columns for a salt
678
+ electrolysis setup instead of adding new ones. With electrolysis
679
+ enabled (see `is_electrolysis_enabled`):
680
+
681
+ - column 36 (`chlorine_canister`) is the cell's **remaining lifetime**
682
+ (or maintenance interval) in percent — the share of the configured
683
+ operating hours that is left, not a canister fill level;
684
+ - column 39 (`chlorine_consumption`) is **today's chlorine production**
685
+ calculated from the configured production rate, not a liquid
686
+ volume drawn from a canister.
687
+
688
+ Callers should label and present these objects accordingly (e.g. no
689
+ canister/volume wording). The pH- and pH+ columns are always real
690
+ canisters.
691
+
692
+ Args:
693
+ data_entity: Any `DataObject` from this state.
694
+
695
+ Returns:
696
+ True only for the chlorine canister (column 36) or chlorine
697
+ consumption (column 39) object while electrolysis is enabled.
698
+ """
699
+ return data_entity.column in (36, 39) and self.is_electrolysis_enabled()
700
+
648
701
  def get_dosage_relay(self, data_entity: DataObject) -> int | None:
649
702
  """Aggregated relay ID that handles the dosing for this canister/consumption entity.
650
703
 
@@ -749,15 +802,28 @@ class GetStateData:
749
802
  return NTP_FAULT_STATE[0]
750
803
 
751
804
  def is_tcpip_boost_enabled(self) -> bool:
752
- """True if TCP/IP boost is enabled in the controller config (bit 0)."""
805
+ """True if bit 0 (TCP/IP boost) of `config_other_enable` is set.
806
+
807
+ Unreliable on current firmware: 1.7.6 was observed reporting this bit
808
+ as 0 while TCP/IP boost was enabled, so ``False`` doesn't prove the
809
+ feature is off.
810
+ """
753
811
  return self._config_other_enable & 1 == 1
754
812
 
755
813
  def is_sd_card_enabled(self) -> bool:
756
- """True if SD card logging is enabled in the controller config (bit 1)."""
814
+ """True if bit 1 (SD card) of `config_other_enable` is set.
815
+
816
+ Unreliable on current firmware: 1.7.6 was observed reporting this bit
817
+ as 0 while the SD card was enabled, so ``False`` doesn't prove the
818
+ feature is off.
819
+ """
757
820
  return self._config_other_enable & 2 == 2
758
821
 
759
822
  def is_dmx_enabled(self) -> bool:
760
- """True if DMX output is enabled in the controller config (bit 2)."""
823
+ """True if DMX output is enabled in the controller config (bit 2).
824
+
825
+ Verified on firmware 1.7.6: the bit follows the "DMX512 aktiv" checkbox.
826
+ """
761
827
  return self._config_other_enable & 4 == 4
762
828
 
763
829
  def is_avatar_enabled(self) -> bool:
@@ -774,7 +840,12 @@ class GetStateData:
774
840
  return self._config_other_enable & 16 == 16
775
841
 
776
842
  def is_high_bus_load_enabled(self) -> bool:
777
- """True if high bus load mode is enabled in the controller config (bit 5)."""
843
+ """True if bit 5 (high bus load, 1-Wire) of `config_other_enable` is set.
844
+
845
+ Unreliable on current firmware: 1.7.6 was observed reporting this bit
846
+ as 0 while high bus load was enabled, so ``False`` doesn't prove the
847
+ feature is off.
848
+ """
778
849
  return self._config_other_enable & 32 == 32
779
850
 
780
851
  def is_flow_sensor_enabled(self) -> bool:
@@ -888,20 +959,28 @@ class GetStateData:
888
959
 
889
960
  @property
890
961
  def canister_objects(self) -> list[DataObject]:
891
- """The three canister fill-level readings (columns 36–38).
962
+ """The three canister remaining-content readings (columns 36–38), in percent.
892
963
 
893
964
  Order: chlorine, pH-, pH+. Convenience properties `chlorine_canister`,
894
965
  `ph_minus_canister`, and `ph_plus_canister` return individual entries.
966
+ The values are estimates the controller derives from pump runtime and
967
+ configured delivery rate. With a salt-electrolysis cell the chlorine
968
+ entry is the cell's remaining lifetime instead — check
969
+ `is_electrolysis_object`.
895
970
  """
896
971
  return self._canister_objects
897
972
 
898
973
  @property
899
974
  def consumption_objects(self) -> list[DataObject]:
900
- """The three dosage consumption counters (columns 39–41).
975
+ """The three daily dosage amounts (columns 39–41).
901
976
 
902
977
  Order: chlorine, pH-, pH+. Convenience properties
903
978
  `chlorine_consumption`, `ph_minus_consumption`, and
904
- `ph_plus_consumption` return individual entries.
979
+ `ph_plus_consumption` return individual entries. Each is the amount
980
+ dosed *today* (the controller resets it daily), in the unit the
981
+ controller reports (``ml``; the chlorine column may report ``g``). With a
982
+ salt-electrolysis cell the chlorine entry is today's calculated
983
+ chlorine production — check `is_electrolysis_object`.
905
984
  """
906
985
  return self._consumption_objects
907
986
 
@@ -917,32 +996,41 @@ class GetStateData:
917
996
 
918
997
  @property
919
998
  def chlorine_canister(self) -> DataObject:
920
- """Chlorine canister fill level (column 36)."""
999
+ """Chlorine canister remaining content in percent (column 36).
1000
+
1001
+ With a salt-electrolysis cell (`is_electrolysis_enabled`) this is the
1002
+ cell's remaining lifetime / maintenance interval in percent instead.
1003
+ """
921
1004
  return self._canister_objects[0]
922
1005
 
923
1006
  @property
924
1007
  def ph_minus_canister(self) -> DataObject:
925
- """pH- canister fill level (column 37)."""
1008
+ """pH- canister remaining content in percent (column 37)."""
926
1009
  return self._canister_objects[1]
927
1010
 
928
1011
  @property
929
1012
  def ph_plus_canister(self) -> DataObject:
930
- """pH+ canister fill level (column 38)."""
1013
+ """pH+ canister remaining content in percent (column 38)."""
931
1014
  return self._canister_objects[2]
932
1015
 
933
1016
  @property
934
1017
  def chlorine_consumption(self) -> DataObject:
935
- """Cumulative chlorine consumption counter (column 39)."""
1018
+ """Chlorine dosed today (column 39), in ``ml`` or ``g``.
1019
+
1020
+ With a salt-electrolysis cell (`is_electrolysis_enabled`) this is
1021
+ today's chlorine production calculated from the configured
1022
+ production rate instead.
1023
+ """
936
1024
  return self._consumption_objects[0]
937
1025
 
938
1026
  @property
939
1027
  def ph_minus_consumption(self) -> DataObject:
940
- """Cumulative pH- consumption counter (column 40)."""
1028
+ """pH- dosed today (column 40), in ``ml``."""
941
1029
  return self._consumption_objects[1]
942
1030
 
943
1031
  @property
944
1032
  def ph_plus_consumption(self) -> DataObject:
945
- """Cumulative pH+ consumption counter (column 41)."""
1033
+ """pH+ dosed today (column 41), in ``ml``."""
946
1034
  return self._consumption_objects[2]
947
1035
 
948
1036
  @property
@@ -36,3 +36,20 @@ def get_state_data(get_state_csv: str) -> GetStateData:
36
36
  @pytest.fixture
37
37
  def get_dmx_data(get_dmx_csv: str) -> GetDmxData:
38
38
  return GetDmxData(get_dmx_csv)
39
+
40
+
41
+ @pytest.fixture
42
+ def get_state_electrolysis_csv() -> str:
43
+ """`get_state.csv` reconfigured for a salt-electrolysis chlorine supply.
44
+
45
+ Derived from the real capture: DOSAGE_CNTRL 273 (Cl enabled + bit 4
46
+ electrolysis + pH- enabled), chlorine daily amount in ``g``, and non-zero
47
+ values in the chlorine "canister" (cell lifetime %) and "consumption"
48
+ (calculated production) columns, per the vendor's TRM column table.
49
+ """
50
+ return (FIXTURES_DIR / "get_state_electrolysis.csv").read_text()
51
+
52
+
53
+ @pytest.fixture
54
+ def get_state_electrolysis_data(get_state_electrolysis_csv: str) -> GetStateData:
55
+ return GetStateData(get_state_electrolysis_csv)
@@ -0,0 +1,6 @@
1
+ SYSINFO,1.7.3,9559698,1,3,0,273,4,4,5
2
+ Time,n.a.,n.a.,n.a.,n.a.,CPU Temp,Redox,pH,Pumpe,n.a.,n.a.,n.a.,n.a.,n.a.,n.a.,n.a.,Terassenlicht,n.a.,n.a.,Gartenlicht,pH minus,Chlor,Bachlauf,Poolpumpe,TASTER1,TASTER2,TASTER3,TASTER4,n.a.,n.a.,n.a.,n.a.,n.a.,n.a.,n.a.,n.a.,Cl Rest,pH- Rest,pH+ Rest,Cl consumption,pH- consumption,pH+ consumption
3
+ h,mV,mV,Bar,ppm,C,mV,pH,C,C,C,C,C,C,C,C,--,--,--,--,--,--,--,--,cm/s,--,--,--,--,--,--,--,--,--,--,--,%,%,%,g,ml,ml
4
+ 0,0,0,-0.400,6.00000,147.5,0.0,0.0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
5
+ 1,0.0625,0.0625,0.0000125,0.000156,-0.00468750,0.0625,0.0078125,0.0625,0.0625,0.0625,0.0625,0.0625,0.0625,0.0625,0.0625,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0.1,0.1,0.1,1,1,1
6
+ 529,-44,-44,-44,-44,24878,14000,932,111,0,0,0,0,0,0,0,2,0,0,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,873,615,1000,412,0,0